From 18012c2c95aae3e44fc70b43c5f558a51244d4e3 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 28 Sep 2026 11:38:42 +0300 Subject: [PATCH] feat(waiter): configurable server/venue layer for native app and plain-HTTP browser mode New waiter_pwa/src/config/server.js is the single place that knows where the backend is. Served by the venue's server (https domain or http://) nothing changes: same-origin URLs and the original storage keys, so existing installs keep their token and unsynced offline queue. With an active venue (native app, or dev builds with VITE_SERVER_URL) URLs become absolute and all venue data is namespaced by siteId: token/savedUsername keys, the Dexie DB (pos_snapshot__, which also covers the WS cursor), favorites and table-view prefs. Switching venue reloads the app. - api client baseURL, WebSocket and SSE URLs routed through the layer - product images / waiter avatars rendered via assetUrl() - service-worker update prompt skipped in native builds - InstallAppBanner: shown only in plain-HTTP browser mode and only when VITE_APP_DOWNLOAD_URL is set at build time (dismiss for 7 days) - VITE_SERVER_URL override is DEV-only (a URL-controlled server in prod would let a crafted link capture PINs) - pack README: rule CS-8 on never assuming same-origin Verified with Playwright/Edge against a local backend: prod build same-origin, prod build via LAN IP over plain HTTP (insecure context, no SW, banner shown), and dev build pointed at the backend by URL - all three log in, reach /tables and receive the WebSocket 'ready' frame; storage keys and IndexedDB names are as expected. Lint: no new problems (103 before/after). Build passes. Co-Authored-By: Claude Opus 5.5 --- docs/README.md | 10 +- waiter_pwa/src/App.jsx | 6 +- waiter_pwa/src/api/client.js | 9 +- .../src/components/InstallAppBanner.jsx | 86 ++++++++++ waiter_pwa/src/components/OrderDrawer.jsx | 3 +- waiter_pwa/src/components/ProductPicker.jsx | 5 +- waiter_pwa/src/components/TableCard.jsx | 3 +- waiter_pwa/src/config/server.js | 152 ++++++++++++++++++ waiter_pwa/src/db/posdb.js | 4 +- waiter_pwa/src/hooks/useSSE.js | 3 +- waiter_pwa/src/hooks/useWebSocket.js | 6 +- waiter_pwa/src/pages/AddItemsPage.jsx | 5 +- waiter_pwa/src/pages/FavoritesSetupPage.jsx | 3 +- waiter_pwa/src/pages/LoginPage.jsx | 5 +- waiter_pwa/src/store/authStore.js | 18 ++- waiter_pwa/src/store/tableViewStore.js | 3 +- waiter_pwa/src/store/waiterFavoritesStore.js | 3 +- 17 files changed, 295 insertions(+), 29 deletions(-) create mode 100644 waiter_pwa/src/components/InstallAppBanner.jsx create mode 100644 waiter_pwa/src/config/server.js diff --git a/docs/README.md b/docs/README.md index 86f7e9e..7d261d5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,7 +25,7 @@ phone ──https──▶ proxy:443 ──http──▶ waiter_pwa nginx:80 ─ └─ /static/ → backend:8000/static/ ``` Every hop must forward WebSocket upgrades: `proxy_http_version 1.1`, plus `Upgrade` and `Connection` headers, plus a long `proxy_read_timeout`. -The frontends call the API **same-origin, with relative paths** (`baseURL: ''`). Don't hardcode hosts. +The manager calls the API **same-origin, with relative paths**. The waiter app resolves every backend URL through `waiter_pwa/src/config/server.js`: same-origin when served by the venue's server, absolute when running in the native app (see CS-8). Don't hardcode hosts. ## Key files @@ -35,6 +35,7 @@ The frontends call the API **same-origin, with relative paths** (`baseURL: ''`). | `local_backend/services/cloud_sync.py` | Every call to the cloud (see the parent's `docs/reference/cloud-contract.md`) | | `local_backend/roles.py`, `routers/deps.py` | Roles, permission checks, auth dependencies | | `local_backend/routers/ws.py` | Real-time event stream (seq + cursor replay) | +| `waiter_pwa/src/config/server.js` | **Where the backend is**: active venue, `apiBase()`, `wsUrl()`, `assetUrl()`, `storageKey()`, `dbName()`, `deliveryMode()` | | `waiter_pwa/src/api/client.js` | axios instance: auth header, 401 → logout, network error → offline | | `waiter_pwa/src/context/SSEContext.jsx` | Real-time lifecycle, event → store/cache updates, visibility refresh | | `waiter_pwa/src/db/posdb`, `src/services/offlineOrders` | Offline cache and queued orders | @@ -69,6 +70,13 @@ Edit `nginx-proxy/nginx.conf` **and** the heredoc in `install.sh` together (glob **CS-6. Permissions are checked on the backend.** Hiding a button in the UI is not access control. Every new endpoint declares its role or `perm_*` requirement through `routers/deps.py`. +**CS-8. The waiter app never assumes it's served by the backend.** +The same waiter build runs same-origin (https domain, or plain `http://`) **and** inside the native app talking to a remote venue server. So in `waiter_pwa`: +- REST goes through `api/client.js` (its `baseURL` is `apiBase()`). Any other fetch, `EventSource` or `WebSocket` must build its URL with `apiBase()` or `wsUrl()`. +- Backend-served files (`/static/...` images, avatars) are rendered through `assetUrl()`. +- Persistent **venue-specific** data (token, IDs of products/tables/zones) uses `storageKey()` for localStorage keys and zustand `persist` names. The IndexedDB name comes from `dbName()`. Device preferences (theme, payment safety…) stay global. +- Features that need a secure context (service worker, camera, notifications, clipboard) must degrade gracefully when `deliveryMode()` is `'plain-web'`. + **CS-7. Greek-market realities.** Staff UIs are used by Greek staff and must render Greek correctly, including on thermal printers (codepage). Money uses €. diff --git a/waiter_pwa/src/App.jsx b/waiter_pwa/src/App.jsx index c50de50..6fb2b63 100644 --- a/waiter_pwa/src/App.jsx +++ b/waiter_pwa/src/App.jsx @@ -24,6 +24,8 @@ import { SSEProvider } from './context/SSEContext' import ConnectivityBar from './components/ConnectivityBar' import AppShell from './components/AppShell' import UpdatePrompt from './components/UpdatePrompt' +import InstallAppBanner from './components/InstallAppBanner' +import { deliveryMode } from './config/server' // ─── Utility ───────────────────────────────────────────────────────────────── @@ -412,7 +414,9 @@ export default function App() { - + {/* Native builds ship their UI in the app bundle — no service worker to update */} + {deliveryMode() !== 'native' && } + } /> } /> diff --git a/waiter_pwa/src/api/client.js b/waiter_pwa/src/api/client.js index 4740949..1abc420 100644 --- a/waiter_pwa/src/api/client.js +++ b/waiter_pwa/src/api/client.js @@ -1,9 +1,10 @@ import axios from 'axios' +import { apiBase, storageKey } from '../config/server' -const client = axios.create({ baseURL: '' }) +const client = axios.create({ baseURL: apiBase() }) client.interceptors.request.use(config => { - const token = localStorage.getItem('token') + const token = localStorage.getItem(storageKey('token')) if (token) config.headers.Authorization = `Bearer ${token}` return config }) @@ -15,8 +16,8 @@ client.interceptors.response.use( window.dispatchEvent(new Event('backend-offline')) } else if (err.response.status === 401) { // Token expired or user blocked — force logout - localStorage.removeItem('token') - localStorage.removeItem('savedUsername') + localStorage.removeItem(storageKey('token')) + localStorage.removeItem(storageKey('savedUsername')) window.location.href = '/login' } return Promise.reject(err) diff --git a/waiter_pwa/src/components/InstallAppBanner.jsx b/waiter_pwa/src/components/InstallAppBanner.jsx new file mode 100644 index 0000000..8bb39aa --- /dev/null +++ b/waiter_pwa/src/components/InstallAppBanner.jsx @@ -0,0 +1,86 @@ +import { useState } from 'react' +import { deliveryMode } from '../config/server' + +// Shown only in plain-HTTP browser mode (http://), where the page can't +// work offline after a reload and can't be installed as a real PWA. Hidden until +// an app download link exists (VITE_APP_DOWNLOAD_URL at build time). +const DOWNLOAD_URL = import.meta.env.VITE_APP_DOWNLOAD_URL || '' +const DISMISS_KEY = 'install-banner-dismissed-until' +const DISMISS_DAYS = 7 + +function isDismissed() { + try { + return Number(localStorage.getItem(DISMISS_KEY) || 0) > Date.now() + } catch { + return false + } +} + +export default function InstallAppBanner() { + const [hidden, setHidden] = useState(isDismissed) + + if (!DOWNLOAD_URL || hidden || deliveryMode() !== 'plain-web') return null + + function dismiss() { + try { + localStorage.setItem(DISMISS_KEY, String(Date.now() + DISMISS_DAYS * 86_400_000)) + } catch { + // storage unavailable — banner just reappears next load + } + setHidden(true) + } + + return ( +
+ + Για γρηγορότερη και πιο σταθερή λειτουργία, εγκαταστήστε την εφαρμογή. + + + Εγκατάσταση + + +
+ ) +} diff --git a/waiter_pwa/src/components/OrderDrawer.jsx b/waiter_pwa/src/components/OrderDrawer.jsx index d020438..444dc28 100644 --- a/waiter_pwa/src/components/OrderDrawer.jsx +++ b/waiter_pwa/src/components/OrderDrawer.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import useWaiterFavoritesStore from '../store/waiterFavoritesStore' import client from '../api/client' +import { assetUrl } from '../config/server' // ── Helpers ────────────────────────────────────────────────────────────────── @@ -1412,7 +1413,7 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt {/* Header */}
{product.image_url && ( - )}
diff --git a/waiter_pwa/src/components/ProductPicker.jsx b/waiter_pwa/src/components/ProductPicker.jsx index 488f905..5af7144 100644 --- a/waiter_pwa/src/components/ProductPicker.jsx +++ b/waiter_pwa/src/components/ProductPicker.jsx @@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import OrderDrawer from './OrderDrawer' import useWaiterFavoritesStore from '../store/waiterFavoritesStore' +import { assetUrl } from '../config/server' const FAV_CAT_ID = '__favorites__' const SVC_CAT_ID = '__service__' @@ -120,7 +121,7 @@ function ProductDetailModal({ product, onClose }) { overflow: 'hidden', }}> {product.image_url - ? + ? : {initials} }
@@ -196,7 +197,7 @@ function ProductCard({ product, onOpen, onQuickAdd, onShowDetail }) {
{product.image_url - ? + ? : {product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()} diff --git a/waiter_pwa/src/components/TableCard.jsx b/waiter_pwa/src/components/TableCard.jsx index dd4030e..34b2422 100644 --- a/waiter_pwa/src/components/TableCard.jsx +++ b/waiter_pwa/src/components/TableCard.jsx @@ -1,6 +1,7 @@ import { useRef, useState, useEffect } from 'react' import useThemeStore from '../store/themeStore' import useTableColourStore from '../store/tableColourStore' +import { assetUrl } from '../config/server' function useFlashToggle(active, intervalMs = 900) { const [frame, setFrame] = useState(false) @@ -106,7 +107,7 @@ function WaiterAvatar({ waiter, size = 22, ring }) { if (waiter.avatar_url) { return ( {displayName}). + * No venue is configured → every URL is same-origin and storage uses the + * original un-prefixed keys. Behaviour is identical to before venues existed, + * so existing installs keep their token and offline queue across the update. + * + * 2. Bundled in the native app (Capacitor), or a dev build with VITE_SERVER_URL. + * An active venue { siteId, name, baseUrl } → absolute URLs to that server, + * and all persistent storage (token, IndexedDB, per-venue prefs) is namespaced + * by siteId so several venues can live side by side on one phone. + * + * The active venue is resolved ONCE at module load. Switching venue reloads the + * app so every module-level singleton (Dexie db, auth store, persisted stores) + * re-initialises against the new namespace — no half-switched state. + */ + +const VENUES_KEY = 'xenia.venues' + +function normalizeBaseUrl(url) { + const trimmed = String(url || '').trim().replace(/\/+$/, '') + if (!/^https?:\/\/[^/]+/i.test(trimmed)) { + throw new Error(`Invalid server URL: "${url}" (expected http(s)://host[:port])`) + } + return trimmed +} + +function readState() { + try { + const parsed = JSON.parse(localStorage.getItem(VENUES_KEY) || 'null') + if (parsed && typeof parsed === 'object') { + return { active: parsed.active ?? null, venues: parsed.venues ?? {} } + } + } catch { + // corrupted or unavailable storage — fall through to empty state + } + return { active: null, venues: {} } +} + +function writeState(state) { + localStorage.setItem(VENUES_KEY, JSON.stringify(state)) +} + +function resolveActiveVenue() { + // Dev convenience: point a `npm run dev` build at a real server on the LAN. + // Deliberately DEV-only — a URL/query-controlled server in production would let + // a crafted link send waiters' PINs to someone else's machine. + const devUrl = import.meta.env.DEV ? import.meta.env.VITE_SERVER_URL : '' + if (devUrl) { + return { siteId: 'dev', name: 'Dev server', baseUrl: normalizeBaseUrl(devUrl) } + } + const { active, venues } = readState() + return (active && venues[active]) || null +} + +const ACTIVE_VENUE = resolveActiveVenue() + +// ── Reading ─────────────────────────────────────────────────────────────────── + +/** The venue this app instance is talking to, or null when running same-origin. */ +export function getActiveVenue() { + return ACTIVE_VENUE +} + +/** All paired venues (used by the venue switcher). */ +export function listVenues() { + return Object.values(readState().venues) +} + +/** Base URL for REST calls: '' (same-origin) or 'http(s)://host[:port]'. */ +export function apiBase() { + return ACTIVE_VENUE?.baseUrl ?? '' +} + +/** Absolute ws(s):// URL for a backend path such as '/api/ws/connect?token=…'. */ +export function wsUrl(path) { + if (ACTIVE_VENUE) { + return ACTIVE_VENUE.baseUrl.replace(/^http/i, 'ws') + path + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${window.location.host}${path}` +} + +/** + * URL for a backend-served asset (product images, avatars). The backend returns + * root-relative paths like '/static/product_images/x.jpg'; in native mode those + * must point at the venue server, not at the app bundle. + */ +export function assetUrl(url) { + if (!url || !ACTIVE_VENUE) return url + if (/^(https?:|data:|blob:)/i.test(url)) return url + return url.startsWith('/') ? ACTIVE_VENUE.baseUrl + url : url +} + +/** + * localStorage key for per-venue data. Same-origin mode keeps the original + * un-prefixed key so existing installs don't lose their session. + */ +export function storageKey(name) { + return ACTIVE_VENUE ? `xenia:${ACTIVE_VENUE.siteId}:${name}` : name +} + +/** IndexedDB (Dexie) database name — the original name in same-origin mode. */ +export function dbName() { + return ACTIVE_VENUE ? `pos_snapshot__${ACTIVE_VENUE.siteId}` : 'pos_snapshot' +} + +/** + * How the app is being delivered: + * 'native' — inside the Capacitor app + * 'secure-web' — https (or localhost): service worker, installable PWA + * 'plain-web' — plain http://: no service worker, no real install + */ +export function deliveryMode() { + if (window.Capacitor?.isNativePlatform?.()) return 'native' + return window.isSecureContext ? 'secure-web' : 'plain-web' +} + +// ── Writing (used by pairing / venue switcher in the native app) ───────────── + +/** Add or update a paired venue without switching to it. */ +export function saveVenue({ siteId, name, baseUrl }) { + if (!siteId) throw new Error('saveVenue: siteId is required') + const state = readState() + state.venues[siteId] = { siteId, name: name || siteId, baseUrl: normalizeBaseUrl(baseUrl) } + writeState(state) + return state.venues[siteId] +} + +/** Make a paired venue active and reload the app into it. */ +export function switchVenue(siteId) { + const state = readState() + if (!state.venues[siteId]) throw new Error(`switchVenue: unknown venue ${siteId}`) + state.active = siteId + writeState(state) + window.location.replace('/') +} + +/** + * Forget a paired venue. Its namespaced storage and IndexedDB are left in place + * on purpose — they may hold unsynced offline orders. Cleanup is an explicit, + * separate action (see the native app's venue manager). + */ +export function removeVenue(siteId) { + const state = readState() + delete state.venues[siteId] + if (state.active === siteId) state.active = null + writeState(state) +} diff --git a/waiter_pwa/src/db/posdb.js b/waiter_pwa/src/db/posdb.js index 0a04947..291808c 100644 --- a/waiter_pwa/src/db/posdb.js +++ b/waiter_pwa/src/db/posdb.js @@ -1,11 +1,13 @@ import Dexie from 'dexie' +import { dbName } from '../config/server' // NOTE: do NOT use 'tables' as a store name — Dexie 4.x has a built-in // `.tables` getter on the prototype that returns all registered stores as an // array, so an own-property store named 'tables' gets shadowed by it. // We use pos_ prefix on store names to avoid all such collisions. -const db = new Dexie('pos_snapshot') +// One database per venue (see config/server.js); same-origin keeps 'pos_snapshot'. +const db = new Dexie(dbName()) db.version(1).stores({ tables: 'id, group_id, is_active', diff --git a/waiter_pwa/src/hooks/useSSE.js b/waiter_pwa/src/hooks/useSSE.js index 4cd1fa1..beea9cb 100644 --- a/waiter_pwa/src/hooks/useSSE.js +++ b/waiter_pwa/src/hooks/useSSE.js @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef } from 'react' +import { apiBase } from '../config/server' const INITIAL_RECONNECT_DELAY = 3000 const MAX_RECONNECT_DELAY = 30000 @@ -38,7 +39,7 @@ export function useSSE({ token, onEvent, onConnect, onDisconnect, enabled = true esRef.current = null } - const url = `/api/sse/stream?token=${encodeURIComponent(token)}` + const url = `${apiBase()}/api/sse/stream?token=${encodeURIComponent(token)}` const es = new EventSource(url) esRef.current = es diff --git a/waiter_pwa/src/hooks/useWebSocket.js b/waiter_pwa/src/hooks/useWebSocket.js index 9c265b7..6c7dd97 100644 --- a/waiter_pwa/src/hooks/useWebSocket.js +++ b/waiter_pwa/src/hooks/useWebSocket.js @@ -14,6 +14,7 @@ import { useCallback, useEffect, useRef } from 'react' import db from '../db/posdb' +import { wsUrl } from '../config/server' const INITIAL_RECONNECT_DELAY = 2_000 const MAX_RECONNECT_DELAY = 30_000 @@ -71,9 +72,8 @@ export function useWebSocket({ token, onEvent, onConnect, onDisconnect, enabled wsRef.current = null } - // Build WS URL — same host, swap http(s) → ws(s) - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const url = `${protocol}//${window.location.host}/api/ws/connect?token=${encodeURIComponent(token)}` + // Same host as the page (web) or the active venue's server (native) + const url = wsUrl(`/api/ws/connect?token=${encodeURIComponent(token)}`) const ws = new WebSocket(url) wsRef.current = ws diff --git a/waiter_pwa/src/pages/AddItemsPage.jsx b/waiter_pwa/src/pages/AddItemsPage.jsx index 9edc4e5..54d8b83 100644 --- a/waiter_pwa/src/pages/AddItemsPage.jsx +++ b/waiter_pwa/src/pages/AddItemsPage.jsx @@ -10,6 +10,7 @@ import useOrderingSettingsStore from '../store/orderingSettingsStore' import useConnectionStore from '../store/connectionStore' import useAuthStore from '../store/authStore' import { queueOfflineOrder } from '../services/offlineOrders' +import { assetUrl } from '../config/server' // ─── Global Course Modal ────────────────────────────────────────────────────── @@ -1103,7 +1104,7 @@ function QuickAddToast({ product, count }) { display: 'flex', alignItems: 'center', justifyContent: 'center', }}> {product.image_url - ? + ? : {initials} }
@@ -1355,7 +1356,7 @@ function SearchModal({ products, query, setQuery, onClose, onAdd, courses = [], display: 'flex', alignItems: 'center', justifyContent: 'center', }}> {p.image_url - ? + ? : {initials} }
diff --git a/waiter_pwa/src/pages/FavoritesSetupPage.jsx b/waiter_pwa/src/pages/FavoritesSetupPage.jsx index eb3f96e..0d6a4f6 100644 --- a/waiter_pwa/src/pages/FavoritesSetupPage.jsx +++ b/waiter_pwa/src/pages/FavoritesSetupPage.jsx @@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { useProductCache } from '../hooks/useProductCache' import useWaiterFavoritesStore from '../store/waiterFavoritesStore' +import { assetUrl } from '../config/server' // ── Heart icon ──────────────────────────────────────────────────────────────── function Heart({ filled, size = 22 }) { @@ -21,7 +22,7 @@ function Thumb({ product, size = 42, radius = 10 }) { display: 'flex', alignItems: 'center', justifyContent: 'center', }}> {product.image_url - ? + ? : {product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()} diff --git a/waiter_pwa/src/pages/LoginPage.jsx b/waiter_pwa/src/pages/LoginPage.jsx index da1409c..306ad9e 100644 --- a/waiter_pwa/src/pages/LoginPage.jsx +++ b/waiter_pwa/src/pages/LoginPage.jsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom' import PinPad from '../components/PinPad' import useAuthStore from '../store/authStore' import client from '../api/client' +import { assetUrl } from '../config/server' // ─── Waiter card ────────────────────────────────────────────────────────────── @@ -42,7 +43,7 @@ function WaiterCard({ waiter, onClick }) { fontSize: 18, fontWeight: 700, color: 'var(--text)', }}> {waiter.avatar_url - ? + ? : initials }
@@ -198,7 +199,7 @@ export default function LoginPage() { fontSize: 15, fontWeight: 700, color: 'var(--text)', }}> {selectedWaiter.avatar_url - ? + ? : initials } diff --git a/waiter_pwa/src/store/authStore.js b/waiter_pwa/src/store/authStore.js index 3cee7be..5c94e8b 100644 --- a/waiter_pwa/src/store/authStore.js +++ b/waiter_pwa/src/store/authStore.js @@ -1,26 +1,30 @@ import { create } from 'zustand' +import { storageKey } from '../config/server' -const savedUsername = localStorage.getItem('savedUsername') +const TOKEN_KEY = storageKey('token') +const USERNAME_KEY = storageKey('savedUsername') + +const savedUsername = localStorage.getItem(USERNAME_KEY) const useAuthStore = create((set) => ({ user: null, - token: localStorage.getItem('token') || null, + token: localStorage.getItem(TOKEN_KEY) || null, savedUsername: savedUsername || null, login(user, token) { - localStorage.setItem('token', token) - localStorage.setItem('savedUsername', user.username) + localStorage.setItem(TOKEN_KEY, token) + localStorage.setItem(USERNAME_KEY, user.username) set({ user, token, savedUsername: user.username }) }, logout() { - localStorage.removeItem('token') - localStorage.removeItem('savedUsername') + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(USERNAME_KEY) set({ user: null, token: null, savedUsername: null }) }, clearSavedUsername() { - localStorage.removeItem('savedUsername') + localStorage.removeItem(USERNAME_KEY) set({ savedUsername: null }) }, })) diff --git a/waiter_pwa/src/store/tableViewStore.js b/waiter_pwa/src/store/tableViewStore.js index b891933..b3cc7d4 100644 --- a/waiter_pwa/src/store/tableViewStore.js +++ b/waiter_pwa/src/store/tableViewStore.js @@ -1,5 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import { storageKey } from '../config/server' // density: '1x1' | '2x1' | '2x2' | '4x1' | '4x2' | '4x3' // ownerFilter: 'all' | 'mine' @@ -60,7 +61,7 @@ const useTableViewStore = create( }), }), { - name: 'table-view-prefs', + name: storageKey('table-view-prefs'), // zone IDs are venue-specific // future: could sync to backend here } ) diff --git a/waiter_pwa/src/store/waiterFavoritesStore.js b/waiter_pwa/src/store/waiterFavoritesStore.js index 6dfe4d0..30038d2 100644 --- a/waiter_pwa/src/store/waiterFavoritesStore.js +++ b/waiter_pwa/src/store/waiterFavoritesStore.js @@ -1,6 +1,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import client from '../api/client' +import { storageKey } from '../config/server' const SETTINGS_KEY = 'waiter-favorites' @@ -94,7 +95,7 @@ const useWaiterFavoritesStore = create( } catch { /* best-effort */ } }, }), - { name: SETTINGS_KEY } + { name: storageKey(SETTINGS_KEY) } // product IDs are venue-specific ) )