From e68543f869ccbd8ce3f2543e2242c5c5136548d0 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sat, 6 Jun 2026 09:08:38 +0330 Subject: [PATCH] load fast --- src/app/[name]/(Main)/[id]/page.tsx | 12 +- .../(Main)/components/CategoryScroll.tsx | 14 +- src/app/[name]/(Main)/order/history/page.tsx | 2 + src/app/[name]/api/logo/route.ts | 24 +- src/app/[name]/api/proxy-svg/route.ts | 4 +- src/app/[name]/layout.tsx | 21 +- src/app/[name]/manifest.webmanifest/route.ts | 71 +++-- src/components/combobox/Combobox.tsx | 2 + src/components/listview/MenuItem.tsx | 11 +- src/components/overlays/SplashScreen.tsx | 271 ++++++++---------- src/components/wrapper/PreferenceWrapper.tsx | 58 ++-- src/lib/helpers/fetchWithTimeout.ts | 13 + 12 files changed, 255 insertions(+), 248 deletions(-) create mode 100644 src/lib/helpers/fetchWithTimeout.ts diff --git a/src/app/[name]/(Main)/[id]/page.tsx b/src/app/[name]/(Main)/[id]/page.tsx index 3616fa8..6e7a7a5 100644 --- a/src/app/[name]/(Main)/[id]/page.tsx +++ b/src/app/[name]/(Main)/[id]/page.tsx @@ -8,7 +8,6 @@ import { getToken } from "@/lib/api/func"; import { ef } from "@/lib/helpers/utfNumbers"; import { motion } from "framer-motion"; import { ArrowLeft, Clock, Cup, Heart, TruckTick } from "iconsax-react"; -import Image from "next/image"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { useGetProfile } from "../../(Profile)/profile/hooks/userProfileData"; @@ -131,13 +130,14 @@ function FoodPage({}: Props) { return (
- {foodName} { + event.currentTarget.src = "/assets/images/no-image.png"; + }} />
diff --git a/src/app/[name]/(Main)/components/CategoryScroll.tsx b/src/app/[name]/(Main)/components/CategoryScroll.tsx index 55b827e..8a6c45a 100644 --- a/src/app/[name]/(Main)/components/CategoryScroll.tsx +++ b/src/app/[name]/(Main)/components/CategoryScroll.tsx @@ -70,7 +70,19 @@ function CategoryImage({ ); } - return {alt}; + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} { + event.currentTarget.src = "/assets/images/food-image.png"; + }} + /> + ); } type Variant = "large" | "small"; diff --git a/src/app/[name]/(Main)/order/history/page.tsx b/src/app/[name]/(Main)/order/history/page.tsx index a4f9cc8..87ad3c9 100644 --- a/src/app/[name]/(Main)/order/history/page.tsx +++ b/src/app/[name]/(Main)/order/history/page.tsx @@ -118,6 +118,7 @@ function OrdersIndex() { width={64} height={64} alt={foodName} + unoptimized />
@@ -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) => { {foodName} 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 && ( +