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 React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { Toaster, toast } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { registerSW } from 'virtual:pwa-register';
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
import { detectInCar, applyInCarClass } from './lib/incar';
|
import { detectInCar, applyInCarClass } from './lib/incar';
|
||||||
|
import { SwNotifier } from './components/SwNotifier';
|
||||||
|
|
||||||
applyInCarClass(detectInCar());
|
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(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
<Toaster position="top-center" richColors closeButton />
|
<Toaster position="top-center" richColors closeButton />
|
||||||
|
<SwNotifier />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user