fast navigate + cache
Build and Deploy Docker Images / build_and_deploy (push) Has been cancelled

This commit is contained in:
hamid zarghami
2026-06-03 15:08:09 +03:30
parent b9608cd486
commit 194533ec5f
27 changed files with 218 additions and 194 deletions
@@ -1,134 +0,0 @@
'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 type { Food } from '@/app/[name]/(Main)/types/Types';
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';
interface CartSummaryProps {
isPremium: boolean;
}
const CartSummary = ({ isPremium }: CartSummaryProps) => {
const params = useParams();
const router = useRouter();
const pathname = usePathname();
const name = params.name as string;
const { isSuccess } = useGetProfile();
const { data: foodsResponse } = useGetFoods();
const foods = React.useMemo(() => foodsResponse?.data ?? [], [foodsResponse?.data]);
const { items } = useCart();
const { data: cartData } = useGetCartItems(isSuccess);
const { t } = useTranslation('parallels', { keyPrefix: 'Cart' });
const { description, setDescription } = useCartStore();
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]);
const totalPrice = React.useMemo(() => {
// اگر داده از API آمده و total وجود دارد، از آن استفاده می‌کنیم
if (isSuccess && cartData?.data?.total !== undefined) {
return cartData.data.total;
}
// در غیر این صورت، محاسبه محلی با در نظر گرفتن تخفیف
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);
}, [cartFoods, items, isSuccess, cartData?.data?.total]);
const isCartEmpty = cartFoods.length === 0;
const formatPrice = React.useCallback(
(value: number) => value.toLocaleString('fa-IR'),
[]
);
return (
<>
{!isCartEmpty && (
<>
{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>
</>
)}
{/* {!isCartEmpty && (
<div className='flex items-center justify-between rounded-xl border border-border px-4 py-3 text-xs text-muted-foreground'>
<span>{t('CartControlsHint', { defaultValue: 'برای ویرایش تعداد، از دکمه‌های هر آیتم استفاده کنید.' })}</span>
<div className='flex items-center gap-1 text-foreground'>
<Add size={16} />
<Minus size={16} />
</div>
</div>
)} */}
</>
);
};
export default CartSummary;
-17
View File
@@ -1,17 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
const CartPage = dynamic(() => import('./CartPage'), {
ssr: false,
loading: () => (
<div className='flex flex-col items-center justify-center gap-2 py-12 text-sm2 text-muted-foreground'>
<span className='h-4 w-4 animate-spin rounded-full border border-dashed border-foreground border-t-transparent'></span>
<p>در حال دریافت سبد خرید...</p>
</div>
),
});
export default function Page() {
return <CartPage />;
}
@@ -9,7 +9,7 @@ import Button from '@/components/button/PrimaryButton';
import { extractErrorMessage } from '@/lib/func';
import { toast } from '@/components/Toast';
import { useEffect, useState } from 'react';
import { useGetCartItems } from '@/app/[name]/(Dialogs)/cart/hooks/useCartData';
import { useGetCartItems } from '@/app/[name]/(Main)/cart/hooks/useCartData';
type CouponSectionProps = {
couponType: string;
@@ -7,7 +7,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useGetPaymentMethod } from '../../hooks/useOrderData';
import { useCheckoutStore } from '../../store/Store';
import { useGetUserWallet } from '@/app/[name]/(Main)/transactions/hooks/useTransactionData';
import { useGetCartItems } from '@/app/[name]/(Dialogs)/cart/hooks/useCartData';
import { useGetCartItems } from '@/app/[name]/(Main)/cart/hooks/useCartData';
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
type PaymentSectionProps = {
@@ -9,12 +9,12 @@ import { toast } from '@/components/Toast';
import { useCreateOrder } from '../../hooks/useOrderData';
import { extractErrorMessage } from '@/lib/func';
import { useParams } from 'next/navigation';
import { useGetCartItems, useSaveAllMethod } from '@/app/[name]/(Dialogs)/cart/hooks/useCartData';
import { useGetCartItems, useSaveAllMethod } from '@/app/[name]/(Main)/cart/hooks/useCartData';
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
import { useCallback, useMemo } from 'react';
import { useGetShipmentMethod } from '../../hooks/useOrderData';
import CartItemsList from '@/app/[name]/(Dialogs)/cart/components/CartItemsList';
import { useCartStore } from '@/app/[name]/(Dialogs)/cart/store/Store';
import CartItemsList from '@/app/[name]/(Main)/cart/components/CartItemsList';
import { useCartStore } from '@/app/[name]/(Main)/cart/store/Store';
type SummarySectionProps = {
cartModal: boolean;
@@ -11,7 +11,7 @@ import { ShippingSection } from './components/ShippingSection';
import { SummarySection } from './components/SummarySection';
import { TableSection } from './components/TableSection';
import { useGetShipmentMethod } from '../hooks/useOrderData';
import { useGetCartItems } from '@/app/[name]/(Dialogs)/cart/hooks/useCartData';
import { useGetCartItems } from '@/app/[name]/(Main)/cart/hooks/useCartData';
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
import { useCheckoutStore } from '../store/Store';
+1 -1
View File
@@ -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,
});
};
@@ -11,35 +11,54 @@ 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 type { Food } from '@/app/[name]/(Main)/types/Types';
import { useGetAbout } from '../../(Main)/about/hooks/useAboutData';
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 { data: foodsResponse, isFetching } = useGetFoods();
const { data: aboutData } = useGetAbout();
const isPremium = aboutData?.data?.plan === 'premium';
const foods = React.useMemo(() => foodsResponse?.data ?? [], [foodsResponse?.data]);
const isLoading = isFetching && !foods.length;
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 (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 isCartEmpty = cartFoods.length === 0;
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();
@@ -55,7 +74,7 @@ const CartPage = () => {
};
return (
<div className='overflow-y-auto h-full noscrollbar flex flex-col bg-background -mx-6 -mt-4 -mb-6 px-6 pt-4 pb-6'>
<div className='overflow-y-auto h-full noscrollbar flex flex-col bg-background'>
<div className='grid grid-cols-3 items-center'>
{isCartEmpty ? (
<span></span>
@@ -77,11 +96,8 @@ const CartPage = () => {
</div>
<div className="mt-8 flex-1 h-full">
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-12 text-sm2 text-muted-foreground'>
<span className='h-4 w-4 animate-spin rounded-full border border-dashed border-foreground border-t-transparent'></span>
<p>در حال دریافت سبد خرید...</p>
</div>
{showInitialCartLoad || guestAwaitingMenu ? (
<CartSkeleton />
) : isCartEmpty ? (
<div className='flex flex-col items-center justify-center gap-4 h-full'>
<Image
@@ -131,7 +147,7 @@ const CartPage = () => {
<MenuItem food={food} />
</MenuItemRenderer>
))}
<CartSummary isPremium={isPremium} />
<CartSummary isPremium={isPremium} hasItems={hasItems} />
</div>
)}
</div>
@@ -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;
@@ -184,6 +184,7 @@ export const useGetCartItems = (enabled = true) => {
queryKey: ["cart-items"],
queryFn: api.getCartItems,
enabled,
staleTime: 60_000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@@ -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);
}
+5
View File
@@ -0,0 +1,5 @@
'use client';
import CartPage from './CartPage';
export default CartPage;
@@ -8,6 +8,8 @@ export const useGetFoods = () => {
queryKey: ["menu"],
queryFn: () => api.getFoods(name),
enabled: !!name,
staleTime: 5 * 60_000,
refetchOnMount: false,
});
};
+4 -7
View File
@@ -1,20 +1,17 @@
import { cache } from "react";
import { API_BASE_URL } from "@/config/const";
import { AboutResponse, Restaurant } from "../(Main)/about/types/Types";
import axios from "axios";
export async function getRestaurant(slug: string): Promise<Restaurant> {
async function fetchRestaurant(slug: string): Promise<Restaurant> {
try {
const response = await axios(`${API_BASE_URL}/public/restaurants/${slug}`, {
// cache: "no-store",
headers: {
"Content-Type": "application/json",
"X-SLUG": slug,
},
});
console.log("response", response);
if (!response.data) {
if (response.status === 404) {
throw new Error("RESTAURANT_NOT_FOUND");
@@ -30,11 +27,11 @@ export async function getRestaurant(slug: string): Promise<Restaurant> {
return data.data;
} catch (error) {
console.log("error", error);
if (error instanceof Error && error.message === "RESTAURANT_NOT_FOUND") {
throw error;
}
throw new Error(`Failed to fetch restaurant data: ${error}`);
}
}
export const getRestaurant = cache(fetchRestaurant);