injectManifest, a new typed SW source, runtime caching, and a non‑blocking update toast with an action button. The
guest shell now links a dedicated manifest and theme color, and background upload sync is managed in a single
PwaManager component.
Key changes (where/why)
- vite.config.ts: added VitePWA injectManifest config, guest manifest, and output to /public so the SW can control /
scope.
- resources/js/guest/guest-sw.ts: new Workbox SW (precache + runtime caching for guest navigation, GET /api/v1/*,
images, fonts) and preserves push/sync/notification logic.
- resources/js/guest/components/PwaManager.tsx: registers SW, shows update/offline toasts, and processes the upload
queue on sync/online.
- resources/js/guest/components/ToastHost.tsx: action-capable toasts so update prompts can include a CTA.
- resources/js/guest/i18n/messages.ts: added common.updateAvailable, common.updateAction, common.offlineReady.
- resources/views/guest.blade.php: manifest + theme color + apple touch icon.
- .gitignore: ignore generated public/guest-sw.js and public/guest.webmanifest; public/guest-sw.js removed since it’s
now build output.
80 lines
2.8 KiB
TypeScript
80 lines
2.8 KiB
TypeScript
// @ts-nocheck
|
|
import React from 'react';
|
|
|
|
type ToastAction = { label: string; onClick: () => void };
|
|
type Toast = {
|
|
id: number;
|
|
text: string;
|
|
type?: 'success' | 'error' | 'info';
|
|
action?: ToastAction;
|
|
durationMs?: number;
|
|
};
|
|
const Ctx = React.createContext<{ push: (t: Omit<Toast,'id'>) => void } | null>(null);
|
|
|
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
const [list, setList] = React.useState<Toast[]>([]);
|
|
const push = React.useCallback((t: Omit<Toast,'id'>) => {
|
|
const id = Date.now() + Math.random();
|
|
const durationMs = t.durationMs ?? 3000;
|
|
setList((arr) => [...arr, { id, ...t, durationMs }]);
|
|
if (durationMs > 0) {
|
|
setTimeout(() => setList((arr) => arr.filter((x) => x.id !== id)), durationMs);
|
|
}
|
|
}, []);
|
|
const dismiss = React.useCallback((id: number) => {
|
|
setList((arr) => arr.filter((x) => x.id !== id));
|
|
}, []);
|
|
const contextValue = React.useMemo(() => ({ push }), [push]);
|
|
React.useEffect(() => {
|
|
const onEvt = (e: CustomEvent<Omit<Toast, 'id'>>) => push(e.detail);
|
|
window.addEventListener('guest-toast', onEvt);
|
|
return () => window.removeEventListener('guest-toast', onEvt);
|
|
}, [push]);
|
|
return (
|
|
<Ctx.Provider value={contextValue}>
|
|
{children}
|
|
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
|
|
<div className="flex w-full max-w-sm flex-col gap-2">
|
|
{list.map((t) => (
|
|
<div
|
|
key={t.id}
|
|
className={`pointer-events-auto rounded-md border p-3 shadow-sm ${
|
|
t.type === 'error'
|
|
? 'border-red-300 bg-red-50 text-red-700'
|
|
: t.type === 'info'
|
|
? 'border-blue-300 bg-blue-50 text-blue-700'
|
|
: 'border-green-300 bg-green-50 text-green-700'
|
|
}`}
|
|
>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<span className="text-sm">{t.text}</span>
|
|
{t.action ? (
|
|
<button
|
|
type="button"
|
|
className="pointer-events-auto rounded-full border border-current/30 px-3 py-1 text-xs font-semibold uppercase tracking-wide transition hover:border-current"
|
|
onClick={() => {
|
|
try {
|
|
t.action?.onClick();
|
|
} finally {
|
|
dismiss(t.id);
|
|
}
|
|
}}
|
|
>
|
|
{t.action.label}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Ctx.Provider>
|
|
);
|
|
}
|
|
|
|
export function useToast() {
|
|
const ctx = React.useContext(Ctx);
|
|
if (!ctx) throw new Error('ToastProvider missing');
|
|
return ctx;
|
|
}
|