From 6d52d1e91af9ff6306d3f245145952f9e75bcbc5 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sun, 14 Sep 2025 12:47:51 +0330 Subject: [PATCH] base logig pages cart shipping and payment --- src/app/cart/components/CartItem.tsx | 73 +++++++++--- src/app/cart/components/DiscountCard.tsx | 80 +++++++++++-- src/app/cart/hooks/useCartData.ts | 31 ++++- src/app/cart/page.tsx | 101 ++++++++++++++-- src/app/cart/payment/page.tsx | 143 ++++++++++++++++++++--- src/app/cart/service/Service.ts | 12 ++ src/app/cart/shipping/page.tsx | 88 ++++++++++++-- src/app/cart/types/Types.ts | 136 +++++++++++++++++++++ src/app/profile/hooks/useProfileData.ts | 12 ++ src/app/profile/service/Service.ts | 7 ++ src/components/CartSummary.tsx | 6 +- src/share/Header.tsx | 6 +- src/share/types/SharedTypes.ts | 50 +++++--- 13 files changed, 663 insertions(+), 82 deletions(-) create mode 100644 src/app/profile/hooks/useProfileData.ts create mode 100644 src/app/profile/service/Service.ts diff --git a/src/app/cart/components/CartItem.tsx b/src/app/cart/components/CartItem.tsx index bb708b8..97c9e08 100644 --- a/src/app/cart/components/CartItem.tsx +++ b/src/app/cart/components/CartItem.tsx @@ -5,14 +5,49 @@ import { clx } from '@/helpers/utils'; import { ArchiveTick, Shop, TruckFast } from 'iconsax-react' import Image from 'next/image' import { FC } from 'react' +import { CartItemType } from '../types/Types' +import { useUpdateCart, useRemoveFromCart } from '../hooks/useCartData' +import { toast } from '@/components/Toast' type Props = { noBorder?: boolean + item: CartItemType } const CartItem: FC = (props) => { - const { noBorder } = props + const { noBorder, item } = props + const updateCartMutation = useUpdateCart() + const removeFromCartMutation = useRemoveFromCart() + + if (!item) { + return null + } + + const handleQuantityChange = async (newQuantity: number) => { + try { + await updateCartMutation.mutateAsync({ + productId: item.product._id, + variantId: item.variant._id, + quantity: newQuantity + }) + toast('تعداد به‌روزرسانی شد', 'success') + } catch { + toast('خطا در به‌روزرسانی تعداد', 'error') + } + } + + const handleRemove = async () => { + try { + await removeFromCartMutation.mutateAsync({ + productId: item.product._id, + variantId: item.variant._id + }) + toast('محصول از سبد خرید حذف شد', 'success') + } catch { + toast('خطا در حذف محصول', 'error') + } + } return (
= (props) => { )}>
= (props) => {
- گوشی موبایل سامسونگ مدل Galaxy S24 Ultra دو سیم کارت ظرفیت 1 ترابایت و رم 12 گیگابایت - ویتنام + {item.product.title_fa}
- 70,400,000 تومان + {item.variant.price.selling_price.toLocaleString()} تومان
-
-
-
+ {item.variant.size && ( +
+
+
+
+
{item.variant.size.value}
-
مشکی
-
+ )}
-
فروشنده فروشگاه سندس
+
فروشنده {item.variant.shop.shopName}
-
گارانتی اصالت و سلامت فیزیکی کالا
+
گارانتی {item.variant.warranty.name}
@@ -66,14 +103,18 @@ const CartItem: FC = (props) => {
-
ارسال از سندس حداکثر پس از 3 روز کاری
+
+ ارسال از {item.variant.shop.shopName} حداکثر پس از {item.variant.shipmentMethod[0]?.deliveryTime || 3} روز کاری +
null} - onRemove={() => null} - quantity={1} + onChange={handleQuantityChange} + onRemove={handleRemove} + quantity={item.quantity} + max={item.variant.stock} + className={updateCartMutation.isPending || removeFromCartMutation.isPending ? 'opacity-50 pointer-events-none' : ''} />
diff --git a/src/app/cart/components/DiscountCard.tsx b/src/app/cart/components/DiscountCard.tsx index 2388885..1edf1bd 100644 --- a/src/app/cart/components/DiscountCard.tsx +++ b/src/app/cart/components/DiscountCard.tsx @@ -1,16 +1,78 @@ import { Button } from '@/components/ui/button' -import { FC } from 'react' +import Input from '@/components/Input' +import { FC, useState } from 'react' -const DiscountCard: FC = () => { - return ( -
-
- کد تخفیف دارید؟ +type DiscountCardProps = { + discountCode: string + setDiscountCode: (code: string) => void + onSubmit: () => void + isLoading?: boolean +} + +const DiscountCard: FC = ({ + discountCode, + setDiscountCode, + onSubmit, + isLoading = false +}) => { + const [isExpanded, setIsExpanded] = useState(false) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit() + } + + if (!isExpanded) { + return ( +
+
+ کد تخفیف دارید؟ +
+ +
+ ) + } - + return ( +
+
کد تخفیف
+ +
+ setDiscountCode(e.target.value)} + className="flex-1" + disabled={isLoading} + label="" + /> + + +
) } diff --git a/src/app/cart/hooks/useCartData.ts b/src/app/cart/hooks/useCartData.ts index 51aad60..b36edb3 100644 --- a/src/app/cart/hooks/useCartData.ts +++ b/src/app/cart/hooks/useCartData.ts @@ -1,5 +1,5 @@ import * as api from "../service/Service"; -import { useQuery, useMutation } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; export const useGetCart = () => { return useQuery({ @@ -9,19 +9,48 @@ export const useGetCart = () => { }; export const useAddToCart = () => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: api.addToCart, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["cart"] }); + }, }); }; export const useUpdateCart = () => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: api.updateCart, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["cart"] }); + }, }); }; export const useRemoveFromCart = () => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: api.removeFromCart, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["cart"] }); + }, + }); +}; + +export const useShippingCost = () => { + return useQuery({ + queryKey: ["shipping-cost"], + queryFn: api.shippingCost, + }); +}; + +export const usePaymentMethods = () => { + return useQuery({ + queryKey: ["payment-methods"], + queryFn: api.paymentMethods, }); }; diff --git a/src/app/cart/page.tsx b/src/app/cart/page.tsx index 262e5fa..067150d 100644 --- a/src/app/cart/page.tsx +++ b/src/app/cart/page.tsx @@ -5,11 +5,72 @@ import CartSummary from "@/components/CartSummary" import Layout from "@/hoc/Layout" import { Trash } from "iconsax-react" import { NextPage } from "next" -import { useGetCart } from "./hooks/useCartData" +import { useGetCart, useRemoveFromCart } from "./hooks/useCartData" +import { toast } from "@/components/Toast" +import { useState } from "react" const Cart: NextPage = () => { - const { data } = useGetCart(); + const { data, isLoading, error } = useGetCart(); + const removeFromCartMutation = useRemoveFromCart() + const [isClearingAll, setIsClearingAll] = useState(false) + + const handleClearAll = async () => { + if (!data?.results?.cart?.items?.length) return + + setIsClearingAll(true) + try { + // حذف همه آیتم‌ها یکی یکی + const removePromises = data.results.cart.items + .filter(item => item && item.product && item.variant) + .map(item => + removeFromCartMutation.mutateAsync({ + productId: item.product._id, + variantId: item.variant._id + }) + ) + + await Promise.all(removePromises) + toast('همه محصولات از سبد خرید حذف شدند', 'success') + } catch { + toast('خطا در حذف محصولات', 'error') + } finally { + setIsClearingAll(false) + } + } + + if (isLoading) { + return ( +
+
+
در حال بارگذاری...
+
+
+ ) + } + + if (error) { + return ( +
+
+
خطا در بارگذاری سبد خرید
+
+
+ ) + } + + const cart = data?.results?.cart + const items = cart?.items || [] + + if (!items.length) { + return ( +
+
+
سبد خرید شما خالی است
+
+
+ ) + } return (
@@ -19,31 +80,47 @@ const Cart: NextPage = () => { سبد خرید
- 10 + {cart?.items_count || 0}
- - + {items.map((item, index) => { + if (!item || !item.product || !item.variant) { + return null + } + return ( + + ) + })}
{ + const { data: paymentMethodsData, isLoading: paymentMethodsLoading } = usePaymentMethods() + const { data: cartData, isLoading: cartLoading } = useGetCart() + const { data: shippingData, isLoading: shippingLoading } = useShippingCost() + const { data: profileData, isLoading: profileLoading } = useGetProfile() + + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState('') + const [discountCode, setDiscountCode] = useState('') + const [isApplyingDiscount, setIsApplyingDiscount] = useState(false) + + const isLoading = paymentMethodsLoading || cartLoading || shippingLoading || profileLoading + + if (isLoading) { + return ( +
+
+
در حال بارگذاری...
+
+
+ ) + } + + const cart = cartData?.results?.cart + const paymentMethods = paymentMethodsData?.results?.paymentMethods || [] + const profile = profileData?.results?.info + + if (!cart?.items?.length) { + return ( +
+
+
سبد خرید شما خالی است
+
+
+ ) + } + + // محاسبه هزینه ارسال + const totalShippingCost = shippingData?.results?.shipping?.reduce((total, shop) => { + return total + (shop.shippers?.[0]?.totalShippingCost || 0) + }, 0) || 0 + + // محاسبه قیمت نهایی + const finalTotal = (cart?.payable_price || 0) + totalShippingCost + + const handlePaymentMethodChange = (value: string) => { + setSelectedPaymentMethod(value) + } + + const handleDiscountCodeSubmit = async () => { + if (!discountCode.trim()) { + toast('لطفاً کد تخفیف را وارد کنید', 'error') + return + } + + setIsApplyingDiscount(true) + try { + // TODO: پیاده‌سازی API اعمال کد تخفیف + // await applyDiscountCode(discountCode) + toast('کد تخفیف با موفقیت اعمال شد', 'success') + } catch { + toast('کد تخفیف نامعتبر است', 'error') + } finally { + setIsApplyingDiscount(false) + } + } + + const handlePayment = async () => { + if (!selectedPaymentMethod) { + toast('لطفاً روش پرداخت را انتخاب کنید', 'error') + return + } + + if (!profile?.userAddress) { + toast('لطفاً ابتدا آدرس خود را تکمیل کنید', 'error') + return + } + + try { + // TODO: پیاده‌سازی API پرداخت نهایی + // await processPayment({ + // paymentMethodId: selectedPaymentMethod, + // discountCode: discountCode || null, + // shippingAddress: profile.userAddress + // }) + toast('پرداخت با موفقیت انجام شد', 'success') + } catch { + toast('خطا در پردازش پرداخت', 'error') + } + } + return (
-
انتخاب روش پرداخت
- -
- -
زین پال
-
-
- -
زین پال
-
+ + {paymentMethods.map((method) => ( +
+ + +
+ ))}
diff --git a/src/app/cart/service/Service.ts b/src/app/cart/service/Service.ts index ad32f63..d8cf84e 100644 --- a/src/app/cart/service/Service.ts +++ b/src/app/cart/service/Service.ts @@ -4,6 +4,8 @@ import { UpdateCartType, RemoveCartType, CartResponseType, + ShippingCostResponseType, + PaymentMethodsResponseType, } from "../types/Types"; export const getCart = async (): Promise => { @@ -25,3 +27,13 @@ export const removeFromCart = async (params: RemoveCartType) => { const { data } = await axios.post("/cart/remove", params); return data; }; + +export const shippingCost = async (): Promise => { + const { data } = await axios.get("/shipment/shipping-cost"); + return data; +}; + +export const paymentMethods = async (): Promise => { + const { data } = await axios.get("/payment/methods"); + return data; +}; diff --git a/src/app/cart/shipping/page.tsx b/src/app/cart/shipping/page.tsx index 49c2fe0..26bc9e8 100644 --- a/src/app/cart/shipping/page.tsx +++ b/src/app/cart/shipping/page.tsx @@ -1,11 +1,63 @@ +'use client' import CartSummary from '@/components/CartSummary' import Layout from '@/hoc/Layout' import { NextPage } from 'next' import CartItem from '../components/CartItem' import TitleBack from '../components/TitleBack' import { Location } from 'iconsax-react' +import { useGetCart, useShippingCost } from '../hooks/useCartData' +import { CartItemType } from '../types/Types' +import { useGetProfile } from '@/app/profile/hooks/useProfileData' +import Link from 'next/link' 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() + + if (cartLoading || shippingLoading || profileLoading) { + return ( +
+
+
در حال بارگذاری...
+
+
+ ) + } + + if (cartError || shippingError || profileError) { + return ( +
+
+
خطا در بارگذاری اطلاعات
+
+
+ ) + } + + const cart = cartData?.results?.cart + const items = cart?.items || [] + const profile = profileData?.results?.info + + if (!items.length) { + return ( +
+
+
سبد خرید شما خالی است
+
+
+ ) + } + + // محاسبه هزینه ارسال + const totalShippingCost = shippingData?.results?.shipping?.reduce((total, shop) => { + return total + (shop.shippers?.[0]?.totalShippingCost || 0) + }, 0) || 0 + + // محاسبه قیمت نهایی + const finalTotal = (cart?.payable_price || 0) + totalShippingCost + return (
@@ -19,25 +71,39 @@ const CartShipping: NextPage = () => {
آدرس تحویل سفارش
-
- اراک خیابان امام حسن غریبی خیابان 123 -
-
حمید ضرغامی
+ {profile?.userAddress && ( +
+ {profile.userAddress.address} + {profile.userAddress.city && `، ${profile.userAddress.city.name}`} + {profile.userAddress.province && `، ${profile.userAddress.province.name}`} +
+ )}
-
ویرایش
+ ویرایش
- - + {items.map((item: CartItemType, index: number) => { + if (!item || !item.product || !item.variant) { + return null + } + return ( + + ) + })}
; + brand: { + _id: string; + }; + category: { + _id: string; + }; + variants: Array<{ + _id: string; + }>; + }; + variant: { + _id: string; + market_status: string; + 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; + }; + stock: number; + postingTime: number; + isFreeShip: boolean; + isWholeSale: boolean; + shop: { + _id: string; + shopName: string; + shopCode: number; + owner: string; + shopDescription: string; + telephoneNumber: string; + isChatActive: boolean; + shopPostalCode: string; + logo: string; + }; + warranty: { + _id: number; + duration: string; + logoUrl: string; + name: string; + }; + meterage?: { + _id: number; + value: string; + }; + size?: { + _id: number; + value: string; + }; + }; + quantity: number; + }>; + shippers: ShipperType[]; +}; + +// Shipping Cost API Response Type +export type ShippingCostResponseType = { + status: number; + success: boolean; + results: { + shipping: ShopShippingType[]; + }; +}; + +// Payment Method Types +export type PaymentMethodType = { + _id: string; + title_fa: string; + title_en: string; + description: string; + provider: string; + isActive: boolean; + type: string; + createdAt: string; + updatedAt: string; +}; + +// Payment Methods API Response Type +export type PaymentMethodsResponseType = { + status: number; + success: boolean; + results: { + paymentMethods: PaymentMethodType[]; + }; +}; diff --git a/src/app/profile/hooks/useProfileData.ts b/src/app/profile/hooks/useProfileData.ts new file mode 100644 index 0000000..ddbad18 --- /dev/null +++ b/src/app/profile/hooks/useProfileData.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import * as api from "../service/Service"; +import { useSharedStore } from "@/share/store/sharedStore"; + +export const useGetProfile = () => { + const { isLogin } = useSharedStore(); + return useQuery({ + queryKey: ["profile"], + queryFn: api.getProfile, + enabled: isLogin, + }); +}; diff --git a/src/app/profile/service/Service.ts b/src/app/profile/service/Service.ts new file mode 100644 index 0000000..d5a513a --- /dev/null +++ b/src/app/profile/service/Service.ts @@ -0,0 +1,7 @@ +import axios from "@/config/axios"; +import { UserMeResponseType } from "@/share/types/SharedTypes"; + +export const getProfile = async () => { + const { data } = await axios.get("/user/profile"); + return data; +}; diff --git a/src/components/CartSummary.tsx b/src/components/CartSummary.tsx index 3c05db6..7cf6335 100644 --- a/src/components/CartSummary.tsx +++ b/src/components/CartSummary.tsx @@ -9,6 +9,7 @@ type CartSummaryProps = { itemsCount: number; itemsPrice: number; discount: number; + shippingCost?: number; total: number; onConfirm?: () => void; confirmHref?: string; @@ -31,7 +32,7 @@ const Row: FC<{ label: string; value: string | number; emphasize?: boolean }> = }; const CartSummary: FC = (props) => { - const { itemsCount, itemsPrice, discount, total, onConfirm, onDownloadInvoice, className, confirmHref, confirmLabel } = props; + const { itemsCount, itemsPrice, discount, shippingCost, total, onConfirm, onDownloadInvoice, className, confirmHref, confirmLabel } = props; return (
@@ -43,6 +44,9 @@ const CartSummary: FC = (props) => {
+ {shippingCost !== undefined && shippingCost > 0 && ( + + )}
diff --git a/src/share/Header.tsx b/src/share/Header.tsx index b7bd089..529c60d 100644 --- a/src/share/Header.tsx +++ b/src/share/Header.tsx @@ -6,11 +6,13 @@ import { FC, Fragment, useEffect, useState } from 'react' import Menu from './components/Menu' import { useSharedStore } from './store/sharedStore' import { getToken } from '@/config/func' +import { useGetProfile } from '@/app/profile/hooks/useProfileData' const Header: FC = () => { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const { setIsLogin } = useSharedStore() + const { data: profile } = useGetProfile() const handleSetIsLogin = async () => { const token = await getToken() @@ -49,7 +51,9 @@ const Header: FC = () => {
-
حساب کاربری
+ { +
{profile?.results?.info?.fullName ? profile?.results?.info?.fullName : 'حساب کاربری'}
+ }
diff --git a/src/share/types/SharedTypes.ts b/src/share/types/SharedTypes.ts index ccaac4b..b92b04f 100644 --- a/src/share/types/SharedTypes.ts +++ b/src/share/types/SharedTypes.ts @@ -44,24 +44,42 @@ export type CategoryResponseType = ApiResponse<{ }>; export type UserMeResponseType = ApiResponse<{ - user: { - id: string; + info: { + _id: string; + fullName: string; + phoneNumber: string; + dateOfBirth: string | null; + address: string; + nationalCode: string | null; + homeNumber: string | null; createdAt: string; updatedAt: string; - firstName: string; - lastName: string; - email: string; - emailVerified: boolean; - phone: string; - birthDate: string; - nationalCode: string; - financialType: string; - profilePic: string | null; - userName: string | null; - roles: Array<{ - id: string; + userAddress: { + _id: string; + address: string; + city: { + _id: number; + name: string; + province: number; + createdAt: string; + updatedAt: string; + __v: number; + }; + province: { + _id: number; + name: string; + createdAt: string; + updatedAt: string; + __v: number; + }; + postalCode: string; + plaque: string; + lat: string; + lon: string; createdAt: string; - name: string; - }>; + updatedAt: string; + __v: number; + }; + isSeller: boolean; }; }>;