once load splash

This commit is contained in:
hamid zarghami
2026-06-06 10:04:47 +03:30
parent e948b00f55
commit 2ae99faca9
12 changed files with 201 additions and 64 deletions
+14 -1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -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,
+8 -7
View File
@@ -5,6 +5,7 @@ import { getRestaurant } from './lib/getRestaurant'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import CartChecker from '@/components/CartChecker' import CartChecker from '@/components/CartChecker'
import ActiveChecker from '@/components/ActiveChecker' import ActiveChecker from '@/components/ActiveChecker'
import { PWA_ICON_192, PWA_ICON_512 } from '@/lib/helpers/pwaIcons'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const revalidate = 0 export const revalidate = 0
@@ -17,8 +18,8 @@ export async function generateMetadata({
params: Promise<LayoutParams> params: Promise<LayoutParams>
}): Promise<Metadata> { }): Promise<Metadata> {
const { name } = await params const { name } = await params
const defaultIcon = "/icons/web-app-manifest-192x192.png" const defaultIcon = PWA_ICON_192
const defaultIcon512 = "/icons/web-app-manifest-512x512.png" const defaultIcon512 = PWA_ICON_512
try { try {
const restaurant = await getRestaurant(name) const restaurant = await getRestaurant(name)
@@ -56,12 +57,12 @@ export async function generateMetadata({
manifest: `/${name}/manifest.webmanifest`, manifest: `/${name}/manifest.webmanifest`,
icons: { icons: {
icon: [ icon: [
{ url: "/icons/web-app-manifest-192x192.png" }, { url: PWA_ICON_192 },
{ url: "/icons/web-app-manifest-192x192.png", sizes: "192x192", type: "image/png" }, { url: PWA_ICON_192, sizes: "192x192", type: "image/png" },
{ url: "/icons/web-app-manifest-512x512.png", sizes: "512x512", type: "image/png" }, { url: PWA_ICON_512, sizes: "512x512", type: "image/png" },
], ],
shortcut: "/icons/web-app-manifest-192x192.png", shortcut: PWA_ICON_192,
apple: "/icons/web-app-manifest-192x192.png", apple: PWA_ICON_192,
}, },
} }
} }
+3 -32
View File
@@ -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(
+34
View File
@@ -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 });
}
}
+2 -1
View File
@@ -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>
+15
View File
@@ -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;
}
+17 -3
View File
@@ -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 () => {
+39 -8
View File
@@ -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]);
+34
View File
@@ -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',
},
];
}