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
+5 -1
View File
@@ -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 />} />
+5 -4
View File
@@ -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>
)
}
+2 -1
View File
@@ -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 }}>
+3 -2
View File
@@ -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>
+2 -1
View File
@@ -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%',
+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 { 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',
+2 -1
View File
@@ -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
+3 -3
View File
@@ -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
+3 -2
View File
@@ -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 -1
View File
@@ -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 -2
View File
@@ -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>
+11 -7
View File
@@ -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 })
},
}))
+2 -1
View File
@@ -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
}
)
+2 -1
View File
@@ -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
)
)