diff --git a/.gitignore b/.gitignore index 54026614..10e02da6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ fix.md # production /build +# generated service worker (rebuilt by `npm run build` with the current env) +/public/sw.js + # misc .DS_Store *.pem diff --git a/README.md b/README.md index 05b01bf3..a1a7ecb0 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,42 @@ export function ExampleComponent() { - Prefer accessible and keyboard-friendly UI patterns. +## Offline support (PWA) + +The merchant dashboard is installable and works offline after the first visit: + +- **App shell** — a Workbox service worker (`scripts/sw-template.js`, bundled to + `public/sw.js` by `scripts/build-sw.mjs` as part of `npm run build`) precaches + the Next.js JS/CSS/fonts and serves navigations network-first with a cached + shell fallback, so reloading a visited page while offline renders normally. +- **Stale-while-revalidate API reads** — same-origin `/api/*` and the configured + `NEXT_PUBLIC_API_URL` destination are cached stale-while-revalidate, keeping + list pages (payments, settlements, rates, …) populated offline. `/healthz` is + never cached, so the offline banner reflects real API reachability. +- **Background sync** — payment links created and webhook test events sent while + offline are queued in IndexedDB (`lib/offline/syncQueue.ts`) and replayed by + the service worker when connectivity returns (native `sync` + `online` event + + client-side reconnect trigger). The offline banner shows how many changes + are waiting to sync. +- **Install prompt & manifest** — `public/manifest.webmanifest` with icons + (`scripts/generate-icons.mjs`) drives Chrome's install prompt, surfaced by + `components/layout/InstallPrompt.tsx` in the merchant layout. + +The service worker is only registered in production builds. `public/sw.js` is +generated and git-ignored. + +To verify the offline behaviours end-to-end: + +```bash +NEXT_PUBLIC_API_URL=http://localhost:3000 npm run build +npm run verify:offline +``` + +`npm run verify:offline` boots the production server and drives a headless +Chromium through: first-load caching, an offline reload rendering from cache with +the banner, creating a payment link offline, and watching it sync back in when +connectivity returns. + ## Next steps - Implement proper server-side auth and refresh token endpoints diff --git a/__tests__/middleware.test.ts b/__tests__/middleware.test.ts index 45ae858c..8f6537a5 100644 --- a/__tests__/middleware.test.ts +++ b/__tests__/middleware.test.ts @@ -118,17 +118,28 @@ describe('Next.js Middleware Auth & RBAC', () => { describe('Middleware config matcher', () => { const matcherPattern = config.matcher[0]; const isExcludedByPattern = (path: string) => { - const testRegex = /^\/((?!api|_next\/static|_next\/image|favicon\.ico).*)$/; + // Mirror of the matcher in middleware.ts. Kept next to the string equality + // assertion so both stay in sync. + const testRegex = + /^\/((?!api|_next\/static|_next\/image|favicon\.ico|sw\.js|manifest\.webmanifest|icons|logo\.png).*)$/; return !testRegex.test(path); }; - it('should match the expected paths and exclude api, static files, and favicon', () => { - expect(matcherPattern).toBe('/((?!api|_next/static|_next/image|favicon.ico).*)'); - + it('should match the expected paths and exclude api, static files, and PWA assets', () => { + expect(matcherPattern).toBe( + '/((?!api|_next/static|_next/image|favicon.ico|sw.js|manifest.webmanifest|icons|logo.png).*)' + ); + expect(isExcludedByPattern('/api/auth/session')).toBe(true); expect(isExcludedByPattern('/_next/static/chunks/main.js')).toBe(true); expect(isExcludedByPattern('/_next/image?url=logo.png')).toBe(true); expect(isExcludedByPattern('/favicon.ico')).toBe(true); + // PWA assets must never be redirected so the service worker can install + // and the manifest/icons resolve for the install prompt. + expect(isExcludedByPattern('/sw.js')).toBe(true); + expect(isExcludedByPattern('/manifest.webmanifest')).toBe(true); + expect(isExcludedByPattern('/icons/icon-192.png')).toBe(true); + expect(isExcludedByPattern('/logo.png')).toBe(true); expect(isExcludedByPattern('/dashboard')).toBe(false); expect(isExcludedByPattern('/overview')).toBe(false); diff --git a/app/(merchant)/layout.tsx b/app/(merchant)/layout.tsx index df181f56..bb97039a 100644 --- a/app/(merchant)/layout.tsx +++ b/app/(merchant)/layout.tsx @@ -13,6 +13,7 @@ import { useSessionTimeout } from "@/lib/hooks/useSessionTimeout"; import { useRateLimitCountdown } from "@/lib/hooks/useRateLimitCountdown"; import { SessionTimeoutModal } from "@/components/SessionTimeoutModal"; import { CommandPalette } from "@/components/command/CommandPalette"; +import { InstallPrompt } from "@/components/layout/InstallPrompt"; export default function MerchantLayout({ children, @@ -114,6 +115,8 @@ export default function MerchantLayout({ + + {isAuthenticated && ( (null); const [linksError, setLinksError] = useState(false); const [isCreating, setIsCreating] = useState(false); + // Links created while offline and waiting for background sync to replay. + const [pendingOfflineCount, setPendingOfflineCount] = useState(0); // Form states const [labelValue, setLabelValue] = useState(''); @@ -211,6 +216,31 @@ export default function PaymentsPage() { resetForm(); refetch(); } catch (err: unknown) { + const { isOnline, isApiReachable } = useOfflineStore.getState(); + if (!isOnline || !isApiReachable) { + // Offline / API unreachable: queue the creation for background sync + // instead of failing. The service worker replays it when connectivity + // returns and we refetch on SYNC_COMPLETE. + const csrf = getCsrfTokenFromCookie(); + const headers: Array<[string, string]> = [['Content-Type', 'application/json']]; + if (csrf) headers.push([CSRF_HEADER_NAME, csrf]); + try { + await enqueueSyncRequest({ + tag: SYNC_TAGS.paymentLink, + url: `${getApiBaseUrl()}/api/payment-links`, + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + await refreshPendingCount(); + notifySuccess("Payment link saved offline — it will sync automatically when you're back online."); + setIsCreateOpen(false); + resetForm(); + return; + } catch { + // Fall through to the generic error below if queueing itself fails. + } + } const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed to create payment link'; @@ -220,6 +250,26 @@ export default function PaymentsPage() { } }; + // Track how many offline-created links are waiting to sync, and refetch the + // list whenever the service worker reports one has been replayed. + const refreshPendingCount = useCallback(async () => { + const count = await getPendingSyncCount(SYNC_TAGS.paymentLink); + setPendingOfflineCount(count); + }, []); + + const refetchRef = useRef(refetch); + refetchRef.current = refetch; + + useEffect(() => { + void refreshPendingCount(); + return watchSyncComplete((message) => { + if (message.tag === SYNC_TAGS.paymentLink) { + void refreshPendingCount(); + refetchRef.current(); + } + }); + }, [refreshPendingCount]); + const { register: registerEdit, handleSubmit: handleEditSubmitForm, reset: resetEditForm, formState: { errors: editErrors } } = useForm({ // The schema marks `currency` with a default, so its input type makes it // optional while the inferred form type requires it. Cast through unknown @@ -270,6 +320,12 @@ export default function PaymentsPage() { description="Create and manage links to accept crypto payments." actions={ <> + {pendingOfflineCount > 0 && ( + + + )} { @@ -190,10 +199,11 @@ export function WebhookTester({ const bodyString = JSON.stringify(payload); const timestamp = Math.floor(Date.now() / 1000).toString(); const eventId = (payload as { id?: string })?.id || `evt_${Date.now()}`; + // Computed before the try so the offline catch can replay the exact + // signed request via background sync. Never throws (falls back to ""). + const signature = await computeHmacSignature(webhookSecret, bodyString); try { - const signature = await computeHmacSignature(webhookSecret, bodyString); - const res = await fetch(endpointUrl, { method: "POST", headers: { @@ -254,6 +264,42 @@ export function WebhookTester({ notify.error(`Webhook endpoint returned status ${responseStatusCode}`); } } catch (err: unknown) { + const { isOnline, isApiReachable } = useOfflineStore.getState(); + if (!isOnline || !isApiReachable) { + // Offline / API unreachable: queue the test for background sync so it + // is sent automatically when connectivity returns. + const headers: Array<[string, string]> = [ + ["Content-Type", "application/json"], + ["X-BettaPay-Signature", signature], + ["X-BettaPay-Timestamp", timestamp], + ["X-BettaPay-Event-Id", eventId], + ]; + try { + const syncId = await enqueueSyncRequest({ + tag: SYNC_TAGS.webhookTest, + url: endpointUrl, + method: "POST", + headers, + body: bodyString, + }); + setDeliveryLog((prev) => [ + { + id: `del_${Date.now()}`, + timestamp: new Date(), + eventType: selectedEvent, + targetUrl: endpointUrl, + status: "pending", + statusCode: 0, + syncId, + }, + ...prev, + ]); + notify.info("Webhook test queued — it will be sent automatically when you're back online."); + return; + } catch { + // Fall through to the generic network error below. + } + } const errorMsg = err instanceof Error ? err.message : "Failed to deliver webhook"; setResponse({ status: 0, @@ -281,6 +327,32 @@ export function WebhookTester({ } }, [endpointUrl, webhookSecret, selectedEvent, notify]); + // When a background-synced test is replayed, flip its pending log entry to + // delivered/failed so the delivery history reflects the actual outcome. + useEffect(() => { + return watchSyncComplete((message: SyncCompleteMessage) => { + if (message.tag !== SYNC_TAGS.webhookTest) return; + setDeliveryLog((prev) => + prev.map((entry) => + entry.syncId === message.id + ? { + ...entry, + status: message.ok ? "success" : "failed", + statusCode: message.ok ? 200 : 0, + resultType: message.ok ? "background sync" : "sync failed", + syncId: undefined, + } + : entry, + ), + ); + if (message.ok) { + notify.success("Queued webhook test delivered (background sync)"); + } else { + notify.error("Queued webhook test could not be delivered (background sync)"); + } + }); + }, [notify]); + const handleCopyPayload = useCallback(() => { navigator.clipboard.writeText(JSON.stringify(SAMPLE_PAYLOADS[selectedEvent], null, 2)); notify.success("Payload copied to clipboard"); @@ -490,12 +562,18 @@ export function WebhookTester({ {entry.eventType} - - {entry.status === "success" ? "Delivered" : "Failed"} - + {entry.status === "pending" ? ( + + Queued (offline) + + ) : ( + + {entry.status === "success" ? "Delivered" : "Failed"} + + )} {entry.statusCode} {entry.resultType && `(${entry.resultType})`} diff --git a/components/layout/InstallPrompt.tsx b/components/layout/InstallPrompt.tsx new file mode 100644 index 00000000..fea85cc4 --- /dev/null +++ b/components/layout/InstallPrompt.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Download, X } from 'lucide-react'; +import { Button } from '@/components/ui'; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; +} + +/** + * Installs the merchant dashboard as a standalone PWA. The browser only fires + * `beforeinstallprompt` once per session, so the event is captured and + * re-dispatched on demand when the user clicks Install. + */ +export function InstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + const onBeforeInstallPrompt = (event: Event) => { + event.preventDefault(); + setDeferredPrompt(event as BeforeInstallPromptEvent); + }; + const onInstalled = () => setDeferredPrompt(null); + window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt); + window.addEventListener('appinstalled', onInstalled); + return () => { + window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt); + window.removeEventListener('appinstalled', onInstalled); + }; + }, []); + + if (!deferredPrompt || dismissed) return null; + + const handleInstall = async () => { + await deferredPrompt.prompt(); + const choice = await deferredPrompt.userChoice; + if (choice.outcome === 'accepted') { + setDeferredPrompt(null); + } + }; + + return ( +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+

Install BettaPay

+

+ Get the merchant dashboard on your home screen — it works offline. +

+
+
+ +
+ +
+ ); +} diff --git a/components/providers.tsx b/components/providers.tsx index 05ea17ad..633882c2 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -15,6 +15,8 @@ import { initErrorReporting } from "@/lib/errorReporting"; import { useRouteChange } from "@/lib/rum/useRouteChange"; import { useHydrationCapture } from "@/lib/rum/useHydrationCapture"; import { isPublicRoute, isAuthRoute } from "@/lib/auth/session"; +import { ServiceWorkerRegistration } from "@/components/ui/service-worker-registration"; +import { triggerSync } from "@/lib/offline/syncQueue"; export function Providers({ children }: { children: ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -42,6 +44,11 @@ export function Providers({ children }: { children: ReactNode }) { staleTime: 30_000, refetchOnWindowFocus: false, retry: 1, + // OfflineFirst lets queries fire while the browser reports being + // offline so the service worker can answer them from its + // stale-while-revalidate cache; without this, React Query would + // short-circuit on navigator.onLine before the SW gets a chance. + networkMode: 'offlineFirst', }, }, }) @@ -49,11 +56,15 @@ export function Providers({ children }: { children: ReactNode }) { // Purge cached merchant data when the user logs out so the next account // never sees stale payment/settlement/rate/profile data from the previous - // session. + // session. The service worker's API cache holds the same data for offline + // use, so ask it to drop those responses too. const wasAuthenticatedRef = useRef(isAuthenticated); useEffect(() => { if (wasAuthenticatedRef.current && !isAuthenticated) { queryClient.clear(); + if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator && navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ type: 'CLEAR_API_CACHE' }); + } } wasAuthenticatedRef.current = isAuthenticated; }, [isAuthenticated, queryClient]); @@ -74,6 +85,17 @@ export function Providers({ children }: { children: ReactNode }) { useHydrationCapture(); const { isVerifying } = useSessionCheck(); useCrossTabAuth(); + + // Replay offline-queued mutations (payment links, webhook tests) the moment + // the browser reports connectivity again. The service worker also drains on + // its own `online`/`sync` events; this is the deterministic client fallback. + useEffect(() => { + const handleOnline = () => { + void triggerSync(); + }; + window.addEventListener("online", handleOnline); + return () => window.removeEventListener("online", handleOnline); + }, []); const pathname = usePathname(); const isLoggedIn = useAuthStore((s) => s.isLoggedIn); // Prevent flash of protected page for logged-out users (fix #575): @@ -88,6 +110,7 @@ export function Providers({ children }: { children: ReactNode }) { return ( + {showFlashGuard ? (
diff --git a/components/ui/index.ts b/components/ui/index.ts index f01da7a8..c586cd3c 100644 --- a/components/ui/index.ts +++ b/components/ui/index.ts @@ -1,4 +1,5 @@ export * from './offline-banner'; +export * from './service-worker-registration'; export * from './card'; export * from './popover'; export * from './network-tooltip'; diff --git a/components/ui/offline-banner.tsx b/components/ui/offline-banner.tsx index 6ca04b8b..f3a22cea 100644 --- a/components/ui/offline-banner.tsx +++ b/components/ui/offline-banner.tsx @@ -1,10 +1,11 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { WifiOff, RotateCw, X } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useOnlineStatus, pingApiHealth } from '@/lib/hooks/useOnlineStatus'; import { useOfflineStore } from '@/lib/store/offlineStore'; +import { getPendingSyncCount, watchSyncComplete } from '@/lib/offline/syncQueue'; export function OfflineBanner() { const detectedOnline = useOnlineStatus(); @@ -15,6 +16,26 @@ export function OfflineBanner() { const setIsApiReachable = useOfflineStore((s) => s.setIsApiReachable); const dismiss = useOfflineStore((s) => s.dismiss); const queryClient = useQueryClient(); + const [pendingSyncCount, setPendingSyncCount] = useState(0); + + // Surface offline-created mutations (payment links, webhook tests) that will + // be background-synced once connectivity returns. Skipped under jest — the + // store is mocked there and IndexedDB does not exist. + useEffect(() => { + if (process.env.NODE_ENV === 'test') return; + let cancelled = false; + const refresh = () => { + void getPendingSyncCount().then((count) => { + if (!cancelled) setPendingSyncCount(count); + }); + }; + refresh(); + const unsubscribe = watchSyncComplete(() => refresh()); + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); // Feed the browser-detected connectivity into the shared store so the banner // (and the rest of the app) render from a single source of truth. @@ -52,6 +73,10 @@ export function OfflineBanner() { const message = !isOnline ? "You are offline. Some features may be unavailable." : "API server is unreachable. Some features may be degraded."; + const syncNote = + pendingSyncCount > 0 + ? `${pendingSyncCount} change${pendingSyncCount === 1 ? '' : 's'} waiting to sync automatically` + : null; return (