feat: add product detail page with components and infinite scroll

- Add SingleProduct component for product detail page
- Implement product data hooks and services
- Add infinite scroll functionality for products list
- Update ProductCard component with new features
- Add product types and specifications
- Fix TypeScript errors in product page
- Update profile page layout
- Add global CSS improvements
This commit is contained in:
hamid zarghami
2025-09-07 09:17:58 +03:30
parent 5ec2b621bc
commit 2995b11900
17 changed files with 667 additions and 343 deletions
+1 -1
View File
@@ -219,7 +219,7 @@ textarea.place-black::placeholder {
.swiper-pagination {
z-index: 1 !important;
}
div {
* {
direction: rtl;
}
+19 -62
View File
@@ -1,71 +1,28 @@
import { NextPage } from 'next'
import Layout from '@/hoc/Layout'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import ProductImage from '@/app/product/components/ProductImage'
import BaseInformation from '@/app/product/components/BaseInformation'
import ShopInformation from '@/app/product/components/ShopInformation'
import Specifications from '@/app/product/components/Specifications'
import ProsCons from '@/app/product/components/ProsCons'
import Description from '@/app/product/components/Description'
import Reviews from '@/app/product/components/Reviews'
import Questions from '@/app/product/components/Questions'
import SingleProduct from '@/app/product/components/SingleProduct'
import { getProductQueryOptions } from '@/app/product/hooks/useProductData'
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query'
import { Suspense } from 'react'
import PageLoading from '@/components/PageLoading'
const SingleProduct: NextPage = () => {
return (
<div className='mt-14 px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20'>
<div className='flex flex-col sm:flex-row justify-between gap-4 sm:gap-0'>
<Breadcrumb aria-label="breadcrumb">
<BreadcrumbList className="text-sm text-muted-foreground">
<BreadcrumbItem>
<BreadcrumbLink href="/">فروشگاه آناهیتا</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-muted-foreground select-none">/</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink href="/products">بهداشت خانگی</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-muted-foreground select-none">/</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage className="text-foreground">مواد شوینده</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className='text-primary text-xs sm:text-sm'>فروشنده شوید</div>
</div>
<div className='flex flex-col xl:flex-row gap-4 sm:gap-6 mt-8 sm:mt-12 md:mt-14'>
<div className='xl:w-[325px] xl:shrink-0'>
<ShopInformation />
</div>
<div className='flex-1 flex flex-col gap-6'>
<div className='flex flex-col lg:flex-row gap-4 sm:gap-6'>
<ProductImage />
<BaseInformation />
</div>
<div>
<Specifications />
<Description />
<ProsCons />
<Reviews />
<Questions />
</div>
</div>
</div>
</div>
)
interface SingleProductPageProps {
params: { id: string }
}
export default function SingleProductWithLayout() {
export default async function SingleProductPage({ params }: SingleProductPageProps) {
const queryClient = new QueryClient()
await queryClient.prefetchQuery(getProductQueryOptions(params.id))
const dehydratedState = dehydrate(queryClient)
return (
<Layout>
<SingleProduct />
<Suspense fallback={<PageLoading />}>
<HydrationBoundary state={dehydratedState}>
<SingleProduct id={params.id} />
</HydrationBoundary>
</Suspense>
</Layout>
)
}
+81 -9
View File
@@ -1,15 +1,87 @@
'use client'
import { Chart2, Heart, Hierarchy2, Notification, RowHorizontal } from 'iconsax-react'
import { FC } from 'react'
import { FC, useState } from 'react'
import { Product } from '@/types/product.types'
interface ActionProductProps {
product: Product
}
const ActionProduct: FC<ActionProductProps> = ({ product }) => {
const [isWishlist, setIsWishlist] = useState(false)
const [isCompare, setIsCompare] = useState(false)
const handleWishlist = () => {
setIsWishlist(!isWishlist)
// TODO: API call to add/remove from wishlist
}
const handleCompare = () => {
setIsCompare(!isCompare)
// TODO: API call to add/remove from compare
}
const handleShare = () => {
if (navigator.share) {
navigator.share({
title: product.title_fa,
text: product.description,
url: window.location.href,
})
} else {
navigator.clipboard.writeText(window.location.href)
// TODO: Show toast notification
}
}
const ActionProduct: FC = () => {
return (
<div className='absolute top-6 right-6'>
<div className='flex flex-col gap-5'>
<Heart size={20} color='#333333' />
<Hierarchy2 size={20} color='#333333' />
<Notification size={20} color='#333333' />
<Chart2 size={20} color='#333333' />
<RowHorizontal size={20} color='#333333' variant='Bulk' />
<div className='absolute top-6 right-4'>
<div className='flex flex-col gap-3'>
<button
onClick={handleWishlist}
className='p-2 rounded-full bg-white/80 backdrop-blur-sm hover:bg-white transition-colors'
title={isWishlist ? 'حذف از علاقه‌مندی‌ها' : 'افزودن به علاقه‌مندی‌ها'}
>
<Heart
size={20}
color={isWishlist ? '#ff4757' : '#333333'}
variant={isWishlist ? 'Bold' : 'Outline'}
/>
</button>
<button
onClick={handleCompare}
className='p-2 rounded-full bg-white/80 backdrop-blur-sm hover:bg-white transition-colors'
title={isCompare ? 'حذف از مقایسه' : 'افزودن به مقایسه'}
>
<Hierarchy2
size={20}
color={isCompare ? '#2ed573' : '#333333'}
variant={isCompare ? 'Bold' : 'Outline'}
/>
</button>
<button
onClick={handleShare}
className='p-2 rounded-full bg-white/80 backdrop-blur-sm hover:bg-white transition-colors'
title='اشتراک‌گذاری'
>
<Notification size={20} color='#333333' />
</button>
<button
className='p-2 rounded-full bg-white/80 backdrop-blur-sm hover:bg-white transition-colors'
title='گزارش مشکل'
>
<Chart2 size={20} color='#333333' />
</button>
<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>
</div>
</div>
)
+32 -28
View File
@@ -1,20 +1,30 @@
'use client'
import { Star1 } from 'iconsax-react'
import { FC } from 'react'
import { Product } from '@/types/product.types'
interface BaseInformationProps {
product: Product
}
const BaseInformation: FC<BaseInformationProps> = ({ product }) => {
const defaultVariant = product.default_variant
const brand = product.brand
const size = defaultVariant.size
const BaseInformation: FC = () => {
return (
<div className='flex-1'>
<h1 className='text-base sm:text-lg text-[#191919] leading-relaxed'>
گوشی موبایل سامسونگ مدل Galaxy S24 Ultra دو سیم کارت ظرفیت 1 ترابایت و رم 12 گیگابایت - ویتنام
{product.title_fa}
</h1>
<p className='mt-4 sm:mt-6 text-[#B2B2B2] text-xs sm:text-sm'>
Samsung Galaxy S24 Ultra Dual SIM 256GB And 12GB RAM Mobile Phone - Vietnam
{product.title_en}
</p>
<div className='mt-4 flex flex-wrap gap-2 sm:gap-4 items-center'>
<h3 className='text-primary text-sm sm:text-base'>
سامسونگ
{brand.title_fa}
</h3>
<div className='size-1.5 rounded-full bg-[#E5E5E5]'></div>
<div className='flex items-center gap-1'>
@@ -28,13 +38,19 @@ const BaseInformation: FC = () => {
</div>
<div className='mt-4 sm:mt-6 text-[#333333] text-sm sm:text-base'>
رنگ : طلایی
اندازه : {size.value}
</div>
<div className='mt-2 flex gap-1.5'>
<div className='size-6 sm:size-8 rounded-full bg-red-200'></div>
<div className='size-6 sm:size-8 rounded-full bg-blue-200'></div>
<div className='size-6 sm:size-8 rounded-full bg-yellow-200'></div>
{product.variants.map((variant, index) => (
<div
key={variant._id}
className='size-6 sm:size-8 rounded-full bg-gray-200 flex items-center justify-center text-xs font-medium'
title={variant.size.value}
>
{index + 1}
</div>
))}
</div>
<div className='mt-6 sm:mt-7 text-base sm:text-lg text-[#333333]'>
@@ -42,27 +58,15 @@ const BaseInformation: FC = () => {
</div>
<div className='mt-6 sm:mt-10 flex flex-col gap-4 sm:gap-8'>
<div className='flex flex-col sm:flex-row sm:gap-3 items-start sm:items-center text-xs sm:text-sm'>
<div className='flex items-center gap-2'>
<div className='size-2 rounded-full bg-[#7F7F7F]'></div>
<div className='text-[#7F7F7F]'>فناوری صفحه نمایش :</div>
{product.specifications.slice(0, 3).map((spec, index) => (
<div key={index} className='flex flex-col sm:flex-row sm:gap-3 items-start sm:items-center text-xs sm:text-sm'>
<div className='flex items-center gap-2'>
<div className='size-2 rounded-full bg-[#7F7F7F]'></div>
<div className='text-[#7F7F7F]'>{spec.title} :</div>
</div>
<div className='mt-1 sm:mt-0'>{spec.values.join(', ')}</div>
</div>
<div className='mt-1 sm:mt-0'>Dynamic LTPO AMOLED 2X</div>
</div>
<div className='flex flex-col sm:flex-row sm:gap-3 items-start sm:items-center text-xs sm:text-sm'>
<div className='flex items-center gap-2'>
<div className='size-2 rounded-full bg-[#7F7F7F]'></div>
<div className='text-[#7F7F7F]'>رزولوشن عکس :</div>
</div>
<div className='mt-1 sm:mt-0'>200 مگاپیکسل</div>
</div>
<div className='flex flex-col sm:flex-row sm:gap-3 items-start sm:items-center text-xs sm:text-sm'>
<div className='flex items-center gap-2'>
<div className='size-2 rounded-full bg-[#7F7F7F]'></div>
<div className='text-[#7F7F7F]'>نسخه سیستم عامل :</div>
</div>
<div className='mt-1 sm:mt-0'>Android 14</div>
</div>
))}
</div>
</div>
)
+13 -8
View File
@@ -1,12 +1,17 @@
'use client'
import { FC } from 'react'
import { Product } from '@/types/product.types'
const DESCRIPTION: string[] = [
'همواره گوشی‌های هوشمند پرچمدار سامسونگ توانسته‌اند با بهره بردن از مشخصات فنی قدرتمند، توجه هر بیننده‌ای را به خود جلب کنند. سامسونگ Galaxy S24 Ultra نیز از این قاعده مستثنا نیست و با نمایشگری باکیفیت، پردازنده پرتوان و سیستم دوربین قدرتمند به میدان آمده است.',
'صفحه‌نمایش 6.8 اینچی این گوشی با رزولوشن 1440×3120 پیکسل و نرخ نوسازی 120 هرتز، تجربه‌ای روان و چشم‌نواز ارائه می‌دهد. در بخش دوربین نیز سنسور اصلی 200 مگاپیکسلی در کنار سنسور 12 مگاپیکسلی فوق‌عریض و سنسور تله‌فوتو با بزرگنمایی اپتیکال 3 برابر، ثبت تصاویر با جزئیات و داینامیک رنج عالی را ممکن کرده است.',
'ترکیب این سخت‌افزار قدرتمند با نرم‌افزار بهینه، باعث شده که Galaxy S24 Ultra انتخاب مناسبی برای بازی، عکاسی و استفاده روزمره باشد. اگر به دنبال گوشی‌ای با کارایی بالا و امکانات کامل هستید، این مدل می‌تواند گزینه‌ای ایده‌آل برای شما باشد.'
]
interface DescriptionProps {
product: Product
}
const Description: FC<DescriptionProps> = ({ product }) => {
// تقسیم توضیحات به پاراگراف‌ها بر اساس خطوط جدید
const descriptionParagraphs = product.description
.split('\n')
.filter(paragraph => paragraph.trim().length > 0)
const Description: FC = () => {
return (
<div className="mt-12 sm:mt-16 w-full">
<div className="flex items-center justify-end gap-3 flex-row-reverse">
@@ -16,8 +21,8 @@ const Description: FC = () => {
<div className="mt-4 sm:mt-6 rounded-xl bg-white p-4 sm:p-6 max-h-[300px] sm:max-h-[420px] overflow-y-auto">
<div className="text-xs sm:text-sm text-[#333333] leading-6 sm:leading-8 text-justify space-y-3 sm:space-y-4">
{DESCRIPTION.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
{descriptionParagraphs.map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
</div>
+34 -9
View File
@@ -1,25 +1,50 @@
'use client'
import Image from 'next/image'
import { FC } from 'react'
import { FC, useState } from 'react'
import ActionProduct from './ActionProduct'
import { Product } from '@/types/product.types'
interface ProductImageProps {
product: Product
}
const ProductImage: FC<ProductImageProps> = ({ product }) => {
const [selectedImageIndex, setSelectedImageIndex] = useState(0)
const allImages = [product.imagesUrl.cover, ...product.imagesUrl.list]
const selectedImage = allImages[selectedImageIndex]
const ProductImage: FC = () => {
return (
<div className='w-full max-w-[380px] mx-auto lg:mx-0'>
<div className='w-full border border-border rounded-xl h-[300px] sm:h-[350px] md:h-[400px] flex justify-center items-center relative'>
<Image
src={'https://dkstatics-public.digikala.com/digikala-products/c0a7ce006d11c222bce16bd3cdd8d79aeb6689bc_1738134271.jpg?x-oss-process=image/resize,m_lfit,h_800,w_800/format,webp/quality,q_90'}
src={selectedImage}
width={300}
height={300}
alt=''
alt={product.title_fa}
className='max-w-[200px] max-h-[200px] sm:max-w-[240px] sm:max-h-[240px] object-contain'
/>
<ActionProduct />
<ActionProduct product={product} />
</div>
<div className='flex gap-2 sm:gap-3 mt-4 justify-center lg:justify-start overflow-x-auto'>
<div className='size-16 sm:size-20 border border-border rounded-xl shrink-0'></div>
<div className='size-16 sm:size-20 border border-border rounded-xl shrink-0'></div>
<div className='size-16 sm:size-20 border border-border rounded-xl shrink-0'></div>
<div className='size-16 sm:size-20 border border-border rounded-xl shrink-0'></div>
{allImages.map((image, index) => (
<div
key={index}
className={`size-16 flex justify-center items-center sm:size-20 border rounded-xl shrink-0 cursor-pointer transition-all ${selectedImageIndex === index
? 'border-primary ring-primary/20'
: 'border-border hover:border-primary/50'
}`}
onClick={() => setSelectedImageIndex(index)}
>
<Image
src={image}
width={50}
height={50}
alt={`${product.title_fa} - تصویر ${index + 1}`}
className='w-full h-full object-contain max-h-[50px] max-w-[50px] rounded-xl'
/>
</div>
))}
</div>
</div>
)
+20 -19
View File
@@ -1,20 +1,13 @@
'use client'
import { FC } from 'react'
import { Separator } from '@/components/ui/separator'
import { Product } from '@/types/product.types'
const PROS: string[] = [
'مجهز به حس‌گر اثرانگشت',
'مقاوم در برابر آب',
'مناسب بازی',
'مناسب عکاسی',
'مناسب عکاسی سلفی',
]
interface ProsConsProps {
product: Product
}
const CONS: string[] = [
'وزن نسبتاً بالا',
'عدم وجود شارژر در جعبه',
]
const ProsCons: FC = () => {
const ProsCons: FC<ProsConsProps> = ({ product }) => {
return (
<div className="mt-12 sm:mt-16 w-full">
<div className="flex items-center justify-end gap-3 flex-row-reverse">
@@ -30,9 +23,13 @@ const ProsCons: FC = () => {
</div>
<div className="col-span-12 md:col-span-9 mt-2 md:mt-0">
<div className="flex flex-col gap-2 sm:gap-3 text-xs sm:text-sm text-[#333333] text-right">
{PROS.map((v) => (
<div key={v}>{v}</div>
))}
{product.advantages.length > 0 ? (
product.advantages.map((advantage, index) => (
<div key={index}>{advantage}</div>
))
) : (
<div className="text-[#999999]">نقطه مثبتی ثبت نشده است</div>
)}
</div>
</div>
</div>
@@ -46,9 +43,13 @@ const ProsCons: FC = () => {
</div>
<div className="col-span-12 md:col-span-9 mt-2 md:mt-0">
<div className="flex flex-col gap-2 sm:gap-3 text-xs sm:text-sm text-[#333333] text-right">
{CONS.map((v) => (
<div key={v}>{v}</div>
))}
{product.disAdvantages.length > 0 ? (
product.disAdvantages.map((disadvantage, index) => (
<div key={index}>{disadvantage}</div>
))
) : (
<div className="text-[#999999]">نقطه منفی ثبت نشده است</div>
)}
</div>
</div>
</div>
+63 -76
View File
@@ -3,50 +3,28 @@
import { FC, Fragment, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { ArrowDown2, MessageQuestion } from 'iconsax-react'
import { Product, Question } from '@/types/product.types'
type AnswerItem = {
id: number
text: string
interface QuestionsProps {
product: Product
questions?: Question[]
}
type QuestionItem = {
id: number
text: string
answers: AnswerItem[]
}
const QUESTIONS: QuestionItem[] = [
{
id: 1,
text: 'سلام ویتنام هست یا هند؟',
answers: [],
},
{
id: 2,
text:
'سلام من دوتا اپلیکیشن همراه بانک نصب نمیشه، دلیلش چیه؟ همچنین مشکل برای کسی پیش اومده؟',
answers: [
{ id: 1, text: 'مشکل از همراه بانک‌های هست که با نسخه جدید طراحی نشدن' },
{ id: 2, text: 'اپ رو آپدیت کنید یا کش رو پاک کنید، برای من حل شد' },
],
},
{
id: 3,
text: 'وزن چطاست؟ ویتنام که نیست؟',
answers: [
{ id: 1, text: 'زیر عکس که نوشتند ویتنام' },
],
},
]
const Questions: FC = () => {
const Questions: FC<QuestionsProps> = ({ questions = [] }) => {
const [sort, setSort] = useState<'new' | 'answers'>('new')
const [expandedId, setExpandedId] = useState<number | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const sorted = useMemo(() => {
if (sort === 'new') return [...QUESTIONS].sort((a, b) => b.id - a.id)
return [...QUESTIONS].sort((a, b) => b.answers.length - a.answers.length)
}, [sort])
if (sort === 'new') {
return [...questions].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}
return [...questions].sort((a, b) => b.answers.length - a.answers.length)
}, [sort, questions])
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('fa-IR')
}
return (
<div className="mt-12 sm:mt-16 w-full">
@@ -80,51 +58,60 @@ const Questions: FC = () => {
</div>
<div className="mt-4 sm:mt-6 rounded-xl bg-white divide-y divide-[#E6E6E6]">
{sorted.map((q) => (
<Fragment key={q.id}>
<div className="px-4 sm:px-6 py-4 sm:py-5">
<div className="flex items-start gap-2 sm:gap-3">
<div className="shrink-0 mt-0.5">
<div className="size-6 sm:size-8 rounded-full border border-[#DDE9FF] bg-[#F6FAFF] flex items-center justify-center">
<MessageQuestion size={14} color="#2F80ED" className="sm:w-[18px] sm:h-[18px]" />
{sorted.length > 0 ? (
sorted.map((question) => (
<Fragment key={question._id}>
<div className="px-4 sm:px-6 py-4 sm:py-5">
<div className="flex items-start gap-2 sm:gap-3">
<div className="shrink-0 mt-0.5">
<div className="size-6 sm:size-8 rounded-full border border-[#DDE9FF] bg-[#F6FAFF] flex items-center justify-center">
<MessageQuestion size={14} color="#2F80ED" className="sm:w-[18px] sm:h-[18px]" />
</div>
</div>
<div className="text-[#333333] text-xs sm:text-sm leading-6 sm:leading-7 text-right flex-1">
{question.question}
</div>
</div>
<div className="text-[#333333] text-xs sm:text-sm leading-6 sm:leading-7 text-right flex-1">
{q.text}
<div className="mt-2 text-xs text-[#999999]">
{question.user.name} {formatDate(question.date)}
</div>
</div>
{q.answers.length === 0 && (
<div className="mt-3 text-primary text-xs cursor-pointer w-fit">ثبت پاسخ</div>
)}
{question.answers.length === 0 && (
<div className="mt-3 text-primary text-xs cursor-pointer w-fit">ثبت پاسخ</div>
)}
{q.answers.length > 0 && (
<div className="mt-3 sm:mt-4 text-[#7F7F7F]">
{(expandedId === q.id ? q.answers : q.answers.slice(0, 1)).map((a) => (
<div key={a.id} className='flex gap-2 items-start mt-2 first:mt-0'>
<div className="text-xs shrink-0">پاسخ</div>
<div className="text-xs leading-6 sm:leading-7 text-right text-[#333333]">
{a.text}
{question.answers.length > 0 && (
<div className="mt-3 sm:mt-4 text-[#7F7F7F]">
{(expandedId === question._id ? question.answers : question.answers.slice(0, 1)).map((answer) => (
<div key={answer._id} className='flex gap-2 items-start mt-2 first:mt-0'>
<div className="text-xs shrink-0">پاسخ</div>
<div className="text-xs leading-6 sm:leading-7 text-right text-[#333333]">
{answer.answer}
</div>
</div>
</div>
))}
))}
{q.answers.length > 1 && (
<button
type="button"
onClick={() => setExpandedId(expandedId === q.id ? null : q.id)}
className="mt-3 flex items-center gap-1 text-xs text-primary"
>
{expandedId === q.id ? 'بستن پاسخ‌ها' : 'مشاهده پاسخ های دیگر'}
<ArrowDown2 size={12} color="blue" className={expandedId === q.id ? 'rotate-180 transition' : 'transition sm:w-[14px] sm:h-[14px]'} />
</button>
)}
</div>
)}
</div>
{/* rows divided by parent divide-y */}
</Fragment>
))}
{question.answers.length > 1 && (
<button
type="button"
onClick={() => setExpandedId(expandedId === question._id ? null : question._id)}
className="mt-3 flex items-center gap-1 text-xs text-primary"
>
{expandedId === question._id ? 'بستن پاسخ‌ها' : 'مشاهده پاسخ های دیگر'}
<ArrowDown2 size={12} color="blue" className={expandedId === question._id ? 'rotate-180 transition' : 'transition sm:w-[14px] sm:h-[14px]'} />
</button>
)}
</div>
)}
</div>
</Fragment>
))
) : (
<div className="px-4 sm:px-6 py-8 text-center text-[#999999] text-sm">
هنوز سوالی ثبت نشده است
</div>
)}
</div>
</div>
+44 -66
View File
@@ -3,65 +3,33 @@ import { FC, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Star1 } from 'iconsax-react'
import { Product, Review } from '@/types/product.types'
type ReviewItem = {
id: number
author: string
date: string
rating: number
text: string
isBuyer: boolean
interface ReviewsProps {
product: Product
reviews?: Review[]
}
const REVIEWS: ReviewItem[] = [
{
id: 1,
author: 'علی رضوانی',
date: '1401 آذر 18',
rating: 3.2,
text: 'قیمتش خوب بود',
isBuyer: true,
},
{
id: 2,
author: 'علی رضوانی',
date: '1401 آذر 18',
rating: 3.0,
text: 'گوشیشه همه چی تمام',
isBuyer: true,
},
{
id: 3,
author: 'علی رضوانی',
date: '1401 آذر 18',
rating: 1.5,
text: 'با کیفیت، خوش دست. بدون هیچ امتیاز قابل توجه نسبت به s23',
isBuyer: true,
},
{
id: 4,
author: 'علی رضوانی',
date: '1401 آذر 18',
rating: 4.0,
text: 'واقعاً گوشی فوق‌العاده‌ای هست هیچ نکته ی منفی ندارد',
isBuyer: true,
},
]
const Reviews: FC = () => {
const Reviews: FC<ReviewsProps> = ({ reviews = [] }) => {
const [sort, setSort] = useState<'new' | 'helpful'>('new')
const sortedReviews = useMemo(() => {
if (sort === 'new') {
return [...REVIEWS].sort((a, b) => b.id - a.id)
return [...reviews].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}
return [...REVIEWS].sort((a, b) => b.rating - a.rating)
}, [sort])
return [...reviews].sort((a, b) => b.helpful - a.helpful)
}, [sort, reviews])
const average = useMemo(() => {
const sum = REVIEWS.reduce((acc, r) => acc + r.rating, 0)
return (sum / REVIEWS.length).toFixed(1)
}, [])
if (reviews.length === 0) return '0.0'
const sum = reviews.reduce((acc, r) => acc + r.rating, 0)
return (sum / reviews.length).toFixed(1)
}, [reviews])
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('fa-IR')
}
return (
<div className="mt-12 sm:mt-16 w-full">
@@ -97,26 +65,36 @@ const Reviews: FC = () => {
</div>
<div className="mt-4 sm:mt-6 rounded-xl bg-white">
{sortedReviews.map((r, idx) => (
<div key={r.id} className="px-4 sm:px-6 py-4 sm:py-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-start gap-2 sm:gap-3">
<div className="px-2 py-0.5 rounded-full bg-[#02A55A] text-white text-xs w-fit">{r.rating}</div>
<div className="flex flex-wrap items-center gap-1 sm:gap-2 text-xs text-[#999999]">
{r.isBuyer && (
<span className="px-2 py-0.5 rounded bg-[#F1F1F1] text-[#7F7F7F]">خریدار</span>
)}
<span className="size-1.5 rounded-full bg-[#E5E5E5]"></span>
<span>{r.date}</span>
<span className="size-1.5 rounded-full bg-[#E5E5E5]"></span>
<span>{r.author}</span>
{sortedReviews.length > 0 ? (
sortedReviews.map((review, idx) => (
<div key={review._id} className="px-4 sm:px-6 py-4 sm:py-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-start gap-2 sm:gap-3">
<div className="px-2 py-0.5 rounded-full bg-[#02A55A] text-white text-xs w-fit">
{review.rating.toFixed(1)}
</div>
<div className="flex flex-wrap items-center gap-1 sm:gap-2 text-xs text-[#999999]">
{review.isVerified && (
<span className="px-2 py-0.5 rounded bg-[#F1F1F1] text-[#7F7F7F]">خریدار</span>
)}
<span className="size-1.5 rounded-full bg-[#E5E5E5]"></span>
<span>{formatDate(review.date)}</span>
<span className="size-1.5 rounded-full bg-[#E5E5E5]"></span>
<span>{review.user.name}</span>
</div>
</div>
<div className="mt-3 text-[#333333] text-xs sm:text-sm leading-6 sm:leading-7 text-right">
{review.comment}
</div>
{idx !== sortedReviews.length - 1 && <div className="mt-4 sm:mt-5"> <Separator /> </div>}
</div>
<div className="mt-3 text-[#333333] text-xs sm:text-sm leading-6 sm:leading-7 text-right">{r.text}</div>
{idx !== sortedReviews.length - 1 && <div className="mt-4 sm:mt-5"> <Separator /> </div>}
))
) : (
<div className="px-4 sm:px-6 py-8 text-center text-[#999999] text-sm">
هنوز نظری ثبت نشده است
</div>
))}
)}
</div>
</div>
+46 -14
View File
@@ -1,9 +1,29 @@
'use client'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { ArchiveTick, BoxTick, I3DRotate } from 'iconsax-react'
import React from 'react'
import { Product } from '@/types/product.types'
import Image from 'next/image'
interface ShopInformationProps {
product: Product
}
const ShopInformation = ({ product }: ShopInformationProps) => {
const defaultVariant = product.default_variant
const shop = defaultVariant.shop
const price = defaultVariant.price
const warranty = defaultVariant.warranty
const postingTime = defaultVariant.postingTime
const formatPrice = (price: number) => {
return price.toLocaleString('fa-IR')
}
const discountPercent = price.discount_percent
const hasDiscount = discountPercent > 0
const ShopInformation = () => {
return (
<div className='w-full xl:w-[325px] bg-[#FAFAFA] rounded-[10px] p-4 sm:p-6 lg:p-7 border border-border h-fit xl:sticky xl:top-[170px] xl:self-start'>
<div className='text-lg text-[#191919]'>
@@ -11,9 +31,17 @@ const ShopInformation = () => {
</div>
<div className='mt-9 flex gap-3 border-b border-border pb-4'>
<div className='size-7 rounded-full bg-primary'></div>
<div className='size-7 rounded-full bg-primary flex items-center justify-center overflow-hidden'>
<Image
src={shop.logo}
alt={shop.shopName}
width={28}
height={28}
className='w-full h-full object-cover'
/>
</div>
<div>
<div className='text-[#333333] text-sm'>فروشگاه سندس</div>
<div className='text-[#333333] text-sm'>{shop.shopName}</div>
<div className='mt-2 text-xs text-[#999999] font-light flex items-center gap-2'>
<div>
<span className='text-[#019907]'>50%</span> رضایت کالا
@@ -26,19 +54,19 @@ const ShopInformation = () => {
<div className='flex gap-4 text-sm font-light border-b border-border pb-4 mt-4'>
<ArchiveTick size={20} color='#333333' />
<div>گارانتی اصالت و سلامت فیزیکی کالا</div>
<div>{warranty.name}</div>
</div>
<div className='mt-4'>
<div className='flex gap-4 text-sm font-light'>
<ArchiveTick size={20} color='#333333' />
<div>گارانتی اصالت و سلامت فیزیکی کالا</div>
<div>مدت گارانتی: {warranty.duration}</div>
</div>
</div>
<div className='mt-6 border-b border-border pb-4'>
<div className='flex gap-4 text-sm font-light'>
<BoxTick size={20} color='blue' />
<div className='text-[#999999]'>ارسال از سندس حداکثر پس از 3 روز کاری</div>
<div className='text-[#999999]'>ارسال از {shop.shopName} حداکثر پس از {postingTime} روز کاری</div>
</div>
</div>
@@ -47,17 +75,21 @@ const ShopInformation = () => {
<div className='text-primary'>گارانتی اصالت و سلامت فیزیکی کالا</div>
</div>
<div className='mt-4 text-sm text-[#999999] font-light flex justify-between'>
<div>قیمت برای شما</div>
<div className='line-through text-base'>70,400,000</div>
</div>
{hasDiscount && (
<div className='mt-4 text-sm text-[#999999] font-light flex justify-between'>
<div>قیمت برای شما</div>
<div className='line-through text-base'>{formatPrice(price.retailPrice)}</div>
</div>
)}
<div className='mt-3 flex justify-between items-center'>
<div className='h-6 text-xs px-3 bg-primary rounded-full text-white w-fit flex items-center justify-center'>
5%
</div>
{hasDiscount && (
<div className='h-6 text-xs px-3 bg-primary rounded-full text-white w-fit flex items-center justify-center'>
{discountPercent}%
</div>
)}
<div>
<span className='text-lg'>70,400,000</span> <span className='text-xs'>تومان</span>
<span className='text-lg'>{formatPrice(price.selling_price)}</span> <span className='text-xs'>تومان</span>
</div>
</div>
@@ -0,0 +1,89 @@
'use client'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import ProductImage from '@/app/product/components/ProductImage'
import BaseInformation from '@/app/product/components/BaseInformation'
import ShopInformation from '@/app/product/components/ShopInformation'
import Specifications from '@/app/product/components/Specifications'
import ProsCons from '@/app/product/components/ProsCons'
import Description from '@/app/product/components/Description'
import Reviews from '@/app/product/components/Reviews'
import Questions from '@/app/product/components/Questions'
import { useGetDetailProduct } from '@/app/product/hooks/useProductData'
import PageLoading from '@/components/PageLoading'
import { notFound } from 'next/navigation'
interface SingleProductProps {
id: string
}
const SingleProduct = ({ id }: SingleProductProps) => {
const { data: productData, isLoading, error } = useGetDetailProduct(id)
if (isLoading) {
return <PageLoading />
}
if (error || !productData) {
notFound()
}
const { product, categoryPath, reviews, questions } = productData.results
return (
<div className='mt-14 px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20'>
<div className='flex flex-col sm:flex-row justify-between gap-4 sm:gap-0'>
<Breadcrumb aria-label="breadcrumb">
<BreadcrumbList className="text-sm text-muted-foreground">
<BreadcrumbItem>
<BreadcrumbLink href="/">فروشگاه آناهیتا</BreadcrumbLink>
</BreadcrumbItem>
{categoryPath.map((category, index) => (
<div key={category.id} className="flex items-center">
<BreadcrumbSeparator className="text-muted-foreground select-none">/</BreadcrumbSeparator>
<BreadcrumbItem>
{index === categoryPath.length - 1 ? (
<BreadcrumbPage className="text-foreground">{category.title_fa}</BreadcrumbPage>
) : (
<BreadcrumbLink href={`/products?category=${category.id}`}>
{category.title_fa}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</div>
))}
</BreadcrumbList>
</Breadcrumb>
<div className='text-primary text-xs sm:text-sm'>فروشنده شوید</div>
</div>
<div className='flex flex-col xl:flex-row gap-4 sm:gap-6 mt-8 sm:mt-12 md:mt-14'>
<div className='flex-1 flex flex-col gap-6'>
<div className='flex flex-col lg:flex-row gap-4 sm:gap-6'>
<ProductImage product={product} />
<BaseInformation product={product} />
</div>
<div>
<Specifications product={product} />
<Description product={product} />
<ProsCons product={product} />
<Reviews product={product} reviews={reviews} />
<Questions product={product} questions={questions} />
</div>
</div>
<div className='xl:w-[325px] xl:shrink-0'>
<ShopInformation product={product} />
</div>
</div>
</div>
)
}
export default SingleProduct
+14 -34
View File
@@ -1,29 +1,13 @@
'use client'
import { FC, Fragment } from 'react'
import { Separator } from '@/components/ui/separator'
import { Product } from '@/types/product.types'
type SpecItem = {
label: string
value: string | string[]
interface SpecificationsProps {
product: Product
}
const SPEC_ITEMS: SpecItem[] = [
{ label: 'ابعاد', value: 'میلی‌متر 163.3×79×8.6' },
{ label: 'وزن', value: '233 گرم' },
{ label: 'توضیحات سیم کارت', value: 'سایز نانو (12.3 × 8.8 میلی‌متر)' },
{
label: 'ویژگی‌های خاص',
value: [
'مجهز به حس‌گر اثرانگشت',
'مقاوم در برابر آب',
'مناسب بازی',
'مناسب عکاسی',
'مناسب عکاسی سلفی',
],
},
{ label: 'تعداد سیم کارت', value: 'دو عدد' },
]
const Specifications: FC = () => {
const Specifications: FC<SpecificationsProps> = ({ product }) => {
return (
<div className="mt-12 sm:mt-16 w-full">
<div className="flex items-center justify-end gap-3 flex-row-reverse">
@@ -32,26 +16,22 @@ const Specifications: FC = () => {
</div>
<div className="mt-4 sm:mt-6 rounded-xl bg-white">
{SPEC_ITEMS.map((item, index) => (
<Fragment key={item.label}>
{product.specifications.map((spec, index) => (
<Fragment key={spec.title}>
<div className="grid grid-cols-12 items-start px-4 sm:px-6 py-4 sm:py-5">
<div className="col-span-12 md:col-span-3 flex items-center justify-end gap-2 text-[#7F7F7F] flex-row-reverse">
<span className="text-xs sm:text-sm">{item.label}</span>
<span className="text-xs sm:text-sm">{spec.title}</span>
<span className="size-1.5 rounded-full bg-[#7F7F7F]"></span>
</div>
<div className="col-span-12 md:col-span-9 mt-2 md:mt-0">
{Array.isArray(item.value) ? (
<div className="flex flex-col gap-2 sm:gap-3 text-xs sm:text-sm text-[#333333] text-right">
{item.value.map((v) => (
<div key={v}>{v}</div>
))}
</div>
) : (
<div className="text-xs sm:text-sm text-[#333333] text-right">{item.value}</div>
)}
<div className="flex flex-col gap-2 sm:gap-3 text-xs sm:text-sm text-[#333333] text-right">
{spec.values.map((value, valueIndex) => (
<div key={valueIndex}>{value}</div>
))}
</div>
</div>
</div>
{index !== SPEC_ITEMS.length - 1 && <Separator />}
{index !== product.specifications.length - 1 && <Separator />}
</Fragment>
))}
</div>
+14
View File
@@ -0,0 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import * as api from "../service/Service";
export const useGetDetailProduct = (id: string) => {
return useQuery({
queryKey: ["product", id],
queryFn: () => api.getProduct(id),
});
};
export const getProductQueryOptions = (id: string) => ({
queryKey: ["product", id],
queryFn: () => api.getProduct(id),
});
+9
View File
@@ -0,0 +1,9 @@
import axios from "@/config/axios";
import { ProductDetailResponse } from "@/types/product.types";
export const getProduct = async (
id: string
): Promise<ProductDetailResponse> => {
const { data } = await axios.get(`/product/${id}`);
return data;
};
+21 -15
View File
@@ -40,7 +40,6 @@ const ProfilePage = () => {
<Menu pageActive='profile' />
<div className='mt-4 sm:mt-6 md:mt-8 lg:mt-10'>
<div className="flex flex-col lg:flex-row gap-4 sm:gap-6">
{/* Left Content - Form */}
<div className="flex-1">
<div className="bg-[#FAFAFA] rounded-lg p-3 sm:p-4 md:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 sm:mb-6 gap-3 sm:gap-0">
@@ -150,25 +149,32 @@ const ProfilePage = () => {
</div>
</div>
{/* Quick Actions Cards */}
<div className='flex flex-row lg:flex-col gap-3 sm:gap-4 lg:gap-6 lg:min-w-[200px]'>
<div className='flex-1 lg:w-full bg-[#FAFAFA] rounded-[10px] flex flex-col gap-3 sm:gap-4 lg:gap-6 justify-center items-center h-[100px] sm:h-[120px] lg:h-[160px] xl:h-[200px]'>
<div className='size-10 sm:size-12 lg:size-16 xl:size-20 bg-[#F2F2F2] rounded-full flex justify-center items-center'>
<ShoppingCart size={20} color='#000' className='sm:w-6 sm:h-6 lg:w-8 lg:h-8 xl:w-10 xl:h-10' />
<div className='flex-1 flex lg:flex-row flex-col gap-6'>
<div className='flex flex-row lg:flex-col gap-3 sm:gap-4 lg:gap-6 flex-1'>
<div className='flex-1 lg:w-full bg-[#FAFAFA] rounded-[10px] flex flex-col gap-3 sm:gap-4 lg:gap-6 justify-center items-center h-[100px] sm:h-[120px] lg:h-[160px] xl:h-[200px]'>
<div className='size-10 sm:size-12 lg:size-16 xl:size-20 bg-[#F2F2F2] rounded-full flex justify-center items-center'>
<ShoppingCart size={20} color='#000' className='sm:w-6 sm:h-6 lg:w-8 lg:h-8 xl:w-10 xl:h-10' />
</div>
<div className='text-xs sm:text-sm lg:text-base text-center'>
سفارشات
</div>
</div>
<div className='text-xs sm:text-sm lg:text-base text-center'>
سفارشات
<div className='flex-1 lg:w-full bg-[#FAFAFA] rounded-[10px] flex flex-col gap-3 sm:gap-4 lg:gap-6 justify-center items-center h-[100px] sm:h-[120px] lg:h-[160px] xl:h-[200px]'>
<div className='size-10 sm:size-12 lg:size-16 xl:size-20 bg-[#F2F2F2] rounded-full flex justify-center items-center'>
<Heart size={20} color='#000' className='sm:w-6 sm:h-6 lg:w-8 lg:h-8 xl:w-10 xl:h-10' />
</div>
<div className='text-xs sm:text-sm lg:text-base text-center'>
علاقه مندی ها
</div>
</div>
</div>
<div className='flex-1 lg:w-full bg-[#FAFAFA] rounded-[10px] flex flex-col gap-3 sm:gap-4 lg:gap-6 justify-center items-center h-[100px] sm:h-[120px] lg:h-[160px] xl:h-[200px]'>
<div className='size-10 sm:size-12 lg:size-16 xl:size-20 bg-[#F2F2F2] rounded-full flex justify-center items-center'>
<Heart size={20} color='#000' className='sm:w-6 sm:h-6 lg:w-8 lg:h-8 xl:w-10 xl:h-10' />
</div>
<div className='text-xs sm:text-sm lg:text-base text-center'>
علاقه مندی ها
</div>
<div className='flex-1 bg-red-50 '>
</div>
</div>
</div>
</div>
</div>
+4 -2
View File
@@ -1,3 +1,4 @@
'use client'
import { IProduct } from '@/types/landing.types'
import { NumberFormat } from '@/config/func'
import { Heart } from 'iconsax-react'
@@ -5,6 +6,7 @@ import Image from 'next/image'
import { FC, useState } from 'react'
import { useAddWishlist, useRemoveWishlist } from '@/app/products/hooks/useProductsData'
import { useSharedStore } from '@/share/store/sharedStore'
import Link from 'next/link'
type Props = {
item?: IProduct
@@ -34,7 +36,7 @@ const ProductCard: FC<Props> = ({ item }) => {
}
if (item) return (
<div className='bg-white rounded-[20px] sm:rounded-[40px] p-4 sm:p-6 w-full max-w-[240px] sm:max-w-[280px] mx-auto shadow'>
<Link href={`/product/${item._id}`} className='bg-white block rounded-[20px] sm:rounded-[40px] p-4 sm:p-6 w-full max-w-[240px] sm:max-w-[280px] mx-auto shadow'>
<div className='flex justify-center'>
<Image
src={item.imagesUrl.cover}
@@ -89,7 +91,7 @@ const ProductCard: FC<Props> = ({ item }) => {
<div className='flex justify-end'>
<Heart onClick={handleWish} variant={isWished ? 'Bold' : 'Outline'} size={20} color='#000' />
</div>
</div>
</Link>
)
return (
<div className='bg-white rounded-[20px] sm:rounded-[40px] p-4 sm:p-6 w-full max-w-[240px] sm:max-w-[280px] mx-auto shadow'>
+163
View File
@@ -0,0 +1,163 @@
export interface ProductPrice {
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 ProductSize {
_id: number;
value: string;
}
export interface ProductWarranty {
_id: number;
duration: string;
logoUrl: string;
name: string;
}
export interface ProductShop {
_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 ProductVariant {
_id: string;
market_status: string;
price: ProductPrice;
stock: number;
postingTime: number;
isFreeShip: boolean;
isWholeSale: boolean;
shop: ProductShop;
shipmentMethod: ShipmentMethod[];
warranty: ProductWarranty;
size: ProductSize;
}
export interface ProductBrand {
_id: string;
status: string;
title_en: string;
title_fa: string;
images: string[];
logoUrl: string;
}
export interface ProductCategory {
_id: string;
title_fa: string;
title_en: string;
icon: string;
imageUrl: string;
theme: string;
description: string;
leaf: boolean;
parent: string;
}
export interface ProductSpecification {
title: string;
values: string[];
}
export interface ProductImages {
cover: string;
list: string[];
}
export interface CategoryPathItem {
id: string;
title_fa: string;
title_en: string;
}
export interface Review {
_id: string;
user: {
name: string;
avatar?: string;
};
rating: number;
comment: string;
date: string;
isVerified: boolean;
helpful: number;
}
export interface Question {
_id: string;
user: {
name: string;
avatar?: string;
};
question: string;
answers: Answer[];
date: string;
helpful: number;
}
export interface Answer {
_id: string;
user: {
name: string;
avatar?: string;
};
answer: string;
date: string;
helpful: number;
}
export interface Product {
_id: number;
url: string;
title_fa: string;
title_en: string;
seoTitle: string | null;
seoDescription: string | null;
source: string;
description: string;
metaDescription: string;
tags: string[];
advantages: string[];
disAdvantages: string[];
imagesUrl: ProductImages;
isFake: string;
voice: string | null;
specifications: ProductSpecification[];
brand: ProductBrand;
category: ProductCategory;
default_variant: ProductVariant;
variants: ProductVariant[];
}
export interface ProductDetailResponse {
status: number;
success: boolean;
results: {
product: Product;
categoryPath: CategoryPathItem[];
reviews?: Review[];
questions?: Question[];
};
}