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 ConnectivityBar from './components/ConnectivityBar'
|
||||||
import AppShell from './components/AppShell'
|
import AppShell from './components/AppShell'
|
||||||
import UpdatePrompt from './components/UpdatePrompt'
|
import UpdatePrompt from './components/UpdatePrompt'
|
||||||
import InstallAppBanner from './components/InstallAppBanner'
|
import InstallAppDialog from './components/InstallAppDialog'
|
||||||
import VenuesPage from './pages/VenuesPage'
|
import VenuesPage from './pages/VenuesPage'
|
||||||
import ServerRediscovery from './components/ServerRediscovery'
|
import ServerRediscovery from './components/ServerRediscovery'
|
||||||
import BundleSync from './components/BundleSync'
|
import BundleSync from './components/BundleSync'
|
||||||
@@ -447,7 +447,7 @@ export default function App() {
|
|||||||
<ConnectivityBar />
|
<ConnectivityBar />
|
||||||
{/* Native builds ship their UI in the app bundle — no service worker to update */}
|
{/* Native builds ship their UI in the app bundle — no service worker to update */}
|
||||||
{deliveryMode() !== 'native' && <UpdatePrompt />}
|
{deliveryMode() !== 'native' && <UpdatePrompt />}
|
||||||
<InstallAppBanner />
|
<InstallAppDialog />
|
||||||
{deliveryMode() === 'native' && <ServerRediscovery />}
|
{deliveryMode() === 'native' && <ServerRediscovery />}
|
||||||
{deliveryMode() === 'native' && <BundleSync />}
|
{deliveryMode() === 'native' && <BundleSync />}
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import useNotificationSettingsStore from '../store/notificationSettingsStore'
|
|||||||
import useTableViewStore from '../store/tableViewStore'
|
import useTableViewStore from '../store/tableViewStore'
|
||||||
import useChatStore from '../store/chatStore'
|
import useChatStore from '../store/chatStore'
|
||||||
import useKdsReadyStore from '../store/kdsReadyStore'
|
import useKdsReadyStore from '../store/kdsReadyStore'
|
||||||
|
import useInstallPromptStore, { apkAvailable } from '../store/installPromptStore'
|
||||||
import useOfflineQueueCount from '../hooks/useOfflineQueueCount'
|
import useOfflineQueueCount from '../hooks/useOfflineQueueCount'
|
||||||
import useFailedPrintCount from '../hooks/useFailedPrintCount'
|
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 }) {
|
function DrawerButton({ item, location, onNavigate, active: activeProp, onClick }) {
|
||||||
const active = activeProp ?? item.match(location)
|
const active = activeProp ?? item.match(location)
|
||||||
return (
|
return (
|
||||||
@@ -392,8 +424,9 @@ function SideDrawer({ open, onClose }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings at bottom */}
|
{/* Install app + settings at bottom */}
|
||||||
<div style={{ padding: '8px 10px 16px', borderTop: '1px solid var(--border)' }}>
|
<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} />
|
<DrawerButton item={DRAWER_SETTINGS_ITEM} location={location} onNavigate={go} />
|
||||||
</div>
|
</div>
|
||||||
</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