This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCart } from "@/app/[name]/(Dialogs)/cart/hook/useCart";
|
||||
import { useCart } from "@/app/[name]/(Main)/cart/hook/useCart";
|
||||
import MinusIcon from "@/components/icons/MinusIcon";
|
||||
import PlusIcon from "@/components/icons/PlusIcon";
|
||||
import { toast } from "@/components/Toast";
|
||||
|
||||
@@ -9,6 +9,8 @@ export const useGetAbout = () => {
|
||||
queryFn: () => api.getAbout(name),
|
||||
enabled: !!name,
|
||||
retry: false,
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Trash } from 'iconsax-react';
|
||||
import Image from 'next/image';
|
||||
import { useCart } from './hook/useCart';
|
||||
import CartSummary from './components/CartSummary';
|
||||
import Prompt from '@/components/utils/Prompt';
|
||||
import { useGetFoods } from '@/app/[name]/(Main)/hooks/useMenuData';
|
||||
import MenuItem from '@/components/listview/MenuItem';
|
||||
import MenuItemRenderer from '@/components/listview/MenuItemRenderer';
|
||||
import { useGetAbout } from '../about/hooks/useAboutData';
|
||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
||||
import { useGetCartItems } from './hooks/useCartData';
|
||||
import PagerModal from '@/components/pager/PagerModal';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import NotificationBellIcon from '@/components/icons/NotificationBellIcon';
|
||||
import CartSkeleton from './components/CartSkeleton';
|
||||
import {
|
||||
cartItemToFood,
|
||||
guestCartItemsToFoods,
|
||||
hasCartEntries,
|
||||
} from './lib/cartUtils';
|
||||
|
||||
const CartPage = () => {
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'Cart' });
|
||||
const router = useRouter();
|
||||
const { isSuccess } = useGetProfile();
|
||||
const { clearCart, items } = useCart();
|
||||
const { data: cartData, isPending: cartPending } = useGetCartItems(isSuccess);
|
||||
const { data: foodsResponse } = useGetFoods();
|
||||
const { data: aboutData } = useGetAbout();
|
||||
const [showClearConfirm, setShowClearConfirm] = React.useState(false);
|
||||
const [showPagerModal, setShowPagerModal] = React.useState(false);
|
||||
|
||||
const isPremium = aboutData?.data?.plan === 'premium';
|
||||
|
||||
const cartFoods = React.useMemo(() => {
|
||||
if (isSuccess) {
|
||||
const apiItems =
|
||||
cartData?.data?.items?.filter((item) => item.quantity > 0) ?? [];
|
||||
return apiItems.map(cartItemToFood);
|
||||
}
|
||||
return guestCartItemsToFoods(items, foodsResponse?.data ?? []);
|
||||
}, [isSuccess, cartData?.data?.items, items, foodsResponse?.data]);
|
||||
|
||||
const hasItems = React.useMemo(() => {
|
||||
if (isSuccess) {
|
||||
return (cartData?.data?.totalItems ?? 0) > 0 || hasCartEntries(items);
|
||||
}
|
||||
return hasCartEntries(items);
|
||||
}, [isSuccess, cartData?.data?.totalItems, items]);
|
||||
|
||||
const guestAwaitingMenu =
|
||||
!isSuccess && hasItems && cartFoods.length === 0 && !foodsResponse?.data;
|
||||
|
||||
const showInitialCartLoad = isSuccess && cartPending && !cartData && hasItems;
|
||||
|
||||
const isCartEmpty = !hasItems;
|
||||
|
||||
const handleClearCart = (e: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
clearCart();
|
||||
setShowClearConfirm(false);
|
||||
};
|
||||
|
||||
const toggleClearConfirm = (e: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
setShowClearConfirm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col bg-background'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
{isCartEmpty ? (
|
||||
<span></span>
|
||||
) : (
|
||||
<Trash
|
||||
className='cursor-pointer place-self-start'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
<h1 className='text-sm2 place-self-center font-medium'>{t('Heading')}</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex-1 h-full">
|
||||
{showInitialCartLoad || guestAwaitingMenu ? (
|
||||
<CartSkeleton />
|
||||
) : isCartEmpty ? (
|
||||
<div className='flex flex-col items-center justify-center gap-4 h-full'>
|
||||
<Image
|
||||
src='/assets/images/cart.png'
|
||||
alt='cart'
|
||||
width={120}
|
||||
height={120}
|
||||
className='object-contain'
|
||||
/>
|
||||
<div className='flex flex-col items-center gap-2 text-sm2 text-muted-foreground'>
|
||||
<p>{t('EmptyStateTitle', { defaultValue: 'سبد خرید شما خالی است' })}</p>
|
||||
<p className='text-xs'>
|
||||
{t('EmptyStateDescription', {
|
||||
defaultValue: 'برای افزودن غذا، به منوی رستوران برگردید.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 pb-24">
|
||||
{!isPremium && (
|
||||
<div className='bg-container rounded-container p-4 border border-border shadow-sm'>
|
||||
<div className='flex items-start gap-3 mb-4'>
|
||||
<div className='shrink-0 mt-0.5'>
|
||||
<NotificationBellIcon width={24} height={24} className="currentColor" />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='text-sm font-medium text-foreground mb-1'>
|
||||
ثبت سفارش
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground leading-5'>
|
||||
برای ثبت سفارش، لطفاً گارسون را صدا کنید
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowPagerModal(true)}
|
||||
className='w-full dark:bg-white! dark:text-black! flex items-center justify-center gap-2'
|
||||
>
|
||||
<NotificationBellIcon width={18} height={18} className="text-white dark:text-black!" />
|
||||
<span>صدا کردن گارسون</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{cartFoods.map((food) => (
|
||||
<MenuItemRenderer key={food.id}>
|
||||
<MenuItem food={food} />
|
||||
</MenuItemRenderer>
|
||||
))}
|
||||
<CartSummary isPremium={isPremium} hasItems={hasItems} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={showClearConfirm ? 'fixed inset-0 z-1001' : 'pointer-events-none fixed inset-0 z-1001'}>
|
||||
<Prompt
|
||||
title='پاک کردن سبد خرید'
|
||||
description='آیا از پاک کردن تمامی آیتمهای سبد خرید اطمینان دارید؟'
|
||||
textConfirm='بله'
|
||||
textCancel='خیر'
|
||||
onConfirm={handleClearCart}
|
||||
onCancel={toggleClearConfirm}
|
||||
visible={showClearConfirm}
|
||||
onClick={toggleClearConfirm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PagerModal visible={showPagerModal} onClose={() => setShowPagerModal(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartPage;
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import MenuItem from '@/components/listview/MenuItem';
|
||||
import MenuItemRenderer from '@/components/listview/MenuItemRenderer';
|
||||
import { useGetFoods } from '@/app/[name]/(Main)/hooks/useMenuData';
|
||||
import type { Food } from '@/app/[name]/(Main)/types/Types';
|
||||
import { useCart } from '../hook/useCart';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CartItemsSkeleton } from './CartSkeleton';
|
||||
|
||||
const CartItemsList = () => {
|
||||
const { data: foodsResponse, isFetching } = useGetFoods();
|
||||
const foods = React.useMemo(() => foodsResponse?.data ?? [], [foodsResponse?.data]);
|
||||
const { items } = useCart();
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'Cart' });
|
||||
|
||||
const cartFoods = React.useMemo(() => {
|
||||
if (!foods.length) return [];
|
||||
return Object.entries(items)
|
||||
.filter(([, detail]) => detail?.quantity && detail.quantity > 0)
|
||||
.map(([id]) =>
|
||||
foods.find((foodItem) => String(foodItem.id) === String(id))
|
||||
)
|
||||
.filter((food): food is Food => Boolean(food));
|
||||
}, [foods, items]);
|
||||
|
||||
if (isFetching && !foods.length) {
|
||||
return <CartItemsSkeleton />;
|
||||
}
|
||||
|
||||
if (cartFoods.length === 0) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center gap-2 py-12 text-sm2 text-muted-foreground'>
|
||||
<p>{t('EmptyStateTitle', { defaultValue: 'سبد خرید شما خالی است' })}</p>
|
||||
<p className='text-xs'>
|
||||
{t('EmptyStateDescription', {
|
||||
defaultValue: 'برای افزودن غذا، به منوی رستوران برگردید.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex-1 space-y-4'>
|
||||
{cartFoods.map((food) => (
|
||||
<MenuItemRenderer key={food.id}>
|
||||
<MenuItem food={food} />
|
||||
</MenuItemRenderer>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartItemsList;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import MenuItemRenderer from '@/components/listview/MenuItemRenderer';
|
||||
|
||||
export const CartItemsSkeleton = () => {
|
||||
return (
|
||||
<div className='flex-1 space-y-4'>
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<MenuItemRenderer key={index}>
|
||||
<div className='flex gap-4 w-full h-full items-center'>
|
||||
<Skeleton className='min-w-28 w-28 h-28 rounded-xl' />
|
||||
<div className='w-full inline-flex flex-col justify-between'>
|
||||
<div>
|
||||
<Skeleton className='h-5 w-32 rounded-md mb-2' />
|
||||
<Skeleton className='h-4 w-full rounded-md mb-1' />
|
||||
<Skeleton className='h-4 w-3/4 rounded-md' />
|
||||
</div>
|
||||
<div className='inline-flex mt-2 gap-2 justify-between w-full items-center'>
|
||||
<div className='w-full flex flex-col gap-1'>
|
||||
<Skeleton className='h-4 w-16 rounded-md' />
|
||||
<Skeleton className='h-4 w-20 rounded-md mt-1' />
|
||||
</div>
|
||||
<Skeleton className='max-w-[115px] w-full h-8 rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItemRenderer>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CartSummarySkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<div className='mt-2'>
|
||||
<div className='relative'>
|
||||
<Skeleton className='h-5 w-32 rounded-md mb-4' />
|
||||
<Skeleton className='w-full h-24 rounded-normal' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-border p-4 w-full'>
|
||||
<div className='flex justify-between items-center gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<Skeleton className='h-3 w-24 rounded-md mb-2' />
|
||||
<Skeleton className='h-5 w-32 rounded-md' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-32 rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CartSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<CartItemsSkeleton />
|
||||
<CartSummarySkeleton />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartSkeleton;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { useGetFoods } from '@/app/[name]/(Main)/hooks/useMenuData';
|
||||
import { useCart } from '../hook/useCart';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
||||
import { useGetCartItems } from '../hooks/useCartData';
|
||||
import { useCartStore } from '../store/Store';
|
||||
import { guestCartItemsToFoods } from '../lib/cartUtils';
|
||||
|
||||
interface CartSummaryProps {
|
||||
isPremium: boolean;
|
||||
hasItems: boolean;
|
||||
}
|
||||
|
||||
const CartSummary = ({ isPremium, hasItems }: CartSummaryProps) => {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const name = params.name as string;
|
||||
const { isSuccess } = useGetProfile();
|
||||
const { data: foodsResponse } = useGetFoods();
|
||||
const { items } = useCart();
|
||||
const { data: cartData } = useGetCartItems(isSuccess);
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'Cart' });
|
||||
const { description, setDescription } = useCartStore();
|
||||
|
||||
const totalPrice = React.useMemo(() => {
|
||||
if (isSuccess && cartData?.data?.total !== undefined) {
|
||||
return cartData.data.total;
|
||||
}
|
||||
|
||||
const foods = foodsResponse?.data ?? [];
|
||||
const cartFoods = guestCartItemsToFoods(items, foods);
|
||||
return cartFoods.reduce((sum, food) => {
|
||||
const qty = items[String(food.id)]?.quantity ?? 0;
|
||||
const basePrice = food.price ?? 0;
|
||||
const discount = food.discount ?? 0;
|
||||
const priceAfterDiscount = basePrice * (1 - discount / 100);
|
||||
return sum + priceAfterDiscount * qty;
|
||||
}, 0);
|
||||
}, [isSuccess, cartData?.data?.total, foodsResponse?.data, items]);
|
||||
|
||||
const formatPrice = React.useCallback(
|
||||
(value: number) => value.toLocaleString('fa-IR'),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!hasItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPremium && (
|
||||
<div className='mt-2 mb-4'>
|
||||
<div className='relative'>
|
||||
<h3 className='text-sm'>{t('InputDescription.Label')}</h3>
|
||||
<textarea
|
||||
className='w-full px-4 py-2.5 mt-4 text-sm2 leading-6 outline-0 border border-border rounded-normal resize-none focus:ring-0'
|
||||
placeholder={t('InputDescription.Placeholder')}
|
||||
id='description'
|
||||
name='description'
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
></textarea>
|
||||
<span className='absolute top-4 right-2 px-2 bg-background text-foreground text-xs'></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-container border-t border-border p-4 w-full'>
|
||||
<div className='flex justify-between items-center gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='text-xs text-gray-400 dark:text-disabled-text'>{t('PayableAmountLabel')}</div>
|
||||
{isPremium && (
|
||||
<div className='text-sm mt-2 font-semibold dark:text-foreground'>
|
||||
{formatPrice(totalPrice)} تومان
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPremium && (
|
||||
<Link
|
||||
href={'order/checkout/0'}
|
||||
onClick={(event) => {
|
||||
if (!isSuccess) {
|
||||
event.preventDefault();
|
||||
const redirectUrl = encodeURIComponent(pathname);
|
||||
router.replace(`/${name}/auth?redirect=${redirectUrl}`);
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button className='w-fit! px-10 dark:bg-white dark:text-black! dark:hover:bg-gray-100'>
|
||||
{t('ButtonCheckout')}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{!isPremium && (
|
||||
<div className='text-sm mt-2 font-semibold dark:text-foreground'>
|
||||
{formatPrice(totalPrice)} تومان
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartSummary;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData";
|
||||
import { useReceiptStore } from "@/zustand/receiptStore";
|
||||
import {
|
||||
useClearCart,
|
||||
useDecrementCart,
|
||||
useGetCartItems,
|
||||
useIncrementCart,
|
||||
} from "../hooks/useCartData";
|
||||
|
||||
export const useCart = () => {
|
||||
const { isSuccess } = useGetProfile();
|
||||
const params = useParams();
|
||||
const currentSlug = (params.name as string) || null;
|
||||
const { clear, increment, decrement, items, slug, setSlug } = useReceiptStore();
|
||||
const { mutate: mutateIncrementCart } = useIncrementCart();
|
||||
const { mutate: mutateDecrementCart } = useDecrementCart();
|
||||
const { mutate: mutateClearCart } = useClearCart();
|
||||
const { data: cartItems } = useGetCartItems(isSuccess);
|
||||
|
||||
const addToCart = (id: string | number) => {
|
||||
if (isSuccess) {
|
||||
mutateIncrementCart(id);
|
||||
} else {
|
||||
// تنظیم slug هنگام افزودن آیتم به سبد (فقط برای کاربران لاگین نشده)
|
||||
if (currentSlug && slug !== currentSlug) {
|
||||
setSlug(currentSlug);
|
||||
}
|
||||
increment(id);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFromCart = (id: string | number) => {
|
||||
if (isSuccess) {
|
||||
mutateDecrementCart(id);
|
||||
} else {
|
||||
decrement(id);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCart = useCallback(() => {
|
||||
if (isSuccess) {
|
||||
mutateClearCart();
|
||||
} else {
|
||||
clear();
|
||||
}
|
||||
}, [isSuccess, mutateClearCart, clear]);
|
||||
|
||||
const cartItemsMap = useMemo(() => {
|
||||
if (isSuccess) {
|
||||
if (!cartItems?.data?.items) {
|
||||
return {};
|
||||
}
|
||||
return cartItems.data.items.reduce((acc, item) => {
|
||||
acc[item.foodId] = { quantity: item.quantity };
|
||||
return acc;
|
||||
}, {} as Record<string | number, { quantity: number }>);
|
||||
} else {
|
||||
return items;
|
||||
}
|
||||
}, [isSuccess, cartItems?.data?.items, items]);
|
||||
|
||||
const isItemLoading = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
// برای کاربران لاگین شده، slug از API میآید (در cartItems.data.restaurantId)
|
||||
// برای کاربران لاگین نشده، slug از receiptStore میآید
|
||||
const cartSlug = useMemo(() => {
|
||||
if (isSuccess && cartItems?.data?.restaurantId) {
|
||||
// اگر از API آمده، میتوانیم restaurantId را برگردانیم
|
||||
// اما بهتر است slug را از URL بگیریم چون API ممکن است restaurantId را برگرداند نه slug
|
||||
return currentSlug;
|
||||
}
|
||||
return slug;
|
||||
}, [isSuccess, cartItems?.data?.restaurantId, currentSlug, slug]);
|
||||
|
||||
return {
|
||||
items: cartItemsMap,
|
||||
slug: cartSlug,
|
||||
increment,
|
||||
decrement,
|
||||
clear,
|
||||
addToCart,
|
||||
removeFromCart,
|
||||
clearCart,
|
||||
isItemLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as api from "../service/CartService";
|
||||
import type { CartResponse, CartItem } from "../types/Types";
|
||||
import type { FoodsResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export const useBulkCart = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.bulkCart,
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["cart-items"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useIncrementCart = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.increment,
|
||||
onMutate: async (id: string | number) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["cart-items"] });
|
||||
await queryClient.cancelQueries({ queryKey: ["menu"] });
|
||||
|
||||
const previousCart = queryClient.getQueryData<CartResponse>([
|
||||
"cart-items",
|
||||
]);
|
||||
const foodsData = queryClient.getQueryData<FoodsResponse>(["menu"]);
|
||||
|
||||
if (previousCart?.data && foodsData?.data) {
|
||||
const existingItem = previousCart.data.items.find(
|
||||
(item) => item.foodId === String(id)
|
||||
);
|
||||
|
||||
let updatedItems: CartItem[];
|
||||
let newTotalItems: number;
|
||||
|
||||
if (existingItem) {
|
||||
updatedItems = previousCart.data.items.map((item) => {
|
||||
if (item.foodId === String(id)) {
|
||||
return {
|
||||
...item,
|
||||
quantity: item.quantity + 1,
|
||||
totalPrice: item.price * (item.quantity + 1) * (1 - item.discount / 100),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
newTotalItems = previousCart.data.totalItems + 1;
|
||||
} else {
|
||||
const food = foodsData.data.find((f) => String(f.id) === String(id));
|
||||
if (food) {
|
||||
const newItem: CartItem = {
|
||||
foodId: String(id),
|
||||
foodTitle: food.title,
|
||||
quantity: 1,
|
||||
price: food.price,
|
||||
discount: food.discount || 0,
|
||||
totalPrice: food.price * (1 - (food.discount || 0) / 100),
|
||||
};
|
||||
updatedItems = [...previousCart.data.items, newItem];
|
||||
newTotalItems = previousCart.data.totalItems + 1;
|
||||
} else {
|
||||
return { previousCart };
|
||||
}
|
||||
}
|
||||
|
||||
// محاسبه subTotal جدید
|
||||
const newSubTotal = updatedItems.reduce(
|
||||
(sum, item) => sum + item.totalPrice,
|
||||
0
|
||||
);
|
||||
|
||||
// محاسبه total جدید (با حفظ deliveryFee، tax و discount)
|
||||
const newTotal =
|
||||
newSubTotal +
|
||||
(previousCart.data.deliveryFee || 0) -
|
||||
(previousCart.data.totalDiscount || 0) +
|
||||
(previousCart.data.tax || 0);
|
||||
|
||||
queryClient.setQueryData<CartResponse>(["cart-items"], {
|
||||
...previousCart,
|
||||
data: {
|
||||
...previousCart.data,
|
||||
items: updatedItems,
|
||||
totalItems: newTotalItems,
|
||||
subTotal: newSubTotal,
|
||||
total: newTotal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { previousCart };
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previousCart) {
|
||||
queryClient.setQueryData(["cart-items"], context.previousCart);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["cart-items"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDecrementCart = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.decrement,
|
||||
onMutate: async (id: string | number) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["cart-items"] });
|
||||
|
||||
const previousCart = queryClient.getQueryData<CartResponse>([
|
||||
"cart-items",
|
||||
]);
|
||||
|
||||
if (previousCart?.data) {
|
||||
const updatedItems = previousCart.data.items
|
||||
.map((item: CartItem) => {
|
||||
if (item.foodId === String(id)) {
|
||||
const newQuantity = item.quantity - 1;
|
||||
if (newQuantity <= 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
quantity: newQuantity,
|
||||
totalPrice: item.price * newQuantity * (1 - item.discount / 100),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter((item: CartItem | null): item is CartItem => item !== null);
|
||||
|
||||
// محاسبه subTotal جدید
|
||||
const newSubTotal = updatedItems.reduce(
|
||||
(sum, item) => sum + item.totalPrice,
|
||||
0
|
||||
);
|
||||
|
||||
// محاسبه total جدید
|
||||
const newTotal =
|
||||
newSubTotal +
|
||||
(previousCart.data.deliveryFee || 0) -
|
||||
(previousCart.data.totalDiscount || 0) +
|
||||
(previousCart.data.tax || 0);
|
||||
|
||||
queryClient.setQueryData<CartResponse>(["cart-items"], {
|
||||
...previousCart,
|
||||
data: {
|
||||
...previousCart.data,
|
||||
items: updatedItems,
|
||||
totalItems: Math.max(0, previousCart.data.totalItems - 1),
|
||||
subTotal: newSubTotal,
|
||||
total: newTotal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { previousCart };
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previousCart) {
|
||||
queryClient.setQueryData(["cart-items"], context.previousCart);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["cart-items"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useClearCart = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.clear,
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["cart-items"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCartItems = (enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: ["cart-items"],
|
||||
queryFn: api.getCartItems,
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSaveAllMethod = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.saveAllMethod,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { CartItem } from '../types/Types';
|
||||
import type { Food } from '@/app/[name]/(Main)/types/Types';
|
||||
|
||||
export function cartItemToFood(item: CartItem): Food {
|
||||
return {
|
||||
id: item.foodId,
|
||||
category: '',
|
||||
title: item.foodTitle,
|
||||
desc: '',
|
||||
content: [],
|
||||
price: item.price,
|
||||
prepareTime: 0,
|
||||
isActive: true,
|
||||
images: [],
|
||||
inPlaceServe: true,
|
||||
pickupServe: true,
|
||||
discount: item.discount,
|
||||
isSpecialOffer: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function guestCartItemsToFoods(
|
||||
items: Record<string | number, { quantity: number }>,
|
||||
foods: Food[]
|
||||
): Food[] {
|
||||
return Object.entries(items)
|
||||
.filter(([, detail]) => detail?.quantity && detail.quantity > 0)
|
||||
.map(([id]) => foods.find((food) => String(food.id) === String(id)))
|
||||
.filter((food): food is Food => Boolean(food));
|
||||
}
|
||||
|
||||
export function hasCartEntries(
|
||||
items: Record<string | number, { quantity: number }>
|
||||
): boolean {
|
||||
return Object.values(items).some((detail) => detail?.quantity && detail.quantity > 0);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import CartPage from './CartPage';
|
||||
|
||||
export default CartPage;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { api } from "@/config/axios";
|
||||
import { BulkCartItem, CartResponse, SaveAllMethod } from "../types/Types";
|
||||
|
||||
export const bulkCart = async (params: BulkCartItem) => {
|
||||
const { data } = await api.post("/public/cart/items/bulk", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const increment = async (id: string | number) => {
|
||||
const { data } = await api.post(`/public/cart/items/${id}/increment`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const decrement = async (id: string | number) => {
|
||||
const { data } = await api.post(`/public/cart/items/${id}/decrement`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const clear = async () => {
|
||||
const { data } = await api.delete("/public/cart");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCartItems = async (): Promise<CartResponse> => {
|
||||
const { data } = await api.get<CartResponse>("/public/cart");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCartSummary = async () => {
|
||||
const { data } = await api.get("/public/cart/summary");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const saveAllMethod = async (params: SaveAllMethod) => {
|
||||
const { data } = await api.patch("/public/cart/all", params);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { CartStoreType, SaveAllMethod } from "../types/Types";
|
||||
|
||||
export const useCartStore = create<CartStoreType>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
description: "",
|
||||
setDescription: (value) => set({ description: value }),
|
||||
deliveryMethodId: null,
|
||||
setDeliveryMethodId: (value) => set({ deliveryMethodId: value }),
|
||||
addressId: null,
|
||||
setAddressId: (value) => set({ addressId: value }),
|
||||
paymentMethodId: null,
|
||||
setPaymentMethodId: (value) => set({ paymentMethodId: value }),
|
||||
carAddress: null,
|
||||
setCarAddress: (value) => set({ carAddress: value }),
|
||||
reset: () =>
|
||||
set({
|
||||
description: "",
|
||||
deliveryMethodId: null,
|
||||
addressId: null,
|
||||
paymentMethodId: null,
|
||||
carAddress: null,
|
||||
}),
|
||||
getSaveAllMethodData: (): SaveAllMethod | null => {
|
||||
const state = get();
|
||||
if (!state.deliveryMethodId || !state.paymentMethodId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
description: state.description,
|
||||
deliveryMethodId: state.deliveryMethodId,
|
||||
...(state.addressId && { addressId: state.addressId }),
|
||||
paymentMethodId: state.paymentMethodId,
|
||||
...(state.carAddress && { carAddress: state.carAddress }),
|
||||
};
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "cart-storage",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,124 @@
|
||||
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export type BulkCartItem = {
|
||||
items: {
|
||||
foodId: string;
|
||||
quantity: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export interface CartItem {
|
||||
foodId: string;
|
||||
foodTitle: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
discount: number;
|
||||
totalPrice: number;
|
||||
}
|
||||
|
||||
export interface Coupon {
|
||||
couponId: string;
|
||||
couponName: string;
|
||||
couponCode: string;
|
||||
}
|
||||
|
||||
export interface CartData {
|
||||
userId: string;
|
||||
restaurantId: string;
|
||||
restaurantName: string;
|
||||
items: CartItem[];
|
||||
deliveryFee: number;
|
||||
subTotal: number;
|
||||
itemsDiscount: number;
|
||||
couponDiscount: number;
|
||||
totalDiscount: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deliveryMethodId?: string;
|
||||
addressId?: string;
|
||||
paymentMethodId?: string;
|
||||
coupon?: Coupon;
|
||||
}
|
||||
|
||||
export interface CartCategory {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
restaurant: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface CartFood {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: string;
|
||||
category: CartCategory;
|
||||
title: string;
|
||||
desc: string;
|
||||
content: string | string[] | null;
|
||||
price: number;
|
||||
points: number | null;
|
||||
order: number | null;
|
||||
prepareTime: number | null;
|
||||
sat: boolean;
|
||||
sun: boolean;
|
||||
mon: boolean;
|
||||
breakfast: boolean;
|
||||
noon: boolean;
|
||||
dinner: boolean;
|
||||
stock: number;
|
||||
stockDefault: number;
|
||||
isActive: boolean;
|
||||
images: string[];
|
||||
inPlaceServe: boolean;
|
||||
pickupServe: boolean;
|
||||
rate: number;
|
||||
discount: number;
|
||||
}
|
||||
|
||||
export type CartResponse = BaseResponse<CartData>;
|
||||
|
||||
export type SaveAllMethod = {
|
||||
description: string;
|
||||
deliveryMethodId: string;
|
||||
addressId?: string;
|
||||
paymentMethodId: string;
|
||||
carAddress?: {
|
||||
carModel: string;
|
||||
carColor: string;
|
||||
plateNumber: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CartStoreType = {
|
||||
description: string;
|
||||
setDescription: (value: string) => void;
|
||||
deliveryMethodId: string | null;
|
||||
setDeliveryMethodId: (value: string | null) => void;
|
||||
addressId: string | null;
|
||||
setAddressId: (value: string | null) => void;
|
||||
paymentMethodId: string | null;
|
||||
setPaymentMethodId: (value: string | null) => void;
|
||||
carAddress: {
|
||||
carModel: string;
|
||||
carColor: string;
|
||||
plateNumber: string;
|
||||
} | null;
|
||||
setCarAddress: (
|
||||
value: {
|
||||
carModel: string;
|
||||
carColor: string;
|
||||
plateNumber: string;
|
||||
} | null
|
||||
) => void;
|
||||
reset: () => void;
|
||||
getSaveAllMethodData: () => SaveAllMethod | null;
|
||||
};
|
||||
@@ -8,6 +8,8 @@ export const useGetFoods = () => {
|
||||
queryKey: ["menu"],
|
||||
queryFn: () => api.getFoods(name),
|
||||
enabled: !!name,
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user