This commit is contained in:
hamid zarghami
2025-09-18 13:20:16 +03:30
parent 7b51a18a4a
commit 4ed55bd1b7
13 changed files with 308 additions and 87 deletions
+5 -1
View File
@@ -1,6 +1,8 @@
import axios from "@/config/axios";
import {
ApiResponse,
AuthenticationType,
RefreshTokenResponse,
RefreshTokenType,
VerifyOtpType,
} from "../types/Types";
@@ -15,7 +17,9 @@ export const verifyOtp = async (params: VerifyOtpType) => {
return data;
};
export const refreshToken = async (params: RefreshTokenType) => {
export const refreshToken = async (
params: RefreshTokenType
): Promise<ApiResponse<RefreshTokenResponse>> => {
const { data } = await axios.post(`/user/token`, params);
return data;
};
+19
View File
@@ -17,3 +17,22 @@ export type VerifyOtpType = {
export type RefreshTokenType = {
token: string;
};
export type TokenInfo = {
token: string;
expires: string;
};
export type RefreshTokenResponse = {
tokenType: string;
accessToken: TokenInfo;
refreshToken: TokenInfo;
};
export type ApiResponse<T> = {
status: number;
success: boolean;
results: {
data: T;
};
};
+4
View File
@@ -1,10 +1,14 @@
import { useSharedStore } from "@/share/store/sharedStore";
import * as api from "../service/Service";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
export const useGetCart = () => {
const { isLogin } = useSharedStore();
return useQuery({
queryKey: ["cart"],
queryFn: api.getCart,
enabled: isLogin,
});
};
+18
View File
@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import * as api from "../service/Service";
export const useGetCompare = (productIds: string[]) => {
return useQuery({
queryKey: ["compare", productIds],
queryFn: () => api.getCompare(productIds),
enabled: !!productIds,
});
};
export const useSearchCompareProduct = (productId?: string) => {
return useQuery({
queryKey: ["compare", productId],
queryFn: () => api.searchComparProduct(productId),
enabled: !!productId,
});
};
+102 -54
View File
@@ -1,62 +1,109 @@
'use client'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import Layout from '@/hoc/Layout'
import { NextPage } from 'next'
import Image from 'next/image'
import { useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react';
import { useGetCompare } from './hooks/useCompareData'
import { useLocalCart } from '@/app/product/hooks/useLocalCart'
import { toast } from '@/components/Toast'
import { CompareProduct } from './types/Types'
import { useSharedStore } from '@/share/store/sharedStore'
const ComparePage: NextPage = () => {
const [productIds, setProductIds] = useState<string[]>([]);
const search = useSearchParams()
const { data, isLoading } = useGetCompare(productIds)
const { addToLocalCart } = useLocalCart()
const { isLogin } = useSharedStore()
const products = data?.results?.products || []
const handleAddToCart = (product: CompareProduct) => {
if (!isLogin) {
toast('لطفا ابتدا وارد حساب کاربری خود شوید', 'error')
return
}
if (!product._id || !product.default_variant?._id) return
addToLocalCart(product._id, product.default_variant._id, 1)
toast('محصول به سبد خرید اضافه شد', 'success')
}
useEffect(() => {
const productId = search.get('productId')
if (productId) {
setProductIds([productId]);
}
}, [search]);
if (isLoading) {
return (
<Layout>
<div className='mt-24 px-20'>
<div className='flex justify-center items-center min-h-[400px]'>
<div className='text-center'>
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto'></div>
<p className='mt-4 text-gray-600'>در حال بارگذاری...</p>
</div>
</div>
</div>
</Layout>
)
}
if (products.length === 0) {
return (
<Layout>
<div className='mt-24 px-20'>
<div className='flex justify-center items-center min-h-[400px]'>
<div className='text-center'>
<p className='text-gray-600'>هیچ محصولی برای مقایسه انتخاب نشده است</p>
</div>
</div>
</div>
</Layout>
)
}
return (
<Layout>
<div className='mt-24 px-20'>
<div className='flex border-b border-border'>
<div className='w-[300px] flex flex-col justify-center items-center border-l border-border pb-5'>
<Image
src='https://shinan.storage.iran.liara.space/media-file/30661b3bde87834d3feae30953d5fc63232d78f181e7fed0.png'
alt='compare'
width={150}
height={150}
className='max-w-[100px] max-h-[100px] object-contain'
/>
{products.map((product, index) => (
<div key={product._id} className={`w-[300px] flex flex-col justify-center items-center ${index < products.length - 1 ? 'border-l border-border' : ''} pb-5`}>
<Image
src={product.imagesUrl.cover}
alt={product.title_fa}
width={150}
height={150}
className='max-w-[100px] max-h-[100px] object-contain'
/>
<div className='mt-6 font-light text-sm'>
رومیزی سندس کد T-2112 طرح فرش
</div>
<div className='mt-6 font-light text-sm text-center'>
{product.title_fa}
</div>
<div className='mt-8 text-sm font-light'>
260,000 <span className='text-[10px]'>تومان</span>
</div>
<div className='mt-8 text-sm font-light'>
{product.default_variant.price.selling_price.toLocaleString()} <span className='text-[10px]'>تومان</span>
</div>
<div className='mt-5'>
<Button
>
افزودن به سبد
</Button>
<div className='mt-5'>
<Button
onClick={() => handleAddToCart(product)}
disabled={!product.default_variant.stock}
className={!product.default_variant.stock ? 'opacity-50 cursor-not-allowed' : ''}
>
{product.default_variant.stock > 0 ? 'افزودن به سبد' : 'ناموجود'}
</Button>
</div>
</div>
</div>
<div className='w-[300px] flex flex-col justify-center items-center border-l border-border pb-5'>
<Image
src='https://shinan.storage.iran.liara.space/media-file/30661b3bde87834d3feae30953d5fc63232d78f181e7fed0.png'
alt='compare'
width={150}
height={150}
className='max-w-[100px] max-h-[100px] object-contain'
/>
<div className='mt-6 font-light text-sm'>
رومیزی سندس کد T-2112 طرح فرش
</div>
<div className='mt-8 text-sm font-light'>
260,000 <span className='text-[10px]'>تومان</span>
</div>
<div className='mt-5'>
<Button
>
افزودن به سبد
</Button>
</div>
</div>
))}
</div>
<div className='mt-8'>
@@ -65,19 +112,20 @@ const ComparePage: NextPage = () => {
</div>
</div>
<div className='mt-9 border-b border-border pb-7'>
<div className='text-[#999999] text-sm'>وزن</div>
{products[0]?.specifications?.map((spec, specIndex) => (
<div key={specIndex} className='mt-9 border-b border-border pb-7'>
<div className='text-[#999999] text-sm'>{spec.title}</div>
<div className='flex mt-6 font-light'>
<div className='w-[300px]'>
350 g
</div>
<div className='w-[300px] flex gap-6'>
<Separator orientation='vertical' />
350 g
<div className='flex mt-6 font-light'>
{products.map((product, productIndex) => (
<div key={product._id} className='w-[300px] flex items-center'>
{product.specifications?.[specIndex]?.values?.join(', ') || '-'}
{productIndex < products.length - 1 && <Separator orientation='vertical' className='ml-6' />}
</div>
))}
</div>
</div>
</div>
))}
</div>
</Layout>
)
+17
View File
@@ -0,0 +1,17 @@
import axios from "@/config/axios";
import { CompareResponse } from "../types/Types";
export const getCompare = async (
productIds: string[]
): Promise<CompareResponse> => {
const { data } = await axios.get(`/product/compare?productIds=${productIds}`);
return data;
};
export const searchComparProduct = async (productId?: string) => {
const { data } = await axios.get(
`/product/compare/search?productId=${productId}`
);
return data;
};
+94
View File
@@ -0,0 +1,94 @@
export interface CompareResponse {
status: number;
success: boolean;
results: {
products: CompareProduct[];
};
}
export interface CompareProduct {
_id: number;
url: string;
title_fa: string;
title_en: string;
seoTitle: string;
seoDescription: string | null;
source: string;
description: string;
metaDescription: string;
tags: string[];
advantages: string[];
disAdvantages: string[];
imagesUrl: ImagesUrl;
isFake: string;
specifications: Specification[];
default_variant: Variant;
variants: Variant[];
}
export interface ImagesUrl {
cover: string;
list: string[];
}
export interface Specification {
title: string;
values: string[];
}
export interface Variant {
_id: string;
market_status: string;
price: Price;
stock: number;
postingTime: number;
isFreeShip: boolean;
isWholeSale: boolean;
shop: Shop;
shipmentMethod: ShipmentMethod[];
warranty: Warranty;
meterage: Meterage;
}
export interface Price {
order_limit: number;
retailPrice: number;
selling_price: number;
is_specialSale: boolean;
discount_percent: number;
specialSale_order_limit: number | null;
specialSale_quantity: number | null;
specialSale_endDate: string | null;
}
export interface Shop {
_id: string;
shopName: string;
shopCode: number;
owner: string;
shopDescription: string;
telephoneNumber: string;
isChatActive: boolean;
shopPostalCode: string;
logo: string;
}
export interface ShipmentMethod {
_id: number;
name: string;
description: string;
deliveryTime: number;
deliveryType: string;
}
export interface Warranty {
_id: number;
duration: string;
logoUrl: string;
name: string;
}
export interface Meterage {
_id: number;
value: string;
}
+14 -5
View File
@@ -1,15 +1,24 @@
'use client'
import { FC } from 'react'
import { useGetLanding } from '../hooks/useHomeData'
import Image from 'next/image'
const Banners3: FC = () => {
const { data } = useGetLanding()
return (
<div className='flex gap-4 h-[500px]'>
<div className='flex flex-col gap-4 h-full flex-1'>
<div className='flex-1 bg-red-50 h-full'>
<div className='flex gap-4'>
<div className='flex flex-col gap-2 h-full flex-1'>
<div className='flex-1 h-full'>
<Image src={data?.results?.banners?.[1]?.imageUrl as string} height={500} width={1000} className='flex-1 rounded-lg' alt={data?.results?.banners?.[0]?.altText as string} />
</div>
<div className='flex-1 bg-red-50 h-full'>
<div className='flex-1 h-full'>
<Image src={data?.results?.banners?.[2]?.imageUrl as string} height={500} width={1000} className='flex-1 rounded-lg' alt={data?.results?.banners?.[2]?.altText as string} />
</div>
</div>
<div className='flex-1 bg-blue-50 h-full'>
<div className='flex-1 h-full'>
<Image src={data?.results?.banners?.[0]?.imageUrl as string} height={1000} width={1000} className='flex-1 rounded-lg h-full object-cover' alt={data?.results?.banners?.[0]?.altText as string} />
</div>
</div>
)
+13 -3
View File
@@ -1,18 +1,28 @@
'use client'
import { FC } from 'react'
import { useGetLanding } from '../hooks/useHomeData'
import Image from 'next/image'
const Banners4: FC = () => {
const { data } = useGetLanding()
return (
<div className='flex gap-6 h-[340px]'>
<div className='flex-1 bg-blue-100 h-full rounded-[10px]'>
<Image src={data?.results?.banners?.[3]?.imageUrl as string} height={340} width={1000} className='flex-1 rounded-lg' alt={data?.results?.banners?.[0]?.altText as string} />
</div>
<div className='w-[158px] flex flex-col gap-6'>
<div className='w-[158px] h-[158px] rounded-[10px] bg-slate-100' />
<div className='w-[158px] h-[158px] rounded-[10px] bg-slate-200' />
<div className='w-[158px] h-[158px] rounded-[10px] bg-slate-100'>
<Image src={data?.results?.banners?.[4]?.imageUrl as string} height={158} width={1000} className='flex-1 rounded-lg' alt={data?.results?.banners?.[0]?.altText as string} />
</div>
<div className='w-[158px] h-[158px] rounded-[10px] bg-slate-200'>
<Image src={data?.results?.banners?.[5]?.imageUrl as string} height={158} width={1000} className='flex-1 rounded-lg' alt={data?.results?.banners?.[0]?.altText as string} />
</div>
</div>
<div className='w-[400px] bg-amber-50'>
<Image src={data?.results?.banners?.[6]?.imageUrl as string} height={400} width={1000} className='flex-1 rounded-lg' alt={data?.results?.banners?.[0]?.altText as string} />
</div>
</div>
+10 -13
View File
@@ -1,5 +1,5 @@
'use client'
import { Chart2, Heart, Hierarchy2, Notification, RowHorizontal } from 'iconsax-react'
import { Heart, Hierarchy2, RowHorizontal } from 'iconsax-react'
import { FC, useEffect, useState } from 'react'
import { Product } from '@/types/product.types'
import { useAddWishlist, useRemoveWishlist } from '@/app/products/hooks/useProductsData'
@@ -8,6 +8,7 @@ import { toast } from '@/components/Toast'
import { useCheckWishProduct } from '../hooks/useProductData'
import { useProductStore } from '../store/Store'
import PriceHistory from './PriceHistory'
import Link from 'next/link'
interface ActionProductProps {
product: Product
@@ -18,7 +19,6 @@ const ActionProduct: FC<ActionProductProps> = ({ product }) => {
const { variantId } = useProductStore()
const { isLogin } = useSharedStore()
const [isWishlist, setIsWishlist] = useState(false)
const [isCompare, setIsCompare] = useState(false)
const [isWishlistLoading, setIsWishlistLoading] = useState(false)
const { mutate: addWishlist } = useAddWishlist()
@@ -72,11 +72,6 @@ const ActionProduct: FC<ActionProductProps> = ({ product }) => {
}
}, [data])
const handleCompare = () => {
setIsCompare(!isCompare)
// TODO: API call to add/remove from compare
}
const handleShare = () => {
if (navigator.share) {
navigator.share({
@@ -135,12 +130,14 @@ const ActionProduct: FC<ActionProductProps> = ({ product }) => {
<PriceHistory productId={product._id.toString()} />
<button
className='p-2 rounded-full bg-white/80 backdrop-blur-sm hover:bg-white transition-colors'
title='مقایسه کالا'
>
<RowHorizontal size={20} color='#333333' variant='Bulk' />
</button>
<Link href={`/compare?productId=${product._id}`}>
<button
className='p-2 rounded-full bg-white/80 backdrop-blur-sm hover:bg-white transition-colors'
title='مقایسه کالا'
>
<RowHorizontal size={20} color='#333333' variant='Bulk' />
</button>
</Link>
</div>
</div>
)
+10 -9
View File
@@ -8,6 +8,7 @@ import {
removeRefreshToken,
} from "./func";
import { refreshToken } from "../app/auth/service/Service";
import { ApiResponse, RefreshTokenResponse } from "../app/auth/types/Types";
import { BASE_URL } from "./const";
declare global {
@@ -47,32 +48,32 @@ axiosInstance.interceptors.response.use(
if (!refreshTokenValue) {
await removeRefreshToken();
await removeToken();
window.location.href = `/auth`;
window.location.href = `/`;
return;
}
const { data } = await refreshToken({
const data: ApiResponse<RefreshTokenResponse> = await refreshToken({
token: refreshTokenValue,
});
if (data?.accessToken?.token) {
await setToken(data?.accessToken?.token);
if (data.refreshToken?.token) {
await setRefreshToken(data.refreshToken?.token);
if (data?.results?.data?.accessToken?.token) {
await setToken(data?.results?.data?.accessToken?.token);
if (data?.results?.data?.refreshToken?.token) {
await setRefreshToken(data?.results?.data?.refreshToken?.token);
}
originalRequest.headers.Authorization = `Bearer ${data.accessToken.token}`;
originalRequest.headers.Authorization = `Bearer ${data.results.data.accessToken.token}`;
return axiosInstance(originalRequest);
} else {
await removeRefreshToken();
await removeToken();
window.location.href = `/auth`;
window.location.href = `/`;
return;
}
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
await removeToken();
await removeRefreshToken();
window.location.href = `/auth`;
window.location.href = `/`;
return Promise.reject(refreshError);
} finally {
window.isRefreshingToken = false;
+1 -1
View File
@@ -1,6 +1,6 @@
export const TOKEN_NAME = "sh_token";
export const REFRESH_TOKEN_NAME = "sh_refresh_token";
export const BASE_URL = "https://shop-api.dev.danakcorp.com";
// export const BASE_URL = "https://api-sondos.run.danakcorp.com";
// export const BASE_URL = "https://api.shinan.ir";
export const PRIMARY_COLOR = "#015699";
+1 -1
View File
@@ -38,7 +38,7 @@ export const getToken = async () => {
return localStorage.getItem(TOKEN_NAME);
};
export const getRefreshToken = () => {
export const getRefreshToken = async () => {
if (typeof window === "undefined") {
return null;
}