diff --git a/public/images/success.png b/public/images/success.png new file mode 100644 index 0000000..e835e91 Binary files /dev/null and b/public/images/success.png differ diff --git a/src/app/cart/components/ShippingMethodSelector.tsx b/src/app/cart/components/ShippingMethodSelector.tsx new file mode 100644 index 0000000..26d9151 --- /dev/null +++ b/src/app/cart/components/ShippingMethodSelector.tsx @@ -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 + 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>(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 = {} + + 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 ( +
+
+
+ +
+

روش ارسال

+

+ {selectedCount} از {totalShops} فروشگاه انتخاب شده +

+ {selectedCount > 0 && ( +

+ مجموع ارسال: {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')} تومان +

+ )} +
+
+
+ {/* انتخاب سریع */} +
+ +
+ + +
+
+ + {/* کنترل‌های مرتب‌سازی */} +
+ + +
+ + {onClearSelections && Object.keys(selectedShipments).length > 0 && ( + + )} +
+
+ +
+ {shippingData?.map((shop) => ( +
+
+
+

{shop.shopName}

+

+ {shop.items?.length || 0} محصول +

+
+ {selectedShipments[shop.shopId] && ( +
+ + + + انتخاب شد +
+ )} +
+ + {/* نمایش محصولات فروشگاه */} +
+ + {expandedShops.has(shop.shopId) && ( +
+ {shop.items?.map((item, index) => ( +
+ {item.product.title_fa} +
+
+ {item.product.title_fa} +
+
+
+ تعداد: {item.quantity} +
+
+ {item.variant.price.selling_price.toLocaleString('fa-IR')} تومان +
+
+
+
+ ))} +
+ )} +
+ + {/* روش‌های ارسال */} +
+
روش ارسال:
+
+ {(() => { + 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 ( + + ) + }) + })()} +
+ + {(!shop.shippers || shop.shippers.length === 0) && ( +
+ روش ارسالی برای این فروشگاه موجود نیست +
+ )} +
+
+ ))} +
+
+ ) +} + +export default ShippingMethodSelector diff --git a/src/app/cart/hooks/useCartData.ts b/src/app/cart/hooks/useCartData.ts index 8dfaa4f..7df38a6 100644 --- a/src/app/cart/hooks/useCartData.ts +++ b/src/app/cart/hooks/useCartData.ts @@ -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, + }); +}; diff --git a/src/app/cart/payment/page.tsx b/src/app/cart/payment/page.tsx index c6a3b3f..a27720c 100644 --- a/src/app/cart/payment/page.tsx +++ b/src/app/cart/payment/page.tsx @@ -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 // محاسبه قیمت نهایی diff --git a/src/app/cart/service/Service.ts b/src/app/cart/service/Service.ts index 0aba998..9afe519 100644 --- a/src/app/cart/service/Service.ts +++ b/src/app/cart/service/Service.ts @@ -13,6 +13,8 @@ import { PaymentStatusType, PaymentStatusResponseType, BulkAddCartType, + ShipmentCartResponseType, + ShipmentSaveType, } from "../types/Types"; export const getCart = async (): Promise => { @@ -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 => { + const { data } = await axios.get("/shipment/cart"); + return data; +}; + +export const saveShipmentCart = async (params: ShipmentSaveType) => { + const { data } = await axios.post("/shipment/save", params); + return data; +}; diff --git a/src/app/cart/shipping/page.tsx b/src/app/cart/shipping/page.tsx index 26bc9e8..f948780 100644 --- a/src/app/cart/shipping/page.tsx +++ b/src/app/cart/shipping/page.tsx @@ -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>({}) + 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((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 = () => { ویرایش -
+ + + + {/*
{items.map((item: CartItemType, index: number) => { if (!item || !item.product || !item.variant) { return null @@ -95,7 +182,7 @@ 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 && ( +
+ لطفا برای همه فروشگاه‌ها روش ارسال انتخاب کنید +
+ )}
diff --git a/src/app/cart/types/Types.ts b/src/app/cart/types/Types.ts index 10fa6b2..1c46bf8 100644 --- a/src/app/cart/types/Types.ts +++ b/src/app/cart/types/Types.ts @@ -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[]; + }; +}; diff --git a/src/app/profile/orders/[id]/page.tsx b/src/app/profile/orders/[id]/page.tsx index dff9517..2a04ae8 100644 --- a/src/app/profile/orders/[id]/page.tsx +++ b/src/app/profile/orders/[id]/page.tsx @@ -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,14 +67,21 @@ const OrderDetail: FC = () => {
-
- -
جزئیات سفارش
+
+
+ +
جزئیات سفارش
+
+ +
router.push(`/profile/orders/${id}/return`)}> + +
فرم درخواست مرجوعی
+
{cancelOrderMutation.isPending ? ( diff --git a/src/app/profile/orders/[id]/return/components/ProductSelection.tsx b/src/app/profile/orders/[id]/return/components/ProductSelection.tsx new file mode 100644 index 0000000..e394990 --- /dev/null +++ b/src/app/profile/orders/[id]/return/components/ProductSelection.tsx @@ -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 = ({ + items, + selectedItems, + onItemSelect +}) => { + return ( +
+ {items.length === 0 && ( +
+ هیچ آیتمی برای نمایش وجود ندارد +
+ )} + {items.map((item, index) => { + const availableQuantity = item.quantity - item.cancelled_quantity - item.returned_quantity + const canReturn = availableQuantity > 0 + + return ( +
+
+
+
+ onItemSelect(item._id, checked as boolean)} + disabled={!canReturn} + className={`w-5 h-5 ${!canReturn ? 'opacity-70' : ''}`} + /> +
+ + {item.product.title_fa} + +
+
+ {item.product.title_fa} +
+ +
+
+ مشکی +
+ +
+ کد کالا: {item.product._id || 'نامشخص'} +
+ +
+ فروشنده: سندس کالا +
+ +
+ وضعیت: آونگ +
+ +
+ {Intl.NumberFormat('fa-IR').format(item.selling_price)} تومان +
+ + {!canReturn && ( +
+ این آیتم قابل مرجوع کردن نیست +
+ )} + +
+
+
+ {index < items.length - 1 && ( +
+ )} +
+ ) + })} +
+ ) +} + +export default ProductSelection diff --git a/src/app/profile/orders/[id]/return/components/ReturnConfirmation.tsx b/src/app/profile/orders/[id]/return/components/ReturnConfirmation.tsx new file mode 100644 index 0000000..83699ea --- /dev/null +++ b/src/app/profile/orders/[id]/return/components/ReturnConfirmation.tsx @@ -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 = ({ + selectedItems, + quantities, + reason, + description, + // images, + imagePreviewUrls, + returnReasons, + onSubmit, + onBack, + isLoading +}) => { + const selectedReason = returnReasons.find(r => r._id === reason) + + return ( +
+

تایید اطلاعات مرجوعی

+ + {/* محصولات انتخاب شده */} +
+

محصولات انتخاب شده

+ {selectedItems.map((item) => ( +
+
+ {item.product.title_fa} +
+
{item.product.title_fa}
+
+ تعداد مرجوعی: {quantities[item._id]} +
+
+ {Intl.NumberFormat('fa-IR').format(item.selling_price)} تومان +
+
+
+
+ ))} +
+ + {/* اطلاعات مرجوعی */} +
+

اطلاعات مرجوعی

+ +
+
+ دلیل مرجوعی: + {selectedReason?.title} +
+ +
+ توضیحات: +

{description}

+
+
+
+ + {/* تصاویر آپلود شده */} + {imagePreviewUrls.length > 0 && ( +
+

تصاویر آپلود شده

+
+ {imagePreviewUrls.map((url, index) => ( +
+ {`تصویر +
+ ))} +
+
+ )} + + {/* اخطار */} +
+
+ ! +
+ + لطفاً قبل از تایید نهایی، تمامی اطلاعات را بررسی کنید. پس از ثبت درخواست مرجوعی، امکان تغییر اطلاعات وجود ندارد. + +
+ + {/* دکمه‌های عملیات */} +
+ + +
+
+ ) +} + +export default ReturnConfirmation diff --git a/src/app/profile/orders/[id]/return/components/ReturnForm.tsx b/src/app/profile/orders/[id]/return/components/ReturnForm.tsx new file mode 100644 index 0000000..fdc67ee --- /dev/null +++ b/src/app/profile/orders/[id]/return/components/ReturnForm.tsx @@ -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) { + 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 = ({ + selectedItems, + onSubmit, + onBack, + isLoading, + returnReasons +}) => { + const [images, setImages] = useState([]) + const [imagePreviewUrls, setImagePreviewUrls] = useState([]) + const fileInputRef = useRef(null) + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + reset + } = useForm({ + 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) => { + 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 ( +
+

تکمیل اطلاعات مرجوعی

+ + {/* انتخاب تعداد مرجوعی برای هر آیتم */} +
+

تعداد مرجوعی

+ {selectedItems.map((item) => { + const availableQuantity = item.quantity - item.cancelled_quantity - item.returned_quantity + return ( +
+
+ {item.product.title_fa} +
+
{item.product.title_fa}
+
قابل مرجوع: {availableQuantity}
+
+
+
+ + +
+
+ ) + })} +
+ +
+ {/* انتخاب دلیل مرجوعی */} +