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:
+9
-1
@@ -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://<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.**
|
||||
Staff UIs are used by Greek staff and must render Greek correctly, including on thermal printers (codepage). Money uses €.
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<SSEProvider>
|
||||
<NotificationProvider>
|
||||
<ConnectivityBar />
|
||||
<UpdatePrompt />
|
||||
{/* Native builds ship their UI in the app bundle — no service worker to update */}
|
||||
{deliveryMode() !== 'native' && <UpdatePrompt />}
|
||||
<InstallAppBanner />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
|
||||
<Route path="/offline" element={<OfflinePage />} />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
<div style={{ padding: '4px 16px 0', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{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 }} />
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
@@ -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
|
||||
? <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>
|
||||
}
|
||||
</div>
|
||||
@@ -196,7 +197,7 @@ function ProductCard({ product, onOpen, onQuickAdd, onShowDetail }) {
|
||||
<div className="product-btn__thumb">
|
||||
<div className="product-btn__thumb-inner">
|
||||
{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">
|
||||
{product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()}
|
||||
</span>
|
||||
|
||||
@@ -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 (
|
||||
<img
|
||||
src={waiter.avatar_url}
|
||||
src={assetUrl(waiter.avatar_url)}
|
||||
alt={displayName}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
? <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>
|
||||
}
|
||||
</div>
|
||||
@@ -1355,7 +1356,7 @@ function SearchModal({ products, query, setQuery, onClose, onAdd, courses = [],
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{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>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
? <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)' }}>
|
||||
{product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()}
|
||||
</span>
|
||||
|
||||
@@ -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
|
||||
? <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
|
||||
}
|
||||
</div>
|
||||
@@ -198,7 +199,7 @@ export default function LoginPage() {
|
||||
fontSize: 15, fontWeight: 700, color: 'var(--text)',
|
||||
}}>
|
||||
{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
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user