diff --git a/apps/web/src/app/meters/page.tsx b/apps/web/src/app/meters/page.tsx index fd59d4a..cf7725e 100644 --- a/apps/web/src/app/meters/page.tsx +++ b/apps/web/src/app/meters/page.tsx @@ -5,6 +5,8 @@ import { WalletGate } from '@/components/wallet-gate' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { PlusCircle, ShieldOff } from 'lucide-react' import { CopyableText } from '@/components/copy-button' +import { useToast } from '@/components/ToastProvider' +import { apiFetch } from '@/lib/api-error' interface Meter { id: string @@ -19,8 +21,7 @@ interface Meter { } async function fetchMeters(): Promise { - const res = await fetch('/api/meters') - if (!res.ok) throw new Error('Failed to load meters') + const res = await apiFetch('/api/meters') return res.json() } @@ -31,21 +32,16 @@ async function registerMeter(body: { meter_group?: string tags?: string[] }): Promise { - const res = await fetch('/api/meters', { + const res = await apiFetch('/api/meters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.error ?? 'Registration failed') - } return res.json() } async function revokeMeter(id: string): Promise { - const res = await fetch(`/api/meters/${id}/revoke`, { method: 'PATCH' }) - if (!res.ok) throw new Error('Revoke failed') + await apiFetch(`/api/meters/${id}/revoke`, { method: 'PATCH' }) } // --------------------------------------------------------------------------- @@ -441,6 +437,7 @@ function RevokeDialog({ // --------------------------------------------------------------------------- export default function MetersPage() { const qc = useQueryClient() + const { pushToast } = useToast() const [revokeTarget, setRevokeTarget] = useState(null) const { data: meters, isLoading, error } = useQuery({ @@ -453,7 +450,9 @@ export default function MetersPage() { onSuccess: () => { qc.invalidateQueries({ queryKey: ['meters'] }) setRevokeTarget(null) + pushToast({ variant: 'success', title: 'Meter revoked', description: 'The meter has been deactivated.' }) }, + onError: (err: Error) => pushToast({ variant: 'error', title: 'Revoke failed', description: err.message }), }) return ( diff --git a/apps/web/src/lib/api-error.ts b/apps/web/src/lib/api-error.ts new file mode 100644 index 0000000..20742fb --- /dev/null +++ b/apps/web/src/lib/api-error.ts @@ -0,0 +1,40 @@ +'use client' + +/** + * Utility for consistent API error handling across pages. + * + * Parses the API error response body (supports both `{ error }` and + * `{ error, code, retriable }` shapes from AppError) and returns a + * normalised message plus optional retry guidance. + */ +export function parseApiError(errBody: unknown): string { + if (!errBody || typeof errBody !== 'object') return 'An unexpected error occurred.' + const body = errBody as Record + const base = typeof body.error === 'string' ? body.error : 'An unexpected error occurred.' + if (body.retriable === true) { + return `${base} Please try again.` + } + if (body.code === 'STELLAR_CIRCUIT' || body.code === 'STELLAR_TIMEOUT') { + return `${base} The Stellar network is temporarily unavailable — please try again in a few moments.` + } + return base +} + +/** + * Fetch wrapper that throws a normalised error string on non-ok responses. + * Use in combination with `useToast` to surface errors consistently. + * + * @example + * const data = await apiFetch('/api/certificates').catch(msg => { + * pushToast({ variant: 'error', title: 'Load failed', description: msg }) + * throw msg + * }) + */ +export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise { + const res = await fetch(input, init) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(parseApiError(body)) + } + return res +}