+
{children}
{/* */}
@@ -32,4 +33,4 @@ function AuthFormWrapper({ children, isPending, onSubmit, ...restProps }: Props)
)
}
-export default AuthFormWrapper
\ No newline at end of file
+export default AuthFormWrapper
diff --git a/src/features/providers/ReactQueryProvider.tsx b/src/features/providers/ReactQueryProvider.tsx
index 9318dd7..7e7dc84 100644
--- a/src/features/providers/ReactQueryProvider.tsx
+++ b/src/features/providers/ReactQueryProvider.tsx
@@ -1,16 +1,45 @@
-'use client'
+"use client";
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
-import { useState } from 'react'
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useState } from "react";
+import {
+ getRestaurantSlugFromPath,
+ restorePersistedRestaurantQueries,
+} from "@/lib/helpers/themeCache";
-export function ReactQueryProvider({ children }: { children: React.ReactNode }) {
- const [queryClient] = useState(() => new QueryClient())
+function createQueryClient() {
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ retryOnMount: false,
+ },
+ mutations: {
+ 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 (
{children}
- {/* */}
- )
+ );
}
diff --git a/src/lib/helpers/backgroundUtils.ts b/src/lib/helpers/backgroundUtils.ts
new file mode 100644
index 0000000..87cf78d
--- /dev/null
+++ b/src/lib/helpers/backgroundUtils.ts
@@ -0,0 +1,155 @@
+export const PATTERN_BACKGROUND_OPACITY = 0.05;
+export const PATTERN_VECTOR_OPACITY = 0.1;
+export const PRIMARY_LIGHT_OPACITY = 0.15;
+
+export type BgType = "pattern" | "custom" | "color";
+
+export type RestaurantBackgroundSettings = {
+ bgType?: BgType | string | null;
+ bgUrl?: string | null;
+ bgBlur?: number | string | null;
+ bgOpacity?: number | string | null;
+ bgOverlay?: string | null;
+ menuColor?: string | null;
+ isPattern?: boolean;
+};
+
+export const normalizeBgNumber = (value: unknown): number | null => {
+ if (value == null || value === "") return null;
+ const parsed = typeof value === "string" ? Number(value) : value;
+ if (typeof parsed !== "number" || Number.isNaN(parsed)) return null;
+ return parsed;
+};
+
+export const hexToRgba = (hex: string, alpha: number): string => {
+ const normalized = hex.replace("#", "");
+ if (normalized.length !== 6) {
+ return `rgba(0, 0, 0, ${alpha})`;
+ }
+
+ const r = parseInt(normalized.slice(0, 2), 16);
+ const g = parseInt(normalized.slice(2, 4), 16);
+ const b = parseInt(normalized.slice(4, 6), 16);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+};
+
+const isUnsetBgValue = (value: unknown): boolean =>
+ value == null || value === "" || value === 0;
+
+const isCustomImageBackgroundLegacy = (
+ settings: RestaurantBackgroundSettings,
+): boolean => {
+ if (!settings.bgUrl) return false;
+
+ return (
+ !isUnsetBgValue(settings.bgBlur) ||
+ !isUnsetBgValue(settings.bgOpacity) ||
+ !isUnsetBgValue(settings.bgOverlay)
+ );
+};
+
+export const resolveBgType = (
+ settings: RestaurantBackgroundSettings,
+): BgType => {
+ if (settings.bgType === null) return "color";
+
+ const normalized = settings.bgType?.toLowerCase();
+ if (
+ normalized === "pattern" ||
+ normalized === "custom" ||
+ normalized === "color"
+ ) {
+ return normalized;
+ }
+
+ if (settings.isPattern === true) return "pattern";
+ if (settings.isPattern === false && settings.bgUrl && isCustomImageBackgroundLegacy(settings)) {
+ return "custom";
+ }
+ if (settings.isPattern === false) return "color";
+ if (isCustomImageBackgroundLegacy(settings)) return "custom";
+ if (settings.bgUrl) return "pattern";
+
+ return "color";
+};
+
+export const isCustomImageBackground = (
+ settings: RestaurantBackgroundSettings,
+): boolean => resolveBgType(settings) === "custom";
+
+export const isPatternBackground = (
+ settings: RestaurantBackgroundSettings,
+): boolean => resolveBgType(settings) === "pattern";
+
+export const isColorBackground = (
+ settings: RestaurantBackgroundSettings,
+): boolean => resolveBgType(settings) === "color";
+
+export const colorizeSvg = (
+ svg: string,
+ color: string,
+ vectorOpacity: number = PATTERN_VECTOR_OPACITY,
+): string => {
+ let result = svg.replace(/fill="(?!black|none)[^"]+"/g, `fill="${color}"`);
+ result = result.replace(
+ /fill-opacity="[^"]+"/g,
+ `fill-opacity="${vectorOpacity}"`,
+ );
+ result = result.replace(
+ /(<(?:path|circle|rect|ellipse|polygon)[^>]*fill="(?!black|none)[^"]+")(?![^>]*fill-opacity)/g,
+ `$1 fill-opacity="${vectorOpacity}"`,
+ );
+ return result;
+};
+
+export const applyColorBackgroundCssVariables = (menuColor: string) => {
+ const root = document.documentElement;
+ root.removeAttribute("data-pattern-bg");
+ root.removeAttribute("data-image-bg");
+ root.style.removeProperty("--bg-pattern-base");
+ root.style.removeProperty("--bg-pattern-vector");
+ root.style.setProperty(
+ "--background",
+ hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY),
+ );
+ root.style.setProperty(
+ "--primary-light",
+ hexToRgba(menuColor, PRIMARY_LIGHT_OPACITY),
+ );
+};
+
+export const applyPatternCssVariables = (menuColor: string) => {
+ const root = document.documentElement;
+ const baseTint = hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY);
+
+ root.setAttribute("data-pattern-bg", "true");
+ root.style.setProperty("--bg-pattern-base", baseTint);
+ root.style.setProperty(
+ "--bg-pattern-vector",
+ hexToRgba(menuColor, PATTERN_VECTOR_OPACITY),
+ );
+ root.style.setProperty(
+ "--primary-light",
+ hexToRgba(menuColor, PRIMARY_LIGHT_OPACITY),
+ );
+ root.style.setProperty("--background", baseTint);
+};
+
+export const clearPatternCssVariables = () => {
+ const root = document.documentElement;
+ root.removeAttribute("data-pattern-bg");
+ root.style.removeProperty("--bg-pattern-base");
+ root.style.removeProperty("--bg-pattern-vector");
+ root.style.removeProperty("--primary-light");
+ root.style.removeProperty("--background");
+};
+
+export const applyCustomImageCssVariables = () => {
+ const root = document.documentElement;
+ root.setAttribute("data-image-bg", "true");
+ root.removeAttribute("data-pattern-bg");
+};
+
+export const clearCustomImageCssVariables = () => {
+ document.documentElement.removeAttribute("data-image-bg");
+};
diff --git a/src/lib/helpers/fetchWithTimeout.ts b/src/lib/helpers/fetchWithTimeout.ts
new file mode 100644
index 0000000..15ffb51
--- /dev/null
+++ b/src/lib/helpers/fetchWithTimeout.ts
@@ -0,0 +1,13 @@
+const DEFAULT_TIMEOUT_MS = 5000;
+
+export async function fetchWithTimeout(
+ url: string,
+ options: RequestInit & { timeoutMs?: number } = {},
+): Promise
{
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options;
+
+ return fetch(url, {
+ ...fetchOptions,
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+}
diff --git a/src/lib/helpers/themeBootScript.ts b/src/lib/helpers/themeBootScript.ts
new file mode 100644
index 0000000..554d7ed
--- /dev/null
+++ b/src/lib/helpers/themeBootScript.ts
@@ -0,0 +1,3 @@
+export function getThemeBootScript(): string {
+ return `(function(){try{function isNight(){try{var p=localStorage.getItem("preference-storage");if(!p)return false;var o=JSON.parse(p);return o&&o.state&&o.state.items&&o.state.items["night-mode"]&&o.state.items["night-mode"].value===true}catch(v){return false}}var night=isNight(),e=document.documentElement;e.setAttribute("data-theme",night?"dark":"light");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&&!night){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)";e.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+")"}function isUnset(v){return v==null||v===""||v===0}function isCustomImg(th){if(!th.bgUrl)return false;return !isUnset(th.bgBlur)||!isUnset(th.bgOpacity)||!isUnset(th.bgOverlay)}function resolveBgType(th){if(th.bgType===null)return"color";var bt=(th.bgType||"").toLowerCase();if(bt==="pattern"||bt==="custom"||bt==="color")return bt;if(th.isPattern===true)return"pattern";if(th.isPattern===false&&th.bgUrl&&isCustomImg(th))return"custom";if(th.isPattern===false)return"color";if(isCustomImg(th))return"custom";if(th.bgUrl)return"pattern";return"color"}if(t.primaryOklch){e.style.setProperty("--primary",t.primaryOklch);e.style.setProperty("--primary-foreground",t.primaryForeground||"oklch(1 0 0)")}if(night)return;var bt=resolveBgType(t);if(bt==="color"){var k=t.backgroundTint||h(t.menuColor,0.05),z=t.primaryLight||h(t.menuColor,0.15);k&&e.style.setProperty("--background",k);z&&e.style.setProperty("--primary-light",z);return};if(bt==="pattern"){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(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+(t.patternDataUrl?';background-image:url("'+t.patternDataUrl+'");background-repeat:repeat;background-size:auto':"")+";";document.body.appendChild(d)}}else if(bt==="custom"){e.setAttribute("data-image-bg","true");var d2=document.createElement("div");d2.id="cached-theme-bg";d2.setAttribute("aria-hidden","true");d2.style.cssText="position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;";var i=document.createElement("div");i.id="cached-theme-bg-img";i.style.cssText="position:absolute;inset:-40px;background-size:cover;background-position:center;background-repeat:no-repeat;background-image:url(\\""+t.bgUrl+"\\");"+(t.bgBlur?"filter:blur("+t.bgBlur+"px);":"")+"opacity:"+(t.bgOpacity!=null?t.bgOpacity/100:1)+";";d2.appendChild(i);var ov=document.createElement("div");ov.id="cached-theme-bg-overlay";ov.style.cssText="position:absolute;inset:0;"+(t.bgOverlay&&Number(t.bgOverlay)>0?"background-color:rgba(0,0,0,"+Number(t.bgOverlay)/100+");":"");d2.appendChild(ov);document.body.appendChild(d2)}}catch(u){}})();`;
+}
diff --git a/src/lib/helpers/themeCache.ts b/src/lib/helpers/themeCache.ts
new file mode 100644
index 0000000..beeefc9
--- /dev/null
+++ b/src/lib/helpers/themeCache.ts
@@ -0,0 +1,456 @@
+import { calculateBrightness, hexToOklch } from "@/lib/helpers/colorUtils";
+import {
+ hexToRgba,
+ resolveBgType,
+ normalizeBgNumber,
+ PATTERN_BACKGROUND_OPACITY,
+ PATTERN_VECTOR_OPACITY,
+ PRIMARY_LIGHT_OPACITY,
+ type BgType,
+ type RestaurantBackgroundSettings,
+} from "@/lib/helpers/backgroundUtils";
+import type { AboutResponse } from "@/app/[name]/(Main)/about/types/Types";
+import type {
+ CategoriesResponse,
+ ProductsResponse,
+} from "@/app/[name]/(Main)/types/Types";
+import type { QueryClient } from "@tanstack/react-query";
+
+export type CachedRestaurantTheme = {
+ version: 1;
+ menuColor: string;
+ bgType?: BgType;
+ bgUrl?: string | null;
+ bgBlur?: number | null;
+ bgOpacity?: number | null;
+ bgOverlay?: 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";
+const THEME_BG_IMG_ID = "cached-theme-bg-img";
+const THEME_BG_OVERLAY_ID = "cached-theme-bg-overlay";
+const PREFERENCE_STORAGE_KEY = "preference-storage";
+
+export function isNightModeEnabled(): boolean {
+ if (typeof window === "undefined") return false;
+
+ try {
+ const raw = localStorage.getItem(PREFERENCE_STORAGE_KEY);
+ if (!raw) return false;
+ const parsed = JSON.parse(raw) as {
+ state?: { items?: Record };
+ };
+ return parsed?.state?.items?.["night-mode"]?.value === true;
+ } catch {
+ return false;
+ }
+}
+
+export function clearRestaurantBackgroundOverrides(): void {
+ if (typeof document === "undefined") return;
+
+ const root = document.documentElement;
+ root.removeAttribute("data-pattern-bg");
+ root.removeAttribute("data-image-bg");
+ root.style.removeProperty("--background");
+ root.style.removeProperty("--bg-pattern-base");
+ root.style.removeProperty("--bg-pattern-vector");
+ root.style.removeProperty("--primary-light");
+ document.getElementById(THEME_BG_ELEMENT_ID)?.remove();
+}
+
+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(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(key: string, data: T): void {
+ try {
+ localStorage.setItem(key, JSON.stringify(data));
+ } catch {
+ // Ignore quota errors
+ }
+}
+
+export function getPersistedMenuData(slug: string): ProductsResponse | undefined {
+ if (!slug || typeof window === "undefined") return undefined;
+ return readPersistedQueryData(getMenuCacheKey(slug));
+}
+
+export function setPersistedMenuData(slug: string, data: ProductsResponse): 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(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,
+): void {
+ if (!slug || typeof window === "undefined") return;
+
+ const existing = getThemeCache(slug);
+ const css = computeThemeCssValues(theme.menuColor);
+ const payload: CachedRestaurantTheme = {
+ ...theme,
+ ...css,
+ patternDataUrl:
+ theme.patternDataUrl !== undefined
+ ? 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"
+ | "bgType"
+ | "bgUrl"
+ | "bgBlur"
+ | "bgOpacity"
+ | "bgOverlay"
+ | "isPattern"
+> {
+ const bgType = resolveBgType(settings);
+
+ return {
+ menuColor: settings.menuColor || "#1E3A8A",
+ bgType,
+ bgUrl: settings.bgUrl,
+ bgBlur: normalizeBgNumber(settings.bgBlur),
+ bgOpacity: normalizeBgNumber(settings.bgOpacity),
+ bgOverlay: settings.bgOverlay ?? null,
+ isPattern: bgType === "pattern",
+ };
+}
+
+export function themeBackgroundMatches(
+ cached: CachedRestaurantTheme,
+ settings: RestaurantBackgroundSettings,
+): boolean {
+ const next = buildThemeCacheFromRestaurant(settings);
+ return (
+ cached.menuColor === next.menuColor &&
+ cached.bgType === next.bgType &&
+ cached.bgUrl === next.bgUrl &&
+ cached.bgBlur === next.bgBlur &&
+ cached.bgOpacity === next.bgOpacity &&
+ cached.bgOverlay === next.bgOverlay &&
+ cached.isPattern === next.isPattern
+ );
+}
+
+export function svgToDataUrl(svg: string): string {
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+}
+
+function ensureBgElement(): HTMLElement {
+ 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);
+ }
+ return bgElement;
+}
+
+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 (isNightModeEnabled()) {
+ clearRestaurantBackgroundOverrides();
+ return;
+ }
+
+ const bgType = resolveBgType(theme);
+
+ if (bgType === "color") {
+ document.getElementById(THEME_BG_ELEMENT_ID)?.remove();
+ const tint =
+ theme.backgroundTint ??
+ hexToRgba(theme.menuColor, PATTERN_BACKGROUND_OPACITY);
+ const primaryLight =
+ theme.primaryLight ??
+ hexToRgba(theme.menuColor, PRIMARY_LIGHT_OPACITY);
+ root.removeAttribute("data-pattern-bg");
+ root.removeAttribute("data-image-bg");
+ root.style.removeProperty("--bg-pattern-base");
+ root.style.removeProperty("--bg-pattern-vector");
+ root.style.setProperty("--background", tint);
+ root.style.setProperty("--primary-light", primaryLight);
+ return;
+ }
+
+ if (bgType === "pattern") {
+ root.setAttribute("data-pattern-bg", "true");
+ root.removeAttribute("data-image-bg");
+
+ 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.backgroundTint) return;
+
+ const bgElement = ensureBgElement();
+ bgElement.style.overflow = "";
+
+ document.getElementById(THEME_BG_IMG_ID)?.remove();
+ document.getElementById(THEME_BG_OVERLAY_ID)?.remove();
+
+ bgElement.style.backgroundColor = theme.backgroundTint;
+ if (theme.patternDataUrl) {
+ bgElement.style.backgroundImage = `url("${theme.patternDataUrl}")`;
+ bgElement.style.backgroundRepeat = "repeat";
+ bgElement.style.backgroundSize = "auto";
+ } else {
+ bgElement.style.removeProperty("background-image");
+ bgElement.style.removeProperty("background-repeat");
+ bgElement.style.removeProperty("background-size");
+ }
+ return;
+ }
+
+ if (bgType === "custom" && theme.bgUrl) {
+ root.setAttribute("data-image-bg", "true");
+ root.removeAttribute("data-pattern-bg");
+
+ const bgElement = ensureBgElement();
+ bgElement.style.backgroundColor = "";
+ bgElement.style.backgroundImage = "";
+ bgElement.style.overflow = "hidden";
+
+ let imgLayer = document.getElementById(THEME_BG_IMG_ID);
+ if (!imgLayer) {
+ imgLayer = document.createElement("div");
+ imgLayer.id = THEME_BG_IMG_ID;
+ imgLayer.style.position = "absolute";
+ imgLayer.style.inset = "-40px";
+ imgLayer.style.backgroundSize = "cover";
+ imgLayer.style.backgroundPosition = "center";
+ imgLayer.style.backgroundRepeat = "no-repeat";
+ bgElement.appendChild(imgLayer);
+ }
+
+ imgLayer.style.backgroundImage = `url("${theme.bgUrl}")`;
+ imgLayer.style.filter = theme.bgBlur ? `blur(${theme.bgBlur}px)` : "";
+ imgLayer.style.opacity =
+ theme.bgOpacity != null ? String(theme.bgOpacity / 100) : "1";
+
+ let overlayLayer = document.getElementById(THEME_BG_OVERLAY_ID);
+ if (!overlayLayer) {
+ overlayLayer = document.createElement("div");
+ overlayLayer.id = THEME_BG_OVERLAY_ID;
+ overlayLayer.style.position = "absolute";
+ overlayLayer.style.inset = "0";
+ bgElement.appendChild(overlayLayer);
+ }
+
+ const overlayOpacity =
+ theme.bgOverlay != null ? Number(theme.bgOverlay) / 100 : 0;
+ overlayLayer.style.backgroundColor =
+ overlayOpacity > 0 ? `rgba(0,0,0,${overlayOpacity})` : "";
+ }
+}
+
+export function removeCachedThemeBackground(): void {
+ document.getElementById(THEME_BG_ELEMENT_ID)?.remove();
+}
diff --git a/src/lib/styles/glassSurface.ts b/src/lib/styles/glassSurface.ts
new file mode 100644
index 0000000..3d7207c
--- /dev/null
+++ b/src/lib/styles/glassSurface.ts
@@ -0,0 +1,33 @@
+import clsx, { type ClassValue } from "clsx";
+
+export const GLASS_SURFACE = "glass-surface";
+export const GLASS_SURFACE_FLAT = "glass-surface--flat";
+export const GLASS_SURFACE_SELECTED = "glass-surface--selected";
+export const GLASS_SURFACE_DISABLED = "glass-surface--disabled";
+export const GLASS_SURFACE_NAV = "glass-surface-nav";
+
+export function glassSurfaceNav(...extra: ClassValue[]) {
+ return clsx(GLASS_SURFACE_NAV, extra);
+}
+
+export function glassSurface(...extra: ClassValue[]) {
+ return clsx(GLASS_SURFACE, extra);
+}
+
+export function glassSurfaceFlat(...extra: ClassValue[]) {
+ return clsx(GLASS_SURFACE, GLASS_SURFACE_FLAT, extra);
+}
+
+export function glassSurfaceSelected(...extra: ClassValue[]) {
+ return clsx(GLASS_SURFACE, GLASS_SURFACE_SELECTED, extra);
+}
+
+/** Page-level card sections (about, cart, profile, etc.) */
+export function glassSurfaceCard(...extra: ClassValue[]) {
+ return clsx(GLASS_SURFACE, "rounded-container", extra);
+}
+
+/** Nested / secondary card sections with no shadow */
+export function glassSurfaceCardFlat(...extra: ClassValue[]) {
+ return clsx(GLASS_SURFACE, GLASS_SURFACE_FLAT, "rounded-container", extra);
+}
diff --git a/src/styles/glass.css b/src/styles/glass.css
new file mode 100644
index 0000000..835f144
--- /dev/null
+++ b/src/styles/glass.css
@@ -0,0 +1,72 @@
+/* Glass surface — Figma: white 60%, border, shadow, backdrop blur */
+
+.glass-surface {
+ background-color: rgba(255, 255, 255, 0.6);
+ border: 2px solid rgba(255, 255, 255, 0.8);
+ -webkit-backdrop-filter: blur(3px);
+ backdrop-filter: blur(3px);
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.03);
+}
+
+html[data-pattern-bg="true"] .glass-surface,
+html[data-image-bg="true"] .glass-surface {
+ -webkit-backdrop-filter: blur(1px);
+ backdrop-filter: blur(1px);
+}
+
+html[data-theme="dark"] .glass-surface {
+ background-color: color-mix(in oklch, var(--container) 60%, transparent);
+ border-color: rgba(255, 255, 255, 0.2);
+ box-shadow: 0 1px 8px rgba(0, 0, 0, 0.1);
+}
+
+.glass-surface--flat {
+ box-shadow: none;
+}
+
+.glass-surface--selected {
+ background-color: rgba(255, 255, 255, 0.85) !important;
+}
+
+html[data-theme="dark"] .glass-surface--selected {
+ background-color: color-mix(in oklch, var(--container) 85%, transparent) !important;
+}
+
+/* Figma disabled category: white 40%, 2px border, blur 12 */
+.glass-surface--disabled {
+ background-color: rgba(255, 255, 255, 0.4) !important;
+ border: 2px solid rgba(255, 255, 255, 0.8);
+ box-shadow: none;
+ -webkit-backdrop-filter: blur(12px);
+ backdrop-filter: blur(12px);
+}
+
+html[data-pattern-bg="true"] .glass-surface--disabled,
+html[data-image-bg="true"] .glass-surface--disabled {
+ -webkit-backdrop-filter: blur(8px);
+ backdrop-filter: blur(8px);
+}
+
+html[data-theme="dark"] .glass-surface--disabled {
+ background-color: color-mix(in oklch, var(--container) 40%, transparent) !important;
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.glass-surface-nav {
+ background-color: rgba(255, 255, 255, 0.32);
+ border: 1px solid rgba(255, 255, 255, 0.8);
+ -webkit-backdrop-filter: blur(8px);
+ backdrop-filter: blur(8px);
+ box-shadow: 0px -2px 14px 0px rgba(0, 0, 0, 0.15);
+}
+
+html[data-pattern-bg="true"] .glass-surface-nav,
+html[data-image-bg="true"] .glass-surface-nav {
+ -webkit-backdrop-filter: blur(4px);
+ backdrop-filter: blur(4px);
+}
+
+html[data-theme="dark"] .glass-surface-nav {
+ background-color: color-mix(in oklch, var(--container) 32%, transparent);
+ border-color: rgba(255, 255, 255, 0.2);
+}