This commit is contained in:
@@ -8,7 +8,6 @@ import { getToken } from "@/lib/api/func";
|
|||||||
import { ef } from "@/lib/helpers/utfNumbers";
|
import { ef } from "@/lib/helpers/utfNumbers";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ArrowLeft, Clock, Cup, Heart, TruckTick } from "iconsax-react";
|
import { ArrowLeft, Clock, Cup, Heart, TruckTick } from "iconsax-react";
|
||||||
import Image from "next/image";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useGetProfile } from "../../(Profile)/profile/hooks/userProfileData";
|
import { useGetProfile } from "../../(Profile)/profile/hooks/userProfileData";
|
||||||
@@ -131,13 +130,14 @@ 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-[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="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">
|
||||||
<Image
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
className="lg:object-contain h-auto object-cover bg-[#F2F2F9]"
|
<img
|
||||||
|
className="lg:object-contain h-auto object-cover bg-[#F2F2F9] w-full"
|
||||||
src={foodImage}
|
src={foodImage}
|
||||||
alt={foodName}
|
alt={foodName}
|
||||||
width={1000}
|
onError={(event) => {
|
||||||
height={100}
|
event.currentTarget.src = "/assets/images/no-image.png";
|
||||||
priority
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-4 left-0 pe-5.5 ps-7 w-full inline-flex justify-between items-center">
|
<div className="absolute top-4 left-0 pe-5.5 ps-7 w-full inline-flex justify-between items-center">
|
||||||
<div className="bg-container whitespace-nowrap rounded-[34px] py-1.5 px-4 w-min text-xs text-disabled2 dark:text-disabled-text font-bold">
|
<div className="bg-container whitespace-nowrap rounded-[34px] py-1.5 px-4 w-min text-xs text-disabled2 dark:text-disabled-text font-bold">
|
||||||
|
|||||||
@@ -70,7 +70,19 @@ function CategoryImage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <img src={src} width={size} height={size} alt={alt} loading="lazy" />;
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
alt={alt}
|
||||||
|
loading="lazy"
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.src = "/assets/images/food-image.png";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Variant = "large" | "small";
|
type Variant = "large" | "small";
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ function OrdersIndex() {
|
|||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
alt={foodName}
|
alt={foodName}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex-1 min-w-0'>
|
<div className='flex-1 min-w-0'>
|
||||||
@@ -203,6 +204,7 @@ function OrdersIndex() {
|
|||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
alt={foodName}
|
alt={foodName}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex-1 min-w-0'>
|
<div className='flex-1 min-w-0'>
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { fetchWithTimeout } from "@/lib/helpers/fetchWithTimeout";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
const FETCH_TIMEOUT_MS = 5000;
|
||||||
|
const DEFAULT_ICON = "/icons/web-app-manifest-192x192.png";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ name: string }> },
|
{ params }: { params: Promise<{ name: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { name } = await params;
|
await params;
|
||||||
console.log("name", name);
|
|
||||||
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const logoUrl = searchParams.get("url");
|
const logoUrl = searchParams.get("url");
|
||||||
@@ -21,29 +24,27 @@ export async function GET(
|
|||||||
return new NextResponse("Logo URL is required", { status: 400 });
|
return new NextResponse("Logo URL is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// دانلود تصویر
|
const imageResponse = await fetchWithTimeout(logoUrl, {
|
||||||
const imageResponse = await fetch(logoUrl);
|
timeoutMs: FETCH_TIMEOUT_MS,
|
||||||
|
});
|
||||||
if (!imageResponse.ok) {
|
if (!imageResponse.ok) {
|
||||||
return new NextResponse("Failed to fetch logo", { status: 404 });
|
return NextResponse.redirect(new URL(DEFAULT_ICON, request.url), 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||||
|
|
||||||
// دریافت ابعاد تصویر
|
|
||||||
const metadata = await sharp(imageBuffer).metadata();
|
const metadata = await sharp(imageBuffer).metadata();
|
||||||
const { width, height } = metadata;
|
const { width, height } = metadata;
|
||||||
|
|
||||||
if (!width || !height) {
|
if (!width || !height) {
|
||||||
return new NextResponse("Invalid image", { status: 400 });
|
return NextResponse.redirect(new URL(DEFAULT_ICON, request.url), 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
// اگر تصویر از قبل مربع باشد، مستقیماً resize میکنیم
|
|
||||||
if (width === height) {
|
if (width === height) {
|
||||||
const processedImage = sharp(imageBuffer).resize(size, size, {
|
const processedImage = sharp(imageBuffer).resize(size, size, {
|
||||||
fit: "contain",
|
fit: "contain",
|
||||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const outputBuffer: any = await processedImage.png().toBuffer();
|
const outputBuffer: any = await processedImage.png().toBuffer();
|
||||||
|
|
||||||
return new NextResponse(outputBuffer, {
|
return new NextResponse(outputBuffer, {
|
||||||
@@ -54,13 +55,11 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// محاسبه نسبت برای resize اولیه (برای بهینهسازی)
|
|
||||||
const maxDimension = Math.max(width, height);
|
const maxDimension = Math.max(width, height);
|
||||||
const scale = size / maxDimension;
|
const scale = size / maxDimension;
|
||||||
const newWidth = Math.round(width * scale);
|
const newWidth = Math.round(width * scale);
|
||||||
const newHeight = Math.round(height * scale);
|
const newHeight = Math.round(height * scale);
|
||||||
|
|
||||||
// ابتدا resize میکنیم و سپس padding اضافه میکنیم
|
|
||||||
const paddingTop = Math.floor((size - newHeight) / 2);
|
const paddingTop = Math.floor((size - newHeight) / 2);
|
||||||
const paddingBottom = size - newHeight - paddingTop;
|
const paddingBottom = size - newHeight - paddingTop;
|
||||||
const paddingLeft = Math.floor((size - newWidth) / 2);
|
const paddingLeft = Math.floor((size - newWidth) / 2);
|
||||||
@@ -79,7 +78,6 @@ export async function GET(
|
|||||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// تبدیل به PNG و برگرداندن
|
|
||||||
const outputBuffer: any = await processedImage.png().toBuffer();
|
const outputBuffer: any = await processedImage.png().toBuffer();
|
||||||
|
|
||||||
return new NextResponse(outputBuffer, {
|
return new NextResponse(outputBuffer, {
|
||||||
@@ -90,6 +88,6 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing logo:", error);
|
console.error("Error processing logo:", error);
|
||||||
return new NextResponse("Internal Server Error", { status: 500 });
|
return NextResponse.redirect(new URL(DEFAULT_ICON, request.url), 302);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fetchWithTimeout } from "@/lib/helpers/fetchWithTimeout";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -15,7 +16,8 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||||
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const res = await fetch(url, {
|
const res = await fetchWithTimeout(url, {
|
||||||
|
timeoutMs: 5000,
|
||||||
headers: { Accept: "image/svg+xml, text/xml, text/plain" },
|
headers: { Accept: "image/svg+xml, text/xml, text/plain" },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ export async function generateMetadata({
|
|||||||
try {
|
try {
|
||||||
const restaurant = await getRestaurant(name)
|
const restaurant = await getRestaurant(name)
|
||||||
const title = restaurant.seoTitle || restaurant.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 {
|
return {
|
||||||
title,
|
title,
|
||||||
@@ -30,12 +41,12 @@ export async function generateMetadata({
|
|||||||
manifest: `/${name}/manifest.webmanifest`,
|
manifest: `/${name}/manifest.webmanifest`,
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: defaultIcon },
|
{ url: iconUrl },
|
||||||
{ url: defaultIcon, sizes: "192x192", type: "image/png" },
|
{ url: icon192Url, sizes: "192x192", type: "image/png" },
|
||||||
{ url: defaultIcon512, sizes: "512x512", type: "image/png" },
|
{ url: icon512Url, sizes: "512x512", type: "image/png" },
|
||||||
],
|
],
|
||||||
shortcut: defaultIcon,
|
shortcut: iconUrl,
|
||||||
apple: defaultIcon,
|
apple: iconUrl,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -4,6 +4,46 @@ 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) {
|
||||||
|
const default192 = "/icons/web-app-manifest-192x192.png";
|
||||||
|
const default512 = "/icons/web-app-manifest-512x512.png";
|
||||||
|
|
||||||
|
if (logo && logo.trim() !== "") {
|
||||||
|
const logo192 = `/${name}/api/logo?url=${encodeURIComponent(logo)}&size=192`;
|
||||||
|
const logo512 = `/${name}/api/logo?url=${encodeURIComponent(logo)}&size=512`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
src: logo192,
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: logo512,
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "any",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
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(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ name: string }> }
|
{ params }: { params: Promise<{ name: string }> }
|
||||||
@@ -13,21 +53,7 @@ export async function GET(
|
|||||||
const restaurant = await getRestaurant(name);
|
const restaurant = await getRestaurant(name);
|
||||||
|
|
||||||
const themeColor = restaurant.menuColor || "#F4F5F9";
|
const themeColor = restaurant.menuColor || "#F4F5F9";
|
||||||
|
const icons = buildManifestIcons(name, restaurant.logo);
|
||||||
const icons = [
|
|
||||||
{
|
|
||||||
src: "/icons/web-app-manifest-192x192.png",
|
|
||||||
sizes: "192x192",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "any",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/icons/web-app-manifest-512x512.png",
|
|
||||||
sizes: "512x512",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "any",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
name: restaurant.name,
|
name: restaurant.name,
|
||||||
@@ -75,20 +101,7 @@ export async function GET(
|
|||||||
orientation: "portrait",
|
orientation: "portrait",
|
||||||
dir: "rtl",
|
dir: "rtl",
|
||||||
lang: "fa-IR",
|
lang: "fa-IR",
|
||||||
icons: [
|
icons: buildManifestIcons(name),
|
||||||
{
|
|
||||||
src: "/icons/web-app-manifest-192x192.png",
|
|
||||||
sizes: "192x192",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "any",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/icons/web-app-manifest-512x512.png",
|
|
||||||
sizes: "512x512",
|
|
||||||
type: "image/png",
|
|
||||||
purpose: "any",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(manifest, {
|
return NextResponse.json(manifest, {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedI
|
|||||||
alt=""
|
alt=""
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
|
unoptimized
|
||||||
className="inline-block mr-1 dark:brightness-0 dark:invert"
|
className="inline-block mr-1 dark:brightness-0 dark:invert"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -191,6 +192,7 @@ function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedI
|
|||||||
alt=""
|
alt=""
|
||||||
width={16}
|
width={16}
|
||||||
height={16}
|
height={16}
|
||||||
|
unoptimized
|
||||||
className="inline-block mr-1 dark:brightness-0 dark:invert"
|
className="inline-block mr-1 dark:brightness-0 dark:invert"
|
||||||
/>
|
/>
|
||||||
) : v?.icon && React.createElement(v.icon, {
|
) : v?.icon && React.createElement(v.icon, {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import PlusIcon from "@/components/icons/PlusIcon";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
interface MenuItemProps {
|
interface MenuItemProps {
|
||||||
food: Food;
|
food: Food;
|
||||||
@@ -38,6 +38,12 @@ const MenuItem = ({ food }: MenuItemProps) => {
|
|||||||
return fallbackImage;
|
return fallbackImage;
|
||||||
}, [food.images]);
|
}, [food.images]);
|
||||||
|
|
||||||
|
const [imageSrc, setImageSrc] = useState(resolvedImage);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageSrc(resolvedImage);
|
||||||
|
}, [resolvedImage]);
|
||||||
|
|
||||||
const foodName = useMemo(() => food.title || "بدون نام", [food.title]);
|
const foodName = useMemo(() => food.title || "بدون نام", [food.title]);
|
||||||
|
|
||||||
const foodContent = useMemo(() => {
|
const foodContent = useMemo(() => {
|
||||||
@@ -95,11 +101,12 @@ const MenuItem = ({ food }: MenuItemProps) => {
|
|||||||
<Link href={foodDetailUrl} className="cursor-pointer">
|
<Link href={foodDetailUrl} className="cursor-pointer">
|
||||||
<img
|
<img
|
||||||
className="rounded-xl bg-[#F2F2F9] min-w-28 w-28 object-cover"
|
className="rounded-xl bg-[#F2F2F9] min-w-28 w-28 object-cover"
|
||||||
src={resolvedImage}
|
src={imageSrc}
|
||||||
height={112}
|
height={112}
|
||||||
width={112}
|
width={112}
|
||||||
alt={foodName}
|
alt={foodName}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
onError={() => setImageSrc(fallbackImage)}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="w-full inline-flex flex-col justify-between min-w-0">
|
<div className="w-full inline-flex flex-col justify-between min-w-0">
|
||||||
|
|||||||
@@ -1,178 +1,143 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
const MAX_DISPLAY_MS = 1200;
|
||||||
|
const EXIT_MS = 350;
|
||||||
|
const LOGO_TIMEOUT_MS = 2500;
|
||||||
|
|
||||||
interface SplashScreenProps {
|
interface SplashScreenProps {
|
||||||
logo?: string;
|
logo?: string;
|
||||||
restaurantName?: string;
|
restaurantName?: string;
|
||||||
duration?: number;
|
onDismiss: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SplashScreen({
|
export default function SplashScreen({
|
||||||
logo,
|
logo,
|
||||||
restaurantName,
|
restaurantName,
|
||||||
duration = 2000
|
onDismiss,
|
||||||
}: SplashScreenProps) {
|
}: SplashScreenProps) {
|
||||||
const [isVisible, setIsVisible] = useState(true);
|
const [opacity, setOpacity] = useState(1);
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [logoState, setLogoState] = useState<'idle' | 'loading' | 'ready' | 'error'>(
|
||||||
|
logo ? 'loading' : 'idle',
|
||||||
|
);
|
||||||
|
const onDismissRef = useRef(onDismiss);
|
||||||
|
onDismissRef.current = onDismiss;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
setLogoState(logo ? 'loading' : 'idle');
|
||||||
// setIsVisible(false);
|
}, [logo]);
|
||||||
}, duration);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
useEffect(() => {
|
||||||
}, [duration]);
|
if (!logo || logoState !== 'loading') return;
|
||||||
|
|
||||||
|
const logoTimeout = setTimeout(() => {
|
||||||
|
setLogoState('error');
|
||||||
|
}, LOGO_TIMEOUT_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(logoTimeout);
|
||||||
|
}, [logo, logoState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fadeTimer = setTimeout(() => {
|
||||||
|
setOpacity(0);
|
||||||
|
}, MAX_DISPLAY_MS);
|
||||||
|
|
||||||
|
const dismissTimer = setTimeout(() => {
|
||||||
|
onDismissRef.current();
|
||||||
|
}, MAX_DISPLAY_MS + EXIT_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(fadeTimer);
|
||||||
|
clearTimeout(dismissTimer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showLogoSpinner = Boolean(logo) && logoState === 'loading';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<motion.div
|
||||||
{isVisible && (
|
initial={{ opacity: 1 }}
|
||||||
|
animate={{ opacity }}
|
||||||
|
transition={{ duration: EXIT_MS / 1000, ease: 'easeOut' }}
|
||||||
|
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 1 }}
|
animate={{
|
||||||
exit={{ opacity: 0 }}
|
scale: [1, 1.2, 1],
|
||||||
transition={{ duration: 0.5 }}
|
opacity: [0.1, 0.2, 0.1],
|
||||||
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center"
|
}}
|
||||||
>
|
transition={{
|
||||||
{/* دایرههای تزئینی پسزمینه */}
|
duration: 3,
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
repeat: Infinity,
|
||||||
<motion.div
|
ease: 'easeInOut',
|
||||||
animate={{
|
}}
|
||||||
scale: [1, 1.2, 1],
|
className="absolute -top-20 -right-20 w-80 h-80 rounded-full bg-primary/20 blur-3xl"
|
||||||
opacity: [0.1, 0.2, 0.1]
|
/>
|
||||||
}}
|
<motion.div
|
||||||
transition={{
|
animate={{
|
||||||
duration: 3,
|
scale: [1.2, 1, 1.2],
|
||||||
repeat: Infinity,
|
opacity: [0.1, 0.2, 0.1],
|
||||||
ease: "easeInOut"
|
}}
|
||||||
}}
|
transition={{
|
||||||
className="absolute -top-20 -right-20 w-80 h-80 rounded-full bg-primary/20 blur-3xl"
|
duration: 3,
|
||||||
/>
|
delay: 0.5,
|
||||||
<motion.div
|
repeat: Infinity,
|
||||||
animate={{
|
ease: 'easeInOut',
|
||||||
scale: [1.2, 1, 1.2],
|
}}
|
||||||
opacity: [0.1, 0.2, 0.1]
|
className="absolute -bottom-20 -left-20 w-80 h-80 rounded-full bg-primary/20 blur-3xl"
|
||||||
}}
|
/>
|
||||||
transition={{
|
</div>
|
||||||
duration: 3,
|
|
||||||
delay: 0.5,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut"
|
|
||||||
}}
|
|
||||||
className="absolute -bottom-20 -left-20 w-80 h-80 rounded-full bg-primary/20 blur-3xl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
<div className="flex flex-col items-center gap-8 px-8 w-full max-w-md relative z-10">
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
{logo && logoState !== 'error' && (
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
<div className="relative">
|
||||||
transition={{
|
<Image
|
||||||
duration: 0.5,
|
src={logo}
|
||||||
ease: "easeOut"
|
alt={restaurantName || 'Restaurant Logo'}
|
||||||
}}
|
width={200}
|
||||||
className="flex flex-col items-center gap-8 px-8 w-full max-w-md relative z-10"
|
height={100}
|
||||||
>
|
unoptimized
|
||||||
{logo && (
|
priority
|
||||||
<motion.div
|
className="object-contain"
|
||||||
initial={{ y: -20, opacity: 0 }}
|
onLoad={() => setLogoState('ready')}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
onError={() => setLogoState('error')}
|
||||||
transition={{ delay: 0.2, duration: 0.5 }}
|
style={{
|
||||||
className="relative"
|
opacity: logoState === 'ready' ? 1 : 0,
|
||||||
>
|
transition: 'opacity 0.3s',
|
||||||
<div>
|
}}
|
||||||
<Image
|
/>
|
||||||
src={logo}
|
{showLogoSpinner && (
|
||||||
alt={restaurantName || 'Restaurant Logo'}
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
width={200}
|
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
height={100}
|
</div>
|
||||||
unoptimized
|
|
||||||
loading="lazy"
|
|
||||||
className="object-contain"
|
|
||||||
onLoad={() => setImageLoaded(true)}
|
|
||||||
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.3s' }}
|
|
||||||
/>
|
|
||||||
{!imageLoaded && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* حلقه درخشان دور لوگو */}
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
scale: [1, 1.05, 1],
|
|
||||||
opacity: [0.5, 0.8, 0.5]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 2,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut"
|
|
||||||
}}
|
|
||||||
// className="absolute -inset-2 rounded-3xl bg-gradient-to-r from-primary/20 via-primary/40 to-primary/20 -z-10 blur-xl"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
{/*
|
</div>
|
||||||
{restaurantName && (
|
)}
|
||||||
<motion.h1
|
|
||||||
initial={{ y: 20, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ delay: 0.3, duration: 0.5 }}
|
|
||||||
className="text-3xl font-bold text-foreground text-center"
|
|
||||||
>
|
|
||||||
{restaurantName}
|
|
||||||
</motion.h1>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-2">
|
||||||
|
{[0, 0.2, 0.4].map((delay) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
key={delay}
|
||||||
animate={{ opacity: 1 }}
|
animate={{
|
||||||
transition={{ delay: 0.5, duration: 0.5 }}
|
scale: [1, 1.3, 1],
|
||||||
className="flex gap-3 mt-2"
|
opacity: [0.4, 1, 0.4],
|
||||||
>
|
}}
|
||||||
<motion.div
|
transition={{
|
||||||
animate={{
|
duration: 1.5,
|
||||||
scale: [1, 1.3, 1],
|
delay,
|
||||||
opacity: [0.4, 1, 0.4]
|
repeat: Infinity,
|
||||||
}}
|
ease: 'easeInOut',
|
||||||
transition={{
|
}}
|
||||||
duration: 1.5,
|
className="w-3 h-3 rounded-full bg-primary shadow-lg shadow-primary/50"
|
||||||
repeat: Infinity,
|
/>
|
||||||
ease: "easeInOut"
|
))}
|
||||||
}}
|
</div>
|
||||||
className="w-3 h-3 rounded-full bg-primary shadow-lg shadow-primary/50"
|
</div>
|
||||||
/>
|
</motion.div>
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
scale: [1, 1.3, 1],
|
|
||||||
opacity: [0.4, 1, 0.4]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 1.5,
|
|
||||||
delay: 0.2,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut"
|
|
||||||
}}
|
|
||||||
className="w-3 h-3 rounded-full bg-primary shadow-lg shadow-primary/50"
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
scale: [1, 1.3, 1],
|
|
||||||
opacity: [0.4, 1, 0.4]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 1.5,
|
|
||||||
delay: 0.4,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut"
|
|
||||||
}}
|
|
||||||
className="w-3 h-3 rounded-full bg-primary shadow-lg shadow-primary/50"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import usePreference from '@/hooks/helpers/usePreference';
|
import usePreference from '@/hooks/helpers/usePreference';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
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';
|
||||||
@@ -10,14 +10,13 @@ type Props = {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
// تابع برای اعمال رنگ primary
|
|
||||||
function applyPrimaryColor(colorHex: string) {
|
function applyPrimaryColor(colorHex: string) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const oklchColor = hexToOklch(colorHex);
|
const oklchColor = hexToOklch(colorHex);
|
||||||
|
|
||||||
if (oklchColor) {
|
if (oklchColor) {
|
||||||
root.style.setProperty('--primary', oklchColor);
|
root.style.setProperty('--primary', oklchColor);
|
||||||
|
|
||||||
const brightness = calculateBrightness(colorHex);
|
const brightness = calculateBrightness(colorHex);
|
||||||
const foregroundOklch = brightness > 0.5 ? 'oklch(0 0 0)' : 'oklch(1 0 0)';
|
const foregroundOklch = brightness > 0.5 ? 'oklch(0 0 0)' : 'oklch(1 0 0)';
|
||||||
root.style.setProperty('--primary-foreground', foregroundOklch);
|
root.style.setProperty('--primary-foreground', foregroundOklch);
|
||||||
@@ -28,47 +27,43 @@ function PreferenceWrapper({ children }: Props) {
|
|||||||
const { state: nightMode } = usePreference('night-mode', false);
|
const { state: nightMode } = usePreference('night-mode', false);
|
||||||
const { data: aboutData } = useGetAbout();
|
const { data: aboutData } = useGetAbout();
|
||||||
|
|
||||||
// بررسی اینکه آیا باید اسپلش نمایش داده بشه
|
const [showSplash, setShowSplash] = useState(false);
|
||||||
const [showSplash, setShowSplash] = useState(() => {
|
|
||||||
if (typeof window === 'undefined') return true;
|
|
||||||
const navigationEntries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
|
|
||||||
const isReload = navigationEntries.length > 0 && navigationEntries[0].type === 'reload';
|
|
||||||
return !sessionStorage.getItem('app-loaded') || isReload;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [cachedLogo, setCachedLogo] = useState<string | null>(null);
|
const [cachedLogo, setCachedLogo] = useState<string | null>(null);
|
||||||
const [cachedName, setCachedName] = useState<string | null>(null);
|
const [cachedName, setCachedName] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSplashDismiss = useCallback(() => {
|
||||||
setMounted(true);
|
setShowSplash(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// بارگذاری رنگ و اطلاعات از localStorage در اولین رندر
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// بارگذاری رنگ
|
setMounted(true);
|
||||||
|
|
||||||
const savedColor = localStorage.getItem('theme-primary-color');
|
const savedColor = localStorage.getItem('theme-primary-color');
|
||||||
if (savedColor) {
|
if (savedColor) {
|
||||||
applyPrimaryColor(savedColor);
|
applyPrimaryColor(savedColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// بارگذاری لوگو و نام
|
|
||||||
const savedLogo = localStorage.getItem('restaurant-logo');
|
const savedLogo = localStorage.getItem('restaurant-logo');
|
||||||
const savedName = localStorage.getItem('restaurant-name');
|
const savedName = localStorage.getItem('restaurant-name');
|
||||||
if (savedLogo) setCachedLogo(savedLogo);
|
if (savedLogo) setCachedLogo(savedLogo);
|
||||||
if (savedName) setCachedName(savedName);
|
if (savedName) setCachedName(savedName);
|
||||||
|
|
||||||
// ثبت اینکه app لود شده
|
const navigationEntries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
|
||||||
|
const isReload = navigationEntries.length > 0 && navigationEntries[0].type === 'reload';
|
||||||
|
const isFirstLoadInSession = !sessionStorage.getItem('app-loaded') || isReload;
|
||||||
|
|
||||||
|
if (isFirstLoadInSession) {
|
||||||
|
setShowSplash(true);
|
||||||
|
}
|
||||||
|
|
||||||
sessionStorage.setItem('app-loaded', 'true');
|
sessionStorage.setItem('app-loaded', 'true');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// اعمال رنگ primary از about (با ذخیره در localStorage)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (aboutData?.data?.menuColor) {
|
if (aboutData?.data?.menuColor) {
|
||||||
// ذخیره رنگ و اطلاعات در localStorage
|
|
||||||
localStorage.setItem('theme-primary-color', aboutData.data.menuColor);
|
localStorage.setItem('theme-primary-color', aboutData.data.menuColor);
|
||||||
|
|
||||||
// ذخیره لوگو و نام برای اسپلش
|
|
||||||
if (aboutData.data.logo) {
|
if (aboutData.data.logo) {
|
||||||
localStorage.setItem('restaurant-logo', aboutData.data.logo);
|
localStorage.setItem('restaurant-logo', aboutData.data.logo);
|
||||||
}
|
}
|
||||||
@@ -81,23 +76,10 @@ 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(() => {
|
useEffect(() => {
|
||||||
const theme = nightMode;
|
document.documentElement.setAttribute('data-theme', nightMode ? 'dark' : 'light');
|
||||||
document.documentElement.setAttribute("data-theme", theme ? "dark" : "light");
|
|
||||||
}, [nightMode]);
|
}, [nightMode]);
|
||||||
|
|
||||||
// بستن اسپلش بعد از مدت زمان مشخص
|
|
||||||
useEffect(() => {
|
|
||||||
if (showSplash) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setShowSplash(false);
|
|
||||||
}, 2500);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [showSplash]);
|
|
||||||
|
|
||||||
const splashLogo = mounted
|
const splashLogo = mounted
|
||||||
? (aboutData?.data?.logo || cachedLogo || undefined)
|
? (aboutData?.data?.logo || cachedLogo || undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -107,16 +89,16 @@ function PreferenceWrapper({ children }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{children}
|
||||||
{showSplash && (
|
{showSplash && (
|
||||||
<SplashScreen
|
<SplashScreen
|
||||||
logo={splashLogo}
|
logo={splashLogo}
|
||||||
restaurantName={splashName}
|
restaurantName={splashName}
|
||||||
duration={2500}
|
onDismiss={handleSplashDismiss}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!showSplash && children}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PreferenceWrapper
|
export default PreferenceWrapper
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const DEFAULT_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
export async function fetchWithTimeout(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit & { timeoutMs?: number } = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
const { timeoutMs = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options;
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
...fetchOptions,
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user