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
@@ -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>
)
}