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 <SwNotifier/> 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.
This commit is contained in:
2026-05-31 23:17:11 +01:00
parent b125f9a723
commit 10235aaa32
2 changed files with 106 additions and 21 deletions
+103
View File
@@ -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<Kind | null>(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 (
<div
onClick={dismiss}
className="fixed inset-0 grid place-items-center p-8"
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.82)' }}
>
<div
onClick={(e) => 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)',
}}
>
<div className="px-8 py-7" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="text-[12px] uppercase tracking-[0.2em] mb-3"
style={{ color: isUpdate ? 'var(--gd-amber)' : 'var(--gd-green)' }}>
{isUpdate ? 'New version available' : 'Ready to use offline'}
</div>
<div className="text-[22px] font-semibold leading-[1.25]">
{isUpdate
? 'Reload to get the latest planner update'
: 'Your trip will be available without signal'}
</div>
<div className="text-[14px] mt-3" style={{ color: 'var(--gd-text-2)' }}>
{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.'}
</div>
</div>
<div className="px-8 py-5 flex items-center gap-3" style={{ borderTop: '1px solid var(--gd-border)' }}>
{isUpdate && (
<button
onClick={dismiss}
className="flex-1 h-14 text-[16px] rounded-2xl"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
Later
</button>
)}
<button
onClick={action}
className="flex-[2] h-14 text-[16px] rounded-2xl font-semibold"
style={{ background: 'var(--gd-red)', color: '#fff', boxShadow: '0 6px 24px rgba(227,25,55,0.35)' }}
>
{isUpdate ? 'Reload' : 'Got it'}
</button>
</div>
</div>
</div>
);
}
+3 -21
View File
@@ -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(
<React.StrictMode>
<BrowserRouter>
<App />
<Toaster position="top-center" richColors closeButton />
<SwNotifier />
</BrowserRouter>
</React.StrictMode>
);