- 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>
98 lines
2.8 KiB
React
98 lines
2.8 KiB
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.
|
||
//
|
||
// 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
|
||
|
||
function isDismissed() {
|
||
try {
|
||
return Number(localStorage.getItem(DISMISS_KEY) || 0) > Date.now()
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
export default function InstallAppBanner() {
|
||
const eligible = deliveryMode() === 'plain-web' && !isDismissed()
|
||
const [available, setAvailable] = useState(false)
|
||
const [hidden, setHidden] = useState(false)
|
||
|
||
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 {
|
||
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}
|
||
download
|
||
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>
|
||
)
|
||
}
|