feat(waiter): native pairing, venue switcher, QR scanner, Android back handling

- First run in the native app shows only the pairing screen (VenuesPage):
  scan the manager's pairing QR or type the server address; the server must
  answer /api/system/identity as xenia-pos with a supported api_version, and
  a QR's ?pair=<site_id> must match - a code from another venue is refused
- /venues (native only): list of paired venues, switch (reloads into that
  venue's own token/IndexedDB), remove (data kept - may hold unsynced
  orders). Reachable from the login screen, the user menu and the offline page
- QrScannerModal: WebView camera via qr-scanner (no Google Play Services
  dependency, carries over to iOS); requests the camera once up front so the
  Android prompt appears a single time and "denied" gets its own message
- Hardware back: @capacitor/app listener - no-op on root screens (/, /tables,
  /login), otherwise history back. Path-based because Chrome's history
  intervention makes the WebView report canGoBack=false for the sentinel
  entry AndroidBackGuard pushes
- Before pairing no IndexedDB is opened, so nothing is created under the
  un-namespaced name
- InstallAppBanner now shows in plain-HTTP browser mode only when the venue
  server actually serves the APK (HEAD /downloads/xenia-waiter.apk)

Verified on an Android 15 emulator (API 35) with real taps via adb and state
via WebView DevTools: wrong address -> error; other venue's QR -> refused;
pairing -> venue-namespaced storage, live WebSocket; back on /tables keeps the
app open; restart keeps venue + session; second venue pairs logged-out and
switching back keeps venue 1's session; camera deny -> one prompt + message;
allow -> live preview. Signed release APK installed and paired. Web modes
(same-origin, plain-HTTP LAN IP, VITE_SERVER_URL) re-verified in Edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 13:31:23 +03:00
co-authored by Claude Opus 5.5
parent 0b0f5c60c3
commit a486e3ff63
10 changed files with 440 additions and 12 deletions
+19 -8
View File
@@ -1,10 +1,13 @@
import { useState } from 'react'
import { useEffect, 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 || ''
// work offline after a reload and can't be installed as a real PWA.
//
// The APK is served by the venue's own server (scripts/build-apk.mjs bundles the
// release APK into the web build), so the banner appears only when that file is
// actually there. VITE_APP_DOWNLOAD_URL overrides the location if ever needed.
const DOWNLOAD_URL = import.meta.env.VITE_APP_DOWNLOAD_URL || '/downloads/xenia-waiter.apk'
const DISMISS_KEY = 'install-banner-dismissed-until'
const DISMISS_DAYS = 7
@@ -17,9 +20,18 @@ function isDismissed() {
}
export default function InstallAppBanner() {
const [hidden, setHidden] = useState(isDismissed)
const eligible = deliveryMode() === 'plain-web' && !isDismissed()
const [available, setAvailable] = useState(false)
const [hidden, setHidden] = useState(false)
if (!DOWNLOAD_URL || hidden || deliveryMode() !== 'plain-web') return null
useEffect(() => {
if (!eligible) return
fetch(DOWNLOAD_URL, { method: 'HEAD' })
.then(r => setAvailable(r.ok && !(r.headers.get('content-type') || '').includes('text/html')))
.catch(() => {})
}, [eligible])
if (!eligible || !available || hidden) return null
function dismiss() {
try {
@@ -51,8 +63,7 @@ export default function InstallAppBanner() {
</span>
<a
href={DOWNLOAD_URL}
target="_blank"
rel="noreferrer"
download
style={{
background: 'var(--accent)',
color: '#0f172a',