From a486e3ff63038fb37c2799a485ac1a35ad3e7d0d Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 28 Sep 2026 13:31:23 +0300 Subject: [PATCH] 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= 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 --- waiter_pwa/src/App.jsx | 15 +- .../src/components/InstallAppBanner.jsx | 27 +++- waiter_pwa/src/components/QrScannerModal.jsx | 90 +++++++++++ waiter_pwa/src/components/UserMenu.jsx | 9 ++ waiter_pwa/src/main.jsx | 12 +- waiter_pwa/src/native/backButton.js | 24 +++ waiter_pwa/src/native/pairing.js | 104 ++++++++++++ waiter_pwa/src/pages/LoginPage.jsx | 10 +- waiter_pwa/src/pages/OfflinePage.jsx | 10 ++ waiter_pwa/src/pages/VenuesPage.jsx | 151 ++++++++++++++++++ 10 files changed, 440 insertions(+), 12 deletions(-) create mode 100644 waiter_pwa/src/components/QrScannerModal.jsx create mode 100644 waiter_pwa/src/native/backButton.js create mode 100644 waiter_pwa/src/native/pairing.js create mode 100644 waiter_pwa/src/pages/VenuesPage.jsx diff --git a/waiter_pwa/src/App.jsx b/waiter_pwa/src/App.jsx index 6fb2b63..68b51b2 100644 --- a/waiter_pwa/src/App.jsx +++ b/waiter_pwa/src/App.jsx @@ -25,7 +25,8 @@ 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' +import VenuesPage from './pages/VenuesPage' +import { deliveryMode, getActiveVenue } from './config/server' // ─── Utility ───────────────────────────────────────────────────────────────── @@ -288,6 +289,8 @@ function OfflineListener() { const { status } = useConnectionStore() useEffect(() => { function handler() { + // The venue switcher must stay usable while the current venue is unreachable + if (window.location.pathname === '/venues') return // If user is logged in, ConnectivityBar handles it — don't redirect to /offline if (token && status !== 'online') return // Not logged in and server is down → redirect to offline page @@ -313,11 +316,13 @@ function ThemeApplier() { // push a sentinel entry so there is always one history entry behind it. If the // user backs into the sentinel we push it again — they can never back past /tables. // -// Only active when running as an installed PWA (standalone display mode). +// Only active when running as an installed PWA (standalone display mode) or in +// the native app, where the hardware back button would otherwise close the app. function AndroidBackGuard() { useEffect(() => { const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true + || deliveryMode() === 'native' if (!isStandalone) return function pushSentinel() { @@ -402,6 +407,11 @@ function TableListRoute() { const queryClient = new QueryClient() export default function App() { + // Native app with no paired venue yet: nothing else can work, show pairing only + if (deliveryMode() === 'native' && !getActiveVenue()) { + return + } + return ( @@ -420,6 +430,7 @@ export default function App() { } /> } /> + : } /> }> } /> } /> diff --git a/waiter_pwa/src/components/InstallAppBanner.jsx b/waiter_pwa/src/components/InstallAppBanner.jsx index 8bb39aa..5df42cf 100644 --- a/waiter_pwa/src/components/InstallAppBanner.jsx +++ b/waiter_pwa/src/components/InstallAppBanner.jsx @@ -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://), 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() { { + let done = false + let scanner = null + + async function start() { + // Ask for the camera ONCE ourselves: qr-scanner retries with another camera + // after a refusal, which would show the Android permission prompt twice, and + // its errors don't tell "denied" apart from "no camera". + try { + const probe = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }) + probe.getTracks().forEach(t => t.stop()) + } catch (err) { + if (!done) setError(err?.name === 'NotAllowedError' ? MSG_DENIED : MSG_NO_CAMERA) + return + } + if (done) return + + scanner = new QrScanner( + videoRef.current, + result => { + if (done) return + done = true + scanner.stop() + onResult(result.data) + }, + { preferredCamera: 'environment', returnDetailedScanResult: true, highlightScanRegion: true }, + ) + scanner.start().catch(() => { + if (!done) setError(MSG_NO_CAMERA) + }) + } + + start() + return () => { + done = true + scanner?.destroy() + } + }, [onResult]) + + return ( +
+
+ {/* 1px transparent poster hides the WebView's big "play" placeholder before the stream starts */} +
+
+ {!error && ( +

+ Σκοπεύστε το QR από τον διαχειριστή (Ρυθμίσεις → Γενικά → Σερβιτόροι WiFi) +

+ )} + +
+
+ ) +} diff --git a/waiter_pwa/src/components/UserMenu.jsx b/waiter_pwa/src/components/UserMenu.jsx index 034a051..9506f7c 100644 --- a/waiter_pwa/src/components/UserMenu.jsx +++ b/waiter_pwa/src/components/UserMenu.jsx @@ -4,6 +4,7 @@ import useAuthStore from '../store/authStore' import useShiftStore from '../store/shiftStore' import useThemeStore from '../store/themeStore' import client from '../api/client' +import { deliveryMode, getActiveVenue } from '../config/server' function formatTime(iso) { if (!iso) return '' @@ -168,6 +169,14 @@ export default function UserMenu() { )} + {/* ── Venue switch (native app only) ───────────────── */} + {deliveryMode() === 'native' && ( + + )} + {/* ── Theme toggle ──────────────────────────────────── */} + )} {/* Scrollable card list */} diff --git a/waiter_pwa/src/pages/OfflinePage.jsx b/waiter_pwa/src/pages/OfflinePage.jsx index a39d7a2..b625ae2 100644 --- a/waiter_pwa/src/pages/OfflinePage.jsx +++ b/waiter_pwa/src/pages/OfflinePage.jsx @@ -1,4 +1,5 @@ import client from '../api/client' +import { deliveryMode } from '../config/server' import { useState } from 'react' export default function OfflinePage() { @@ -25,6 +26,15 @@ export default function OfflinePage() { + {deliveryMode() === 'native' && ( + + )} ) diff --git a/waiter_pwa/src/pages/VenuesPage.jsx b/waiter_pwa/src/pages/VenuesPage.jsx new file mode 100644 index 0000000..3dde2aa --- /dev/null +++ b/waiter_pwa/src/pages/VenuesPage.jsx @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useState } from 'react' +import useThemeStore from '../store/themeStore' +import QrScannerModal from '../components/QrScannerModal' +import { pairWith } from '../native/pairing' +import { getActiveVenue, listVenues, removeVenue, switchVenue } from '../config/server' + +/** + * Native app: pair with a venue server and switch between paired venues. + * + * `firstRun` — rendered on its own (no router, no auth) when the app has no + * active venue yet. Otherwise it's the /venues route, reachable from the login + * screen, the user menu and the offline screen. + */ +export default function VenuesPage({ firstRun = false }) { + const dark = useThemeStore(s => s.dark) + const [venues, setVenues] = useState(listVenues) + const [address, setAddress] = useState('') + const [scanning, setScanning] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const active = getActiveVenue() + + // First run renders outside 's ThemeApplier + useEffect(() => { + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') + }, [dark]) + + const connect = useCallback(async raw => { + setScanning(false) + setBusy(true) + setError(null) + try { + const venue = await pairWith(raw) + switchVenue(venue.siteId) // reloads the app into the venue + } catch (e) { + setError(e.message || 'Αποτυχία σύνδεσης.') + setBusy(false) + } + }, []) + + function handleRemove(venue) { + const msg = venue.siteId === active?.siteId + ? `Αφαίρεση του ενεργού καταστήματος «${venue.name}»; Θα χρειαστεί νέα σύνδεση.` + : `Αφαίρεση του καταστήματος «${venue.name}» από αυτό το κινητό;` + if (!window.confirm(msg)) return + removeVenue(venue.siteId) + if (venue.siteId === active?.siteId) { + window.location.replace('/') + } else { + setVenues(listVenues()) + } + } + + return ( +
+ {!firstRun && ( + + )} + +
+
🏪
+

+ {firstRun ? 'Σύνδεση με κατάστημα' : 'Καταστήματα'} +

+

+ Σαρώστε το QR από τον διαχειριστή ή πληκτρολογήστε τη διεύθυνση του server. + Το κινητό πρέπει να είναι στο WiFi του καταστήματος. +

+
+ + {venues.length > 0 && ( +
+ {venues.map(v => { + const isActive = v.siteId === active?.siteId + return ( +
+ + +
+ ) + })} +
+ )} + +
+ + +
+ setAddress(e.target.value)} + onKeyDown={e => e.key === 'Enter' && address && connect(address)} + placeholder="π.χ. 192.168.1.50" + inputMode="url" + autoCapitalize="off" + autoCorrect="off" + disabled={busy} + style={{ + flex: 1, minWidth: 0, background: 'var(--bg3)', border: '1px solid var(--border)', + borderRadius: 12, padding: '0 14px', height: 52, color: 'var(--text)', fontSize: 16, outline: 'none', + }} + /> + +
+ + {error && ( +

+ {error} +

+ )} +
+ + {scanning && setScanning(false)} />} +
+ ) +}