This commit is contained in:
hamid zarghami
2026-06-21 14:52:20 +03:30
parent ba82744219
commit de45b1f197
19 changed files with 622 additions and 157 deletions
@@ -1,16 +1,23 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as api from "../service/AboutService";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import * as api from "../service/AboutService";
import { setPersistedAboutData } from "@/lib/helpers/themeCache";
export const useGetAbout = () => { export const useGetAbout = () => {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
return useQuery({ return useQuery({
queryKey: ["about", name], queryKey: ["about", name],
queryFn: () => api.getAbout(name), queryFn: async () => {
const data = await api.getAbout(name);
setPersistedAboutData(name, data);
return data;
},
enabled: !!name, enabled: !!name,
retry: false, retry: false,
staleTime: 5 * 60_000, staleTime: 60 * 60_000,
gcTime: 24 * 60 * 60_000,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false,
}); });
}; };
@@ -22,12 +22,12 @@ import type { Category, Food } from "../types/Types";
const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const; const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const;
const MenuIndex = () => { const MenuIndex = () => {
const { data: foodsData, isLoading: foodsLoading } = useGetFoods(); const { data: foodsData, isPending: foodsPending } = useGetFoods();
const { data: categoriesData, isLoading: categoriesLoading } = useGetCategories(); const { data: categoriesData, isPending: categoriesPending } = useGetCategories();
const foods = useMemo<Food[]>(() => foodsData?.data || [], [foodsData?.data]); const foods = useMemo<Food[]>(() => foodsData?.data || [], [foodsData?.data]);
const categories = useMemo<Category[]>(() => categoriesData?.data || [], [categoriesData?.data]); const categories = useMemo<Category[]>(() => categoriesData?.data || [], [categoriesData?.data]);
const isLoading = foodsLoading || categoriesLoading; const isLoading = foodsPending || categoriesPending;
const { t: tCommon } = useTranslation("common"); const { t: tCommon } = useTranslation("common");
const { t: tMenu } = useTranslation("menu", { keyPrefix: "Menu" }); const { t: tMenu } = useTranslation("menu", { keyPrefix: "Menu" });
const { state: filterModal, toggle: toggleFilterModal } = useToggle(); const { state: filterModal, toggle: toggleFilterModal } = useToggle();
+39 -4
View File
@@ -1,28 +1,63 @@
"use client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
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"; import { hasAuthToken } from "@/lib/api/func";
import {
getPersistedCategoriesData,
getPersistedMenuData,
setPersistedCategoriesData,
setPersistedMenuData,
} from "@/lib/helpers/themeCache";
export const useGetFoods = () => { export const useGetFoods = () => {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
return useQuery({ const query = useQuery({
queryKey: ["menu", name], queryKey: ["menu", name],
queryFn: () => api.getFoods(name), queryFn: async () => {
const data = await api.getFoods(name);
setPersistedMenuData(name, data);
return data;
},
enabled: !!name, enabled: !!name,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
refetchOnMount: false, refetchOnMount: false,
placeholderData: () => (name ? getPersistedMenuData(name) : undefined),
}); });
useEffect(() => {
if (name && query.data) {
setPersistedMenuData(name, query.data);
}
}, [name, query.data]);
return query;
}; };
export const useGetCategories = () => { export const useGetCategories = () => {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
return useQuery({ const query = useQuery({
queryKey: ["categories", name], queryKey: ["categories", name],
queryFn: () => api.getCategories(name), queryFn: async () => {
const data = await api.getCategories(name);
setPersistedCategoriesData(name, data);
return data;
},
enabled: !!name, enabled: !!name,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
refetchOnMount: false, refetchOnMount: false,
placeholderData: () => (name ? getPersistedCategoriesData(name) : undefined),
}); });
useEffect(() => {
if (name && query.data) {
setPersistedCategoriesData(name, query.data);
}
}, [name, query.data]);
return query;
}; };
export const useGetNotificationsCount = () => { export const useGetNotificationsCount = () => {
-10
View File
@@ -1,10 +0,0 @@
import LoadingOverlay from '@/components/overlays/LoadingOverlay'
import React from 'react'
function Loading() {
return (
<LoadingOverlay />
)
}
export default Loading
+2
View File
@@ -7,6 +7,7 @@ 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 { ThemeBootScript } from "@/components/ThemeBootScript";
import PwaServiceWorker from "@/components/PwaServiceWorker"; import PwaServiceWorker from "@/components/PwaServiceWorker";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -48,6 +49,7 @@ export default async function RootLayout({
className={`antialiased bg-background h-svh overflow-hidden`} className={`antialiased bg-background h-svh overflow-hidden`}
> >
<ThemeBootScript />
<ReactQueryProvider> <ReactQueryProvider>
<TranslationsProvider <TranslationsProvider
namespaces={i18nNamespaces} namespaces={i18nNamespaces}
-10
View File
@@ -1,10 +0,0 @@
import LoadingOverlay from '@/components/overlays/LoadingOverlay'
import React from 'react'
function Loading() {
return (
<LoadingOverlay />
)
}
export default Loading
+10
View File
@@ -0,0 +1,10 @@
import { getThemeBootScript } from "@/lib/helpers/themeBootScript";
export function ThemeBootScript() {
return (
<script
id="theme-boot"
dangerouslySetInnerHTML={{ __html: getThemeBootScript() }}
/>
);
}
+11 -3
View File
@@ -1,12 +1,20 @@
'use client' 'use client'
import { useEffect } from 'react' import { useLayoutEffect } from 'react'
import { hexToOklch, calculateBrightness } from '@/lib/helpers/colorUtils' import { hexToOklch, calculateBrightness } from '@/lib/helpers/colorUtils'
import { getCachedMenuColor, getRestaurantSlugFromPath, getThemeCache, applyCachedThemeToDom } from '@/lib/helpers/themeCache'
export function ThemeColorSetter() { export function ThemeColorSetter() {
useEffect(() => { useLayoutEffect(() => {
try { try {
const savedColor = localStorage.getItem('theme-primary-color') const slug = getRestaurantSlugFromPath()
const cachedTheme = slug ? getThemeCache(slug) : null
if (cachedTheme) {
applyCachedThemeToDom(cachedTheme)
return
}
const savedColor = getCachedMenuColor(slug)
if (savedColor) { if (savedColor) {
const oklchColor = hexToOklch(savedColor) const oklchColor = hexToOklch(savedColor)
+22 -20
View File
@@ -1,30 +1,32 @@
"use client"; "use client";
import { usePatternBackground } from "@/components/background/PatternBackgroundProvider"; import { usePatternBackground } from "@/components/background/PatternBackgroundProvider";
import { useEffect, useState } from "react"; import { THEME_BG_ELEMENT_ID } from "@/lib/helpers/themeCache";
import { createPortal } from "react-dom"; import { useLayoutEffect } from "react";
export default function AppBackground() { export default function AppBackground() {
const { isPattern, backgroundStyle } = usePatternBackground(); const { isPattern, backgroundStyle } = usePatternBackground();
const [mounted, setMounted] = useState(false);
useEffect(() => { useLayoutEffect(() => {
setMounted(true); if (!isPattern) {
}, []); document.getElementById(THEME_BG_ELEMENT_ID)?.remove();
return;
if (!mounted || !isPattern) {
return null;
} }
return createPortal( let element = document.getElementById(THEME_BG_ELEMENT_ID);
<div if (!element) {
aria-hidden element = document.createElement("div");
className="fixed inset-0 pointer-events-none" element.id = THEME_BG_ELEMENT_ID;
style={{ element.setAttribute("aria-hidden", "true");
zIndex: 0, element.style.position = "fixed";
...backgroundStyle, element.style.inset = "0";
}} element.style.pointerEvents = "none";
/>, element.style.zIndex = "0";
document.body, document.body.appendChild(element);
); }
Object.assign(element.style, backgroundStyle as Record<string, string>);
}, [backgroundStyle, isPattern]);
return null;
} }
@@ -9,13 +9,22 @@ import {
isPatternBackground, isPatternBackground,
PATTERN_BACKGROUND_OPACITY, PATTERN_BACKGROUND_OPACITY,
} from "@/lib/helpers/backgroundUtils"; } from "@/lib/helpers/backgroundUtils";
import {
applyCachedThemeToDom,
buildThemeCacheFromRestaurant,
getThemeCache,
removeCachedThemeBackground,
setThemeCache,
svgToDataUrl,
type CachedRestaurantTheme,
} from "@/lib/helpers/themeCache";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { import {
createContext, createContext,
useContext, useContext,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef,
useState, useState,
type CSSProperties, type CSSProperties,
type ReactNode, type ReactNode,
@@ -37,32 +46,74 @@ export function PatternBackgroundProvider({ children }: { children: ReactNode })
const { name: restaurantSlug } = useParams<{ name: string }>(); const { name: restaurantSlug } = useParams<{ name: string }>();
const { data: aboutData } = useGetAbout(); const { data: aboutData } = useGetAbout();
const restaurant = aboutData?.data; const restaurant = aboutData?.data;
const [hydratedCache, setHydratedCache] = useState<CachedRestaurantTheme | null>(null);
const [patternImageUrl, setPatternImageUrl] = useState<string | null>(null); const [patternImageUrl, setPatternImageUrl] = useState<string | null>(null);
const blobUrlRef = useRef<string | null>(null);
const isPattern = restaurant ? isPatternBackground(restaurant) : false; const isPattern = restaurant
const menuColor = restaurant?.menuColor || DEFAULT_MENU_COLOR; ? isPatternBackground(restaurant)
: (hydratedCache?.isPattern ?? false);
const menuColor =
restaurant?.menuColor || hydratedCache?.menuColor || DEFAULT_MENU_COLOR;
const baseTint = hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY); const baseTint = hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY);
useEffect(() => { useLayoutEffect(() => {
const revokeBlobUrl = () => { if (!restaurantSlug) return;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
setPatternImageUrl(null);
};
if (!isPattern || !restaurant?.bgUrl || !restaurantSlug) { const cached = getThemeCache(restaurantSlug);
setHydratedCache(cached);
if (!cached) return;
applyCachedThemeToDom(cached);
if (cached.patternDataUrl) {
setPatternImageUrl(cached.patternDataUrl);
}
}, [restaurantSlug]);
useEffect(() => {
if (!restaurantSlug) {
clearPatternCssVariables(); clearPatternCssVariables();
revokeBlobUrl(); removeCachedThemeBackground();
setPatternImageUrl(null);
return;
}
if (!restaurant) return;
const themeBase = buildThemeCacheFromRestaurant(restaurant);
if (!themeBase.isPattern || !themeBase.bgUrl) {
clearPatternCssVariables();
removeCachedThemeBackground();
setPatternImageUrl(null);
setThemeCache(restaurantSlug, {
...themeBase,
patternDataUrl: null,
});
return; return;
} }
applyPatternCssVariables(menuColor); applyPatternCssVariables(menuColor);
const cached = getThemeCache(restaurantSlug);
const cacheHit =
cached?.isPattern &&
cached.bgUrl === themeBase.bgUrl &&
cached.menuColor === menuColor &&
cached.patternDataUrl;
if (cacheHit) {
setPatternImageUrl(cached.patternDataUrl!);
setThemeCache(restaurantSlug, {
...themeBase,
patternDataUrl: cached.patternDataUrl,
});
applyCachedThemeToDom(cached);
return;
}
let cancelled = false; let cancelled = false;
const proxyUrl = `/${restaurantSlug}/api/proxy-svg?url=${encodeURIComponent(restaurant.bgUrl)}`; const proxyUrl = `/${restaurantSlug}/api/proxy-svg?url=${encodeURIComponent(themeBase.bgUrl)}`;
fetch(proxyUrl) fetch(proxyUrl)
.then((response) => { .then((response) => {
@@ -74,26 +125,31 @@ export function PatternBackgroundProvider({ children }: { children: ReactNode })
.then((svg) => { .then((svg) => {
if (cancelled) return; if (cancelled) return;
revokeBlobUrl(); const dataUrl = svgToDataUrl(colorizeSvg(svg, menuColor));
const blob = new Blob([colorizeSvg(svg, menuColor)], { setPatternImageUrl(dataUrl);
type: "image/svg+xml;charset=utf-8",
}); const nextCache = {
const blobUrl = URL.createObjectURL(blob); ...themeBase,
blobUrlRef.current = blobUrl; patternDataUrl: dataUrl,
setPatternImageUrl(blobUrl); };
setThemeCache(restaurantSlug, nextCache);
const saved = getThemeCache(restaurantSlug);
if (saved) {
applyCachedThemeToDom(saved);
}
}) })
.catch(() => { .catch(() => {
if (!cancelled) { if (!cancelled && cached?.patternDataUrl) {
revokeBlobUrl(); setPatternImageUrl(cached.patternDataUrl);
applyCachedThemeToDom(cached);
} }
}); });
return () => { return () => {
cancelled = true; cancelled = true;
clearPatternCssVariables();
revokeBlobUrl();
}; };
}, [isPattern, menuColor, restaurant?.bgUrl, restaurantSlug]); }, [menuColor, restaurant, restaurantSlug]);
const backgroundStyle = useMemo<CSSProperties>( const backgroundStyle = useMemo<CSSProperties>(
() => ({ () => ({
+9 -8
View File
@@ -81,10 +81,10 @@ function BottomNavBar({ onPagerClick }: BottomNavBarProps) {
{onPagerClick ? ( {onPagerClick ? (
<button <button
onClick={onPagerClick} onClick={onPagerClick}
className="flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto **:stroke-primary dark:text-white dark:**:stroke-white" className="flex flex-col justify-arround items-center gap-[5px] text-disabled-text pointer-events-auto **:stroke-disabled-text"
> >
<PagerIcon width={20} height={20} /> <PagerIcon width={20} height={20} />
<span className="text-xs2 dark:text-white"> <span className="text-xs2">
{t('Pager')} {t('Pager')}
</span> </span>
</button> </button>
@@ -95,9 +95,7 @@ function BottomNavBar({ onPagerClick }: BottomNavBarProps) {
href={`/${name}`} href={`/${name}`}
icon={ icon={
<HomeIcon <HomeIcon
className={clsx( className="transition-all duration-200 stroke-primary"
'transition-all duration-200 stroke-primary dark:stroke-black! dark:text-white'
)}
fill="white" fill="white"
stroke="currentColor" stroke="currentColor"
variant={isHomeRoute ? 'Bold' : 'Outline'} variant={isHomeRoute ? 'Bold' : 'Outline'}
@@ -107,10 +105,13 @@ function BottomNavBar({ onPagerClick }: BottomNavBarProps) {
<Link <Link
href={`/${name}/about`} href={`/${name}/about`}
className={clsx( className={clsx(
'flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto **:stroke-primary min-w-0 dark:text-white dark:**:stroke-white' 'flex flex-col justify-arround items-center gap-[5px] pointer-events-auto min-w-0',
isAboutRoute
? 'text-primary **:stroke-primary'
: 'text-disabled-text **:stroke-disabled-text',
)} )}
> >
<div className="shrink-0 text-primary dark:text-white"> <div className="shrink-0">
<Building <Building
key={`building-${buildingVariant}-${isAboutRoute}`} key={`building-${buildingVariant}-${isAboutRoute}`}
size={20} size={20}
@@ -119,7 +120,7 @@ function BottomNavBar({ onPagerClick }: BottomNavBarProps) {
className='stroke-0' className='stroke-0'
/> />
</div> </div>
<span className="text-xs2 text-center truncate w-full dark:text-white"> <span className="text-xs2 text-center truncate w-full">
{t('about')} {t('about')}
</span> </span>
</Link> </Link>
+5 -5
View File
@@ -18,16 +18,16 @@ function BottomNavLink({ icon, value, href, prefetch, ...restProps }: Props) {
return ( return (
<Link {...restProps} href={href} prefetch={shouldPrefetch} className={clsx( <Link {...restProps} href={href} prefetch={shouldPrefetch} className={clsx(
'flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto **:stroke-primary min-w-0 dark:text-white dark:**:stroke-white', 'flex flex-col justify-arround items-center gap-[5px] pointer-events-auto min-w-0',
isActive && 'text-primary-foreground **:stroke-primary-foreground', isActive
? 'text-primary **:stroke-primary'
: 'text-disabled-text **:stroke-disabled-text',
restProps.className ?? '', restProps.className ?? '',
)}> )}>
<div className="shrink-0"> <div className="shrink-0">
{icon} {icon}
</div> </div>
<span className={clsx( <span className="text-xs2 text-center truncate w-full">
'text-xs2 text-center truncate w-full dark:text-white',
)}>
{value} {value}
</span> </span>
</Link> </Link>
@@ -3,7 +3,6 @@
import Link from 'next/link' import Link from 'next/link'
import React from 'react' import React from 'react'
import type { LinkProps } from 'next/link' import type { LinkProps } from 'next/link'
import { usePathname } from 'next/navigation'
import clsx from 'clsx' import clsx from 'clsx'
type Props = { type Props = {
@@ -12,9 +11,6 @@ type Props = {
} & LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement> } & LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
function BottomNavHighlightLink({ icon, value, href, ...restProps }: Props) { function BottomNavHighlightLink({ icon, value, href, ...restProps }: Props) {
const pathname = usePathname();
const isActive = pathname === href;
return ( return (
<Link {...restProps} href={href} className={clsx( <Link {...restProps} href={href} className={clsx(
'flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto', 'flex flex-col justify-arround items-center gap-[5px] text-primary pointer-events-auto',
@@ -25,7 +21,7 @@ function BottomNavHighlightLink({ icon, value, href, ...restProps }: Props) {
)}> )}>
{icon} {icon}
</div> </div>
<span className="text-xs2 dark:text-white"> <span className="text-xs2 text-disabled-text">
{value} {value}
</span> </span>
</Link> </Link>
+9 -7
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React, { useLayoutEffect, useState } from 'react'
import PingAnimation from './animations/PingAnimation'; import PingAnimation from './animations/PingAnimation';
type Props = { type Props = {
@@ -10,21 +10,23 @@ type Props = {
} & React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> } & React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
const LoadingOverlay = ({ visible = true, bgOpacity = 30, delay = 0, children = <PingAnimation /> }: Props) => { const LoadingOverlay = ({ visible = true, bgOpacity = 30, delay = 0, children = <PingAnimation /> }: Props) => {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(visible);
useEffect(() => { useLayoutEffect(() => {
// Trigger fade-in after mount setLoaded(visible);
const timeout = setTimeout(() => setLoaded(visible), 300);
return () => clearTimeout(timeout);
}, [visible]); }, [visible]);
if (!visible) {
return null;
}
return ( return (
<div <div
data-visible={visible} data-visible={visible}
data-loaded={loaded} data-loaded={loaded}
className={`absolute inset-0 flex items-center justify-center className={`absolute inset-0 flex items-center justify-center
backdrop-blur-sm bg-background/${bgOpacity} backdrop-blur-sm bg-background/${bgOpacity}
opacity-0 transition-opacity duration-300 ease-in-out delay-${delay} transition-opacity duration-300 ease-in-out delay-${delay}
data-[loaded=false]:-z-50 data-[loaded=false]:-z-50
data-[visible=true]:z-50 data-[visible=true]:z-50
data-[visible=true]:opacity-100`} data-[visible=true]:opacity-100`}
+1 -6
View File
@@ -55,7 +55,6 @@ export default function SplashScreen({
}; };
}, []); }, []);
const showLogoSpinner = Boolean(logo) && logoState === 'loading';
return ( return (
<motion.div <motion.div
@@ -112,11 +111,7 @@ export default function SplashScreen({
transition: 'opacity 0.3s', transition: 'opacity 0.3s',
}} }}
/> />
{showLogoSpinner && (
<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> </div>
)} )}
+80 -29
View File
@@ -1,20 +1,52 @@
'use client'; 'use client';
import usePreference from '@/hooks/helpers/usePreference'; import usePreference from '@/hooks/helpers/usePreference';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from 'next/navigation'; 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';
import {
applyCachedThemeToDom,
buildThemeCacheFromRestaurant,
getCachedMenuColor,
getThemeCache,
setThemeCache,
} from '@/lib/helpers/themeCache';
function getSplashStorageKey(restaurantSlug: string) { function getSplashStorageKey(restaurantSlug: string) {
return `splash-seen:${restaurantSlug}`; return `splash-seen:${restaurantSlug}`;
} }
function getSplashSessionKey(restaurantSlug: string) {
return `splash-session:${restaurantSlug}`;
}
function getSplashFingerprint(logo?: string | null, name?: string | null) { function getSplashFingerprint(logo?: string | null, name?: string | null) {
return `${logo ?? ''}|${name ?? ''}`; return `${logo ?? ''}|${name ?? ''}`;
} }
function readStoredRestaurantIdentity() {
return {
logo: localStorage.getItem('restaurant-logo'),
name: localStorage.getItem('restaurant-name'),
};
}
function resolveSeenSplashFingerprint(restaurantSlug: string, currentFingerprint: string) {
const storageKey = getSplashStorageKey(restaurantSlug);
const seenFingerprint = localStorage.getItem(storageKey);
if (!seenFingerprint || seenFingerprint === '|') {
if (currentFingerprint !== '|') {
localStorage.setItem(storageKey, currentFingerprint);
return currentFingerprint;
}
}
return seenFingerprint;
}
type Props = { type Props = {
children: React.ReactNode children: React.ReactNode
} }
@@ -43,69 +75,88 @@ function PreferenceWrapper({ children }: Props) {
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);
const splashCheckedRef = useRef(false);
const handleSplashDismiss = useCallback(() => { const handleSplashDismiss = useCallback(() => {
setShowSplash(false); setShowSplash(false);
if (!restaurantSlug) return; if (!restaurantSlug) return;
const logo = aboutData?.data?.logo || cachedLogo; const stored = readStoredRestaurantIdentity();
const name = aboutData?.data?.name || cachedName; const logo = aboutData?.data?.logo || stored.logo || cachedLogo;
const name = aboutData?.data?.name || stored.name || cachedName;
const fingerprint = getSplashFingerprint(logo, name); const fingerprint = getSplashFingerprint(logo, name);
localStorage.setItem(getSplashStorageKey(restaurantSlug), fingerprint); localStorage.setItem(getSplashStorageKey(restaurantSlug), fingerprint);
sessionStorage.setItem(getSplashSessionKey(restaurantSlug), '1');
}, [aboutData?.data?.logo, aboutData?.data?.name, cachedLogo, cachedName, restaurantSlug]); }, [aboutData?.data?.logo, aboutData?.data?.name, cachedLogo, cachedName, restaurantSlug]);
useEffect(() => { useEffect(() => {
if (splashCheckedRef.current) return;
splashCheckedRef.current = true;
setMounted(true); setMounted(true);
const savedColor = localStorage.getItem('theme-primary-color'); const savedColor = getCachedMenuColor(restaurantSlug);
if (savedColor) { const cachedTheme = restaurantSlug ? getThemeCache(restaurantSlug) : null;
if (cachedTheme) {
applyCachedThemeToDom(cachedTheme);
} else if (savedColor) {
applyPrimaryColor(savedColor); applyPrimaryColor(savedColor);
} }
const savedLogo = localStorage.getItem('restaurant-logo'); const { logo: savedLogo, name: savedName } = readStoredRestaurantIdentity();
const savedName = localStorage.getItem('restaurant-name');
if (savedLogo) setCachedLogo(savedLogo); if (savedLogo) setCachedLogo(savedLogo);
if (savedName) setCachedName(savedName); if (savedName) setCachedName(savedName);
if (!restaurantSlug) return; if (!restaurantSlug) return;
if (sessionStorage.getItem(getSplashSessionKey(restaurantSlug))) {
return;
}
const currentFingerprint = getSplashFingerprint(savedLogo, savedName); const currentFingerprint = getSplashFingerprint(savedLogo, savedName);
const seenFingerprint = localStorage.getItem(getSplashStorageKey(restaurantSlug)); const seenFingerprint = resolveSeenSplashFingerprint(restaurantSlug, currentFingerprint);
if (seenFingerprint !== currentFingerprint) { if (seenFingerprint !== currentFingerprint) {
setShowSplash(true); setShowSplash(true);
sessionStorage.setItem(getSplashSessionKey(restaurantSlug), '1');
} }
}, [restaurantSlug]); }, [restaurantSlug]);
useEffect(() => { useEffect(() => {
if (aboutData?.data?.menuColor) { const menuColor = aboutData?.data?.menuColor;
localStorage.setItem('theme-primary-color', aboutData.data.menuColor); if (!menuColor || !restaurantSlug) return;
if (aboutData.data.logo) { const restaurant = aboutData.data;
localStorage.setItem('restaurant-logo', aboutData.data.logo); const cached = getThemeCache(restaurantSlug);
setThemeCache(restaurantSlug, {
menuColor,
bgUrl: restaurant.bgUrl,
isPattern: buildThemeCacheFromRestaurant(restaurant).isPattern,
patternDataUrl: cached?.patternDataUrl ?? null,
});
if (restaurant.logo) {
localStorage.setItem('restaurant-logo', restaurant.logo);
} }
if (aboutData.data.name) { if (restaurant.name) {
localStorage.setItem('restaurant-name', aboutData.data.name); localStorage.setItem('restaurant-name', restaurant.name);
} }
applyPrimaryColor(aboutData.data.menuColor); applyPrimaryColor(menuColor);
const currentFingerprint = getSplashFingerprint(
restaurant.logo ?? readStoredRestaurantIdentity().logo,
restaurant.name ?? readStoredRestaurantIdentity().name,
);
const seenFingerprint = localStorage.getItem(getSplashStorageKey(restaurantSlug));
if (seenFingerprint === '|' && currentFingerprint !== '|') {
localStorage.setItem(getSplashStorageKey(restaurantSlug), currentFingerprint);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [aboutData?.data?.menuColor]); }, [aboutData?.data?.menuColor, restaurantSlug]);
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');
+24 -10
View File
@@ -2,15 +2,13 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import {
getRestaurantSlugFromPath,
restorePersistedRestaurantQueries,
} from "@/lib/helpers/themeCache";
export function ReactQueryProvider({ function createQueryClient() {
children, const client = new QueryClient({
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
retry: false, retry: false,
@@ -20,8 +18,24 @@ export function ReactQueryProvider({
retry: false, retry: false,
}, },
}, },
}), });
);
if (typeof window !== "undefined") {
const slug = getRestaurantSlugFromPath();
if (slug) {
restorePersistedRestaurantQueries(client, slug);
}
}
return client;
}
export function ReactQueryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(createQueryClient);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+3
View File
@@ -0,0 +1,3 @@
export function getThemeBootScript(): string {
return `(function(){try{var s=location.pathname.split("/").filter(Boolean)[0],t=null;if(s&&s!=="auth"){var r=localStorage.getItem("restaurant-theme:"+s);if(r)t=JSON.parse(r)}if(!t||t.version!==1||!t.menuColor){var l=localStorage.getItem("theme-primary-color");if(l){var x=l.replace("#","");if(x.length===6){var p=parseInt(x.slice(0,2),16),f=parseInt(x.slice(2,4),16),m=parseInt(x.slice(4,6),16),b="rgba("+p+","+f+","+m+",0.05)";document.documentElement.style.setProperty("--background",b)}}return}function h(n,a){var u=n.replace("#","");if(u.length!==6)return null;var v=parseInt(u.slice(0,2),16),g=parseInt(u.slice(2,4),16),y=parseInt(u.slice(4,6),16);return"rgba("+v+","+g+","+y+","+a+")"}var e=document.documentElement;if(t.primaryOklch){e.style.setProperty("--primary",t.primaryOklch);e.style.setProperty("--primary-foreground",t.primaryForeground||"oklch(1 0 0)")}if(!t.isPattern)return;e.setAttribute("data-pattern-bg","true");var k=t.backgroundTint||h(t.menuColor,0.05),w=t.bgPatternVector||h(t.menuColor,0.1),z=t.primaryLight||h(t.menuColor,0.15);k&&(e.style.setProperty("--background",k),e.style.setProperty("--bg-pattern-base",k));w&&e.style.setProperty("--bg-pattern-vector",w);z&&e.style.setProperty("--primary-light",z);if(t.patternDataUrl&&k){var d=document.createElement("div");d.id="cached-theme-bg";d.setAttribute("aria-hidden","true");d.style.cssText="position:fixed;inset:0;pointer-events:none;z-index:0;background-color:"+k+';background-image:url("'+t.patternDataUrl+'");background-repeat:repeat;background-size:auto;';document.body.appendChild(d)}}catch(u){}})();`;
}
+303
View File
@@ -0,0 +1,303 @@
import { calculateBrightness, hexToOklch } from "@/lib/helpers/colorUtils";
import {
hexToRgba,
isPatternBackground,
PATTERN_BACKGROUND_OPACITY,
PATTERN_VECTOR_OPACITY,
PRIMARY_LIGHT_OPACITY,
type RestaurantBackgroundSettings,
} from "@/lib/helpers/backgroundUtils";
import type { AboutResponse } from "@/app/[name]/(Main)/about/types/Types";
import type {
CategoriesResponse,
FoodsResponse,
} from "@/app/[name]/(Main)/types/Types";
import type { QueryClient } from "@tanstack/react-query";
export type CachedRestaurantTheme = {
version: 1;
menuColor: string;
bgUrl?: string | null;
isPattern: boolean;
patternDataUrl?: string | null;
primaryOklch?: string;
primaryForeground?: string;
backgroundTint?: string;
bgPatternVector?: string;
primaryLight?: string;
};
const CACHE_VERSION = 1;
const CACHE_KEY_PREFIX = "restaurant-theme";
const LEGACY_COLOR_KEY = "theme-primary-color";
const ABOUT_CACHE_PREFIX = "restaurant-about";
const MENU_CACHE_PREFIX = "restaurant-menu";
const CATEGORIES_CACHE_PREFIX = "restaurant-categories";
export const THEME_BG_ELEMENT_ID = "cached-theme-bg";
export function getThemeCacheKey(slug: string): string {
return `${CACHE_KEY_PREFIX}:${slug}`;
}
export function getAboutCacheKey(slug: string): string {
return `${ABOUT_CACHE_PREFIX}:${slug}`;
}
export function getMenuCacheKey(slug: string): string {
return `${MENU_CACHE_PREFIX}:${slug}`;
}
export function getCategoriesCacheKey(slug: string): string {
return `${CATEGORIES_CACHE_PREFIX}:${slug}`;
}
export function getRestaurantSlugFromPath(pathname?: string): string | null {
const path =
pathname ?? (typeof window !== "undefined" ? window.location.pathname : "");
const segment = path.split("/").filter(Boolean)[0];
if (!segment || segment === "auth") return null;
return segment;
}
export function computeThemeCssValues(menuColor: string) {
const primaryOklch = hexToOklch(menuColor);
const brightness = calculateBrightness(menuColor);
return {
primaryOklch: primaryOklch ?? undefined,
primaryForeground: brightness > 0.5 ? "oklch(0 0 0)" : "oklch(1 0 0)",
backgroundTint: hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY),
bgPatternVector: hexToRgba(menuColor, PATTERN_VECTOR_OPACITY),
primaryLight: hexToRgba(menuColor, PRIMARY_LIGHT_OPACITY),
};
}
export function getThemeCache(slug: string): CachedRestaurantTheme | null {
if (!slug || typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(getThemeCacheKey(slug));
if (!raw) return null;
const parsed = JSON.parse(raw) as CachedRestaurantTheme;
if (parsed.version !== CACHE_VERSION || !parsed.menuColor) return null;
if (!parsed.primaryOklch) {
const enriched: CachedRestaurantTheme = {
...parsed,
...computeThemeCssValues(parsed.menuColor),
version: CACHE_VERSION,
};
try {
localStorage.setItem(getThemeCacheKey(slug), JSON.stringify(enriched));
} catch {
// Ignore quota errors during migration
}
return enriched;
}
return parsed;
} catch {
return null;
}
}
export function getCachedMenuColor(slug?: string | null): string | null {
if (slug) {
const cached = getThemeCache(slug);
if (cached?.menuColor) return cached.menuColor;
}
if (typeof window === "undefined") return null;
try {
return localStorage.getItem(LEGACY_COLOR_KEY);
} catch {
return null;
}
}
export function getPersistedAboutData(slug: string): AboutResponse | undefined {
if (!slug || typeof window === "undefined") return undefined;
try {
const raw = localStorage.getItem(getAboutCacheKey(slug));
if (!raw) return undefined;
return JSON.parse(raw) as AboutResponse;
} catch {
return undefined;
}
}
export function setPersistedAboutData(slug: string, data: AboutResponse): void {
if (!slug || typeof window === "undefined") return;
try {
localStorage.setItem(getAboutCacheKey(slug), JSON.stringify(data));
} catch {
// Ignore quota errors
}
}
function readPersistedQueryData<T>(key: string): T | undefined {
try {
const raw = localStorage.getItem(key);
if (!raw) return undefined;
return JSON.parse(raw) as T;
} catch {
return undefined;
}
}
function writePersistedQueryData<T>(key: string, data: T): void {
try {
localStorage.setItem(key, JSON.stringify(data));
} catch {
// Ignore quota errors
}
}
export function getPersistedMenuData(slug: string): FoodsResponse | undefined {
if (!slug || typeof window === "undefined") return undefined;
return readPersistedQueryData<FoodsResponse>(getMenuCacheKey(slug));
}
export function setPersistedMenuData(slug: string, data: FoodsResponse): void {
if (!slug || typeof window === "undefined") return;
writePersistedQueryData(getMenuCacheKey(slug), data);
}
export function getPersistedCategoriesData(
slug: string,
): CategoriesResponse | undefined {
if (!slug || typeof window === "undefined") return undefined;
return readPersistedQueryData<CategoriesResponse>(getCategoriesCacheKey(slug));
}
export function setPersistedCategoriesData(
slug: string,
data: CategoriesResponse,
): void {
if (!slug || typeof window === "undefined") return;
writePersistedQueryData(getCategoriesCacheKey(slug), data);
}
export function restorePersistedRestaurantQueries(
client: QueryClient,
slug: string,
): void {
const about = getPersistedAboutData(slug);
if (about) {
client.setQueryData(["about", slug], about);
}
const menu = getPersistedMenuData(slug);
if (menu) {
client.setQueryData(["menu", slug], menu);
}
const categories = getPersistedCategoriesData(slug);
if (categories) {
client.setQueryData(["categories", slug], categories);
}
}
export function setThemeCache(
slug: string,
theme: Omit<CachedRestaurantTheme, "version">,
): void {
if (!slug || typeof window === "undefined") return;
const existing = getThemeCache(slug);
const css = computeThemeCssValues(theme.menuColor);
const payload: CachedRestaurantTheme = {
...theme,
...css,
patternDataUrl: theme.patternDataUrl ?? existing?.patternDataUrl ?? null,
version: CACHE_VERSION,
};
try {
localStorage.setItem(getThemeCacheKey(slug), JSON.stringify(payload));
localStorage.setItem(LEGACY_COLOR_KEY, theme.menuColor);
} catch {
try {
const { patternDataUrl: _pattern, ...withoutPattern } = payload;
localStorage.setItem(
getThemeCacheKey(slug),
JSON.stringify({ ...withoutPattern, patternDataUrl: null }),
);
localStorage.setItem(LEGACY_COLOR_KEY, theme.menuColor);
} catch {
// Ignore quota errors
}
}
}
export function buildThemeCacheFromRestaurant(
settings: RestaurantBackgroundSettings,
): Pick<CachedRestaurantTheme, "menuColor" | "bgUrl" | "isPattern"> {
return {
menuColor: settings.menuColor || "#1E3A8A",
bgUrl: settings.bgUrl,
isPattern: isPatternBackground(settings),
};
}
export function svgToDataUrl(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
export function applyCachedThemeToDom(theme: CachedRestaurantTheme): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (theme.primaryOklch) {
root.style.setProperty("--primary", theme.primaryOklch);
root.style.setProperty(
"--primary-foreground",
theme.primaryForeground ?? "oklch(1 0 0)",
);
}
if (!theme.isPattern) return;
root.setAttribute("data-pattern-bg", "true");
if (theme.backgroundTint) {
root.style.setProperty("--background", theme.backgroundTint);
root.style.setProperty("--bg-pattern-base", theme.backgroundTint);
}
if (theme.bgPatternVector) {
root.style.setProperty("--bg-pattern-vector", theme.bgPatternVector);
}
if (theme.primaryLight) {
root.style.setProperty("--primary-light", theme.primaryLight);
}
if (!theme.patternDataUrl || !theme.backgroundTint) return;
let bgElement = document.getElementById(THEME_BG_ELEMENT_ID);
if (!bgElement) {
bgElement = document.createElement("div");
bgElement.id = THEME_BG_ELEMENT_ID;
bgElement.setAttribute("aria-hidden", "true");
bgElement.style.position = "fixed";
bgElement.style.inset = "0";
bgElement.style.pointerEvents = "none";
bgElement.style.zIndex = "0";
document.body.appendChild(bgElement);
}
bgElement.style.backgroundColor = theme.backgroundTint;
bgElement.style.backgroundImage = `url("${theme.patternDataUrl}")`;
bgElement.style.backgroundRepeat = "repeat";
bgElement.style.backgroundSize = "auto";
}
export function removeCachedThemeBackground(): void {
document.getElementById(THEME_BG_ELEMENT_ID)?.remove();
}