From a349043abb0ed241640ae109a39001f154a849b2 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 28 Sep 2026 22:01:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(waiter):=20install-app=20dialog=20(first?= =?UTF-8?q?=20visit)=20+=20"=CE=95=CE=B3=CE=BA=CE=B1=CF=84=CE=AC=CF=83?= =?UTF-8?q?=CF=84=CE=B1=CF=83=CE=B7=20=CE=B5=CF=86=CE=B1=CF=81=CE=BC=CE=BF?= =?UTF-8?q?=CE=B3=CE=AE=CF=82"=20in=20the=20side=20drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- waiter_pwa/src/App.jsx | 4 +- waiter_pwa/src/components/AppShell.jsx | 37 +++++- .../src/components/InstallAppBanner.jsx | 97 ---------------- .../src/components/InstallAppDialog.jsx | 107 ++++++++++++++++++ waiter_pwa/src/store/installPromptStore.js | 59 ++++++++++ 5 files changed, 203 insertions(+), 101 deletions(-) delete mode 100644 waiter_pwa/src/components/InstallAppBanner.jsx create mode 100644 waiter_pwa/src/components/InstallAppDialog.jsx create mode 100644 waiter_pwa/src/store/installPromptStore.js diff --git a/waiter_pwa/src/App.jsx b/waiter_pwa/src/App.jsx index 1429b0a..7113e2a 100644 --- a/waiter_pwa/src/App.jsx +++ b/waiter_pwa/src/App.jsx @@ -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() { {/* Native builds ship their UI in the app bundle — no service worker to update */} {deliveryMode() !== 'native' && } - + {deliveryMode() === 'native' && } {deliveryMode() === 'native' && } diff --git a/waiter_pwa/src/components/AppShell.jsx b/waiter_pwa/src/components/AppShell.jsx index 73fd882..1e9f6d2 100644 --- a/waiter_pwa/src/components/AppShell.jsx +++ b/waiter_pwa/src/components/AppShell.jsx @@ -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: ( + + + + + ), +} + +// 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 ( + { 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 }) { })} - {/* Settings at bottom */} -
+ {/* Install app + settings at bottom */} +
+
diff --git a/waiter_pwa/src/components/InstallAppBanner.jsx b/waiter_pwa/src/components/InstallAppBanner.jsx deleted file mode 100644 index 5df42cf..0000000 --- a/waiter_pwa/src/components/InstallAppBanner.jsx +++ /dev/null @@ -1,97 +0,0 @@ -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. -// -// 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 ( -
- - Για γρηγορότερη και πιο σταθερή λειτουργία, εγκαταστήστε την εφαρμογή. - - - Εγκατάσταση - - -
- ) -} diff --git a/waiter_pwa/src/components/InstallAppDialog.jsx b/waiter_pwa/src/components/InstallAppDialog.jsx new file mode 100644 index 0000000..c165879 --- /dev/null +++ b/waiter_pwa/src/components/InstallAppDialog.jsx @@ -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 ( +
+
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, + }} + > +
+ +

+ Εφαρμογή Xenia +

+

+ Εγκαταστήστε την εφαρμογή για γρηγορότερη και πιο σταθερή λειτουργία. +

+
+ +
+ {BENEFITS.map(b => ( +
+ + {b.icon} + +
+
{b.title}
+
{b.text}
+
+
+ ))} +
+ +
    + {STEPS.map(s =>
  1. {s}
  2. )} +
+ +
+ + Λήψη εφαρμογής + + +
+
+
+ ) +} diff --git a/waiter_pwa/src/store/installPromptStore.js b/waiter_pwa/src/store/installPromptStore.js new file mode 100644 index 0000000..865661f --- /dev/null +++ b/waiter_pwa/src/store/installPromptStore.js @@ -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