From 10235aaa323febefe542d7d5aab70ae8fc8fcf6a Mon Sep 17 00:00:00 2001 From: Tony James Date: Sun, 31 May 2026 23:17:11 +0100 Subject: [PATCH] feat(pwa): in-car PWA notifications use a full-screen ack dialog Small sonner toasts are unreadable + untappable on a car dash. Move SW registration into so we have React state: - Desktop / small-screen: normal sonner toasts (unchanged). - In-car (detectInCar().isInCar): full-screen modal with 56px-tall buttons, big copy, finger-friendly Got it / Reload + Later actions. - offline-ready dismisses on tap; needs-refresh has Later + Reload. --- client/src/components/SwNotifier.tsx | 103 +++++++++++++++++++++++++++ client/src/main.tsx | 24 +------ 2 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 client/src/components/SwNotifier.tsx diff --git a/client/src/components/SwNotifier.tsx b/client/src/components/SwNotifier.tsx new file mode 100644 index 0000000..6ce45b4 --- /dev/null +++ b/client/src/components/SwNotifier.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { registerSW } from 'virtual:pwa-register'; +import { detectInCar } from '../lib/incar'; + +type Kind = 'offline-ready' | 'needs-refresh'; + +export function SwNotifier() { + const [event, setEvent] = useState(null); + const [updateSW, setUpdateSW] = useState<((reload?: boolean) => void) | null>(null); + const inCar = detectInCar().isInCar; + + useEffect(() => { + if (!('serviceWorker' in navigator)) return; + const fn = registerSW({ + immediate: true, + onNeedRefresh() { setEvent('needs-refresh'); }, + onOfflineReady() { setEvent('offline-ready'); }, + }); + setUpdateSW(() => fn); + }, []); + + // Desktop / small-screen UX: use a normal toast. + useEffect(() => { + if (!event || inCar) return; + if (event === 'offline-ready') { + toast.success('Ready to use offline', { + description: 'Your planned trip + map tiles will be available without signal.', + }); + setEvent(null); + } else if (event === 'needs-refresh') { + toast('New version available', { + action: { label: 'Reload', onClick: () => updateSW?.(true) }, + duration: Infinity, + }); + setEvent(null); + } + }, [event, inCar, updateSW]); + + if (!inCar || !event) return null; + + // In-car: render a big full-screen acknowledgement with finger-friendly button. + const isUpdate = event === 'needs-refresh'; + const dismiss = () => setEvent(null); + const action = () => { + if (isUpdate) updateSW?.(true); + else dismiss(); + }; + + return ( +
+
e.stopPropagation()} + className="w-full max-w-[560px] overflow-hidden" + style={{ + background: 'var(--gd-bg-2)', + border: '1px solid var(--gd-border-2)', + borderRadius: 20, + boxShadow: '0 24px 60px rgba(0,0,0,0.55)', + }} + > +
+
+ {isUpdate ? 'New version available' : 'Ready to use offline'} +
+
+ {isUpdate + ? 'Reload to get the latest planner update' + : 'Your trip will be available without signal'} +
+
+ {isUpdate + ? 'Your current plan stays the same. We just refresh the app.' + : 'Planned route, map tiles, and last-known itinerary are saved on this device. You can keep using the planner if LTE drops.'} +
+
+
+ {isUpdate && ( + + )} + +
+
+
+ ); +} diff --git a/client/src/main.tsx b/client/src/main.tsx index 705617b..26f6219 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,38 +1,20 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; -import { Toaster, toast } from 'sonner'; -import { registerSW } from 'virtual:pwa-register'; +import { Toaster } from 'sonner'; import App from './App'; import './styles/globals.css'; import { detectInCar, applyInCarClass } from './lib/incar'; +import { SwNotifier } from './components/SwNotifier'; applyInCarClass(detectInCar()); -// Register the service worker. autoUpdate strategy means a refresh picks up -// the new build immediately — we still show a toast so the user knows. -if (import.meta.env.PROD) { - const updateSW = registerSW({ - immediate: true, - onNeedRefresh() { - toast('New version available', { - action: { label: 'Reload', onClick: () => updateSW(true) }, - duration: Infinity, - }); - }, - onOfflineReady() { - toast.success('Ready to use offline', { - description: 'Your planned trip + map tiles will be available without signal.', - }); - }, - }); -} - ReactDOM.createRoot(document.getElementById('root')!).render( + );