feat(waiter): install-app dialog (first visit) + "Εγκατάσταση εφαρμογής" in the side drawer

Replaces the thin bottom banner with a proper dialog (taller than wide):
app icon, four reasons to install (starts instantly, survives WiFi drops,
encrypted link, finds the server + updates itself), the three install
steps, "Λήψη εφαρμογής" and "Όχι τώρα". It opens by itself once, on the
first browser visit; closing it or tapping download marks it seen for good
(previously a 7-day dismissal, and once gone there was no way back).
Afterwards it is reachable from the side drawer, right above Settings.

Offered only where it makes sense: a browser on an Android phone (never in
the native app, on iPhones or desktops) and only if this venue's server
actually serves the APK (HEAD /downloads/xenia-waiter.apk, checked once).

Verified in Edge emulating Android / iPhone / desktop against the prod
build: auto-opens once, "Όχι τώρα" persists across reloads, drawer item
above Settings reopens it, not offered on iPhone/desktop or when the
server has no APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:01:05 +03:00
co-authored by Claude Opus 5.5
parent 251cac9807
commit a349043abb
5 changed files with 203 additions and 101 deletions
+2 -2
View File
@@ -24,7 +24,7 @@ import { SSEProvider } from './context/SSEContext'
import ConnectivityBar from './components/ConnectivityBar'
import AppShell from './components/AppShell'
import UpdatePrompt from './components/UpdatePrompt'
import InstallAppBanner from './components/InstallAppBanner'
import InstallAppDialog from './components/InstallAppDialog'
import VenuesPage from './pages/VenuesPage'
import ServerRediscovery from './components/ServerRediscovery'
import BundleSync from './components/BundleSync'
@@ -447,7 +447,7 @@ export default function App() {
<ConnectivityBar />
{/* Native builds ship their UI in the app bundle — no service worker to update */}
{deliveryMode() !== 'native' && <UpdatePrompt />}
<InstallAppBanner />
<InstallAppDialog />
{deliveryMode() === 'native' && <ServerRediscovery />}
{deliveryMode() === 'native' && <BundleSync />}
<Routes>
+35 -2
View File
@@ -6,6 +6,7 @@ import useNotificationSettingsStore from '../store/notificationSettingsStore'
import useTableViewStore from '../store/tableViewStore'
import useChatStore from '../store/chatStore'
import useKdsReadyStore from '../store/kdsReadyStore'
import useInstallPromptStore, { apkAvailable } from '../store/installPromptStore'
import useOfflineQueueCount from '../hooks/useOfflineQueueCount'
import useFailedPrintCount from '../hooks/useFailedPrintCount'
@@ -296,6 +297,37 @@ const DRAWER_SETTINGS_ITEM = {
),
}
const DRAWER_INSTALL_ITEM = {
key: 'install-app',
label: 'Εγκατάσταση εφαρμογής',
match: () => false,
icon: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M12 3v12m0 0-4.5-4.5M12 15l4.5-4.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
}
// Browser on Android only, and only if this venue's server serves the APK
// (the native app never shows it) — opens the install dialog.
function InstallAppDrawerButton({ location, onClose }) {
const [available, setAvailable] = useState(false)
const show = useInstallPromptStore(s => s.show)
useEffect(() => {
apkAvailable().then(setAvailable)
}, [])
if (!available) return null
return (
<DrawerButton
item={DRAWER_INSTALL_ITEM}
location={location}
active={false}
onClick={() => { onClose(); show() }}
/>
)
}
function DrawerButton({ item, location, onNavigate, active: activeProp, onClick }) {
const active = activeProp ?? item.match(location)
return (
@@ -392,8 +424,9 @@ function SideDrawer({ open, onClose }) {
})}
</div>
{/* Settings at bottom */}
<div style={{ padding: '8px 10px 16px', borderTop: '1px solid var(--border)' }}>
{/* Install app + settings at bottom */}
<div style={{ padding: '8px 10px 16px', borderTop: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4 }}>
<InstallAppDrawerButton location={location} onClose={onClose} />
<DrawerButton item={DRAWER_SETTINGS_ITEM} location={location} onNavigate={go} />
</div>
</div>
@@ -1,97 +0,0 @@
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>
)
}
@@ -0,0 +1,107 @@
import { useEffect } from 'react'
import useInstallPromptStore, { APK_URL, canOfferApp } from '../store/installPromptStore'
const BENEFITS = [
{ icon: '⚡', title: 'Ανοίγει αμέσως', text: 'Χωρίς φόρτωση από τον browser, σαν κανονική εφαρμογή.' },
{ icon: '📶', title: 'Αντέχει στο WiFi', text: 'Συνεχίζει να δουλεύει όταν χάνεται για λίγο η σύνδεση.' },
{ icon: '🔒', title: 'Ασφαλής σύνδεση', text: 'Κρυπτογραφημένη επικοινωνία με τον server του καταστήματος.' },
{ icon: '🔄', title: 'Χωρίς ρυθμίσεις', text: 'Βρίσκει μόνη της τον server και ενημερώνεται αυτόματα.' },
]
const STEPS = [
'Πατήστε «Λήψη εφαρμογής».',
'Ανοίξτε το αρχείο και επιτρέψτε την εγκατάσταση.',
'Ανοίξτε το Xenia και σαρώστε ξανά το QR του καταστήματος.',
]
/**
* Dialog offering the Android app to browser users (see installPromptStore).
* Opens by itself once; afterwards from the side drawer.
*/
export default function InstallAppDialog() {
const { open, dismiss, autoOpen } = useInstallPromptStore()
useEffect(() => {
if (canOfferApp()) autoOpen()
}, [autoOpen])
if (!open) return null
return (
<div
onClick={dismiss}
style={{
position: 'fixed', inset: 0, zIndex: 10000, background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="install-app-title"
onClick={e => e.stopPropagation()}
style={{
width: '100%', maxWidth: 360, maxHeight: '92vh', overflowY: 'auto',
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 22,
boxShadow: '0 16px 48px rgba(0,0,0,0.5)', padding: '26px 22px 20px',
display: 'flex', flexDirection: 'column', gap: 18,
}}
>
<div style={{ textAlign: 'center' }}>
<img
src="/icons/icon-192.png"
alt=""
width={76}
height={76}
style={{ borderRadius: 18, boxShadow: '0 6px 20px rgba(0,0,0,0.35)' }}
/>
<h2 id="install-app-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--text)', margin: '14px 0 4px' }}>
Εφαρμογή Xenia
</h2>
<p style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.45 }}>
Εγκαταστήστε την εφαρμογή για γρηγορότερη και πιο σταθερή λειτουργία.
</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{BENEFITS.map(b => (
<div key={b.title} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<span style={{
fontSize: 18, width: 36, height: 36, flexShrink: 0, borderRadius: 10,
background: 'var(--bg3)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{b.icon}
</span>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>{b.title}</div>
<div style={{ fontSize: 12.5, color: 'var(--muted)', lineHeight: 1.4 }}>{b.text}</div>
</div>
</div>
))}
</div>
<ol style={{
margin: 0, padding: '12px 14px 12px 32px', borderRadius: 12, background: 'var(--bg3)',
fontSize: 12.5, color: 'var(--muted)', lineHeight: 1.55,
}}>
{STEPS.map(s => <li key={s}>{s}</li>)}
</ol>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<a
href={APK_URL}
download
onClick={dismiss}
className="btn btn--accent btn--lg"
style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
Λήψη εφαρμογής
</a>
<button className="btn btn--secondary" onClick={dismiss} style={{ minHeight: 44 }}>
Όχι τώρα
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,59 @@
import { create } from 'zustand'
import { deliveryMode } from '../config/server'
/**
* "Install the Android app" dialog (browser mode only).
*
* It opens by itself once, the first time an Android phone opens the waiter app
* in a browser, and only if this venue's server actually serves the APK. Closing it or tapping
* download marks it as seen for good; after that it's reachable from the side
* drawer ("Εγκατάσταση εφαρμογής", above Settings).
*/
export const APK_URL = import.meta.env.VITE_APP_DOWNLOAD_URL || '/downloads/xenia-waiter.apk'
const SEEN_KEY = 'install-app-prompt-seen'
/** Browser on an Android phone — the only place an APK is useful. */
export function canOfferApp() {
return deliveryMode() !== 'native' && /Android/i.test(navigator.userAgent)
}
let availability = null
/** Resolves true if this server serves the APK (checked once per page load). */
export function apkAvailable() {
if (!canOfferApp()) return Promise.resolve(false)
availability ??= fetch(APK_URL, { method: 'HEAD' })
// nginx answers a real 404 for a missing file under /downloads/
.then(r => r.ok && !(r.headers.get('content-type') || '').includes('text/html'))
.catch(() => false)
return availability
}
function wasSeen() {
try {
return localStorage.getItem(SEEN_KEY) === '1'
} catch {
return false
}
}
const useInstallPromptStore = create((set) => ({
open: false,
show: () => set({ open: true }),
/** Close and never open by itself again. */
dismiss: () => {
try {
localStorage.setItem(SEEN_KEY, '1')
} catch {
// storage unavailable — it may open once more on the next visit
}
set({ open: false })
},
/** First visit: open once if the APK is there and the dialog wasn't seen yet. */
autoOpen: async () => {
if (wasSeen() || !(await apkAvailable())) return
set({ open: true })
},
}))
export default useInstallPromptStore