Compare commits

..

18 Commits

Author SHA1 Message Date
hamid zarghami bc3cb0714b fix tailwind error
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-22 15:55:50 +03:30
hamid zarghami 80552458cd special offer
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-22 15:54:52 +03:30
hamid zarghami c08b4ec023 update glass + pattenr + ...
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-07 14:48:33 +03:30
hamid zarghami 2dc44d1c77 log error fix
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-29 14:05:32 +03:30
hamid zarghami 6b81c39327 docker file update 2
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-29 14:04:31 +03:30
hamid zarghami 4ad88fb7f0 mirror
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-29 14:03:01 +03:30
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
hamid zarghami 4f3b97792d less space
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-20 10:46:11 +03:30
danak 0c8d132783 Update Dockerfile
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-14 10:45:21 +00:00
hamid zarghami f2fd774dc4 color icon
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-14 12:12:58 +03:30
hamid zarghami 1172e27879 Merge branch 'main' of https://git.danakcorp.com/danak/dkala-front
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-14 11:57:18 +03:30
hamid zarghami b7a046f59d domains 2026-06-14 11:56:25 +03:30
63 changed files with 2724 additions and 1038 deletions
+2 -4
View File
@@ -1,11 +1,9 @@
FROM node:20-alpine AS base FROM node:20-alpine AS base
# Change Alpine repo
RUN sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://mirror.de.velop.ir/alpine|g' /etc/apk/repositories RUN sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://mirror.de.velop.ir/alpine|g' /etc/apk/repositories
# Configure npm registry mirror (Liara) ENV NPM_CONFIG_REGISTRY=https://package-mirror.liara.ir/repository/npm/
# ENV NPM_CONFIG_REGISTRY=https://package-mirror.liara.ir/repository/npm/ RUN npm config set registry https://package-mirror.liara.ir/repository/npm/
# RUN npm config set registry https://package-mirror.liara.ir/repository/npm/
WORKDIR /app WORKDIR /app
BIN
View File
Binary file not shown.
@@ -3,6 +3,7 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter, usePathname } from 'next/navigation'; import { useParams, useRouter, usePathname } from 'next/navigation';
import { glassSurfaceNav } from '@/lib/styles/glassSurface';
import Button from '@/components/button/PrimaryButton'; import Button from '@/components/button/PrimaryButton';
import { useGetProducts } from '@/app/[name]/(Main)/hooks/useMenuData'; import { useGetProducts } from '@/app/[name]/(Main)/hooks/useMenuData';
import type { Product } from '@/app/[name]/(Main)/types/Types'; import type { Product } from '@/app/[name]/(Main)/types/Types';
@@ -105,7 +106,7 @@ const CartSummary = ({ isPremium }: CartSummaryProps) => {
</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={glassSurfaceNav('fixed bottom-0 left-0 right-0 z-50 border-t border-border p-4 w-full')}>
<div className='flex justify-between items-center gap-4'> <div className='flex justify-between items-center gap-4'>
<div className='flex flex-col'> <div className='flex flex-col'>
<div className='text-xs text-gray-400 dark:text-disabled-text'>{t('PayableAmountLabel')}</div> <div className='text-xs text-gray-400 dark:text-disabled-text'>{t('PayableAmountLabel')}</div>
+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,
}; };
@@ -1,4 +1,5 @@
'use client'; 'use client';
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
import { useGetAddresses } from '@/app/[name]/(Profile)/profile/address/hooks/useAddressData'; import { useGetAddresses } from '@/app/[name]/(Profile)/profile/address/hooks/useAddressData';
import { Address } from '@/app/[name]/(Profile)/profile/address/types/Types'; import { Address } from '@/app/[name]/(Profile)/profile/address/types/Types';
@@ -1,4 +1,5 @@
'use client'; 'use client';
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
import InputField from '@/components/input/InputField'; import InputField from '@/components/input/InputField';
import { useCheckoutStore } from '../../store/Store'; import { useCheckoutStore } from '../../store/Store';
@@ -1,4 +1,5 @@
'use client'; 'use client';
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
import InputField from '@/components/input/InputField'; import InputField from '@/components/input/InputField';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -1,4 +1,5 @@
'use client'; 'use client';
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
import { Card, Icon, Wallet2 } from 'iconsax-react'; import { Card, Icon, Wallet2 } from 'iconsax-react';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
@@ -1,4 +1,5 @@
'use client'; 'use client';
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
import Combobox from '@/components/combobox/Combobox'; import Combobox from '@/components/combobox/Combobox';
import { Box, Location, Car, Building } from 'iconsax-react'; import { Box, Location, Car, Building } from 'iconsax-react';
@@ -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>
@@ -1,4 +1,5 @@
'use client'; 'use client';
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
import { MinusIcon, PlusIcon } from 'lucide-react'; import { MinusIcon, PlusIcon } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
@@ -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"
+256 -167
View File
@@ -1,32 +1,58 @@
'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 "روش ارسال";
} }
}; };
// const getStatusPercentage = (status: string) => { // const getStatusPercentage = (status: string) => {
@@ -53,17 +79,17 @@ 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",
}); });
}; };
// const formatTime = (dateString: string): string => { // const formatTime = (dateString: string): string => {
@@ -75,150 +101,213 @@ const formatDate = (dateString: string): string => {
// }; // };
function OrderTrackingPage() { function OrderTrackingPage() {
const { id, name } = useParams(); const { id, name } = useParams();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
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 onCancelOrder = (e: React.MouseEvent | null) => { const orderStatus = orderDetail?.data?.status;
if (!e || !id) return; const showPayButton = orderStatus === "pendingPayment" || orderStatus === "needConfirmation";
const isPayDisabled = orderStatus === "needConfirmation";
cancelOrder(id as string, { const onPayOrder = () => {
onSuccess: () => { if (!id || isPayDisabled) return;
toggleCancelModal();
queryClient.invalidateQueries({ queryKey: ['order-detail', id] });
toast('سفارش با موفقیت لغو شد', 'success');
},
onError: (error) => {
toast(extractErrorMessage(error), 'error');
}
});
};
return ( payOrder(id as string, {
<div className="fixed inset-0 bg-container flex flex-col"> onSuccess: (data) => {
<div className='flex-1 overflow-y-auto px-4 py-6'> const paymentUrl = data?.data?.paymentUrl;
<div className='max-w-2xl mx-auto flex flex-col gap-6'> if (paymentUrl) {
{isLoading ? ( toast("در حال ریدایرکت به صفحه پرداخت...", "success");
<> window.location.href = paymentUrl;
<Skeleton className='h-6 w-48 mx-auto' /> } else {
<div className='flex flex-col gap-4'> queryClient.invalidateQueries({ queryKey: ["order-detail", id] });
<Skeleton className='h-5 w-full' /> toast("پرداخت با موفقیت انجام شد", "success");
<Skeleton className='h-5 w-full' /> }
<Skeleton className='h-20 w-full' /> },
<Skeleton className='h-5 w-full' /> onError: (error) => {
</div> toast(extractErrorMessage(error), "error");
</> },
) : orderDetail?.data ? ( });
<> };
<h1 className='text-lg font-semibold text-center'>
{getDeliveryMethodTitle(orderDetail.data.deliveryMethod.method)}
</h1>
<div className='flex flex-col'> const onCancelOrder = (e: React.MouseEvent | null) => {
<div className='flex justify-between items-center h-16 border-b border-border'> if (!e || !id) return;
<span className='text-sm text-muted-foreground flex items-center gap-2'>
<Clock className='stroke-current' size={16} />
زمان سفارش
</span>
<div className='flex items-end gap-0.5'>
<span className='text-sm font-semibold'>{formatDate(orderDetail.data.createdAt)}</span>
{/* <span className='text-xs text-muted-foreground'>{formatTime(orderDetail.data.createdAt)}</span> */}
</div>
</div>
<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 font-semibold'>{orderDetail.data?.deliveryMethod?.description}</span>
</div>
{orderDetail.data.carAddress && (
<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 font-semibold'>
{orderDetail.data.carAddress.carModel} {orderDetail.data.carAddress.carColor} - پلاک: {orderDetail.data.carAddress.plateNumber}
</div>
</div>
)}
<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 font-semibold'>#{orderDetail.data.orderNumber}</span>
</div>
<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 font-semibold'>{getStatusText(orderDetail.data.status)}</span>
</div>
{orderDetail.data.tableNumber && ( cancelOrder(id as string, {
<div className='flex justify-between items-center h-16 border-b border-border'> onSuccess: () => {
<span className='text-sm text-muted-foreground'>شماره میز</span> toggleCancelModal();
<span className='text-sm font-semibold'>#{orderDetail.data.tableNumber}</span> queryClient.invalidateQueries({ queryKey: ["order-detail", id] });
</div> toast("سفارش با موفقیت لغو شد", "success");
)} },
onError: (error) => {
toast(extractErrorMessage(error), "error");
},
});
};
{orderDetail.data.userAddress && ( return (
<div className='py-3 border-b border-border'> <div className="fixed inset-0 bg-container flex flex-col">
<div className='text-sm text-muted-foreground mb-1'>آدرس تحویل</div> <div className="flex-1 overflow-y-auto px-4 py-6">
<div className='text-sm'>{orderDetail.data.userAddress.address}</div> <div className="max-w-2xl mx-auto flex flex-col gap-6">
</div> {isLoading ? (
)} <>
<Skeleton className="h-6 w-48 mx-auto" />
<div className='flex justify-between items-center py-4 border-t border-border'> <div className="flex flex-col gap-4">
<span className='text-sm font-medium'>مجموع سفارش</span> <Skeleton className="h-5 w-full" />
<span className='text-sm font-bold '> <Skeleton className="h-5 w-full" />
{orderDetail.data.total.toLocaleString('fa-IR')} تومان <Skeleton className="h-20 w-full" />
</span> <Skeleton className="h-5 w-full" />
</div> </div>
</div> </>
</> ) : orderDetail?.data ? (
) : ( <>
<div className='text-center text-muted-foreground py-12'> {orderDetail.data.status === "needConfirmation" && (
اطلاعات سفارش یافت نشد <div className="rounded-2xl bg-red-50 px-4 py-3 text-center">
</div> <p className="text-sm text-red-800 leading-6">لطفاً پس از دریافت پیامک، حداکثر تا ۲ ساعت نسبت به پرداخت اقدام کنید؛ در غیر این صورت سفارش بهصورت خودکار لغو خواهد شد.</p>
)}
</div> </div>
</div> )}
<div className='p-4 border-t border-border bg-container'> <h1 className="text-lg font-semibold text-center">{getDeliveryMethodTitle(orderDetail.data.deliveryMethod.method)}</h1>
<div className='max-w-2xl mx-auto grid grid-cols-2 gap-4'>
<Button <div className="flex flex-col">
className='dark:bg-white dark:text-black! dark:hover:bg-gray-100' <div className="flex justify-between items-center h-16 border-b border-border">
onClick={() => router.push(`/${name}`)} <span className="text-sm text-muted-foreground flex items-center gap-2">
type='button'> <Clock className="stroke-current" size={16} />
بازگشت به منو زمان سفارش
</Button> </span>
<Button <div className="flex items-end gap-0.5">
type='submit' <span className="text-sm font-semibold">{formatDate(orderDetail.data.createdAt)}</span>
onClick={toggleCancelModal} {/* <span className='text-xs text-muted-foreground'>{formatTime(orderDetail.data.createdAt)}</span> */}
className='bg-disabled! text-foreground!' </div>
disabled={ </div>
orderDetail?.data?.status === 'cancelled' || <div className="flex justify-between items-center h-16 border-b border-border">
orderDetail?.data?.status === 'delivered' || <span className="text-sm text-muted-foreground">نوع ارسال</span>
orderDetail?.data?.status === 'delivering' <span className="text-sm font-semibold">{orderDetail.data?.deliveryMethod?.description}</span>
} </div>
> {orderDetail.data.carAddress && (
لغو سفارش <div className="flex justify-between items-center h-16 border-b border-border">
</Button> <div className="text-sm text-muted-foreground mb-1">اطلاعات خودرو</div>
<div className="text-sm font-semibold">
{orderDetail.data.carAddress.carModel} {orderDetail.data.carAddress.carColor} - پلاک: {orderDetail.data.carAddress.plateNumber}
</div>
</div>
)}
<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 font-semibold">#{orderDetail.data.orderNumber}</span>
</div>
<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 font-semibold">{getStatusText(orderDetail.data.status)}</span>
</div> </div>
</div>
<div className={clsx( {orderDetail.data.tableNumber && (
'fixed inset-0 z-1001', <div className="flex justify-between items-center h-16 border-b border-border">
!cancelModal && 'pointer-events-none' <span className="text-sm text-muted-foreground">شماره میز</span>
)}> <span className="text-sm font-semibold">#{orderDetail.data.tableNumber}</span>
<Prompt </div>
title={'لغو سفارش'} )}
description={'آیا از درخواست خود اطمینان دارید؟'}
textConfirm={'بله'} {orderDetail.data.userAddress && (
textCancel={'منصرف شدم'} <div className="py-3 border-b border-border">
onConfirm={onCancelOrder} <div className="text-sm text-muted-foreground mb-1">آدرس تحویل</div>
visible={cancelModal} <div className="text-sm">{orderDetail.data.userAddress.address}</div>
onClick={toggleCancelModal} </div>
onCancel={toggleCancelModal} )}
/>
</div> {orderDetail.data.items?.length > 0 && (
<div className="py-4 mt-7 border-b border-border">
<div className="text-sm font-medium mb-4">اقلام سفارش</div>
<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 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 className="text-center text-muted-foreground py-12">اطلاعات سفارش یافت نشد</div>
)}
</div> </div>
); </div>
<div className="p-4 border-t border-border bg-container">
<div className="max-w-2xl mx-auto grid grid-cols-2 gap-4">
<Button className="bg-disabled! text-foreground!" onClick={() => router.push(`/${name}`)} type="button">
بازگشت به منو
</Button>
{showPayButton ? (
<Button
type="button"
onClick={onPayOrder}
disabled={isPayDisabled}
pending={isPaying}
>
پرداخت
</Button>
) : (
<Button
type="submit"
onClick={toggleCancelModal}
className="bg-disabled! text-foreground!"
disabled={orderDetail?.data?.status === "cancelled" || orderDetail?.data?.status === "delivered" || orderDetail?.data?.status === "delivering"}
>
لغو سفارش
</Button>
)}
</div>
</div>
<div className={clsx("fixed inset-0 z-1001", !cancelModal && "pointer-events-none")}>
<Prompt
title={"لغو سفارش"}
description={"آیا از درخواست خود اطمینان دارید؟"}
textConfirm={"بله"}
textCancel={"منصرف شدم"}
onConfirm={onCancelOrder}
visible={cancelModal}
onClick={toggleCancelModal}
onCancel={toggleCancelModal}
/>
</div>
</div>
);
} }
export default OrderTrackingPage; export default OrderTrackingPage;
+317 -239
View File
@@ -1,270 +1,348 @@
'use client' "use client";
import { CartQuantityControl } from '@/components/CartQuantityControl' import { useCart } from "@/app/[name]/(Dialogs)/cart/hook/useCart";
import { ef } from '@/lib/helpers/utfNumbers' import { getPrimaryVariantId, getPurchaseUnitLabel, getVariableQuantityRange, isVariablePricing } from "@/app/[name]/(Main)/types/Types";
import { useCart } from '@/app/[name]/(Dialogs)/cart/hook/useCart' import { CartQuantityControl } from "@/components/CartQuantityControl";
import { getPrimaryVariantId } from '@/app/[name]/(Main)/types/Types' import PlusIcon from "@/components/icons/PlusIcon";
import { ArrowLeft, Cup, Heart, TruckTick } from 'iconsax-react' import { VariableWeightSlider } from "@/components/product/VariableWeightSlider";
import Image from 'next/image' import { toast } from "@/components/Toast";
import { useParams, useRouter } from 'next/navigation' import { getToken } from "@/lib/api/func";
import React, { useEffect, useMemo, useState } from 'react' import { ef } from "@/lib/helpers/utfNumbers";
import { useGetProduct, useToggleFavorite } from './hooks/useProductData' import { ArrowLeft, Cup, Heart, TruckTick } from "iconsax-react";
import { useGetAbout } from '../about/hooks/useAboutData' import Image from "next/image";
import { useGetProfile } from '../../(Profile)/profile/hooks/userProfileData' import { useParams, useRouter } from "next/navigation";
import { toast } from '@/components/Toast' import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getToken } from '@/lib/api/func' import { useGetProfile } from "../../(Profile)/profile/hooks/userProfileData";
import { useGetAbout } from "../about/hooks/useAboutData";
import { useGetProduct, useToggleFavorite } from "./hooks/useProductData";
type Props = object type Props = object;
function ProductPage({ }: Props) { function ProductPage({}: Props) {
const { id, name } = useParams();
const router = useRouter();
const { isSuccess } = useGetProfile();
const { data: product, isLoading } = useGetProduct(id as string);
const { data: about } = useGetAbout();
const { items, addToCart, removeFromCart, setCartQuantity, isCartMutating } = useCart();
const [isFavorite, setIsFavorite] = useState<boolean>(false);
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 { id, name } = useParams(); const productId = product?.data?.id || (id as string);
const router = useRouter(); const cartId = useMemo(() => {
const { isSuccess } = useGetProfile(); if (!product?.data) return null;
const { data: product, isLoading } = useGetProduct(id as string); return selectedVariantId ?? getPrimaryVariantId(product.data) ?? productId;
const { data: about } = useGetAbout(); }, [product?.data, selectedVariantId, productId]);
const { items, addToCart, removeFromCart, isCartMutating } = useCart(); const quantity = useMemo(() => {
const [isFavorite, setIsFavorite] = useState<boolean>(false); if (!cartId) return 0;
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null); const item = items?.[cartId] ?? items?.[String(cartId)];
const { mutate: toggleFavorite } = useToggleFavorite(); if (item && typeof item === "object" && "quantity" in item) {
return item.quantity || 0;
}
return 0;
}, [items, cartId]);
const productId = product?.data?.id || id as string; const handleAddToCart = () => {
const cartId = useMemo(() => { if (cartId) {
if (!product?.data) return null; addToCart(cartId);
return selectedVariantId ?? getPrimaryVariantId(product.data) ?? productId; }
}, [product?.data, selectedVariantId, productId]); };
const quantity = useMemo(() => {
if (!cartId) return 0;
const item = items?.[cartId] ?? items?.[String(cartId)];
if (item && typeof item === 'object' && 'quantity' in item) {
return item.quantity || 0;
}
return 0;
}, [items, cartId]);
const handleAddToCart = () => { const handleAddVariableToCart = () => {
if (cartId) { if (!cartId) return;
addToCart(cartId); if (cartSyncTimerRef.current) {
} clearTimeout(cartSyncTimerRef.current);
}; cartSyncTimerRef.current = null;
}
pendingCartWeightRef.current = null;
isUserInteractingRef.current = false;
setCartQuantity(cartId, selectedWeight);
};
const handleRemoveFromCart = () => { const clearCartSyncTimer = useCallback(() => {
if (cartId) { if (cartSyncTimerRef.current) {
removeFromCart(cartId); clearTimeout(cartSyncTimerRef.current);
} cartSyncTimerRef.current = null;
}; }
}, []);
const handleToggleFavorite = async () => { const handleVariableWeightChange = useCallback(
if (!productId) return; (weight: number) => {
isUserInteractingRef.current = true;
setSelectedWeight(weight);
clearCartSyncTimer();
pendingCartWeightRef.current = null;
},
[clearCartSyncTimer],
);
const token = await getToken(); const handleVariableWeightCommit = useCallback(
if (!token || !isSuccess) { (weight: number) => {
toast('ابتدا لاگین کنید', 'error'); if (!isSuccess || !cartId || quantity <= 0) {
return; 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;
} }
// به‌روزرسانی خوش‌بینانه: تغییر state قبل از ارسال درخواست const currentItem = items?.[cartId] ?? items?.[String(cartId)];
const previousFavorite = isFavorite; const currentQty = currentItem && typeof currentItem === "object" && "quantity" in currentItem ? currentItem.quantity : 0;
setIsFavorite(!isFavorite);
toggleFavorite(productId, { if (Math.abs(pendingWeight - currentQty) > 0.0001) {
onSuccess: () => { setCartQuantity(cartId, pendingWeight);
// تایید تغییر در صورت موفقیت
},
onError: () => {
// بازگرداندن به حالت قبلی در صورت خطا
setIsFavorite(previousFavorite);
toast('خطا در تغییر وضعیت علاقه‌مندی', 'error');
},
});
};
useEffect(() => {
const typed = product as unknown as { data?: { isFavorite?: boolean } };
const isFav = Boolean(typed?.data?.isFavorite);
setIsFavorite(isFav);
}, [product]);
useEffect(() => {
if (!product?.data) return;
const variants = product.data.variants ?? [];
if (!variants.length) {
setSelectedVariantId(product.data.id);
return;
} }
setSelectedVariantId((prev) => prev ?? variants[0].id); isUserInteractingRef.current = false;
}, [product?.data]); }, 1000);
},
[isSuccess, cartId, quantity, clearCartSyncTimer, items, setCartQuantity],
);
if (isLoading) { const handleRemoveFromCart = () => {
return ( if (cartId) {
<div className='flex items-center justify-center min-h-[400px]'> removeFromCart(cartId);
<p className='text-disabled-text'>در حال بارگذاری...</p> }
</div> };
);
const handleToggleFavorite = async () => {
if (!productId) return;
const token = await getToken();
if (!token || !isSuccess) {
toast("ابتدا لاگین کنید", "error");
return;
} }
if (!product?.data) { // به‌روزرسانی خوش‌بینانه: تغییر state قبل از ارسال درخواست
return ( const previousFavorite = isFavorite;
<div className='flex items-center justify-center min-h-[400px]'> setIsFavorite(!isFavorite);
<p className='text-disabled-text'>کالا یافت نشد</p>
</div> toggleFavorite(productId, {
); onSuccess: () => {
// تایید تغییر در صورت موفقیت
},
onError: () => {
// بازگرداندن به حالت قبلی در صورت خطا
setIsFavorite(previousFavorite);
toast("خطا در تغییر وضعیت علاقه‌مندی", "error");
},
});
};
useEffect(() => {
const typed = product as unknown as { data?: { isFavorite?: boolean } };
const isFav = Boolean(typed?.data?.isFavorite);
setIsFavorite(isFav);
}, [product]);
useEffect(() => {
if (!product?.data) return;
const variants = product.data.variants ?? [];
if (!variants.length) {
setSelectedVariantId(product.data.id);
return;
} }
setSelectedVariantId((prev) => prev ?? variants[0].id);
}, [product?.data]);
const productData = product.data; useEffect(() => {
const productName = productData.title || productData.name || ''; if (!product?.data || !isVariablePricing(product.data) || !cartId) return;
const productImage = typeof productData.image === 'string' if (isUserInteractingRef.current) return;
? productData.image
: Array.isArray(productData.images) && productData.images.length > 0
? typeof productData.images[0] === 'string'
? productData.images[0]
: typeof productData.images[0] === 'object' && productData.images[0] !== null && 'url' in productData.images[0]
? (productData.images[0] as { url: string }).url
: '/assets/images/no-image.png'
: '/assets/images/no-image.png';
const categoryName = productData.category?.title;
const variants = productData.variants ?? [];
const hasVariants = Boolean(productData.attribute?.trim());
const selectedVariant = selectedVariantId
? variants.find((v) => v.id === selectedVariantId) ?? variants[0]
: variants[0];
const price = hasVariants
? (selectedVariant?.price ?? 0)
: (productData.price ?? selectedVariant?.price ?? 0);
// const prepareTime = productData.prepareTime || 0;
const content = Array.isArray(productData.content)
? productData.content.join('، ')
: productData.content || productData.desc || '';
const handleBackToMenu = () => { const { min } = getVariableQuantityRange(product.data);
const urlParams = new URLSearchParams(window.location.search); const targetWeight = quantity > 0 ? quantity : min;
const categoryParam = urlParams.get('category') || productData.category?.id; setSelectedWeight((prev) => (Math.abs(prev - targetWeight) < 0.0001 ? prev : targetWeight));
if (categoryParam) { }, [product?.data, cartId, quantity]);
router.push(`/${name}?category=${categoryParam}`);
} else {
router.push(`/${name}`);
}
};
useEffect(
() => () => {
clearCartSyncTimer();
},
[clearCartSyncTimer],
);
if (isLoading) {
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="flex items-center justify-center min-h-100">
<div className="relative w-full h-full not-lg:bg-container rounded-2xl overflow-hidden lg:rounded-r-none lg:order-1"> <p className="text-disabled-text">در حال بارگذاری...</p>
<Image </div>
className='w-full object-cover bg-[#F2F2F9] h-full' );
src={productImage} }
alt={productName}
height={100}
width={100}
unoptimized
priority
/>
<div className="absolute top-4 left-0 pe-5.5 ps-7 w-full inline-flex justify-between items-center">
<div className="bg-container whitespace-nowrap rounded-[34px] py-1.5 px-4 w-min text-xs text-disabled2 dark:text-disabled-text font-bold">
{categoryName || '-'}
</div>
<button onClick={handleBackToMenu} className='p-2 rounded-full bg-container/40'>
<ArrowLeft size={18} className='stroke-primary dark:stroke-foreground' />
</button>
</div>
</div>
<div className="relative px-6 pt-6 pb-7.5 lg:flex lg:flex-col lg:justify-center bg-container w-full rounded-3xl overflow-hidden not-lg:-translate-y-6 lg:rounded-l-none"> if (!product?.data) {
<div className="w-full inline-flex justify-between items-center"> return (
<h5 className="text-base font-bold"> <div className="flex items-center justify-center min-h-100">
{productName} <p className="text-disabled-text">کالا یافت نشد</p>
</h5> </div>
<button );
onClick={handleToggleFavorite} }
className='p-2 bg-[#EAECF0] dark:bg-neutral-600 rounded-lg'
>
<Heart
variant={isFavorite ? 'Bold' : 'Outline'}
size={24}
color='currentColor'
className={isFavorite ? 'fill-primary dark:fill-foreground' : ''}
/>
</button>
</div>
<div className="mt-4 text-xs"> const productData = product.data;
{/* <div className='flex items-center gap-1'> const productName = productData.title || productData.name || "";
const productImage =
typeof productData.image === "string"
? productData.image
: Array.isArray(productData.images) && productData.images.length > 0
? typeof productData.images[0] === "string"
? productData.images[0]
: typeof productData.images[0] === "object" && productData.images[0] !== null && "url" in productData.images[0]
? (productData.images[0] as { url: string }).url
: "/assets/images/no-image.png"
: "/assets/images/no-image.png";
const categoryName = productData.category?.title;
const variants = productData.variants ?? [];
const hasVariants = Boolean(productData.attribute?.trim());
const selectedVariant = selectedVariantId ? (variants.find((v) => v.id === selectedVariantId) ?? variants[0]) : variants[0];
const price = hasVariants ? (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 content = Array.isArray(productData.content) ? productData.content.join("، ") : productData.content || productData.desc || "";
const handleBackToMenu = () => {
const urlParams = new URLSearchParams(window.location.search);
const categoryParam = urlParams.get("category") || productData.category?.id;
if (categoryParam) {
router.push(`/${name}?category=${categoryParam}`);
} else {
router.push(`/${name}`);
}
};
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-71.25 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">
<Image className="w-full object-cover bg-[#F2F2F9] h-full" src={productImage} alt={productName} height={100} width={100} unoptimized priority />
<div className="absolute top-4 left-0 pe-5.5 ps-7 w-full inline-flex justify-between items-center">
<div className="bg-container whitespace-nowrap rounded-[34px] py-1.5 px-4 w-min text-xs text-disabled2 dark:text-disabled-text font-bold">{categoryName || "-"}</div>
<button onClick={handleBackToMenu} className="p-2 rounded-full bg-container/40">
<ArrowLeft size={18} className="stroke-primary dark:stroke-foreground" />
</button>
</div>
</div>
<div className="relative px-6 pt-6 pb-7.5 lg:flex lg:flex-col lg:justify-center bg-container w-full rounded-3xl overflow-hidden not-lg:-translate-y-6 lg:rounded-l-none">
<div className="w-full inline-flex justify-between items-center">
<h5 className="text-base font-bold">{productName}</h5>
<button onClick={handleToggleFavorite} className="p-2 bg-[#EAECF0] dark:bg-neutral-600 rounded-lg">
<Heart variant={isFavorite ? "Bold" : "Outline"} size={24} color="currentColor" className={isFavorite ? "fill-primary dark:fill-foreground" : ""} />
</button>
</div>
<div className="mt-4 text-xs">
{/* <div className='flex items-center gap-1'>
<Clock size={14} className='stroke-disabled-text' /> <Clock size={14} className='stroke-disabled-text' />
<span className='text-disabled-text'> <span className='text-disabled-text'>
زمان پخت و آماده سازی: {prepareTime > 0 ? `${ef(String(prepareTime))} دقیقه` : '-'} زمان پخت و آماده سازی: {prepareTime > 0 ? `${ef(String(prepareTime))} دقیقه` : '-'}
</span> </span>
</div> */} </div> */}
<div className='flex items-center gap-2 mt-2'> <div className="flex items-center gap-2 mt-2">
<Cup size={14} className='stroke-disabled-text' /> <Cup size={14} className="stroke-disabled-text" />
<span className='text-disabled-text flex gap-1'> <span className="text-disabled-text flex gap-1">
{about?.data?.plan === 'base' ? ( {about?.data?.plan === "base" ? (
<> <>
<div>۰</div> <div>۰</div>
<span>امتیاز برای هر بار خرید</span> <span>امتیاز برای هر بار خرید</span>
</> </>
) : about?.data?.score?.purchaseScore && about?.data?.score?.purchaseAmount ? ( ) : about?.data?.score?.purchaseScore && about?.data?.score?.purchaseAmount ? (
<> <>
<div> <div>{ef(Math.round(price * (Number(about.data.score.purchaseScore) / Number(about.data.score.purchaseAmount))).toLocaleString("en-US"))}</div>
{ef( <span>امتیاز برای هر بار خرید</span>
Math.round( </>
price * ) : (
(Number(about.data.score.purchaseScore) / Number(about.data.score.purchaseAmount)) <span>-</span>
).toLocaleString('en-US') )}
)} </span>
</div> </div>
<span>امتیاز برای هر بار خرید</span> <div className="flex items-center gap-2 mt-2">
</> <TruckTick size={14} className="stroke-disabled-text" />
) : ( <span className="text-disabled-text">{productData.pickupServe ? "ارسال با پیک" : "-"}</span>
<span>-</span> </div>
)}
</span>
</div>
<div className='flex items-center gap-2 mt-2'>
<TruckTick size={14} className='stroke-disabled-text' />
<span className='text-disabled-text'>
{productData.pickupServe ? 'ارسال با پیک' : '-'}
</span>
</div>
</div>
<div className="mt-7 text-xs">
<p className='font-bold'>محتویات:</p>
<p className='mt-2 leading-6'>{content || '-'}</p>
</div>
{hasVariants && variants.length > 0 && (
<div className='mt-6 text-xs'>
<p className='font-bold'>{productData.attribute}</p>
<div className='flex flex-wrap gap-2 mt-2'>
{variants.map((variant) => (
<button
key={variant.id}
type="button"
onClick={() => setSelectedVariantId(variant.id)}
className={`px-3 py-1 rounded-full border text-xs ${variant.id === selectedVariant?.id
? 'bg-primary text-white border-primary'
: 'bg-background text-foreground border-border'
}`}
>
{variant.value}
</button>
))}
</div>
</div>
)}
<div className='mt-12 flex justify-between items-center'>
<span dir='ltr'>{ef(price.toLocaleString('en-US'))} T</span>
<CartQuantityControl
quantity={quantity}
onAdd={handleAddToCart}
onRemove={handleRemoveFromCart}
isMutating={isCartMutating}
addDisabled={!cartId}
/>
</div>
</div>
</div> </div>
)
<div className="mt-7 text-xs">
<p className="font-bold">محتویات:</p>
<p className="mt-2 leading-6">{content || "-"}</p>
</div>
{hasVariants && variants.length > 0 && (
<div className="mt-6 text-xs">
<p className="font-bold">{productData.attribute}</p>
<div className="flex flex-wrap gap-2 mt-2">
{variants.map((variant) => (
<button
key={variant.id}
type="button"
onClick={() => setSelectedVariantId(variant.id)}
className={`px-3 py-1 rounded-full border text-xs ${variant.id === selectedVariant?.id ? "bg-primary text-white border-primary" : "bg-background text-foreground border-border"}`}
>
{variant.value}
</button>
))}
</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">
<span dir="ltr">{ef(price.toLocaleString("en-US"))} T</span>
<CartQuantityControl quantity={quantity} onAdd={handleAddToCart} onRemove={handleRemoveFromCart} isMutating={isCartMutating} addDisabled={!cartId} />
</div>
)}
</div>
</div>
);
} }
export default ProductPage export default ProductPage;
+8 -7
View File
@@ -13,6 +13,7 @@ import Image from 'next/image';
import React from 'react' import React from 'react'
import { useGetAbout, useGetReviews, useGetSchedules } from './hooks/useAboutData'; import { useGetAbout, useGetReviews, useGetSchedules } from './hooks/useAboutData';
import AboutSkeleton from './components/AboutSkeleton'; import AboutSkeleton from './components/AboutSkeleton';
import { glassSurfaceCard, glassSurfaceCardFlat, glassSurfaceFlat } from '@/lib/styles/glassSurface';
const sortings = [ const sortings = [
'جدیدترین', 'جدیدترین',
@@ -81,7 +82,7 @@ function AboutPage() {
return ( return (
<section aria-labelledby="about-title" className='py-4'> <section aria-labelledby="about-title" className='py-4'>
<section <section
className="bg-container rounded-container shadow-container p-4"> className={glassSurfaceCard('p-4')}>
<div className="flex justify-between items-center border-b-[1.5px] border-gray-200 pb-[25px]"> <div className="flex justify-between items-center border-b-[1.5px] border-gray-200 pb-[25px]">
<div className=""> <div className="">
<h2 className='text-sm2 font-bold leading-5'>{shop.name}</h2> <h2 className='text-sm2 font-bold leading-5'>{shop.name}</h2>
@@ -123,7 +124,7 @@ function AboutPage() {
{(shop.phone || shop.telegram || shop.whatsapp || shop.instagram) && ( {(shop.phone || shop.telegram || shop.whatsapp || shop.instagram) && (
<section <section
className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4 grid grid-cols-3 items-center"> className={glassSurfaceCard('pt-3 pb-3.5 px-[16px] mt-4 grid grid-cols-3 items-center')}>
<h2 className='text-sm2 font-medium leading-5'>ارتباط</h2> <h2 className='text-sm2 font-medium leading-5'>ارتباط</h2>
<div className='col-span-1 text-center flex justify-center gap-4'> <div className='col-span-1 text-center flex justify-center gap-4'>
{shop.phone && {shop.phone &&
@@ -151,7 +152,7 @@ function AboutPage() {
)} )}
{shop.address && ( {shop.address && (
<section className="bg-container rounded-container shadow-container px-4 pt-6 pb-6 mt-4"> <section className={glassSurfaceCard('px-4 pt-6 pb-6 mt-4')}>
<h2 className='text-sm2 font-medium leading-5'>آدرس</h2> <h2 className='text-sm2 font-medium leading-5'>آدرس</h2>
<p className='text-sm2 mt-[9px] leading-5'>{shop.address}</p> <p className='text-sm2 mt-[9px] leading-5'>{shop.address}</p>
{shop.latitude && shop.longitude && ( {shop.latitude && shop.longitude && (
@@ -166,7 +167,7 @@ function AboutPage() {
{schedules.length > 0 && getTodaySchedule() && ( {schedules.length > 0 && getTodaySchedule() && (
<section <section
aria-label='ساعات کاری' aria-label='ساعات کاری'
className="bg-container rounded-container shadow-container py-6 px-4 mt-4 flex justify-between items-center"> className={glassSurfaceCard('py-6 px-4 mt-4 flex justify-between items-center')}>
<div className="flex items-center gap-2 justify-start"> <div className="flex items-center gap-2 justify-start">
<Clock size={16} className='stroke-disabled-text' /> <Clock size={16} className='stroke-disabled-text' />
<div className='text-sm2 font-medium leading-5 mt-0.5 text-[#8C90A3]'>باز</div> <div className='text-sm2 font-medium leading-5 mt-0.5 text-[#8C90A3]'>باز</div>
@@ -254,7 +255,7 @@ function AboutPage() {
<section aria-labelledby="reviews-title" className='py-4'> <section aria-labelledby="reviews-title" className='py-4'>
<section <section
aria-label='امتیاز کاربران' aria-label='امتیاز کاربران'
className="bg-container rounded-container shadow-container p-4 py-6 grid grid-cols-2 items-center"> className={glassSurfaceCard('p-4 py-6 grid grid-cols-2 items-center')}>
<div className="text-center font-bold text-5xl"> <div className="text-center font-bold text-5xl">
{averageRating.toFixed(1)} {averageRating.toFixed(1)}
</div> </div>
@@ -267,10 +268,10 @@ function AboutPage() {
</div> </div>
</section> </section>
<section className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4"> <section className={glassSurfaceCard('pt-3 pb-3.5 px-[16px] mt-4')}>
<div className="flex justify-between items-center border-b-[1.5px] border-border pb-2"> <div className="flex justify-between items-center border-b-[1.5px] border-border pb-2">
<h2 className='text-sm2 font-medium leading-5'>نظرات کاربران</h2> <h2 className='text-sm2 font-medium leading-5'>نظرات کاربران</h2>
<button onClick={toggleSortingModal} className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-[7px]"> <button onClick={toggleSortingModal} className={glassSurfaceFlat('rounded-xl h-8 pattern-secondary-bg ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-[7px]')}>
<EqualizerIcon className='dark:text-white' /> <EqualizerIcon className='dark:text-white' />
<span className="text-xs leading-5 font-medium">{sortings[+sorting]}</span> <span className="text-xs leading-5 font-medium">{sortings[+sorting]}</span>
</button> </button>
@@ -3,13 +3,13 @@
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import TabContainer from "@/components/tab/TabContainer"; import TabContainer from "@/components/tab/TabContainer";
import { TabHeader } from "@/components/tab/TabHeader"; import { TabHeader } from "@/components/tab/TabHeader";
import { glassSurfaceCard, glassSurfaceCardFlat } from '@/lib/styles/glassSurface';
import { InfoCircle, Star1 } from 'iconsax-react'; import { InfoCircle, Star1 } from 'iconsax-react';
const AboutSkeleton = () => { const AboutSkeleton = () => {
const firstTabSkeleton = () => ( const firstTabSkeleton = () => (
<section className='py-4'> <section className='py-4'>
{/* اطلاعات فروشگاه */} <section className={glassSurfaceCard('p-4')}>
<section className="bg-container rounded-container shadow-container p-4">
<div className="flex justify-between items-center border-b-[1.5px] border-gray-200 pb-[25px]"> <div className="flex justify-between items-center border-b-[1.5px] border-gray-200 pb-[25px]">
<div className="flex-1"> <div className="flex-1">
<Skeleton className="h-6 w-32 rounded-md mb-4" /> <Skeleton className="h-6 w-32 rounded-md mb-4" />
@@ -30,8 +30,7 @@ const AboutSkeleton = () => {
</div> </div>
</section> </section>
{/* ارتباط */} <section className={glassSurfaceCard('pt-3 pb-3.5 px-[16px] mt-4 grid grid-cols-3 items-center')}>
<section className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4 grid grid-cols-3 items-center">
<Skeleton className="h-5 w-16 rounded-md" /> <Skeleton className="h-5 w-16 rounded-md" />
<div className='col-span-1 text-center flex justify-center gap-4'> <div className='col-span-1 text-center flex justify-center gap-4'>
{[...Array(3)].map((_, index) => ( {[...Array(3)].map((_, index) => (
@@ -40,8 +39,7 @@ const AboutSkeleton = () => {
</div> </div>
</section> </section>
{/* آدرس */} <section className={glassSurfaceCard('px-4 pt-6 pb-6 mt-4')}>
<section className="bg-container rounded-container shadow-container px-4 pt-6 pb-6 mt-4">
<Skeleton className="h-5 w-12 rounded-md mb-[9px]" /> <Skeleton className="h-5 w-12 rounded-md mb-[9px]" />
<Skeleton className="h-4 w-full rounded-md mb-2" /> <Skeleton className="h-4 w-full rounded-md mb-2" />
<Skeleton className="h-4 w-3/4 rounded-md mb-[23px]" /> <Skeleton className="h-4 w-3/4 rounded-md mb-[23px]" />
@@ -51,8 +49,7 @@ const AboutSkeleton = () => {
</div> </div>
</section> </section>
{/* ساعات کاری امروز */} <section className={glassSurfaceCard('py-6 px-4 mt-4 flex justify-between items-center')}>
<section className="bg-container rounded-container shadow-container py-6 px-4 mt-4 flex justify-between items-center">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="w-4 h-4 rounded-full" /> <Skeleton className="w-4 h-4 rounded-full" />
<Skeleton className="h-5 w-8 rounded-md" /> <Skeleton className="h-5 w-8 rounded-md" />
@@ -62,11 +59,10 @@ const AboutSkeleton = () => {
<Skeleton className="w-4 h-4 rounded-md" /> <Skeleton className="w-4 h-4 rounded-md" />
</section> </section>
{/* ساعات کاری هفته */}
{[...Array(3)].map((_, index) => ( {[...Array(3)].map((_, index) => (
<div <div
key={index} key={index}
className="bg-[#F6F6FA] dark:bg-border rounded-container leading-5 py-6 px-4 mt-4 flex justify-between items-center"> className={glassSurfaceCardFlat('leading-5 py-6 px-4 mt-4 flex justify-between items-center')}>
<Skeleton className="h-5 w-20 rounded-md" /> <Skeleton className="h-5 w-20 rounded-md" />
<Skeleton className="h-5 w-24 rounded-md" /> <Skeleton className="h-5 w-24 rounded-md" />
</div> </div>
@@ -76,8 +72,7 @@ const AboutSkeleton = () => {
const secondTabSkeleton = () => ( const secondTabSkeleton = () => (
<section className='py-4'> <section className='py-4'>
{/* امتیاز کاربران */} <section className={glassSurfaceCard('p-4 py-6 grid grid-cols-2 items-center')}>
<section className="bg-container rounded-container shadow-container p-4 py-6 grid grid-cols-2 items-center">
<Skeleton className="h-16 w-20 rounded-md mx-auto" /> <Skeleton className="h-16 w-20 rounded-md mx-auto" />
<div className="flex flex-col w-fit items-end gap-1"> <div className="flex flex-col w-fit items-end gap-1">
{[...Array(5)].map((_, index) => ( {[...Array(5)].map((_, index) => (
@@ -86,8 +81,7 @@ const AboutSkeleton = () => {
</div> </div>
</section> </section>
{/* نظرات کاربران */} <section className={glassSurfaceCard('pt-3 pb-3.5 px-[16px] mt-4')}>
<section className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4">
<div className="flex justify-between items-center border-b-[1.5px] border-border pb-2"> <div className="flex justify-between items-center border-b-[1.5px] border-border pb-2">
<Skeleton className="h-5 w-24 rounded-md" /> <Skeleton className="h-5 w-24 rounded-md" />
<Skeleton className="h-8 w-28 rounded-xl" /> <Skeleton className="h-8 w-28 rounded-xl" />
@@ -130,4 +124,3 @@ const AboutSkeleton = () => {
}; };
export default AboutSkeleton; export default AboutSkeleton;
@@ -1,21 +1,43 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as api from "../service/AboutService";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useEffect } from "react";
import * as api from "../service/AboutService";
import {
getPersistedAboutData,
setPersistedAboutData,
} from "@/lib/helpers/themeCache";
export const useGetAbout = () => { export const useGetAbout = () => {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
return useQuery({ const query = useQuery({
queryKey: ["about"], queryKey: ["about", name],
queryFn: () => api.getAbout(name), queryFn: async () => {
const data = await api.getAbout(name);
setPersistedAboutData(name, data);
return data;
},
enabled: !!name, enabled: !!name,
retry: false, retry: false,
staleTime: 60 * 60_000,
gcTime: 24 * 60 * 60_000,
refetchOnMount: "always",
refetchOnWindowFocus: false,
placeholderData: () => (name ? getPersistedAboutData(name) : undefined),
}); });
useEffect(() => {
if (name && query.data) {
setPersistedAboutData(name, query.data);
}
}, [name, query.data]);
return query;
}; };
export const useGetReviews = () => { export const useGetReviews = () => {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
return useQuery({ return useQuery({
queryKey: ["reviews"], queryKey: ["reviews", name],
queryFn: () => api.getReviews(name), queryFn: () => api.getReviews(name),
enabled: !!name, enabled: !!name,
retry: false, retry: false,
@@ -25,7 +47,7 @@ export const useGetReviews = () => {
export const useGetSchedules = () => { export const useGetSchedules = () => {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
return useQuery({ return useQuery({
queryKey: ["schedules"], queryKey: ["schedules", name],
queryFn: () => api.getSchedules(name), queryFn: () => api.getSchedules(name),
enabled: !!name, enabled: !!name,
retry: false, retry: false,
@@ -26,6 +26,11 @@ export interface Shop {
logo: string | null; logo: string | null;
address: string | null; address: string | null;
menuColor: string | null; menuColor: string | null;
bgType?: "pattern" | "custom" | "color";
bgBlur?: number;
bgOpacity?: number;
bgOverlay?: string;
bgUrl?: string;
latitude: number | null; latitude: number | null;
longitude: number | null; longitude: number | null;
serviceArea: ServiceArea; serviceArea: ServiceArea;
@@ -1,11 +1,61 @@
'use client'; "use client";
import Image from "next/image"; import { Category } from "@/app/[name]/(Main)/types/Types";
import clsx from "clsx";
import HorizontalScrollView from "@/components/listview/HorizontalScrollView";
import CategoryItemRenderer from "@/components/listview/CategoryItemRenderer"; import CategoryItemRenderer from "@/components/listview/CategoryItemRenderer";
import CategorySmallItemRenderer from "@/components/listview/CategorySmallItemRenderer"; import CategorySmallItemRenderer from "@/components/listview/CategorySmallItemRenderer";
import { Category } from "@/app/[name]/(Main)/types/Types"; import HorizontalScrollView from "@/components/listview/HorizontalScrollView";
import { GLASS_SURFACE_DISABLED, GLASS_SURFACE_SELECTED } from "@/lib/styles/glassSurface";
import clsx from "clsx";
import { usePathname } from "next/navigation";
/** TODO: remove when backend sends avatarRenderMode */
const COLORED_SVG_RESTAURANT_SLUGS = new Set(["havasone"]);
const SVG_MASK_STYLE = {
maskSize: "contain",
maskRepeat: "no-repeat",
maskPosition: "center",
WebkitMaskSize: "contain",
WebkitMaskRepeat: "no-repeat",
WebkitMaskPosition: "center",
} as const;
function CategoryImage({ src, size, alt, tintWithPrimary = true, disabled = false }: { src: string; size: number; alt: string; tintWithPrimary?: boolean; disabled?: boolean }) {
const isSvg = src.endsWith(".svg");
const shouldUseSvgMask = isSvg && tintWithPrimary;
if (shouldUseSvgMask) {
return (
<div
className={clsx("shrink-0 bg-primary", disabled && "opacity-40")}
style={{
width: size,
height: size,
maskImage: `url(${src})`,
WebkitMaskImage: `url(${src})`,
...SVG_MASK_STYLE,
}}
role="img"
aria-label={alt}
/>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
width={size}
height={size}
alt={alt}
loading="lazy"
className={clsx("shrink-0 object-contain", disabled && "opacity-40")}
onError={(event) => {
event.currentTarget.src = "/assets/images/food-image.png";
}}
/>
);
}
type Variant = "large" | "small"; type Variant = "large" | "small";
@@ -34,17 +84,11 @@ type Props = {
className?: string; className?: string;
}; };
const CategoryScroll = ({ const CategoryScroll = ({ categories, selectedCategory, onSelect, variant = "large", className }: Props) => {
categories, const segment = usePathname()?.split("/").filter(Boolean)[0];
selectedCategory, const usesColoredSvg = segment != null && COLORED_SVG_RESTAURANT_SLUGS.has(segment.toLowerCase());
onSelect,
variant = "large",
className,
}: Props) => {
const { renderer: Renderer, imageSize } = variantConfig[variant]; const { renderer: Renderer, imageSize } = variantConfig[variant];
const selectedParent = const selectedParent = categories.find((c) => c.id === selectedCategory) ?? categories.find((c) => c.children?.some((ch) => ch.id === selectedCategory));
categories.find((c) => c.id === selectedCategory) ??
categories.find((c) => c.children?.some((ch) => ch.id === selectedCategory));
const children = selectedParent?.children ?? []; const children = selectedParent?.children ?? [];
const handleSelect = (categoryId: string) => () => onSelect(categoryId); const handleSelect = (categoryId: string) => () => onSelect(categoryId);
@@ -53,51 +97,32 @@ const CategoryScroll = ({
<div className="flex flex-col"> <div className="flex flex-col">
<HorizontalScrollView <HorizontalScrollView
className={clsx( className={clsx(
"w-full noscrollbar pt-4! pb-1!", "w-full noscrollbar",
variant === "large" && "mt-4!", children.length > 0 ? "pt-4! pb-1!" : "py-4!",
className // variant === "large" && "mt-4!",
className,
)} )}
> >
{categories.map((item) => { {categories.map((item) => {
const isSelected = const isSelected = item.id === selectedCategory || item.children?.some((ch) => ch.id === selectedCategory);
item.id === selectedCategory ||
item.children?.some((ch) => ch.id === selectedCategory);
return ( return (
<Renderer <Renderer key={item.id} className={clsx(!isSelected && GLASS_SURFACE_DISABLED, isSelected && GLASS_SURFACE_SELECTED)} onClick={handleSelect(item.id)}>
key={item.id} <CategoryImage src={item.avatarUrl || "/assets/images/food-image.png"} size={imageSize} alt="category image" tintWithPrimary={!usesColoredSvg} disabled={!item.isActive} />
className={clsx(isSelected && "bg-container!")} <span className={clsx("text-xs text-foreground text-center", !item.isActive && "text-disabled-text")}>{item.title}</span>
onClick={handleSelect(item.id)}
>
<Image
priority
src={item.avatarUrl || "/assets/images/food-image.png"}
width={imageSize}
height={imageSize}
alt="category image"
/>
<span className="text-xs text-foreground text-center">{item.title}</span>
</Renderer> </Renderer>
); );
})} })}
</HorizontalScrollView> </HorizontalScrollView>
{children.length > 0 && ( {children.length > 0 && (
<HorizontalScrollView <HorizontalScrollView className={clsx("w-full noscrollbar py-2!", variant === "small" && "py-1!")}>
className={clsx(
"w-full noscrollbar py-2!",
variant === "small" && "py-1!"
)}
>
{children.map((child) => { {children.map((child) => {
const isChildSelected = child.id === selectedCategory; const isChildSelected = child.id === selectedCategory;
return ( return (
<CategorySmallItemRenderer <CategorySmallItemRenderer key={child.id} compact className={clsx(isChildSelected && GLASS_SURFACE_SELECTED, !isChildSelected && GLASS_SURFACE_DISABLED)} onClick={handleSelect(child.id)}>
key={child.id} {child.avatarUrl && <CategoryImage src={child.avatarUrl} size={16} alt="category image" tintWithPrimary={!usesColoredSvg} />}
className={clsx(isChildSelected && "bg-container!")}
onClick={handleSelect(child.id)}
>
<span className="text-[10px] text-foreground whitespace-nowrap">{child.title}</span> <span className="text-[10px] text-foreground whitespace-nowrap">{child.title}</span>
</CategorySmallItemRenderer> </CategorySmallItemRenderer>
); );
@@ -109,4 +134,3 @@ const CategoryScroll = ({
}; };
export default CategoryScroll; export default CategoryScroll;
@@ -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"> </div>
<Skeleton className="h-4 w-16 rounded-md" /> <div className="flex flex-col justify-end shrink-0">
</div> <Skeleton className="h-8 w-20 rounded-full" />
<Skeleton className="max-w-[115px] w-full h-8 rounded-md" />
</div>
</div> </div>
</div> </div>
</MenuItemRenderer> </MenuItemRenderer>
+39 -6
View File
@@ -1,23 +1,56 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as api from "../service/MenuService"; import * as api from "../service/MenuService";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useEffect } from "react";
import {
getPersistedCategoriesData,
getPersistedMenuData,
setPersistedCategoriesData,
setPersistedMenuData,
} from "@/lib/helpers/themeCache";
export const useGetProducts = () => { export const useGetProducts = () => {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
return useQuery({ const query = useQuery({
queryKey: ["menu"], queryKey: ["menu", name],
queryFn: () => api.getProducts(name), queryFn: async () => {
const data = await api.getProducts(name);
setPersistedMenuData(name, data);
return data;
},
enabled: !!name, enabled: !!name,
placeholderData: () => (name ? getPersistedMenuData(name) : undefined),
}); });
useEffect(() => {
if (name && query.data) {
setPersistedMenuData(name, query.data);
}
}, [name, query.data]);
return query;
}; };
export const useGetCategories = () => { export const useGetCategories = () => {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
return useQuery({ const query = useQuery({
queryKey: ["categories"], queryKey: ["categories", name],
queryFn: () => api.getCategories(name), queryFn: async () => {
const data = await api.getCategories(name);
setPersistedCategoriesData(name, data);
return data;
},
enabled: !!name, enabled: !!name,
placeholderData: () => (name ? getPersistedCategoriesData(name) : undefined),
}); });
useEffect(() => {
if (name && query.data) {
setPersistedCategoriesData(name, query.data);
}
}, [name, query.data]);
return query;
}; };
export const useGetNotificationsCount = () => { export const useGetNotificationsCount = () => {
+70 -84
View File
@@ -1,21 +1,22 @@
'use client'; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import SearchBox from "@/components/input/SearchBox";
import TextAlignIcon from "@/components/icons/TextAlignIcon";
import VerticalScrollView from "@/components/listview/VerticalScrollView";
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
import MenuItem, { type MenuItemViewMode } from "@/components/listview/MenuItem";
import { useQueryState } from "next-usequerystate";
import clsx from "clsx";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import useToggle from "@/hooks/helpers/useToggle";
import CategoryScroll from "@/app/[name]/(Main)/components/CategoryScroll"; import CategoryScroll from "@/app/[name]/(Main)/components/CategoryScroll";
import MenuFilterDrawer from "@/app/[name]/(Main)/components/MenuFilterDrawer"; import MenuFilterDrawer from "@/app/[name]/(Main)/components/MenuFilterDrawer";
import MenuSkeleton from "@/app/[name]/(Main)/components/MenuSkeleton"; import MenuSkeleton from "@/app/[name]/(Main)/components/MenuSkeleton";
import { useGetCategories, useGetProducts } from "./hooks/useMenuData"; import TextAlignIcon from "@/components/icons/TextAlignIcon";
import type { Product, Category } from "./types/Types"; import SearchBox from "@/components/input/SearchBox";
import MenuItem, { type MenuItemViewMode } from "@/components/listview/MenuItem";
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
import VerticalScrollView from "@/components/listview/VerticalScrollView";
import useToggle from "@/hooks/helpers/useToggle";
import { glassSurfaceFlat } from "@/lib/styles/glassSurface";
import clsx from "clsx";
import { motion } from "framer-motion";
import { RowHorizontal, RowVertical } from "iconsax-react"; import { RowHorizontal, RowVertical } from "iconsax-react";
import { useQueryState } from "next-usequerystate";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useGetCategories, useGetProducts } from "./hooks/useMenuData";
import type { Category, Product } from "./types/Types";
const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const; const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const;
@@ -26,13 +27,13 @@ const MenuIndex = () => {
const categories = useMemo<Category[]>(() => categoriesData?.data || [], [categoriesData?.data]); const categories = useMemo<Category[]>(() => categoriesData?.data || [], [categoriesData?.data]);
const isLoading = productsLoading || categoriesLoading; const isLoading = productsLoading || categoriesLoading;
const { t: tCommon } = useTranslation('common'); const { t: tCommon } = useTranslation("common");
const { t: tMenu } = useTranslation('menu', { keyPrefix: "Menu" }); const { t: tMenu } = useTranslation("menu", { keyPrefix: "Menu" });
const { state: filterSortModal, toggle: toggleFilterSortModal } = useToggle(); const { state: filterSortModal, toggle: toggleFilterSortModal } = useToggle();
const [sorting, setSorting] = useQueryState('sortBy', { defaultValue: '0' }); const [sorting, setSorting] = useQueryState("sortBy", { defaultValue: "0" });
const [search, setSearch] = useQueryState("q", { defaultValue: '' }); const [search, setSearch] = useQueryState("q", { defaultValue: "" });
const [selectedCategory, setSelectedCategory] = useQueryState('category', { defaultValue: '0' }); const [selectedCategory, setSelectedCategory] = useQueryState("category", { defaultValue: "0" });
const [filterSearch, setFilterSearch] = useQueryState('ingredients', { defaultValue: '' }); const [filterSearch, setFilterSearch] = useQueryState("ingredients", { defaultValue: "" });
const smallCategoriesRef: React.RefObject<HTMLDivElement | null> = useRef(null); const smallCategoriesRef: React.RefObject<HTMLDivElement | null> = useRef(null);
const wrapperRef: React.RefObject<HTMLDivElement | null> = useRef(null); const wrapperRef: React.RefObject<HTMLDivElement | null> = useRef(null);
const [smallCategoriesVisible, setSmallCategoriesVisibility] = useState(false); const [smallCategoriesVisible, setSmallCategoriesVisibility] = useState(false);
@@ -76,38 +77,49 @@ const MenuIndex = () => {
const parent = wrapperRef.current.parentElement?.parentElement; const parent = wrapperRef.current.parentElement?.parentElement;
if (!parent) return; if (!parent) return;
parent.addEventListener('scroll', onScroll); parent.addEventListener("scroll", onScroll);
return () => { return () => {
parent.removeEventListener('scroll', onScroll); parent.removeEventListener("scroll", onScroll);
} };
}, [onScroll]); }, [onScroll]);
const updateSearch = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
},
[setSearch],
);
const updateSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const updateCategory = useCallback(
setSearch(e.target.value); (categoryId: string) => {
}, [setSearch]); setSelectedCategory(categoryId);
},
const updateCategory = useCallback((categoryId: string) => { [setSelectedCategory],
setSelectedCategory(categoryId); );
}, [setSelectedCategory]);
const sortingIndex = Number(sorting); const sortingIndex = Number(sorting);
const activeSortingIndex = Number.isNaN(sortingIndex) ? 0 : sortingIndex; const activeSortingIndex = Number.isNaN(sortingIndex) ? 0 : sortingIndex;
const changeSorting = useCallback((index: number) => { const changeSorting = useCallback(
setSorting(() => String(index)); (index: number) => {
}, [setSorting]); setSorting(() => String(index));
},
[setSorting],
);
const changeFilterSearch = useCallback((value: string) => { const changeFilterSearch = useCallback(
setFilterSearch(value); (value: string) => {
}, [setFilterSearch]); setFilterSearch(value);
},
[setFilterSearch],
);
const filteredReceiptItems = useMemo(() => { const filteredReceiptItems = useMemo(() => {
if (!products.length) return []; if (!products.length) return [];
const lowerSearch = search.toLowerCase(); const lowerSearch = search.toLowerCase();
const lowerFilterQuery = filterSearch ? filterSearch.toLowerCase() : ""; const lowerFilterQuery = filterSearch ? filterSearch.toLowerCase() : "";
const selectedCatId = selectedCategory === '0' ? null : selectedCategory; const selectedCatId = selectedCategory === "0" ? null : selectedCategory;
// اگر دستهٔ اصلی انتخاب شده، خودش + همهٔ زیردسته‌ها را در نظر بگیر تا غذاهای زیردسته هم بیایند // اگر دستهٔ اصلی انتخاب شده، خودش + همهٔ زیردسته‌ها را در نظر بگیر تا غذاهای زیردسته هم بیایند
const effectiveCategoryIds = (() => { const effectiveCategoryIds = (() => {
@@ -120,24 +132,20 @@ const MenuIndex = () => {
})(); })();
const filtered = products.filter((item) => { const filtered = products.filter((item) => {
const matchesCategory = const matchesCategory = !selectedCatId || (item.category?.id != null && effectiveCategoryIds?.has(item.category.id));
!selectedCatId ||
(item.category?.id != null && effectiveCategoryIds?.has(item.category.id));
const itemName = item.name ?? item.title; const itemName = item.name ?? item.title;
const matchesSearch = const matchesSearch = !search || itemName.toLowerCase().includes(lowerSearch);
!search || itemName.toLowerCase().includes(lowerSearch);
// جستجو در عنوان و توضیحات (و در صورت وجود در محتویات) // جستجو در عنوان و توضیحات (و در صورت وجود در محتویات)
const matchesFilterSearch = !filterSearch || const matchesFilterSearch =
!filterSearch ||
(() => { (() => {
const titleMatch = itemName.toLowerCase().includes(lowerFilterQuery); const titleMatch = itemName.toLowerCase().includes(lowerFilterQuery);
const description = item.description || item.desc || ""; const description = item.description || item.desc || "";
const descMatch = description.toLowerCase().includes(lowerFilterQuery); const descMatch = description.toLowerCase().includes(lowerFilterQuery);
if (titleMatch || descMatch) return true; if (titleMatch || descMatch) return true;
if (item.content && Array.isArray(item.content) && item.content.length > 0) { if (item.content && Array.isArray(item.content) && item.content.length > 0) {
return item.content.some((content: string) => return item.content.some((content: string) => content.toLowerCase().includes(lowerFilterQuery));
content.toLowerCase().includes(lowerFilterQuery)
);
} }
return false; return false;
})(); })();
@@ -168,30 +176,22 @@ const MenuIndex = () => {
} }
return ( return (
<div className="flex flex-col gap-4 items-center pt-8 mb-8" ref={wrapperRef}> <div className="flex flex-col gap-2 items-center pt-4 mb-8" ref={wrapperRef}>
<div className="w-full"> <div className="w-full">
<SearchBox value={search} placeholder={tCommon('SearchPlaceholder')} onChange={updateSearch} /> <SearchBox value={search} placeholder={tCommon("SearchPlaceholder")} onChange={updateSearch} />
<CategoryScroll <CategoryScroll categories={categories} selectedCategory={selectedCategory} onSelect={updateCategory} />
categories={categories}
selectedCategory={selectedCategory}
onSelect={updateCategory}
/>
</div> </div>
<section className="w-full"> <section className="w-full">
<div className="flex gap-2 justify-between items-center relative" ref={smallCategoriesRef} > <div className="flex gap-2 justify-between items-center relative" ref={smallCategoriesRef}>
<span className="sm:text-base text-sm font-medium"> <span className="sm:text-base text-sm font-medium">{selectedCategory === "0" ? "" : categories.find((c) => c.id === selectedCategory)?.title || ""}</span>
{selectedCategory === '0' ? '' : categories.find(c => c.id === selectedCategory)?.title || ''}
</span>
<div className="inline-flex gap-2 justify-end items-center"> <div className="inline-flex gap-2 justify-end items-center">
<div className="inline-flex rounded-xl h-8 bg-container p-1"> <div className={glassSurfaceFlat("inline-flex rounded-xl h-8 p-1")}>
<button <button
onClick={() => setViewModeAndPersist("list")} onClick={() => setViewModeAndPersist("list")}
className={clsx( className={clsx(
"rounded-lg h-6 w-8 inline-flex items-center justify-center transition-colors", "rounded-lg h-6 w-8 inline-flex items-center justify-center transition-colors",
viewMode === "list" viewMode === "list" ? "bg-background text-foreground" : "text-foreground/60 hover:text-foreground",
? "bg-background text-foreground"
: "text-foreground/60 hover:text-foreground"
)} )}
title="نمایش یک ستونه" title="نمایش یک ستونه"
> >
@@ -201,21 +201,16 @@ const MenuIndex = () => {
onClick={() => setViewModeAndPersist("grid")} onClick={() => setViewModeAndPersist("grid")}
className={clsx( className={clsx(
"rounded-lg h-6 w-8 inline-flex items-center justify-center transition-colors", "rounded-lg h-6 w-8 inline-flex items-center justify-center transition-colors",
viewMode === "grid" viewMode === "grid" ? "bg-background text-foreground" : "text-foreground/60 hover:text-foreground",
? "bg-background text-foreground"
: "text-foreground/60 hover:text-foreground"
)} )}
title="نمایش دو ستونه" title="نمایش دو ستونه"
> >
<RowHorizontal color="black" className="w-4 h-4" /> <RowHorizontal color="black" className="w-4 h-4" />
</button> </button>
</div> </div>
<button <button onClick={toggleFilterSortModal} className={glassSurfaceFlat("rounded-xl h-8 ps-4 pe-2 py-1.5 inline-flex items-center gap-2")}>
onClick={toggleFilterSortModal}
className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center gap-2"
>
<TextAlignIcon className="text-foreground" /> <TextAlignIcon className="text-foreground" />
<span className="text-xs leading-5 font-medium">{tMenu('MenuFilterDrawer.Label')}</span> <span className="text-xs leading-5 font-medium">{tMenu("MenuFilterDrawer.Label")}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -223,24 +218,15 @@ const MenuIndex = () => {
<motion.div <motion.div
initial={{ y: smallCategoriesVisible ? 0 : -50, opacity: smallCategoriesVisible ? 1 : 0 }} initial={{ y: smallCategoriesVisible ? 0 : -50, opacity: smallCategoriesVisible ? 1 : 0 }}
animate={{ y: smallCategoriesVisible ? 0 : -50, opacity: smallCategoriesVisible ? 1 : 0 }} animate={{ y: smallCategoriesVisible ? 0 : -50, opacity: smallCategoriesVisible ? 1 : 0 }}
transition={{ duration: .1 }} transition={{ duration: 0.1 }}
className={clsx( className={clsx(glassSurfaceFlat("fixed left-0 z-10 top-0 px-4 pt-16 right-0 xl:pr-72 xl:pt-20 border-x-0 border-t-0 rounded-none"), !smallCategoriesVisible && "pointer-events-none")}
'fixed left-0 z-10 top-0 px-4 pt-16 bg-[#F4F5F9CC] dark:bg-background/70 backdrop-blur-[44px] right-0 xl:pr-72 xl:pt-20', >
`` <CategoryScroll categories={categories} selectedCategory={selectedCategory} onSelect={updateCategory} variant="small" />
)}>
<CategoryScroll
categories={categories}
selectedCategory={selectedCategory}
onSelect={updateCategory}
variant="small"
/>
</motion.div> </motion.div>
<VerticalScrollView className="mt-5! overflow-y-auto h-full"> <VerticalScrollView className="mt-5! overflow-y-auto h-full">
{filteredReceiptItems.length === 0 ? ( {filteredReceiptItems.length === 0 ? (
<div className="text-center text-foreground/60 py-8"> <div className="text-center text-foreground/60 py-8">{productsData ? "کالایی یافت نشد" : "در حال بارگذاری..."}</div>
{productsData ? 'کالایی یافت نشد' : 'در حال بارگذاری...'}
</div>
) : viewMode === "grid" ? ( ) : viewMode === "grid" ? (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{filteredReceiptItems.map((product) => ( {filteredReceiptItems.map((product) => (
@@ -264,7 +250,7 @@ const MenuIndex = () => {
onClose={toggleFilterSortModal} onClose={toggleFilterSortModal}
searchQuery={filterSearch} searchQuery={filterSearch}
onSearchChange={changeFilterSearch} onSearchChange={changeFilterSearch}
onApply={() => { }} onApply={() => {}}
sortings={sortings} sortings={sortings}
activeSortIndex={activeSortingIndex} activeSortIndex={activeSortingIndex}
onSortChange={changeSorting} onSortChange={changeSorting}
+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;
} }
@@ -1,5 +1,7 @@
"use client"; "use client";
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
import clsx from 'clsx';
import Button from '@/components/button/PrimaryButton'; import Button from '@/components/button/PrimaryButton';
import { ef } from '@/lib/helpers/utfNumbers'; import { ef } from '@/lib/helpers/utfNumbers';
import { ArrowLeft, Edit2, TickCircle, Trash } from 'iconsax-react'; import { ArrowLeft, Edit2, TickCircle, Trash } from 'iconsax-react';
@@ -102,7 +104,7 @@ function UserAddressesPage({ }: Props) {
addresses.map((address) => ( addresses.map((address) => (
<div <div
key={address.id} key={address.id}
className={`bg-container rounded-container w-full shadow-xl px-4 py-4 flex flex-col justify-between ${address.isDefault ? 'border border-primary' : ''}`} className={glassSurfaceCard('w-full px-4 py-4 flex flex-col justify-between', address.isDefault && 'border border-primary')}
> >
<div className="flex justify-between items-start mb-2"> <div className="flex justify-between items-start mb-2">
<h2 className='text-sm font-medium'>{address.title}</h2> <h2 className='text-sm font-medium'>{address.title}</h2>
@@ -1,4 +1,5 @@
"use client"; "use client";
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
import Button from '@/components/button/PrimaryButton'; import Button from '@/components/button/PrimaryButton';
import { Button as ShadButton } from '@/components/ui/button'; import { Button as ShadButton } from '@/components/ui/button';
@@ -197,7 +198,7 @@ function ProfileIndex({ }: Props) {
<h1 className='text-sm2 place-self-center font-medium'>ویرایش اطلاعات</h1> <h1 className='text-sm2 place-self-center font-medium'>ویرایش اطلاعات</h1>
</div> </div>
<form onSubmit={formik.handleSubmit} className="mt-8 flex-1 bg-container rounded-container w-full box-shadow-normal py-6 px-4 flex flex-col justify-between"> <form onSubmit={formik.handleSubmit} className={glassSurfaceCard('mt-8 flex-1 w-full py-6 px-4 flex flex-col justify-between')}>
<div className="bg-inherit"> <div className="bg-inherit">
<div className="justify-self-center w-fit relative text-center pb-10"> <div className="justify-self-center w-fit relative text-center pb-10">
<input <input
+2 -1
View File
@@ -1,4 +1,5 @@
"use client"; "use client";
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
import Button from '@/components/button/PrimaryButton'; import Button from '@/components/button/PrimaryButton';
// import { useProfile } from '@/hooks/auth/useProfile'; // import { useProfile } from '@/hooks/auth/useProfile';
@@ -43,7 +44,7 @@ function ProfileIndex({ }: Props) {
/> />
</div> </div>
<div className="mt-8 flex-1 h-full bg-container rounded-container w-full box-shadow-normal py-6 px-4 flex flex-col justify-between"> <div className={glassSurfaceCard('mt-8 flex-1 h-full w-full py-6 px-4 flex flex-col justify-between')}>
<div className=""> <div className="">
<div className="flex items-center justify-start gap-3 pb-6 border-b-[1.5px] border-border"> <div className="flex items-center justify-start gap-3 pb-6 border-b-[1.5px] border-border">
@@ -1,4 +1,5 @@
'use client'; 'use client';
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
import Button from '@/components/button/PrimaryButton'; import Button from '@/components/button/PrimaryButton';
import PasswordField from '@/components/input/PasswordField'; import PasswordField from '@/components/input/PasswordField';
@@ -38,7 +39,7 @@ function UserSettingsIndex() {
return ( return (
<section aria-labelledby="about-title" className='py-4 flex-1 h-full'> <section aria-labelledby="about-title" className='py-4 flex-1 h-full'>
<section <section
className="bg-container rounded-container shadow-container p-4"> className={glassSurfaceCard('p-4')}>
<div className="flex justify-between items-center pb-[25px]"> <div className="flex justify-between items-center pb-[25px]">
<h2 className='text-sm leading-5'>{data.TabNotifications.Heading}</h2> <h2 className='text-sm leading-5'>{data.TabNotifications.Heading}</h2>
@@ -94,7 +95,7 @@ function UserSettingsIndex() {
return ( return (
<section aria-labelledby="reviews-title" className='py-4'> <section aria-labelledby="reviews-title" className='py-4'>
<section <section
className="bg-container rounded-container shadow-container p-4"> className={glassSurfaceCard('p-4')}>
<div className="flex justify-between items-center pb-[25px]"> <div className="flex justify-between items-center pb-[25px]">
<h2 className='text-sm leading-5'>{data.TabPassword.Heading}</h2> <h2 className='text-sm leading-5'>{data.TabPassword.Heading}</h2>
@@ -154,7 +155,7 @@ function UserSettingsIndex() {
return ( return (
<section aria-labelledby="reviews-title" className='py-4'> <section aria-labelledby="reviews-title" className='py-4'>
<div <div
className="bg-container rounded-container shadow-container p-4"> className={glassSurfaceCard('p-4')}>
<div className="pb-4"> <div className="pb-4">
<h2 className='text-sm leading-5'>{data.TabAuthenticator.Heading}</h2> <h2 className='text-sm leading-5'>{data.TabAuthenticator.Heading}</h2>
@@ -236,7 +237,7 @@ function UserSettingsIndex() {
/> />
</div> </div>
<section className='bg-container rounded-container shadow-container p-4'> <section className={glassSurfaceCard('p-4')}>
<div className='flex justify-between items-center pb-4'> <div className='flex justify-between items-center pb-4'>
<h2 className='text-sm leading-5'>{data.StatisticalParticipation.Heading}</h2> <h2 className='text-sm leading-5'>{data.StatisticalParticipation.Heading}</h2>
<Switch <Switch
@@ -1,4 +1,5 @@
"use client"; "use client";
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
import Button from '@/components/button/PrimaryButton'; import Button from '@/components/button/PrimaryButton';
import InputField from '@/components/input/InputField'; import InputField from '@/components/input/InputField';
@@ -34,7 +35,7 @@ function UserWalletIndex({ }: Props) {
<form <form
onSubmit={chargeAction} onSubmit={chargeAction}
className="mt-8 flex-1 h-full bg-container rounded-container w-full box-shadow-normal py-6 px-4 flex flex-col justify-between"> className={glassSurfaceCard('mt-8 flex-1 h-full w-full py-6 px-4 flex flex-col justify-between')}>
<div className=""> <div className="">
<div className="flex items-center justify-center text-center gap-4 pb-6 border-b-[1.5px] border-border"> <div className="flex items-center justify-center text-center gap-4 pb-6 border-b-[1.5px] border-border">
+48
View File
@@ -0,0 +1,48 @@
import { fetchWithTimeout } from "@/lib/helpers/fetchWithTimeout";
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const revalidate = 0;
const CACHE_MAX_AGE = "public, max-age=3600";
export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams.get("url");
if (!url) {
return NextResponse.json({ error: "url is required" }, { status: 400 });
}
try {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
}
const res = await fetchWithTimeout(url, {
timeoutMs: 15_000,
headers: { Accept: "image/svg+xml, text/xml, text/plain" },
});
if (!res.ok) {
return NextResponse.json(
{ error: "Failed to fetch SVG" },
{ status: res.status }
);
}
const text = await res.text();
if (!text.trim().toLowerCase().includes("<svg")) {
return NextResponse.json(
{ error: "Response is not SVG" },
{ status: 400 }
);
}
return new NextResponse(text, {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": CACHE_MAX_AGE,
},
});
} catch {
return NextResponse.json(
{ error: "Failed to fetch SVG" },
{ status: 502 }
);
}
}
+2 -8
View File
@@ -1,10 +1,8 @@
import PreferenceWrapper from '@/components/wrapper/PreferenceWrapper' import RestaurantLayoutClient from '@/components/RestaurantLayoutClient'
import React from 'react' import React from 'react'
import type { Metadata, Viewport } from 'next' import type { Metadata, Viewport } from 'next'
import { getShop } from './lib/getShop' import { getShop } from './lib/getShop'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import CartChecker from '@/components/CartChecker'
import ActiveChecker from '@/components/ActiveChecker'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const revalidate = 0 export const revalidate = 0
@@ -97,11 +95,7 @@ export default async function Layout({
try { try {
const { name } = await params const { name } = await params
await getShop(name) await getShop(name)
return <> return <RestaurantLayoutClient>{children}</RestaurantLayoutClient>
<PreferenceWrapper>{children}</PreferenceWrapper>
<CartChecker />
<ActiveChecker />
</>
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'SHOP_NOT_FOUND') { if (error instanceof Error && error.message === 'SHOP_NOT_FOUND') {
notFound() notFound()
+35
View File
@@ -1,6 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "../../public/assets/css/fonts.css"; @import "../../public/assets/css/fonts.css";
@import "tw-animate-css"; @import "tw-animate-css";
@import "../styles/glass.css";
/* @custom-variant dark (&:is(.dark *)); */ /* @custom-variant dark (&:is(.dark *)); */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@@ -386,3 +387,37 @@ html[data-theme="dark"] {
.game-explanation { .game-explanation {
display: none; display: none;
} }
html[data-pattern-bg="true"] #root {
position: relative;
z-index: 1;
}
html[data-pattern-bg="true"] [data-slot="dialog-content"] {
background-color: var(--container) !important;
}
html[data-pattern-bg="true"] [data-slot="splash-screen"] {
background-color: var(--container) !important;
}
html[data-pattern-bg="true"] .pattern-secondary-bg {
background-color: var(--primary-light) !important;
}
html[data-image-bg="true"] #root {
position: relative;
z-index: 1;
}
html[data-image-bg="true"] [data-slot="dialog-content"] {
background-color: var(--container) !important;
}
html[data-image-bg="true"] [data-slot="splash-screen"] {
background-color: var(--container) !important;
}
html[data-image-bg="true"] .pattern-secondary-bg {
background-color: var(--primary-light) !important;
}
+4 -1
View File
@@ -7,6 +7,7 @@ import initTranslations from '@/lib/i18n';
import TranslationsProvider from "@/components/utils/TranslationsProdiver"; import TranslationsProvider from "@/components/utils/TranslationsProdiver";
import ToastContainer from "@/components/Toast"; import ToastContainer from "@/components/Toast";
import { ThemeColorSetter } from "@/components/ThemeColorSetter"; import { ThemeColorSetter } from "@/components/ThemeColorSetter";
import { ThemeBootScript } from "@/components/ThemeBootScript";
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Dashboard', title: 'Dashboard',
@@ -43,7 +44,9 @@ export default async function RootLayout({
dir='rtl' dir='rtl'
className="h-svh overflow-hidden" className="h-svh overflow-hidden"
data-theme="light" > data-theme="light" >
<head /> <head>
<ThemeBootScript />
</head>
<body <body
className={`antialiased bg-background h-svh overflow-hidden`} className={`antialiased bg-background h-svh overflow-hidden`}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import ActiveChecker from "@/components/ActiveChecker";
import AppBackground from "@/components/background/AppBackground";
import CartChecker from "@/components/CartChecker";
import { PatternBackgroundProvider } from "@/components/background/PatternBackgroundProvider";
import PreferenceWrapper from "@/components/wrapper/PreferenceWrapper";
type Props = {
children: React.ReactNode;
};
export default function RestaurantLayoutClient({ children }: Props) {
return (
<>
<PatternBackgroundProvider>
<PreferenceWrapper>
<AppBackground />
{children}
</PreferenceWrapper>
</PatternBackgroundProvider>
<CartChecker />
<ActiveChecker />
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { getThemeBootScript } from "@/lib/helpers/themeBootScript";
export function ThemeBootScript() {
return (
<script
id="theme-boot"
dangerouslySetInnerHTML={{ __html: getThemeBootScript() }}
/>
);
}
+11 -4
View File
@@ -1,12 +1,20 @@
'use client' 'use client'
import { useEffect } from 'react' import { useLayoutEffect } from 'react'
import { hexToOklch, calculateBrightness } from '@/lib/helpers/colorUtils' import { hexToOklch, calculateBrightness } from '@/lib/helpers/colorUtils'
import { getCachedMenuColor, getRestaurantSlugFromPath, getThemeCache, applyCachedThemeToDom } from '@/lib/helpers/themeCache'
export function ThemeColorSetter() { export function ThemeColorSetter() {
useEffect(() => { useLayoutEffect(() => {
try { try {
const savedColor = localStorage.getItem('theme-primary-color') const slug = getRestaurantSlugFromPath()
const cachedTheme = slug ? getThemeCache(slug) : null
if (cachedTheme) {
applyCachedThemeToDom(cachedTheme)
return
}
const savedColor = getCachedMenuColor(slug)
if (savedColor) { if (savedColor) {
const oklchColor = hexToOklch(savedColor) const oklchColor = hexToOklch(savedColor)
@@ -25,4 +33,3 @@ export function ThemeColorSetter() {
return null return null
} }
@@ -0,0 +1,83 @@
"use client";
import { usePatternBackground } from "@/components/background/PatternBackgroundProvider";
import usePreference from "@/hooks/helpers/usePreference";
import { THEME_BG_ELEMENT_ID } from "@/lib/helpers/themeCache";
import { useLayoutEffect } from "react";
const THEME_BG_IMG_ID = "cached-theme-bg-img";
const THEME_BG_OVERLAY_ID = "cached-theme-bg-overlay";
export default function AppBackground() {
const { state: nightMode } = usePreference("night-mode", false);
const { isPattern, backgroundStyle, isCustomImage, customImageSettings } =
usePatternBackground();
useLayoutEffect(() => {
if (nightMode || (!isPattern && !isCustomImage)) {
document.getElementById(THEME_BG_ELEMENT_ID)?.remove();
return;
}
let element = document.getElementById(THEME_BG_ELEMENT_ID);
if (!element) {
element = document.createElement("div");
element.id = THEME_BG_ELEMENT_ID;
element.setAttribute("aria-hidden", "true");
element.style.position = "fixed";
element.style.inset = "0";
element.style.pointerEvents = "none";
element.style.zIndex = "0";
document.body.appendChild(element);
}
if (isPattern) {
element.style.overflow = "";
document.getElementById(THEME_BG_IMG_ID)?.remove();
document.getElementById(THEME_BG_OVERLAY_ID)?.remove();
Object.assign(element.style, backgroundStyle as Record<string, string>);
return;
}
if (isCustomImage && customImageSettings) {
const { bgUrl, bgBlur, bgOpacity, bgOverlay } = customImageSettings;
element.style.backgroundColor = "";
element.style.backgroundImage = "";
element.style.overflow = "hidden";
let imgLayer = document.getElementById(THEME_BG_IMG_ID);
if (!imgLayer) {
imgLayer = document.createElement("div");
imgLayer.id = THEME_BG_IMG_ID;
imgLayer.style.position = "absolute";
imgLayer.style.inset = "-40px";
imgLayer.style.backgroundSize = "cover";
imgLayer.style.backgroundPosition = "center";
imgLayer.style.backgroundRepeat = "no-repeat";
element.appendChild(imgLayer);
}
imgLayer.style.backgroundImage = `url("${bgUrl}")`;
imgLayer.style.filter = bgBlur ? `blur(${bgBlur}px)` : "";
imgLayer.style.opacity =
bgOpacity != null ? String(bgOpacity / 100) : "1";
let overlayLayer = document.getElementById(THEME_BG_OVERLAY_ID);
if (!overlayLayer) {
overlayLayer = document.createElement("div");
overlayLayer.id = THEME_BG_OVERLAY_ID;
overlayLayer.style.position = "absolute";
overlayLayer.style.inset = "0";
element.appendChild(overlayLayer);
}
const overlayOpacity =
bgOverlay != null ? Number(bgOverlay) / 100 : 0;
overlayLayer.style.backgroundColor =
overlayOpacity > 0 ? `rgba(0,0,0,${overlayOpacity})` : "";
}
}, [backgroundStyle, isCustomImage, isPattern, customImageSettings, nightMode]);
return null;
}
@@ -0,0 +1,248 @@
"use client";
import { useGetAbout } from "@/app/[name]/(Main)/about/hooks/useAboutData";
import {
applyColorBackgroundCssVariables,
applyCustomImageCssVariables,
applyPatternCssVariables,
clearCustomImageCssVariables,
clearPatternCssVariables,
colorizeSvg,
hexToRgba,
normalizeBgNumber,
PATTERN_BACKGROUND_OPACITY,
resolveBgType,
} from "@/lib/helpers/backgroundUtils";
import usePreference from "@/hooks/helpers/usePreference";
import {
applyCachedThemeToDom,
buildThemeCacheFromRestaurant,
clearRestaurantBackgroundOverrides,
getThemeCache,
removeCachedThemeBackground,
setThemeCache,
svgToDataUrl,
themeBackgroundMatches,
type CachedRestaurantTheme,
} from "@/lib/helpers/themeCache";
import { useParams } from "next/navigation";
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
type CSSProperties,
type ReactNode,
} from "react";
const DEFAULT_MENU_COLOR = "#1E3A8A";
export type CustomImageSettings = {
bgUrl: string;
bgBlur: number | null;
bgOpacity: number | null;
bgOverlay: string | null;
};
type PatternBackgroundContextValue = {
isPattern: boolean;
isCustomImage: boolean;
backgroundStyle: CSSProperties;
customImageSettings: CustomImageSettings | null;
};
const PatternBackgroundContext = createContext<PatternBackgroundContextValue>({
isPattern: false,
isCustomImage: false,
backgroundStyle: {},
customImageSettings: null,
});
export function PatternBackgroundProvider({ children }: { children: ReactNode }) {
const { name: restaurantSlug } = useParams<{ name: string }>();
const { state: nightMode } = usePreference("night-mode", false);
const { data: aboutData } = useGetAbout();
const restaurant = aboutData?.data;
const [hydratedCache, setHydratedCache] = useState<CachedRestaurantTheme | null>(null);
const [patternImageUrl, setPatternImageUrl] = useState<string | null>(null);
const bgType = restaurant
? resolveBgType(restaurant)
: hydratedCache
? resolveBgType(hydratedCache)
: "color";
const isPattern = !nightMode && bgType === "pattern";
const isCustomImage = !nightMode && bgType === "custom";
const menuColor =
restaurant?.menuColor || hydratedCache?.menuColor || DEFAULT_MENU_COLOR;
const baseTint = hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY);
useLayoutEffect(() => {
if (!restaurantSlug) return;
const cached = getThemeCache(restaurantSlug);
setHydratedCache(cached);
if (!cached) return;
applyCachedThemeToDom(cached);
if (!nightMode && cached.patternDataUrl) {
setPatternImageUrl(cached.patternDataUrl);
}
}, [nightMode, restaurantSlug]);
useEffect(() => {
if (!restaurantSlug) {
clearPatternCssVariables();
clearCustomImageCssVariables();
removeCachedThemeBackground();
setPatternImageUrl(null);
return;
}
if (nightMode) {
clearPatternCssVariables();
clearCustomImageCssVariables();
clearRestaurantBackgroundOverrides();
setPatternImageUrl(null);
return;
}
if (!restaurant) return;
const themeBase = buildThemeCacheFromRestaurant(restaurant);
const currentBgType = themeBase.bgType;
if (currentBgType === "color") {
clearCustomImageCssVariables();
removeCachedThemeBackground();
setPatternImageUrl(null);
applyColorBackgroundCssVariables(menuColor);
setThemeCache(restaurantSlug, { ...themeBase, patternDataUrl: null });
return;
}
if (currentBgType === "custom") {
clearPatternCssVariables();
setPatternImageUrl(null);
applyCustomImageCssVariables();
setThemeCache(restaurantSlug, { ...themeBase, patternDataUrl: null });
return;
}
clearCustomImageCssVariables();
applyPatternCssVariables(menuColor);
if (!themeBase.bgUrl) {
setPatternImageUrl(null);
setThemeCache(restaurantSlug, {
...themeBase,
patternDataUrl: null,
});
const saved = getThemeCache(restaurantSlug);
if (saved) {
applyCachedThemeToDom(saved);
}
return;
}
const cached = getThemeCache(restaurantSlug);
const cacheHit =
cached &&
themeBackgroundMatches(cached, restaurant) &&
cached.isPattern &&
cached.patternDataUrl;
if (cacheHit) {
setPatternImageUrl(cached.patternDataUrl!);
setThemeCache(restaurantSlug, {
...themeBase,
patternDataUrl: cached.patternDataUrl,
});
applyCachedThemeToDom(cached);
return;
}
let cancelled = false;
const proxyUrl = `/${restaurantSlug}/api/proxy-svg?url=${encodeURIComponent(themeBase.bgUrl)}`;
fetch(proxyUrl)
.then((response) => {
if (!response.ok) {
throw new Error("Failed to fetch background pattern");
}
return response.text();
})
.then((svg) => {
if (cancelled) return;
const dataUrl = svgToDataUrl(colorizeSvg(svg, menuColor));
setPatternImageUrl(dataUrl);
const nextCache = {
...themeBase,
patternDataUrl: dataUrl,
};
setThemeCache(restaurantSlug, nextCache);
const saved = getThemeCache(restaurantSlug);
if (saved) {
applyCachedThemeToDom(saved);
}
})
.catch(() => {
if (!cancelled && cached?.patternDataUrl) {
setPatternImageUrl(cached.patternDataUrl);
applyCachedThemeToDom(cached);
}
});
return () => {
cancelled = true;
};
}, [menuColor, nightMode, restaurant, restaurantSlug]);
const backgroundStyle = useMemo<CSSProperties>(
() => ({
backgroundColor: baseTint,
...(patternImageUrl
? {
backgroundImage: `url("${patternImageUrl}")`,
backgroundRepeat: "repeat",
backgroundSize: "auto",
}
: {}),
}),
[baseTint, patternImageUrl],
);
const customImageSettings = useMemo<CustomImageSettings | null>(() => {
const src = restaurant?.bgUrl || hydratedCache?.bgUrl;
if (!isCustomImage || !src) return null;
return {
bgUrl: src,
bgBlur: normalizeBgNumber(restaurant?.bgBlur ?? hydratedCache?.bgBlur),
bgOpacity: normalizeBgNumber(restaurant?.bgOpacity ?? hydratedCache?.bgOpacity),
bgOverlay: restaurant?.bgOverlay ?? hydratedCache?.bgOverlay ?? null,
};
}, [isCustomImage, restaurant, hydratedCache]);
const value = useMemo(
() => ({ isPattern, isCustomImage, backgroundStyle, customImageSettings }),
[backgroundStyle, isPattern, isCustomImage, customImageSettings],
);
return (
<PatternBackgroundContext.Provider value={value}>
{children}
</PatternBackgroundContext.Provider>
);
}
export function usePatternBackground() {
return useContext(PatternBackgroundContext);
}
+4 -4
View File
@@ -2,7 +2,7 @@
import React from 'react' import React from 'react'
import SearchIcon from '../icons/SearchIcon' import SearchIcon from '../icons/SearchIcon'
import clsx from 'clsx' import { glassSurfaceFlat } from '@/lib/styles/glassSurface'
interface SearchboxProps interface SearchboxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'results'> { extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'results'> {
@@ -19,9 +19,9 @@ export default function SearchBox({
}: SearchboxProps) { }: SearchboxProps) {
return ( return (
<div <div
className={clsx( className={glassSurfaceFlat(
'bg-container inline-flex rounded-xl px-4 h-10 w-full items-center content-center', 'inline-flex rounded-xl px-4 h-10 w-full items-center content-center',
className className,
)} )}
> >
<SearchIcon stroke='#8C90A3' /> <SearchIcon stroke='#8C90A3' />
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { glassSurface } from '@/lib/styles/glassSurface';
import React from 'react'; import React from 'react';
type Props = { type Props = {
@@ -8,10 +9,10 @@ type Props = {
function CategoryItemRenderer({ children, ...rest }: Props) { function CategoryItemRenderer({ children, ...rest }: Props) {
return ( return (
<div className={`${rest.className} cursor-pointer transition-all duration-200 ease-out gradient-border overflow-hidden rounded-xl backdrop-blur-md bg-container/40`}> <div className={glassSurface(rest.className, 'cursor-pointer transition-[background-color,box-shadow,opacity] duration-200 ease-out rounded-xl outline-none [-webkit-tap-highlight-color:transparent]')}>
<div <div
{...rest} {...rest}
className="rounded-normal w-[88px] h-[88px] flex flex-col justify-center items-center gap-2" className="rounded-normal w-[108px] h-[88px] flex flex-col justify-center items-center gap-2 overflow-hidden"
> >
{children} {children}
</div> </div>
@@ -1,17 +1,23 @@
'use client'; 'use client';
import { glassSurface } from '@/lib/styles/glassSurface';
import React from 'react'; import React from 'react';
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
compact?: boolean;
} & React.HTMLAttributes<HTMLDivElement>; } & React.HTMLAttributes<HTMLDivElement>;
function CategorySmallItemRenderer({ children, ...rest }: Props) { function CategorySmallItemRenderer({ children, compact = false, className, ...rest }: Props) {
return ( return (
<div className={`${rest.className} cursor-pointer transition-all duration-200 ease-out gradient-border overflow-hidden rounded-xl backdrop-blur-md bg-container/40 dark:bg-background`}> <div className={glassSurface(className, 'cursor-pointer transition-[background-color,box-shadow,opacity] duration-200 ease-out rounded-xl outline-none [-webkit-tap-highlight-color:transparent]')}>
<div <div
{...rest} {...rest}
className="rounded-normal flex flex-row justify-center items-center h-8 px-2 gap-2" className={
compact
? "rounded-normal flex flex-row justify-center items-center h-8 px-2 gap-2"
: "rounded-normal h-[44px] flex flex-row justify-center items-center p-2.5 gap-2"
}
> >
{children} {children}
</div> </div>
+117 -140
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,30 @@ 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 isSpecialOffer = product.isSpecialOffer === true;
const specialOfferBadge = isSpecialOffer ? (
<span className="inline-block rounded-md bg-primary text-primary-foreground text-[10px] font-medium px-1.5 py-0.5 leading-tight">پیشنهاد ویژه</span>
) : null;
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 +126,39 @@ 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="relative 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]"> {specialOfferBadge && <div className="absolute top-0 left-0 z-10">{specialOfferBadge}</div>}
<img <Link href={productDetailUrl} className="cursor-pointer rounded-xl aspect-square overflow-hidden flex items-center justify-center max-h-25">
className="rounded-xl max-w-full max-h-full object-center" <img className="rounded-xl max-w-full max-h-full object-center" src={resolvedImage} alt={productName} />
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 +166,64 @@ 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 || specialOfferBadge) && (
<img <div className="absolute top-3 left-3 z-10 flex flex-col items-start gap-1">
className="rounded-xl bg-[#F2F2F9] min-w-28 w-28 aspect-square object-cover" {specialOfferBadge}
src={resolvedImage} {badge}
height={112} </div>
width={112} )}
alt={productName} <div className="flex gap-4 w-full min-h-28 items-stretch">
/> <Link href={productDetailUrl} className="cursor-pointer shrink-0 self-center">
</Link> <img className="rounded-xl bg-[#F2F2F9] min-w-28 w-28 aspect-square object-cover" src={resolvedImage} height={112} width={112} alt={productName} />
<div className="w-full inline-flex flex-col justify-between min-w-0"> </Link>
<Link href={productDetailUrl} className="cursor-pointer min-w-0"> <div className="flex-1 flex flex-col justify-between min-w-0">
<div className="min-w-0"> <Link href={productDetailUrl} className="cursor-pointer min-w-0">
<div className="flex items-center justify-between gap-2 min-w-0"> <div className="min-w-0">
<div className="text-sm2 font-normal text-black dark:text-white wrap-break-word min-w-0"> <div className="flex items-center justify-between gap-2 min-w-0">
{productName} <div className="text-sm2 font-bold text-black dark:text-white wrap-break-word min-w-0">{productName}</div>
{variantValueDisplay && <span className="bg-primary text-primary-foreground text-[10px] px-1 py-0.5 rounded-md shrink-0">{variantValueDisplay}</span>}
</div> </div>
{variantValueDisplay && ( {(productContent || productDescription) && (
<span className="bg-primary text-primary-foreground text-[10px] px-1 py-0.5 rounded-md shrink-0"> <div className="text-[#7F7F7F] line-clamp-2 text-xs leading-5 font-normal mt-4 wrap-break-word overflow-hidden">{productContent || productDescription}</div>
{variantValueDisplay}
</span>
)} )}
</div> </div>
{(productContent || productDescription) && ( </Link>
<div className="text-[#7F7F7F] line-clamp-2 text-xs leading-5 font-normal mt-2 wrap-break-word overflow-hidden"> <div className="flex flex-col gap-1 mt-2" dir="ltr">
{productContent || productDescription} {variablePriceLabel ? (
<span className="text-sm text-right">{variablePriceLabel}</span>
) : hasDiscount ? (
<>
<span className="text-xs text-disabled-text line-through">{formattedOriginalPrice} T</span>
<span className="text-sm font-medium text-primary dark:text-foreground">{formattedPrice} T</span>
</>
) : (
<span className="text-sm text-right">{formattedPrice} T</span>
)}
</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 ? (
<div className="w-28.75">
<Link href={productDetailUrl} className={`${actionButtonClass} w-full`}>
<span className={actionButtonLabelClass}>جزئیات</span>
<ArrowLeft size={16} className="stroke-foreground" />
</Link>
</div>
) : (
<div className="w-28.75">
<CartQuantityControl quantity={quantity} onAdd={handleAddToCart} onRemove={handleRemoveFromCart} isMutating={isCartMutating} />
</div> </div>
)} )}
</div> </div>
</Link> ) : null}
<div className="flex flex-col mt-2 gap-2 w-full">
<div className="flex flex-col gap-1 min-h-10 justify-center" dir="ltr">
{hasDiscount ? (
<>
<span className="text-xs text-disabled-text line-through">
{formattedOriginalPrice} T
</span>
<span className="text-sm font-medium text-primary dark:text-foreground">
{formattedPrice} T
</span>
</>
) : (
<span className="text-sm text-right">
{formattedPrice} T
</span>
)}
</div>
{showDetailsInsteadOfAdd ? (
<Link
href={productDetailUrl}
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>
) : (
<CartQuantityControl
quantity={quantity}
onAdd={handleAddToCart}
onRemove={handleRemoveFromCart}
isMutating={isCartMutating}
/>
)}
</div>
</div> </div>
</div> </>
); );
}; };
+9 -6
View File
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { glassSurface } from '@/lib/styles/glassSurface';
import React from 'react'; import React from 'react';
type Props = { type Props = {
@@ -12,18 +13,20 @@ function MenuItemRendererComponent({ children, variant = 'list', ...rest }: Prop
return ( return (
<div <div
{...rest} {...rest}
className={`${rest.className} box-shadow-normal transition-all duration-200 ease-out w-full bg-container rounded-3xl ${ className={glassSurface(
isGrid rest.className,
? 'p-3 flex flex-col' `transition-all duration-200 ease-out w-full rounded-3xl ${
: 'py-4 pb-4! px-4 flex items-center justify-between gap-4' isGrid
}`} ? 'p-3 flex flex-col'
: 'relative py-4 pb-4! px-4 flex items-stretch overflow-visible'
}`,
)}
> >
{children} {children}
</div> </div>
); );
} }
// Memoize with shallow comparison of props
const MenuItemRenderer = React.memo(MenuItemRendererComponent, (prevProps, nextProps) => { const MenuItemRenderer = React.memo(MenuItemRendererComponent, (prevProps, nextProps) => {
return ( return (
prevProps.className === nextProps.className && prevProps.className === nextProps.className &&
+3 -2
View File
@@ -19,6 +19,7 @@ import { useGetAbout } from '@/app/[name]/(Main)/about/hooks/useAboutData';
import Image from 'next/image'; import Image from 'next/image';
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData'; import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
import { toast } from '../Toast'; import { toast } from '../Toast';
import { glassSurface } from '@/lib/styles/glassSurface';
type MenuItemType = { type MenuItemType = {
href: string | undefined; href: string | undefined;
@@ -324,13 +325,13 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS
data-visible={menuState} data-visible={menuState}
data-isvisible={menuStateMemo} data-isvisible={menuStateMemo}
onClick={(e) => { e.stopPropagation(); }} onClick={(e) => { e.stopPropagation(); }}
className="fixed top-0 bottom-0 right-0 bg-container w-[200px] h-dvh flex flex-col z-40 overflow-clip not-xl:rounded-s-none!" className={glassSurface("fixed top-0 bottom-0 right-0 w-[200px] h-dvh flex flex-col z-40 overflow-clip not-xl:rounded-s-none!")}
> >
{renderMenu()} {renderMenu()}
</motion.nav> </motion.nav>
</BlurredOverlayContainer> </BlurredOverlayContainer>
<nav <nav
className="hidden fixed top-4 bottom-4 right-4 bg-container xl:w-[250px] xl:flex flex-col z-40 overflow-clip xl:rounded-4xl" className={glassSurface('hidden fixed top-4 bottom-4 right-4 xl:w-[250px] xl:flex flex-col z-40 overflow-clip xl:rounded-4xl')}
> >
{renderMenu()} {renderMenu()}
</nav> </nav>
+58 -180
View File
@@ -1,46 +1,63 @@
'use client'; 'use client';
import React from 'react'
import Link from 'next/link'
import BottomNavLink from './BottomNavLink'
import BottomNavHighlightLink from './BottomNavLinkBig'
import BagIcon from '../icons/BagIcon'
import HeartIcon from '../icons/HeartIcon'
import { useParams, usePathname, useRouter } from 'next/navigation'
import HomeIcon from '../icons/HomeIcon'
import { useTranslation } from 'react-i18next';
import { useCart } from '@/app/[name]/(Dialogs)/cart/hook/useCart'; import { useCart } from '@/app/[name]/(Dialogs)/cart/hook/useCart';
import { Building, Receipt21 } from 'iconsax-react';
import clsx from 'clsx';
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData'; import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
import { glassSurfaceNav } from '@/lib/styles/glassSurface';
import clsx from 'clsx';
import type { Icon } from 'iconsax-react';
import { Bag2, Building, Heart, Home2, Receipt21 } from 'iconsax-react';
import Link from 'next/link';
import { useParams, usePathname, useRouter } from 'next/navigation';
import { useMemo } from 'react';
import { toast } from '../Toast'; import { toast } from '../Toast';
function BottomNavBar() { type NavItemProps = {
href?: string;
isActive: boolean;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
Icon: Icon;
badge?: number;
};
function NavItem({ href, isActive, onClick, Icon, badge }: NavItemProps) {
const className = clsx('flex-1 flex items-center justify-center h-full rounded-full', isActive && 'bg-primary/12');
const icon = (
<span className="relative">
<Icon size={20} color="currentColor" variant={isActive ? 'Bold' : 'Linear'} className={isActive ? 'text-primary' : 'text-icon-deactive'} />
{badge != null && badge > 0 && (
<span className="absolute -right-1.5 -top-1 flex min-w-3.5 h-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[8px] font-medium text-container dark:bg-white dark:text-black">
{badge > 99 ? '99+' : badge}
</span>
)}
</span>
);
return (
<Link href={href ?? '#'} onClick={onClick} className={className}>
{icon}
</Link>
);
}
function BottomNavBar() {
const params = useParams(); const params = useParams();
const { name } = params; const name = params.name as string;
const pathname = usePathname(); const pathname = usePathname();
const isHomeRoute = pathname === `/${name}`; const router = useRouter();
const isAboutRoute = pathname.includes('about'); const { isSuccess } = useGetProfile();
const isOrderRoute = pathname.includes('order');
const buildingVariant = React.useMemo(() => {
return (isAboutRoute ? 'Bold' : 'Outline') as 'Bold' | 'Outline';
}, [isAboutRoute]);
const orderVariant = React.useMemo(() => {
return (isOrderRoute ? 'Bold' : 'Outline') as 'Bold' | 'Outline';
}, [isOrderRoute]);
const { isSuccess } = useGetProfile()
const router = useRouter()
const { t } = useTranslation('common', {
keyPrefix: 'BottomNavbar'
});
const { items } = useCart(); const { items } = useCart();
const cartItemsCount = React.useMemo(() => { const cartItemCount = useMemo(
return Object.values(items).reduce((total, item) => { () => Object.values(items).reduce((sum, item) => sum + (item?.quantity ?? 0), 0),
return total + (item?.quantity || 0); [items],
}, 0); );
}, [items]);
const isCartActive = pathname.startsWith(`/${name}/cart`);
const isOrderActive = pathname.includes('/order');
const isHomeActive = pathname === `/${name}`;
const isAboutActive = pathname.startsWith(`/${name}/about`);
const isFavoriteActive = pathname.startsWith(`/${name}/favorite`);
const handleFavoritesClick = (e: React.MouseEvent<HTMLAnchorElement>) => { const handleFavoritesClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault(); e.preventDefault();
@@ -51,156 +68,17 @@ function BottomNavBar() {
toast('ابتدا لاگین کنید', 'error'); toast('ابتدا لاگین کنید', 'error');
router.replace(`/${name}/auth`); router.replace(`/${name}/auth`);
} }
} };
return ( return (
<div className="fixed bottom-0 left-0 w-full bg-transparent pointer-events-none"> <div className={glassSurfaceNav('mx-auto w-full rounded-full flex items-center h-14 max-w-md gap-2.5')}>
<div className="max-w-md mx-auto w-full aspect-436/104 relative z-30"> <NavItem href={`/${name}/cart`} isActive={isCartActive} Icon={Bag2} badge={cartItemCount} />
<svg <NavItem href={`/${name}/order/history`} isActive={isOrderActive} Icon={Receipt21} />
viewBox="0 0 436 104" <NavItem href={`/${name}`} isActive={isHomeActive} Icon={Home2} />
fill="none" <NavItem href={`/${name}/about`} isActive={isAboutActive} Icon={Building} />
xmlns="http://www.w3.org/2000/svg" <NavItem href={`/${name}/favorite`} isActive={isFavoriteActive} onClick={handleFavoritesClick} Icon={Heart} />
className="w-full h-full block"
preserveAspectRatio="xMidYMid meet"
>
<g filter="url(#filter0_d_9095_18036)" className='fill-container'>
<path
className='pointer-events-auto'
d="M218 16C229.034 16 238.711 22.0313 244.148 31.0943C245.844 33.9222 248.724 36 252.022 36H406C414.837 36 422 43.1634 422 52V80C422 88.8366 414.837 96 406 96H30C21.1634 96 14 88.8366 14 80V52C14 43.1634 21.1634 36 30 36H183.979C187.277 36 190.157 33.9222 191.853 31.0943C197.29 22.0315 206.966 16.0001 218 16Z"
/>
</g>
{/* 🔽 Your HTML content inside SVG */}
<foreignObject x="0" y="10" width="100%" height="80">
<nav
className="h-full px-4 grid grid-cols-5 gap-x-1 text-[10px] items-end"
>
<BottomNavLink
href={`/${name}/cart`}
icon={<BagIcon width={20} height={20} />}
value={t('Cart')}
/>
<Link
href={`/${name}/order/history`}
className={clsx(
'flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto **:stroke-primary min-w-0 dark:text-white dark:**:stroke-white'
)}
>
<div className="shrink-0 text-primary dark:text-white">
<Receipt21
key={`receipt-${orderVariant}-${isOrderRoute}`}
size={20}
variant={orderVariant}
color="currentColor"
className='stroke-0'
/>
</div>
<span className="text-xs2 text-center truncate w-full dark:text-white">
{t('Orders')}
</span>
</Link>
{/* <BottomNavLink href={`/${name}/order/history`} icon={<ReceiptIcon width={20} height={20} />} value={t('Orders')} /> */}
<BottomNavHighlightLink
href={`/${name}`}
icon={
<HomeIcon
className={clsx(
'transition-all duration-200 stroke-primary dark:stroke-black! dark:text-white'
)}
fill="white"
stroke="currentColor"
variant={isHomeRoute ? 'Bold' : 'Outline'}
width={20}
height={20} />}
value={t('Menu')} />
<Link
href={`/${name}/about`}
className={clsx(
'flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto **:stroke-primary min-w-0 dark:text-white dark:**:stroke-white'
)}
>
<div className="shrink-0 text-primary dark:text-white">
<Building
key={`building-${buildingVariant}-${isAboutRoute}`}
size={20}
variant={buildingVariant as 'Bold' | 'Outline'}
color="currentColor"
className='stroke-0'
/>
</div>
<span className="text-xs2 text-center truncate w-full dark:text-white">
{t('about')}
</span>
</Link>
<BottomNavLink href={`/${name}/favorite`} onClick={handleFavoritesClick} icon={<HeartIcon width={20} height={20} />} value={t('Favorites')} />
</nav>
</foreignObject>
<defs>
<filter
id="filter0_d_9095_18036"
x="0"
y="0"
width="100%"
height="100%"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-2" />
<feGaussianBlur stdDeviation="7" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_9095_18036"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_9095_18036"
result="shape"
/>
</filter>
</defs>
</svg>
{/* Badge Layer - خارج از SVG - دقیقاً مثل foreignObject */}
{cartItemsCount > 0 && (
<div
className="absolute left-0 right-0 pointer-events-none"
style={{
top: 'calc(10 / 104 * 100%)',
height: 'calc(80 / 104 * 100%)'
}}
>
<div className="h-full px-4 grid grid-cols-5 gap-x-1 items-end">
<div className="flex flex-col items-center gap-[5px]">
<div className="relative shrink-0">
<div className="w-5 h-5 opacity-0"></div>
<span className="absolute sm:-top-1 sm:-right-1 top-0 -right-1 flex items-center justify-center min-w-[14px] min-h-[14px] px-1 text-[8px] font-medium text-white dark:bg-white dark:text-black bg-primary rounded-full z-50">
{cartItemsCount > 99 ? '99+' : cartItemsCount}
</span>
</div>
<span className="text-xs2 opacity-0">{t('Cart')}</span>
</div>
</div>
</div>
)}
</div>
</div> </div>
) );
} }
export default BottomNavBar export default BottomNavBar;
@@ -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>
)
}
+2 -1
View File
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { glassSurfaceNav } from "@/lib/styles/glassSurface";
import React, { ReactElement, ReactNode, useCallback, useState } from 'react' import React, { ReactElement, ReactNode, useCallback, useState } from 'react'
import HorizontalScrollView from '../listview/HorizontalScrollView' import HorizontalScrollView from '../listview/HorizontalScrollView'
import { TabHeader, TabItemProps } from './TabHeader'; import { TabHeader, TabItemProps } from './TabHeader';
@@ -38,7 +39,7 @@ export type TabContainerClassName = {
export const TabContainerClassNames: TabContainerClassName = { export const TabContainerClassNames: TabContainerClassName = {
wrapper: 'h-full', wrapper: 'h-full',
scrollView: 'h-[72px] gradient-border !pt-3 !pb-2 w-full overflow-y-hidden bg-[#FFFFFF70] dark:bg-container/70 justify-center rounded-[32px] inline-flex items-center gap-10', scrollView: glassSurfaceNav('h-[72px] gradient-border !pt-3 !pb-2 w-full overflow-y-hidden justify-center rounded-[32px] inline-flex items-center gap-10 !shadow-none'),
header: '', header: '',
headerDeactive: '', headerDeactive: '',
headerActive: '', headerActive: '',
@@ -1,12 +1,14 @@
'use client'; 'use client';
import React, { useEffect } from 'react' import React, { useEffect, useRef } from 'react'
import TopBar from '../topbar/TopBar' import TopBar from '../topbar/TopBar'
import SideMenu from '../menu/SideMenu' import SideMenu from '../menu/SideMenu'
import BottomNavBar from '../navigation/BottomNavBar' import BottomNavBar from '../navigation/BottomNavBar'
import useToggle from '@/hooks/helpers/useToggle'; import useToggle from '@/hooks/helpers/useToggle';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname } from 'next/navigation';
import usePreference from '@/hooks/helpers/usePreference'; import usePreference from '@/hooks/helpers/usePreference';
import { glassSurface } from '@/lib/styles/glassSurface';
import clsx from 'clsx';
type Props = {} & React.AllHTMLAttributes<HTMLBodyElementEventMap> type Props = {} & React.AllHTMLAttributes<HTMLBodyElementEventMap>
@@ -15,7 +17,6 @@ function ClientMenuRouteWrapper({ children }: Props) {
const { state: menuState, toggle: _toggleMenuState, set: setMenuState } = useToggle(false); const { state: menuState, toggle: _toggleMenuState, set: setMenuState } = useToggle(false);
const { state: searchModal, toggle: _toggleSearchModal } = useToggle(false); const { state: searchModal, toggle: _toggleSearchModal } = useToggle(false);
const { state: nightMode, toggle: _toggleNightMode } = usePreference<boolean>('night-mode', false); const { state: nightMode, toggle: _toggleNightMode } = usePreference<boolean>('night-mode', false);
const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const toggleProfileDrop = () => { const toggleProfileDrop = () => {
@@ -35,6 +36,12 @@ function ClientMenuRouteWrapper({ children }: Props) {
} }
const location = usePathname(); const location = usePathname();
const hideBottomNav = pathname.endsWith('/cart');
const mainRef = useRef<HTMLElement>(null);
const navContainerRef = useRef<HTMLDivElement>(null);
const targetProgressRef = useRef(0);
const currentProgressRef = useRef(0);
const rafIdRef = useRef(0);
useEffect(() => { useEffect(() => {
if (menuState) { if (menuState) {
@@ -43,10 +50,54 @@ function ClientMenuRouteWrapper({ children }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [location]) }, [location])
useEffect(() => {
const scrollEl = mainRef.current;
const navEl = navContainerRef.current;
if (!navEl) return;
if (!scrollEl || hideBottomNav) {
cancelAnimationFrame(rafIdRef.current);
navEl.style.transform = '';
targetProgressRef.current = 0;
currentProgressRef.current = 0;
return;
}
const SCROLL_RANGE = 150;
const LERP_FACTOR = 0.1;
const MIN_SCALE = 0.82;
const tick = () => {
const diff = targetProgressRef.current - currentProgressRef.current;
if (Math.abs(diff) < 0.0005) {
currentProgressRef.current = targetProgressRef.current;
} else {
currentProgressRef.current += diff * LERP_FACTOR;
rafIdRef.current = requestAnimationFrame(tick);
}
const scale = 1 - (1 - MIN_SCALE) * currentProgressRef.current;
navEl.style.transform = `scale(${scale.toFixed(4)})`;
};
const onScroll = () => {
targetProgressRef.current = Math.min(1, Math.max(0, scrollEl.scrollTop / SCROLL_RANGE));
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = requestAnimationFrame(tick);
};
scrollEl.addEventListener('scroll', onScroll, { passive: true });
return () => {
scrollEl.removeEventListener('scroll', onScroll);
cancelAnimationFrame(rafIdRef.current);
};
}, [hideBottomNav, location]);
return ( return (
<div className="h-svh overflow-hidden pt-10 flex flex-col pb-4 xl:pt-12"> <div className="h-svh overflow-hidden pt-10 flex flex-col pb-4 xl:pt-12">
<div className="z-50 fixed right-4 left-4 xl:right-[285px] top-4 xl:h-16 h-12 flex items-center px-4 bg-container justify-between rounded-[32px] xl:w-[calc(100%-305px)]"> <div data-menu-topbar className={glassSurface('z-50 fixed right-4 left-4 xl:right-[285px] top-4 xl:h-16 h-12 flex items-center px-4 justify-between rounded-[32px] xl:w-[calc(100%-305px)]')}>
<TopBar <TopBar
profileDropState={profileDrop} profileDropState={profileDrop}
toggleProfileDropState={toggleProfileDrop} toggleProfileDropState={toggleProfileDrop}
@@ -59,11 +110,16 @@ function ClientMenuRouteWrapper({ children }: Props) {
/> />
</div> </div>
{!hideBottomNav && (
<div className="fixed bottom-0 left-0 right-0 z-45 px-4"> <div
<BottomNavBar /> ref={navContainerRef}
</div> data-menu-bottom-nav
className="fixed bottom-7 left-0 right-0 z-45 px-4"
style={{ willChange: 'transform', transformOrigin: 'center bottom' }}
>
<BottomNavBar />
</div>
)}
<SideMenu <SideMenu
nightModeState={nightMode} nightModeState={nightMode}
@@ -72,14 +128,13 @@ function ClientMenuRouteWrapper({ children }: Props) {
toggleMenuState={toggleMenuState} toggleMenuState={toggleMenuState}
/> />
<main <main
className="noscrollbar overflow-y-auto h-svh pt-6 xl:pt-8 pb-12 px-4 xl:pr-0 xl:pl-5 w-full xl:w-[calc(100%-285px)] md:self-end" ref={mainRef}
className={clsx('relative noscrollbar overflow-y-auto h-svh pt-6 xl:pt-8 px-4 xl:pr-0 xl:pl-5 w-full xl:w-[calc(100%-285px)] md:self-end', hideBottomNav ? 'pb-4' : 'pb-12')}
> >
{children} {children}
</main> </main>
</div> </div>
) )
} }
+63 -29
View File
@@ -2,15 +2,23 @@
import usePreference from '@/hooks/helpers/usePreference'; import usePreference from '@/hooks/helpers/usePreference';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useGetAbout } from '@/app/[name]/(Main)/about/hooks/useAboutData'; import { useGetAbout } from '@/app/[name]/(Main)/about/hooks/useAboutData';
import SplashScreen from '@/components/overlays/SplashScreen'; import SplashScreen from '@/components/overlays/SplashScreen';
import { hexToOklch, calculateBrightness } from '@/lib/helpers/colorUtils'; import { hexToOklch, calculateBrightness } from '@/lib/helpers/colorUtils';
import {
applyCachedThemeToDom,
buildThemeCacheFromRestaurant,
clearRestaurantBackgroundOverrides,
getCachedMenuColor,
getThemeCache,
setThemeCache,
} from '@/lib/helpers/themeCache';
type Props = { type Props = {
children: React.ReactNode children: React.ReactNode
} }
// تابع برای اعمال رنگ primary
function applyPrimaryColor(colorHex: string) { function applyPrimaryColor(colorHex: string) {
const root = document.documentElement; const root = document.documentElement;
const oklchColor = hexToOklch(colorHex); const oklchColor = hexToOklch(colorHex);
@@ -25,10 +33,12 @@ function applyPrimaryColor(colorHex: string) {
} }
function PreferenceWrapper({ children }: Props) { function PreferenceWrapper({ children }: Props) {
const params = useParams();
const restaurantSlug = typeof params.name === 'string' ? params.name : '';
const { state: nightMode } = usePreference('night-mode', false); const { state: nightMode } = usePreference('night-mode', false);
const { data: aboutData } = useGetAbout(); const { data: aboutData } = useGetAbout();
// بررسی اینکه آیا باید اسپلش نمایش داده بشه
const [showSplash, setShowSplash] = useState(() => { const [showSplash, setShowSplash] = useState(() => {
if (typeof window === 'undefined') return true; if (typeof window === 'undefined') return true;
const navigationEntries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[]; const navigationEntries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
@@ -39,50 +49,75 @@ function PreferenceWrapper({ children }: Props) {
const [cachedLogo, setCachedLogo] = useState<string | null>(null); const [cachedLogo, setCachedLogo] = useState<string | null>(null);
const [cachedName, setCachedName] = useState<string | null>(null); const [cachedName, setCachedName] = useState<string | null>(null);
// بارگذاری رنگ و اطلاعات از localStorage در اولین رندر
useEffect(() => { useEffect(() => {
// بارگذاری رنگ const savedColor = getCachedMenuColor(restaurantSlug);
const savedColor = localStorage.getItem('theme-primary-color'); const cachedTheme = restaurantSlug ? getThemeCache(restaurantSlug) : null;
if (savedColor) { if (cachedTheme) {
applyCachedThemeToDom(cachedTheme);
} else if (savedColor) {
applyPrimaryColor(savedColor); applyPrimaryColor(savedColor);
} }
// بارگذاری لوگو و نام
const savedLogo = localStorage.getItem('shop-logo'); const savedLogo = localStorage.getItem('shop-logo');
const savedName = localStorage.getItem('shop-name'); const savedName = localStorage.getItem('shop-name');
if (savedLogo) setCachedLogo(savedLogo); if (savedLogo) setCachedLogo(savedLogo);
if (savedName) setCachedName(savedName); if (savedName) setCachedName(savedName);
// ثبت اینکه app لود شده
sessionStorage.setItem('app-loaded', 'true'); sessionStorage.setItem('app-loaded', 'true');
}, []); }, [restaurantSlug]);
// اعمال رنگ primary از about (با ذخیره در localStorage)
useEffect(() => { useEffect(() => {
if (aboutData?.data?.menuColor) { const menuColor = aboutData?.data?.menuColor;
// ذخیره رنگ و اطلاعات در localStorage if (!menuColor || !restaurantSlug) return;
localStorage.setItem('theme-primary-color', aboutData.data.menuColor);
// ذخیره لوگو و نام برای اسپلش const restaurant = aboutData.data;
if (aboutData.data.logo) { const cached = getThemeCache(restaurantSlug);
localStorage.setItem('shop-logo', aboutData.data.logo); const themeBase = buildThemeCacheFromRestaurant(restaurant);
}
if (aboutData.data.name) {
localStorage.setItem('shop-name', aboutData.data.name);
}
applyPrimaryColor(aboutData.data.menuColor); setThemeCache(restaurantSlug, {
menuColor,
bgType: themeBase.bgType,
bgUrl: restaurant.bgUrl,
bgBlur: themeBase.bgBlur,
bgOpacity: themeBase.bgOpacity,
bgOverlay: themeBase.bgOverlay,
isPattern: themeBase.isPattern,
patternDataUrl: cached?.patternDataUrl ?? null,
});
const updatedTheme = getThemeCache(restaurantSlug);
if (updatedTheme) {
applyCachedThemeToDom(updatedTheme);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [aboutData?.data?.menuColor]);
// اعمال تم تاریک/روشن localStorage.setItem('theme-primary-color', menuColor);
if (restaurant.logo) {
localStorage.setItem('shop-logo', restaurant.logo);
}
if (restaurant.name) {
localStorage.setItem('shop-name', restaurant.name);
}
applyPrimaryColor(menuColor);
}, [aboutData?.data, restaurantSlug]);
useEffect(() => { useEffect(() => {
const theme = nightMode; document.documentElement.setAttribute('data-theme', nightMode ? 'dark' : 'light');
document.documentElement.setAttribute("data-theme", theme ? "dark" : "light");
}, [nightMode]); if (nightMode) {
clearRestaurantBackgroundOverrides();
return;
}
if (!restaurantSlug) return;
const cached = getThemeCache(restaurantSlug);
if (cached) {
applyCachedThemeToDom(cached);
}
}, [nightMode, restaurantSlug]);
// بستن اسپلش بعد از مدت زمان مشخص
useEffect(() => { useEffect(() => {
if (showSplash) { if (showSplash) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -93,7 +128,6 @@ function PreferenceWrapper({ children }: Props) {
} }
}, [showSplash]); }, [showSplash]);
// استفاده از داده‌های cache شده برای اسپلش
const splashLogo = aboutData?.data?.logo || cachedLogo || undefined; const splashLogo = aboutData?.data?.logo || cachedLogo || undefined;
const splashName = aboutData?.data?.name || cachedName || undefined; const splashName = aboutData?.data?.name || cachedName || undefined;
+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";
@@ -2,6 +2,7 @@
// import LoadingOverlay from '@/components/overlays/LoadingOverlay'; // import LoadingOverlay from '@/components/overlays/LoadingOverlay';
import React from 'react' import React from 'react'
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
type Props = { type Props = {
isPending: boolean, isPending: boolean,
@@ -24,7 +25,7 @@ function AuthFormWrapper({ children, isPending, onSubmit, ...restProps }: Props)
onSubmit={handleSubmit} onSubmit={handleSubmit}
className='p-6 lg:py-[75px] w-full flex items-center justify-center py-4 lg:items-center lg:px-10 px-4 drop-shadow-black h-full' className='p-6 lg:py-[75px] w-full flex items-center justify-center py-4 lg:items-center lg:px-10 px-4 drop-shadow-black h-full'
> >
<div className='relative h-full w-full px-4 sm:px-6 lg:p-0 flex flex-col max-h-[812px] max-w-[1200px] bg-container rounded-container overflow-clip lg:flex-row justify-between'> <div className={glassSurfaceCard('relative h-full w-full px-4 sm:px-6 lg:p-0 flex flex-col max-h-[812px] max-w-[1200px] overflow-clip lg:flex-row justify-between')}>
{children} {children}
{/* <LoadingOverlay visible={isPending} bgOpacity={0} /> */} {/* <LoadingOverlay visible={isPending} bgOpacity={0} /> */}
</div> </div>
+37 -8
View File
@@ -1,16 +1,45 @@
'use client' "use client";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { useState } from "react";
import { useState } from 'react' import {
getRestaurantSlugFromPath,
restorePersistedRestaurantQueries,
} from "@/lib/helpers/themeCache";
export function ReactQueryProvider({ children }: { children: React.ReactNode }) { function createQueryClient() {
const [queryClient] = useState(() => new QueryClient()) const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
retryOnMount: false,
},
mutations: {
retry: false,
},
},
});
if (typeof window !== "undefined") {
const slug = getRestaurantSlugFromPath();
if (slug) {
restorePersistedRestaurantQueries(client, slug);
}
}
return client;
}
export function ReactQueryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(createQueryClient);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
</QueryClientProvider> </QueryClientProvider>
) );
} }
+155
View File
@@ -0,0 +1,155 @@
export const PATTERN_BACKGROUND_OPACITY = 0.05;
export const PATTERN_VECTOR_OPACITY = 0.1;
export const PRIMARY_LIGHT_OPACITY = 0.15;
export type BgType = "pattern" | "custom" | "color";
export type RestaurantBackgroundSettings = {
bgType?: BgType | string | null;
bgUrl?: string | null;
bgBlur?: number | string | null;
bgOpacity?: number | string | null;
bgOverlay?: string | null;
menuColor?: string | null;
isPattern?: boolean;
};
export const normalizeBgNumber = (value: unknown): number | null => {
if (value == null || value === "") return null;
const parsed = typeof value === "string" ? Number(value) : value;
if (typeof parsed !== "number" || Number.isNaN(parsed)) return null;
return parsed;
};
export const hexToRgba = (hex: string, alpha: number): string => {
const normalized = hex.replace("#", "");
if (normalized.length !== 6) {
return `rgba(0, 0, 0, ${alpha})`;
}
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
const b = parseInt(normalized.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
const isUnsetBgValue = (value: unknown): boolean =>
value == null || value === "" || value === 0;
const isCustomImageBackgroundLegacy = (
settings: RestaurantBackgroundSettings,
): boolean => {
if (!settings.bgUrl) return false;
return (
!isUnsetBgValue(settings.bgBlur) ||
!isUnsetBgValue(settings.bgOpacity) ||
!isUnsetBgValue(settings.bgOverlay)
);
};
export const resolveBgType = (
settings: RestaurantBackgroundSettings,
): BgType => {
if (settings.bgType === null) return "color";
const normalized = settings.bgType?.toLowerCase();
if (
normalized === "pattern" ||
normalized === "custom" ||
normalized === "color"
) {
return normalized;
}
if (settings.isPattern === true) return "pattern";
if (settings.isPattern === false && settings.bgUrl && isCustomImageBackgroundLegacy(settings)) {
return "custom";
}
if (settings.isPattern === false) return "color";
if (isCustomImageBackgroundLegacy(settings)) return "custom";
if (settings.bgUrl) return "pattern";
return "color";
};
export const isCustomImageBackground = (
settings: RestaurantBackgroundSettings,
): boolean => resolveBgType(settings) === "custom";
export const isPatternBackground = (
settings: RestaurantBackgroundSettings,
): boolean => resolveBgType(settings) === "pattern";
export const isColorBackground = (
settings: RestaurantBackgroundSettings,
): boolean => resolveBgType(settings) === "color";
export const colorizeSvg = (
svg: string,
color: string,
vectorOpacity: number = PATTERN_VECTOR_OPACITY,
): string => {
let result = svg.replace(/fill="(?!black|none)[^"]+"/g, `fill="${color}"`);
result = result.replace(
/fill-opacity="[^"]+"/g,
`fill-opacity="${vectorOpacity}"`,
);
result = result.replace(
/(<(?:path|circle|rect|ellipse|polygon)[^>]*fill="(?!black|none)[^"]+")(?![^>]*fill-opacity)/g,
`$1 fill-opacity="${vectorOpacity}"`,
);
return result;
};
export const applyColorBackgroundCssVariables = (menuColor: string) => {
const root = document.documentElement;
root.removeAttribute("data-pattern-bg");
root.removeAttribute("data-image-bg");
root.style.removeProperty("--bg-pattern-base");
root.style.removeProperty("--bg-pattern-vector");
root.style.setProperty(
"--background",
hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY),
);
root.style.setProperty(
"--primary-light",
hexToRgba(menuColor, PRIMARY_LIGHT_OPACITY),
);
};
export const applyPatternCssVariables = (menuColor: string) => {
const root = document.documentElement;
const baseTint = hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY);
root.setAttribute("data-pattern-bg", "true");
root.style.setProperty("--bg-pattern-base", baseTint);
root.style.setProperty(
"--bg-pattern-vector",
hexToRgba(menuColor, PATTERN_VECTOR_OPACITY),
);
root.style.setProperty(
"--primary-light",
hexToRgba(menuColor, PRIMARY_LIGHT_OPACITY),
);
root.style.setProperty("--background", baseTint);
};
export const clearPatternCssVariables = () => {
const root = document.documentElement;
root.removeAttribute("data-pattern-bg");
root.style.removeProperty("--bg-pattern-base");
root.style.removeProperty("--bg-pattern-vector");
root.style.removeProperty("--primary-light");
root.style.removeProperty("--background");
};
export const applyCustomImageCssVariables = () => {
const root = document.documentElement;
root.setAttribute("data-image-bg", "true");
root.removeAttribute("data-pattern-bg");
};
export const clearCustomImageCssVariables = () => {
document.documentElement.removeAttribute("data-image-bg");
};
+13
View File
@@ -0,0 +1,13 @@
const DEFAULT_TIMEOUT_MS = 5000;
export async function fetchWithTimeout(
url: string,
options: RequestInit & { timeoutMs?: number } = {},
): Promise<Response> {
const { timeoutMs = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options;
return fetch(url, {
...fetchOptions,
signal: AbortSignal.timeout(timeoutMs),
});
}
+3
View File
@@ -0,0 +1,3 @@
export function getThemeBootScript(): string {
return `(function(){try{function isNight(){try{var p=localStorage.getItem("preference-storage");if(!p)return false;var o=JSON.parse(p);return o&&o.state&&o.state.items&&o.state.items["night-mode"]&&o.state.items["night-mode"].value===true}catch(v){return false}}var night=isNight(),e=document.documentElement;e.setAttribute("data-theme",night?"dark":"light");var s=location.pathname.split("/").filter(Boolean)[0],t=null;if(s&&s!=="auth"){var r=localStorage.getItem("restaurant-theme:"+s);if(r)t=JSON.parse(r)}if(!t||t.version!==1||!t.menuColor){var l=localStorage.getItem("theme-primary-color");if(l&&!night){var x=l.replace("#","");if(x.length===6){var p=parseInt(x.slice(0,2),16),f=parseInt(x.slice(2,4),16),m=parseInt(x.slice(4,6),16),b="rgba("+p+","+f+","+m+",0.05)";e.style.setProperty("--background",b)}}return}function h(n,a){var u=n.replace("#","");if(u.length!==6)return null;var v=parseInt(u.slice(0,2),16),g=parseInt(u.slice(2,4),16),y=parseInt(u.slice(4,6),16);return"rgba("+v+","+g+","+y+","+a+")"}function isUnset(v){return v==null||v===""||v===0}function isCustomImg(th){if(!th.bgUrl)return false;return !isUnset(th.bgBlur)||!isUnset(th.bgOpacity)||!isUnset(th.bgOverlay)}function resolveBgType(th){if(th.bgType===null)return"color";var bt=(th.bgType||"").toLowerCase();if(bt==="pattern"||bt==="custom"||bt==="color")return bt;if(th.isPattern===true)return"pattern";if(th.isPattern===false&&th.bgUrl&&isCustomImg(th))return"custom";if(th.isPattern===false)return"color";if(isCustomImg(th))return"custom";if(th.bgUrl)return"pattern";return"color"}if(t.primaryOklch){e.style.setProperty("--primary",t.primaryOklch);e.style.setProperty("--primary-foreground",t.primaryForeground||"oklch(1 0 0)")}if(night)return;var bt=resolveBgType(t);if(bt==="color"){var k=t.backgroundTint||h(t.menuColor,0.05),z=t.primaryLight||h(t.menuColor,0.15);k&&e.style.setProperty("--background",k);z&&e.style.setProperty("--primary-light",z);return};if(bt==="pattern"){e.setAttribute("data-pattern-bg","true");var k=t.backgroundTint||h(t.menuColor,0.05),w=t.bgPatternVector||h(t.menuColor,0.1),z=t.primaryLight||h(t.menuColor,0.15);k&&(e.style.setProperty("--background",k),e.style.setProperty("--bg-pattern-base",k));w&&e.style.setProperty("--bg-pattern-vector",w);z&&e.style.setProperty("--primary-light",z);if(k){var d=document.createElement("div");d.id="cached-theme-bg";d.setAttribute("aria-hidden","true");d.style.cssText="position:fixed;inset:0;pointer-events:none;z-index:0;background-color:"+k+(t.patternDataUrl?';background-image:url("'+t.patternDataUrl+'");background-repeat:repeat;background-size:auto':"")+";";document.body.appendChild(d)}}else if(bt==="custom"){e.setAttribute("data-image-bg","true");var d2=document.createElement("div");d2.id="cached-theme-bg";d2.setAttribute("aria-hidden","true");d2.style.cssText="position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;";var i=document.createElement("div");i.id="cached-theme-bg-img";i.style.cssText="position:absolute;inset:-40px;background-size:cover;background-position:center;background-repeat:no-repeat;background-image:url(\\""+t.bgUrl+"\\");"+(t.bgBlur?"filter:blur("+t.bgBlur+"px);":"")+"opacity:"+(t.bgOpacity!=null?t.bgOpacity/100:1)+";";d2.appendChild(i);var ov=document.createElement("div");ov.id="cached-theme-bg-overlay";ov.style.cssText="position:absolute;inset:0;"+(t.bgOverlay&&Number(t.bgOverlay)>0?"background-color:rgba(0,0,0,"+Number(t.bgOverlay)/100+");":"");d2.appendChild(ov);document.body.appendChild(d2)}}catch(u){}})();`;
}
+456
View File
@@ -0,0 +1,456 @@
import { calculateBrightness, hexToOklch } from "@/lib/helpers/colorUtils";
import {
hexToRgba,
resolveBgType,
normalizeBgNumber,
PATTERN_BACKGROUND_OPACITY,
PATTERN_VECTOR_OPACITY,
PRIMARY_LIGHT_OPACITY,
type BgType,
type RestaurantBackgroundSettings,
} from "@/lib/helpers/backgroundUtils";
import type { AboutResponse } from "@/app/[name]/(Main)/about/types/Types";
import type {
CategoriesResponse,
ProductsResponse,
} from "@/app/[name]/(Main)/types/Types";
import type { QueryClient } from "@tanstack/react-query";
export type CachedRestaurantTheme = {
version: 1;
menuColor: string;
bgType?: BgType;
bgUrl?: string | null;
bgBlur?: number | null;
bgOpacity?: number | null;
bgOverlay?: string | null;
isPattern: boolean;
patternDataUrl?: string | null;
primaryOklch?: string;
primaryForeground?: string;
backgroundTint?: string;
bgPatternVector?: string;
primaryLight?: string;
};
const CACHE_VERSION = 1;
const CACHE_KEY_PREFIX = "restaurant-theme";
const LEGACY_COLOR_KEY = "theme-primary-color";
const ABOUT_CACHE_PREFIX = "restaurant-about";
const MENU_CACHE_PREFIX = "restaurant-menu";
const CATEGORIES_CACHE_PREFIX = "restaurant-categories";
export const THEME_BG_ELEMENT_ID = "cached-theme-bg";
const THEME_BG_IMG_ID = "cached-theme-bg-img";
const THEME_BG_OVERLAY_ID = "cached-theme-bg-overlay";
const PREFERENCE_STORAGE_KEY = "preference-storage";
export function isNightModeEnabled(): boolean {
if (typeof window === "undefined") return false;
try {
const raw = localStorage.getItem(PREFERENCE_STORAGE_KEY);
if (!raw) return false;
const parsed = JSON.parse(raw) as {
state?: { items?: Record<string, { value?: unknown }> };
};
return parsed?.state?.items?.["night-mode"]?.value === true;
} catch {
return false;
}
}
export function clearRestaurantBackgroundOverrides(): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.removeAttribute("data-pattern-bg");
root.removeAttribute("data-image-bg");
root.style.removeProperty("--background");
root.style.removeProperty("--bg-pattern-base");
root.style.removeProperty("--bg-pattern-vector");
root.style.removeProperty("--primary-light");
document.getElementById(THEME_BG_ELEMENT_ID)?.remove();
}
export function getThemeCacheKey(slug: string): string {
return `${CACHE_KEY_PREFIX}:${slug}`;
}
export function getAboutCacheKey(slug: string): string {
return `${ABOUT_CACHE_PREFIX}:${slug}`;
}
export function getMenuCacheKey(slug: string): string {
return `${MENU_CACHE_PREFIX}:${slug}`;
}
export function getCategoriesCacheKey(slug: string): string {
return `${CATEGORIES_CACHE_PREFIX}:${slug}`;
}
export function getRestaurantSlugFromPath(pathname?: string): string | null {
const path =
pathname ?? (typeof window !== "undefined" ? window.location.pathname : "");
const segment = path.split("/").filter(Boolean)[0];
if (!segment || segment === "auth") return null;
return segment;
}
export function computeThemeCssValues(menuColor: string) {
const primaryOklch = hexToOklch(menuColor);
const brightness = calculateBrightness(menuColor);
return {
primaryOklch: primaryOklch ?? undefined,
primaryForeground: brightness > 0.5 ? "oklch(0 0 0)" : "oklch(1 0 0)",
backgroundTint: hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY),
bgPatternVector: hexToRgba(menuColor, PATTERN_VECTOR_OPACITY),
primaryLight: hexToRgba(menuColor, PRIMARY_LIGHT_OPACITY),
};
}
export function getThemeCache(slug: string): CachedRestaurantTheme | null {
if (!slug || typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(getThemeCacheKey(slug));
if (!raw) return null;
const parsed = JSON.parse(raw) as CachedRestaurantTheme;
if (parsed.version !== CACHE_VERSION || !parsed.menuColor) return null;
if (!parsed.primaryOklch) {
const enriched: CachedRestaurantTheme = {
...parsed,
...computeThemeCssValues(parsed.menuColor),
version: CACHE_VERSION,
};
try {
localStorage.setItem(getThemeCacheKey(slug), JSON.stringify(enriched));
} catch {
// Ignore quota errors during migration
}
return enriched;
}
return parsed;
} catch {
return null;
}
}
export function getCachedMenuColor(slug?: string | null): string | null {
if (slug) {
const cached = getThemeCache(slug);
if (cached?.menuColor) return cached.menuColor;
}
if (typeof window === "undefined") return null;
try {
return localStorage.getItem(LEGACY_COLOR_KEY);
} catch {
return null;
}
}
export function getPersistedAboutData(slug: string): AboutResponse | undefined {
if (!slug || typeof window === "undefined") return undefined;
try {
const raw = localStorage.getItem(getAboutCacheKey(slug));
if (!raw) return undefined;
return JSON.parse(raw) as AboutResponse;
} catch {
return undefined;
}
}
export function setPersistedAboutData(slug: string, data: AboutResponse): void {
if (!slug || typeof window === "undefined") return;
try {
localStorage.setItem(getAboutCacheKey(slug), JSON.stringify(data));
} catch {
// Ignore quota errors
}
}
function readPersistedQueryData<T>(key: string): T | undefined {
try {
const raw = localStorage.getItem(key);
if (!raw) return undefined;
return JSON.parse(raw) as T;
} catch {
return undefined;
}
}
function writePersistedQueryData<T>(key: string, data: T): void {
try {
localStorage.setItem(key, JSON.stringify(data));
} catch {
// Ignore quota errors
}
}
export function getPersistedMenuData(slug: string): ProductsResponse | undefined {
if (!slug || typeof window === "undefined") return undefined;
return readPersistedQueryData<ProductsResponse>(getMenuCacheKey(slug));
}
export function setPersistedMenuData(slug: string, data: ProductsResponse): void {
if (!slug || typeof window === "undefined") return;
writePersistedQueryData(getMenuCacheKey(slug), data);
}
export function getPersistedCategoriesData(
slug: string,
): CategoriesResponse | undefined {
if (!slug || typeof window === "undefined") return undefined;
return readPersistedQueryData<CategoriesResponse>(getCategoriesCacheKey(slug));
}
export function setPersistedCategoriesData(
slug: string,
data: CategoriesResponse,
): void {
if (!slug || typeof window === "undefined") return;
writePersistedQueryData(getCategoriesCacheKey(slug), data);
}
export function restorePersistedRestaurantQueries(
client: QueryClient,
slug: string,
): void {
const about = getPersistedAboutData(slug);
if (about) {
client.setQueryData(["about", slug], about);
}
const menu = getPersistedMenuData(slug);
if (menu) {
client.setQueryData(["menu", slug], menu);
}
const categories = getPersistedCategoriesData(slug);
if (categories) {
client.setQueryData(["categories", slug], categories);
}
}
export function setThemeCache(
slug: string,
theme: Omit<CachedRestaurantTheme, "version">,
): void {
if (!slug || typeof window === "undefined") return;
const existing = getThemeCache(slug);
const css = computeThemeCssValues(theme.menuColor);
const payload: CachedRestaurantTheme = {
...theme,
...css,
patternDataUrl:
theme.patternDataUrl !== undefined
? theme.patternDataUrl
: (existing?.patternDataUrl ?? null),
version: CACHE_VERSION,
};
try {
localStorage.setItem(getThemeCacheKey(slug), JSON.stringify(payload));
localStorage.setItem(LEGACY_COLOR_KEY, theme.menuColor);
} catch {
try {
const { patternDataUrl: _pattern, ...withoutPattern } = payload;
localStorage.setItem(
getThemeCacheKey(slug),
JSON.stringify({ ...withoutPattern, patternDataUrl: null }),
);
localStorage.setItem(LEGACY_COLOR_KEY, theme.menuColor);
} catch {
// Ignore quota errors
}
}
}
export function buildThemeCacheFromRestaurant(
settings: RestaurantBackgroundSettings,
): Pick<
CachedRestaurantTheme,
| "menuColor"
| "bgType"
| "bgUrl"
| "bgBlur"
| "bgOpacity"
| "bgOverlay"
| "isPattern"
> {
const bgType = resolveBgType(settings);
return {
menuColor: settings.menuColor || "#1E3A8A",
bgType,
bgUrl: settings.bgUrl,
bgBlur: normalizeBgNumber(settings.bgBlur),
bgOpacity: normalizeBgNumber(settings.bgOpacity),
bgOverlay: settings.bgOverlay ?? null,
isPattern: bgType === "pattern",
};
}
export function themeBackgroundMatches(
cached: CachedRestaurantTheme,
settings: RestaurantBackgroundSettings,
): boolean {
const next = buildThemeCacheFromRestaurant(settings);
return (
cached.menuColor === next.menuColor &&
cached.bgType === next.bgType &&
cached.bgUrl === next.bgUrl &&
cached.bgBlur === next.bgBlur &&
cached.bgOpacity === next.bgOpacity &&
cached.bgOverlay === next.bgOverlay &&
cached.isPattern === next.isPattern
);
}
export function svgToDataUrl(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
function ensureBgElement(): HTMLElement {
let bgElement = document.getElementById(THEME_BG_ELEMENT_ID);
if (!bgElement) {
bgElement = document.createElement("div");
bgElement.id = THEME_BG_ELEMENT_ID;
bgElement.setAttribute("aria-hidden", "true");
bgElement.style.position = "fixed";
bgElement.style.inset = "0";
bgElement.style.pointerEvents = "none";
bgElement.style.zIndex = "0";
document.body.appendChild(bgElement);
}
return bgElement;
}
export function applyCachedThemeToDom(theme: CachedRestaurantTheme): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (theme.primaryOklch) {
root.style.setProperty("--primary", theme.primaryOklch);
root.style.setProperty(
"--primary-foreground",
theme.primaryForeground ?? "oklch(1 0 0)",
);
}
if (isNightModeEnabled()) {
clearRestaurantBackgroundOverrides();
return;
}
const bgType = resolveBgType(theme);
if (bgType === "color") {
document.getElementById(THEME_BG_ELEMENT_ID)?.remove();
const tint =
theme.backgroundTint ??
hexToRgba(theme.menuColor, PATTERN_BACKGROUND_OPACITY);
const primaryLight =
theme.primaryLight ??
hexToRgba(theme.menuColor, PRIMARY_LIGHT_OPACITY);
root.removeAttribute("data-pattern-bg");
root.removeAttribute("data-image-bg");
root.style.removeProperty("--bg-pattern-base");
root.style.removeProperty("--bg-pattern-vector");
root.style.setProperty("--background", tint);
root.style.setProperty("--primary-light", primaryLight);
return;
}
if (bgType === "pattern") {
root.setAttribute("data-pattern-bg", "true");
root.removeAttribute("data-image-bg");
if (theme.backgroundTint) {
root.style.setProperty("--background", theme.backgroundTint);
root.style.setProperty("--bg-pattern-base", theme.backgroundTint);
}
if (theme.bgPatternVector) {
root.style.setProperty("--bg-pattern-vector", theme.bgPatternVector);
}
if (theme.primaryLight) {
root.style.setProperty("--primary-light", theme.primaryLight);
}
if (!theme.backgroundTint) return;
const bgElement = ensureBgElement();
bgElement.style.overflow = "";
document.getElementById(THEME_BG_IMG_ID)?.remove();
document.getElementById(THEME_BG_OVERLAY_ID)?.remove();
bgElement.style.backgroundColor = theme.backgroundTint;
if (theme.patternDataUrl) {
bgElement.style.backgroundImage = `url("${theme.patternDataUrl}")`;
bgElement.style.backgroundRepeat = "repeat";
bgElement.style.backgroundSize = "auto";
} else {
bgElement.style.removeProperty("background-image");
bgElement.style.removeProperty("background-repeat");
bgElement.style.removeProperty("background-size");
}
return;
}
if (bgType === "custom" && theme.bgUrl) {
root.setAttribute("data-image-bg", "true");
root.removeAttribute("data-pattern-bg");
const bgElement = ensureBgElement();
bgElement.style.backgroundColor = "";
bgElement.style.backgroundImage = "";
bgElement.style.overflow = "hidden";
let imgLayer = document.getElementById(THEME_BG_IMG_ID);
if (!imgLayer) {
imgLayer = document.createElement("div");
imgLayer.id = THEME_BG_IMG_ID;
imgLayer.style.position = "absolute";
imgLayer.style.inset = "-40px";
imgLayer.style.backgroundSize = "cover";
imgLayer.style.backgroundPosition = "center";
imgLayer.style.backgroundRepeat = "no-repeat";
bgElement.appendChild(imgLayer);
}
imgLayer.style.backgroundImage = `url("${theme.bgUrl}")`;
imgLayer.style.filter = theme.bgBlur ? `blur(${theme.bgBlur}px)` : "";
imgLayer.style.opacity =
theme.bgOpacity != null ? String(theme.bgOpacity / 100) : "1";
let overlayLayer = document.getElementById(THEME_BG_OVERLAY_ID);
if (!overlayLayer) {
overlayLayer = document.createElement("div");
overlayLayer.id = THEME_BG_OVERLAY_ID;
overlayLayer.style.position = "absolute";
overlayLayer.style.inset = "0";
bgElement.appendChild(overlayLayer);
}
const overlayOpacity =
theme.bgOverlay != null ? Number(theme.bgOverlay) / 100 : 0;
overlayLayer.style.backgroundColor =
overlayOpacity > 0 ? `rgba(0,0,0,${overlayOpacity})` : "";
}
}
export function removeCachedThemeBackground(): void {
document.getElementById(THEME_BG_ELEMENT_ID)?.remove();
}
+33
View File
@@ -0,0 +1,33 @@
import clsx, { type ClassValue } from "clsx";
export const GLASS_SURFACE = "glass-surface";
export const GLASS_SURFACE_FLAT = "glass-surface--flat";
export const GLASS_SURFACE_SELECTED = "glass-surface--selected";
export const GLASS_SURFACE_DISABLED = "glass-surface--disabled";
export const GLASS_SURFACE_NAV = "glass-surface-nav";
export function glassSurfaceNav(...extra: ClassValue[]) {
return clsx(GLASS_SURFACE_NAV, extra);
}
export function glassSurface(...extra: ClassValue[]) {
return clsx(GLASS_SURFACE, extra);
}
export function glassSurfaceFlat(...extra: ClassValue[]) {
return clsx(GLASS_SURFACE, GLASS_SURFACE_FLAT, extra);
}
export function glassSurfaceSelected(...extra: ClassValue[]) {
return clsx(GLASS_SURFACE, GLASS_SURFACE_SELECTED, extra);
}
/** Page-level card sections (about, cart, profile, etc.) */
export function glassSurfaceCard(...extra: ClassValue[]) {
return clsx(GLASS_SURFACE, "rounded-container", extra);
}
/** Nested / secondary card sections with no shadow */
export function glassSurfaceCardFlat(...extra: ClassValue[]) {
return clsx(GLASS_SURFACE, GLASS_SURFACE_FLAT, "rounded-container", extra);
}
+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": "مبلغ قابل پرداخت"
} }
} }
+4 -6
View File
@@ -1,9 +1,11 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
// Map host → tenant path // Map host → tenant path
const HOST_MAP: Record<string, string> = { const HOST_MAP: Record<string, string> = {
"avitaprotein.com": "/avita", "avitaprotein.com": "/avita",
"boteflower.ir": "/boote",
"pedarprotein.ir": "/pedar",
// دامنه‌های جدید اینجا اضافه می‌شوند // دامنه‌های جدید اینجا اضافه می‌شوند
}; };
@@ -18,11 +20,7 @@ export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname; const pathname = req.nextUrl.pathname;
// مسیرهای api و استاتیک را rewrite نکن // مسیرهای api و استاتیک را rewrite نکن
if ( if (pathname.startsWith("/api") || pathname.startsWith("/_next") || pathname.includes(".")) {
pathname.startsWith("/api") ||
pathname.startsWith("/_next") ||
pathname.includes(".")
) {
return NextResponse.next(); return NextResponse.next();
} }
+72
View File
@@ -0,0 +1,72 @@
/* Glass surface — Figma: white 60%, border, shadow, backdrop blur */
.glass-surface {
background-color: rgba(255, 255, 255, 0.6);
border: 2px solid rgba(255, 255, 255, 0.8);
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.03);
}
html[data-pattern-bg="true"] .glass-surface,
html[data-image-bg="true"] .glass-surface {
-webkit-backdrop-filter: blur(1px);
backdrop-filter: blur(1px);
}
html[data-theme="dark"] .glass-surface {
background-color: color-mix(in oklch, var(--container) 60%, transparent);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.1);
}
.glass-surface--flat {
box-shadow: none;
}
.glass-surface--selected {
background-color: rgba(255, 255, 255, 0.85) !important;
}
html[data-theme="dark"] .glass-surface--selected {
background-color: color-mix(in oklch, var(--container) 85%, transparent) !important;
}
/* Figma disabled category: white 40%, 2px border, blur 12 */
.glass-surface--disabled {
background-color: rgba(255, 255, 255, 0.4) !important;
border: 2px solid rgba(255, 255, 255, 0.8);
box-shadow: none;
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
html[data-pattern-bg="true"] .glass-surface--disabled,
html[data-image-bg="true"] .glass-surface--disabled {
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
html[data-theme="dark"] .glass-surface--disabled {
background-color: color-mix(in oklch, var(--container) 40%, transparent) !important;
border-color: rgba(255, 255, 255, 0.2);
}
.glass-surface-nav {
background-color: rgba(255, 255, 255, 0.32);
border: 1px solid rgba(255, 255, 255, 0.8);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
box-shadow: 0px -2px 14px 0px rgba(0, 0, 0, 0.15);
}
html[data-pattern-bg="true"] .glass-surface-nav,
html[data-image-bg="true"] .glass-surface-nav {
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
}
html[data-theme="dark"] .glass-surface-nav {
background-color: color-mix(in oklch, var(--container) 32%, transparent);
border-color: rgba(255, 255, 255, 0.2);
}
+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