From 7b05f96576eb9b2797f94d461f111c7b0130525a Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sun, 14 Sep 2025 10:27:33 +0330 Subject: [PATCH] online and offline base cart --- src/app/cart/hooks/useCartData.ts | 27 +++ src/app/cart/page.tsx | 5 + src/app/cart/service/Service.ts | 27 +++ src/app/cart/types/Types.ts | 168 ++++++++++++++++++ .../product/components/BaseInformation.tsx | 39 ++-- src/app/product/components/Cart.tsx | 22 +++ src/app/product/components/OfflineCart.tsx | 107 +++++++++++ src/app/product/components/OnlineCart.tsx | 138 ++++++++++++++ .../product/components/ShopInformation.tsx | 6 +- src/app/product/hooks/useLocalCart.ts | 63 +++++++ src/app/product/store/LocalCartStore.ts | 78 ++++++++ src/app/product/store/Store.ts | 7 + src/app/product/types/Types.ts | 5 + src/components/Toast.tsx | 2 +- src/config/const.ts | 4 +- src/share/Header.tsx | 16 +- 16 files changed, 690 insertions(+), 24 deletions(-) create mode 100644 src/app/cart/hooks/useCartData.ts create mode 100644 src/app/cart/service/Service.ts create mode 100644 src/app/cart/types/Types.ts create mode 100644 src/app/product/components/Cart.tsx create mode 100644 src/app/product/components/OfflineCart.tsx create mode 100644 src/app/product/components/OnlineCart.tsx create mode 100644 src/app/product/hooks/useLocalCart.ts create mode 100644 src/app/product/store/LocalCartStore.ts create mode 100644 src/app/product/store/Store.ts diff --git a/src/app/cart/hooks/useCartData.ts b/src/app/cart/hooks/useCartData.ts new file mode 100644 index 0000000..51aad60 --- /dev/null +++ b/src/app/cart/hooks/useCartData.ts @@ -0,0 +1,27 @@ +import * as api from "../service/Service"; +import { useQuery, useMutation } from "@tanstack/react-query"; + +export const useGetCart = () => { + return useQuery({ + queryKey: ["cart"], + queryFn: api.getCart, + }); +}; + +export const useAddToCart = () => { + return useMutation({ + mutationFn: api.addToCart, + }); +}; + +export const useUpdateCart = () => { + return useMutation({ + mutationFn: api.updateCart, + }); +}; + +export const useRemoveFromCart = () => { + return useMutation({ + mutationFn: api.removeFromCart, + }); +}; diff --git a/src/app/cart/page.tsx b/src/app/cart/page.tsx index 8f19197..262e5fa 100644 --- a/src/app/cart/page.tsx +++ b/src/app/cart/page.tsx @@ -1,11 +1,16 @@ +'use client' import { Button } from "@/components/ui/button" import CartItem from "@/app/cart/components/CartItem" import CartSummary from "@/components/CartSummary" import Layout from "@/hoc/Layout" import { Trash } from "iconsax-react" import { NextPage } from "next" +import { useGetCart } from "./hooks/useCartData" const Cart: NextPage = () => { + + const { data } = useGetCart(); + return (
diff --git a/src/app/cart/service/Service.ts b/src/app/cart/service/Service.ts new file mode 100644 index 0000000..ad32f63 --- /dev/null +++ b/src/app/cart/service/Service.ts @@ -0,0 +1,27 @@ +import axios from "@/config/axios"; +import { + AddCartType, + UpdateCartType, + RemoveCartType, + CartResponseType, +} from "../types/Types"; + +export const getCart = async (): Promise => { + const { data } = await axios.get("/cart"); + return data; +}; + +export const addToCart = async (params: AddCartType) => { + const { data } = await axios.post("/cart/add", params); + return data; +}; + +export const updateCart = async (params: UpdateCartType) => { + const { data } = await axios.post("/cart/update", params); + return data; +}; + +export const removeFromCart = async (params: RemoveCartType) => { + const { data } = await axios.post("/cart/remove", params); + return data; +}; diff --git a/src/app/cart/types/Types.ts b/src/app/cart/types/Types.ts new file mode 100644 index 0000000..7ba2c46 --- /dev/null +++ b/src/app/cart/types/Types.ts @@ -0,0 +1,168 @@ +// Cart Item Types +export type CartItemType = { + 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: { + cover: string; + list: string[]; + }; + isFake: string; + voice: string | null; + specifications: Array<{ + title: string; + values: string[]; + }>; + category: { + _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; + }; + shipmentMethod: Array<{ + _id: number; + name: string; + description: string; + deliveryTime: number; + deliveryType: string; + }>; + warranty: { + _id: number; + duration: string; + logoUrl: string; + name: string; + }; + meterage?: { + _id: number; + value: string; + }; + size?: { + _id: number; + value: string; + }; + }; + quantity: number; +}; + +// Cart Types +export type CartType = { + _id: string; + coupon_discount: number; + items: CartItemType[]; + items_count: number; + payable_price: number; + retail_price: number; + total_discount: number; + user: { + _id: string; + fullName: string; + phoneNumber: string; + dateOfBirth: string | null; + address: { + _id: string; + }; + nationalCode: string | null; + homeNumber: string | null; + }; + isWholeSale: boolean; +}; + +// Recommended Product Type +export type RecommendedProductType = { + _id: number; + title_fa: string; + title_en: string; + seoTitle: string | null; + seoDescription: string | null; + model: string; + source: string; + description: string; + metaDescription: string; + tags: string[]; + advantages: string[]; + disAdvantages: string[]; + totalRate: number; + category: string; + shop: string; + brand: string; + status: string; + adminComments: string | null; + step: number; + isFake: boolean; + variants: string[]; + voice: string | null; + deleted: boolean; + specifications: Array<{ + title: string; + values: string[]; + }>; + createdAt: string; + updatedAt: string; + imagesUrl: { + cover: string; + list: string[]; + }; + url: string; +}; + +// Cart API Response Type +export type CartResponseType = { + status: number; + success: boolean; + results: { + cart: CartType; + recommended: RecommendedProductType[]; + }; +}; + +// Cart Action Types +export type AddCartType = { + productId: number; + variantId: string; +}; + +export type UpdateCartType = { + productId: number; + variantId: string; + quantity: number; +}; + +export type RemoveCartType = { + productId: number; + variantId: string; +}; diff --git a/src/app/product/components/BaseInformation.tsx b/src/app/product/components/BaseInformation.tsx index 9ad2505..1bf8cfe 100644 --- a/src/app/product/components/BaseInformation.tsx +++ b/src/app/product/components/BaseInformation.tsx @@ -1,27 +1,32 @@ 'use client' import { Star1 } from 'iconsax-react' -import { FC, useState } from 'react' +import { FC, useEffect } from 'react' import { Product } from '@/types/product.types' import Image from 'next/image' import { clx } from '@/helpers/utils' +import { useProductStore } from '../store/Store' interface BaseInformationProps { product: Product } const BaseInformation: FC = ({ product }) => { + + const { setVariantId, variantId } = useProductStore() + const brand = product.brand - // State برای انتخاب variant ها - const [selectedSize, setSelectedSize] = useState( - product.variants.find(v => v.size)?.size?._id?.toString() || null - ) - const [selectedColor, setSelectedColor] = useState( - product.variants.find(v => v.color)?.color?._id?.toString() || null - ) - const [selectedMeterage, setSelectedMeterage] = useState( - product.variants.find(v => v.meterage)?.meterage?._id?.toString() || null - ) + // تابع برای انتخاب variant + const handleVariantSelect = (variantId: string) => { + setVariantId(variantId) + } + + // انتخاب اولین variant به صورت پیش‌فرض + useEffect(() => { + if (product.variants.length > 0 && !variantId) { + setVariantId(product.variants[0]._id) + } + }, [product.variants, variantId, setVariantId]) return (
@@ -54,11 +59,11 @@ const BaseInformation: FC = ({ product }) => {
{product.variants.map((variant) => { if (variant.meterage) { - const isSelected = selectedMeterage === variant.meterage._id.toString() + const isSelected = variantId === variant._id return ( + : +
+
+ + + {quantity} + + +
+ + +
+ } +
+ ) +} + +export default OfflineCart diff --git a/src/app/product/components/OnlineCart.tsx b/src/app/product/components/OnlineCart.tsx new file mode 100644 index 0000000..46800c2 --- /dev/null +++ b/src/app/product/components/OnlineCart.tsx @@ -0,0 +1,138 @@ +import { useAddToCart, useGetCart, useRemoveFromCart, useUpdateCart } from '@/app/cart/hooks/useCartData' +import { Button } from '@/components/ui/button' +import React, { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import { Add, Minus, Trash } from 'iconsax-react' +import { PRIMARY_COLOR } from '@/config/const' +import { toast } from '@/components/Toast' +import { extractErrorMessage } from '@/helpers/errorUtils' + +interface OnlineCartProps { + variantId: string +} + +const OnlineCart: React.FC = ({ variantId }) => { + const { id } = useParams() + + // API hooks for logged in users + const { mutate: addToCart, isPending: isAddingToCart } = useAddToCart() + const { mutate: updateCart, isPending: isUpdatingCart } = useUpdateCart() + const { mutate: removeFromCart, isPending: isRemovingFromCart } = useRemoveFromCart() + const { data: cart, isPending } = useGetCart() + + const [isInCart, setIsInCart] = useState(false) + const [quantity, setQuantity] = useState(1) + + const handleAddToCart = () => { + addToCart({ + productId: Number(id), + variantId: variantId, + }, { + onSuccess: () => { + setIsInCart(true) + setQuantity(1) + toast('محصول به سبد خرید اضافه شد', 'success') + }, + onError: (error: Error) => { + toast(extractErrorMessage(error), 'error') + } + }) + } + + useEffect(() => { + // Check API cart for logged in users + const item = cart?.results?.cart?.items?.find(item => item.variant?._id === variantId) + if (item) { + setIsInCart(true) + setQuantity(item.quantity) + } else { + setIsInCart(false) + } + }, [cart, variantId]) + + const handleQuantityChange = (number: number) => { + if (number === 0) { + handleEmptyCart() + return + } + + const oldQuantity = quantity + setQuantity(number) + + updateCart({ + productId: Number(id), + variantId: variantId, + quantity: number, + }, { + onError: (error: Error) => { + toast(extractErrorMessage(error), 'error') + setQuantity(oldQuantity) + } + }) + } + + const handleEmptyCart = () => { + removeFromCart({ + productId: Number(id), + variantId: variantId, + }, { + onError: (error: Error) => { + toast(extractErrorMessage(error), 'error') + }, + onSuccess: () => { + toast('محصول از سبد خرید حذف شد', 'success') + setIsInCart(false) + setQuantity(0) + } + }) + } + + return ( +
+ { + !isInCart && !isPending ? + + : +
+
+ + + {quantity} + + +
+ + +
+ } +
+ ) +} + +export default OnlineCart diff --git a/src/app/product/components/ShopInformation.tsx b/src/app/product/components/ShopInformation.tsx index b9bf135..1d87107 100644 --- a/src/app/product/components/ShopInformation.tsx +++ b/src/app/product/components/ShopInformation.tsx @@ -5,12 +5,14 @@ import { BoxTick, I3DRotate } from 'iconsax-react' import React from 'react' import { Product } from '@/types/product.types' import Image from 'next/image' +import Cart from './Cart' interface ShopInformationProps { product: Product } const ShopInformation = ({ product }: ShopInformationProps) => { + const defaultVariant = product.default_variant const shop = defaultVariant.shop const price = defaultVariant.price @@ -94,9 +96,7 @@ const ShopInformation = ({ product }: ShopInformationProps) => {
- + diff --git a/src/app/product/hooks/useLocalCart.ts b/src/app/product/hooks/useLocalCart.ts new file mode 100644 index 0000000..f60a546 --- /dev/null +++ b/src/app/product/hooks/useLocalCart.ts @@ -0,0 +1,63 @@ +import { useLocalCartStore, LocalCartItem } from "../store/LocalCartStore"; + +export const useLocalCart = () => { + const { + items, + addItem, + updateItem, + removeItem, + getItem, + clearCart, + } = useLocalCartStore(); + + const addToLocalCart = ( + productId: number, + variantId: string, + quantity: number = 1 + ) => { + addItem({ productId, variantId, quantity }); + }; + + const updateLocalCartItem = ( + productId: number, + variantId: string, + quantity: number + ) => { + if (quantity <= 0) { + removeFromLocalCart(productId, variantId); + } else { + updateItem(productId, variantId, quantity); + } + }; + + const removeFromLocalCart = (productId: number, variantId: string) => { + removeItem(productId, variantId); + }; + + const getLocalCartItem = (productId: number, variantId: string) => { + return getItem(productId, variantId); + }; + + const getLocalCartItems = () => { + return items; + }; + + const getLocalCartItemsCount = () => { + return items.reduce((total, item) => total + item.quantity, 0); + }; + + const clearLocalCart = () => { + clearCart(); + }; + + return { + localCartItems: items, + addToLocalCart, + updateLocalCartItem, + removeFromLocalCart, + getLocalCartItem, + getLocalCartItems, + getLocalCartItemsCount, + clearLocalCart, + }; +}; diff --git a/src/app/product/store/LocalCartStore.ts b/src/app/product/store/LocalCartStore.ts new file mode 100644 index 0000000..f6a4f58 --- /dev/null +++ b/src/app/product/store/LocalCartStore.ts @@ -0,0 +1,78 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type LocalCartItem = { + productId: number; + variantId: string; + quantity: number; +}; + +export type LocalCartStoreType = { + items: LocalCartItem[]; + addItem: (item: LocalCartItem) => void; + updateItem: (productId: number, variantId: string, quantity: number) => void; + removeItem: (productId: number, variantId: string) => void; + getItem: (productId: number, variantId: string) => LocalCartItem | undefined; + clearCart: () => void; +}; + +export const useLocalCartStore = create()( + persist( + (set, get) => ({ + items: [], + + addItem: (item: LocalCartItem) => { + const existingItem = get().items.find( + (i) => + i.productId === item.productId && i.variantId === item.variantId + ); + + if (existingItem) { + set((state) => ({ + items: state.items.map((i) => + i.productId === item.productId && i.variantId === item.variantId + ? { ...i, quantity: i.quantity + item.quantity } + : i + ), + })); + } else { + set((state) => ({ + items: [...state.items, item], + })); + } + }, + + updateItem: (productId: number, variantId: string, quantity: number) => { + set((state) => ({ + items: state.items.map((item) => + item.productId === productId && item.variantId === variantId + ? { ...item, quantity } + : item + ), + })); + }, + + removeItem: (productId: number, variantId: string) => { + set((state) => ({ + items: state.items.filter( + (item) => + !(item.productId === productId && item.variantId === variantId) + ), + })); + }, + + getItem: (productId: number, variantId: string) => { + return get().items.find( + (item) => item.productId === productId && item.variantId === variantId + ); + }, + + clearCart: () => { + set({ items: [] }); + }, + }), + { + name: "local-cart-storage", + } + ) +); diff --git a/src/app/product/store/Store.ts b/src/app/product/store/Store.ts new file mode 100644 index 0000000..b776702 --- /dev/null +++ b/src/app/product/store/Store.ts @@ -0,0 +1,7 @@ +import { create } from "zustand"; +import { ProductStoreType } from "../types/Types"; + +export const useProductStore = create((set) => ({ + variantId: "", + setVariantId: (value) => set({ variantId: value }), +})); diff --git a/src/app/product/types/Types.ts b/src/app/product/types/Types.ts index c3890a6..d8e0db6 100644 --- a/src/app/product/types/Types.ts +++ b/src/app/product/types/Types.ts @@ -11,3 +11,8 @@ export type AddQuestionProps = { content: string; productId?: string; }; + +export type ProductStoreType = { + variantId: string; + setVariantId: (value: string) => void; +}; diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index a692a46..351bea5 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -29,7 +29,7 @@ const ToastContainer: React.FC = () => { }, []); return ( -
+
{toasts.map((toast) => (
{ + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) + const { setIsLogin } = useSharedStore() + + const handleSetIsLogin = async () => { + const token = await getToken() + setIsLogin(token ? true : false) + } + + useEffect(() => { + handleSetIsLogin() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) return (