return order + success page return order + ...

This commit is contained in:
hamid zarghami
2025-10-08 09:08:01 +03:30
parent 1b7e9eb168
commit 700e757128
17 changed files with 1295 additions and 24 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,319 @@
'use client'
import { useState, useEffect } from 'react'
import { Truck, Sort, Flash } from 'iconsax-react'
import { ShopShippingType, ShipmentInfoType } from '../types/Types'
interface ShippingMethodSelectorProps {
shippingData: ShopShippingType[]
selectedShipments: Record<string, number>
onShipmentSelect: (shopId: string, shipperId: number) => void
onClearSelections?: () => void
}
const ShippingMethodSelector = ({
shippingData,
selectedShipments,
onShipmentSelect,
onClearSelections
}: ShippingMethodSelectorProps) => {
const selectedCount = Object.keys(selectedShipments).length
const totalShops = shippingData?.length || 0
// State برای مرتب‌سازی
const [sortBy, setSortBy] = useState<'price' | 'time' | 'default'>('default')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
// State برای collapse/expand محصولات
const [expandedShops, setExpandedShops] = useState<Set<string>>(new Set())
// تابع مرتب‌سازی روش‌های ارسال
const sortShippers = (shippers: any[]) => {
if (sortBy === 'default') return shippers
return [...shippers].sort((a, b) => {
let aValue, bValue
if (sortBy === 'price') {
aValue = a.totalShippingCost
bValue = b.totalShippingCost
} else if (sortBy === 'time') {
aValue = a.shippingDaysRange?.[0] || 0
bValue = b.shippingDaysRange?.[0] || 0
}
if (sortOrder === 'asc') {
return aValue - bValue
} else {
return bValue - aValue
}
})
}
// هندلر تغییر مرتب‌سازی
const handleSortChange = (newSortBy: 'price' | 'time' | 'default') => {
if (sortBy === newSortBy) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(newSortBy)
setSortOrder('asc')
}
}
// هندلر toggle محصولات فروشگاه
const toggleShopExpansion = (shopId: string) => {
const newExpanded = new Set(expandedShops)
if (newExpanded.has(shopId)) {
newExpanded.delete(shopId)
} else {
newExpanded.add(shopId)
}
setExpandedShops(newExpanded)
}
// هندلر انتخاب سریع بهترین روش ارسال برای همه فروشگاه‌ها
const handleQuickSelect = (criteria: 'cheapest' | 'fastest') => {
const newSelections: Record<string, number> = {}
shippingData?.forEach((shop) => {
if (shop.shippers && shop.shippers.length > 0) {
let bestShipper = shop.shippers[0]
shop.shippers.forEach((shipper) => {
if (criteria === 'cheapest') {
if (shipper.totalShippingCost < bestShipper.totalShippingCost) {
bestShipper = shipper
}
} else if (criteria === 'fastest') {
const shipperTime = shipper.shippingDaysRange?.[0] || 999
const bestTime = bestShipper.shippingDaysRange?.[0] || 999
if (shipperTime < bestTime) {
bestShipper = shipper
}
}
})
newSelections[shop.shopId] = bestShipper.shipperId
}
})
// اعمال انتخاب‌ها
Object.entries(newSelections).forEach(([shopId, shipperId]) => {
onShipmentSelect(shopId, shipperId)
})
}
return (
<div className="mt-5 border border-border rounded-2xl p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Truck size={20} color="#333333" />
<div>
<h3 className="text-lg font-semibold">روش ارسال</h3>
<p className="text-sm text-gray-600">
{selectedCount} از {totalShops} فروشگاه انتخاب شده
</p>
{selectedCount > 0 && (
<p className="text-sm text-gray-600">
مجموع ارسال: {Object.entries(selectedShipments).reduce((total, [shopId, shipperId]) => {
const shop = shippingData?.find(s => s.shopId === shopId)
const shipper = shop?.shippers?.find(s => s.shipperId === shipperId)
return total + (shipper?.totalShippingCost || 0)
}, 0).toLocaleString('fa-IR')} تومان
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{/* انتخاب سریع */}
<div className="flex items-center gap-1">
<Flash size={16} color="#666" />
<div className="flex gap-1">
<button
onClick={() => handleQuickSelect('cheapest')}
className="text-xs bg-green-100 hover:bg-green-200 text-green-800 px-2 py-1 rounded transition-colors"
title="انتخاب ارزان‌ترین روش برای همه فروشگاه‌ها"
>
ارزانترین
</button>
<button
onClick={() => handleQuickSelect('fastest')}
className="text-xs bg-blue-100 hover:bg-blue-200 text-blue-800 px-2 py-1 rounded transition-colors"
title="انتخاب سریع‌ترین روش برای همه فروشگاه‌ها"
>
سریعترین
</button>
</div>
</div>
{/* کنترل‌های مرتب‌سازی */}
<div className="flex items-center gap-1">
<Sort size={16} color="#666" />
<select
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [newSortBy, newSortOrder] = e.target.value.split('-') as ['price' | 'time' | 'default', 'asc' | 'desc']
setSortBy(newSortBy)
setSortOrder(newSortOrder)
}}
className="text-sm border border-gray-300 rounded px-2 py-1 bg-white"
>
<option value="default-asc">پیشفرض</option>
<option value="price-asc">ارزانترین</option>
<option value="price-desc">گرانترین</option>
<option value="time-asc">سریعترین</option>
<option value="time-desc">آهستهترین</option>
</select>
</div>
{onClearSelections && Object.keys(selectedShipments).length > 0 && (
<button
onClick={onClearSelections}
className="text-sm text-red-600 hover:text-red-800 font-medium px-3 py-1 rounded-md hover:bg-red-50 transition-colors"
>
پاک کردن انتخابها
</button>
)}
</div>
</div>
<div className="space-y-6">
{shippingData?.map((shop) => (
<div key={shop.shopId} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h4 className="font-medium text-gray-900 text-lg">{shop.shopName}</h4>
<p className="text-sm text-gray-600">
{shop.items?.length || 0} محصول
</p>
</div>
{selectedShipments[shop.shopId] && (
<div className="flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
انتخاب شد
</div>
)}
</div>
{/* نمایش محصولات فروشگاه */}
<div className="mb-6">
<button
onClick={() => toggleShopExpansion(shop.shopId)}
className="flex items-center justify-between w-full text-sm font-medium text-gray-700 mb-3 hover:text-gray-900 transition-colors"
>
<span>محصولات این فروشگاه ({shop.items?.length || 0})</span>
<svg
className={`w-4 h-4 transition-transform ${expandedShops.has(shop.shopId) ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedShops.has(shop.shopId) && (
<div className="space-y-3 max-h-48 overflow-y-auto">
{shop.items?.map((item, index) => (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-100">
<img
src={item.product.imagesUrl.cover}
alt={item.product.title_fa}
className="w-12 h-12 object-cover rounded-lg flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate mb-1">
{item.product.title_fa}
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-gray-600">
تعداد: {item.quantity}
</div>
<div className="text-sm font-semibold text-gray-900">
{item.variant.price.selling_price.toLocaleString('fa-IR')} تومان
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* روش‌های ارسال */}
<div>
<h5 className="text-sm font-medium text-gray-700 mb-3">روش ارسال:</h5>
<div className="space-y-2">
{(() => {
const shippers = sortShippers(shop.shippers || [])
const cheapestPrice = Math.min(...shippers.map(s => s.totalShippingCost))
return shippers.map((shipper) => {
const priceDifference = shipper.totalShippingCost - cheapestPrice
const isCheapest = priceDifference === 0
return (
<label
key={shipper.shipperId}
className={`flex items-center justify-between p-3 border rounded-lg cursor-pointer transition-colors ${selectedShipments[shop.shopId] === shipper.shipperId
? 'border-primary bg-primary/5'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3">
<input
type="radio"
name={`shipment-${shop.shopId}`}
value={shipper.shipperId}
checked={selectedShipments[shop.shopId] === shipper.shipperId}
onChange={() => onShipmentSelect(shop.shopId, shipper.shipperId)}
className="text-primary focus:ring-primary"
/>
<div>
<div className="font-medium text-gray-900 flex items-center gap-2">
{shipper.shipperName}
{isCheapest && (
<span className="text-xs bg-green-100 text-green-800 px-1.5 py-0.5 rounded">
ارزانترین
</span>
)}
</div>
<div className="text-sm text-gray-600">
زمان ارسال: {shipper.shippingDaysRange?.join(' تا ') || 'نامشخص'}
</div>
</div>
</div>
<div className="text-left">
<div className="font-medium text-gray-900 relative group">
{shipper.totalShippingCost.toLocaleString('fa-IR')} تومان
{priceDifference > 0 && (
<div className="text-xs text-orange-600 mt-1">
+{priceDifference.toLocaleString('fa-IR')} تومان
</div>
)}
<div className="absolute bottom-full right-0 mb-2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap z-10">
هزینه ارسال کل این فروشگاه
</div>
</div>
</div>
</label>
)
})
})()}
</div>
{(!shop.shippers || shop.shippers.length === 0) && (
<div className="text-center py-4 text-gray-500">
روش ارسالی برای این فروشگاه موجود نیست
</div>
)}
</div>
</div>
))}
</div>
</div>
)
}
export default ShippingMethodSelector
+13
View File
@@ -97,3 +97,16 @@ export const useBulkAddCart = () => {
mutationFn: api.bulkAddCart,
});
};
export const useGetShipmentCart = () => {
return useQuery({
queryKey: ["shipment-cart"],
queryFn: api.getShipmentCart,
});
};
export const useSaveShipmentCart = () => {
return useMutation({
mutationFn: api.saveShipmentCart,
});
};
+8 -2
View File
@@ -48,9 +48,15 @@ const CartPayment: NextPage = () => {
)
}
// محاسبه هزینه ارسال
// بارگذاری انتخاب‌های ارسال از localStorage
const savedShipments = typeof window !== 'undefined' ? localStorage.getItem('selectedShipments') : null
const selectedShipments = savedShipments ? JSON.parse(savedShipments) : {}
// محاسبه هزینه ارسال بر اساس انتخاب‌های کاربر
const totalShippingCost = shippingData?.results?.shipping?.reduce((total, shop) => {
return total + (shop.shippers?.[0]?.totalShippingCost || 0)
const selectedShipperId = selectedShipments[shop.shopId]
const selectedShipper = shop.shippers?.find(shipper => shipper.shipperId === selectedShipperId)
return total + (selectedShipper?.totalShippingCost || shop.shippers?.[0]?.totalShippingCost || 0)
}, 0) || 0
// محاسبه قیمت نهایی
+12
View File
@@ -13,6 +13,8 @@ import {
PaymentStatusType,
PaymentStatusResponseType,
BulkAddCartType,
ShipmentCartResponseType,
ShipmentSaveType,
} from "../types/Types";
export const getCart = async (): Promise<CartResponseType> => {
@@ -75,3 +77,13 @@ export const bulkAddCart = async (params: BulkAddCartType) => {
const { data } = await axios.post("/cart/bulk-add", params);
return data;
};
export const getShipmentCart = async (): Promise<ShipmentCartResponseType> => {
const { data } = await axios.get<ShipmentCartResponseType>("/shipment/cart");
return data;
};
export const saveShipmentCart = async (params: ShipmentSaveType) => {
const { data } = await axios.post("/shipment/save", params);
return data;
};
+102 -8
View File
@@ -2,19 +2,96 @@
import CartSummary from '@/components/CartSummary'
import Layout from '@/hoc/Layout'
import { NextPage } from 'next'
import CartItem from '../components/CartItem'
// import CartItem from '../components/CartItem'
import TitleBack from '../components/TitleBack'
import ShippingMethodSelector from '../components/ShippingMethodSelector'
import { Location } from 'iconsax-react'
import { useGetCart, useShippingCost } from '../hooks/useCartData'
import { CartItemType } from '../types/Types'
import { useGetCart, useSaveShipmentCart, useShippingCost } from '../hooks/useCartData'
// import { CartItemType } from '../types/Types'
import { useGetProfile } from '@/app/profile/hooks/useProfileData'
import Link from 'next/link'
import { useState, useEffect } from 'react'
const CartShipping: NextPage = () => {
const { data: cartData, isLoading: cartLoading, error: cartError } = useGetCart()
const { data: shippingData, isLoading: shippingLoading, error: shippingError } = useShippingCost()
const { data: profileData, isLoading: profileLoading, error: profileError } = useGetProfile()
const { mutate: saveShipmentCart } = useSaveShipmentCart()
// State برای ذخیره انتخاب‌های روش ارسال
const [selectedShipments, setSelectedShipments] = useState<Record<string, number>>({})
const [isSavingShipment, setIsSavingShipment] = useState(false)
// بارگذاری انتخاب‌های قبلی از localStorage
useEffect(() => {
const savedShipments = localStorage.getItem('selectedShipments')
if (savedShipments) {
try {
setSelectedShipments(JSON.parse(savedShipments))
} catch (error) {
console.error('Error parsing saved shipments:', error)
}
}
}, [])
// ذخیره انتخاب‌ها در localStorage
useEffect(() => {
if (Object.keys(selectedShipments).length > 0) {
localStorage.setItem('selectedShipments', JSON.stringify(selectedShipments))
}
}, [selectedShipments])
// هندلر انتخاب روش ارسال
const handleShipmentSelect = (shopId: string, shipperId: number) => {
setSelectedShipments(prev => ({
...prev,
[shopId]: shipperId
}))
}
// چک کردن اینکه آیا همه فروشگاه‌ها روش ارسال انتخاب کرده‌اند
const allShipmentsSelected = shippingData?.results?.shipping?.every(shop =>
selectedShipments[shop.shopId] !== undefined
) || false
// هندلر پاک کردن همه انتخاب‌ها
const handleClearSelections = () => {
setSelectedShipments({})
localStorage.removeItem('selectedShipments')
}
// هندلر ذخیره انتخاب‌های ارسال و انتقال به صفحه پرداخت
const handleProceedToPayment = async () => {
if (!allShipmentsSelected) return
setIsSavingShipment(true)
try {
// تبدیل selectedShipments به فرمت مورد نیاز API
const shipmentsInfo = Object.entries(selectedShipments).map(([shopId, shipperId]) => ({
shopId,
shipmentId: shipperId
}))
// ذخیره انتخاب‌ها در API
await new Promise<void>((resolve, reject) => {
saveShipmentCart({ shipmentsInfo }, {
onSuccess: () => resolve(),
onError: (error) => reject(error)
})
})
// انتقال به صفحه پرداخت
window.location.href = '/cart/payment'
} catch (error) {
console.error('Error saving shipment cart:', error)
// در صورت خطا همچنان به صفحه پرداخت برویم
window.location.href = '/cart/payment'
} finally {
setIsSavingShipment(false)
}
}
if (cartLoading || shippingLoading || profileLoading) {
return (
@@ -50,9 +127,11 @@ const CartShipping: NextPage = () => {
)
}
// محاسبه هزینه ارسال
// محاسبه هزینه ارسال بر اساس انتخاب‌های کاربر
const totalShippingCost = shippingData?.results?.shipping?.reduce((total, shop) => {
return total + (shop.shippers?.[0]?.totalShippingCost || 0)
const selectedShipperId = selectedShipments[shop.shopId]
const selectedShipper = shop.shippers?.find(shipper => shipper.shipperId === selectedShipperId)
return total + (selectedShipper?.totalShippingCost || shop.shippers?.[0]?.totalShippingCost || 0)
}, 0) || 0
// محاسبه قیمت نهایی
@@ -82,7 +161,15 @@ const CartShipping: NextPage = () => {
</div>
<Link href={'/profile'} className='text-primary text-sm self-end sm:self-auto'>ویرایش</Link>
</div>
<div className="flex-1 mt-5 border border-border p-4 sm:p-5 rounded-2xl">
<ShippingMethodSelector
shippingData={shippingData?.results?.shipping || []}
selectedShipments={selectedShipments}
onShipmentSelect={handleShipmentSelect}
onClearSelections={handleClearSelections}
/>
{/* <div className="flex-1 mt-5 border border-border p-4 sm:p-5 rounded-2xl">
{items.map((item: CartItemType, index: number) => {
if (!item || !item.product || !item.variant) {
return null
@@ -95,7 +182,7 @@ const CartShipping: NextPage = () => {
/>
)
})}
</div>
</div> */}
</div>
<div className="w-full lg:w-[360px] lg:flex-shrink-0">
<CartSummary
@@ -104,10 +191,17 @@ const CartShipping: NextPage = () => {
discount={cart?.total_discount || 0}
shippingCost={totalShippingCost}
total={finalTotal}
confirmHref="/cart/payment"
onConfirm={handleProceedToPayment}
confirmLabel="پرداخت"
className="w-full"
confirmDisabled={!allShipmentsSelected}
isLoading={isSavingShipment}
/>
{!allShipmentsSelected && (
<div className="mt-2 text-sm text-red-600 text-center">
لطفا برای همه فروشگاهها روش ارسال انتخاب کنید
</div>
)}
</div>
</div>
</div>
+20
View File
@@ -413,3 +413,23 @@ export type PaymentStatusResponseType = {
};
};
};
// Shipment Save Types
export type ShipmentInfoType = {
shopId: string;
shipmentId: number;
};
export type ShipmentSaveType = {
shipmentsInfo: ShipmentInfoType[];
};
// Shipment Cart Response Type
export type ShipmentCartResponseType = {
status: number;
success: boolean;
results: {
cart: CartType;
recommended: RecommendedProductType[];
};
};
+11 -3
View File
@@ -1,6 +1,6 @@
'use client'
import { FC, useState } from 'react'
import { ArrowRight2 } from 'iconsax-react'
import { ArrowLeft, TaskSquare } from 'iconsax-react'
import { useRouter, useParams } from 'next/navigation'
import PageLoading from '@/components/PageLoading'
import Layout from '@/hoc/Layout'
@@ -10,6 +10,7 @@ import OrderMainContent from './components/OrderMainContent'
import CancelOrderModal from './components/CancelOrderModal'
import { useCancelOrder, useGetOrderDetail } from '../hooks/useOrdersData'
import { toast } from '@/components/Toast'
import { PRIMARY_COLOR } from '@/config/const'
const OrderDetail: FC = () => {
@@ -66,16 +67,23 @@ const OrderDetail: FC = () => {
<Menu pageActive='orders' />
<div className='border p-6 rounded-2xl text-sm mt-7'>
<div className='flex gap-2 items-center border-b pb-6'>
<div className='flex justify-between items-center border-b pb-6'>
<div className='flex gap-2 items-center'>
<button
onClick={() => router.push('/profile/orders')}
className='p-2 hover:bg-gray-100 rounded-lg transition-colors'
>
<ArrowRight2 size={20} className="rotate-180" />
<ArrowLeft color='black' size={20} className="rotate-180" />
</button>
<div className="text-lg font-semibold">جزئیات سفارش</div>
</div>
<div className='flex gap-2 items-center cursor-pointer' onClick={() => router.push(`/profile/orders/${id}/return`)}>
<TaskSquare color={PRIMARY_COLOR} size={20} />
<div className='text-primary'>فرم درخواست مرجوعی</div>
</div>
</div>
{cancelOrderMutation.isPending ? (
<PageLoading />
) : orderDetail?.results.order ? (
@@ -0,0 +1,96 @@
'use client'
import React from 'react'
import Image from 'next/image'
import { Checkbox } from '@/components/ui/checkbox'
import { ShipmentItemDetail } from '../../../types/Types'
interface ProductSelectionProps {
items: ShipmentItemDetail[]
selectedItems: string[]
onItemSelect: (itemId: string, checked: boolean) => void
}
const ProductSelection: React.FC<ProductSelectionProps> = ({
items,
selectedItems,
onItemSelect
}) => {
return (
<div className='space-y-0'>
{items.length === 0 && (
<div className='text-center py-8 text-gray-500'>
هیچ آیتمی برای نمایش وجود ندارد
</div>
)}
{items.map((item, index) => {
const availableQuantity = item.quantity - item.cancelled_quantity - item.returned_quantity
const canReturn = availableQuantity > 0
return (
<div key={item._id}>
<div className={`p-4 ${!canReturn ? 'opacity-60' : ''}`}>
<div className='flex items-start gap-4'>
<div className='flex items-center justify-center'>
<Checkbox
id={item._id}
checked={selectedItems.includes(item._id)}
onCheckedChange={(checked) => onItemSelect(item._id, checked as boolean)}
disabled={!canReturn}
className={`w-5 h-5 ${!canReturn ? 'opacity-70' : ''}`}
/>
</div>
<Image
src={item.product.imagesUrl.cover}
width={80}
height={80}
className='w-20 h-20 object-cover rounded-lg border flex-shrink-0'
alt={item.product.title_fa}
/>
<div className='flex-1 min-w-0'>
<div className='font-medium text-sm leading-relaxed mb-2'>
{item.product.title_fa}
</div>
<div className='flex items-center gap-2 mb-2'>
<div className='w-3 h-3 bg-gray-800 rounded-full'></div>
<span className='text-xs text-gray-600'>مشکی</span>
</div>
<div className='text-xs text-gray-500 mb-1'>
کد کالا: {item.product._id || 'نامشخص'}
</div>
<div className='text-xs text-gray-500 mb-1'>
فروشنده: سندس کالا
</div>
<div className='text-xs text-gray-500 mb-2'>
وضعیت: آونگ
</div>
<div className='text-sm font-bold text-primary'>
{Intl.NumberFormat('fa-IR').format(item.selling_price)} تومان
</div>
{!canReturn && (
<div className='text-xs text-red-500 mt-2'>
این آیتم قابل مرجوع کردن نیست
</div>
)}
</div>
</div>
</div>
{index < items.length - 1 && (
<div className='border-t border-gray-200'></div>
)}
</div>
)
})}
</div>
)
}
export default ProductSelection
@@ -0,0 +1,134 @@
'use client'
import React from 'react'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { ShipmentItemDetail } from '../../../types/Types'
interface ReturnConfirmationProps {
selectedItems: ShipmentItemDetail[]
quantities: { [key: string]: number }
reason: string
description: string
images: File[]
imagePreviewUrls: string[]
returnReasons: Array<{ _id: string; title: string }>
onSubmit: () => void
onBack: () => void
isLoading: boolean
}
const ReturnConfirmation: React.FC<ReturnConfirmationProps> = ({
selectedItems,
quantities,
reason,
description,
// images,
imagePreviewUrls,
returnReasons,
onSubmit,
onBack,
isLoading
}) => {
const selectedReason = returnReasons.find(r => r._id === reason)
return (
<div className='space-y-6'>
<h3 className='text-lg font-semibold'>تایید اطلاعات مرجوعی</h3>
{/* محصولات انتخاب شده */}
<div className='space-y-4'>
<h4 className='font-medium'>محصولات انتخاب شده</h4>
{selectedItems.map((item) => (
<div key={item._id} className='p-4 border rounded-lg'>
<div className='flex items-center gap-4'>
<Image
src={item.product.imagesUrl.cover}
width={60}
height={60}
className='w-15 h-15 object-cover rounded-lg border flex-shrink-0'
alt={item.product.title_fa}
/>
<div className='flex-1'>
<div className='font-medium'>{item.product.title_fa}</div>
<div className='text-sm text-gray-600 mt-1'>
تعداد مرجوعی: {quantities[item._id]}
</div>
<div className='text-sm text-primary font-medium mt-1'>
{Intl.NumberFormat('fa-IR').format(item.selling_price)} تومان
</div>
</div>
</div>
</div>
))}
</div>
{/* اطلاعات مرجوعی */}
<div className='space-y-4'>
<h4 className='font-medium'>اطلاعات مرجوعی</h4>
<div className='p-4 border rounded-lg space-y-3'>
<div>
<span className='font-medium text-sm'>دلیل مرجوعی: </span>
<span className='text-sm'>{selectedReason?.title}</span>
</div>
<div>
<span className='font-medium text-sm'>توضیحات: </span>
<p className='text-sm text-gray-700 mt-1 leading-relaxed'>{description}</p>
</div>
</div>
</div>
{/* تصاویر آپلود شده */}
{imagePreviewUrls.length > 0 && (
<div className='space-y-4'>
<h4 className='font-medium'>تصاویر آپلود شده</h4>
<div className='grid grid-cols-2 md:grid-cols-3 gap-4'>
{imagePreviewUrls.map((url, index) => (
<div key={index} className='relative'>
<Image
src={url}
width={100}
height={100}
className='max-w-[200px] max-h-[200px] object-contain rounded border'
alt={`تصویر ${index + 1}`}
/>
</div>
))}
</div>
</div>
)}
{/* اخطار */}
<div className='flex gap-2 text-xs text-[#7F7F7F] p-3 bg-primary/10 rounded-lg'>
<div className="size-5 bg-primary/20 rounded-full flex items-center justify-center">
<span className="text-primary text-xs">!</span>
</div>
<span>
لطفاً قبل از تایید نهایی، تمامی اطلاعات را بررسی کنید. پس از ثبت درخواست مرجوعی، امکان تغییر اطلاعات وجود ندارد.
</span>
</div>
{/* دکمه‌های عملیات */}
<div className='flex gap-3 justify-center'>
<Button
type="button"
variant="outline"
onClick={onBack}
>
بازگشت
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={isLoading}
isLoading={isLoading}
>
تایید و ثبت مرجوعی
</Button>
</div>
</div>
)
}
export default ReturnConfirmation
@@ -0,0 +1,257 @@
'use client'
import React, { useState, useRef } from 'react'
import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import * as yup from 'yup'
import Select from '@/components/Select'
import { Button } from '@/components/ui/button'
import { ShipmentItemDetail } from '../../../types/Types'
import Image from 'next/image'
import { CloseCircle, DocumentUpload } from 'iconsax-react'
interface ReturnFormData {
quantities: { [key: string]: number }
reason: string
description: string
}
interface ReturnFormProps {
selectedItems: ShipmentItemDetail[]
onSubmit: (data: { quantities: { [key: string]: number }; reason: string; description: string; images: File[] }) => void
onBack: () => void
isLoading: boolean
returnReasons: Array<{ _id: string; title: string }>
}
const returnFormSchema = yup.object({
reason: yup.string().required('دلیل مرجوعی را انتخاب کنید'),
description: yup.string().required('توضیحات را وارد کنید').min(10, 'توضیحات باید حداقل ۱۰ کاراکتر باشد'),
quantities: yup.object().test('quantities', 'تعداد مرجوعی باید معتبر باشد', function (value: Record<string, number>) {
if (!value) return false
const selectedItems = this.parent.selectedItems || []
for (const item of selectedItems) {
const quantity = value[item._id]
const availableQuantity = item.quantity - item.cancelled_quantity - item.returned_quantity
if (!quantity || quantity < 1 || quantity > availableQuantity) {
return false
}
}
return true
})
})
const ReturnForm: React.FC<ReturnFormProps> = ({
selectedItems,
onSubmit,
onBack,
isLoading,
returnReasons
}) => {
const [images, setImages] = useState<File[]>([])
const [imagePreviewUrls, setImagePreviewUrls] = useState<string[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
reset
} = useForm<ReturnFormData>({
resolver: yupResolver(returnFormSchema),
defaultValues: {
quantities: selectedItems.reduce((acc, item) => ({
...acc,
[item._id]: 1
}), {})
}
})
const handleFormSubmit = (data: ReturnFormData) => {
onSubmit({
quantities: data.quantities,
reason: data.reason,
description: data.description,
images
})
reset()
}
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || [])
const validFiles = files.filter(file => {
const isValidType = file.type === 'image/jpeg' || file.type === 'image/png'
const isValidSize = file.size <= 10 * 1024 * 1024 // 10MB
return isValidType && isValidSize
})
if (validFiles.length !== files.length) {
alert('فقط فایل‌های JPG و PNG با حداکثر حجم ۱۰ مگابایت مجاز هستند')
}
const newImages = [...images, ...validFiles].slice(0, 5) // حداکثر ۵ عکس
setImages(newImages)
const newPreviewUrls = newImages.map(file => URL.createObjectURL(file))
setImagePreviewUrls(newPreviewUrls)
}
const removeImage = (index: number) => {
const newImages = images.filter((_, i) => i !== index)
const newPreviewUrls = imagePreviewUrls.filter((_, i) => i !== index)
setImages(newImages)
setImagePreviewUrls(newPreviewUrls)
}
return (
<div className='space-y-6'>
<h3 className='text-lg font-semibold'>تکمیل اطلاعات مرجوعی</h3>
{/* انتخاب تعداد مرجوعی برای هر آیتم */}
<div className='space-y-4'>
<h4 className='font-medium'>تعداد مرجوعی</h4>
{selectedItems.map((item) => {
const availableQuantity = item.quantity - item.cancelled_quantity - item.returned_quantity
return (
<div key={item._id} className='p-4 border rounded-lg'>
<div className='flex items-center gap-4 mb-2'>
<Image
src={item.product.imagesUrl.cover}
width={40}
height={40}
className='w-10 h-10 object-cover rounded border'
alt={item.product.title_fa}
/>
<div className='flex-1'>
<div className='font-medium text-sm'>{item.product.title_fa}</div>
<div className='text-xs text-gray-600'>قابل مرجوع: {availableQuantity}</div>
</div>
</div>
<div className='flex items-center gap-2'>
<label className='text-sm'>تعداد:</label>
<input
type='number'
min={1}
max={availableQuantity}
defaultValue={1}
{...register(`quantities.${item._id}`, {
valueAsNumber: true,
min: { value: 1, message: 'حداقل تعداد ۱ است' },
max: { value: availableQuantity, message: `حداکثر تعداد ${availableQuantity} است` }
})}
className='w-20 px-2 py-1 border rounded text-center'
/>
</div>
</div>
)
})}
</div>
<form onSubmit={handleSubmit(handleFormSubmit)} className='space-y-6'>
{/* انتخاب دلیل مرجوعی */}
<Select
placeholder='دلیل مرجوعی'
items={returnReasons.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}
/>
{/* توضیحات */}
<div>
<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>
<div className="text-sm font-medium mb-2">آپلود عکس</div>
<div className='text-xs text-gray-600 mb-3'>
اگر ایراد کالا قابل مشاهده است به صورتی عکس بگیرید که مشخص باشد (با فرمت JPG یا PNG و هر عکس حداکثر ۱۰ مگابایت)
</div>
{/* نمایش عکس‌های آپلود شده */}
{imagePreviewUrls.length > 0 && (
<div className='grid grid-cols-2 md:grid-cols-3 gap-4 mb-4'>
{imagePreviewUrls.map((url, index) => (
<div key={index} className='relative'>
<Image
src={url}
width={100}
height={100}
className='max-w-[200px] max-h-[200px] object-contain rounded border'
alt={`تصویر ${index + 1}`}
/>
<button
type='button'
onClick={() => removeImage(index)}
className='absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center'
>
<CloseCircle color='white' size={14} />
</button>
</div>
))}
</div>
)}
{/* دکمه آپلود */}
{images.length < 5 && (
<div>
<input
ref={fileInputRef}
type='file'
accept='image/jpeg,image/png'
multiple
onChange={handleImageUpload}
className='hidden'
/>
<button
type='button'
onClick={() => fileInputRef.current?.click()}
className='flex items-center gap-2 px-4 py-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-primary transition-colors'
>
<DocumentUpload color='gray' size={20} />
<span>انتخاب عکس</span>
</button>
</div>
)}
</div>
{/* دکمه‌های عملیات */}
<div className='flex gap-3 justify-center'>
<Button
type="button"
variant="outline"
onClick={onBack}
>
بازگشت
</Button>
<Button
type="submit"
disabled={isLoading}
isLoading={isLoading}
>
ادامه
</Button>
</div>
</form>
</div>
)
}
export default ReturnForm
+221 -2
View File
@@ -1,8 +1,227 @@
import { NextPage } from 'next'
'use client'
import Layout from '@/hoc/Layout'
import { type NextPage } from 'next'
import { useParams, useRouter } from 'next/navigation'
import { useState } from 'react'
import { useGetOrderDetail, useReturnOrder, useGetReturnReasons } from '../../hooks/useOrdersData'
import ProductSelection from './components/ProductSelection'
import ReturnForm from './components/ReturnForm'
import ReturnConfirmation from './components/ReturnConfirmation'
import { toast } from '@/components/Toast'
import { extractErrorMessage } from '@/helpers/errorUtils'
import { Button } from '@/components/ui/button'
type Step = 1 | 2 | 3
interface FormData {
quantities: { [key: string]: number }
reason: string
description: string
images: File[]
}
const ReturnPage: NextPage = () => {
const { id } = useParams()
const router = useRouter()
const { data: order, isLoading: isOrderLoading } = useGetOrderDetail(id as string)
const returnOrderMutation = useReturnOrder()
const { data: returnReasons } = useGetReturnReasons()
const [currentStep, setCurrentStep] = useState<Step>(1)
const [selectedItems, setSelectedItems] = useState<string[]>([])
const [formData, setFormData] = useState<FormData | null>(null)
const [imagePreviewUrls, setImagePreviewUrls] = useState<string[]>([])
const handleItemSelect = (itemId: string, checked: boolean) => {
if (checked) {
setSelectedItems(prev => [...prev, itemId])
} else {
setSelectedItems(prev => prev.filter(id => id !== itemId))
}
}
const handleFormSubmit = (data: FormData) => {
setFormData(data)
// ایجاد preview URLs برای تصاویر
const previewUrls = data.images.map(file => URL.createObjectURL(file))
setImagePreviewUrls(previewUrls)
setCurrentStep(3)
}
const handleFinalSubmit = async () => {
if (!formData || selectedItems.length === 0) {
toast('اطلاعات ناقص است', 'error')
return
}
// تبدیل تصاویر به base64 یا URL
const imagePromises = formData.images.map(async (file) => {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.readAsDataURL(file)
})
})
const imageUrls = await Promise.all(imagePromises)
const returnItems = selectedItems.map(itemId => {
// Find the order item that contains this shipment item
const orderItem = order?.results.order.orderItems.find(orderItem =>
orderItem.shipmentItems.some(shipmentItem => shipmentItem._id === itemId)
)
const shipmentItem = orderItem?.shipmentItems.find(item => item._id === itemId)
if (!orderItem || !shipmentItem) return null
return {
orderItemId: orderItem._id,
shipmentItemId: shipmentItem._id,
quantity: formData.quantities[itemId],
comment: formData.description,
reason: formData.reason,
images: imageUrls
}
}).filter(Boolean) as { orderItemId: string; shipmentItemId: string; quantity: number; comment: string; reason: string; images: string[] }[]
await returnOrderMutation.mutateAsync({
orderId: Number(id),
items: returnItems
}, {
onSuccess: () => {
toast('درخواست مرجوعی با موفقیت ثبت شد', 'success')
router.push(`/profile/orders/${id}/return/success`)
},
onError: (error) => {
toast(extractErrorMessage(error), 'error')
}
})
}
const handleBackToStep1 = () => {
setCurrentStep(1)
setFormData(null)
setImagePreviewUrls([])
}
const handleBackToStep2 = () => {
setCurrentStep(2)
}
// Flatten all shipment items from all order items
const allShipmentItems = order?.results.order.orderItems
.flatMap(orderItem => orderItem.shipmentItems) || []
// Filter selected shipment items
const selectedShipmentItems = allShipmentItems.filter(item =>
selectedItems.includes(item._id)
)
if (isOrderLoading) {
return (
<div>page</div>
<Layout>
<div className='mt-14 px-4 sm:px-8 lg:px-20'>
<div className='flex justify-center items-center min-h-[400px]'>
<div className='text-center'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4'></div>
<p>در حال بارگذاری...</p>
</div>
</div>
</div>
</Layout>
)
}
if (!order) {
return (
<Layout>
<div className='mt-14 px-4 sm:px-8 lg:px-20'>
<div className='text-center min-h-[400px] flex items-center justify-center'>
<p>سفارش یافت نشد</p>
</div>
</div>
</Layout>
)
}
return (
<Layout>
<div className='mt-14 px-4 sm:px-8 lg:px-20 mx-auto'>
{/* Header */}
<div className='mb-6'>
<div className='flex items-center justify-between mb-4'>
<h1 className='text-xl font-bold'>انتخاب کالاهای مرجوعی</h1>
<div className='text-sm text-gray-600'>شناسه سفارش: {order.results.order._id}</div>
</div>
<div className='border-t border-gray-200 mb-4'></div>
{/* Warning Message */}
<div className='flex items-start gap-3 p-4 bg-orange-50 border border-orange-200 rounded-lg mb-6'>
<div className='w-5 h-5 bg-orange-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5'>
<span className='text-white text-xs font-bold'>!</span>
</div>
<p className='text-sm text-gray-700 leading-relaxed'>
دقت کنید که برچسب سریال کالا (چسبیده روی بسته) را سالم نگه دارید و کالای اصلی را با تمام لوازم در بسته بندی اولیه قرار دهید
</p>
</div>
</div>
<div className='space-y-8'>
{currentStep === 1 && (
<>
<ProductSelection
items={allShipmentItems}
selectedItems={selectedItems}
onItemSelect={handleItemSelect}
/>
<div className='flex gap-3 mt-8 justify-center'>
<Button
onClick={() => router.back()}
variant="outline"
size="default"
>
بازگشت
</Button>
<Button
onClick={() => setCurrentStep(2)}
disabled={selectedItems.length === 0}
size="default"
>
ادامه
</Button>
</div>
</>
)}
{currentStep === 2 && (
<ReturnForm
selectedItems={selectedShipmentItems}
onSubmit={handleFormSubmit}
onBack={handleBackToStep1}
isLoading={false}
returnReasons={returnReasons?.results.reasons || []}
/>
)}
{currentStep === 3 && formData && (
<ReturnConfirmation
selectedItems={selectedShipmentItems}
quantities={formData.quantities}
reason={formData.reason}
description={formData.description}
images={formData.images}
imagePreviewUrls={imagePreviewUrls}
returnReasons={returnReasons?.results.reasons || []}
onSubmit={handleFinalSubmit}
onBack={handleBackToStep2}
isLoading={returnOrderMutation.isPending}
/>
)}
</div>
</div>
</Layout>
)
}
@@ -0,0 +1,39 @@
import { Button } from "@/components/ui/button"
import Layout from "@/hoc/Layout"
import { NextPage } from "next"
import Image from "next/image"
import Link from "next/link"
const SuccesPage: NextPage = () => {
return (
<Layout>
<div className='w-full lg:px-10 px-4 mt-20 '>
<div className="shadow-lg rounded-2xl p-6 max-w-4xl mx-auto flex justify-between items-center">
<div className="flex-1">
<div className="text-[#019907]">
درخواست مرجوعی ثبت شد
</div>
<p className="mt-5 text-[#7F7F7F] font-light text-sm">
درخواست شما درحال بررسی است در صورت تایید یا عدم تایید درخواست، با پیامک به شما اطلاع میدهیم
</p>
<Link href="/profile/orders">
<Button className="mt-20">پیگیری درخواست</Button>
</Link>
</div>
<Image
src="/images/success.png"
alt="return-success"
width={200}
height={130}
className="max-w-[200px] max-h-[130px] h-full object-contain"
/>
</div>
</div>
</Layout>
)
}
export default SuccesPage
@@ -30,3 +30,16 @@ export const useGetCancelReasons = () => {
queryFn: api.getCancelReasons,
});
};
export const useGetReturnReasons = () => {
return useQuery({
queryKey: ["returnReasons"],
queryFn: api.getReturnReasons,
});
};
export const useReturnOrder = () => {
return useMutation({
mutationFn: api.returnOrder,
});
};
+12
View File
@@ -4,6 +4,8 @@ import {
OrderDetailResponse,
CancelOrderType,
CancelReasonsResponse,
ReturnOrderType,
ReturnReasonsResponse,
} from "../types/Types";
export const getOrders = async (activeTab: string): Promise<OrdersResponse> => {
@@ -34,3 +36,13 @@ export const cancelOrder = async (params: CancelOrderType) => {
const { data } = await axios.post(`/cancel/user`, params);
return data;
};
export const getReturnReasons = async (): Promise<ReturnReasonsResponse> => {
const { data } = await axios.get(`/returns/reasons`);
return data;
};
export const returnOrder = async (params: ReturnOrderType) => {
const { data } = await axios.post(`/returns/user`, params);
return data;
};
+28
View File
@@ -313,3 +313,31 @@ export interface CancelOrderType {
orderId: number;
items: CancelOrderItem[];
}
export interface ReturnReason {
_id: string;
title: string;
deleted: boolean;
}
export interface ReturnReasonsResponse {
status: number;
success: boolean;
results: {
reasons: ReturnReason[];
};
}
export interface ReturnOrderItem {
orderItemId: string;
shipmentItemId: string;
quantity: number;
comment: string;
reason: string;
images: string[];
}
export interface ReturnOrderType {
orderId: number;
items: ReturnOrderItem[];
}
+4 -3
View File
@@ -14,6 +14,7 @@ type CartSummaryProps = {
onConfirm?: () => void;
confirmHref?: string;
confirmLabel?: string;
confirmDisabled?: boolean;
onDownloadInvoice?: () => void;
className?: string;
isLoading?: boolean;
@@ -33,7 +34,7 @@ const Row: FC<{ label: string; value: string | number; emphasize?: boolean }> =
};
const CartSummary: FC<CartSummaryProps> = (props) => {
const { itemsCount, itemsPrice, discount, shippingCost, total, onConfirm, onDownloadInvoice, className, confirmHref, confirmLabel, isLoading } = props;
const { itemsCount, itemsPrice, discount, shippingCost, total, onConfirm, onDownloadInvoice, className, confirmHref, confirmLabel, confirmDisabled, isLoading } = props;
return (
<div className={"rounded-xl sm:rounded-2xl border border-border p-4 sm:p-6 h-fit bg-[#FAFAFA] " + (className ?? "")}>
@@ -62,10 +63,10 @@ const CartSummary: FC<CartSummaryProps> = (props) => {
<Button
type="button"
onClick={() => !confirmHref && onConfirm?.()}
disabled={isLoading}
disabled={isLoading || confirmDisabled}
className="w-full h-10 sm:h-12 rounded-lg sm:rounded-xl text-white text-sm sm:text-base disabled:opacity-50 disabled:cursor-not-allowed"
>
{confirmHref ? (
{confirmHref && !confirmDisabled ? (
<Link href={confirmHref} className="w-full h-full flex items-center justify-center">
{confirmLabel ?? "تایید و تکمیل سفارش"}
</Link>