Cache
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import LoadingOverlay from '@/components/overlays/LoadingOverlay'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
function Loading() {
|
|
||||||
return (
|
|
||||||
<LoadingOverlay />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Loading
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import LoadingOverlay from '@/components/overlays/LoadingOverlay'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
function Loading() {
|
|
||||||
return (
|
|
||||||
<LoadingOverlay />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Loading
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getThemeBootScript } from "@/lib/helpers/themeBootScript";
|
||||||
|
|
||||||
|
export function ThemeBootScript() {
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
id="theme-boot"
|
||||||
|
dangerouslySetInnerHTML={{ __html: getThemeBootScript() }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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){}})();`;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user