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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user