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 (
+