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