@@ -203,6 +204,7 @@ function OrdersIndex() {
width={64}
height={64}
alt={foodName}
+ unoptimized
/>
diff --git a/src/app/[name]/api/logo/route.ts b/src/app/[name]/api/logo/route.ts
index f95e3ac..c9d20e7 100644
--- a/src/app/[name]/api/logo/route.ts
+++ b/src/app/[name]/api/logo/route.ts
@@ -1,17 +1,20 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
+import { fetchWithTimeout } from "@/lib/helpers/fetchWithTimeout";
import { NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
export const dynamic = "force-dynamic";
export const revalidate = 0;
+const FETCH_TIMEOUT_MS = 5000;
+const DEFAULT_ICON = "/icons/web-app-manifest-192x192.png";
+
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
try {
- const { name } = await params;
- console.log("name", name);
+ await params;
const searchParams = request.nextUrl.searchParams;
const logoUrl = searchParams.get("url");
@@ -21,29 +24,27 @@ export async function GET(
return new NextResponse("Logo URL is required", { status: 400 });
}
- // دانلود تصویر
- const imageResponse = await fetch(logoUrl);
+ const imageResponse = await fetchWithTimeout(logoUrl, {
+ timeoutMs: FETCH_TIMEOUT_MS,
+ });
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 metadata = await sharp(imageBuffer).metadata();
const { width, height } = metadata;
if (!width || !height) {
- return new NextResponse("Invalid image", { status: 400 });
+ return NextResponse.redirect(new URL(DEFAULT_ICON, request.url), 302);
}
- // اگر تصویر از قبل مربع باشد، مستقیماً resize میکنیم
if (width === height) {
const processedImage = sharp(imageBuffer).resize(size, size, {
fit: "contain",
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();
return new NextResponse(outputBuffer, {
@@ -54,13 +55,11 @@ export async function GET(
});
}
- // محاسبه نسبت برای resize اولیه (برای بهینهسازی)
const maxDimension = Math.max(width, height);
const scale = size / maxDimension;
const newWidth = Math.round(width * scale);
const newHeight = Math.round(height * scale);
- // ابتدا resize میکنیم و سپس padding اضافه میکنیم
const paddingTop = Math.floor((size - newHeight) / 2);
const paddingBottom = size - newHeight - paddingTop;
const paddingLeft = Math.floor((size - newWidth) / 2);
@@ -79,7 +78,6 @@ export async function GET(
background: { r: 0, g: 0, b: 0, alpha: 0 },
});
- // تبدیل به PNG و برگرداندن
const outputBuffer: any = await processedImage.png().toBuffer();
return new NextResponse(outputBuffer, {
@@ -90,6 +88,6 @@ export async function GET(
});
} catch (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);
}
}
diff --git a/src/app/[name]/api/proxy-svg/route.ts b/src/app/[name]/api/proxy-svg/route.ts
index f57e2cc..91e709d 100644
--- a/src/app/[name]/api/proxy-svg/route.ts
+++ b/src/app/[name]/api/proxy-svg/route.ts
@@ -1,3 +1,4 @@
+import { fetchWithTimeout } from "@/lib/helpers/fetchWithTimeout";
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
@@ -15,7 +16,8 @@ export async function GET(request: NextRequest) {
if (!["http:", "https:"].includes(parsed.protocol)) {
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" },
});
if (!res.ok) {
diff --git a/src/app/[name]/layout.tsx b/src/app/[name]/layout.tsx
index bbf869b..81b5d18 100644
--- a/src/app/[name]/layout.tsx
+++ b/src/app/[name]/layout.tsx
@@ -23,6 +23,17 @@ export async function generateMetadata({
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,
@@ -30,12 +41,12 @@ export async function generateMetadata({
manifest: `/${name}/manifest.webmanifest`,
icons: {
icon: [
- { url: defaultIcon },
- { url: defaultIcon, sizes: "192x192", type: "image/png" },
- { url: defaultIcon512, sizes: "512x512", type: "image/png" },
+ { url: iconUrl },
+ { url: icon192Url, sizes: "192x192", type: "image/png" },
+ { url: icon512Url, sizes: "512x512", type: "image/png" },
],
- shortcut: defaultIcon,
- apple: defaultIcon,
+ shortcut: iconUrl,
+ apple: iconUrl,
},
}
} catch {
diff --git a/src/app/[name]/manifest.webmanifest/route.ts b/src/app/[name]/manifest.webmanifest/route.ts
index 2f91e67..796438f 100644
--- a/src/app/[name]/manifest.webmanifest/route.ts
+++ b/src/app/[name]/manifest.webmanifest/route.ts
@@ -4,6 +4,46 @@ import { getRestaurant } from "../lib/getRestaurant";
export const dynamic = "force-dynamic";
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(
request: Request,
{ params }: { params: Promise<{ name: string }> }
@@ -13,21 +53,7 @@ export async function GET(
const restaurant = await getRestaurant(name);
const themeColor = restaurant.menuColor || "#F4F5F9";
-
- 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 icons = buildManifestIcons(name, restaurant.logo);
const manifest = {
name: restaurant.name,
@@ -75,20 +101,7 @@ export async function GET(
orientation: "portrait",
dir: "rtl",
lang: "fa-IR",
- 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",
- },
- ],
+ icons: buildManifestIcons(name),
};
return NextResponse.json(manifest, {
diff --git a/src/components/combobox/Combobox.tsx b/src/components/combobox/Combobox.tsx
index 62a158d..e157d20 100644
--- a/src/components/combobox/Combobox.tsx
+++ b/src/components/combobox/Combobox.tsx
@@ -74,6 +74,7 @@ function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedI
alt=""
width={20}
height={20}
+ unoptimized
className="inline-block mr-1 dark:brightness-0 dark:invert"
/>
);
@@ -191,6 +192,7 @@ function Combobox({ title, options, placeholder, icon: Icon, expanded, selectedI
alt=""
width={16}
height={16}
+ unoptimized
className="inline-block mr-1 dark:brightness-0 dark:invert"
/>
) : v?.icon && React.createElement(v.icon, {
diff --git a/src/components/listview/MenuItem.tsx b/src/components/listview/MenuItem.tsx
index 444bc50..f0b5441 100644
--- a/src/components/listview/MenuItem.tsx
+++ b/src/components/listview/MenuItem.tsx
@@ -8,7 +8,7 @@ import PlusIcon from "@/components/icons/PlusIcon";
import { motion } from "framer-motion";
import Link from "next/link";
import { useParams } from "next/navigation";
-import { memo, useMemo } from "react";
+import { memo, useEffect, useMemo, useState } from "react";
interface MenuItemProps {
food: Food;
@@ -38,6 +38,12 @@ const MenuItem = ({ food }: MenuItemProps) => {
return fallbackImage;
}, [food.images]);
+ const [imageSrc, setImageSrc] = useState(resolvedImage);
+
+ useEffect(() => {
+ setImageSrc(resolvedImage);
+ }, [resolvedImage]);
+
const foodName = useMemo(() => food.title || "بدون نام", [food.title]);
const foodContent = useMemo(() => {
@@ -95,11 +101,12 @@ const MenuItem = ({ food }: MenuItemProps) => {

setImageSrc(fallbackImage)}
/>
diff --git a/src/components/overlays/SplashScreen.tsx b/src/components/overlays/SplashScreen.tsx
index b70f763..936bbe4 100644
--- a/src/components/overlays/SplashScreen.tsx
+++ b/src/components/overlays/SplashScreen.tsx
@@ -1,178 +1,143 @@
'use client';
-import { useEffect, useState } from 'react';
-import { motion, AnimatePresence } from 'framer-motion';
+import { useEffect, useRef, useState } from 'react';
+import { motion } from 'framer-motion';
import Image from 'next/image';
+const MAX_DISPLAY_MS = 1200;
+const EXIT_MS = 350;
+const LOGO_TIMEOUT_MS = 2500;
+
interface SplashScreenProps {
logo?: string;
restaurantName?: string;
- duration?: number;
+ onDismiss: () => void;
}
export default function SplashScreen({
logo,
restaurantName,
- duration = 2000
+ onDismiss,
}: SplashScreenProps) {
- const [isVisible, setIsVisible] = useState(true);
- const [imageLoaded, setImageLoaded] = useState(false);
+ const [opacity, setOpacity] = useState(1);
+ const [logoState, setLogoState] = useState<'idle' | 'loading' | 'ready' | 'error'>(
+ logo ? 'loading' : 'idle',
+ );
+ const onDismissRef = useRef(onDismiss);
+ onDismissRef.current = onDismiss;
useEffect(() => {
- const timer = setTimeout(() => {
- // setIsVisible(false);
- }, duration);
+ setLogoState(logo ? 'loading' : 'idle');
+ }, [logo]);
- return () => clearTimeout(timer);
- }, [duration]);
+ useEffect(() => {
+ 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 (
-
- {isVisible && (
+
+
- {/* دایرههای تزئینی پسزمینه */}
-
-
-
-
+ animate={{
+ scale: [1, 1.2, 1],
+ opacity: [0.1, 0.2, 0.1],
+ }}
+ transition={{
+ duration: 3,
+ repeat: Infinity,
+ ease: 'easeInOut',
+ }}
+ className="absolute -top-20 -right-20 w-80 h-80 rounded-full bg-primary/20 blur-3xl"
+ />
+
+
-
- {logo && (
-
-
-
setImageLoaded(true)}
- style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.3s' }}
- />
- {!imageLoaded && (
-
- )}
-
- {/* حلقه درخشان دور لوگو */}
-
-
+
+ {logo && logoState !== 'error' && (
+
+
setLogoState('ready')}
+ onError={() => setLogoState('error')}
+ style={{
+ opacity: logoState === 'ready' ? 1 : 0,
+ transition: 'opacity 0.3s',
+ }}
+ />
+ {showLogoSpinner && (
+
)}
- {/*
- {restaurantName && (
-
- {restaurantName}
-
- )} */}
+
+ )}
+
+ {[0, 0.2, 0.4].map((delay) => (
-
-
-
-
-
-
- )}
-
+ key={delay}
+ animate={{
+ scale: [1, 1.3, 1],
+ opacity: [0.4, 1, 0.4],
+ }}
+ transition={{
+ duration: 1.5,
+ delay,
+ repeat: Infinity,
+ ease: 'easeInOut',
+ }}
+ className="w-3 h-3 rounded-full bg-primary shadow-lg shadow-primary/50"
+ />
+ ))}
+
+
+
);
}
diff --git a/src/components/wrapper/PreferenceWrapper.tsx b/src/components/wrapper/PreferenceWrapper.tsx
index 6810f00..ce433d2 100644
--- a/src/components/wrapper/PreferenceWrapper.tsx
+++ b/src/components/wrapper/PreferenceWrapper.tsx
@@ -1,7 +1,7 @@
'use client';
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 SplashScreen from '@/components/overlays/SplashScreen';
import { hexToOklch, calculateBrightness } from '@/lib/helpers/colorUtils';
@@ -10,14 +10,13 @@ type Props = {
children: React.ReactNode
}
-// تابع برای اعمال رنگ primary
function applyPrimaryColor(colorHex: string) {
const root = document.documentElement;
const oklchColor = hexToOklch(colorHex);
-
+
if (oklchColor) {
root.style.setProperty('--primary', oklchColor);
-
+
const brightness = calculateBrightness(colorHex);
const foregroundOklch = brightness > 0.5 ? 'oklch(0 0 0)' : 'oklch(1 0 0)';
root.style.setProperty('--primary-foreground', foregroundOklch);
@@ -28,47 +27,43 @@ function PreferenceWrapper({ children }: Props) {
const { state: nightMode } = usePreference('night-mode', false);
const { data: aboutData } = useGetAbout();
- // بررسی اینکه آیا باید اسپلش نمایش داده بشه
- 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 [showSplash, setShowSplash] = useState(false);
const [mounted, setMounted] = useState(false);
const [cachedLogo, setCachedLogo] = useState(null);
const [cachedName, setCachedName] = useState(null);
- useEffect(() => {
- setMounted(true);
+ const handleSplashDismiss = useCallback(() => {
+ setShowSplash(false);
}, []);
- // بارگذاری رنگ و اطلاعات از localStorage در اولین رندر
useEffect(() => {
- // بارگذاری رنگ
+ setMounted(true);
+
const savedColor = localStorage.getItem('theme-primary-color');
if (savedColor) {
applyPrimaryColor(savedColor);
}
- // بارگذاری لوگو و نام
const savedLogo = localStorage.getItem('restaurant-logo');
const savedName = localStorage.getItem('restaurant-name');
if (savedLogo) setCachedLogo(savedLogo);
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');
}, []);
- // اعمال رنگ primary از about (با ذخیره در localStorage)
useEffect(() => {
if (aboutData?.data?.menuColor) {
- // ذخیره رنگ و اطلاعات در localStorage
localStorage.setItem('theme-primary-color', aboutData.data.menuColor);
- // ذخیره لوگو و نام برای اسپلش
if (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
}, [aboutData?.data?.menuColor]);
- // اعمال تم تاریک/روشن
useEffect(() => {
- const theme = nightMode;
- document.documentElement.setAttribute("data-theme", theme ? "dark" : "light");
+ document.documentElement.setAttribute('data-theme', nightMode ? 'dark' : 'light');
}, [nightMode]);
- // بستن اسپلش بعد از مدت زمان مشخص
- useEffect(() => {
- if (showSplash) {
- const timer = setTimeout(() => {
- setShowSplash(false);
- }, 2500);
-
- return () => clearTimeout(timer);
- }
- }, [showSplash]);
-
const splashLogo = mounted
? (aboutData?.data?.logo || cachedLogo || undefined)
: undefined;
@@ -107,16 +89,16 @@ function PreferenceWrapper({ children }: Props) {
return (
<>
+ {children}
{showSplash && (
)}
- {!showSplash && children}
>
)
}
-export default PreferenceWrapper
\ No newline at end of file
+export default PreferenceWrapper
diff --git a/src/lib/helpers/fetchWithTimeout.ts b/src/lib/helpers/fetchWithTimeout.ts
new file mode 100644
index 0000000..15ffb51
--- /dev/null
+++ b/src/lib/helpers/fetchWithTimeout.ts
@@ -0,0 +1,13 @@
+const DEFAULT_TIMEOUT_MS = 5000;
+
+export async function fetchWithTimeout(
+ url: string,
+ options: RequestInit & { timeoutMs?: number } = {},
+): Promise {
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options;
+
+ return fetch(url, {
+ ...fetchOptions,
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+}