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://<LAN IP>)
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__<siteId>, 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 11:38:42 +03:00
co-authored by Claude Opus 5.5
parent 678b54dac7
commit 18012c2c95
17 changed files with 295 additions and 29 deletions
+9 -1
View File
@@ -25,7 +25,7 @@ phone ──https──▶ proxy:443 ──http──▶ waiter_pwa nginx:80 ─
└─ /static/ → backend:8000/static/ └─ /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`. 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 ## 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/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/roles.py`, `routers/deps.py` | Roles, permission checks, auth dependencies |
| `local_backend/routers/ws.py` | Real-time event stream (seq + cursor replay) | | `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/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/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 | | `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.** **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`. 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://<LAN IP>`) **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.** **CS-7. Greek-market realities.**
Staff UIs are used by Greek staff and must render Greek correctly, including on thermal printers (codepage). Money uses €. Staff UIs are used by Greek staff and must render Greek correctly, including on thermal printers (codepage). Money uses €.
+5 -1
View File
@@ -24,6 +24,8 @@ import { SSEProvider } from './context/SSEContext'
import ConnectivityBar from './components/ConnectivityBar' import ConnectivityBar from './components/ConnectivityBar'
import AppShell from './components/AppShell' import AppShell from './components/AppShell'
import UpdatePrompt from './components/UpdatePrompt' import UpdatePrompt from './components/UpdatePrompt'
import InstallAppBanner from './components/InstallAppBanner'
import { deliveryMode } from './config/server'
// ─── Utility ───────────────────────────────────────────────────────────────── // ─── Utility ─────────────────────────────────────────────────────────────────
@@ -412,7 +414,9 @@ export default function App() {
<SSEProvider> <SSEProvider>
<NotificationProvider> <NotificationProvider>
<ConnectivityBar /> <ConnectivityBar />
<UpdatePrompt /> {/* Native builds ship their UI in the app bundle — no service worker to update */}
{deliveryMode() !== 'native' && <UpdatePrompt />}
<InstallAppBanner />
<Routes> <Routes>
<Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} /> <Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
<Route path="/offline" element={<OfflinePage />} /> <Route path="/offline" element={<OfflinePage />} />
+5 -4
View File
@@ -1,9 +1,10 @@
import axios from 'axios' 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 => { client.interceptors.request.use(config => {
const token = localStorage.getItem('token') const token = localStorage.getItem(storageKey('token'))
if (token) config.headers.Authorization = `Bearer ${token}` if (token) config.headers.Authorization = `Bearer ${token}`
return config return config
}) })
@@ -15,8 +16,8 @@ client.interceptors.response.use(
window.dispatchEvent(new Event('backend-offline')) window.dispatchEvent(new Event('backend-offline'))
} else if (err.response.status === 401) { } else if (err.response.status === 401) {
// Token expired or user blocked — force logout // Token expired or user blocked — force logout
localStorage.removeItem('token') localStorage.removeItem(storageKey('token'))
localStorage.removeItem('savedUsername') localStorage.removeItem(storageKey('savedUsername'))
window.location.href = '/login' window.location.href = '/login'
} }
return Promise.reject(err) return Promise.reject(err)
@@ -0,0 +1,86 @@
import { useState } from 'react'
import { deliveryMode } from '../config/server'
// Shown only in plain-HTTP browser mode (http://<LAN IP>), 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 (
<div style={{
position: 'fixed',
bottom: 80,
left: 12,
right: 12,
zIndex: 9998,
background: 'var(--bg2)',
border: '1px solid var(--accent)',
borderRadius: 16,
padding: '12px 14px',
display: 'flex',
alignItems: 'center',
gap: 12,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
}}>
<span style={{ flex: 1, fontSize: 13, color: 'var(--text)', lineHeight: 1.35 }}>
Για γρηγορότερη και πιο σταθερή λειτουργία, εγκαταστήστε την εφαρμογή.
</span>
<a
href={DOWNLOAD_URL}
target="_blank"
rel="noreferrer"
style={{
background: 'var(--accent)',
color: '#0f172a',
borderRadius: 10,
padding: '8px 14px',
fontSize: 14,
fontWeight: 700,
textDecoration: 'none',
whiteSpace: 'nowrap',
}}
>
Εγκατάσταση
</a>
<button
onClick={dismiss}
aria-label="Κλείσιμο"
style={{
background: 'none',
border: 'none',
color: 'var(--muted)',
fontSize: 20,
lineHeight: 1,
padding: 4,
cursor: 'pointer',
}}
>
×
</button>
</div>
)
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import useWaiterFavoritesStore from '../store/waiterFavoritesStore' import useWaiterFavoritesStore from '../store/waiterFavoritesStore'
import client from '../api/client' import client from '../api/client'
import { assetUrl } from '../config/server'
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -1412,7 +1413,7 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
{/* Header */} {/* Header */}
<div style={{ padding: '4px 16px 0', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}> <div style={{ padding: '4px 16px 0', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
{product.image_url && ( {product.image_url && (
<img src={product.image_url} alt="" <img src={assetUrl(product.image_url)} alt=""
style={{ width: 48, height: 48, borderRadius: 12, objectFit: 'cover', flexShrink: 0 }} /> style={{ width: 48, height: 48, borderRadius: 12, objectFit: 'cover', flexShrink: 0 }} />
)} )}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
+3 -2
View File
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import OrderDrawer from './OrderDrawer' import OrderDrawer from './OrderDrawer'
import useWaiterFavoritesStore from '../store/waiterFavoritesStore' import useWaiterFavoritesStore from '../store/waiterFavoritesStore'
import { assetUrl } from '../config/server'
const FAV_CAT_ID = '__favorites__' const FAV_CAT_ID = '__favorites__'
const SVC_CAT_ID = '__service__' const SVC_CAT_ID = '__service__'
@@ -120,7 +121,7 @@ function ProductDetailModal({ product, onClose }) {
overflow: 'hidden', overflow: 'hidden',
}}> }}>
{product.image_url {product.image_url
? <img src={product.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> ? <img src={assetUrl(product.image_url)} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 48, fontWeight: 800, color: 'var(--muted)', letterSpacing: -2 }}>{initials}</span> : <span style={{ fontSize: 48, fontWeight: 800, color: 'var(--muted)', letterSpacing: -2 }}>{initials}</span>
} }
</div> </div>
@@ -196,7 +197,7 @@ function ProductCard({ product, onOpen, onQuickAdd, onShowDetail }) {
<div className="product-btn__thumb"> <div className="product-btn__thumb">
<div className="product-btn__thumb-inner"> <div className="product-btn__thumb-inner">
{product.image_url {product.image_url
? <img src={product.image_url} alt="" className="product-btn__img" /> ? <img src={assetUrl(product.image_url)} alt="" className="product-btn__img" />
: <span className="product-btn__initials"> : <span className="product-btn__initials">
{product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()} {product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()}
</span> </span>
+2 -1
View File
@@ -1,6 +1,7 @@
import { useRef, useState, useEffect } from 'react' import { useRef, useState, useEffect } from 'react'
import useThemeStore from '../store/themeStore' import useThemeStore from '../store/themeStore'
import useTableColourStore from '../store/tableColourStore' import useTableColourStore from '../store/tableColourStore'
import { assetUrl } from '../config/server'
function useFlashToggle(active, intervalMs = 900) { function useFlashToggle(active, intervalMs = 900) {
const [frame, setFrame] = useState(false) const [frame, setFrame] = useState(false)
@@ -106,7 +107,7 @@ function WaiterAvatar({ waiter, size = 22, ring }) {
if (waiter.avatar_url) { if (waiter.avatar_url) {
return ( return (
<img <img
src={waiter.avatar_url} src={assetUrl(waiter.avatar_url)}
alt={displayName} alt={displayName}
style={{ style={{
width: size, height: size, borderRadius: '50%', width: size, height: size, borderRadius: '50%',
+152
View File
@@ -0,0 +1,152 @@
/**
* Server / venue configuration — the single place that knows WHERE the backend is.
*
* Two ways the waiter app runs:
*
* 1. Served by the venue's own server (https domain, or plain http://<LAN IP>).
* 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://<LAN IP>: 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)
}
+3 -1
View File
@@ -1,11 +1,13 @@
import Dexie from 'dexie' 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 // 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 // `.tables` getter on the prototype that returns all registered stores as an
// array, so an own-property store named 'tables' gets shadowed by it. // array, so an own-property store named 'tables' gets shadowed by it.
// We use pos_ prefix on store names to avoid all such collisions. // 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({ db.version(1).stores({
tables: 'id, group_id, is_active', tables: 'id, group_id, is_active',
+2 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { apiBase } from '../config/server'
const INITIAL_RECONNECT_DELAY = 3000 const INITIAL_RECONNECT_DELAY = 3000
const MAX_RECONNECT_DELAY = 30000 const MAX_RECONNECT_DELAY = 30000
@@ -38,7 +39,7 @@ export function useSSE({ token, onEvent, onConnect, onDisconnect, enabled = true
esRef.current = null 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) const es = new EventSource(url)
esRef.current = es esRef.current = es
+3 -3
View File
@@ -14,6 +14,7 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import db from '../db/posdb' import db from '../db/posdb'
import { wsUrl } from '../config/server'
const INITIAL_RECONNECT_DELAY = 2_000 const INITIAL_RECONNECT_DELAY = 2_000
const MAX_RECONNECT_DELAY = 30_000 const MAX_RECONNECT_DELAY = 30_000
@@ -71,9 +72,8 @@ export function useWebSocket({ token, onEvent, onConnect, onDisconnect, enabled
wsRef.current = null wsRef.current = null
} }
// Build WS URL — same host, swap http(s) → ws(s) // Same host as the page (web) or the active venue's server (native)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const url = wsUrl(`/api/ws/connect?token=${encodeURIComponent(token)}`)
const url = `${protocol}//${window.location.host}/api/ws/connect?token=${encodeURIComponent(token)}`
const ws = new WebSocket(url) const ws = new WebSocket(url)
wsRef.current = ws wsRef.current = ws
+3 -2
View File
@@ -10,6 +10,7 @@ import useOrderingSettingsStore from '../store/orderingSettingsStore'
import useConnectionStore from '../store/connectionStore' import useConnectionStore from '../store/connectionStore'
import useAuthStore from '../store/authStore' import useAuthStore from '../store/authStore'
import { queueOfflineOrder } from '../services/offlineOrders' import { queueOfflineOrder } from '../services/offlineOrders'
import { assetUrl } from '../config/server'
// ─── Global Course Modal ────────────────────────────────────────────────────── // ─── Global Course Modal ──────────────────────────────────────────────────────
@@ -1103,7 +1104,7 @@ function QuickAddToast({ product, count }) {
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>
{product.image_url {product.image_url
? <img src={product.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> ? <img src={assetUrl(product.image_url)} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 16, fontWeight: 700, color: 'var(--muted)' }}>{initials}</span> : <span style={{ fontSize: 16, fontWeight: 700, color: 'var(--muted)' }}>{initials}</span>
} }
</div> </div>
@@ -1355,7 +1356,7 @@ function SearchModal({ products, query, setQuery, onClose, onAdd, courses = [],
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>
{p.image_url {p.image_url
? <img src={p.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> ? <img src={assetUrl(p.image_url)} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 13, fontWeight: 700, color: 'var(--muted)' }}>{initials}</span> : <span style={{ fontSize: 13, fontWeight: 700, color: 'var(--muted)' }}>{initials}</span>
} }
</div> </div>
+2 -1
View File
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useProductCache } from '../hooks/useProductCache' import { useProductCache } from '../hooks/useProductCache'
import useWaiterFavoritesStore from '../store/waiterFavoritesStore' import useWaiterFavoritesStore from '../store/waiterFavoritesStore'
import { assetUrl } from '../config/server'
// ── Heart icon ──────────────────────────────────────────────────────────────── // ── Heart icon ────────────────────────────────────────────────────────────────
function Heart({ filled, size = 22 }) { function Heart({ filled, size = 22 }) {
@@ -21,7 +22,7 @@ function Thumb({ product, size = 42, radius = 10 }) {
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>
{product.image_url {product.image_url
? <img src={product.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> ? <img src={assetUrl(product.image_url)} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: Math.round(size * 0.33), fontWeight: 700, color: 'var(--muted)' }}> : <span style={{ fontSize: Math.round(size * 0.33), fontWeight: 700, color: 'var(--muted)' }}>
{product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()} {product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()}
</span> </span>
+3 -2
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import PinPad from '../components/PinPad' import PinPad from '../components/PinPad'
import useAuthStore from '../store/authStore' import useAuthStore from '../store/authStore'
import client from '../api/client' import client from '../api/client'
import { assetUrl } from '../config/server'
// ─── Waiter card ────────────────────────────────────────────────────────────── // ─── Waiter card ──────────────────────────────────────────────────────────────
@@ -42,7 +43,7 @@ function WaiterCard({ waiter, onClick }) {
fontSize: 18, fontWeight: 700, color: 'var(--text)', fontSize: 18, fontWeight: 700, color: 'var(--text)',
}}> }}>
{waiter.avatar_url {waiter.avatar_url
? <img src={waiter.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> ? <img src={assetUrl(waiter.avatar_url)} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: initials : initials
} }
</div> </div>
@@ -198,7 +199,7 @@ export default function LoginPage() {
fontSize: 15, fontWeight: 700, color: 'var(--text)', fontSize: 15, fontWeight: 700, color: 'var(--text)',
}}> }}>
{selectedWaiter.avatar_url {selectedWaiter.avatar_url
? <img src={selectedWaiter.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> ? <img src={assetUrl(selectedWaiter.avatar_url)} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: initials : initials
} }
</div> </div>
+11 -7
View File
@@ -1,26 +1,30 @@
import { create } from 'zustand' 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) => ({ const useAuthStore = create((set) => ({
user: null, user: null,
token: localStorage.getItem('token') || null, token: localStorage.getItem(TOKEN_KEY) || null,
savedUsername: savedUsername || null, savedUsername: savedUsername || null,
login(user, token) { login(user, token) {
localStorage.setItem('token', token) localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem('savedUsername', user.username) localStorage.setItem(USERNAME_KEY, user.username)
set({ user, token, savedUsername: user.username }) set({ user, token, savedUsername: user.username })
}, },
logout() { logout() {
localStorage.removeItem('token') localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem('savedUsername') localStorage.removeItem(USERNAME_KEY)
set({ user: null, token: null, savedUsername: null }) set({ user: null, token: null, savedUsername: null })
}, },
clearSavedUsername() { clearSavedUsername() {
localStorage.removeItem('savedUsername') localStorage.removeItem(USERNAME_KEY)
set({ savedUsername: null }) set({ savedUsername: null })
}, },
})) }))
+2 -1
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import { storageKey } from '../config/server'
// density: '1x1' | '2x1' | '2x2' | '4x1' | '4x2' | '4x3' // density: '1x1' | '2x1' | '2x2' | '4x1' | '4x2' | '4x3'
// ownerFilter: 'all' | 'mine' // 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 // future: could sync to backend here
} }
) )
+2 -1
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import client from '../api/client' import client from '../api/client'
import { storageKey } from '../config/server'
const SETTINGS_KEY = 'waiter-favorites' const SETTINGS_KEY = 'waiter-favorites'
@@ -94,7 +95,7 @@ const useWaiterFavoritesStore = create(
} catch { /* best-effort */ } } catch { /* best-effort */ }
}, },
}), }),
{ name: SETTINGS_KEY } { name: storageKey(SETTINGS_KEY) } // product IDs are venue-specific
) )
) )