order detail

This commit is contained in:
hamid zarghami
2025-08-26 15:58:24 +03:30
parent 90bf32f8fd
commit 09a210c943
6 changed files with 634 additions and 3 deletions
@@ -0,0 +1,21 @@
'use client'
import { FC } from 'react'
type Props = {
orderId: number
}
const TrackCode: FC<Props> = ({ orderId }) => {
return (
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2">
<div className="text-[#7F7F7F] text-sm">کد پیگیری:</div>
<div className="font-mono text-sm bg-white px-2 py-1 rounded border">
{orderId.toString().padStart(8, '0')}
</div>
</div>
</div>
)
}
export default TrackCode
+417
View File
@@ -0,0 +1,417 @@
'use client'
import { FC, useState } from 'react'
import { ArrowRight2, ShieldTick, Truck, MessageText, CloseCircle } from 'iconsax-react'
import { useRouter, useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import * as yup from 'yup'
import PageLoading from '@/components/PageLoading'
import TrackCode from './components/TrackCode'
import { Button } from '@/components/ui/button'
import Modal from '@/components/Modal'
import Select from '@/components/Select'
import Input from '@/components/Input'
import withLayout from '@/hoc/withLayout'
import Image from 'next/image'
// Mock data
const mockOrderDetail = {
payment: {
transaction_id: 'TRX123456789'
},
order: {
_id: 'ORD001',
user: {
fullName: 'احمد محمدی'
},
shipmentAddress: {
postalCode: '1234567890',
phone: '09123456789',
address: 'تهران، خیابان ولیعصر، پلاک 123'
},
createdAt: '1402/12/15',
orderItems: {
_id: 'ITEM001',
itemsCount: 3,
status: 'processing',
totalShipmentCost: '25000 تومان',
totalPaymentPrice: '450000',
shipper: {
name: 'پست ایران',
deliveryTime: 3
},
shipmentItems: [
{
_id: 'SHIP001',
product: {
_id: 'PROD001',
title_fa: 'لپ‌تاپ اپل مک‌بوک پرو 13 اینچی',
imagesUrl: {
cover: 'https://via.placeholder.com/80x80'
}
},
quantity: 1,
cancelled_quantity: 0,
returned_quantity: 0,
selling_price: 450000,
variant: {
color: {
name: 'نقره‌ای'
},
size: {
name: '13 اینچ'
},
meterages: {
name: '256GB'
},
warranty: {
duration: '2 سال',
name: 'گارانتی'
}
}
}
]
}
}
}
const mockCancelReasons = [
{ _id: 'REASON1', title: 'تغییر نظر در خرید' },
{ _id: 'REASON2', title: 'یافتن قیمت بهتر' },
{ _id: 'REASON3', title: 'مشکل در محصول' },
{ _id: 'REASON4', title: 'تأخیر در ارسال' }
]
// Form validation schema
const cancelOrderSchema = yup.object({
reason: yup.string().required('لطفا دلیل لغو سفارش را انتخاب کنید'),
description: yup.string().required('لطفا توضیحات لغو سفارش را وارد کنید'),
count: yup.string().required('تعداد لغو سفارش را وارد کنید')
})
type CancelOrderFormData = yup.InferType<typeof cancelOrderSchema>
const OrderDetail: FC = () => {
const { id } = useParams()
const [showModalCancel, setShowModalCancel] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const router = useRouter()
// Form with react-hook-form
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
reset
} = useForm<CancelOrderFormData>({
resolver: yupResolver(cancelOrderSchema)
})
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
'processing': 'در حال پردازش',
'shipped': 'ارسال شده',
'delivered': 'تحویل داده شده',
'cancelled': 'لغو شده'
}
return statusMap[status] || status
}
const onSubmit = async () => {
setIsLoading(true)
// Simulate API call
setTimeout(() => {
setIsLoading(false)
setShowModalCancel(false)
reset() // Reset form
alert('درخواست لغو سفارش با موفقیت ثبت شد')
router.push('/profile/orders')
}, 1000)
}
return (
<div className='w-full lg:px-10 px-4 mt-20'>
<div className='border p-6 rounded-2xl text-sm'>
<div className='flex gap-2 items-center border-b pb-6'>
<button
onClick={() => router.push('/profile/orders')}
className='p-2 hover:bg-gray-100 rounded-lg transition-colors'
>
<ArrowRight2 size={20} className="rotate-180" />
</button>
<div className="text-lg font-semibold">جزئیات سفارش</div>
</div>
{isLoading ? (
<PageLoading />
) : (
<div className='mt-6 lg:flex-row flex-col flex gap-6 items-start'>
{/* Sidebar */}
<div className='lg:w-[280px] w-full p-5 rounded-2xl shadow-lg'>
<div className='flex items-center gap-2'>
<div className='text-[#7F7F7F]'>شناسه تراکنش :</div>
<div className="font-mono">
{mockOrderDetail.payment.transaction_id}
</div>
</div>
<div className='flex items-center gap-2 mt-4'>
<div className='text-[#7F7F7F]'>گیرنده :</div>
<div>
{mockOrderDetail.order.user.fullName}
</div>
</div>
<div className='flex items-center gap-2 mt-4'>
<div className='text-[#7F7F7F]'>کدپستی :</div>
<div>
{mockOrderDetail.order.shipmentAddress.postalCode}
</div>
</div>
<div className='flex items-center gap-2 mt-4'>
<div className='text-[#7F7F7F] whitespace-nowrap'>تاریخ ثبت سفارش :</div>
<div>
{mockOrderDetail.order.createdAt}
</div>
</div>
<div className='flex items-center gap-2 mt-4'>
<div className='text-[#7F7F7F]'>شماره موبایل :</div>
<div>
{mockOrderDetail.order.shipmentAddress.phone}
</div>
</div>
<div className='flex items-start gap-2 mt-4'>
<div className='text-[#7F7F7F] whitespace-nowrap'>آدرس :</div>
<div>
{mockOrderDetail.order.shipmentAddress.address}
</div>
</div>
<TrackCode orderId={Number(id) || 0} />
<Button
className='mt-3 w-full'
variant="outline"
>
مشاهده فاکتور
</Button>
</div>
{/* Main Content */}
<div className='flex-1 w-full bg-white shadow-lg p-6 rounded-2xl'>
<div className='flex md:flex-row flex-col gap-4 justify-between md:items-center'>
<div className='flex md:flex-row flex-col gap-4'>
<div className='flex items-center gap-2'>
<div className='text-[#7F7F7F]'>تعداد مرسوله :</div>
<div>1</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-[#7F7F7F]'>تعداد اقلام :</div>
<div>
{mockOrderDetail.order.orderItems.itemsCount}
</div>
</div>
</div>
<div className='text-primary font-semibold'>
{getStatusText(mockOrderDetail.order.orderItems.status)}
</div>
</div>
<div className='flex items-center gap-2 mt-6'>
<div className='text-[#7F7F7F]'>حداکثر مهلت ارسال :</div>
<div>
{mockOrderDetail.order.orderItems.shipper.deliveryTime} روز
</div>
</div>
<div className='flex md:flex-row flex-col gap-3 justify-between mt-6'>
<div className='flex items-center gap-2'>
<div className='text-[#7F7F7F]'>هزینه ارسال :</div>
<div>
{mockOrderDetail.order.orderItems.totalShipmentCost}
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-[#7F7F7F]'>مبلغ مرسوله :</div>
<div>
{mockOrderDetail.order.orderItems.totalPaymentPrice} تومان
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-[#7F7F7F]'>شناسه سفارش :</div>
<div className="font-mono">
{mockOrderDetail.order._id}
</div>
</div>
</div>
{/* Product Items */}
{mockOrderDetail.order.orderItems.shipmentItems.map((item) => (
<div key={item.product._id} className='mt-8 py-7 border-t items-start flex gap-6 sm:flex-row flex-col'>
<Image
src={item.product.imagesUrl.cover}
width={80}
height={80}
className='w-20 h-20 object-cover rounded-lg border'
alt={item.product.title_fa}
/>
<div className='flex-1 w-full'>
<div className='truncate max-w-[90%] font-medium'>
{item.product.title_fa}
</div>
<div className='mt-4 text-[#999999] space-y-3'>
<div className='flex gap-2'>
<div>تعداد اقلام :</div>
<div>{item.quantity}</div>
</div>
<div className='flex gap-2'>
<div>تعداد کنسلی :</div>
<div>{item.cancelled_quantity}</div>
</div>
<div className='flex gap-2'>
<div>تعداد مرجوعی :</div>
<div>{item.returned_quantity}</div>
</div>
<div className='flex gap-3 items-center'>
<div className='size-3 bg-black rounded-full'></div>
<div>
{item.variant.color?.name ||
item.variant.size?.name ||
item.variant.meterages?.name}
</div>
</div>
<div className='flex gap-3 items-center'>
<MessageText size={20} className="text-gray-400" />
<div className="font-mono">
SKP_{item.product._id}
</div>
</div>
<div className='flex gap-3 items-center'>
<ShieldTick size={20} className="text-green-500" />
<div>
گارانتی {item.variant.warranty.duration} {item.variant.warranty.name}
</div>
</div>
<div className='flex gap-3 items-center'>
<Truck size={20} className="text-blue-500" />
<div>
{mockOrderDetail.order.orderItems.shipper.name}
</div>
</div>
</div>
<div className='mt-8 flex justify-between items-center'>
<div>
<div className='text-lg font-semibold'>
{Intl.NumberFormat('fa-IR').format(item.selling_price)}
</div>
<div className='text-xs mt-1 text-gray-500'>تومان</div>
</div>
{item.quantity !== item.cancelled_quantity && (
<button
onClick={() => setShowModalCancel(true)}
className='flex cursor-pointer gap-2 text-[#B3261E] hover:bg-red-50 p-2 rounded-lg transition-colors'
>
<div>لغو سفارش</div>
<CloseCircle size={16} />
</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Cancel Order Modal */}
<Modal
open={showModalCancel}
close={() => setShowModalCancel(false)}
isHeader
title='درخواست لغو سفارش'
>
<form onSubmit={handleSubmit(onSubmit)} className='mt-6'>
<Select
placeholder='دلیل لغو سفارش'
items={mockCancelReasons.map((item) => ({
label: item.title,
value: item._id
}))}
className='bg-white border'
value={watch('reason')}
name='reason'
onChange={(e) => setValue('reason', e.target.value)}
error_text={errors.reason?.message}
/>
<Input
variant='floating_outlined'
label='تعداد'
className='mt-6'
{...register('count')}
error_text={errors.count?.message}
/>
<div className='mt-6'>
<div className="text-sm font-medium mb-2">توضیحات :</div>
<textarea
{...register('description')}
className='h-[123px] rounded-xl p-3 text-sm border w-full resize-none focus:outline-none focus:ring-2 focus:ring-primary/20'
placeholder='متن توضیحات خود را بنویسید'
/>
{errors.description && (
<div className='text-xs text-red-500 mt-1'>
{errors.description.message}
</div>
)}
</div>
<div className='flex gap-2 text-xs text-[#7F7F7F] mt-4 p-3 bg-blue-50 rounded-lg'>
<div className="size-5 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 text-xs">!</span>
</div>
<span>
توجه داشته باشید که بعد از ثبت لغو سفارش طبق
</span>
<span className='text-primary font-medium'>
جدول جرائم
</span>
<span>
جریمه میشود
</span>
</div>
<div className='mt-6'>
<Button
type="submit"
className='w-full'
disabled={isLoading}
isLoading={isLoading}
>
لغو سفارش
</Button>
</div>
</form>
</Modal>
</div>
)
}
export default withLayout(OrderDetail)
+65
View File
@@ -0,0 +1,65 @@
'use client'
import { FC, ReactNode, useEffect } from 'react'
import { CloseCircle } from 'iconsax-react'
import { cn } from '@/lib/utils'
type Props = {
open: boolean
close: () => void
children: ReactNode
title?: string
isHeader?: boolean
className?: string
}
const Modal: FC<Props> = ({ open, close, children, title, isHeader = false, className }) => {
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [open])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={close}
/>
{/* Modal */}
<div className={cn(
"relative bg-white rounded-2xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto",
className
)}>
{/* Header */}
{isHeader && (
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button
onClick={close}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<CloseCircle size={20} />
</button>
</div>
)}
{/* Content */}
<div className="p-6">
{children}
</div>
</div>
</div>
)
}
export default Modal
+15
View File
@@ -0,0 +1,15 @@
'use client'
import { FC } from 'react'
const PageLoading: FC = () => {
return (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-4">
<div className="size-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin"></div>
<p className="text-gray-600">در حال بارگذاری...</p>
</div>
</div>
)
}
export default PageLoading
+114
View File
@@ -0,0 +1,114 @@
'use client'
import { FC, useState, useRef, useEffect } from 'react'
import { ArrowDown2 } from 'iconsax-react'
import { cn } from '@/lib/utils'
type SelectItem = {
label: string
value: string
}
type Props = {
placeholder?: string
items?: SelectItem[]
value?: string
name?: string
onChange?: (e: { target: { name: string; value: string } }) => void
className?: string
error_text?: string
disabled?: boolean
}
const Select: FC<Props> = ({
placeholder,
items = [],
value,
name,
onChange,
className,
error_text,
disabled = false
}) => {
const [isOpen, setIsOpen] = useState(false)
const [selectedItem, setSelectedItem] = useState<SelectItem | null>(
items.find(item => item.value === value) || null
)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
useEffect(() => {
const item = items.find(item => item.value === value)
setSelectedItem(item || null)
}, [value, items])
const handleSelect = (item: SelectItem) => {
setSelectedItem(item)
setIsOpen(false)
if (onChange && name) {
onChange({
target: {
name,
value: item.value
}
})
}
}
return (
<div className="w-full relative" ref={dropdownRef}>
<div
className={cn(
"w-full h-10 px-4 py-2 text-sm border border-[#D0D0D0] rounded-xl bg-white cursor-pointer flex items-center justify-between",
disabled && "bg-gray-100 cursor-not-allowed",
error_text && "border-red-500",
className
)}
onClick={() => !disabled && setIsOpen(!isOpen)}
>
<span className={selectedItem ? "text-black" : "text-gray-500"}>
{selectedItem ? selectedItem.label : placeholder}
</span>
<ArrowDown2
size={16}
className={cn(
"transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</div>
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-[#D0D0D0] rounded-xl shadow-lg z-50 max-h-60 overflow-y-auto">
{items.map((item, index) => (
<div
key={index}
className="px-4 py-2 hover:bg-gray-50 cursor-pointer text-sm"
onClick={() => handleSelect(item)}
>
{item.label}
</div>
))}
</div>
)}
{error_text && (
<div className="text-xs text-red-500 mt-1">
{error_text}
</div>
)}
</div>
)
}
export default Select
+2 -3
View File
@@ -1,12 +1,11 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { ClipLoader } from "react-spinners"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
@@ -61,7 +60,7 @@ function Button({
> >
{isLoading ? ( {isLoading ? (
<> <>
<ClipLoader size={16} color="currentColor" /> <div className="size-4 border-2 border-current/20 border-t-current rounded-full animate-spin"></div>
<span>در حال بارگذاری...</span> <span>در حال بارگذاری...</span>
</> </>
) : ( ) : (