refresh token + base orders list and detail order

This commit is contained in:
hamid zarghami
2025-09-15 11:30:41 +03:30
parent 21319eef94
commit 220c8be526
18 changed files with 1316 additions and 534 deletions
+93 -43
View File
@@ -22,7 +22,8 @@
"embla-carousel-react": "^8.6.0",
"iconsax-react": "^0.0.8",
"lucide-react": "^0.539.0",
"next": "15.4.5",
"moment-jalaali": "^0.10.4",
"next": "^15.5.3",
"rc-rate": "^2.13.1",
"react": "19.1.0",
"react-dom": "19.1.0",
@@ -38,6 +39,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/moment-jalaali": "^0.7.9",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -820,9 +822,9 @@
}
},
"node_modules/@next/env": {
"version": "15.4.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.5.tgz",
"integrity": "sha512-ruM+q2SCOVCepUiERoxOmZY9ZVoecR3gcXNwCYZRvQQWRjhOiPJGmQ2fAiLR6YKWXcSAh7G79KEFxN3rwhs4LQ==",
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz",
"integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -836,9 +838,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.4.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.5.tgz",
"integrity": "sha512-84dAN4fkfdC7nX6udDLz9GzQlMUwEMKD7zsseXrl7FTeIItF8vpk1lhLEnsotiiDt+QFu3O1FVWnqwcRD2U3KA==",
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz",
"integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==",
"cpu": [
"arm64"
],
@@ -852,9 +854,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.4.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.5.tgz",
"integrity": "sha512-CL6mfGsKuFSyQjx36p2ftwMNSb8PQog8y0HO/ONLdQqDql7x3aJb/wB+LA651r4we2pp/Ck+qoRVUeZZEvSurA==",
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz",
"integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==",
"cpu": [
"x64"
],
@@ -868,9 +870,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.4.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.5.tgz",
"integrity": "sha512-1hTVd9n6jpM/thnDc5kYHD1OjjWYpUJrJxY4DlEacT7L5SEOXIifIdTye6SQNNn8JDZrcN+n8AWOmeJ8u3KlvQ==",
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz",
"integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==",
"cpu": [
"arm64"
],
@@ -884,9 +886,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.4.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.5.tgz",
"integrity": "sha512-4W+D/nw3RpIwGrqpFi7greZ0hjrCaioGErI7XHgkcTeWdZd146NNu1s4HnaHonLeNTguKnL2Urqvj28UJj6Gqw==",
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz",
"integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==",
"cpu": [
"arm64"
],
@@ -900,9 +902,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.4.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.5.tgz",
"integrity": "sha512-N6Mgdxe/Cn2K1yMHge6pclffkxzbSGOydXVKYOjYqQXZYjLCfN/CuFkaYDeDHY2VBwSHyM2fUjYBiQCIlxIKDA==",
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz",
"integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==",
"cpu": [
"x64"
],
@@ -916,9 +918,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.4.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.5.tgz",
"integrity": "sha512-YZ3bNDrS8v5KiqgWE0xZQgtXgCTUacgFtnEgI4ccotAASwSvcMPDLua7BWLuTfucoRv6mPidXkITJLd8IdJplQ==",
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz",
"integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==",
"cpu": [
"x64"
],
@@ -932,9 +934,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.4.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.5.tgz",
"integrity": "sha512-9Wr4t9GkZmMNcTVvSloFtjzbH4vtT4a8+UHqDoVnxA5QyfWe6c5flTH1BIWPGNWSUlofc8dVJAE7j84FQgskvQ==",
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz",
"integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==",
"cpu": [
"arm64"
],
@@ -948,9 +950,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.4.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.5.tgz",
"integrity": "sha512-voWk7XtGvlsP+w8VBz7lqp8Y+dYw/MTI4KeS0gTVtfdhdJ5QwhXLmNrndFOin/MDoCvUaLWMkYKATaCoUkt2/A==",
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz",
"integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==",
"cpu": [
"x64"
],
@@ -1853,6 +1855,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/moment-jalaali": {
"version": "0.7.9",
"resolved": "https://registry.npmjs.org/@types/moment-jalaali/-/moment-jalaali-0.7.9.tgz",
"integrity": "sha512-gsDOoAzRnCfQTbfdlUrCvX6R0wIto6CvwfvV2C3j4qJLK+DEiTK8Rl/xlOCBO9C6qeUfX8oyZ2UfjnXJTOvHSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"moment": ">=2.14.0"
}
},
"node_modules/@types/node": {
"version": "20.19.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz",
@@ -2723,9 +2735,9 @@
}
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -4777,6 +4789,12 @@
"node": ">= 0.4"
}
},
"node_modules/jalaali-js": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
"integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==",
"license": "MIT"
},
"node_modules/jiti": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
@@ -5309,6 +5327,38 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moment-jalaali": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/moment-jalaali/-/moment-jalaali-0.10.4.tgz",
"integrity": "sha512-/eD0HeyvATznb5iE0G1BHjKRZAFEpJ9ZNUkcHwXhNgt1WJJVVzHD7+uDmqzZWVFLdbGme2gvIXKb3ezDYOXcZA==",
"license": "MIT",
"dependencies": {
"jalaali-js": "^1.2.7",
"moment": "^2.29.4",
"moment-timezone": "^0.5.46"
}
},
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5358,12 +5408,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "15.4.5",
"resolved": "https://registry.npmjs.org/next/-/next-15.4.5.tgz",
"integrity": "sha512-nJ4v+IO9CPmbmcvsPebIoX3Q+S7f6Fu08/dEWu0Ttfa+wVwQRh9epcmsyCPjmL2b8MxC+CkBR97jgDhUUztI3g==",
"version": "15.5.3",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz",
"integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==",
"license": "MIT",
"dependencies": {
"@next/env": "15.4.5",
"@next/env": "15.5.3",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -5376,14 +5426,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.4.5",
"@next/swc-darwin-x64": "15.4.5",
"@next/swc-linux-arm64-gnu": "15.4.5",
"@next/swc-linux-arm64-musl": "15.4.5",
"@next/swc-linux-x64-gnu": "15.4.5",
"@next/swc-linux-x64-musl": "15.4.5",
"@next/swc-win32-arm64-msvc": "15.4.5",
"@next/swc-win32-x64-msvc": "15.4.5",
"@next/swc-darwin-arm64": "15.5.3",
"@next/swc-darwin-x64": "15.5.3",
"@next/swc-linux-arm64-gnu": "15.5.3",
"@next/swc-linux-arm64-musl": "15.5.3",
"@next/swc-linux-x64-gnu": "15.5.3",
"@next/swc-linux-x64-musl": "15.5.3",
"@next/swc-win32-arm64-msvc": "15.5.3",
"@next/swc-win32-x64-msvc": "15.5.3",
"sharp": "^0.34.3"
},
"peerDependencies": {
+3 -1
View File
@@ -23,7 +23,8 @@
"embla-carousel-react": "^8.6.0",
"iconsax-react": "^0.0.8",
"lucide-react": "^0.539.0",
"next": "15.4.5",
"moment-jalaali": "^0.10.4",
"next": "^15.5.3",
"rc-rate": "^2.13.1",
"react": "19.1.0",
"react-dom": "19.1.0",
@@ -39,6 +40,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/moment-jalaali": "^0.7.9",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+10 -1
View File
@@ -1,5 +1,9 @@
import axios from "@/config/axios";
import { AuthenticationType, VerifyOtpType } from "../types/Types";
import {
AuthenticationType,
RefreshTokenType,
VerifyOtpType,
} from "../types/Types";
export const authentication = async (params: AuthenticationType) => {
const { data } = await axios.post("/user/authenticate", params);
@@ -10,3 +14,8 @@ export const verifyOtp = async (params: VerifyOtpType) => {
const { data } = await axios.post("/user/login/otp", params);
return data;
};
export const refreshToken = async (params: RefreshTokenType) => {
const { data } = await axios.post(`/auth/refresh`, params);
return data;
};
+4
View File
@@ -13,3 +13,7 @@ export type VerifyOtpType = {
phone: string;
otpCode: string;
};
export type RefreshTokenType = {
token: string;
};
@@ -0,0 +1,132 @@
'use client'
import React from 'react'
import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import * as yup from 'yup'
import Modal from '@/components/Modal'
import Select from '@/components/Select'
import Input from '@/components/Input'
import { Button } from '@/components/ui/button'
interface CancelOrderFormData {
reason: string
count: string
description: string
}
interface CancelReason {
_id: string
title: string
}
interface CancelOrderModalProps {
showModalCancel: boolean
setShowModalCancel: (show: boolean) => void
onSubmit: (data: CancelOrderFormData) => void
isLoading: boolean
cancelReasons?: Array<{ _id: string; title: string }>
}
const cancelOrderSchema = yup.object({
reason: yup.string().required('دلیل لغو سفارش را انتخاب کنید'),
count: yup.string().required('تعداد را وارد کنید'),
description: yup.string().required('توضیحات را وارد کنید')
})
const CancelOrderModal: React.FC<CancelOrderModalProps> = ({
showModalCancel,
setShowModalCancel,
onSubmit,
isLoading,
cancelReasons = []
}) => {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
reset
} = useForm<CancelOrderFormData>({
resolver: yupResolver(cancelOrderSchema)
})
const handleFormSubmit = (data: CancelOrderFormData) => {
onSubmit(data)
reset()
}
return (
<Modal
open={showModalCancel}
close={() => setShowModalCancel(false)}
isHeader
title='درخواست لغو سفارش'
>
<form onSubmit={handleSubmit(handleFormSubmit)} className='mt-6'>
<Select
placeholder='دلیل لغو سفارش'
items={cancelReasons.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>
)
}
export default CancelOrderModal
@@ -0,0 +1,96 @@
'use client'
import React from 'react'
import OrderProductItem from './OrderProductItem'
import { OrderItemDetail } from '../../types/Types'
interface OrderMainContentProps {
orderItems: OrderItemDetail[]
orderId: string
getStatusText: (status: string) => string
onCancelClick: () => void
}
const OrderMainContent: React.FC<OrderMainContentProps> = ({
orderItems,
orderId,
getStatusText,
onCancelClick
}) => {
// محاسبه تعداد مرسوله (length of orderItems)
const shipmentCount = orderItems.length
// محاسبه تعداد اقلام (sum of all itemsCount)
const totalItemsCount = orderItems.reduce((sum, item) => sum + item.itemsCount, 0)
// گرفتن اولین آیتم برای نمایش اطلاعات (در صورت نیاز)
const firstOrderItem = orderItems[0]
// محاسبه مجموع هزینه ارسال و مبلغ مرسوله
const totalShipmentCost = orderItems.reduce((sum, item) => sum + item.totalShipmentCost, 0)
const totalPaymentPrice = orderItems.reduce((sum, item) => sum + item.totalPaymentPrice, 0)
return (
<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>{shipmentCount}</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-[#7F7F7F]'>تعداد اقلام :</div>
<div>
{totalItemsCount}
</div>
</div>
</div>
<div className='text-primary font-semibold'>
{firstOrderItem ? getStatusText(firstOrderItem.status) : ''}
</div>
</div>
<div className='flex items-center gap-2 mt-6'>
<div className='text-[#7F7F7F]'>حداکثر مهلت ارسال :</div>
<div>
{firstOrderItem?.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>
{totalShipmentCost.toLocaleString()} تومان
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-[#7F7F7F]'>مبلغ مرسوله :</div>
<div>
{totalPaymentPrice.toLocaleString()} تومان
</div>
</div>
<div className='flex items-center gap-2'>
<div className='text-[#7F7F7F]'>شناسه سفارش :</div>
<div className="font-mono">
{orderId}
</div>
</div>
</div>
{/* Product Items */}
{orderItems.map((orderItem) =>
orderItem.shipmentItems.map((item) => (
<OrderProductItem
key={item._id}
item={item}
shipperName={orderItem.shipper.name}
onCancelClick={onCancelClick}
/>
))
)}
</div>
)
}
export default OrderMainContent
@@ -0,0 +1,97 @@
'use client'
import React from 'react'
import Image from 'next/image'
import { MessageText, ShieldTick, Truck, CloseCircle } from 'iconsax-react'
import { ShipmentItemDetail } from '../../types/Types'
interface OrderProductItemProps {
item: ShipmentItemDetail
shipperName: string
onCancelClick: () => void
}
const OrderProductItem: React.FC<OrderProductItemProps> = ({ item, shipperName, onCancelClick }) => {
return (
<div 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.meterage?.value ||
item.variant.size?.value ||
'ندارد'}
</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>
{shipperName}
</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={onCancelClick}
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>
)
}
export default OrderProductItem
@@ -0,0 +1,104 @@
'use client'
import React, { useState } from 'react'
import { Button } from '@/components/ui/button'
import TrackCode from './TrackCode'
import { Payment, User, ShipmentAddress } from '../../types/Types'
import * as api from '../../service/Service'
import { toast } from '@/components/Toast'
interface OrderSidebarProps {
orderData: {
payment: Payment
order: {
_id: string
user: User
shipmentAddress: ShipmentAddress
createdAt: string
}
}
orderId: number
}
const OrderSidebar: React.FC<OrderSidebarProps> = ({ orderData, orderId }) => {
const [isLoadingInvoice, setIsLoadingInvoice] = useState(false)
const handleInvoice = async () => {
try {
setIsLoadingInvoice(true)
const blob = await api.getInvoice(orderId.toString())
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `invoice-${orderId}.pdf`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast("فاکتور با موفقیت دانلود شد", "success")
} catch (error) {
toast("مشکلی در دانلود فاکتور رخ داد", "error")
} finally {
setIsLoadingInvoice(false)
}
}
return (
<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">
{orderData.payment.transaction_id}
</div>
</div>
<div className='flex items-center gap-2 mt-4'>
<div className='text-[#7F7F7F]'>گیرنده :</div>
<div>
{orderData.order.user.fullName}
</div>
</div>
<div className='flex items-center gap-2 mt-4'>
<div className='text-[#7F7F7F]'>کدپستی :</div>
<div>
{orderData.order.shipmentAddress.postalCode}
</div>
</div>
<div className='flex items-center gap-2 mt-4'>
<div className='text-[#7F7F7F] whitespace-nowrap'>تاریخ ثبت سفارش :</div>
<div>
{orderData.order.createdAt}
</div>
</div>
<div className='flex items-center gap-2 mt-4'>
<div className='text-[#7F7F7F]'>شماره موبایل :</div>
<div>
{orderData.order.shipmentAddress.phone}
</div>
</div>
<div className='flex items-start gap-2 mt-4'>
<div className='text-[#7F7F7F] whitespace-nowrap'>آدرس :</div>
<div>
{orderData.order.shipmentAddress.address}
</div>
</div>
<TrackCode orderId={orderId} />
<Button
className='mt-3 w-full'
variant="outline"
onClick={handleInvoice}
disabled={isLoadingInvoice}
>
{isLoadingInvoice ? 'در حال دانلود...' : 'مشاهده فاکتور'}
</Button>
</div>
)
}
export default OrderSidebar
+51 -363
View File
@@ -1,115 +1,25 @@
'use client'
import { FC, useState } from 'react'
import { ArrowRight2, ShieldTick, Truck, MessageText, CloseCircle } from 'iconsax-react'
import { ArrowRight2 } 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 Layout from '@/hoc/Layout'
import Image from 'next/image'
import Menu from '../../components/Menu'
import OrderSidebar from './components/OrderSidebar'
import OrderMainContent from './components/OrderMainContent'
import CancelOrderModal from './components/CancelOrderModal'
import { useGetOrderDetail } from '../hooks/useOrdersData'
import * as api from '../service/Service'
import { toast } from '@/components/Toast'
// 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 { data: orderDetail } = useGetOrderDetail(id as string)
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': 'در حال پردازش',
@@ -120,17 +30,24 @@ const OrderDetail: FC = () => {
return statusMap[status] || status
}
const onSubmit = async () => {
setIsLoading(true)
const onSubmit = async (cancelData: { reason: string; count: string; description: string }) => {
try {
setIsLoading(true)
await api.cancelOrder(id as string, {
reason: cancelData.reason,
count: parseInt(cancelData.count),
description: cancelData.description
})
toast("درخواست لغو سفارش با موفقیت ثبت شد", "success")
// Simulate API call
setTimeout(() => {
setIsLoading(false)
setShowModalCancel(false)
reset() // Reset form
alert('درخواست لغو سفارش با موفقیت ثبت شد')
router.push('/profile/orders')
}, 1000)
} catch {
toast("مشکلی در ثبت درخواست لغو سفارش رخ داد", "error")
} finally {
setIsLoading(false)
}
}
return (
@@ -151,269 +68,40 @@ const OrderDetail: FC = () => {
{isLoading ? (
<PageLoading />
) : (
) : orderDetail?.results.order ? (
<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>
<OrderSidebar
orderData={{
payment: orderDetail.results.order.payment,
order: {
_id: orderDetail.results.order._id.toString(),
user: orderDetail.results.order.user,
shipmentAddress: orderDetail.results.order.shipmentAddress,
createdAt: orderDetail.results.order.createdAt
}
}}
orderId={orderDetail.results.order._id}
/>
<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>
<OrderMainContent
orderItems={orderDetail.results.order.orderItems}
orderId={orderDetail.results.order._id.toString()}
getStatusText={getStatusText}
onCancelClick={() => setShowModalCancel(true)}
/>
</div>
) : (
<div className='mt-6 text-center'>در حال بارگذاری...</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>
<CancelOrderModal
showModalCancel={showModalCancel}
setShowModalCancel={setShowModalCancel}
onSubmit={onSubmit}
isLoading={isLoading}
cancelReasons={orderDetail?.results.order.cancelReasons}
/>
</div>
)
}
@@ -0,0 +1,103 @@
'use client'
import React from 'react'
import { Button } from '@/components/ui/button'
import { useRouter } from 'next/navigation'
interface OrderButtonsProps {
status: string
orderId: string
}
const OrderButtons: React.FC<OrderButtonsProps> = ({ status, orderId }) => {
const statusLower = status.toLowerCase();
const router = useRouter();
switch (statusLower) {
case 'pending':
return (
<>
<Button
variant="outline"
className='px-4 sm:px-6 py-2 h-8 sm:h-9 border-[#E0E0E0] text-[#666666] hover:bg-gray-50 text-xs sm:text-sm'
onClick={() => router.push(`/profile/orders/${orderId}`)}
>
جزئیات سفارش
</Button>
<Button className='bg-primary hover:bg-primary/80 px-4 sm:px-6 py-2 h-8 sm:h-9 text-white text-xs sm:text-sm'>
پرداخت
</Button>
</>
);
case 'processing':
case 'shipped':
return (
<>
<Button
variant="outline"
className='px-4 sm:px-6 py-2 h-8 sm:h-9 border-[#E0E0E0] text-[#666666] hover:bg-gray-50 text-xs sm:text-sm'
>
پیگیری سفارش
</Button>
<Button
variant="outline"
className='px-4 sm:px-6 py-2 h-8 sm:h-9 border-[#E0E0E0] text-[#666666] hover:bg-gray-50 text-xs sm:text-sm'
onClick={() => router.push(`/profile/orders/${orderId}`)}
>
جزئیات سفارش
</Button>
</>
);
case 'delivered':
return (
<>
<Button
variant="outline"
className='px-4 sm:px-6 py-2 h-8 sm:h-9 border-[#E0E0E0] text-[#666666] hover:bg-gray-50 text-xs sm:text-sm'
>
امتیازدهی
</Button>
<Button className='bg-primary hover:bg-primary/80 px-4 sm:px-6 py-2 h-8 sm:h-9 text-white text-xs sm:text-sm'>
خرید مجدد
</Button>
</>
);
case 'returned':
return (
<>
<Button
variant="outline"
className='px-4 sm:px-6 py-2 h-8 sm:h-9 border-[#E0E0E0] text-[#666666] hover:bg-gray-50 text-xs sm:text-sm'
onClick={() => router.push(`/profile/orders/${orderId}`)}
>
جزئیات سفارش
</Button>
<Button className='bg-primary hover:bg-primary/80 px-4 sm:px-6 py-2 h-8 sm:h-9 text-white text-xs sm:text-sm'>
خرید مجدد
</Button>
</>
);
case 'cancelled':
return (
<Button className='bg-primary hover:bg-primary/80 px-4 sm:px-6 py-2 h-8 sm:h-9 text-white text-xs sm:text-sm'>
سفارش مجدد
</Button>
);
default:
return (
<Button
variant="outline"
className='px-4 sm:px-6 py-2 h-8 sm:h-9 border-[#E0E0E0] text-[#666666] hover:bg-gray-50 text-xs sm:text-sm'
onClick={() => router.push(`/profile/orders/${orderId}`)}
>
جزئیات سفارش
</Button>
);
}
};
export default OrderButtons
@@ -0,0 +1,53 @@
'use client'
import React from 'react'
import OrderButtons from './OrderButtons'
import OrderProducts from './OrderProducts'
import { Order } from '../types/Types'
import moment from 'moment-jalaali'
interface OrderItemProps {
order: Order
getStatusText: (status: string) => string
}
const OrderItem: React.FC<OrderItemProps> = ({ order, getStatusText }) => {
return (
<div className='bg-white border border-[#E0E0E0] rounded-[8px] p-3 sm:p-4 md:p-5'>
<div className='flex flex-col lg:flex-row gap-4 sm:gap-6'>
<div className='lg:max-w-[234px] w-full'>
<div className=''>
<div className='text-xs sm:text-sm text-[#7F7F7F] mb-1'>
شماره سفارش: <span className='text-black'> {order._id.toString()}</span>
</div>
<div className='text-xs sm:text-sm text-[#7F7F7F] mt-3 sm:mt-4'>
مبلغ: <span className='text-black'> {(order.payment?.totalPrice || 0).toLocaleString('fa-IR')} تومان</span>
</div>
</div>
<div className='mb-4 sm:mb-6 mt-3 sm:mt-4'>
<div className='text-xs sm:text-sm text-[#7F7F7F]'>
وضعیت سفارش : <span className='text-black'>{getStatusText(order.status || 'pending')}</span>
</div>
<div className='text-xs dltr text-right sm:text-sm text-[#7F7F7F] mt-3 sm:mt-4'>
تاریخ ثبت سفارش: <span className='text-black'> {moment(order.payment?.createdAt, 'jYYYY/jMM/jDD HH:mm:ss').format('jYYYY-jMM-jDD')}</span>
</div>
</div>
<div className='flex flex-col sm:flex-row gap-2 sm:gap-3'>
<OrderButtons status={order.status || 'pending'} orderId={order._id.toString()} />
</div>
</div>
<OrderProducts products={order.orderItems.flatMap(item =>
item.shipmentItems.map(shipmentItem => ({
id: shipmentItem.product._id,
image: shipmentItem.product.imagesUrl?.cover || '',
name: shipmentItem.product.title_fa
}))
)} />
</div>
</div>
)
}
export default OrderItem
@@ -0,0 +1,47 @@
'use client'
import React from 'react'
import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/css';
import Image from 'next/image'
interface Product {
id: number
image: string
name: string
}
interface OrderProductsProps {
products: Product[]
}
const OrderProducts: React.FC<OrderProductsProps> = ({ products }) => {
return (
<div className='flex-1 flex gap-3 sm:gap-4 lg:border-r lg:border-border lg:pr-5 overflow-hidden min-h-[200px] sm:min-h-[225px]'>
<Swiper
spaceBetween={12}
slidesPerView={'auto'}
className='h-full w-full'
grabCursor={true}
>
{products.map((product) => (
<SwiperSlide key={product.id} className='!w-[180px] sm:!w-[200px] lg:!w-[234px]'>
<div className='flex w-[180px] sm:w-[200px] lg:w-[234px] h-[180px] sm:h-[200px] lg:h-[234px] flex-col p-4 sm:p-5 lg:p-7 items-center rounded-xl gap-2 border border-border'>
<Image
src={product.image}
className='max-w-[100px] sm:max-w-[120px] lg:max-w-[140px] max-h-[100px] sm:max-h-[120px] lg:max-h-[140px] object-center'
alt={product.name}
width={100}
height={100}
/>
<div className='max-w-[160px] truncate h-full sm:max-w-[180px] lg:max-w-[200px] text-center text-xs sm:text-sm font-light mt-2 sm:mt-4'>
{product.name}
</div>
</div>
</SwiperSlide>
))}
</Swiper>
</div>
)
}
export default OrderProducts
@@ -0,0 +1,55 @@
'use client'
import React from 'react'
interface Tab {
key: 'Processing' | 'Delivered' | 'Cancelled'
label: string
count: number
}
interface OrderTabsProps {
activeTab: 'Processing' | 'Delivered' | 'Cancelled'
setActiveTab: (tab: 'Processing' | 'Delivered' | 'Cancelled') => void
tabCounts: {
current: number
delivered: number
returned: number
cancelled: number
}
}
const OrderTabs: React.FC<OrderTabsProps> = ({ activeTab, setActiveTab, tabCounts }) => {
const tabs: Tab[] = [
{ key: 'Processing', label: 'جاری', count: tabCounts.current },
{ key: 'Delivered', label: 'تحویل شده', count: tabCounts.delivered },
// { key: 'Returned', label: 'مرجوع شده', count: tabCounts.returned },
{ key: 'Cancelled', label: 'لغو شده', count: tabCounts.cancelled }
]
return (
<div className='mt-6 sm:mt-8 md:mt-10 lg:mt-14 border-b border-gray-200'>
<div className='flex gap-2 sm:gap-4 lg:gap-8 overflow-x-auto'>
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`pb-3 sm:pb-4 px-1 sm:px-2 border-b-2 transition-colors whitespace-nowrap ${activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-gray-600 hover:text-gray-800'
}`}
>
<span className='ml-1 sm:ml-2 text-xs sm:text-sm'>{tab.label}</span>
<span className={`inline-flex items-center justify-center min-w-[16px] sm:min-w-[20px] h-4 sm:h-5 px-1 sm:px-1.5 text-xs rounded-full ${activeTab === tab.key
? 'bg-primary text-white'
: 'bg-gray-200 text-gray-600'
}`}>
{tab.count}
</span>
</button>
))}
</div>
</div>
)
}
export default OrderTabs
@@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import * as api from "../service/Service";
import { OrdersResponse, OrderDetailResponse } from "../types/Types";
export const useGetOrders = (activeTab: string) => {
return useQuery<OrdersResponse>({
queryKey: ["orders", activeTab],
queryFn: () => api.getOrders(activeTab),
enabled: !!activeTab,
});
};
export const useGetOrderDetail = (orderId: string) => {
return useQuery<OrderDetailResponse>({
queryKey: ["orderDetail", orderId],
queryFn: () => api.getOrderDetail(orderId),
enabled: !!orderId,
});
};
+51 -118
View File
@@ -1,139 +1,72 @@
'use client'
import Layout from '@/hoc/Layout'
import { NextPage } from 'next'
import React, { useState } from 'react'
import React, { useState, useMemo } from 'react'
import Menu from '../components/Menu'
import { Button } from '@/components/ui/button'
import Image from 'next/image'
import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/css';
import { useGetOrders } from './hooks/useOrdersData'
import { Order } from './types/Types'
import OrderTabs from './components/OrderTabs'
import OrderItem from './components/OrderItem'
const Orders: NextPage = () => {
const [activeTab, setActiveTab] = useState<'current' | 'delivered' | 'returned' | 'cancelled'>('current')
const mockOrders = [
{
id: '656151',
amount: '18,500,000',
status: 'در انتظار پرداخت',
date: '1401 / 10 / 12',
products: [
{ id: 1, image: 'https://picsum.photos/80/80?random=1', name: 'گوشی موبایل سامسونگ مدل Galaxy S24 Ultra' },
{ id: 2, image: 'https://picsum.photos/80/80?random=2', name: 'لحاف کرسی ستین مدل ساده' },
{ id: 3, image: 'https://picsum.photos/80/80?random=3', name: 'روغنی ستین کد T-2112 طرح گلریز' },
{ id: 4, image: 'https://picsum.photos/80/80?random=4', name: 'شارژر دیواری 65 وات ماگسیونگ' },
{ id: 5, image: 'https://picsum.photos/80/80?random=5', name: 'گوشی موبایل سامسونگ مدل Galaxy S24 Ultra' },
{ id: 6, image: 'https://picsum.photos/80/80?random=6', name: 'لحاف کرسی ستین مدل ساده' },
{ id: 7, image: 'https://picsum.photos/80/80?random=7', name: 'روغنی ستین کد T-2112 طرح گلریز' },
{ id: 8, image: 'https://picsum.photos/80/80?random=8', name: 'شارژر دیواری 65 وات ماگسیونگ' },
]
},
{
id: '846511',
amount: '254,500,000',
status: 'وضعیت سفارش: در حال ارسال',
date: '1401 / 8 / 10',
products: [
{ id: 1, image: 'https://picsum.photos/80/80?random=5', name: 'گوشی موبایل سامسونگ مدل Galaxy S24 Ultra' },
{ id: 2, image: 'https://picsum.photos/80/80?random=6', name: 'لحاف کرسی ستین مدل ساده' },
{ id: 3, image: 'https://picsum.photos/80/80?random=7', name: 'روغنی ستین کد T-2112 طرح گلریز' },
{ id: 4, image: 'https://picsum.photos/80/80?random=8', name: 'شارژر دیواری 65 وات ماگسیونگ' }
]
}
]
const [activeTab, setActiveTab] = useState<'Processing' | 'Delivered' | 'Cancelled'>('Processing')
const { data: ordersData } = useGetOrders(activeTab);
// محاسبه تعداد سفارشات در هر تب از داده‌های اصلی
const tabCounts = useMemo(() => {
if (!ordersData?.results?.orders) return { current: 0, delivered: 0, returned: 0, cancelled: 0 };
return ordersData.results.orders.reduce((acc, order: Order) => {
const status = (order.status || 'pending').toLowerCase();
if (['pending', 'processing', 'shipped'].includes(status)) {
acc.current++;
} else if (status === 'delivered') {
acc.delivered++;
} else if (status === 'returned') {
acc.returned++;
} else if (status === 'cancelled') {
acc.cancelled++;
}
return acc;
}, { current: 0, delivered: 0, returned: 0, cancelled: 0 });
}, [ordersData]);
// تابع تبدیل وضعیت به فارسی
const getStatusText = (status: string) => {
const statusMap: { [key: string]: string } = {
'pending': 'در انتظار پرداخت',
'processing': 'در حال پردازش',
'shipped': 'در حال ارسال',
'delivered': 'تحویل شده',
'returned': 'مرجوع شده',
'cancelled': 'لغو شده'
};
return statusMap[status.toLowerCase()] || status;
};
const tabs = [
{ key: 'current' as const, label: 'جاری', count: 10 },
{ key: 'delivered' as const, label: 'تحویل شده', count: 3 },
{ key: 'returned' as const, label: 'مرجوع شده', count: 3 },
{ key: 'cancelled' as const, label: 'لغو شده', count: 3 }
]
return (
<div className='p-3 sm:p-4 md:p-6'>
<Menu pageActive='orders' />
{/* Tabs */}
<div className='mt-6 sm:mt-8 md:mt-10 lg:mt-14 border-b border-gray-200'>
<div className='flex gap-2 sm:gap-4 lg:gap-8 overflow-x-auto'>
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`pb-3 sm:pb-4 px-1 sm:px-2 border-b-2 transition-colors whitespace-nowrap ${activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-gray-600 hover:text-gray-800'
}`}
>
<span className='ml-1 sm:ml-2 text-xs sm:text-sm'>{tab.label}</span>
<span className={`inline-flex items-center justify-center min-w-[16px] sm:min-w-[20px] h-4 sm:h-5 px-1 sm:px-1.5 text-xs rounded-full ${activeTab === tab.key
? 'bg-primary text-white'
: 'bg-gray-200 text-gray-600'
}`}>
{tab.count}
</span>
</button>
))}
</div>
</div>
<OrderTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
tabCounts={tabCounts}
/>
{/* Orders List */}
<div className='mt-4 sm:mt-6 space-y-3 sm:space-y-4'>
{mockOrders.map((order) => (
<div key={order.id} className='bg-white border border-[#E0E0E0] rounded-[8px] p-3 sm:p-4 md:p-5'>
<div className='flex flex-col lg:flex-row gap-4 sm:gap-6'>
<div className='lg:max-w-[234px] w-full'>
<div className=''>
<div className='text-xs sm:text-sm text-[#7F7F7F] mb-1'>
شماره سفارش: <span className='text-black'> {order.id}</span>
</div>
<div className='text-xs sm:text-sm text-[#7F7F7F] mt-3 sm:mt-4'>
مبلغ: <span className='text-black'> {order.amount} تومان</span>
</div>
</div>
<div className='mb-4 sm:mb-6 mt-3 sm:mt-4'>
<div className='text-xs sm:text-sm text-[#7F7F7F]'>
وضعیت سفارش : <span className='text-black'>{order.status}</span>
</div>
<div className='text-xs sm:text-sm text-[#7F7F7F] mt-3 sm:mt-4'>
تاریخ ثبت سفارش: <span className='text-black'> {order.date}</span>
</div>
</div>
<div className='flex flex-col sm:flex-row gap-2 sm:gap-3'>
<Button
variant="outline"
className='px-4 sm:px-6 py-2 h-8 sm:h-9 border-[#E0E0E0] text-[#666666] hover:bg-gray-50 text-xs sm:text-sm'
>
جزئیات سفارش
</Button>
<Button className='bg-primary hover:bg-primary/80 px-4 sm:px-6 py-2 h-8 sm:h-9 text-white text-xs sm:text-sm'>
پرداخت
</Button>
</div>
</div>
<div className='flex-1 flex gap-3 sm:gap-4 lg:border-r lg:border-border lg:pr-5 overflow-hidden min-h-[200px] sm:min-h-[225px]'>
<Swiper
spaceBetween={12}
slidesPerView={'auto'}
className='h-full w-full'
grabCursor={true}
>
{order.products.map((product) => (
<SwiperSlide key={product.id} className='!w-[180px] sm:!w-[200px] lg:!w-[234px]'>
<div className='flex w-[180px] sm:w-[200px] lg:w-[234px] h-[180px] sm:h-[200px] lg:h-[224px] flex-col p-4 sm:p-5 lg:p-7 items-center rounded-xl gap-2 border border-border'>
<Image src={product.image} className='max-w-[100px] sm:max-w-[120px] lg:max-w-[140px] max-h-[100px] sm:max-h-[120px] lg:max-h-[140px] object-center' alt={product.name} width={100} height={100} />
<div className='max-w-[160px] sm:max-w-[180px] lg:max-w-[200px] text-center text-xs sm:text-sm font-light mt-2 sm:mt-4'>{product.name}</div>
</div>
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</div>
{ordersData?.results?.orders.map((order) => (
<OrderItem
key={order._id.toString()}
order={order}
getStatusText={getStatusText}
/>
))}
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
import axios from "@/config/axios";
import { OrdersResponse, OrderDetailResponse } from "../types/Types";
export const getOrders = async (activeTab: string): Promise<OrdersResponse> => {
const { data } = await axios.get(`/user/profile/orders?status=${activeTab}`);
return data;
};
export const getOrderDetail = async (
orderId: string
): Promise<OrderDetailResponse> => {
const { data } = await axios.get(`/order/${orderId}/user`);
return data;
};
export const cancelOrder = async (
orderId: string,
cancelData: {
reason: string;
count: number;
description: string;
}
) => {
const { data } = await axios.post(`/order/${orderId}/cancel`, cancelData);
return data;
};
export const getInvoice = async (orderId: string) => {
const { data } = await axios.get(`/order/${orderId}/invoice`, {
responseType: "blob",
});
return data;
};
+293
View File
@@ -0,0 +1,293 @@
export interface ProductImage {
cover: string;
list: string[];
}
export interface ProductSpecification {
title: string;
values: string[];
}
export interface Brand {
_id: string;
}
export interface Category {
_id: string;
}
export interface Variant {
_id: string;
}
export interface Product {
_id: number;
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: ProductImage;
isFake: string;
voice: string | null;
specifications: ProductSpecification[];
brand: Brand;
category: Category;
variants: Variant[];
}
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;
}
export interface ProductVariant {
_id: string;
market_status: string;
price: Price;
stock: number;
postingTime: number;
isFreeShip: boolean;
isWholeSale: boolean;
shop: Shop;
warranty: number;
meterage?: number;
size?: number;
}
export interface OrderItem {
product: Product;
variant: ProductVariant;
quantity: number;
cancelled_quantity: number;
returned_quantity: number;
selling_price: number;
retail_price: number;
discount_percent: number;
shipmentCost: number;
}
export interface PaymentMethod {
_id: string;
title_fa: string;
title_en: string;
description: string;
provider: string;
isActive: boolean;
type: string;
}
export interface PriceDetails {
shipping_cost: number;
process_cost: number;
total_retail_price: number;
total_payable_price: number;
total_discount: number;
coupon_discount: number;
}
export interface Payment {
_id: string;
transaction_id: string | null;
payment_method: PaymentMethod;
priceDetails: PriceDetails;
totalPrice: number;
paymentStatus: string;
createdAt: string;
}
export interface ShipmentAddress {
address: string;
city: string;
province: string;
plaque: string;
postalCode: string;
phone: string;
}
export interface MappedOrder {
orderId: number;
orderItems: OrderItem[];
payment: Payment;
shipmentAddress: ShipmentAddress;
orderStatus: string;
createdAt: string;
}
export interface ShopDetails {
_id: string;
shopName: string;
shopCode: number;
owner: string;
shopDescription: string;
telephoneNumber: string;
isChatActive: boolean;
shopPostalCode: string;
logo: string;
}
export interface ShipmentItem {
product: Product;
variant: ProductVariant;
quantity: number;
cancelled_quantity: number;
returned_quantity: number;
selling_price: number;
retail_price: number;
discount_percent: number;
shipmentCost: number;
}
export interface OrderItemDetails {
_id: string;
shop: ShopDetails;
shipmentItems: ShipmentItem[];
}
export interface Shipper {
_id: number;
name: string;
description: string;
deliveryTime: number;
deliveryType: string;
}
export interface Order {
_id: number;
payment: Payment;
orderItems: OrderItemDetails[];
shipper: Shipper;
trackCode: string | null;
itemsCount: number;
totalPaymentPrice: number;
totalRetailPrice: number;
totalSellingPrice: number;
totalShipmentCost: number;
postingDate: string | null;
postingReceipt: string | null;
status: string;
}
export interface User {
_id: string;
fullName: string;
phoneNumber: string;
dateOfBirth: string | null;
address: {
_id: string;
};
nationalCode: string | null;
homeNumber: string | null;
}
export interface Warranty {
_id: number;
duration: string;
logoUrl: string;
name: string;
}
export interface Meterage {
_id: number;
value: string;
}
export interface Size {
_id: number;
value: string;
}
export interface ProductVariantDetail {
_id: string;
market_status: string;
price: Price;
stock: number;
postingTime: number;
isFreeShip: boolean;
isWholeSale: boolean;
shop: ShopDetails;
warranty: Warranty;
meterage?: Meterage;
size?: Size;
}
export interface ShipmentItemDetail {
_id: string;
product: Product;
variant: ProductVariantDetail;
quantity: number;
cancelled_quantity: number;
returned_quantity: number;
selling_price: number;
retail_price: number;
discount_percent: number;
shipmentCost: number;
}
export interface OrderItemDetail {
_id: string;
order_id: number;
shop: ShopDetails;
shipmentItems: ShipmentItemDetail[];
shipper: Shipper;
trackCode: string | null;
itemsCount: number;
totalPaymentPrice: number;
totalRetailPrice: number;
totalSellingPrice: number;
totalShipmentCost: number;
postingDate: string | null;
postingReceipt: string | null;
status: string;
}
export interface CancelReason {
_id: string;
title: string;
}
export interface OrderDetail {
_id: number;
user: User;
payment: Payment;
orderItems: OrderItemDetail[];
shipmentAddress: ShipmentAddress;
orderStatus: string;
createdAt: string;
cancelReasons?: CancelReason[];
}
export interface OrderDetailResponse {
status: number;
success: boolean;
results: {
order: OrderDetail;
};
}
export interface OrdersResponse {
status: number;
success: boolean;
results: {
mappedOrders: MappedOrder[];
orders: Order[];
shipmentAddress: ShipmentAddress;
orderStatus: string;
createdAt: string;
};
}
+72 -8
View File
@@ -1,16 +1,83 @@
import axios from "axios";
import { getToken } from "./func";
import {
getToken,
getRefreshToken,
setToken,
setRefreshToken,
removeToken,
removeRefreshToken,
} from "./func";
import { refreshToken } from "../app/auth/service/Service";
import { BASE_URL } from "./const";
// import { Pages } from "./Pages";
declare global {
interface Window {
isRefreshingToken?: boolean;
}
}
const axiosInstance = axios.create({
// baseURL: process.env.NEXT_PUBLIC_BASE_URL,
baseURL: BASE_URL,
});
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
async (error) => {
const originalRequest = error.config;
if (error?.response?.status === 401 && !originalRequest._retry) {
if (window.isRefreshingToken) {
// Wait for the refresh to complete
await new Promise((resolve) => {
const checkComplete = setInterval(() => {
if (!window.isRefreshingToken) {
clearInterval(checkComplete);
resolve(true);
}
}, 100);
});
return axiosInstance(originalRequest);
}
originalRequest._retry = true;
window.isRefreshingToken = true;
try {
const refreshTokenValue = await getRefreshToken();
if (!refreshTokenValue) {
await removeRefreshToken();
await removeToken();
window.location.href = `/auth`;
return;
}
const { data } = await refreshToken({
token: refreshTokenValue,
});
if (data?.accessToken?.token) {
await setToken(data?.accessToken?.token);
if (data.refreshToken?.token) {
await setRefreshToken(data.refreshToken?.token);
}
originalRequest.headers.Authorization = `Bearer ${data.accessToken.token}`;
return axiosInstance(originalRequest);
} else {
await removeRefreshToken();
await removeToken();
window.location.href = `/auth`;
return;
}
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
await removeToken();
await removeRefreshToken();
window.location.href = `/auth`;
return Promise.reject(refreshError);
} finally {
window.isRefreshingToken = false;
}
}
return Promise.reject(error);
}
);
@@ -18,10 +85,7 @@ axiosInstance.interceptors.response.use(
axiosInstance.interceptors.request.use(async function (config) {
const tokenValue = await getToken();
const token = tokenValue ? tokenValue : "";
if (token) {
config.headers.Authorization = "Bearer " + token;
}
config.headers.Authorization = "Bearer " + token;
return config;
});