Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94e41da139 | |||
| adf19bfa32 | |||
| 931cc76a47 | |||
| 347017f1be | |||
| 22be2ab19e | |||
| 8cb22ef9fd | |||
| 24ff9a4100 | |||
| 0d2f2696ec | |||
| d13792219b | |||
| 22231fe800 | |||
| 3819efbfd0 | |||
| 4e48ce7214 | |||
| 2ae99faca9 | |||
| e948b00f55 |
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
description: پاسخهای Agent همیشه به فارسی باشد
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# زبان پاسخ
|
||||||
|
|
||||||
|
- همیشه به **فارسی (Farsi)** پاسخ بده.
|
||||||
|
- مگر اینکه کاربر صریحاً زبان دیگری بخواهد.
|
||||||
|
- کد، نام متغیرها، و کامنتهای کد را به انگلیسی نگه دار؛ فقط توضیحات و متنهای کاربرپسند را فارسی بنویس.
|
||||||
+14
-1
@@ -17,7 +17,20 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
headers: async () => [
|
headers: async () => [
|
||||||
{
|
{
|
||||||
source: "/:path*",
|
source: "/sw.js",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "Cache-Control",
|
||||||
|
value: "public, max-age=0, must-revalidate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Service-Worker-Allowed",
|
||||||
|
value: "/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/((?!sw\\.js|api/pwa-icon).*)",
|
||||||
headers: [
|
headers: [
|
||||||
{
|
{
|
||||||
key: "Cache-Control",
|
key: "Cache-Control",
|
||||||
|
|||||||
+14
-2
@@ -23,16 +23,28 @@
|
|||||||
},
|
},
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icons/web-app-manifest-192x192.png",
|
"src": "/api/pwa-icon/192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/web-app-manifest-512x512.png",
|
"src": "/api/pwa-icon/512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/api/pwa-icon/192",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/api/pwa-icon/512",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": [
|
"categories": [
|
||||||
|
|||||||
+11
-1
File diff suppressed because one or more lines are too long
@@ -3,8 +3,6 @@ import { type FC } from 'react'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { CloseCircle } from 'iconsax-react'
|
import { CloseCircle } from 'iconsax-react'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
const Game2048Page: FC = () => {
|
const Game2048Page: FC = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Modal from '@/components/utils/Modal';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ef } from '@/lib/helpers/utfNumbers';
|
import { ef } from '@/lib/helpers/utfNumbers';
|
||||||
|
import { Edit2 } from 'iconsax-react';
|
||||||
|
|
||||||
const formatAddress = (address: Address) => {
|
const formatAddress = (address: Address) => {
|
||||||
return `${address.address}، ${address.city}، ${address.province}`;
|
return `${address.address}، ${address.city}، ${address.province}`;
|
||||||
@@ -78,6 +79,20 @@ export const AddressSelectionModal = ({
|
|||||||
<p className='text-xs mt-2 text-disabled-text'>
|
<p className='text-xs mt-2 text-disabled-text'>
|
||||||
تلفن: {ef(address.phone)}
|
تلفن: {ef(address.phone)}
|
||||||
</p>
|
</p>
|
||||||
|
<div className='mt-3 flex justify-end'>
|
||||||
|
<Link
|
||||||
|
href={`/${name}/profile/address/new?id=${address.id}&redirect=${encodeURIComponent(redirectUrl)}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='text-xs text-primary dark:text-foreground flex items-center gap-1'
|
||||||
|
>
|
||||||
|
<Edit2 className='size-4 stroke-primary dark:stroke-foreground' />
|
||||||
|
ویرایش
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,11 +128,11 @@ function FoodPage({}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="not-lg:max-w-lg not-lg:place-self-center not-lg:w-full not-lg:mb-10 pt-6 not-lg:flex not-lg:flex-col lg:fixed lg:z-10 lg:top-20 xl:top-[5.5rem] lg:bottom-24 lg:left-5 lg:right-4 xl:right-[285px] lg:grid lg:grid-cols-2 lg:min-h-0 lg:overflow-hidden lg:rounded-2xl">
|
<div className="not-lg:max-w-lg not-lg:place-self-center not-lg:w-full not-lg:mb-10 pt-6 not-lg:flex not-lg:flex-col lg:fixed lg:z-10 lg:top-20 xl:top-22 lg:bottom-24 lg:left-5 lg:right-4 xl:right-[285px] lg:grid lg:grid-cols-2 lg:min-h-0 lg:overflow-hidden lg:rounded-2xl">
|
||||||
<div className="relative w-full lg:h-full min-h-0 not-lg:bg-container rounded-2xl overflow-hidden lg:rounded-r-none lg:order-1">
|
<div className="relative w-full lg:h-full min-h-0 not-lg:bg-container rounded-2xl overflow-hidden lg:rounded-r-none lg:order-1">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
className="lg:object-contain h-auto object-cover bg-[#F2F2F9] w-full"
|
className="lg:object-cover lg:h-full object-cover bg-[#F2F2F9] w-full"
|
||||||
src={foodImage}
|
src={foodImage}
|
||||||
alt={foodName}
|
alt={foodName}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
@@ -155,8 +155,8 @@ function FoodPage({}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative px-6 pt-6 pb-7.5 lg:flex lg:flex-col lg:justify-center lg:h-full lg:min-h-0 bg-container w-full rounded-3xl overflow-hidden not-lg:-translate-y-6 lg:rounded-l-none lg:overflow-y-auto">
|
<div className="relative px-6 pt-6 pb-7.5 lg:flex lg:flex-col lg:h-full lg:min-h-0 bg-container w-full rounded-3xl overflow-hidden not-lg:-translate-y-6 lg:rounded-l-none lg:overflow-y-auto">
|
||||||
<div className="w-full inline-flex justify-between items-center">
|
<div className="w-full inline-flex justify-between items-center shrink-0">
|
||||||
<h5 className="text-base font-bold">{foodName}</h5>
|
<h5 className="text-base font-bold">{foodName}</h5>
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleFavorite}
|
onClick={handleToggleFavorite}
|
||||||
@@ -171,7 +171,8 @@ function FoodPage({}: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 text-xs">
|
<div className="mt-4 lg:mt-0 lg:flex-1 lg:flex lg:flex-col lg:justify-center lg:min-h-0 lg:overflow-y-auto">
|
||||||
|
<div className="text-xs">
|
||||||
<div className="flex items-center gap-1">
|
<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">
|
||||||
@@ -218,8 +219,9 @@ function FoodPage({}: Props) {
|
|||||||
<p className="font-bold">محتویات:</p>
|
<p className="font-bold">محتویات:</p>
|
||||||
<p className="mt-2 leading-6">{content || "-"}</p>
|
<p className="mt-2 leading-6">{content || "-"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 flex justify-between items-center">
|
<div className="mt-12 lg:mt-0 shrink-0 flex justify-between items-center">
|
||||||
<span dir="ltr">{ef(price.toLocaleString("en-US"))} T</span>
|
<span dir="ltr">{ef(price.toLocaleString("en-US"))} T</span>
|
||||||
<motion.div
|
<motion.div
|
||||||
whileTap={{ scale: 1.05 }}
|
whileTap={{ scale: 1.05 }}
|
||||||
|
|||||||
@@ -1,36 +1,5 @@
|
|||||||
import React from 'react'
|
export default function AboutLayout({
|
||||||
import { Metadata } from 'next';
|
children,
|
||||||
import { getAboutData } from '@/lib/api/info/getAboutData';
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return <>{children}</>;
|
||||||
export type AboutPageProps = { name: string }
|
|
||||||
|
|
||||||
type Params = { name: string };
|
|
||||||
|
|
||||||
export const revalidate = 60
|
|
||||||
|
|
||||||
export const dynamicParams = false // or false, to 404 on unknown paths
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
|
||||||
const { name } = await params;
|
|
||||||
const data = await getAboutData(name);
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: data.metadata.title || `About | ${name}`,
|
|
||||||
description: data.metadata.description || `This is the about-us page for ${name}.`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
|
||||||
return [
|
|
||||||
{ name: 'zhivan' },
|
|
||||||
{ name: 'boote' },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function layout({ children }: Readonly<{ children: React.ReactNode; params: Promise<Params> }>) {
|
|
||||||
return (
|
|
||||||
<>{children}</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default layout
|
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
import React from 'react'
|
'use client';
|
||||||
import AboutPage from './AboutPage'
|
|
||||||
|
|
||||||
const page = () => {
|
export { default } from './AboutPage';
|
||||||
return (
|
|
||||||
<AboutPage />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default page
|
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import { useGetFoods } from "@/app/[name]/(Main)/hooks/useMenuData";
|
||||||
import { useRouter } from 'next/navigation';
|
import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData";
|
||||||
import { useTranslation } from 'react-i18next';
|
import Button from "@/components/button/PrimaryButton";
|
||||||
import { ArrowLeft, Trash } from 'iconsax-react';
|
import NotificationBellIcon from "@/components/icons/NotificationBellIcon";
|
||||||
import Image from 'next/image';
|
import MenuItem from "@/components/listview/MenuItem";
|
||||||
import { useCart } from './hook/useCart';
|
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
|
||||||
import CartSummary from './components/CartSummary';
|
import PagerModal from "@/components/pager/PagerModal";
|
||||||
import Prompt from '@/components/utils/Prompt';
|
import Prompt from "@/components/utils/Prompt";
|
||||||
import { useGetFoods } from '@/app/[name]/(Main)/hooks/useMenuData';
|
import { ArrowLeft, Trash } from "iconsax-react";
|
||||||
import MenuItem from '@/components/listview/MenuItem';
|
import Image from "next/image";
|
||||||
import MenuItemRenderer from '@/components/listview/MenuItemRenderer';
|
import { useRouter } from "next/navigation";
|
||||||
import { useGetAbout } from '../about/hooks/useAboutData';
|
import React from "react";
|
||||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetCartItems } from './hooks/useCartData';
|
import { useGetAbout } from "../about/hooks/useAboutData";
|
||||||
import PagerModal from '@/components/pager/PagerModal';
|
import CartSkeleton from "./components/CartSkeleton";
|
||||||
import Button from '@/components/button/PrimaryButton';
|
import CartSummary from "./components/CartSummary";
|
||||||
import NotificationBellIcon from '@/components/icons/NotificationBellIcon';
|
import { useCart } from "./hook/useCart";
|
||||||
import CartSkeleton from './components/CartSkeleton';
|
import { useGetCartItems } from "./hooks/useCartData";
|
||||||
import {
|
import {
|
||||||
cartItemToFood,
|
cartItemToFood,
|
||||||
guestCartItemsToFoods,
|
guestCartItemsToFoods,
|
||||||
hasCartEntries,
|
hasCartEntries,
|
||||||
} from './lib/cartUtils';
|
} from "./lib/cartUtils";
|
||||||
|
|
||||||
const CartPage = () => {
|
const CartPage = () => {
|
||||||
const { t } = useTranslation('parallels', { keyPrefix: 'Cart' });
|
const { t } = useTranslation("parallels", { keyPrefix: "Cart" });
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isSuccess } = useGetProfile();
|
const { isSuccess } = useGetProfile();
|
||||||
const { clearCart, items } = useCart();
|
const { clearCart, items } = useCart();
|
||||||
@@ -35,7 +35,7 @@ const CartPage = () => {
|
|||||||
const [showClearConfirm, setShowClearConfirm] = React.useState(false);
|
const [showClearConfirm, setShowClearConfirm] = React.useState(false);
|
||||||
const [showPagerModal, setShowPagerModal] = React.useState(false);
|
const [showPagerModal, setShowPagerModal] = React.useState(false);
|
||||||
|
|
||||||
const isPremium = aboutData?.data?.plan === 'premium';
|
const isPremium = aboutData?.data?.plan === "premium";
|
||||||
|
|
||||||
const cartFoods = React.useMemo(() => {
|
const cartFoods = React.useMemo(() => {
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
@@ -74,23 +74,25 @@ const CartPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col bg-background'>
|
<div className="overflow-y-auto h-full noscrollbar flex flex-col bg-background">
|
||||||
<div className='grid grid-cols-3 items-center'>
|
<div className="grid grid-cols-3 items-center mt-6">
|
||||||
{isCartEmpty ? (
|
{isCartEmpty ? (
|
||||||
<span></span>
|
<span></span>
|
||||||
) : (
|
) : (
|
||||||
<Trash
|
<Trash
|
||||||
className='cursor-pointer place-self-start'
|
className="cursor-pointer place-self-start"
|
||||||
size='24'
|
size="24"
|
||||||
color='currentColor'
|
color="currentColor"
|
||||||
onClick={() => setShowClearConfirm(true)}
|
onClick={() => setShowClearConfirm(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<h1 className='text-sm2 place-self-center font-medium'>{t('Heading')}</h1>
|
<h1 className="text-sm2 place-self-center font-medium">
|
||||||
|
{t("Heading")}
|
||||||
|
</h1>
|
||||||
<ArrowLeft
|
<ArrowLeft
|
||||||
className='cursor-pointer place-self-end'
|
className="cursor-pointer place-self-end"
|
||||||
size='24'
|
size="24"
|
||||||
color='currentColor'
|
color="currentColor"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,19 +101,23 @@ const CartPage = () => {
|
|||||||
{showInitialCartLoad || guestAwaitingMenu ? (
|
{showInitialCartLoad || guestAwaitingMenu ? (
|
||||||
<CartSkeleton />
|
<CartSkeleton />
|
||||||
) : isCartEmpty ? (
|
) : isCartEmpty ? (
|
||||||
<div className='flex flex-col items-center justify-center gap-4 h-full'>
|
<div className="flex flex-col items-center justify-center gap-4 h-full">
|
||||||
<Image
|
<Image
|
||||||
src='/assets/images/cart.png'
|
src="/assets/images/cart.png"
|
||||||
alt='cart'
|
alt="cart"
|
||||||
width={120}
|
width={120}
|
||||||
height={120}
|
height={120}
|
||||||
className='object-contain'
|
className="object-contain"
|
||||||
/>
|
/>
|
||||||
<div className='flex flex-col items-center gap-2 text-sm2 text-muted-foreground'>
|
<div className="flex flex-col items-center gap-2 text-sm2 text-muted-foreground">
|
||||||
<p>{t('EmptyStateTitle', { defaultValue: 'سبد خرید شما خالی است' })}</p>
|
<p>
|
||||||
<p className='text-xs'>
|
{t("EmptyStateTitle", {
|
||||||
{t('EmptyStateDescription', {
|
defaultValue: "سبد خرید شما خالی است",
|
||||||
defaultValue: 'برای افزودن غذا، به منوی رستوران برگردید.',
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
{t("EmptyStateDescription", {
|
||||||
|
defaultValue: "برای افزودن غذا، به منوی رستوران برگردید.",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,25 +125,33 @@ const CartPage = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 pb-24">
|
<div className="flex flex-col gap-4 pb-24">
|
||||||
{!isPremium && (
|
{!isPremium && (
|
||||||
<div className='bg-container rounded-container p-4 border border-border shadow-sm'>
|
<div className="bg-container rounded-container p-4 border border-border shadow-sm">
|
||||||
<div className='flex items-start gap-3 mb-4'>
|
<div className="flex items-start gap-3 mb-4">
|
||||||
<div className='shrink-0 mt-0.5'>
|
<div className="shrink-0 mt-0.5">
|
||||||
<NotificationBellIcon width={24} height={24} className="currentColor" />
|
<NotificationBellIcon
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="currentColor"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex-1'>
|
<div className="flex-1">
|
||||||
<p className='text-sm font-medium text-foreground mb-1'>
|
<p className="text-sm font-medium text-foreground mb-1">
|
||||||
ثبت سفارش
|
ثبت سفارش
|
||||||
</p>
|
</p>
|
||||||
<p className='text-xs text-muted-foreground leading-5'>
|
<p className="text-xs text-muted-foreground leading-5">
|
||||||
برای ثبت سفارش، لطفاً گارسون را صدا کنید
|
برای ثبت سفارش، لطفاً گارسون را صدا کنید
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowPagerModal(true)}
|
onClick={() => setShowPagerModal(true)}
|
||||||
className='w-full dark:bg-white! dark:text-black! flex items-center justify-center gap-2'
|
className="w-full dark:bg-white! dark:text-black! flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<NotificationBellIcon width={18} height={18} className="text-white dark:text-black!" />
|
<NotificationBellIcon
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
className="text-white dark:text-black!"
|
||||||
|
/>
|
||||||
<span>صدا کردن گارسون</span>
|
<span>صدا کردن گارسون</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,12 +166,18 @@ const CartPage = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={showClearConfirm ? 'fixed inset-0 z-1001' : 'pointer-events-none fixed inset-0 z-1001'}>
|
<div
|
||||||
|
className={
|
||||||
|
showClearConfirm
|
||||||
|
? "fixed inset-0 z-1001"
|
||||||
|
: "pointer-events-none fixed inset-0 z-1001"
|
||||||
|
}
|
||||||
|
>
|
||||||
<Prompt
|
<Prompt
|
||||||
title='پاک کردن سبد خرید'
|
title="پاک کردن سبد خرید"
|
||||||
description='آیا از پاک کردن تمامی آیتمهای سبد خرید اطمینان دارید؟'
|
description="آیا از پاک کردن تمامی آیتمهای سبد خرید اطمینان دارید؟"
|
||||||
textConfirm='بله'
|
textConfirm="بله"
|
||||||
textCancel='خیر'
|
textCancel="خیر"
|
||||||
onConfirm={handleClearCart}
|
onConfirm={handleClearCart}
|
||||||
onCancel={toggleClearConfirm}
|
onCancel={toggleClearConfirm}
|
||||||
visible={showClearConfirm}
|
visible={showClearConfirm}
|
||||||
@@ -165,7 +185,10 @@ const CartPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PagerModal visible={showPagerModal} onClose={() => setShowPagerModal(false)} />
|
<PagerModal
|
||||||
|
visible={showPagerModal}
|
||||||
|
onClose={() => setShowPagerModal(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,43 +20,30 @@ function CategoryImage({
|
|||||||
src,
|
src,
|
||||||
size,
|
size,
|
||||||
alt,
|
alt,
|
||||||
proxyBase,
|
// proxyBase,
|
||||||
}: {
|
}: {
|
||||||
src: string;
|
src: string;
|
||||||
size: number;
|
size: number;
|
||||||
alt: string;
|
alt: string;
|
||||||
proxyBase: string | null;
|
// proxyBase: string | null;
|
||||||
}) {
|
}) {
|
||||||
const isSvg = src.endsWith(".svg");
|
const isSvg = src.endsWith(".svg");
|
||||||
|
|
||||||
if (isSvg) {
|
if (isSvg) {
|
||||||
const isSameOrigin =
|
|
||||||
src.startsWith("/") ||
|
|
||||||
(typeof window !== "undefined" &&
|
|
||||||
new URL(src, window.location.href).origin === window.location.origin);
|
|
||||||
|
|
||||||
const maskUrl = isSameOrigin
|
|
||||||
? src
|
|
||||||
: proxyBase
|
|
||||||
? `${proxyBase}/api/proxy-svg?url=${encodeURIComponent(src)}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (maskUrl) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="shrink-0 bg-primary"
|
className="shrink-0 bg-primary"
|
||||||
style={{
|
style={{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
maskImage: `url(${maskUrl})`,
|
maskImage: `url(${src})`,
|
||||||
WebkitMaskImage: `url(${maskUrl})`,
|
WebkitMaskImage: `url(${src})`,
|
||||||
...SVG_MASK_STYLE,
|
...SVG_MASK_STYLE,
|
||||||
}}
|
}}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -160,7 +147,7 @@ const CategoryScroll = ({
|
|||||||
src={item.avatarUrl || "/assets/images/food-image.png"}
|
src={item.avatarUrl || "/assets/images/food-image.png"}
|
||||||
size={imageSize}
|
size={imageSize}
|
||||||
alt="category image"
|
alt="category image"
|
||||||
proxyBase={proxyBase}
|
// proxyBase={proxyBase}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-foreground text-center">
|
<span className="text-xs text-foreground text-center">
|
||||||
{item.title}
|
{item.title}
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"use client";
|
||||||
|
import CategoryScroll from "@/app/[name]/(Main)/components/CategoryScroll";
|
||||||
|
import MenuFilterDrawer from "@/app/[name]/(Main)/components/MenuFilterDrawer";
|
||||||
|
import MenuSkeleton from "@/app/[name]/(Main)/components/MenuSkeleton";
|
||||||
|
import MenuSortingDrawer from "@/app/[name]/(Main)/components/MenuSortingDrawer";
|
||||||
|
import TextAlignIcon from "@/components/icons/TextAlignIcon";
|
||||||
|
import SearchBox from "@/components/input/SearchBox";
|
||||||
|
import MenuItem from "@/components/listview/MenuItem";
|
||||||
|
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
|
||||||
|
import VerticalScrollView from "@/components/listview/VerticalScrollView";
|
||||||
|
import useToggle from "@/hooks/helpers/useToggle";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Candle2 } from "iconsax-react";
|
||||||
|
import { useQueryState } from "next-usequerystate";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useGetCategories, useGetFoods } from "../hooks/useMenuData";
|
||||||
|
import type { Category, Food } from "../types/Types";
|
||||||
|
|
||||||
|
const sortings = [
|
||||||
|
"PopularityDescending",
|
||||||
|
"RateDescending",
|
||||||
|
"PriceAscending",
|
||||||
|
"PriceDescending",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const MenuIndex = () => {
|
||||||
|
const { data: foodsData, isLoading: foodsLoading } = useGetFoods();
|
||||||
|
const { data: categoriesData, isLoading: categoriesLoading } =
|
||||||
|
useGetCategories();
|
||||||
|
const foods = useMemo<Food[]>(() => foodsData?.data || [], [foodsData?.data]);
|
||||||
|
const categories = useMemo<Category[]>(
|
||||||
|
() => categoriesData?.data || [],
|
||||||
|
[categoriesData?.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading = foodsLoading || categoriesLoading;
|
||||||
|
const { t: tCommon } = useTranslation("common");
|
||||||
|
const { t: tMenu } = useTranslation("menu", { keyPrefix: "Menu" });
|
||||||
|
const { state: filterModal, toggle: toggleFilterModal } = useToggle();
|
||||||
|
const { state: sortingModal, toggle: toggleSortingModal } = useToggle();
|
||||||
|
const [sorting, setSorting] = useQueryState("sortBy", { defaultValue: "0" });
|
||||||
|
const [search, setSearch] = useQueryState("q", { defaultValue: "" });
|
||||||
|
const [selectedCategory, setSelectedCategory] = useQueryState("category", {
|
||||||
|
defaultValue: "0",
|
||||||
|
});
|
||||||
|
const [selectedIngredients, setSelectedIngredients] = useQueryState(
|
||||||
|
"ingredients",
|
||||||
|
{ defaultValue: "" },
|
||||||
|
);
|
||||||
|
const [selectedDeliveryId, setSelectedDeliveryId] = useQueryState(
|
||||||
|
"delivery",
|
||||||
|
{ defaultValue: "0" },
|
||||||
|
);
|
||||||
|
const smallCategoriesRef: React.RefObject<HTMLDivElement | null> =
|
||||||
|
useRef(null);
|
||||||
|
const wrapperRef: React.RefObject<HTMLDivElement | null> = useRef(null);
|
||||||
|
const [smallCategoriesVisible, setSmallCategoriesVisibility] =
|
||||||
|
useState(false);
|
||||||
|
const [isInitialMount, setIsInitialMount] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialMount) {
|
||||||
|
setSearch(null);
|
||||||
|
setSelectedIngredients(null);
|
||||||
|
setSelectedDeliveryId(null);
|
||||||
|
setSorting(null);
|
||||||
|
setIsInitialMount(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isInitialMount,
|
||||||
|
setSearch,
|
||||||
|
setSelectedIngredients,
|
||||||
|
setSelectedDeliveryId,
|
||||||
|
setSorting,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (categoriesData?.data && selectedCategory === "0") {
|
||||||
|
setSelectedCategory(categoriesData?.data?.[0]?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedCategory, categoriesData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || !wrapperRef.current) return;
|
||||||
|
|
||||||
|
const scrollContainer = wrapperRef.current.parentElement?.parentElement;
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!smallCategoriesRef.current) return;
|
||||||
|
setSmallCategoriesVisibility(
|
||||||
|
scrollContainer.scrollTop >= smallCategoriesRef.current.offsetTop,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollContainer.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollContainer.removeEventListener("scroll", handleScroll);
|
||||||
|
};
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
|
const updateSearch = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
},
|
||||||
|
[setSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCategory = useCallback(
|
||||||
|
(categoryId: string) => {
|
||||||
|
setSelectedCategory(categoryId);
|
||||||
|
},
|
||||||
|
[setSelectedCategory],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortingIndex = Number(sorting);
|
||||||
|
const activeSortingIndex = Number.isNaN(sortingIndex) ? 0 : sortingIndex;
|
||||||
|
const activeSortingKey = sortings[activeSortingIndex] ?? sortings[0];
|
||||||
|
const activeSortingLabel = tMenu(
|
||||||
|
`MenuSortingDrawer.Options.${activeSortingKey}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeSorting = (index: number) => {
|
||||||
|
setSorting(() => String(index));
|
||||||
|
toggleSortingModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeSelectedIngredients = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setSelectedIngredients(value);
|
||||||
|
},
|
||||||
|
[setSelectedIngredients],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeSelectedDelivery = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setSelectedDeliveryId(() => value);
|
||||||
|
},
|
||||||
|
[setSelectedDeliveryId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredReceiptItems = useMemo(() => {
|
||||||
|
if (!foods.length) return [];
|
||||||
|
const lowerSearch = search.toLowerCase();
|
||||||
|
const lowerIngredients = selectedIngredients
|
||||||
|
? selectedIngredients.toLowerCase()
|
||||||
|
: "";
|
||||||
|
const selectedCatId = selectedCategory === "0" ? null : selectedCategory;
|
||||||
|
|
||||||
|
const filtered = foods.filter((item) => {
|
||||||
|
const matchesCategory = !selectedCatId || item.category === selectedCatId;
|
||||||
|
const itemName = item.title;
|
||||||
|
const matchesSearch =
|
||||||
|
!search || itemName.toLowerCase().includes(lowerSearch);
|
||||||
|
|
||||||
|
const matchesIngredients =
|
||||||
|
!selectedIngredients ||
|
||||||
|
(() => {
|
||||||
|
if (
|
||||||
|
item.content &&
|
||||||
|
Array.isArray(item.content) &&
|
||||||
|
item.content.length > 0
|
||||||
|
) {
|
||||||
|
return item.content.some((content: string) =>
|
||||||
|
content.toLowerCase().includes(lowerIngredients),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return item.desc.toLowerCase().includes(lowerIngredients);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const matchesDelivery =
|
||||||
|
selectedDeliveryId === "0" ||
|
||||||
|
(selectedDeliveryId === "pickup" && item.pickupServe) ||
|
||||||
|
(selectedDeliveryId === "inPlace" && item.inPlaceServe);
|
||||||
|
|
||||||
|
return (
|
||||||
|
matchesCategory &&
|
||||||
|
matchesSearch &&
|
||||||
|
matchesIngredients &&
|
||||||
|
matchesDelivery
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortingKey = sortings[Number(sorting)] || sortings[0];
|
||||||
|
|
||||||
|
return [...filtered].sort((a, b) => {
|
||||||
|
switch (sortingKey) {
|
||||||
|
case "PopularityDescending":
|
||||||
|
case "RateDescending":
|
||||||
|
return 0;
|
||||||
|
case "PriceAscending":
|
||||||
|
return (a.price ?? 0) - (b.price ?? 0);
|
||||||
|
case "PriceDescending":
|
||||||
|
return (b.price ?? 0) - (a.price ?? 0);
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
selectedCategory,
|
||||||
|
search,
|
||||||
|
selectedIngredients,
|
||||||
|
selectedDeliveryId,
|
||||||
|
foods,
|
||||||
|
sorting,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <MenuSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-4 items-center pt-8 mb-8"
|
||||||
|
ref={wrapperRef}
|
||||||
|
>
|
||||||
|
<div className="w-full">
|
||||||
|
<SearchBox
|
||||||
|
value={search}
|
||||||
|
placeholder={tCommon("SearchPlaceholder")}
|
||||||
|
onChange={updateSearch}
|
||||||
|
/>
|
||||||
|
<CategoryScroll
|
||||||
|
categories={categories}
|
||||||
|
selectedCategory={selectedCategory}
|
||||||
|
onSelect={updateCategory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="w-full">
|
||||||
|
<div
|
||||||
|
className="flex gap-2 justify-between items-center relative"
|
||||||
|
ref={smallCategoriesRef}
|
||||||
|
>
|
||||||
|
<span className="sm:text-base text-sm font-medium max-w-[113px] sm:max-w-auto">
|
||||||
|
{selectedCategory === "0"
|
||||||
|
? ""
|
||||||
|
: categories.find((c) => c.id === selectedCategory)?.title || ""}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex min-w-[247px] gap-2 justify-around items-center">
|
||||||
|
<button
|
||||||
|
onClick={toggleFilterModal}
|
||||||
|
className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-[7px]"
|
||||||
|
>
|
||||||
|
<Candle2 className="stroke-foreground" size={16} />
|
||||||
|
<span className="text-xs leading-5 font-medium">
|
||||||
|
{tMenu("MenuFilterDrawer.Label")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleSortingModal}
|
||||||
|
className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<TextAlignIcon className="text-foreground" />
|
||||||
|
<span className="text-xs leading-5 font-medium">
|
||||||
|
{activeSortingLabel}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{
|
||||||
|
y: smallCategoriesVisible ? 0 : -50,
|
||||||
|
opacity: smallCategoriesVisible ? 1 : 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
y: smallCategoriesVisible ? 0 : -50,
|
||||||
|
opacity: smallCategoriesVisible ? 1 : 0,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
className={clsx(
|
||||||
|
"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",
|
||||||
|
!smallCategoriesVisible && "pointer-events-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CategoryScroll
|
||||||
|
categories={categories}
|
||||||
|
selectedCategory={selectedCategory}
|
||||||
|
onSelect={updateCategory}
|
||||||
|
variant="small"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<VerticalScrollView className="mt-5! overflow-y-auto h-full">
|
||||||
|
{filteredReceiptItems.length === 0 ? (
|
||||||
|
<div className="text-center text-foreground/60 py-8">
|
||||||
|
{foodsData ? "غذایی یافت نشد" : "در حال بارگذاری..."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredReceiptItems.map((food) => (
|
||||||
|
<MenuItemRenderer key={food.id}>
|
||||||
|
<MenuItem food={food} />
|
||||||
|
</MenuItemRenderer>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</VerticalScrollView>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MenuFilterDrawer
|
||||||
|
visible={filterModal}
|
||||||
|
onClose={toggleFilterModal}
|
||||||
|
selectedIngredients={selectedIngredients}
|
||||||
|
selectedDeliveryId={selectedDeliveryId}
|
||||||
|
onIngredientsChange={changeSelectedIngredients}
|
||||||
|
onDeliveryChange={changeSelectedDelivery}
|
||||||
|
onApply={() => {}}
|
||||||
|
tMenu={tMenu}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MenuSortingDrawer
|
||||||
|
visible={sortingModal}
|
||||||
|
onClose={toggleSortingModal}
|
||||||
|
sortings={sortings}
|
||||||
|
activeIndex={activeSortingIndex}
|
||||||
|
onSelect={changeSorting}
|
||||||
|
tMenu={tMenu}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MenuIndex;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { hasAuthToken } from "@/lib/api/func";
|
||||||
|
|
||||||
export const useGetFoods = () => {
|
export const useGetFoods = () => {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
@@ -26,5 +27,6 @@ export const useGetNotificationsCount = () => {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["notifications-count"],
|
queryKey: ["notifications-count"],
|
||||||
queryFn: api.getNotificationsCount,
|
queryFn: api.getNotificationsCount,
|
||||||
|
enabled: hasAuthToken(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
+38
-245
@@ -1,253 +1,46 @@
|
|||||||
'use client';
|
import type { Metadata, Viewport } from "next";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { notFound } from "next/navigation";
|
||||||
import SearchBox from "@/components/input/SearchBox";
|
import { getRestaurant } from "@/app/[name]/lib/getRestaurant";
|
||||||
import TextAlignIcon from "@/components/icons/TextAlignIcon";
|
import {
|
||||||
import VerticalScrollView from "@/components/listview/VerticalScrollView";
|
buildRestaurantMetadata,
|
||||||
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
|
buildRestaurantViewport,
|
||||||
import MenuItem from "@/components/listview/MenuItem";
|
} from "@/app/[name]/lib/restaurantMetadata";
|
||||||
import { Candle2 } from "iconsax-react";
|
import MenuIndex from "./components/MenuIndex";
|
||||||
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 MenuFilterDrawer from "@/app/[name]/(Main)/components/MenuFilterDrawer";
|
|
||||||
import MenuSortingDrawer from "@/app/[name]/(Main)/components/MenuSortingDrawer";
|
|
||||||
import MenuSkeleton from "@/app/[name]/(Main)/components/MenuSkeleton";
|
|
||||||
import { useGetCategories, useGetFoods } from "./hooks/useMenuData";
|
|
||||||
import type { Food, Category } from "./types/Types";
|
|
||||||
|
|
||||||
const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const;
|
type PageParams = { name: string };
|
||||||
|
|
||||||
const MenuIndex = () => {
|
export async function generateMetadata({
|
||||||
const { data: foodsData, isLoading: foodsLoading } = useGetFoods();
|
params,
|
||||||
const { data: categoriesData, isLoading: categoriesLoading } = useGetCategories();
|
}: {
|
||||||
const foods = useMemo<Food[]>(() => foodsData?.data || [], [foodsData?.data]);
|
params: Promise<PageParams>;
|
||||||
const categories = useMemo<Category[]>(() => categoriesData?.data || [], [categoriesData?.data]);
|
}): Promise<Metadata> {
|
||||||
|
const { name } = await params;
|
||||||
const isLoading = foodsLoading || categoriesLoading;
|
return buildRestaurantMetadata(name);
|
||||||
const { t: tCommon } = useTranslation('common');
|
|
||||||
const { t: tMenu } = useTranslation('menu', { keyPrefix: "Menu" });
|
|
||||||
const { state: filterModal, toggle: toggleFilterModal } = useToggle();
|
|
||||||
const { state: sortingModal, toggle: toggleSortingModal } = useToggle();
|
|
||||||
const [sorting, setSorting] = useQueryState('sortBy', { defaultValue: '0' });
|
|
||||||
const [search, setSearch] = useQueryState("q", { defaultValue: '' });
|
|
||||||
const [selectedCategory, setSelectedCategory] = useQueryState('category', { defaultValue: '0' });
|
|
||||||
const [selectedIngredients, setSelectedIngredients] = useQueryState('ingredients', { defaultValue: '' });
|
|
||||||
const [selectedDeliveryId, setSelectedDeliveryId] = useQueryState('delivery', { defaultValue: '0' });
|
|
||||||
const smallCategoriesRef: React.RefObject<HTMLDivElement | null> = useRef(null);
|
|
||||||
const wrapperRef: React.RefObject<HTMLDivElement | null> = useRef(null);
|
|
||||||
const [smallCategoriesVisible, setSmallCategoriesVisibility] = useState(false);
|
|
||||||
const [isInitialMount, setIsInitialMount] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isInitialMount) {
|
|
||||||
setSearch(null);
|
|
||||||
// category را نگه میداریم تا در بک زدن حفظ شود
|
|
||||||
// setSelectedCategory(null);
|
|
||||||
setSelectedIngredients(null);
|
|
||||||
setSelectedDeliveryId(null);
|
|
||||||
setSorting(null);
|
|
||||||
setIsInitialMount(false);
|
|
||||||
}
|
|
||||||
}, [isInitialMount, setSearch, setSelectedIngredients, setSelectedDeliveryId, setSorting]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('selectedCategory', selectedCategory);
|
|
||||||
|
|
||||||
if (categoriesData?.data && selectedCategory === '0') {
|
|
||||||
setSelectedCategory(categoriesData?.data?.[0]?.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
export async function generateViewport({
|
||||||
}, [selectedCategory, categoriesData])
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<PageParams>;
|
||||||
const onScroll = useCallback(() => {
|
}): Promise<Viewport> {
|
||||||
if (!wrapperRef?.current?.parentElement?.parentElement?.scrollTop || !smallCategoriesRef.current?.offsetTop) return;
|
const { name } = await params;
|
||||||
|
return buildRestaurantViewport(name);
|
||||||
if (wrapperRef.current.parentElement?.parentElement.scrollTop >= smallCategoriesRef.current.offsetTop) {
|
|
||||||
setSmallCategoriesVisibility(true);
|
|
||||||
} else {
|
|
||||||
setSmallCategoriesVisibility(false);
|
|
||||||
}
|
|
||||||
}, [wrapperRef, smallCategoriesRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!wrapperRef.current) return;
|
|
||||||
|
|
||||||
const parent = wrapperRef.current.parentElement?.parentElement;
|
|
||||||
if (!parent) return;
|
|
||||||
|
|
||||||
parent.addEventListener('scroll', onScroll);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
parent.removeEventListener('scroll', onScroll);
|
|
||||||
}
|
|
||||||
}, [onScroll]);
|
|
||||||
|
|
||||||
|
|
||||||
const updateSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
}, [setSearch]);
|
|
||||||
|
|
||||||
const updateCategory = useCallback((categoryId: string) => {
|
|
||||||
setSelectedCategory(categoryId);
|
|
||||||
}, [setSelectedCategory]);
|
|
||||||
|
|
||||||
const sortingIndex = Number(sorting);
|
|
||||||
const activeSortingIndex = Number.isNaN(sortingIndex) ? 0 : sortingIndex;
|
|
||||||
const activeSortingKey = sortings[activeSortingIndex] ?? sortings[0];
|
|
||||||
const activeSortingLabel = tMenu(`MenuSortingDrawer.Options.${activeSortingKey}`);
|
|
||||||
|
|
||||||
const changeSorting = (index: number) => {
|
|
||||||
setSorting(() => String(index));
|
|
||||||
toggleSortingModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeSelectedIngredients = useCallback((value: string) => {
|
|
||||||
setSelectedIngredients(value);
|
|
||||||
}, [setSelectedIngredients]);
|
|
||||||
|
|
||||||
const changeSelectedDelivery = useCallback((value: string) => {
|
|
||||||
setSelectedDeliveryId(() => value);
|
|
||||||
}, [setSelectedDeliveryId]);
|
|
||||||
|
|
||||||
const filteredReceiptItems = useMemo(() => {
|
|
||||||
if (!foods.length) return [];
|
|
||||||
const lowerSearch = search.toLowerCase();
|
|
||||||
const lowerIngredients = selectedIngredients ? selectedIngredients.toLowerCase() : "";
|
|
||||||
const selectedCatId = selectedCategory === '0' ? null : selectedCategory;
|
|
||||||
|
|
||||||
const filtered = foods.filter((item) => {
|
|
||||||
const matchesCategory = !selectedCatId || item.category === selectedCatId;
|
|
||||||
const itemName = item.title;
|
|
||||||
const matchesSearch =
|
|
||||||
!search || itemName.toLowerCase().includes(lowerSearch);
|
|
||||||
|
|
||||||
const matchesIngredients = !selectedIngredients ||
|
|
||||||
(() => {
|
|
||||||
// ابتدا در محتویات جستجو میکنیم
|
|
||||||
if (item.content && Array.isArray(item.content) && item.content.length > 0) {
|
|
||||||
return item.content.some((content: string) =>
|
|
||||||
content.toLowerCase().includes(lowerIngredients)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return item.desc.toLowerCase().includes(lowerIngredients);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const matchesDelivery = selectedDeliveryId === "0" ||
|
|
||||||
(selectedDeliveryId === "pickup" && item.pickupServe) ||
|
|
||||||
(selectedDeliveryId === "inPlace" && item.inPlaceServe);
|
|
||||||
|
|
||||||
return matchesCategory && matchesSearch && matchesIngredients && matchesDelivery;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortingKey = sortings[Number(sorting)] || sortings[0];
|
|
||||||
|
|
||||||
return [...filtered].sort((a, b) => {
|
|
||||||
switch (sortingKey) {
|
|
||||||
case "PopularityDescending":
|
|
||||||
case "RateDescending":
|
|
||||||
return 0;
|
|
||||||
case "PriceAscending":
|
|
||||||
return (a.price ?? 0) - (b.price ?? 0);
|
|
||||||
case "PriceDescending":
|
|
||||||
return (b.price ?? 0) - (a.price ?? 0);
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [selectedCategory, search, selectedIngredients, selectedDeliveryId, foods, sorting]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <MenuSkeleton />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
export default async function Page({
|
||||||
<div className="flex flex-col gap-4 items-center pt-8 mb-8" ref={wrapperRef}>
|
params,
|
||||||
<div className="w-full">
|
}: {
|
||||||
<SearchBox value={search} placeholder={tCommon('SearchPlaceholder')} onChange={updateSearch} />
|
params: Promise<PageParams>;
|
||||||
<CategoryScroll
|
}) {
|
||||||
categories={categories}
|
const { name } = await params;
|
||||||
selectedCategory={selectedCategory}
|
|
||||||
onSelect={updateCategory}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="w-full">
|
try {
|
||||||
<div className="flex gap-2 justify-between items-center relative" ref={smallCategoriesRef} >
|
await getRestaurant(name);
|
||||||
<span className="sm:text-base text-sm font-medium">
|
} catch (error) {
|
||||||
{selectedCategory === '0' ? '' : categories.find(c => c.id === selectedCategory)?.title || ''}
|
if (error instanceof Error && error.message === "RESTAURANT_NOT_FOUND") {
|
||||||
</span>
|
notFound();
|
||||||
<div className="inline-flex min-w-[247px] gap-2 justify-around items-center">
|
}
|
||||||
<button onClick={toggleFilterModal} className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-[7px]">
|
}
|
||||||
<Candle2 className="stroke-foreground" size={16} />
|
|
||||||
<span className="text-xs leading-5 font-medium">{tMenu('MenuFilterDrawer.Label')}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={toggleSortingModal}
|
|
||||||
className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<TextAlignIcon className="text-foreground" />
|
|
||||||
<span className="text-xs leading-5 font-medium">{activeSortingLabel}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
return <MenuIndex />;
|
||||||
initial={{ y: smallCategoriesVisible ? 0 : -50, opacity: smallCategoriesVisible ? 1 : 0 }}
|
}
|
||||||
animate={{ y: smallCategoriesVisible ? 0 : -50, opacity: smallCategoriesVisible ? 1 : 0 }}
|
|
||||||
transition={{ duration: .1 }}
|
|
||||||
className={clsx(
|
|
||||||
'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"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<VerticalScrollView className="mt-5! overflow-y-auto h-full">
|
|
||||||
{filteredReceiptItems.length === 0 ? (
|
|
||||||
<div className="text-center text-foreground/60 py-8">
|
|
||||||
{foodsData ? 'غذایی یافت نشد' : 'در حال بارگذاری...'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredReceiptItems.map((food) => (
|
|
||||||
<MenuItemRenderer key={food.id}>
|
|
||||||
<MenuItem food={food} />
|
|
||||||
</MenuItemRenderer>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</VerticalScrollView>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<MenuFilterDrawer
|
|
||||||
visible={filterModal}
|
|
||||||
onClose={toggleFilterModal}
|
|
||||||
selectedIngredients={selectedIngredients}
|
|
||||||
selectedDeliveryId={selectedDeliveryId}
|
|
||||||
onIngredientsChange={changeSelectedIngredients}
|
|
||||||
onDeliveryChange={changeSelectedDelivery}
|
|
||||||
onApply={() => { }}
|
|
||||||
tMenu={tMenu}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MenuSortingDrawer
|
|
||||||
visible={sortingModal}
|
|
||||||
onClose={toggleSortingModal}
|
|
||||||
sortings={sortings}
|
|
||||||
activeIndex={activeSortingIndex}
|
|
||||||
onSelect={changeSorting}
|
|
||||||
tMenu={tMenu}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MenuIndex;
|
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ export const useUpdateAddress = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: updateAddress,
|
mutationFn: updateAddress,
|
||||||
onSuccess: () => {
|
onSuccess: (_data, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["address", variables.id] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import AnimatedBottomSheet from '@/components/bottomsheet/AnimatedBottomSheet';
|
|||||||
import Button from '@/components/button/PrimaryButton';
|
import Button from '@/components/button/PrimaryButton';
|
||||||
import { AddressDisplay } from './AddressDisplay';
|
import { AddressDisplay } from './AddressDisplay';
|
||||||
import { AddressForm } from './AddressForm';
|
import { AddressForm } from './AddressForm';
|
||||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
import { Address, NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||||
import { Location } from 'iconsax-react';
|
import { Location } from 'iconsax-react';
|
||||||
|
|
||||||
interface AddressDetailsModalProps {
|
interface AddressDetailsModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||||
|
existingAddress?: Address | null;
|
||||||
formData: {
|
formData: {
|
||||||
title: string;
|
title: string;
|
||||||
addressDetails: string;
|
addressDetails: string;
|
||||||
@@ -26,6 +27,7 @@ interface AddressDetailsModalProps {
|
|||||||
export const AddressDetailsModal = ({
|
export const AddressDetailsModal = ({
|
||||||
visible,
|
visible,
|
||||||
selectedAddress,
|
selectedAddress,
|
||||||
|
existingAddress,
|
||||||
formData,
|
formData,
|
||||||
isPending,
|
isPending,
|
||||||
editMode = false,
|
editMode = false,
|
||||||
@@ -41,13 +43,13 @@ export const AddressDetailsModal = ({
|
|||||||
blurOpacity={null}
|
blurOpacity={null}
|
||||||
noBlur
|
noBlur
|
||||||
visible={visible}
|
visible={visible}
|
||||||
title='آدرس'
|
title={editMode ? 'ویرایش آدرس' : 'آدرس'}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
overrideClassName='bg-container!'
|
overrideClassName='bg-container!'
|
||||||
>
|
>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<div className='px-4'>
|
<div className='px-4'>
|
||||||
<AddressDisplay selectedAddress={selectedAddress} />
|
<AddressDisplay selectedAddress={selectedAddress} existingAddress={existingAddress} />
|
||||||
{editMode && onChangePosition && (
|
{editMode && onChangePosition && (
|
||||||
<div className='mt-4 mb-6 flex justify-end'>
|
<div className='mt-4 mb-6 flex justify-end'>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
import { Address, NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||||
import { formatSelectedAddress } from '../utils/formatAddress';
|
import { formatAddress, formatSelectedAddress } from '../utils/formatAddress';
|
||||||
|
|
||||||
interface AddressDisplayProps {
|
interface AddressDisplayProps {
|
||||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||||
|
existingAddress?: Address | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddressDisplay = ({ selectedAddress }: AddressDisplayProps) => {
|
export const AddressDisplay = ({ selectedAddress, existingAddress }: AddressDisplayProps) => {
|
||||||
|
const displayText = selectedAddress
|
||||||
|
? formatSelectedAddress(selectedAddress)
|
||||||
|
: existingAddress
|
||||||
|
? formatAddress(existingAddress)
|
||||||
|
: 'آدرس را به صورت دستی وارد کنید';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='px-4 mt-2 bg-container'>
|
<div className='px-4 mt-2 bg-container'>
|
||||||
<span className='text-sm'>
|
<span className='text-sm'>
|
||||||
{!selectedAddress ? (
|
{displayText}
|
||||||
'آدرس را به صورت دستی وارد کنید'
|
|
||||||
) : (
|
|
||||||
formatSelectedAddress(selectedAddress)
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<hr className='border border-border mt-3 mb-10' />
|
<hr className='border border-border mt-3 mb-10' />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ArrowLeft } from 'iconsax-react';
|
|||||||
import { useAuthStore } from '@/zustand/authStore';
|
import { useAuthStore } from '@/zustand/authStore';
|
||||||
import { AddressDetailsModal } from './components/AddressDetailsModal';
|
import { AddressDetailsModal } from './components/AddressDetailsModal';
|
||||||
import { useAddressForm } from './hooks/useAddressForm';
|
import { useAddressForm } from './hooks/useAddressForm';
|
||||||
import { useGetAddressById } from '../hooks/useAddressData';
|
import { useGetAddresses } from '../hooks/useAddressData';
|
||||||
import { useGetAbout } from '@/app/[name]/(Main)/about/hooks/useAboutData';
|
import { useGetAbout } from '@/app/[name]/(Main)/about/hooks/useAboutData';
|
||||||
|
|
||||||
function OrderTrackingPage() {
|
function OrderTrackingPage() {
|
||||||
@@ -15,9 +15,14 @@ function OrderTrackingPage() {
|
|||||||
const id = params.get('id');
|
const id = params.get('id');
|
||||||
const editMode = !!id;
|
const editMode = !!id;
|
||||||
|
|
||||||
const { data: addressResponse, isLoading: isLoadingAddress } = useGetAddressById(id);
|
const { data: addressesResponse, isLoading: isLoadingAddresses } = useGetAddresses();
|
||||||
const { data: aboutData } = useGetAbout();
|
const { data: aboutData } = useGetAbout();
|
||||||
const address = addressResponse?.data;
|
|
||||||
|
const address = useMemo(() => {
|
||||||
|
if (!editMode || !id) return null;
|
||||||
|
return addressesResponse?.data?.find((item) => item.id === id) ?? null;
|
||||||
|
}, [editMode, id, addressesResponse?.data]);
|
||||||
|
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const restaurant = aboutData?.data;
|
const restaurant = aboutData?.data;
|
||||||
|
|
||||||
@@ -55,7 +60,7 @@ function OrderTrackingPage() {
|
|||||||
addressData: address || null,
|
addressData: address || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (editMode && isLoadingAddress) {
|
if (editMode && isLoadingAddresses) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-gray-50 flex items-center justify-center">
|
<div className="fixed inset-0 bg-gray-50 flex items-center justify-center">
|
||||||
<p className="text-sm2 text-disabled-text">در حال بارگذاری...</p>
|
<p className="text-sm2 text-disabled-text">در حال بارگذاری...</p>
|
||||||
@@ -63,8 +68,22 @@ function OrderTrackingPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editMode && !address) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-gray-50 flex flex-col items-center justify-center gap-4 px-4">
|
||||||
|
<p className="text-sm2 text-disabled-text text-center">آدرس مورد نظر یافت نشد</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="p-2 rounded-full bg-container shadow-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={24} className="stroke-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!restaurant) {
|
if (!restaurant) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,6 +101,7 @@ function OrderTrackingPage() {
|
|||||||
<AddressDetailsModal
|
<AddressDetailsModal
|
||||||
visible
|
visible
|
||||||
selectedAddress={null}
|
selectedAddress={null}
|
||||||
|
existingAddress={address}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
import { Address, NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||||
|
|
||||||
|
export const formatAddress = (address: Address): string => {
|
||||||
|
return `${address.address}، ${address.city}، ${address.province}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const formatSelectedAddress = (selectedAddress: NominatimReverseGeocodingResponse | null): string => {
|
export const formatSelectedAddress = (selectedAddress: NominatimReverseGeocodingResponse | null): string => {
|
||||||
if (!selectedAddress) {
|
if (!selectedAddress) {
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ function UserAddressesPage({ }: Props) {
|
|||||||
return `${address.address}، ${address.city}، ${address.province}`;
|
return `${address.address}، ${address.city}، ${address.province}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getEditHref = (addressId: string) => {
|
||||||
|
const base = `address/new?id=${addressId}`;
|
||||||
|
return redirect ? `${base}&redirect=${encodeURIComponent(redirect)}` : base;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col'>
|
<div className='overflow-y-auto h-full noscrollbar flex flex-col'>
|
||||||
<div className='grid grid-cols-3 items-center'>
|
<div className='grid grid-cols-3 items-center'>
|
||||||
@@ -134,7 +139,7 @@ function UserAddressesPage({ }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href={`address/new?id=${address.id}`}>
|
<Link href={getEditHref(address.id)}>
|
||||||
<Button className='bg-background! text-foreground! flex justify-center items-center gap-2 text-xs p-0! size-8!'>
|
<Button className='bg-background! text-foreground! flex justify-center items-center gap-2 text-xs p-0! size-8!'>
|
||||||
<Edit2 className='stroke-foreground size-5 mb-0.5' />
|
<Edit2 className='stroke-foreground size-5 mb-0.5' />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import * as api from "../service/ProfileService";
|
import * as api from "../service/ProfileService";
|
||||||
import { getToken } from "@/lib/api/func";
|
import { hasAuthToken } from "@/lib/api/func";
|
||||||
|
|
||||||
export const useGetProfile = () => {
|
export const useGetProfile = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["profile"],
|
queryKey: ["profile"],
|
||||||
queryFn: api.getProfile,
|
queryFn: api.getProfile,
|
||||||
retry: false,
|
retry: false,
|
||||||
enabled: !!getToken(),
|
enabled: hasAuthToken(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ActiveChecker from "@/components/ActiveChecker";
|
||||||
|
import CartChecker from "@/components/CartChecker";
|
||||||
|
import RestaurantHeadManager from "@/components/RestaurantHeadManager";
|
||||||
|
import RestaurantNotFoundGuard from "@/components/RestaurantNotFoundGuard";
|
||||||
|
import PreferenceWrapper from "@/components/wrapper/PreferenceWrapper";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RestaurantLayoutClient({ children }: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RestaurantHeadManager />
|
||||||
|
<RestaurantNotFoundGuard>
|
||||||
|
<PreferenceWrapper>{children}</PreferenceWrapper>
|
||||||
|
</RestaurantNotFoundGuard>
|
||||||
|
<CartChecker />
|
||||||
|
<ActiveChecker />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { fetchWithTimeout } from "@/lib/helpers/fetchWithTimeout";
|
import { fetchWithTimeout } from "@/lib/helpers/fetchWithTimeout";
|
||||||
|
import { PWA_ICON_192 } from "@/lib/helpers/pwaIcons";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
|
|||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
const FETCH_TIMEOUT_MS = 5000;
|
const FETCH_TIMEOUT_MS = 5000;
|
||||||
const DEFAULT_ICON = "/icons/web-app-manifest-192x192.png";
|
const DEFAULT_ICON = PWA_ICON_192;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|||||||
+5
-109
@@ -1,113 +1,9 @@
|
|||||||
import PreferenceWrapper from '@/components/wrapper/PreferenceWrapper'
|
import RestaurantLayoutClient from "./RestaurantLayoutClient";
|
||||||
import React from 'react'
|
|
||||||
import type { Metadata, Viewport } from 'next'
|
|
||||||
import { getRestaurant } from './lib/getRestaurant'
|
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
import CartChecker from '@/components/CartChecker'
|
|
||||||
import ActiveChecker from '@/components/ActiveChecker'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export default function Layout({
|
||||||
export const revalidate = 0
|
|
||||||
|
|
||||||
type LayoutParams = { name: string }
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
|
||||||
params
|
|
||||||
}: {
|
|
||||||
params: Promise<LayoutParams>
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const { name } = await params
|
|
||||||
const defaultIcon = "/icons/web-app-manifest-192x192.png"
|
|
||||||
const defaultIcon512 = "/icons/web-app-manifest-512x512.png"
|
|
||||||
|
|
||||||
try {
|
|
||||||
const restaurant = await getRestaurant(name)
|
|
||||||
const title = restaurant.seoTitle || restaurant.name
|
|
||||||
const logo = restaurant.logo
|
|
||||||
|
|
||||||
let iconUrl = defaultIcon
|
|
||||||
let icon192Url = defaultIcon
|
|
||||||
let icon512Url = defaultIcon512
|
|
||||||
|
|
||||||
if (logo && logo.trim() !== "") {
|
|
||||||
iconUrl = `/${name}/api/logo?url=${encodeURIComponent(logo)}&size=192`
|
|
||||||
icon192Url = iconUrl
|
|
||||||
icon512Url = `/${name}/api/logo?url=${encodeURIComponent(logo)}&size=512`
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
description: restaurant.seoDescription || restaurant.description || `منوی ${restaurant.name}`,
|
|
||||||
manifest: `/${name}/manifest.webmanifest`,
|
|
||||||
icons: {
|
|
||||||
icon: [
|
|
||||||
{ url: iconUrl },
|
|
||||||
{ url: icon192Url, sizes: "192x192", type: "image/png" },
|
|
||||||
{ url: icon512Url, sizes: "512x512", type: "image/png" },
|
|
||||||
],
|
|
||||||
shortcut: iconUrl,
|
|
||||||
apple: iconUrl,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
title: `${name} - Dashboard`,
|
|
||||||
description: `PWA dashboard for ${name}`,
|
|
||||||
manifest: `/${name}/manifest.webmanifest`,
|
|
||||||
icons: {
|
|
||||||
icon: [
|
|
||||||
{ url: "/icons/web-app-manifest-192x192.png" },
|
|
||||||
{ url: "/icons/web-app-manifest-192x192.png", sizes: "192x192", type: "image/png" },
|
|
||||||
{ url: "/icons/web-app-manifest-512x512.png", sizes: "512x512", type: "image/png" },
|
|
||||||
],
|
|
||||||
shortcut: "/icons/web-app-manifest-192x192.png",
|
|
||||||
apple: "/icons/web-app-manifest-192x192.png",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateViewport({
|
|
||||||
params
|
|
||||||
}: {
|
|
||||||
params: Promise<LayoutParams>
|
|
||||||
}): Promise<Viewport> {
|
|
||||||
try {
|
|
||||||
const { name } = await params
|
|
||||||
const restaurant = await getRestaurant(name)
|
|
||||||
|
|
||||||
return {
|
|
||||||
themeColor: restaurant.menuColor || '#F4F5F9'
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
themeColor: '#F4F5F9'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Layout({
|
|
||||||
children,
|
children,
|
||||||
params
|
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
params: Promise<LayoutParams>
|
}) {
|
||||||
}): Promise<React.ReactNode> {
|
return <RestaurantLayoutClient>{children}</RestaurantLayoutClient>;
|
||||||
const { name } = await params
|
|
||||||
|
|
||||||
try {
|
|
||||||
await getRestaurant(name)
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message === 'RESTAURANT_NOT_FOUND') {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PreferenceWrapper>{children}</PreferenceWrapper>
|
|
||||||
<CartChecker />
|
|
||||||
<ActiveChecker />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { PWA_ICON_192, PWA_ICON_512 } from "@/lib/helpers/pwaIcons";
|
||||||
|
import { getRestaurant } from "./getRestaurant";
|
||||||
|
|
||||||
|
export async function buildRestaurantMetadata(name: string): Promise<Metadata> {
|
||||||
|
const defaultIcon = PWA_ICON_192;
|
||||||
|
const defaultIcon512 = PWA_ICON_512;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const restaurant = await getRestaurant(name);
|
||||||
|
const title = restaurant.seoTitle || restaurant.name;
|
||||||
|
const logo = restaurant.logo;
|
||||||
|
|
||||||
|
let iconUrl = defaultIcon;
|
||||||
|
let icon192Url = defaultIcon;
|
||||||
|
let icon512Url = defaultIcon512;
|
||||||
|
|
||||||
|
if (logo && logo.trim() !== "") {
|
||||||
|
iconUrl = `/${name}/api/logo?url=${encodeURIComponent(logo)}&size=192`;
|
||||||
|
icon192Url = iconUrl;
|
||||||
|
icon512Url = `/${name}/api/logo?url=${encodeURIComponent(logo)}&size=512`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
restaurant.seoDescription ||
|
||||||
|
restaurant.description ||
|
||||||
|
`منوی ${restaurant.name}`,
|
||||||
|
manifest: `/${name}/manifest.webmanifest`,
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: iconUrl },
|
||||||
|
{ url: icon192Url, sizes: "192x192", type: "image/png" },
|
||||||
|
{ url: icon512Url, sizes: "512x512", type: "image/png" },
|
||||||
|
],
|
||||||
|
shortcut: iconUrl,
|
||||||
|
apple: iconUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
title: `${name} - Dashboard`,
|
||||||
|
description: `PWA dashboard for ${name}`,
|
||||||
|
manifest: `/${name}/manifest.webmanifest`,
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: PWA_ICON_192 },
|
||||||
|
{ url: PWA_ICON_192, sizes: "192x192", type: "image/png" },
|
||||||
|
{ url: PWA_ICON_512, sizes: "512x512", type: "image/png" },
|
||||||
|
],
|
||||||
|
shortcut: PWA_ICON_192,
|
||||||
|
apple: PWA_ICON_192,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildRestaurantViewport(name: string): Promise<Viewport> {
|
||||||
|
try {
|
||||||
|
const restaurant = await getRestaurant(name);
|
||||||
|
return {
|
||||||
|
themeColor: restaurant.menuColor || "#F4F5F9",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
themeColor: "#F4F5F9",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { buildPwaManifestIcons } from "@/lib/helpers/pwaIcons";
|
||||||
import { getRestaurant } from "../lib/getRestaurant";
|
import { getRestaurant } from "../lib/getRestaurant";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
function buildManifestIcons(name: string, logo?: string | null) {
|
function buildManifestIcons(name: string, logo?: string | null) {
|
||||||
const default192 = "/icons/web-app-manifest-192x192.png";
|
|
||||||
const default512 = "/icons/web-app-manifest-512x512.png";
|
|
||||||
|
|
||||||
if (logo && logo.trim() !== "") {
|
if (logo && logo.trim() !== "") {
|
||||||
const logo192 = `/${name}/api/logo?url=${encodeURIComponent(logo)}&size=192`;
|
const logo192 = `/${name}/api/logo?url=${encodeURIComponent(logo)}&size=192`;
|
||||||
const logo512 = `/${name}/api/logo?url=${encodeURIComponent(logo)}&size=512`;
|
const logo512 = `/${name}/api/logo?url=${encodeURIComponent(logo)}&size=512`;
|
||||||
|
return buildPwaManifestIcons(logo192, logo512);
|
||||||
return [
|
|
||||||
{
|
|
||||||
src: logo192,
|
|
||||||
sizes: "192x192",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "any",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: logo512,
|
|
||||||
sizes: "512x512",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "any",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return buildPwaManifestIcons();
|
||||||
{
|
|
||||||
src: default192,
|
|
||||||
sizes: "192x192",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "any",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: default512,
|
|
||||||
sizes: "512x512",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "any",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const VALID_SIZES = [192, 512] as const;
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ size: string }> },
|
||||||
|
) {
|
||||||
|
const { size: sizeParam } = await params;
|
||||||
|
const size = parseInt(sizeParam, 10);
|
||||||
|
|
||||||
|
if (!VALID_SIZES.includes(size as (typeof VALID_SIZES)[number])) {
|
||||||
|
return new NextResponse('Invalid size', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const svgPath = path.join(process.cwd(), 'public', 'icon0.svg');
|
||||||
|
const svgBuffer = await readFile(svgPath);
|
||||||
|
const pngBuffer = await sharp(svgBuffer).resize(size, size).png().toBuffer();
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(pngBuffer), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating PWA icon:', error);
|
||||||
|
return new NextResponse('Failed to generate icon', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -386,3 +386,13 @@ html[data-theme="dark"] {
|
|||||||
.game-explanation {
|
.game-explanation {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-delivery {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: var(--primary);
|
||||||
|
-webkit-mask: url("/assets/images/fast-delivery.svg") no-repeat center / contain;
|
||||||
|
mask: url("/assets/images/fast-delivery.svg") no-repeat center / contain;
|
||||||
|
}
|
||||||
+2
-1
@@ -7,11 +7,11 @@ 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 PwaServiceWorker from "@/components/PwaServiceWorker";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
description: 'Webapp dashboard',
|
description: 'Webapp dashboard',
|
||||||
manifest: '/manifest.json',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
@@ -54,6 +54,7 @@ export default async function RootLayout({
|
|||||||
locale={locale}
|
locale={locale}
|
||||||
resources={resources}>
|
resources={resources}>
|
||||||
<ThemeColorSetter />
|
<ThemeColorSetter />
|
||||||
|
<PwaServiceWorker />
|
||||||
<div id="root" className="h-svh overflow-hidden">
|
<div id="root" className="h-svh overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function PwaServiceWorker() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
|
||||||
|
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
|
||||||
|
// Registration can fail on unsupported contexts (e.g. in-app browsers)
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useGetAbout } from "@/app/[name]/(Main)/about/hooks/useAboutData";
|
||||||
|
import { PWA_ICON_192 } from "@/lib/helpers/pwaIcons";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
function upsertMeta(name: string, content: string, attribute: "name" | "property" = "name") {
|
||||||
|
let element = document.querySelector(`meta[${attribute}="${name}"]`);
|
||||||
|
if (!element) {
|
||||||
|
element = document.createElement("meta");
|
||||||
|
element.setAttribute(attribute, name);
|
||||||
|
document.head.appendChild(element);
|
||||||
|
}
|
||||||
|
element.setAttribute("content", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertLink(rel: string, href: string) {
|
||||||
|
let element = document.querySelector(`link[rel="${rel}"]`) as HTMLLinkElement | null;
|
||||||
|
if (!element) {
|
||||||
|
element = document.createElement("link");
|
||||||
|
element.rel = rel;
|
||||||
|
document.head.appendChild(element);
|
||||||
|
}
|
||||||
|
element.href = href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RestaurantHeadManager() {
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const { data } = useGetAbout();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!name || !data?.data) return;
|
||||||
|
|
||||||
|
const restaurant = data.data;
|
||||||
|
document.title = restaurant.seoTitle || restaurant.name;
|
||||||
|
|
||||||
|
upsertMeta(
|
||||||
|
"description",
|
||||||
|
restaurant.seoDescription ||
|
||||||
|
restaurant.description ||
|
||||||
|
`منوی ${restaurant.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const themeColor = restaurant.menuColor || "#F4F5F9";
|
||||||
|
upsertMeta("theme-color", themeColor);
|
||||||
|
|
||||||
|
upsertLink("manifest", `/${name}/manifest.webmanifest`);
|
||||||
|
|
||||||
|
const logo = restaurant.logo?.trim();
|
||||||
|
const iconUrl =
|
||||||
|
logo && logo !== ""
|
||||||
|
? `/${name}/api/logo?url=${encodeURIComponent(logo)}&size=192`
|
||||||
|
: PWA_ICON_192;
|
||||||
|
|
||||||
|
upsertLink("icon", iconUrl);
|
||||||
|
upsertLink("apple-touch-icon", iconUrl);
|
||||||
|
}, [data, name]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useGetAbout } from "@/app/[name]/(Main)/about/hooks/useAboutData";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RestaurantNotFoundGuard({ children }: Props) {
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const { isError, isLoading, isFetching } = useGetAbout();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || isFetching) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[50vh] items-center justify-center px-6 text-center">
|
||||||
|
<p className="text-disabled-text">رستوران یافت نشد</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import clsx from "clsx";
|
||||||
import { motion, Variants } from 'framer-motion';
|
import { motion, Variants } from "framer-motion";
|
||||||
import { Icon, SearchNormal } from 'iconsax-react';
|
import { Icon, SearchNormal } from "iconsax-react";
|
||||||
import clsx from 'clsx';
|
import { ChevronDown } from "lucide-react";
|
||||||
import { ChevronDown } from 'lucide-react';
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
export interface ComboboxOption {
|
export interface ComboboxOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,12 +22,25 @@ type Props = {
|
|||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
icon?: React.ElementType;
|
icon?: React.ElementType;
|
||||||
onSelectionChange: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, index: number) => void,
|
onSelectionChange: (
|
||||||
} & React.HTMLAttributes<HTMLDivElement>
|
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||||
|
index: number,
|
||||||
|
) => void;
|
||||||
|
} & React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedId, searchable = true, onSelectionChange, ...props }: Props) {
|
function Combobox({
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
icon: Icon,
|
||||||
|
expanded,
|
||||||
|
selectedId,
|
||||||
|
searchable = true,
|
||||||
|
onSelectionChange,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
const [expand, setExpand] = useState(expanded);
|
const [expand, setExpand] = useState(expanded);
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,74 +51,69 @@ function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedI
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (expand) {
|
if (expand) {
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [expand]);
|
}, [expand]);
|
||||||
|
|
||||||
const toggleExpand = () => {
|
const toggleExpand = () => {
|
||||||
setExpand((prev) => !prev);
|
setExpand((prev) => !prev);
|
||||||
setSearchValue('');
|
setSearchValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const searchChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearchValue(e.target.value);
|
setSearchValue(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSelection = (e: React.MouseEvent<HTMLDivElement, MouseEvent>, index: number) => {
|
const setSelection = (
|
||||||
|
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
e.stopPropagation(); // prevent toggle
|
e.stopPropagation(); // prevent toggle
|
||||||
onSelectionChange(e, index);
|
onSelectionChange(e, index);
|
||||||
setExpand(false);
|
setExpand(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedOption = options.find((x) => x.id === selectedId);
|
const selectedOption = options.find((x) => x.id === selectedId);
|
||||||
const activeIcon = selectedOption?.icon && React.createElement(selectedOption.icon, {
|
const activeIcon =
|
||||||
|
selectedOption?.icon &&
|
||||||
|
React.createElement(selectedOption.icon, {
|
||||||
size: 16,
|
size: 16,
|
||||||
color: "currentColor",
|
color: "currentColor",
|
||||||
className: "inline-block mr-1 text-primary dark:text-foreground"
|
className: "inline-block mr-1 text-primary dark:text-foreground",
|
||||||
});
|
});
|
||||||
const activeImage = selectedOption?.imagePath && (
|
const activeImage = selectedOption?.imagePath && (
|
||||||
<Image
|
<span className="icon-delivery mr-1" aria-hidden />
|
||||||
src={selectedOption.imagePath}
|
|
||||||
alt=""
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
unoptimized
|
|
||||||
className="inline-block mr-1 dark:brightness-0 dark:invert"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
const variants: Variants = {
|
const variants: Variants = {
|
||||||
collapse: {
|
collapse: {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
scale: 0.95,
|
scale: 0.95,
|
||||||
pointerEvents: 'none', // disable clicks
|
pointerEvents: "none", // disable clicks
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.1,
|
duration: 0.1,
|
||||||
ease: 'easeInOut'
|
ease: "easeInOut",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
expand: {
|
expand: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
pointerEvents: 'auto', // re-enable clicks
|
pointerEvents: "auto", // re-enable clicks
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.1,
|
duration: 0.1,
|
||||||
ease: 'easeInOut'
|
ease: "easeInOut",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={boxRef}
|
<div
|
||||||
className={clsx(
|
ref={boxRef}
|
||||||
"relative",
|
className={clsx("relative", props.className ?? "")}
|
||||||
props.className ?? '')}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -114,7 +121,7 @@ function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedI
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={toggleExpand}
|
onClick={toggleExpand}
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-controls=''
|
aria-controls=""
|
||||||
aria-expanded={expand}
|
aria-expanded={expand}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
@@ -129,24 +136,31 @@ function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedI
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="w-full text-sm2 flex items-center justify-start gap-3">
|
<div className="w-full text-sm2 flex items-center justify-start gap-3">
|
||||||
{
|
{activeImage
|
||||||
activeImage ? (
|
? activeImage
|
||||||
activeImage
|
: activeIcon
|
||||||
) : activeIcon ? (
|
? activeIcon
|
||||||
activeIcon
|
: Icon && (
|
||||||
) : (
|
<Icon
|
||||||
Icon && <Icon size={16} color="currentColor" className="inline-block mr-1 text-primary dark:text-foreground" />
|
size={16}
|
||||||
)
|
color="currentColor"
|
||||||
}
|
className="inline-block mr-1 text-primary dark:text-foreground"
|
||||||
<span className={clsx(
|
/>
|
||||||
selectedOption?.title ? "mt-0.5" : "mt-1 text-sm font-light"
|
)}
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
selectedOption?.title ? "mt-0.5" : "mt-1 text-sm font-light",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedOption?.title ?? placeholder}
|
{selectedOption?.title ?? placeholder}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChevronDown size={20} data-expand={expand} className="transition-all stroke-foreground duration-200 data-[expand=true]:rotate-x-180" />
|
<ChevronDown
|
||||||
|
size={20}
|
||||||
|
data-expand={expand}
|
||||||
|
className="transition-all stroke-foreground duration-200 data-[expand=true]:rotate-x-180"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -160,10 +174,9 @@ function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedI
|
|||||||
animate={expand ? "expand" : "collapse"}
|
animate={expand ? "expand" : "collapse"}
|
||||||
variants={variants}
|
variants={variants}
|
||||||
>
|
>
|
||||||
|
{searchable && (
|
||||||
{searchable &&
|
|
||||||
<div className="w-full flex gap-2 border-b border-border px-3 items-center">
|
<div className="w-full flex gap-2 border-b border-border px-3 items-center">
|
||||||
<SearchNormal size={16} className='stroke-gray-400' />
|
<SearchNormal size={16} className="stroke-gray-400" />
|
||||||
<input
|
<input
|
||||||
placeholder="جستجو ..."
|
placeholder="جستجو ..."
|
||||||
className="w-full outline-none text-sm2 pb-2 pt-3"
|
className="w-full outline-none text-sm2 pb-2 pt-3"
|
||||||
@@ -171,8 +184,8 @@ function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedI
|
|||||||
value={searchValue}
|
value={searchValue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
<div className='p-1'>
|
<div className="p-1">
|
||||||
{options
|
{options
|
||||||
.filter((v) => v.title?.includes(searchValue))
|
.filter((v) => v.title?.includes(searchValue))
|
||||||
.map((v, i) => (
|
.map((v, i) => (
|
||||||
@@ -185,23 +198,20 @@ function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedI
|
|||||||
role="option"
|
role="option"
|
||||||
aria-selected
|
aria-selected
|
||||||
>
|
>
|
||||||
{
|
{v?.imagePath ? (
|
||||||
v?.imagePath ? (
|
<span className="icon-delivery mr-1" aria-hidden />
|
||||||
<Image
|
) : (
|
||||||
src={v.imagePath}
|
v?.icon &&
|
||||||
alt=""
|
React.createElement(v.icon, {
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
unoptimized
|
|
||||||
className="inline-block mr-1 dark:brightness-0 dark:invert"
|
|
||||||
/>
|
|
||||||
) : v?.icon && React.createElement(v.icon, {
|
|
||||||
size: 16,
|
size: 16,
|
||||||
color: "currentColor",
|
color: "currentColor",
|
||||||
className: "inline-block mr-1 text-primary dark:text-foreground"
|
className:
|
||||||
|
"inline-block mr-1 text-primary dark:text-foreground",
|
||||||
})
|
})
|
||||||
}
|
)}
|
||||||
<span className="text-sm2 tracking-[0.13px] leading-5 w-full mt-1">{v.title}</span>
|
<span className="text-sm2 tracking-[0.13px] leading-5 w-full mt-1">
|
||||||
|
{v.title}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -210,5 +220,4 @@ function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedI
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Combobox;
|
||||||
export default Combobox
|
|
||||||
|
|||||||
@@ -69,7 +69,16 @@ const isIOS = () => {
|
|||||||
|
|
||||||
const isStandalone = () => {
|
const isStandalone = () => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
return ('standalone' in window.navigator) && (window.navigator as { standalone?: boolean }).standalone === true;
|
|
||||||
|
const isIOSStandalone =
|
||||||
|
('standalone' in window.navigator) &&
|
||||||
|
(window.navigator as { standalone?: boolean }).standalone === true;
|
||||||
|
|
||||||
|
const isDisplayModeStandalone =
|
||||||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
window.matchMedia('(display-mode: fullscreen)').matches;
|
||||||
|
|
||||||
|
return isIOSStandalone || isDisplayModeStandalone;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeState }: Props) {
|
function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeState }: Props) {
|
||||||
@@ -114,18 +123,23 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS
|
|||||||
setIsIOSDevice(ios);
|
setIsIOSDevice(ios);
|
||||||
setIsPWAInstalled(standalone);
|
setIsPWAInstalled(standalone);
|
||||||
|
|
||||||
if (!ios) {
|
|
||||||
const handleBeforeInstallPrompt = (e: Event) => {
|
const handleBeforeInstallPrompt = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAppInstalled = () => {
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
setIsPWAInstalled(true);
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||||
|
window.addEventListener('appinstalled', handleAppInstalled);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||||
|
window.removeEventListener('appinstalled', handleAppInstalled);
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleInstallPWA = async () => {
|
const handleInstallPWA = async () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import useToggle from '@/hooks/helpers/useToggle';
|
|||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import usePreference from '@/hooks/helpers/usePreference';
|
import usePreference from '@/hooks/helpers/usePreference';
|
||||||
import PagerModal from '../pager/PagerModal';
|
import PagerModal from '../pager/PagerModal';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
type Props = {} & React.AllHTMLAttributes<HTMLBodyElementEventMap>
|
type Props = {} & React.AllHTMLAttributes<HTMLBodyElementEventMap>
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ function ClientMenuRouteWrapper({ children }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const location = usePathname();
|
const location = usePathname();
|
||||||
|
const hideBottomNav = pathname.endsWith('/cart');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (menuState) {
|
if (menuState) {
|
||||||
@@ -66,9 +68,11 @@ function ClientMenuRouteWrapper({ children }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{!hideBottomNav && (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-45 px-4">
|
<div className="fixed bottom-0 left-0 right-0 z-45 px-4">
|
||||||
<BottomNavBar onPagerClick={togglePager} />
|
<BottomNavBar onPagerClick={togglePager} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
<SideMenu
|
<SideMenu
|
||||||
@@ -80,7 +84,10 @@ function ClientMenuRouteWrapper({ children }: Props) {
|
|||||||
|
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className="relative 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"
|
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>
|
||||||
|
|||||||
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
import usePreference from '@/hooks/helpers/usePreference';
|
import usePreference from '@/hooks/helpers/usePreference';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, 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';
|
||||||
|
|
||||||
|
function getSplashStorageKey(restaurantSlug: string) {
|
||||||
|
return `splash-seen:${restaurantSlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSplashFingerprint(logo?: string | null, name?: string | null) {
|
||||||
|
return `${logo ?? ''}|${name ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
@@ -24,6 +33,9 @@ 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();
|
||||||
|
|
||||||
@@ -34,7 +46,14 @@ function PreferenceWrapper({ children }: Props) {
|
|||||||
|
|
||||||
const handleSplashDismiss = useCallback(() => {
|
const handleSplashDismiss = useCallback(() => {
|
||||||
setShowSplash(false);
|
setShowSplash(false);
|
||||||
}, []);
|
|
||||||
|
if (!restaurantSlug) return;
|
||||||
|
|
||||||
|
const logo = aboutData?.data?.logo || cachedLogo;
|
||||||
|
const name = aboutData?.data?.name || cachedName;
|
||||||
|
const fingerprint = getSplashFingerprint(logo, name);
|
||||||
|
localStorage.setItem(getSplashStorageKey(restaurantSlug), fingerprint);
|
||||||
|
}, [aboutData?.data?.logo, aboutData?.data?.name, cachedLogo, cachedName, restaurantSlug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -49,16 +68,15 @@ function PreferenceWrapper({ children }: Props) {
|
|||||||
if (savedLogo) setCachedLogo(savedLogo);
|
if (savedLogo) setCachedLogo(savedLogo);
|
||||||
if (savedName) setCachedName(savedName);
|
if (savedName) setCachedName(savedName);
|
||||||
|
|
||||||
const navigationEntries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
|
if (!restaurantSlug) return;
|
||||||
const isReload = navigationEntries.length > 0 && navigationEntries[0].type === 'reload';
|
|
||||||
const isFirstLoadInSession = !sessionStorage.getItem('app-loaded') || isReload;
|
|
||||||
|
|
||||||
if (isFirstLoadInSession) {
|
const currentFingerprint = getSplashFingerprint(savedLogo, savedName);
|
||||||
|
const seenFingerprint = localStorage.getItem(getSplashStorageKey(restaurantSlug));
|
||||||
|
|
||||||
|
if (seenFingerprint !== currentFingerprint) {
|
||||||
setShowSplash(true);
|
setShowSplash(true);
|
||||||
}
|
}
|
||||||
|
}, [restaurantSlug]);
|
||||||
sessionStorage.setItem('app-loaded', 'true');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (aboutData?.data?.menuColor) {
|
if (aboutData?.data?.menuColor) {
|
||||||
@@ -76,6 +94,19 @@ function PreferenceWrapper({ children }: Props) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [aboutData?.data?.menuColor]);
|
}, [aboutData?.data?.menuColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!restaurantSlug || showSplash) return;
|
||||||
|
|
||||||
|
const logo = aboutData?.data?.logo || cachedLogo;
|
||||||
|
const name = aboutData?.data?.name || cachedName;
|
||||||
|
const fingerprint = getSplashFingerprint(logo, name);
|
||||||
|
const seenFingerprint = localStorage.getItem(getSplashStorageKey(restaurantSlug));
|
||||||
|
|
||||||
|
if (seenFingerprint === '|' && fingerprint !== '|') {
|
||||||
|
localStorage.setItem(getSplashStorageKey(restaurantSlug), fingerprint);
|
||||||
|
}
|
||||||
|
}, [aboutData?.data?.logo, aboutData?.data?.name, cachedLogo, cachedName, restaurantSlug, showSplash]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', nightMode ? 'dark' : 'light');
|
document.documentElement.setAttribute('data-theme', nightMode ? 'dark' : 'light');
|
||||||
}, [nightMode]);
|
}, [nightMode]);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useParams } from 'next/navigation'
|
|||||||
import { toast } from '@/components/Toast'
|
import { toast } from '@/components/Toast'
|
||||||
import StepOtp from './StepOtp'
|
import StepOtp from './StepOtp'
|
||||||
import { extractErrorMessage } from '@/lib/func'
|
import { extractErrorMessage } from '@/lib/func'
|
||||||
|
import { useCountdown } from '@/hooks/useCountdown'
|
||||||
|
|
||||||
type StepEnterNumberProps = {
|
type StepEnterNumberProps = {
|
||||||
pending?: boolean
|
pending?: boolean
|
||||||
@@ -29,6 +30,10 @@ function StepEnterNumber(_props: StepEnterNumberProps = {}) {
|
|||||||
const { name } = useParams()
|
const { name } = useParams()
|
||||||
const [step, setStep] = useState(AUTH_STEP.ENTER_NUMBER)
|
const [step, setStep] = useState(AUTH_STEP.ENTER_NUMBER)
|
||||||
const { mutate: mutateOtpRequest, isPending } = useOtpRequest()
|
const { mutate: mutateOtpRequest, isPending } = useOtpRequest()
|
||||||
|
const { timerRunning, secondsLeft, restart } = useCountdown(
|
||||||
|
step === AUTH_STEP.ENTER_OTP,
|
||||||
|
120
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
const formik = useFormik<LoginOTPRequestType>({
|
const formik = useFormik<LoginOTPRequestType>({
|
||||||
@@ -56,6 +61,22 @@ function StepEnterNumber(_props: StepEnterNumberProps = {}) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleResendOtp = () => {
|
||||||
|
if (timerRunning || !name) return
|
||||||
|
|
||||||
|
mutateOtpRequest(
|
||||||
|
{ phone: formik.values.phone, slug: name as string },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast(t('otp_sent'), 'success')
|
||||||
|
restart()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast(extractErrorMessage(error), 'error')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -100,7 +121,14 @@ function StepEnterNumber(_props: StepEnterNumberProps = {}) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === AUTH_STEP.ENTER_OTP && (
|
{step === AUTH_STEP.ENTER_OTP && (
|
||||||
<StepOtp phone={formik.values.phone} slug={formik.values.slug} />
|
<StepOtp
|
||||||
|
phone={formik.values.phone}
|
||||||
|
slug={(name as string) || formik.values.slug}
|
||||||
|
timerRunning={timerRunning}
|
||||||
|
secondsLeft={secondsLeft}
|
||||||
|
onResend={handleResendOtp}
|
||||||
|
isResendPending={isPending}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ type Props = {
|
|||||||
pending?: boolean | undefined
|
pending?: boolean | undefined
|
||||||
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'id'>
|
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'id'>
|
||||||
|
|
||||||
|
const formatCountdown = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
if (mins > 0) {
|
||||||
|
return `${mins}:${String(secs).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return String(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
function StepEnterOtp ({
|
function StepEnterOtp ({
|
||||||
onChange,
|
onChange,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -87,10 +96,7 @@ function StepEnterOtp ({
|
|||||||
<div>
|
<div>
|
||||||
{timerRunning ? (
|
{timerRunning ? (
|
||||||
<div>
|
<div>
|
||||||
{t('OTP.TimerRunning').replace(
|
{t('OTP.TimerRunning', { time: formatCountdown(secondsLeft) })}
|
||||||
'{seconds}',
|
|
||||||
String(secondsLeft)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -12,14 +12,28 @@ import { useReceiptStore } from '@/zustand/receiptStore'
|
|||||||
import { useBulkCart } from '@/app/[name]/(Main)/cart/hooks/useCartData'
|
import { useBulkCart } from '@/app/[name]/(Main)/cart/hooks/useCartData'
|
||||||
import { setRefreshToken, setToken } from '@/lib/api/func'
|
import { setRefreshToken, setToken } from '@/lib/api/func'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { AUTH_PAGE_ELEMENT } from '@/enums'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
phone: string,
|
phone: string
|
||||||
slug: string
|
slug: string
|
||||||
|
timerRunning: boolean
|
||||||
|
secondsLeft: number
|
||||||
|
onResend: () => void
|
||||||
|
isResendPending?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const StepOtp = ({ phone, slug }: Props) => {
|
const formatCountdown = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
if (mins > 0) {
|
||||||
|
return `${mins}:${String(secs).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return String(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepOtp = ({ phone, slug, timerRunning, secondsLeft, onResend, isResendPending }: Props) => {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const redirectUrl = searchParams.get('redirect')
|
const redirectUrl = searchParams.get('redirect')
|
||||||
const { t } = useTranslation('auth')
|
const { t } = useTranslation('auth')
|
||||||
@@ -89,6 +103,7 @@ const StepOtp = ({ phone, slug }: Props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className='mt-6 sm:mt-8 dltr otp'>
|
<div className='mt-6 sm:mt-8 dltr otp'>
|
||||||
<OTPInput
|
<OTPInput
|
||||||
shouldAutoFocus
|
shouldAutoFocus
|
||||||
@@ -106,6 +121,33 @@ const StepOtp = ({ phone, slug }: Props) => {
|
|||||||
className='w-full h-[45px] sm:h-[50px] flex-1 mx-1 sm:mx-2 bg-container border border-border text-foreground outline-0 rounded-[10px] text-center text-sm' />
|
className='w-full h-[45px] sm:h-[50px] flex-1 mx-1 sm:mx-2 bg-container border border-border text-foreground outline-0 rounded-[10px] text-center text-sm' />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='text-center w-full pt-6 text-xs'>
|
||||||
|
{timerRunning ? (
|
||||||
|
<div>
|
||||||
|
{t('OTP.TimerRunning', { time: formatCountdown(secondsLeft) })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{t('OTP.TimerOut')}
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
disabled={isResendPending}
|
||||||
|
id={AUTH_PAGE_ELEMENT.RESEND_OTP}
|
||||||
|
className={clsx(
|
||||||
|
'px-1 transition-colors duration-200',
|
||||||
|
!isResendPending
|
||||||
|
? 'text-primary cursor-pointer'
|
||||||
|
: 'text-neutral-200 cursor-auto'
|
||||||
|
)}
|
||||||
|
onClick={onResend}
|
||||||
|
>
|
||||||
|
{t('OTP.TimerReset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className='mt-10 dark:bg-white dark:text-black hover:dark:bg-white'
|
className='mt-10 dark:bg-white dark:text-black hover:dark:bg-white'
|
||||||
@@ -116,7 +158,7 @@ const StepOtp = ({ phone, slug }: Props) => {
|
|||||||
>
|
>
|
||||||
{t('OTP.ButtonSubmit')}
|
{t('OTP.ButtonSubmit')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { REFRESH_TOKEN_NAME, TOKEN_NAME } from "@/config/const";
|
import { REFRESH_TOKEN_NAME, TOKEN_NAME } from "@/config/const";
|
||||||
|
|
||||||
|
export const hasAuthToken = (): boolean => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return !!localStorage.getItem(TOKEN_NAME);
|
||||||
|
};
|
||||||
|
|
||||||
export const getToken = async (): Promise<string | null> => {
|
export const getToken = async (): Promise<string | null> => {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
return localStorage.getItem(TOKEN_NAME);
|
return localStorage.getItem(TOKEN_NAME);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export const PWA_ICON_192 = '/api/pwa-icon/192';
|
||||||
|
export const PWA_ICON_512 = '/api/pwa-icon/512';
|
||||||
|
|
||||||
|
export function buildPwaManifestIcons(logo192?: string, logo512?: string) {
|
||||||
|
const icon192 = logo192 ?? PWA_ICON_192;
|
||||||
|
const icon512 = logo512 ?? PWA_ICON_512;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
src: icon192,
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: icon512,
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: icon192,
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: icon512,
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
"Heading": "Enter Activation Code",
|
"Heading": "Enter Activation Code",
|
||||||
"Description": "Please enter the 6-digit code sent to the number {{phoneNumber}}.",
|
"Description": "Please enter the 6-digit code sent to the number {{phoneNumber}}.",
|
||||||
"Label": "",
|
"Label": "",
|
||||||
"TimerRunning": "{seconds} seconds left to receive the code",
|
"TimerRunning": "Resend available in {{time}}",
|
||||||
"TimerOut": "Didn’t receive the code?",
|
"TimerOut": "Didn’t receive the code?",
|
||||||
"TimerReset": "Try again",
|
"TimerReset": "Resend",
|
||||||
"ButtonSubmit": "Next"
|
"ButtonSubmit": "Next"
|
||||||
},
|
},
|
||||||
"NewPassword": {
|
"NewPassword": {
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"Heading": "کد فعالسازی را وارد کنید",
|
"Heading": "کد فعالسازی را وارد کنید",
|
||||||
"Description": "کد ۵ رقمی ارسال شده به شماره {{phoneNumber}} را وارد نمایید.",
|
"Description": "کد ۵ رقمی ارسال شده به شماره {{phoneNumber}} را وارد نمایید.",
|
||||||
"Label": "",
|
"Label": "",
|
||||||
"TimerRunning": "{seconds} ثانیه دیگر تا دریافت کد",
|
"TimerRunning": "ارسال مجدد تا {{time}} دیگر",
|
||||||
"TimerOut": "کد را دریافت نکردید؟",
|
"TimerOut": "کد را دریافت نکردید؟",
|
||||||
"TimerReset": "دوباره امتحان کنید",
|
"TimerReset": "ارسال مجدد",
|
||||||
"ButtonSubmit": "بعدی"
|
"ButtonSubmit": "بعدی"
|
||||||
},
|
},
|
||||||
"NewPassword": {
|
"NewPassword": {
|
||||||
|
|||||||
Reference in New Issue
Block a user