Compare commits

..

7 Commits

Author SHA1 Message Date
hamid zarghami cc27bce1b2 compelete variable
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-29 09:37:07 +03:30
hamid zarghami a269266897 show items order 2026-06-28 16:24:38 +03:30
hamid zarghami 1300491a2d need confrim 2026-06-28 16:17:08 +03:30
hamid zarghami 8b6f586636 redirect to single order 2026-06-28 16:07:52 +03:30
hamid zarghami 1cf0069db3 variant 2026-06-28 15:40:50 +03:30
hamid zarghami 50cb3ecc65 menu item 2026-06-28 10:25:40 +03:30
hamid zarghami d2418da54a menu Item 2026-06-28 10:06:30 +03:30
16 changed files with 826 additions and 363 deletions
+26 -1
View File
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData"; import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData";
import { useReceiptStore } from "@/zustand/receiptStore"; import { useReceiptStore } from "@/zustand/receiptStore";
import { import {
useBulkCart,
useClearCart, useClearCart,
useDecrementCart, useDecrementCart,
useGetCartItems, useGetCartItems,
@@ -19,6 +20,7 @@ export const useCart = () => {
clear, clear,
increment, increment,
decrement, decrement,
setQuantity,
items, items,
slug, slug,
setSlug, setSlug,
@@ -32,6 +34,10 @@ export const useCart = () => {
isPending: isDecrementPending, isPending: isDecrementPending,
} = useDecrementCart(); } = useDecrementCart();
const { mutate: mutateClearCart } = useClearCart(); const { mutate: mutateClearCart } = useClearCart();
const {
mutate: mutateBulkCart,
isPending: isBulkPending,
} = useBulkCart();
const { data: cartItems } = useGetCartItems(isSuccess); const { data: cartItems } = useGetCartItems(isSuccess);
const addToCart = (id: string | number) => { const addToCart = (id: string | number) => {
@@ -62,6 +68,24 @@ export const useCart = () => {
} }
}; };
const setCartQuantity = (id: string | number, quantity: number) => {
if (isSuccess) {
mutateBulkCart(
{ items: [{ variantId: String(id), quantity }] },
{
onError: (error) => {
toast(extractErrorMessage(error), "error");
},
}
);
} else {
if (currentSlug && slug !== currentSlug) {
setSlug(currentSlug);
}
setQuantity(id, quantity);
}
};
const clearCart = useCallback(() => { const clearCart = useCallback(() => {
if (isSuccess) { if (isSuccess) {
mutateClearCart(undefined, { mutateClearCart(undefined, {
@@ -92,7 +116,7 @@ export const useCart = () => {
} }
}, [isSuccess, cartItems?.data?.items, items]); }, [isSuccess, cartItems?.data?.items, items]);
const isCartMutating = isIncrementPending || isDecrementPending; const isCartMutating = isIncrementPending || isDecrementPending || isBulkPending;
// برای کاربران لاگین شده، slug از API می‌آید (در cartItems.data.shopId) // برای کاربران لاگین شده، slug از API می‌آید (در cartItems.data.shopId)
// برای کاربران لاگین نشده، slug از receiptStore می‌آید // برای کاربران لاگین نشده، slug از receiptStore می‌آید
@@ -111,6 +135,7 @@ export const useCart = () => {
clear, clear,
addToCart, addToCart,
removeFromCart, removeFromCart,
setCartQuantity,
clearCart, clearCart,
isCartMutating, isCartMutating,
}; };
@@ -13,6 +13,8 @@ import { useGetCartItems, useSaveAllMethod } from '@/app/[name]/(Dialogs)/cart/h
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData'; import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useGetShipmentMethod } from '../../hooks/useOrderData'; import { useGetShipmentMethod } from '../../hooks/useOrderData';
import { useGetProducts } from '@/app/[name]/(Main)/hooks/useMenuData';
import { isVariablePricing } from '@/app/[name]/(Main)/types/Types';
import CartItemsList from '@/app/[name]/(Dialogs)/cart/components/CartItemsList'; import CartItemsList from '@/app/[name]/(Dialogs)/cart/components/CartItemsList';
import { useCartStore } from '@/app/[name]/(Dialogs)/cart/store/Store'; import { useCartStore } from '@/app/[name]/(Dialogs)/cart/store/Store';
@@ -33,8 +35,22 @@ export const SummarySection = ({ cartModal, onToggleCartModal, deliveryType }: S
const name = params.name as string; const name = params.name as string;
const { isSuccess } = useGetProfile(); const { isSuccess } = useGetProfile();
const { data: cartData } = useGetCartItems(isSuccess); const { data: cartData } = useGetCartItems(isSuccess);
const { data: productsResponse } = useGetProducts();
const { data: shipmentMethod } = useGetShipmentMethod(); const { data: shipmentMethod } = useGetShipmentMethod();
const hasVariablePricingItem = useMemo(() => {
const cartItems = cartData?.data?.items;
const products = productsResponse?.data;
if (!cartItems?.length || !products?.length) return false;
return cartItems.some((item) => {
const product = products.find((p) =>
p.variants?.some((v) => String(v.id) === String(item.variantId))
);
return product != null && isVariablePricing(product);
});
}, [cartData?.data?.items, productsResponse?.data]);
const selectedDeliveryMethod = useMemo(() => { const selectedDeliveryMethod = useMemo(() => {
if (!shipmentMethod?.data || !deliveryType) return null; if (!shipmentMethod?.data || !deliveryType) return null;
return shipmentMethod.data.find((item) => item.id === deliveryType); return shipmentMethod.data.find((item) => item.id === deliveryType);
@@ -132,8 +148,8 @@ export const SummarySection = ({ cartModal, onToggleCartModal, deliveryType }: S
onSuccess: (data) => { onSuccess: (data) => {
clear(); clear();
resetCart(); resetCart();
if (data?.data?.paymentUrl) { if (!hasVariablePricingItem && data?.data?.paymentUrl) {
window.location.href = data?.data?.paymentUrl; window.location.href = data.data.paymentUrl;
toast('در حال ریدایرکت به صفحه پرداخت...', 'success'); toast('در حال ریدایرکت به صفحه پرداخت...', 'success');
} else { } else {
window.location.href = `/${name}/order/track/${data?.data?.order?.id}`; window.location.href = `/${name}/order/track/${data?.data?.order?.id}`;
@@ -193,7 +209,7 @@ export const SummarySection = ({ cartModal, onToggleCartModal, deliveryType }: S
<div className='text-sm mt-2 font-semibold'>{formatPrice(total)} تومان</div> <div className='text-sm mt-2 font-semibold'>{formatPrice(total)} تومان</div>
</div> </div>
<Button pending={isCreateOrderPending || isSaveAllMethodPending} onClick={handleSubmit} className='px-10 w-fit dark:bg-white dark:text-black! dark:hover:bg-gray-100'> <Button pending={isCreateOrderPending || isSaveAllMethodPending} onClick={handleSubmit} className='px-10 w-fit dark:bg-white dark:text-black! dark:hover:bg-gray-100'>
{t("ButtonSubmit")} {hasVariablePricingItem ? t("ButtonRegister") : t("ButtonSubmit")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -4,6 +4,7 @@ import {
PaymentMethodsResponse, PaymentMethodsResponse,
CreateOrderResponse, CreateOrderResponse,
OrderDetailResponse, OrderDetailResponse,
PayOrderResponse,
} from "../types/Types"; } from "../types/Types";
export const getShipmentMethod = async (): Promise<ShipmentMethodsResponse> => { export const getShipmentMethod = async (): Promise<ShipmentMethodsResponse> => {
@@ -81,7 +82,7 @@ export const cancelOrder = async (id: string) => {
return data; return data;
}; };
export const confirmOrder = async (id: string) => { export const confirmOrder = async (id: string): Promise<PayOrderResponse> => {
const { data } = await api.get(`/public/payments/pay-order/${id}`); const { data } = await api.get<PayOrderResponse>(`/public/payments/pay-order/${id}`);
return data; return data;
}; };
@@ -1,4 +1,4 @@
import { BaseResponse } from "@/app/[name]/(Main)/types/Types"; import { BaseResponse, Product } from "@/app/[name]/(Main)/types/Types";
export interface ShipmentMethod { export interface ShipmentMethod {
id: string; id: string;
@@ -272,6 +272,12 @@ export interface CreateOrderData {
export type CreateOrderResponse = BaseResponse<CreateOrderData>; export type CreateOrderResponse = BaseResponse<CreateOrderData>;
export interface PayOrderData {
paymentUrl?: string;
}
export type PayOrderResponse = BaseResponse<PayOrderData>;
export interface OrderDetailUser { export interface OrderDetailUser {
id: string; id: string;
createdAt: string; createdAt: string;
@@ -361,28 +367,15 @@ export interface OrderDetailPaymentMethod {
merchantId: string | null; merchantId: string | null;
} }
export interface OrderDetailFood { export interface OrderDetailVariant {
id: string; id: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
restaurant: string; value: string;
category: string;
title: string;
desc: string;
content: string[];
price: number; price: number;
order: number | null; stock: number | null;
prepareTime: number | null; product: Product;
weekDays: number[];
mealTypes: string[];
isActive: boolean;
images: string[];
inPlaceServe: boolean;
pickupServe: boolean;
score: number;
discount: number;
isSpecialOffer: boolean;
} }
export interface OrderDetailItem { export interface OrderDetailItem {
@@ -391,11 +384,12 @@ export interface OrderDetailItem {
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
order: string; order: string;
food: OrderDetailFood; variant: OrderDetailVariant;
quantity: number; quantity: number;
unitPrice: number; unitPrice: number;
discount: number; discount: number;
totalPrice: number; totalPrice: number;
status?: string;
} }
export interface OrderDetailHistory { export interface OrderDetailHistory {
@@ -454,6 +448,7 @@ export interface OrderDetail {
tableNumber: string | null; tableNumber: string | null;
status: status:
| "pendingPayment" | "pendingPayment"
| "needConfirmation"
| "new" | "new"
| "preparing" | "preparing"
| "ready" | "ready"
@@ -1,31 +1,57 @@
'use client'; "use client";
import { useParams, useRouter } from 'next/navigation'; import type { Product, ProductCategory } from "@/app/[name]/(Main)/types/Types";
import React from 'react'; import { isVariablePricing } from "@/app/[name]/(Main)/types/Types";
import Button from '@/components/button/PrimaryButton'; import Button from "@/components/button/PrimaryButton";
import { Skeleton } from '@/components/ui/skeleton'; import MenuItem from "@/components/listview/MenuItem";
import { Clock } from 'iconsax-react'; import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
import clsx from 'clsx'; import { toast } from "@/components/Toast";
import useToggle from '@/hooks/helpers/useToggle'; import { Skeleton } from "@/components/ui/skeleton";
import Prompt from '@/components/utils/Prompt'; import Prompt from "@/components/utils/Prompt";
import { useCancelOrder, useGetOrderDetail } from '../../checkout/hooks/useOrderData'; import useToggle from "@/hooks/helpers/useToggle";
import ordersTranslations from '@/locales/fa/orders.json'; import { extractErrorMessage } from "@/lib/func";
import { toast } from '@/components/Toast'; import ordersTranslations from "@/locales/fa/orders.json";
import { extractErrorMessage } from '@/lib/func'; import { useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from '@tanstack/react-query'; import clsx from "clsx";
import { Clock } from "iconsax-react";
import { useParams, useRouter } from "next/navigation";
import React from "react";
import { useCancelOrder, useConfirmOrder, useGetOrderDetail } from "../../checkout/hooks/useOrderData";
import type { OrderDetailItem } from "../../checkout/types/Types";
const orderItemToProduct = (item: OrderDetailItem): Product => {
const { product, ...variant } = item.variant;
const categoryId = typeof product.category === "string" ? product.category : (product.category?.id ?? "");
return {
...product,
category: (typeof product.category === "object" && product.category ? product.category : { id: categoryId }) as ProductCategory,
variants: [
{
id: variant.id,
createdAt: variant.createdAt,
updatedAt: variant.updatedAt,
deletedAt: variant.deletedAt,
product: product.id,
value: variant.value,
price: item.unitPrice,
},
],
} as Product;
};
const getDeliveryMethodTitle = (method: string) => { const getDeliveryMethodTitle = (method: string) => {
switch (method) { switch (method) {
case 'deliveryCourier': case "deliveryCourier":
return 'ارسال توسط پیک'; return "ارسال توسط پیک";
case 'deliveryCar': case "deliveryCar":
return 'تحویل به خودرو'; return "تحویل به خودرو";
case 'pickup': case "pickup":
return 'تحویل حضوری'; return "تحویل حضوری";
case 'dineIn': case "dineIn":
return 'سرو در فروشگاه'; return "سرو در فروشگاه";
default: default:
return 'روش ارسال'; return "روش ارسال";
} }
}; };
@@ -54,15 +80,15 @@ const getDeliveryMethodTitle = (method: string) => {
const getStatusText = (status: string): string => { const getStatusText = (status: string): string => {
const statusKey = status as keyof typeof ordersTranslations.status; const statusKey = status as keyof typeof ordersTranslations.status;
return ordersTranslations.status[statusKey] || 'نامشخص'; return ordersTranslations.status[statusKey] || "نامشخص";
}; };
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString('fa-IR', { return date.toLocaleDateString("fa-IR", {
year: 'numeric', year: "numeric",
month: 'long', month: "long",
day: 'numeric' day: "numeric",
}); });
}; };
@@ -81,6 +107,31 @@ function OrderTrackingPage() {
const { state: cancelModal, toggle: toggleCancelModal } = useToggle(); const { state: cancelModal, toggle: toggleCancelModal } = useToggle();
const { data: orderDetail, isLoading } = useGetOrderDetail(id as string); const { data: orderDetail, isLoading } = useGetOrderDetail(id as string);
const { mutate: cancelOrder } = useCancelOrder(); const { mutate: cancelOrder } = useCancelOrder();
const { mutate: payOrder, isPending: isPaying } = useConfirmOrder();
const orderStatus = orderDetail?.data?.status;
const showPayButton = orderStatus === "pendingPayment" || orderStatus === "needConfirmation";
const isPayDisabled = orderStatus === "needConfirmation";
const onPayOrder = () => {
if (!id || isPayDisabled) return;
payOrder(id as string, {
onSuccess: (data) => {
const paymentUrl = data?.data?.paymentUrl;
if (paymentUrl) {
toast("در حال ریدایرکت به صفحه پرداخت...", "success");
window.location.href = paymentUrl;
} else {
queryClient.invalidateQueries({ queryKey: ["order-detail", id] });
toast("پرداخت با موفقیت انجام شد", "success");
}
},
onError: (error) => {
toast(extractErrorMessage(error), "error");
},
});
};
const onCancelOrder = (e: React.MouseEvent | null) => { const onCancelOrder = (e: React.MouseEvent | null) => {
if (!e || !id) return; if (!e || !id) return;
@@ -88,129 +139,167 @@ function OrderTrackingPage() {
cancelOrder(id as string, { cancelOrder(id as string, {
onSuccess: () => { onSuccess: () => {
toggleCancelModal(); toggleCancelModal();
queryClient.invalidateQueries({ queryKey: ['order-detail', id] }); queryClient.invalidateQueries({ queryKey: ["order-detail", id] });
toast('سفارش با موفقیت لغو شد', 'success'); toast("سفارش با موفقیت لغو شد", "success");
}, },
onError: (error) => { onError: (error) => {
toast(extractErrorMessage(error), 'error'); toast(extractErrorMessage(error), "error");
} },
}); });
}; };
return ( return (
<div className="fixed inset-0 bg-container flex flex-col"> <div className="fixed inset-0 bg-container flex flex-col">
<div className='flex-1 overflow-y-auto px-4 py-6'> <div className="flex-1 overflow-y-auto px-4 py-6">
<div className='max-w-2xl mx-auto flex flex-col gap-6'> <div className="max-w-2xl mx-auto flex flex-col gap-6">
{isLoading ? ( {isLoading ? (
<> <>
<Skeleton className='h-6 w-48 mx-auto' /> <Skeleton className="h-6 w-48 mx-auto" />
<div className='flex flex-col gap-4'> <div className="flex flex-col gap-4">
<Skeleton className='h-5 w-full' /> <Skeleton className="h-5 w-full" />
<Skeleton className='h-5 w-full' /> <Skeleton className="h-5 w-full" />
<Skeleton className='h-20 w-full' /> <Skeleton className="h-20 w-full" />
<Skeleton className='h-5 w-full' /> <Skeleton className="h-5 w-full" />
</div> </div>
</> </>
) : orderDetail?.data ? ( ) : orderDetail?.data ? (
<> <>
<h1 className='text-lg font-semibold text-center'> {orderDetail.data.status === "needConfirmation" && (
{getDeliveryMethodTitle(orderDetail.data.deliveryMethod.method)} <div className="rounded-2xl bg-red-50 px-4 py-3 text-center">
</h1> <p className="text-sm text-red-800 leading-6">لطفاً پس از دریافت پیامک، حداکثر تا ۲ ساعت نسبت به پرداخت اقدام کنید؛ در غیر این صورت سفارش بهصورت خودکار لغو خواهد شد.</p>
</div>
)}
<div className='flex flex-col'> <h1 className="text-lg font-semibold text-center">{getDeliveryMethodTitle(orderDetail.data.deliveryMethod.method)}</h1>
<div className='flex justify-between items-center h-16 border-b border-border'>
<span className='text-sm text-muted-foreground flex items-center gap-2'> <div className="flex flex-col">
<Clock className='stroke-current' size={16} /> <div className="flex justify-between items-center h-16 border-b border-border">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Clock className="stroke-current" size={16} />
زمان سفارش زمان سفارش
</span> </span>
<div className='flex items-end gap-0.5'> <div className="flex items-end gap-0.5">
<span className='text-sm font-semibold'>{formatDate(orderDetail.data.createdAt)}</span> <span className="text-sm font-semibold">{formatDate(orderDetail.data.createdAt)}</span>
{/* <span className='text-xs text-muted-foreground'>{formatTime(orderDetail.data.createdAt)}</span> */} {/* <span className='text-xs text-muted-foreground'>{formatTime(orderDetail.data.createdAt)}</span> */}
</div> </div>
</div> </div>
<div className='flex justify-between items-center h-16 border-b border-border'> <div className="flex justify-between items-center h-16 border-b border-border">
<span className='text-sm text-muted-foreground'>نوع ارسال</span> <span className="text-sm text-muted-foreground">نوع ارسال</span>
<span className='text-sm font-semibold'>{orderDetail.data?.deliveryMethod?.description}</span> <span className="text-sm font-semibold">{orderDetail.data?.deliveryMethod?.description}</span>
</div> </div>
{orderDetail.data.carAddress && ( {orderDetail.data.carAddress && (
<div className='flex justify-between items-center h-16 border-b border-border'> <div className="flex justify-between items-center h-16 border-b border-border">
<div className='text-sm text-muted-foreground mb-1'>اطلاعات خودرو</div> <div className="text-sm text-muted-foreground mb-1">اطلاعات خودرو</div>
<div className='text-sm font-semibold'> <div className="text-sm font-semibold">
{orderDetail.data.carAddress.carModel} {orderDetail.data.carAddress.carColor} - پلاک: {orderDetail.data.carAddress.plateNumber} {orderDetail.data.carAddress.carModel} {orderDetail.data.carAddress.carColor} - پلاک: {orderDetail.data.carAddress.plateNumber}
</div> </div>
</div> </div>
)} )}
<div className='flex justify-between items-center h-16 border-b border-border'> <div className="flex justify-between items-center h-16 border-b border-border">
<span className='text-sm text-muted-foreground'>شماره سفارش</span> <span className="text-sm text-muted-foreground">شماره سفارش</span>
<span className='text-sm font-semibold'>#{orderDetail.data.orderNumber}</span> <span className="text-sm font-semibold">#{orderDetail.data.orderNumber}</span>
</div> </div>
<div className='flex justify-between items-center h-16 border-b border-border'> <div className="flex justify-between items-center h-16 border-b border-border">
<span className='text-sm text-muted-foreground'>وضعیت سفارش</span> <span className="text-sm text-muted-foreground">وضعیت سفارش</span>
<span className='text-sm font-semibold'>{getStatusText(orderDetail.data.status)}</span> <span className="text-sm font-semibold">{getStatusText(orderDetail.data.status)}</span>
</div> </div>
{orderDetail.data.tableNumber && ( {orderDetail.data.tableNumber && (
<div className='flex justify-between items-center h-16 border-b border-border'> <div className="flex justify-between items-center h-16 border-b border-border">
<span className='text-sm text-muted-foreground'>شماره میز</span> <span className="text-sm text-muted-foreground">شماره میز</span>
<span className='text-sm font-semibold'>#{orderDetail.data.tableNumber}</span> <span className="text-sm font-semibold">#{orderDetail.data.tableNumber}</span>
</div> </div>
)} )}
{orderDetail.data.userAddress && ( {orderDetail.data.userAddress && (
<div className='py-3 border-b border-border'> <div className="py-3 border-b border-border">
<div className='text-sm text-muted-foreground mb-1'>آدرس تحویل</div> <div className="text-sm text-muted-foreground mb-1">آدرس تحویل</div>
<div className='text-sm'>{orderDetail.data.userAddress.address}</div> <div className="text-sm">{orderDetail.data.userAddress.address}</div>
</div> </div>
)} )}
<div className='flex justify-between items-center py-4 border-t border-border'> {orderDetail.data.items?.length > 0 && (
<span className='text-sm font-medium'>مجموع سفارش</span> <div className="py-4 mt-7 border-b border-border">
<span className='text-sm font-bold '> <div className="text-sm font-medium mb-4">اقلام سفارش</div>
{orderDetail.data.total.toLocaleString('fa-IR')} تومان <div className="flex flex-col gap-4">
{orderDetail.data.items.map((item) => {
const product = orderItemToProduct(item);
const needsConfirmation = isVariablePricing(product) || product.needAdminAcceptance;
return (
<MenuItemRenderer key={item.id}>
<MenuItem
product={product}
variantId={item.variant.id}
variantValue={item.variant.value}
readOnly
displayQuantity={item.quantity}
badge={
needsConfirmation ? (
item.status === "confirmed" ? (
<span className="inline-block rounded-lg bg-green-50 dark:bg-green-950/40 px-3 py-1.5 text-xs text-green-600 dark:text-green-400">
{ordersTranslations.itemStatus.confirmed}
</span> </span>
) : (
<span className="inline-block rounded-lg bg-orange-50 dark:bg-orange-950/40 px-3 py-1.5 text-xs text-orange-600 dark:text-orange-400">
{ordersTranslations.itemStatus.needConfirmation}
</span>
)
) : undefined
}
/>
</MenuItemRenderer>
);
})}
</div>
</div>
)}
<div className="flex justify-between items-center py-4 border-t border-border">
<span className="text-sm font-medium">مجموع سفارش</span>
<span className="text-sm font-bold ">{orderDetail.data.total.toLocaleString("fa-IR")} تومان</span>
</div> </div>
</div> </div>
</> </>
) : ( ) : (
<div className='text-center text-muted-foreground py-12'> <div className="text-center text-muted-foreground py-12">اطلاعات سفارش یافت نشد</div>
اطلاعات سفارش یافت نشد
</div>
)} )}
</div> </div>
</div> </div>
<div className='p-4 border-t border-border bg-container'> <div className="p-4 border-t border-border bg-container">
<div className='max-w-2xl mx-auto grid grid-cols-2 gap-4'> <div className="max-w-2xl mx-auto grid grid-cols-2 gap-4">
<Button <Button className="bg-disabled! text-foreground!" onClick={() => router.push(`/${name}`)} type="button">
className='dark:bg-white dark:text-black! dark:hover:bg-gray-100'
onClick={() => router.push(`/${name}`)}
type='button'>
بازگشت به منو بازگشت به منو
</Button> </Button>
{showPayButton ? (
<Button <Button
type='submit' type="button"
onClick={onPayOrder}
disabled={isPayDisabled}
pending={isPaying}
>
پرداخت
</Button>
) : (
<Button
type="submit"
onClick={toggleCancelModal} onClick={toggleCancelModal}
className='bg-disabled! text-foreground!' className="bg-disabled! text-foreground!"
disabled={ disabled={orderDetail?.data?.status === "cancelled" || orderDetail?.data?.status === "delivered" || orderDetail?.data?.status === "delivering"}
orderDetail?.data?.status === 'cancelled' ||
orderDetail?.data?.status === 'delivered' ||
orderDetail?.data?.status === 'delivering'
}
> >
لغو سفارش لغو سفارش
</Button> </Button>
)}
</div> </div>
</div> </div>
<div className={clsx( <div className={clsx("fixed inset-0 z-1001", !cancelModal && "pointer-events-none")}>
'fixed inset-0 z-1001',
!cancelModal && 'pointer-events-none'
)}>
<Prompt <Prompt
title={'لغو سفارش'} title={"لغو سفارش"}
description={'آیا از درخواست خود اطمینان دارید؟'} description={"آیا از درخواست خود اطمینان دارید؟"}
textConfirm={'بله'} textConfirm={"بله"}
textCancel={'منصرف شدم'} textCancel={"منصرف شدم"}
onConfirm={onCancelOrder} onConfirm={onCancelOrder}
visible={cancelModal} visible={cancelModal}
onClick={toggleCancelModal} onClick={toggleCancelModal}
+134 -4
View File
@@ -1,13 +1,20 @@
'use client' 'use client'
import { CartQuantityControl } from '@/components/CartQuantityControl' import { CartQuantityControl } from '@/components/CartQuantityControl'
import { VariableWeightSlider } from '@/components/product/VariableWeightSlider'
import PlusIcon from '@/components/icons/PlusIcon'
import { ef } from '@/lib/helpers/utfNumbers' import { ef } from '@/lib/helpers/utfNumbers'
import { useCart } from '@/app/[name]/(Dialogs)/cart/hook/useCart' import { useCart } from '@/app/[name]/(Dialogs)/cart/hook/useCart'
import { getPrimaryVariantId } from '@/app/[name]/(Main)/types/Types' import {
getPrimaryVariantId,
getPurchaseUnitLabel,
getVariableQuantityRange,
isVariablePricing,
} from '@/app/[name]/(Main)/types/Types'
import { ArrowLeft, Cup, Heart, TruckTick } from 'iconsax-react' import { ArrowLeft, Cup, Heart, TruckTick } from 'iconsax-react'
import Image from 'next/image' import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import React, { useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useGetProduct, useToggleFavorite } from './hooks/useProductData' import { useGetProduct, useToggleFavorite } from './hooks/useProductData'
import { useGetAbout } from '../about/hooks/useAboutData' import { useGetAbout } from '../about/hooks/useAboutData'
import { useGetProfile } from '../../(Profile)/profile/hooks/userProfileData' import { useGetProfile } from '../../(Profile)/profile/hooks/userProfileData'
@@ -23,9 +30,13 @@ function ProductPage({ }: Props) {
const { isSuccess } = useGetProfile(); const { isSuccess } = useGetProfile();
const { data: product, isLoading } = useGetProduct(id as string); const { data: product, isLoading } = useGetProduct(id as string);
const { data: about } = useGetAbout(); const { data: about } = useGetAbout();
const { items, addToCart, removeFromCart, isCartMutating } = useCart(); const { items, addToCart, removeFromCart, setCartQuantity, isCartMutating } = useCart();
const [isFavorite, setIsFavorite] = useState<boolean>(false); const [isFavorite, setIsFavorite] = useState<boolean>(false);
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null); const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null);
const [selectedWeight, setSelectedWeight] = useState<number>(1);
const isUserInteractingRef = useRef(false);
const cartSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingCartWeightRef = useRef<number | null>(null);
const { mutate: toggleFavorite } = useToggleFavorite(); const { mutate: toggleFavorite } = useToggleFavorite();
const productId = product?.data?.id || id as string; const productId = product?.data?.id || id as string;
@@ -48,6 +59,62 @@ function ProductPage({ }: Props) {
} }
}; };
const handleAddVariableToCart = () => {
if (!cartId) return;
if (cartSyncTimerRef.current) {
clearTimeout(cartSyncTimerRef.current);
cartSyncTimerRef.current = null;
}
pendingCartWeightRef.current = null;
isUserInteractingRef.current = false;
setCartQuantity(cartId, selectedWeight);
};
const clearCartSyncTimer = useCallback(() => {
if (cartSyncTimerRef.current) {
clearTimeout(cartSyncTimerRef.current);
cartSyncTimerRef.current = null;
}
}, []);
const handleVariableWeightChange = useCallback((weight: number) => {
isUserInteractingRef.current = true;
setSelectedWeight(weight);
clearCartSyncTimer();
pendingCartWeightRef.current = null;
}, [clearCartSyncTimer]);
const handleVariableWeightCommit = useCallback((weight: number) => {
if (!isSuccess || !cartId || quantity <= 0) {
isUserInteractingRef.current = false;
return;
}
pendingCartWeightRef.current = weight;
clearCartSyncTimer();
cartSyncTimerRef.current = setTimeout(() => {
cartSyncTimerRef.current = null;
const pendingWeight = pendingCartWeightRef.current;
pendingCartWeightRef.current = null;
if (pendingWeight == null || !cartId) {
isUserInteractingRef.current = false;
return;
}
const currentItem = items?.[cartId] ?? items?.[String(cartId)];
const currentQty = currentItem && typeof currentItem === 'object' && 'quantity' in currentItem
? currentItem.quantity
: 0;
if (Math.abs(pendingWeight - currentQty) > 0.0001) {
setCartQuantity(cartId, pendingWeight);
}
isUserInteractingRef.current = false;
}, 1000);
}, [isSuccess, cartId, quantity, clearCartSyncTimer, items, setCartQuantity]);
const handleRemoveFromCart = () => { const handleRemoveFromCart = () => {
if (cartId) { if (cartId) {
removeFromCart(cartId); removeFromCart(cartId);
@@ -95,6 +162,21 @@ function ProductPage({ }: Props) {
setSelectedVariantId((prev) => prev ?? variants[0].id); setSelectedVariantId((prev) => prev ?? variants[0].id);
}, [product?.data]); }, [product?.data]);
useEffect(() => {
if (!product?.data || !isVariablePricing(product.data) || !cartId) return;
if (isUserInteractingRef.current) return;
const { min } = getVariableQuantityRange(product.data);
const targetWeight = quantity > 0 ? quantity : min;
setSelectedWeight((prev) => (
Math.abs(prev - targetWeight) < 0.0001 ? prev : targetWeight
));
}, [product?.data, cartId, quantity]);
useEffect(() => () => {
clearCartSyncTimer();
}, [clearCartSyncTimer]);
if (isLoading) { if (isLoading) {
return ( return (
<div className='flex items-center justify-center min-h-[400px]'> <div className='flex items-center justify-center min-h-[400px]'>
@@ -131,6 +213,12 @@ function ProductPage({ }: Props) {
const price = hasVariants const price = hasVariants
? (selectedVariant?.price ?? 0) ? (selectedVariant?.price ?? 0)
: (productData.price ?? selectedVariant?.price ?? 0); : (productData.price ?? selectedVariant?.price ?? 0);
const isVariable = isVariablePricing(productData);
const weightRange = isVariable ? getVariableQuantityRange(productData) : null;
const unitPriceLabel = isVariable
? getPurchaseUnitLabel(productData.purchaseUnit)
: '';
const calculatedPrice = isVariable ? Math.round(price * selectedWeight) : price;
// const prepareTime = productData.prepareTime || 0; // const prepareTime = productData.prepareTime || 0;
const content = Array.isArray(productData.content) const content = Array.isArray(productData.content)
? productData.content.join('، ') ? productData.content.join('، ')
@@ -147,7 +235,7 @@ function ProductPage({ }: Props) {
}; };
return ( return (
<div className='lg:absolute lg:top-1/2 not-lg:max-w-lg not-lg:place-self-center lg:-translate-y-1/2 xl:right-[285px] lg:left-5 lg:right-4 lg:bottom-4 not-lg:w-full not-md:-translate-y-2 pt-6 flex flex-col lg:grid grid-cols-2 bottom-10'> <div className={`lg:absolute lg:top-1/2 not-lg:max-w-lg not-lg:place-self-center lg:-translate-y-1/2 xl:right-[285px] lg:left-5 lg:right-4 lg:bottom-4 not-lg:w-full not-md:-translate-y-2 pt-6 flex flex-col lg:grid grid-cols-2 bottom-10`}>
<div className="relative w-full h-full not-lg:bg-container rounded-2xl overflow-hidden lg:rounded-r-none lg:order-1"> <div className="relative w-full h-full not-lg:bg-container rounded-2xl overflow-hidden lg:rounded-r-none lg:order-1">
<Image <Image
className='w-full object-cover bg-[#F2F2F9] h-full' className='w-full object-cover bg-[#F2F2F9] h-full'
@@ -252,6 +340,47 @@ function ProductPage({ }: Props) {
</div> </div>
)} )}
{isVariable && weightRange ? (
<div className='mt-8'>
<p className='text-sm font-bold'>
قیمت هر {unitPriceLabel}{' '}
<span dir='ltr'>T {ef(price.toLocaleString('en-US'))}</span>
</p>
<VariableWeightSlider
className='mt-10 mb-8'
value={selectedWeight}
min={weightRange.min}
max={weightRange.max}
step={weightRange.pitch}
purchaseUnit={productData.purchaseUnit}
onChange={handleVariableWeightChange}
onCommit={handleVariableWeightCommit}
/>
<div className='flex items-center gap-3'>
<div
dir='ltr'
className='flex-1 rounded-xl bg-[#EAECF0] dark:bg-neutral-700 px-4 py-3.5 text-sm font-semibold text-center'
>
{ef(calculatedPrice.toLocaleString('en-US'))} T
</div>
<button
type='button'
onClick={handleAddVariableToCart}
disabled={isCartMutating || !cartId}
className='flex-1 inline-flex items-center justify-center gap-2 rounded-xl bg-black text-white px-4 py-3.5 text-sm font-semibold disabled:opacity-60 disabled:cursor-not-allowed'
>
{isCartMutating ? (
<span className="size-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<PlusIcon className='text-white' />
)}
<span>{quantity > 0 ? 'بروزرسانی' : 'افزودن'}</span>
</button>
</div>
</div>
) : (
<div className='mt-12 flex justify-between items-center'> <div className='mt-12 flex justify-between items-center'>
<span dir='ltr'>{ef(price.toLocaleString('en-US'))} T</span> <span dir='ltr'>{ef(price.toLocaleString('en-US'))} T</span>
<CartQuantityControl <CartQuantityControl
@@ -262,6 +391,7 @@ function ProductPage({ }: Props) {
addDisabled={!cartId} addDisabled={!cartId}
/> />
</div> </div>
)}
</div> </div>
</div> </div>
) )
@@ -1,15 +1,15 @@
'use client'; "use client";
import { Skeleton } from "@/components/ui/skeleton";
import VerticalScrollView from "@/components/listview/VerticalScrollView";
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
import type { MenuItemViewMode } from "@/components/listview/MenuItem"; import type { MenuItemViewMode } from "@/components/listview/MenuItem";
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
import VerticalScrollView from "@/components/listview/VerticalScrollView";
import { Skeleton } from "@/components/ui/skeleton";
interface MenuSkeletonProps { interface MenuSkeletonProps {
viewMode?: MenuItemViewMode; viewMode?: MenuItemViewMode;
} }
const MenuSkeleton = ({ viewMode = 'list' }: MenuSkeletonProps) => { const MenuSkeleton = ({ viewMode = "list" }: MenuSkeletonProps) => {
return ( return (
<div className="flex flex-col gap-4 items-center pt-8 mb-8"> <div className="flex flex-col gap-4 items-center pt-8 mb-8">
<div className="w-full"> <div className="w-full">
@@ -31,7 +31,7 @@ const MenuSkeleton = ({ viewMode = 'list' }: MenuSkeletonProps) => {
</div> </div>
<VerticalScrollView className="mt-5! overflow-y-auto h-full"> <VerticalScrollView className="mt-5! overflow-y-auto h-full">
{viewMode === 'grid' ? ( {viewMode === "grid" ? (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{[...Array(6)].map((_, index) => ( {[...Array(6)].map((_, index) => (
<MenuItemRenderer key={index} variant="grid"> <MenuItemRenderer key={index} variant="grid">
@@ -52,20 +52,18 @@ const MenuSkeleton = ({ viewMode = 'list' }: MenuSkeletonProps) => {
) : ( ) : (
[...Array(6)].map((_, index) => ( [...Array(6)].map((_, index) => (
<MenuItemRenderer key={index} variant="list"> <MenuItemRenderer key={index} variant="list">
<div className="flex gap-4 w-full h-full items-center"> <div className="flex gap-4 w-full min-h-28 items-stretch">
<Skeleton className="min-w-28 w-28 h-28 rounded-xl" /> <Skeleton className="min-w-28 w-28 h-28 rounded-xl shrink-0 self-center" />
<div className="w-full inline-flex flex-col justify-between"> <div className="flex-1 flex flex-col justify-between min-w-0">
<div> <div>
<Skeleton className="h-5 w-32 rounded-md mb-2" /> <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-full rounded-md mb-1" />
<Skeleton className="h-4 w-3/4 rounded-md" /> <Skeleton className="h-4 w-3/4 rounded-md" />
</div> </div>
<div className="inline-flex mt-2 gap-2 justify-between w-full items-center"> <Skeleton className="h-4 w-16 rounded-md mt-2" />
<div className="w-full flex flex-col gap-1">
<Skeleton className="h-4 w-16 rounded-md" />
</div>
<Skeleton className="max-w-[115px] w-full h-8 rounded-md" />
</div> </div>
<div className="flex flex-col justify-end shrink-0">
<Skeleton className="h-8 w-20 rounded-full" />
</div> </div>
</div> </div>
</MenuItemRenderer> </MenuItemRenderer>
+139
View File
@@ -47,6 +47,18 @@ export interface ProductVariant {
price: number; price: number;
} }
export enum PurchaseUnit {
NUMBER = "number",
KILOGRAM = "kilogram",
GRAM = "gram",
CENTIMETER = "centimeter",
METER = "meter",
MILLILITER = "milliliter",
LITER = "liter",
}
export type PricingType = "fixed" | "variable";
export interface Product { export interface Product {
id: string; id: string;
createdAt: string; createdAt: string;
@@ -65,6 +77,12 @@ export interface Product {
isSpecialOffer: boolean; isSpecialOffer: boolean;
price: number | null; price: number | null;
variants: ProductVariant[]; variants: ProductVariant[];
pricingType?: PricingType;
purchaseUnit?: PurchaseUnit | string;
purchasePitch?: string;
minPurchaseQuantity?: string;
maxPurchaseQuantity?: string;
needAdminAcceptance?: boolean;
/** Backward compatibility / other APIs */ /** Backward compatibility / other APIs */
name?: string; name?: string;
description?: string; description?: string;
@@ -99,6 +117,127 @@ export function getProductEffectivePrice(product: Product): number {
return first?.price ?? 0; return first?.price ?? 0;
} }
export function isVariablePricing(product: Product): boolean {
return product.pricingType === "variable";
}
export function getPurchaseUnitLabel(unit: string | undefined): string {
switch (unit) {
case PurchaseUnit.NUMBER:
return "عدد";
case PurchaseUnit.KILOGRAM:
return "کیلوگرم";
case PurchaseUnit.GRAM:
return "گرم";
case PurchaseUnit.CENTIMETER:
return "سانتی‌متر";
case PurchaseUnit.METER:
return "متر";
case PurchaseUnit.MILLILITER:
return "میلی‌لیتر";
case PurchaseUnit.LITER:
return "لیتر";
default:
return unit ?? "";
}
}
/** برچسب کوتاه برای نمایش روی اسلایدر */
export function getPurchaseUnitShortLabel(unit: string | undefined): string {
switch (unit) {
case PurchaseUnit.KILOGRAM:
return "kg";
case PurchaseUnit.GRAM:
return "gr";
case PurchaseUnit.CENTIMETER:
return "cm";
case PurchaseUnit.METER:
return "m";
case PurchaseUnit.MILLILITER:
return "ml";
case PurchaseUnit.LITER:
return "L";
case PurchaseUnit.NUMBER:
return "";
default:
return unit ?? "";
}
}
export function formatPurchaseQuantity(value: number, unit: string | undefined): string {
const short = getPurchaseUnitShortLabel(unit);
if (unit === PurchaseUnit.KILOGRAM) {
const grams = Math.round(value * 1000);
if (grams < 1000) {
return `${grams} gr`;
}
if (Number.isInteger(value)) {
return `${value} kg`;
}
return `${parseFloat(value.toFixed(3))} kg`;
}
if (unit === PurchaseUnit.GRAM) {
return `${Math.round(value)} gr`;
}
if (unit === PurchaseUnit.MILLILITER) {
if (value >= 1000) {
const liters = value / 1000;
return Number.isInteger(liters) ? `${liters} L` : `${parseFloat(liters.toFixed(2))} L`;
}
return `${Math.round(value)} ml`;
}
if (unit === PurchaseUnit.LITER) {
if (value < 1) {
return `${Math.round(value * 1000)} ml`;
}
return Number.isInteger(value) ? `${value} L` : `${parseFloat(value.toFixed(2))} L`;
}
if (unit === PurchaseUnit.CENTIMETER) {
if (value >= 100) {
const meters = value / 100;
return Number.isInteger(meters) ? `${meters} m` : `${parseFloat(meters.toFixed(2))} m`;
}
return `${Math.round(value)} cm`;
}
if (unit === PurchaseUnit.METER) {
if (value < 1) {
return `${Math.round(value * 100)} cm`;
}
return Number.isInteger(value) ? `${value} m` : `${parseFloat(value.toFixed(2))} m`;
}
if (unit === PurchaseUnit.NUMBER) {
return `${value}`;
}
return short ? `${value} ${short}` : `${value}`;
}
export function parsePurchaseQuantity(value: string | undefined, fallback: number): number {
const parsed = Number.parseFloat(value ?? "");
return Number.isFinite(parsed) ? parsed : fallback;
}
export function getVariableQuantityRange(product: Product) {
const pitch = parsePurchaseQuantity(product.purchasePitch, 0.05);
const min = parsePurchaseQuantity(product.minPurchaseQuantity, 1);
const max = parsePurchaseQuantity(product.maxPurchaseQuantity, min);
return {
min,
max: Math.max(max, min),
pitch: pitch > 0 ? pitch : 0.05,
};
}
/** @deprecated use getVariableQuantityRange */
export const getVariableWeightRange = getVariableQuantityRange;
export interface NotificationsCountData { export interface NotificationsCountData {
count: number; count: number;
} }
+95 -125
View File
@@ -1,19 +1,16 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
'use client' "use client";
import { memo, useMemo } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { CartQuantityControl } from "@/components/CartQuantityControl";
import { useCart } from "@/app/[name]/(Dialogs)/cart/hook/useCart"; import { useCart } from "@/app/[name]/(Dialogs)/cart/hook/useCart";
import type { Product, ProductImage } from "@/app/[name]/(Main)/types/Types"; import type { Product, ProductImage } from "@/app/[name]/(Main)/types/Types";
import { import { formatPurchaseQuantity, getPrimaryVariantId, getProductEffectivePrice, getPurchaseUnitLabel, hasVariants, isVariablePricing } from "@/app/[name]/(Main)/types/Types";
hasVariants, import { CartQuantityControl } from "@/components/CartQuantityControl";
getPrimaryVariantId, import { ArrowLeft } from "iconsax-react";
getProductEffectivePrice, import Link from "next/link";
} from "@/app/[name]/(Main)/types/Types"; import { useParams } from "next/navigation";
import { memo, useMemo, type ReactNode } from "react";
export type MenuItemViewMode = 'list' | 'grid'; export type MenuItemViewMode = "list" | "grid";
interface MenuItemProps { interface MenuItemProps {
product: Product; product: Product;
@@ -23,19 +20,24 @@ interface MenuItemProps {
variantValue?: string | null; variantValue?: string | null;
/** حالت نمایش: list = یک ستونه (پیش‌فرض)، grid = دو ستونه */ /** حالت نمایش: list = یک ستونه (پیش‌فرض)، grid = دو ستونه */
viewMode?: MenuItemViewMode; viewMode?: MenuItemViewMode;
/** فقط نمایش اطلاعات بدون دکمه‌های افزودن/جزئیات */
readOnly?: boolean;
/** تعداد انتخاب‌شده — در حالت readOnly نمایش داده می‌شود */
displayQuantity?: number;
/** برچسب اختیاری بالای آیتم */
badge?: ReactNode;
} }
const MenuItem = ({ product, variantId: variantIdProp, variantValue: variantValueProp, viewMode = 'list' }: MenuItemProps) => { const MenuItem = ({ product, variantId: variantIdProp, variantValue: variantValueProp, viewMode = "list", readOnly = false, displayQuantity, badge }: MenuItemProps) => {
const { items, addToCart, removeFromCart, isCartMutating } = useCart(); const { items, addToCart, removeFromCart, isCartMutating } = useCart();
const params = useParams<{ name: string }>(); const params = useParams<{ name: string }>();
const name = params?.name || ""; const name = params?.name || "";
const variantId = variantIdProp ?? getPrimaryVariantId(product); const variantId = variantIdProp ?? getPrimaryVariantId(product);
const selectedVariant = variantId const selectedVariant = variantId ? product.variants?.find((v) => v.id === variantId) : undefined;
? product.variants?.find((v) => v.id === variantId)
: undefined;
const withVariants = hasVariants(product); const withVariants = hasVariants(product);
/** در لیست منو اگر تنوع داشت دکمه جزئیات؛ در سبد همیشه افزودن/کم کردن */ const withVariablePricing = isVariablePricing(product);
const showDetailsInsteadOfAdd = withVariants && variantIdProp == null; /** در لیست منو اگر تنوع یا قیمت متغیر داشت دکمه جزئیات؛ در سبد همیشه افزودن/کم کردن */
const showDetailsInsteadOfAdd = (withVariants || withVariablePricing) && variantIdProp == null;
const quantity = useMemo(() => { const quantity = useMemo(() => {
if (!variantId) return 0; if (!variantId) return 0;
@@ -58,10 +60,7 @@ const MenuItem = ({ product, variantId: variantIdProp, variantValue: variantValu
return fallbackImage; return fallbackImage;
}, [product.image, product.images]); }, [product.image, product.images]);
const productName = useMemo( const productName = useMemo(() => product.name || product.title || "بدون نام", [product.name, product.title]);
() => product.name || product.title || 'بدون نام',
[product.name, product.title]
);
/** فقط در سبد خرید تنوع نمایش داده شود */ /** فقط در سبد خرید تنوع نمایش داده شود */
const isInCart = variantIdProp != null; const isInCart = variantIdProp != null;
@@ -69,25 +68,22 @@ const MenuItem = ({ product, variantId: variantIdProp, variantValue: variantValu
const productContent = useMemo(() => { const productContent = useMemo(() => {
const content = product.content; const content = product.content;
if (!content) return ''; if (!content) return "";
if (Array.isArray(content)) { if (Array.isArray(content)) {
return content.filter(item => item && typeof item === 'string').join('، '); return content.filter((item) => item && typeof item === "string").join("، ");
} }
return ''; return "";
}, [product.content]); }, [product.content]);
const productDescription = useMemo(() => { const productDescription = useMemo(() => {
const desc = product.description || product.desc; const desc = product.description || product.desc;
if (!desc || typeof desc !== 'string') return ''; if (!desc || typeof desc !== "string") return "";
return desc.replace(/,/g, '،'); return desc.replace(/,/g, "،");
}, [product.description, product.desc]); }, [product.description, product.desc]);
const effectivePrice = const effectivePrice = selectedVariant != null ? selectedVariant.price : getProductEffectivePrice(product);
selectedVariant != null
? selectedVariant.price
: getProductEffectivePrice(product);
const finalPrice = useMemo(() => { const finalPrice = useMemo(() => {
const discount = product.discount || 0; const discount = product.discount || 0;
if (discount > 0) { if (discount > 0) {
@@ -96,21 +92,25 @@ const MenuItem = ({ product, variantId: variantIdProp, variantValue: variantValu
return effectivePrice; return effectivePrice;
}, [effectivePrice, product.discount]); }, [effectivePrice, product.discount]);
const formattedPrice = useMemo( const formattedPrice = useMemo(() => (finalPrice ? finalPrice.toLocaleString("fa-IR") : "0"), [finalPrice]);
() => (finalPrice ? finalPrice.toLocaleString("fa-IR") : "0"),
[finalPrice]
);
const formattedOriginalPrice = useMemo( const variablePriceLabel = useMemo(() => {
() => if (!withVariablePricing) return null;
effectivePrice ? effectivePrice.toLocaleString("fa-IR") : "0", const unitLabel = getPurchaseUnitLabel(product.purchaseUnit);
[effectivePrice] return `قیمت هر ${unitLabel} T ${formattedPrice}`;
); }, [withVariablePricing, product.purchaseUnit, formattedPrice]);
const hasDiscount = useMemo( const formattedOriginalPrice = useMemo(() => (effectivePrice ? effectivePrice.toLocaleString("fa-IR") : "0"), [effectivePrice]);
() => (product.discount || 0) > 0,
[product.discount] const hasDiscount = useMemo(() => (product.discount || 0) > 0, [product.discount]);
);
const quantityLabel = useMemo(() => {
if (displayQuantity == null) return null;
if (withVariablePricing) {
return formatPurchaseQuantity(displayQuantity, product.purchaseUnit);
}
return `${displayQuantity.toLocaleString("fa-IR")}×`;
}, [displayQuantity, withVariablePricing, product.purchaseUnit]);
const handleAddToCart = () => { const handleAddToCart = () => {
if (variantId) addToCart(variantId); if (variantId) addToCart(variantId);
@@ -121,60 +121,38 @@ const MenuItem = ({ product, variantId: variantIdProp, variantValue: variantValu
}; };
const productDetailUrl = `/${name}/${product.id}?category=${product.category.id}`; const productDetailUrl = `/${name}/${product.id}?category=${product.category.id}`;
const actionButtonClass = "bg-background active:drop-shadow-xs rounded-md h-8 inline-flex p-1 justify-center items-center gap-2 relative overflow-hidden";
const actionButtonLabelClass = "text-sm2 pt-0.5 font-normal text-foreground";
if (viewMode === 'grid') { if (viewMode === "grid") {
return ( return (
<div className="flex flex-col w-full h-full"> <div className="flex flex-col w-full h-full">
<Link href={productDetailUrl} className="cursor-pointer rounded-xl aspect-square overflow-hidden flex items-center justify-center max-h-[100px]"> <Link href={productDetailUrl} className="cursor-pointer rounded-xl aspect-square overflow-hidden flex items-center justify-center max-h-[100px]">
<img <img className="rounded-xl max-w-full max-h-full object-center" src={resolvedImage} alt={productName} />
className="rounded-xl max-w-full max-h-full object-center"
src={resolvedImage}
alt={productName}
/>
</Link> </Link>
<Link href={productDetailUrl} className="cursor-pointer min-w-0 mt-4 flex items-center justify-between gap-2"> <Link href={productDetailUrl} className="cursor-pointer min-w-0 mt-4 flex items-center justify-between gap-2">
<div className="text-sm2 font-normal text-black dark:text-white wrap-break-word line-clamp-2 min-w-0"> <div className="text-sm2 font-normal text-black dark:text-white wrap-break-word line-clamp-2 min-w-0">{productName}</div>
{productName} {variantValueDisplay && <span className="bg-primary text-primary-foreground text-[10px] px-1 py-0.5 rounded-md shrink-0">{variantValueDisplay}</span>}
</div>
{variantValueDisplay && (
<span className="bg-primary text-primary-foreground text-[10px] px-1 py-0.5 rounded-md shrink-0">
{variantValueDisplay}
</span>
)}
</Link> </Link>
<div className="flex flex-col gap-2 mt-1"> <div className="flex flex-col gap-2 mt-1">
<div className="flex flex-col gap-1 min-h-10 justify-center" dir="ltr"> <div className="flex flex-col gap-1 min-h-10 justify-center" dir="ltr">
{hasDiscount ? ( {variablePriceLabel ? (
<span className="text-sm text-right">{variablePriceLabel}</span>
) : hasDiscount ? (
<> <>
<span className="text-xs text-disabled-text line-through"> <span className="text-xs text-disabled-text line-through">{formattedOriginalPrice} T</span>
{formattedOriginalPrice} T <span className="text-sm font-medium text-primary dark:text-foreground">{formattedPrice} T</span>
</span>
<span className="text-sm font-medium text-primary dark:text-foreground">
{formattedPrice} T
</span>
</> </>
) : ( ) : (
<span className="text-sm text-right"> <span className="text-sm text-right">{formattedPrice} T</span>
{formattedPrice} T
</span>
)} )}
</div> </div>
{showDetailsInsteadOfAdd ? ( {showDetailsInsteadOfAdd ? (
<Link <Link href={productDetailUrl} className={`${actionButtonClass} w-full`}>
href={productDetailUrl} <span className={actionButtonLabelClass}>جزئیات</span>
className="bg-background active:drop-shadow-xs w-full rounded-md h-8 inline-flex justify-center items-center gap-2 px-2 text-sm2 font-normal text-foreground"
>
جزئیات
</Link> </Link>
) : ( ) : (
<CartQuantityControl <CartQuantityControl quantity={quantity} onAdd={handleAddToCart} onRemove={handleRemoveFromCart} isMutating={isCartMutating} fullWidth />
quantity={quantity}
onAdd={handleAddToCart}
onRemove={handleRemoveFromCart}
isMutating={isCartMutating}
fullWidth
/>
)} )}
</div> </div>
</div> </div>
@@ -182,71 +160,63 @@ const MenuItem = ({ product, variantId: variantIdProp, variantValue: variantValu
} }
return ( return (
<div className="flex gap-4 w-full h-full items-center"> <>
<Link href={productDetailUrl} className="cursor-pointer"> {badge && (
<img <div className="absolute top-3 left-3 z-10">
className="rounded-xl bg-[#F2F2F9] min-w-28 w-28 aspect-square object-cover" {badge}
src={resolvedImage} </div>
height={112} )}
width={112} <div className="flex gap-4 w-full min-h-28 items-stretch">
alt={productName} <Link href={productDetailUrl} className="cursor-pointer shrink-0 self-center">
/> <img className="rounded-xl bg-[#F2F2F9] min-w-28 w-28 aspect-square object-cover" src={resolvedImage} height={112} width={112} alt={productName} />
</Link> </Link>
<div className="w-full inline-flex flex-col justify-between min-w-0"> <div className="flex-1 flex flex-col justify-between min-w-0">
<Link href={productDetailUrl} className="cursor-pointer min-w-0"> <Link href={productDetailUrl} className="cursor-pointer min-w-0">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center justify-between gap-2 min-w-0"> <div className="flex items-center justify-between gap-2 min-w-0">
<div className="text-sm2 font-normal text-black dark:text-white wrap-break-word min-w-0"> <div className="text-sm2 font-bold text-black dark:text-white wrap-break-word min-w-0">{productName}</div>
{productName} {variantValueDisplay && <span className="bg-primary text-primary-foreground text-[10px] px-1 py-0.5 rounded-md shrink-0">{variantValueDisplay}</span>}
</div>
{variantValueDisplay && (
<span className="bg-primary text-primary-foreground text-[10px] px-1 py-0.5 rounded-md shrink-0">
{variantValueDisplay}
</span>
)}
</div> </div>
{(productContent || productDescription) && ( {(productContent || productDescription) && (
<div className="text-[#7F7F7F] line-clamp-2 text-xs leading-5 font-normal mt-2 wrap-break-word overflow-hidden"> <div className="text-[#7F7F7F] line-clamp-2 text-xs leading-5 font-normal mt-4 wrap-break-word overflow-hidden">{productContent || productDescription}</div>
{productContent || productDescription}
</div>
)} )}
</div> </div>
</Link> </Link>
<div className="flex flex-col mt-2 gap-2 w-full"> <div className="flex flex-col gap-1 mt-2" dir="ltr">
<div className="flex flex-col gap-1 min-h-10 justify-center" dir="ltr"> {variablePriceLabel ? (
{hasDiscount ? ( <span className="text-sm text-right">{variablePriceLabel}</span>
) : hasDiscount ? (
<> <>
<span className="text-xs text-disabled-text line-through"> <span className="text-xs text-disabled-text line-through">{formattedOriginalPrice} T</span>
{formattedOriginalPrice} T <span className="text-sm font-medium text-primary dark:text-foreground">{formattedPrice} T</span>
</span>
<span className="text-sm font-medium text-primary dark:text-foreground">
{formattedPrice} T
</span>
</> </>
) : ( ) : (
<span className="text-sm text-right"> <span className="text-sm text-right">{formattedPrice} T</span>
{formattedPrice} T
</span>
)} )}
</div> </div>
</div>
{readOnly && quantityLabel ? (
<div className="flex flex-col justify-end shrink-0">
<span className="text-sm font-semibold text-foreground">{quantityLabel}</span>
</div>
) : !readOnly ? (
<div className="flex flex-col justify-end shrink-0">
{showDetailsInsteadOfAdd ? ( {showDetailsInsteadOfAdd ? (
<Link <div className="w-[115px]">
href={productDetailUrl} <Link href={productDetailUrl} className={`${actionButtonClass} w-full`}>
className="bg-background active:drop-shadow-xs w-full rounded-md h-8 inline-flex justify-center items-center gap-2 px-2 text-sm2 font-normal text-foreground" <span className={actionButtonLabelClass}>جزئیات</span>
> <ArrowLeft size={16} className="stroke-foreground" />
جزئیات
</Link> </Link>
</div>
) : ( ) : (
<CartQuantityControl <div className="w-[115px]">
quantity={quantity} <CartQuantityControl quantity={quantity} onAdd={handleAddToCart} onRemove={handleRemoveFromCart} isMutating={isCartMutating} />
onAdd={handleAddToCart} </div>
onRemove={handleRemoveFromCart}
isMutating={isCartMutating}
/>
)} )}
</div> </div>
) : null}
</div> </div>
</div> </>
); );
}; };
+1 -1
View File
@@ -15,7 +15,7 @@ function MenuItemRendererComponent({ children, variant = 'list', ...rest }: Prop
className={`${rest.className} box-shadow-normal transition-all duration-200 ease-out w-full bg-container rounded-3xl ${ className={`${rest.className} box-shadow-normal transition-all duration-200 ease-out w-full bg-container rounded-3xl ${
isGrid isGrid
? 'p-3 flex flex-col' ? 'p-3 flex flex-col'
: 'py-4 pb-4! px-4 flex items-center justify-between gap-4' : 'relative py-4 pb-4! px-4 flex items-stretch overflow-visible'
}`} }`}
> >
{children} {children}
@@ -0,0 +1,86 @@
'use client'
import { formatPurchaseQuantity } from '@/app/[name]/(Main)/types/Types'
import { ef } from '@/lib/helpers/utfNumbers'
import { cn } from '@/lib/utils'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { useCallback, useEffect, useRef, useState } from 'react'
interface VariableWeightSliderProps {
value: number
min: number
max: number
step: number
purchaseUnit?: string
onChange: (value: number) => void
onCommit?: (value: number) => void
className?: string
}
export function VariableWeightSlider({
value,
min,
max,
step,
purchaseUnit,
onChange,
onCommit,
className,
}: VariableWeightSliderProps) {
const rootRef = useRef<HTMLDivElement>(null)
const [tooltipLeft, setTooltipLeft] = useState(0)
const updateTooltipPosition = useCallback(() => {
const root = rootRef.current
if (!root) return
const thumb = root.querySelector<HTMLElement>('[data-slot="slider-thumb"]')
if (!thumb) return
const rootRect = root.getBoundingClientRect()
const thumbRect = thumb.getBoundingClientRect()
setTooltipLeft(thumbRect.left - rootRect.left + thumbRect.width / 2)
}, [])
useEffect(() => {
updateTooltipPosition()
window.addEventListener('resize', updateTooltipPosition)
return () => window.removeEventListener('resize', updateTooltipPosition)
}, [value, min, max, updateTooltipPosition])
const quantityLabel = ef(formatPurchaseQuantity(value, purchaseUnit))
return (
<div ref={rootRef} className={cn('relative w-full', className)}>
<div
className="pointer-events-none absolute -top-10 z-10 -translate-x-1/2 whitespace-nowrap rounded-lg bg-[#EAECF0] px-3.5 py-1.5 text-xs font-semibold text-foreground"
style={{ left: tooltipLeft }}
>
{quantityLabel}
</div>
<SliderPrimitive.Root
data-slot="slider"
value={[value]}
min={min}
max={max}
step={step}
onValueChange={(values) => onChange(values[0] ?? min)}
onValueCommit={(values) => onCommit?.(values[0] ?? min)}
className="relative flex w-full touch-none select-none items-center py-3"
>
<SliderPrimitive.Track
data-slot="slider-track"
className="relative h-[3px] w-full grow overflow-hidden rounded-full bg-[#EAECF0]"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="absolute h-full bg-black"
/>
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
data-slot="slider-thumb"
className="block size-5 shrink-0 cursor-grab rounded-full bg-black shadow-none outline-none active:cursor-grabbing focus-visible:ring-0"
/>
</SliderPrimitive.Root>
</div>
)
}
+1 -1
View File
@@ -1,5 +1,5 @@
// export const API_BASE_URL = "https://dmenuplus-api.dev.danakcorp.com"; // export const API_BASE_URL = "https://dmenuplus-api.dev.danakcorp.com";
export const API_BASE_URL = "https://dkala-api.danakcorp.com"; export const API_BASE_URL = "https://dkala-api.danakcorp.com";
// export const API_BASE_URL = "http://10.191.241.88:4000"; // export const API_BASE_URL = "http://192.168.99.131:4000";
export const TOKEN_NAME = "dkala-t"; export const TOKEN_NAME = "dkala-t";
export const REFRESH_TOKEN_NAME = "dkala-rt"; export const REFRESH_TOKEN_NAME = "dkala-rt";
+5
View File
@@ -19,8 +19,13 @@
"Online": "پرداخت آنلاین", "Online": "پرداخت آنلاین",
"Wallet": "پرداخت با کیف پول" "Wallet": "پرداخت با کیف پول"
}, },
"itemStatus": {
"needConfirmation": "در انتظار تایید قیمت نهایی",
"confirmed": "تایید شده"
},
"status": { "status": {
"new": "سفارش جدید", "new": "سفارش جدید",
"needConfirmation": "نیاز به تایید فروشگاه",
"pendingPayment": "در انتظار پرداخت", "pendingPayment": "در انتظار پرداخت",
"paid": "پرداخت شده", "paid": "پرداخت شده",
"confirmed": "تایید شده", "confirmed": "تایید شده",
+1
View File
@@ -111,6 +111,7 @@
"Title": "اقلام سفارش" "Title": "اقلام سفارش"
}, },
"ButtonSubmit": "پرداخت", "ButtonSubmit": "پرداخت",
"ButtonRegister": "ثبت",
"PayableAmountLabel": "مبلغ قابل پرداخت" "PayableAmountLabel": "مبلغ قابل پرداخت"
} }
} }
+8
View File
@@ -14,6 +14,7 @@ type ReceiptStore = {
setSlug: (slug: string) => void; setSlug: (slug: string) => void;
increment: (id: string | number) => void; increment: (id: string | number) => void;
decrement: (id: string | number) => void; decrement: (id: string | number) => void;
setQuantity: (id: string | number, quantity: number) => void;
clear: () => void; clear: () => void;
}; };
@@ -45,6 +46,13 @@ export const useReceiptStore = create<ReceiptStore>()(
}, },
}; };
}), }),
setQuantity: (id, quantity) =>
set((state) => ({
items: {
...state.items,
[id]: { quantity },
},
})),
}), }),
{ {
name: "receipt-storage", name: "receipt-storage",
+1 -1
View File
File diff suppressed because one or more lines are too long