online and offline base cart

This commit is contained in:
hamid zarghami
2025-09-14 10:27:33 +03:30
parent cb5a9a8e21
commit 7b05f96576
16 changed files with 690 additions and 24 deletions
+27
View File
@@ -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,
});
};
+5
View File
@@ -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 (
<div className="mt-12 sm:mt-14 px-3 sm:px-4 md:px-8 lg:px-20">
<div className="border-b border-border flex flex-row justify-between items-center gap-3 sm:gap-4">
+27
View File
@@ -0,0 +1,27 @@
import axios from "@/config/axios";
import {
AddCartType,
UpdateCartType,
RemoveCartType,
CartResponseType,
} from "../types/Types";
export const getCart = async (): Promise<CartResponseType> => {
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;
};
+168
View File
@@ -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;
};
+22 -17
View File
@@ -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<BaseInformationProps> = ({ product }) => {
const { setVariantId, variantId } = useProductStore()
const brand = product.brand
// State برای انتخاب variant ها
const [selectedSize, setSelectedSize] = useState<string | null>(
product.variants.find(v => v.size)?.size?._id?.toString() || null
)
const [selectedColor, setSelectedColor] = useState<string | null>(
product.variants.find(v => v.color)?.color?._id?.toString() || null
)
const [selectedMeterage, setSelectedMeterage] = useState<string | null>(
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 (
<div className='flex-1'>
@@ -54,11 +59,11 @@ const BaseInformation: FC<BaseInformationProps> = ({ product }) => {
<div className='flex flex-wrap items-center gap-1.5 mb-4'>
{product.variants.map((variant) => {
if (variant.meterage) {
const isSelected = selectedMeterage === variant.meterage._id.toString()
const isSelected = variantId === variant._id
return (
<button
key={variant._id}
onClick={() => setSelectedMeterage(variant.meterage!._id.toString())}
onClick={() => handleVariantSelect(variant._id)}
className={`text-[13px] px-4 py-2 border border-border rounded-lg whitespace-nowrap transition-colors ${isSelected
? 'bg-primary text-white border-primary'
: 'hover:border-primary/50'
@@ -76,11 +81,11 @@ const BaseInformation: FC<BaseInformationProps> = ({ product }) => {
<div className='flex flex-wrap items-center gap-1.5 mb-4'>
{product.variants.map((variant) => {
if (variant.color) {
const isSelected = selectedColor === variant.color._id.toString()
const isSelected = variantId === variant._id
return (
<button
key={variant._id}
onClick={() => setSelectedColor(variant.color!._id.toString())}
onClick={() => handleVariantSelect(variant._id)}
className={clx(
'size-10 rounded-full flex justify-center items-center',
variant.color.hexColor === '#FFFFFF' ? 'border border-border' : ''
@@ -102,11 +107,11 @@ const BaseInformation: FC<BaseInformationProps> = ({ product }) => {
<div className='flex flex-wrap items-center gap-1.5'>
{product.variants.map((variant) => {
if (variant.size) {
const isSelected = selectedSize === variant.size._id.toString()
const isSelected = variantId === variant._id
return (
<button
key={variant._id}
onClick={() => setSelectedSize(variant.size!._id.toString())}
onClick={() => handleVariantSelect(variant._id)}
className={`text-[13px] px-4 py-2 border border-border rounded-lg whitespace-nowrap transition-colors ${isSelected
? 'bg-primary text-white border-primary'
: 'hover:border-primary/50'
+22
View File
@@ -0,0 +1,22 @@
import React from 'react'
import { useProductStore } from '../store/Store'
import { useSharedStore } from '@/share/store/sharedStore'
import OnlineCart from './OnlineCart'
import OfflineCart from './OfflineCart'
const Cart = () => {
const { variantId } = useProductStore()
const { isLogin } = useSharedStore()
return (
<div>
{isLogin ? (
<OnlineCart variantId={variantId} />
) : (
<OfflineCart variantId={variantId} />
)}
</div>
)
}
export default Cart
+107
View File
@@ -0,0 +1,107 @@
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 { useLocalCart } from '../hooks/useLocalCart'
interface OfflineCartProps {
variantId: string
}
const OfflineCart: React.FC<OfflineCartProps> = ({ variantId }) => {
const { id } = useParams()
// Local cart hooks for non-logged in users
const {
addToLocalCart,
updateLocalCartItem,
removeFromLocalCart,
getLocalCartItem,
} = useLocalCart()
const [isInCart, setIsInCart] = useState<boolean>(false)
const [quantity, setQuantity] = useState<number>(1)
const handleAddToCart = () => {
addToLocalCart(Number(id), variantId, 1)
setIsInCart(true)
setQuantity(1)
toast('محصول به سبد خرید اضافه شد', 'success')
}
useEffect(() => {
// Check local cart for non-logged in users
const localItem = getLocalCartItem(Number(id), variantId)
if (localItem) {
setIsInCart(true)
setQuantity(localItem.quantity)
} else {
setIsInCart(false)
}
}, [variantId, id, getLocalCartItem])
const handleQuantityChange = (number: number) => {
if (number === 0) {
handleEmptyCart()
return
}
setQuantity(number)
updateLocalCartItem(Number(id), variantId, number)
}
const handleEmptyCart = () => {
removeFromLocalCart(Number(id), variantId)
toast('محصول از سبد خرید حذف شد', 'success')
setIsInCart(false)
setQuantity(0)
}
return (
<div>
{
!isInCart ?
<Button className='w-full mt-8' onClick={handleAddToCart}>
افزودن به سبد خرید
</Button>
:
<div className={"flex items-center gap-2 sm:gap-4 mt-8"}>
<div className="h-10 sm:h-12 rounded-lg sm:rounded-xl border border-border bg-white px-2 sm:px-4 flex-1 flex items-center justify-between">
<button
type="button"
onClick={() => handleQuantityChange(quantity + 1)}
className="p-1"
aria-label="increase quantity"
>
<Add size={18} color={PRIMARY_COLOR} className="sm:w-5 sm:h-5" />
</button>
<span className="text-sm sm:text-[18px] font-medium text-primary">
{quantity}
</span>
<button
type="button"
onClick={() => handleQuantityChange(quantity - 1)}
className="p-1"
aria-label="decrease quantity"
>
<Minus size={18} color={PRIMARY_COLOR} className="sm:w-5 sm:h-5" />
</button>
</div>
<button
type="button"
onClick={handleEmptyCart}
className="h-10 w-10 sm:h-12 sm:w-12 rounded-lg sm:rounded-xl border border-border grid place-items-center bg-white"
aria-label="remove from cart"
>
<Trash size={18} color="#AD3434" className="sm:w-5 sm:h-5" />
</button>
</div>
}
</div>
)
}
export default OfflineCart
+138
View File
@@ -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<OnlineCartProps> = ({ 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<boolean>(false)
const [quantity, setQuantity] = useState<number>(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 (
<div>
{
!isInCart && !isPending ?
<Button isLoading={isAddingToCart} className='w-full mt-8' onClick={handleAddToCart}>
افزودن به سبد خرید
</Button>
:
<div className={"flex items-center gap-2 sm:gap-4 mt-8"}>
<div className="h-10 sm:h-12 rounded-lg sm:rounded-xl border border-border bg-white px-2 sm:px-4 flex-1 flex items-center justify-between">
<button
type="button"
onClick={() => handleQuantityChange(quantity + 1)}
className="disabled:opacity-40 disabled:cursor-not-allowed p-1"
aria-label="increase quantity"
disabled={isAddingToCart || isUpdatingCart}
>
<Add size={18} color={PRIMARY_COLOR} className="sm:w-5 sm:h-5" />
</button>
<span className="text-sm sm:text-[18px] font-medium text-primary">
{quantity}
</span>
<button
type="button"
onClick={() => handleQuantityChange(quantity - 1)}
className="disabled:opacity-40 disabled:cursor-not-allowed p-1"
disabled={isAddingToCart || isUpdatingCart}
aria-label="decrease quantity"
>
<Minus size={18} color={PRIMARY_COLOR} className="sm:w-5 sm:h-5" />
</button>
</div>
<button
type="button"
onClick={handleEmptyCart}
className="h-10 w-10 sm:h-12 sm:w-12 rounded-lg sm:rounded-xl border border-border grid place-items-center bg-white"
aria-label="remove from cart"
disabled={isRemovingFromCart}
>
<Trash size={18} color="#AD3434" className="sm:w-5 sm:h-5" />
</button>
</div>
}
</div>
)
}
export default OnlineCart
@@ -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) => {
</div>
</div>
<Button className='w-full mt-8'>
افزودن به سبد خرید
</Button>
<Cart />
<Button className='w-full mt-3 bg-[#323232]'>
گفتگو با فروشنده
</Button>
+63
View File
@@ -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,
};
};
+78
View File
@@ -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<LocalCartStoreType>()(
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",
}
)
);
+7
View File
@@ -0,0 +1,7 @@
import { create } from "zustand";
import { ProductStoreType } from "../types/Types";
export const useProductStore = create<ProductStoreType>((set) => ({
variantId: "",
setVariantId: (value) => set({ variantId: value }),
}));
+5
View File
@@ -11,3 +11,8 @@ export type AddQuestionProps = {
content: string;
productId?: string;
};
export type ProductStoreType = {
variantId: string;
setVariantId: (value: string) => void;
};
+1 -1
View File
@@ -29,7 +29,7 @@ const ToastContainer: React.FC = () => {
}, []);
return (
<div className="fixed top-4 text-sm right-0 left-0 mx-auto w-fit z-50 flex flex-col gap-2">
<div className="fixed top-4 text-sm right-0 left-0 mx-auto w-fit z-[9999999999] flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
+2 -2
View File
@@ -1,6 +1,6 @@
export const TOKEN_NAME = "sh_token";
export const REFRESH_TOKEN_NAME = "sh_refresh_token";
// export const BASE_URL = "https://api.shinan.ir";
export const BASE_URL = "https://api-sondos.run.danakcorp.com";
export const BASE_URL = "https://api.shinan.ir";
// export const BASE_URL = "https://api-sondos.run.danakcorp.com";
export const PRIMARY_COLOR = "#015699";
+15 -1
View File
@@ -2,11 +2,25 @@
import Input from '@/components/Input'
import { Separator } from '@/components/ui/separator'
import { DocumentText, Home, Profile, ShoppingCart, HambergerMenu } from 'iconsax-react'
import { FC, Fragment, useState } from 'react'
import { FC, Fragment, useEffect, useState } from 'react'
import Menu from './components/Menu'
import { useSharedStore } from './store/sharedStore'
import { getToken } from '@/config/func'
const Header: FC = () => {
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 (
<Fragment>