Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions __tests__/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions app/(merchant)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -114,6 +115,8 @@ export default function MerchantLayout({

<CommandPalette role="merchant" />

<InstallPrompt />

{isAuthenticated && (
<SessionTimeoutModal
open={showWarning}
Expand Down
62 changes: 59 additions & 3 deletions app/(merchant)/payments/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, memo, useMemo, useEffect } from 'react';
import { useState, memo, useMemo, useEffect, useCallback, useRef } from 'react';
import { useForm, type Resolver } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { editPaymentLinkSchema, type EditPaymentLinkFormValues } from '@/lib/utils/validation';
Expand All @@ -10,7 +10,7 @@ import { PageHeader } from '@/components/shared/PageHeader';
import { QRCodeModal } from '@/components/payments/QRCode';
import { CurrencySelector } from '@/components/payments/CurrencySelector';
import { CardGridSkeleton } from '@/components/skeletons/CardGridSkeleton';
import { Plus, QrCode, Link2, Search, Edit3, Trash2 } from 'lucide-react';
import { Plus, QrCode, Link2, Search, Edit3, Trash2, CloudOff } from 'lucide-react';
import {
Dialog,
DialogContent,
Expand All @@ -24,7 +24,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { trimInput } from '@/lib/utils/sanitize';
import { useNotify } from '@/lib/hooks/useNotify';
import { usePayments, type ApiPayment } from '@/lib/api/hooks';
import { apiClient } from '@/lib/api/axios';
import { apiClient, getApiBaseUrl } from '@/lib/api/axios';
import { getCsrfTokenFromCookie, CSRF_HEADER_NAME } from '@/lib/utils/csrf';
import { useOfflineStore } from '@/lib/store/offlineStore';
import { enqueueSyncRequest, getPendingSyncCount, watchSyncComplete, SYNC_TAGS } from '@/lib/offline/syncQueue';
import Link from 'next/link';

type PaymentLink = ApiPayment;
Expand Down Expand Up @@ -119,6 +122,8 @@ export default function PaymentsPage() {
const [selectedQrLink, setSelectedQrLink] = useState<PaymentLink | null>(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('');
Expand Down Expand Up @@ -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';
Expand All @@ -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<EditPaymentLinkFormValues>({
// The schema marks `currency` with a default, so its input type makes it
// optional while the inferred form type requires it. Cast through unknown
Expand Down Expand Up @@ -270,6 +320,12 @@ export default function PaymentsPage() {
description="Create and manage links to accept crypto payments."
actions={
<>
{pendingOfflineCount > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-3 py-1.5 text-xs font-medium text-warning" role="status">
<CloudOff className="w-3.5 h-3.5" aria-hidden="true" />
{pendingOfflineCount} offline link{pendingOfflineCount === 1 ? '' : 's'} waiting to sync
</span>
)}
<ExportMenu
filename="payment-links"
headers={['Title', 'Reference', 'URL', 'AmountUSDC', 'Status', 'CreatedAt']}
Expand Down
16 changes: 16 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://betta.pay"),
title: "BettaPay | Non-custodial Merchant Platform",
description: "Accept USDC and stablecoins easily across Africa",
// PWA: web manifest, theme colour and icons drive the install prompt and the
// installed (standalone) merchant dashboard experience.
manifest: "/manifest.webmanifest",
themeColor: "#F0A500",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "BettaPay",
},
icons: {
icon: [
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
],
apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
},
};


Expand Down
98 changes: 88 additions & 10 deletions components/developers/WebhookTester.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui";
import { Button } from "@/components/ui";
import { Input } from "@/components/ui";
Expand Down Expand Up @@ -35,6 +35,13 @@ import {
import { cn } from "@/lib/utils";

import { useAuthStore } from "@/lib/store/authStore";
import { useOfflineStore } from "@/lib/store/offlineStore";
import {
enqueueSyncRequest,
watchSyncComplete,
SYNC_TAGS,
type SyncCompleteMessage,
} from "@/lib/offline/syncQueue";

const EVENT_TYPES = [
{ value: "payment.completed", label: "payment.completed" },
Expand Down Expand Up @@ -89,9 +96,11 @@ interface DeliveryLogEntry {
timestamp: Date;
eventType: string;
targetUrl: string;
status: "success" | "failed";
status: "success" | "failed" | "pending";
statusCode: number;
resultType?: string;
/** Queue id when the attempt was background-synced while offline. */
syncId?: string;
}

async function computeHmacSignature(secret: string, payloadStr: string): Promise<string> {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -490,12 +562,18 @@ export function WebhookTester({
</TableCell>
<TableCell className="font-medium">{entry.eventType}</TableCell>
<TableCell>
<Badge
variant={entry.status === "success" ? "outline" : "destructive"}
className={entry.status === "success" ? "text-success border-success/30" : undefined}
>
{entry.status === "success" ? "Delivered" : "Failed"}
</Badge>
{entry.status === "pending" ? (
<Badge variant="outline" className="text-warning border-warning/40">
Queued (offline)
</Badge>
) : (
<Badge
variant={entry.status === "success" ? "outline" : "destructive"}
className={entry.status === "success" ? "text-success border-success/30" : undefined}
>
{entry.status === "success" ? "Delivered" : "Failed"}
</Badge>
)}
</TableCell>
<TableCell className="font-mono text-xs">
{entry.statusCode} {entry.resultType && `(${entry.resultType})`}
Expand Down
Loading