diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx
new file mode 100644
index 00000000..61b463e5
--- /dev/null
+++ b/src/components/Skeleton.tsx
@@ -0,0 +1,275 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+interface SkeletonProps {
+ className?: string;
+ width?: string | number;
+ height?: string | number;
+ rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
+ shimmer?: boolean;
+}
+
+/**
+ * Base skeleton component with reduced motion support
+ *
+ * Accessibility considerations:
+ * - Uses `prefers-reduced-motion` media query to disable animations
+ * - Provides static loading state for users with motion sensitivity
+ * - Includes aria-label for screen readers
+ */
+export function Skeleton({
+ className,
+ width,
+ height,
+ rounded = 'md',
+ shimmer = true,
+}: SkeletonProps) {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+
+ useEffect(() => {
+ // Check for reduced motion preference
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
+ setPrefersReducedMotion(mediaQuery.matches);
+
+ const handleChange = (e: MediaQueryListEvent) => {
+ setPrefersReducedMotion(e.matches);
+ };
+
+ mediaQuery.addEventListener('change', handleChange);
+ return () => mediaQuery.removeEventListener('change', handleChange);
+ }, []);
+
+ const borderRadius = {
+ none: 'rounded-none',
+ sm: 'rounded-sm',
+ md: 'rounded-md',
+ lg: 'rounded-lg',
+ xl: 'rounded-xl',
+ full: 'rounded-full',
+ }[rounded];
+
+ const style: React.CSSProperties = {};
+ if (width) style.width = typeof width === 'number' ? `${width}px` : width;
+ if (height) style.height = typeof height === 'number' ? `${height}px` : height;
+
+ return (
+
+ {/* Base background */}
+
+
+ {/* Shimmer effect with reduced motion support */}
+ {shimmer && !prefersReducedMotion && (
+
+ )}
+
+ {/* Static loading indicator for reduced motion */}
+ {shimmer && prefersReducedMotion && (
+
+ )}
+
+ );
+}
+
+/**
+ * Skeleton for commitment cards in the commitments list
+ */
+export function CommitmentCardSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Skeleton for marketplace cards
+ */
+export function MarketplaceCardSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Skeleton for health metrics charts
+ */
+export function HealthChartSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+ {/* Chart area */}
+
+ {/* Y-axis */}
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+
+ {/* X-axis */}
+
+ {[...Array(6)].map((_, i) => (
+
+ ))}
+
+
+ {/* Chart lines */}
+
+
+ {/* Grid lines */}
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+ {/* Simulated chart line */}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Skeleton for commitment stats
+ */
+export function CommitmentStatsSkeleton() {
+ return (
+
+ {[...Array(4)].map((_, i) => (
+
+
+
+
+
+ ))}
+
+ );
+}
+
+/**
+ * Skeleton for filters section
+ */
+export function FiltersSkeleton() {
+ return (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/dashboard/RecentActivityFeed.test.tsx b/src/components/dashboard/RecentActivityFeed.test.tsx
new file mode 100644
index 00000000..9f613a0f
--- /dev/null
+++ b/src/components/dashboard/RecentActivityFeed.test.tsx
@@ -0,0 +1,218 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { RecentActivityFeed } from './RecentActivityFeed';
+import { apiGet } from '@/lib/apiClient';
+import { Commitment } from '@/lib/types/domain';
+
+vi.mock('@/lib/apiClient', () => ({
+ apiGet: vi.fn(),
+}));
+
+const mockCommitments: Commitment[] = [
+ {
+ id: 'CMT-ABC123',
+ type: 'Safe',
+ status: 'Active',
+ asset: 'XLM',
+ amount: '50,000',
+ createdDate: 'Jan 10, 2026',
+ expiryDate: 'Feb 9, 2026',
+ },
+ {
+ id: 'CMT-XYZ789',
+ type: 'Balanced',
+ status: 'Active',
+ asset: 'USDC',
+ amount: '100,000',
+ createdDate: 'Dec 15, 2025',
+ expiryDate: 'Feb 13, 2026',
+ },
+];
+
+const mockedApiGet = vi.mocked(apiGet);
+
+describe('RecentActivityFeed', () => {
+ beforeEach(() => {
+ mockedApiGet.mockReset();
+ });
+
+ it('renders loading state initially', () => {
+ mockedApiGet.mockImplementation(() => new Promise(() => {}));
+
+ render();
+
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
+ });
+
+ it('renders empty state when no commitments', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('No Recent Activity')).toBeInTheDocument();
+ });
+ });
+
+ it('renders mixed event types correctly', async () => {
+ mockedApiGet.mockImplementation((url: string) => {
+ if (url.includes('CMT-ABC123')) {
+ return Promise.resolve({
+ success: true,
+ data: {
+ events: [
+ {
+ eventId: 'created:CMT-ABC123',
+ kind: 'created',
+ occurredAt: new Date(Date.now() - 86400000).toISOString(),
+ payload: { asset: 'XLM', amount: '50,000' },
+ },
+ {
+ eventId: 'attestation:ATTR-001',
+ kind: 'attestation',
+ occurredAt: new Date(Date.now() - 3600000).toISOString(),
+ payload: { attestationId: 'ATTR-001', attestationType: 'health_check' },
+ },
+ ],
+ },
+ });
+ }
+ if (url.includes('CMT-XYZ789')) {
+ return Promise.resolve({
+ success: true,
+ data: {
+ events: [
+ {
+ eventId: 'settlement:CMT-XYZ789',
+ kind: 'settlement',
+ occurredAt: new Date(Date.now() - 7200000).toISOString(),
+ payload: { settlementAmount: '105,000' },
+ },
+ ],
+ },
+ });
+ }
+ return Promise.resolve({ success: true, data: { events: [] } });
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Recent Activity')).toBeInTheDocument();
+ expect(screen.getByText('Commitment Created')).toBeInTheDocument();
+ expect(screen.getByText('Attestation Recorded')).toBeInTheDocument();
+ expect(screen.getByText('Settlement Complete')).toBeInTheDocument();
+ });
+ });
+
+ it('caps feed length and shows view all', async () => {
+ const manyEvents = Array.from({ length: 10 }, (_, i) => ({
+ eventId: `event-${i}`,
+ kind: 'attestation' as const,
+ occurredAt: new Date(Date.now() - i * 3600000).toISOString(),
+ payload: { attestationId: `ATTR-${i}`, attestationType: 'health_check' },
+ }));
+
+ mockedApiGet.mockResolvedValue({
+ success: true,
+ data: { events: manyEvents },
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('View All Activity')).toBeInTheDocument();
+ });
+ });
+
+ it('does not show view all when events <= maxItems', async () => {
+ const fewEvents = [
+ {
+ eventId: 'event-1',
+ kind: 'created' as const,
+ occurredAt: new Date().toISOString(),
+ payload: { asset: 'XLM', amount: '50,000' },
+ },
+ ];
+
+ mockedApiGet.mockResolvedValue({
+ success: true,
+ data: { events: fewEvents },
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByText('View All Activity')).not.toBeInTheDocument();
+ });
+ });
+
+ it('issues all per-commitment history requests concurrently', async () => {
+ const pendingResolvers: Array<(value: unknown) => void> = [];
+ mockedApiGet.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ pendingResolvers.push(resolve);
+ }),
+ );
+
+ render();
+
+ // Every request must already be in flight before any of them resolves.
+ // A sequential loop would only issue the next request after the previous
+ // one settled, so both calls being issued here proves parallel issuance.
+ await waitFor(() => {
+ expect(mockedApiGet).toHaveBeenCalledTimes(mockCommitments.length);
+ });
+
+ expect(mockedApiGet).toHaveBeenCalledWith('/api/commitments/CMT-ABC123/history');
+ expect(mockedApiGet).toHaveBeenCalledWith('/api/commitments/CMT-XYZ789/history');
+
+ // Resolve all pending requests so loading finishes and the feed renders.
+ pendingResolvers.forEach((resolve) => resolve({ success: true, data: { events: [] } }));
+
+ await waitFor(() => {
+ expect(screen.getByText('No Recent Activity')).toBeInTheDocument();
+ });
+ });
+
+ it('still renders events from other commitments when one request fails', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ mockedApiGet.mockImplementation((url: string) => {
+ if (url.includes('CMT-ABC123')) {
+ return Promise.reject(new Error('network down'));
+ }
+ return Promise.resolve({
+ success: true,
+ data: {
+ events: [
+ {
+ eventId: 'settlement:CMT-XYZ789',
+ kind: 'settlement',
+ occurredAt: new Date().toISOString(),
+ payload: { settlementAmount: '105,000' },
+ },
+ ],
+ },
+ });
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Settlement Complete')).toBeInTheDocument();
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to load history for CMT-ABC123',
+ expect.any(Error),
+ );
+ } finally {
+ consoleErrorSpy.mockRestore();
+ }
+ });
+});
diff --git a/src/components/dashboard/RecentActivityFeed.tsx b/src/components/dashboard/RecentActivityFeed.tsx
new file mode 100644
index 00000000..c9d133a3
--- /dev/null
+++ b/src/components/dashboard/RecentActivityFeed.tsx
@@ -0,0 +1,259 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import Link from 'next/link';
+import { apiGet } from '@/lib/apiClient';
+import { Commitment, HistoryEvent, HistoryEventKind } from '@/lib/types/domain';
+import { Skeleton } from '@/components/Skeleton';
+
+type FeedEvent = HistoryEvent & {
+ commitmentId: string;
+};
+
+interface RecentActivityFeedProps {
+ commitments: Commitment[];
+ maxItems?: number;
+}
+
+function formatRelativeTime(timestamp: string): string {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSeconds = Math.floor(diffMs / 1000);
+ const diffMinutes = Math.floor(diffSeconds / 60);
+ const diffHours = Math.floor(diffMinutes / 60);
+ const diffDays = Math.floor(diffHours / 24);
+ const diffWeeks = Math.floor(diffDays / 7);
+ const diffMonths = Math.floor(diffDays / 30);
+
+ if (diffSeconds < 60) return 'just now';
+ if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
+ if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
+ if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
+ if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`;
+ if (diffMonths < 12) return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`;
+ const diffYears = Math.floor(diffMonths / 12);
+ return `${diffYears} year${diffYears === 1 ? '' : 's'} ago`;
+}
+
+function getEventIcon(kind: HistoryEventKind) {
+ switch (kind) {
+ case 'created':
+ return (
+
+ );
+ case 'attestation':
+ return (
+
+ );
+ case 'early_exit':
+ return (
+
+ );
+ case 'settlement':
+ return (
+
+ );
+ default:
+ return null;
+ }
+}
+
+function getEventTitle(event: FeedEvent): string {
+ switch (event.kind) {
+ case 'created':
+ return `Commitment Created`;
+ case 'attestation':
+ return `Attestation Recorded`;
+ case 'early_exit':
+ return `Early Exit`;
+ case 'settlement':
+ return `Settlement Complete`;
+ default:
+ return 'Event';
+ }
+}
+
+function getEventDescription(event: FeedEvent): string {
+ switch (event.kind) {
+ case 'created':
+ return `${event.payload.amount} ${event.payload.asset}`;
+ case 'attestation':
+ return event.payload.attestationType || 'Health check';
+ case 'early_exit':
+ return event.payload.exitedBy
+ ? `Exited by ${event.payload.exitedBy.slice(0, 8)}...`
+ : 'Early exit executed';
+ case 'settlement':
+ return event.payload.settlementAmount
+ ? `Settled: ${event.payload.settlementAmount}`
+ : 'Commitment settled';
+ default:
+ return '';
+ }
+}
+
+export function RecentActivityFeed({ commitments, maxItems = 5 }: RecentActivityFeedProps) {
+ const [events, setEvents] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ async function loadEvents() {
+ if (commitments.length === 0) {
+ setLoading(false);
+ return;
+ }
+
+ // Fetch every commitment's history in parallel. `Promise.allSettled`
+ // preserves the previous per-commitment try/catch-and-log behavior: a
+ // failed request for one commitment never blocks the others from
+ // rendering, so total time is ~max(latency) instead of N × latency.
+ const results = await Promise.allSettled(
+ commitments.map(async (commitment) => {
+ try {
+ const response = await apiGet<{ success: boolean; data: { events: HistoryEvent[] } }>(
+ `/api/commitments/${commitment.id}/history`,
+ );
+
+ if (response.success && response.data?.events) {
+ return response.data.events.map((event) => ({
+ ...event,
+ commitmentId: commitment.id,
+ }));
+ }
+ return [];
+ } catch (err) {
+ console.error(`Failed to load history for ${commitment.id}`, err);
+ return [];
+ }
+ }),
+ );
+
+ const allEvents: FeedEvent[] = results.flatMap((result) =>
+ result.status === 'fulfilled' ? result.value : [],
+ );
+
+ allEvents.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
+
+ setEvents(allEvents);
+ setLoading(false);
+ }
+
+ loadEvents();
+ }, [commitments]);
+
+ if (loading) {
+ return (
+
+
+
+
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+
+ );
+ }
+
+ const displayEvents = events.slice(0, maxItems);
+ const hasMore = events.length > maxItems;
+
+ if (displayEvents.length === 0) {
+ return (
+
+
No Recent Activity
+
Activity from your commitments will appear here.
+
+ );
+ }
+
+ return (
+
+
+
Recent Activity
+
+ {events.length} events
+
+
+
+
+ {displayEvents.map((event) => (
+ -
+
+
+ {getEventIcon(event.kind)}
+
+
+
+ {getEventTitle(event)}
+
+
+ {getEventDescription(event)} · Commitment {event.commitmentId.substring(0, 8)}
+
+
+
+
+
+ ))}
+
+
+ {hasMore && (
+
+
+ View All Activity
+
+
+
+ )}
+
+ );
+}
diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts
new file mode 100644
index 00000000..89ee269a
--- /dev/null
+++ b/src/lib/apiClient.ts
@@ -0,0 +1,10 @@
+export {
+ ApiClientError,
+ ApiError,
+ apiRequest,
+ apiGet,
+ apiPost,
+ apiPut,
+ apiDelete,
+} from './client/apiClient';
+export type { UiApiError } from '@/utils/errorHelpers';
diff --git a/src/lib/client/apiClient.ts b/src/lib/client/apiClient.ts
new file mode 100644
index 00000000..e9f66222
--- /dev/null
+++ b/src/lib/client/apiClient.ts
@@ -0,0 +1,231 @@
+import { z } from 'zod';
+import { ErrorBodySchema, OkBodySchema } from '@/lib/schemas/apiContracts';
+import { normalizeApiError, type UiApiError } from '@/utils/errorHelpers';
+import type { FailResponse, OkResponse } from '@/lib/backend/apiResponse';
+
+export class ApiClientError extends Error {
+ public readonly code: string;
+ public readonly status?: number;
+ public readonly details?: unknown;
+ public readonly retryAfterSeconds?: number;
+ public readonly correlationId?: string;
+ public readonly friendlyMessage: string;
+
+ constructor(error: UiApiError) {
+ super(error.message);
+ this.name = 'ApiClientError';
+ this.code = error.code;
+ if (error.status !== undefined) {
+ this.status = error.status;
+ }
+ if (error.details !== undefined) {
+ this.details = error.details;
+ }
+ if (error.retryAfterSeconds !== undefined) {
+ this.retryAfterSeconds = error.retryAfterSeconds;
+ }
+ if (error.correlationId !== undefined) {
+ this.correlationId = error.correlationId;
+ }
+ this.friendlyMessage = error.message;
+
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, ApiClientError);
+ }
+ }
+}
+
+export class ApiError extends ApiClientError {}
+
+function isErrorEnvelope(value: unknown): value is FailResponse {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ const candidate = value as Partial & Record;
+ const hasFailure = candidate.success === false || candidate.ok === false;
+ const hasErrorShape =
+ typeof candidate.error?.code === 'string' && typeof candidate.error?.message === 'string';
+ return hasFailure && hasErrorShape;
+}
+
+function isSuccessEnvelope(value: unknown): value is OkResponse {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ const candidate = value as Partial> & Record;
+ const hasSuccess = candidate.success === true || candidate.ok === true;
+ return hasSuccess && ('data' in candidate || 'result' in candidate);
+}
+
+export function parseApiResponse(payload: unknown): T {
+ if (isErrorEnvelope(payload)) {
+ const parsed = ErrorBodySchema.safeParse(payload);
+ if (parsed.success) {
+ throw new ApiError(
+ normalizeApiError(
+ {
+ code: parsed.data.error.code,
+ message: parsed.data.error.message,
+ details: parsed.data.error.details,
+ },
+ 500,
+ ),
+ );
+ }
+ }
+
+ if (isSuccessEnvelope(payload)) {
+ const record = payload as unknown as Record;
+ if ('data' in record) {
+ return record.data as T;
+ }
+
+ const parsed = OkBodySchema(z.any()).safeParse(payload);
+ if (parsed.success) {
+ return parsed.data.data as T;
+ }
+ }
+
+ return payload as T;
+}
+
+export async function apiRequest(
+ input: string | URL | Request,
+ init?: RequestInit,
+ options?: { timeoutMs?: number },
+): Promise {
+ const timeoutMs = options?.timeoutMs ?? 5000;
+ const controller = new AbortController();
+ const externalSignal = init?.signal;
+
+ const timeoutId = globalThis.setTimeout(() => controller.abort(), timeoutMs);
+
+ const abortPromise = new Promise((_, reject) => {
+ const onAbort = () => {
+ controller.abort();
+ reject(new DOMException('Aborted', 'AbortError'));
+ };
+
+ if (externalSignal) {
+ if (externalSignal.aborted) {
+ onAbort();
+ return;
+ }
+ externalSignal.addEventListener('abort', onAbort, { once: true });
+ }
+
+ controller.signal.addEventListener('abort', onAbort, { once: true });
+ });
+
+ try {
+ const response = await Promise.race([
+ fetch(input, { ...init, signal: controller.signal }),
+ abortPromise,
+ ]);
+ clearTimeout(timeoutId);
+
+ let payload: unknown;
+ let isJson = true;
+ try {
+ payload = await response.json();
+ } catch {
+ isJson = false;
+ payload = null;
+ }
+
+ if (!response.ok) {
+ if (isJson && isErrorEnvelope(payload)) {
+ throw new ApiError(
+ normalizeApiError(
+ {
+ code: payload.error.code,
+ message: payload.error.message,
+ details: payload.error.details,
+ retryAfterSeconds: payload.error.retryAfterSeconds,
+ correlationId: payload.error.correlationId,
+ },
+ response.status,
+ ),
+ );
+ }
+
+ const contentType = response.headers.get('content-type') || '';
+ const statusText = `${response.status} ${response.statusText || 'Error'}`;
+
+ if (!isJson) {
+ throw new ApiError(
+ normalizeApiError(
+ new Error(
+ `Request failed with status ${statusText} (non-JSON response, content-type: ${contentType})`,
+ ),
+ response.status,
+ ),
+ );
+ }
+
+ throw new ApiError(
+ normalizeApiError(new Error(`Request failed with status ${statusText}`), response.status),
+ );
+ }
+
+ return parseApiResponse(payload);
+ } catch (error) {
+ clearTimeout(timeoutId);
+
+ if (error instanceof ApiError) {
+ throw error;
+ }
+
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ throw new ApiError({
+ code: 'TIMEOUT',
+ message: 'The request was cancelled.',
+ status: 0,
+ });
+ }
+
+ throw new ApiError(normalizeApiError(error));
+ }
+}
+
+export function apiGet(
+ url: string,
+ initOrTimeout?: RequestInit | number,
+ timeoutMs = 5000,
+): Promise {
+ if (typeof initOrTimeout === 'number') {
+ return apiRequest(url, { method: 'GET' }, { timeoutMs: initOrTimeout });
+ }
+
+ return apiRequest(url, { method: 'GET', ...(initOrTimeout ?? {}) }, { timeoutMs });
+}
+
+export function apiPost(url: string, body: unknown, timeoutMs = 5000): Promise {
+ return apiRequest(
+ url,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ },
+ { timeoutMs },
+ );
+}
+
+export function apiPut(url: string, body: unknown, timeoutMs = 5000): Promise {
+ return apiRequest(
+ url,
+ {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ },
+ { timeoutMs },
+ );
+}
+
+export function apiDelete(url: string, timeoutMs = 5000): Promise {
+ return apiRequest(url, { method: 'DELETE' }, { timeoutMs });
+}
diff --git a/src/utils/errorHelpers.ts b/src/utils/errorHelpers.ts
new file mode 100644
index 00000000..eed971a9
--- /dev/null
+++ b/src/utils/errorHelpers.ts
@@ -0,0 +1,195 @@
+import { ERROR_CODE_REGISTRY } from '../lib/backend/errorCodes';
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+export interface ApiErrorResponse {
+ success: false;
+ error: {
+ code: number;
+ type: string;
+ message: string;
+ retryAfter?: number;
+ details?: string;
+ };
+}
+
+export interface UiApiError {
+ code: string;
+ message: string;
+ status?: number;
+ details?: unknown;
+ retryAfterSeconds?: number;
+ correlationId?: string;
+}
+
+// ─── Error Factories ──────────────────────────────────────────────────────────
+
+/**
+ * Creates an ApiErrorResponse for 429 Rate Limit Exceeded.
+ * Default message and status code are sourced from ERROR_CODE_REGISTRY.TOO_MANY_REQUESTS.
+ */
+export function rateLimitError(retryAfter = 60, details?: string): ApiErrorResponse {
+ const registryEntry = ERROR_CODE_REGISTRY.TOO_MANY_REQUESTS!;
+ return {
+ success: false,
+ error: {
+ code: registryEntry.statusCode,
+ type: 'RATE_LIMIT_EXCEEDED',
+ message: registryEntry.meaning,
+ retryAfter,
+ ...(details && process.env.NODE_ENV === 'development' ? { details } : {}),
+ },
+ };
+}
+
+/**
+ * Creates an ApiErrorResponse for 500 Internal Server Error.
+ * Default message and status code are sourced from ERROR_CODE_REGISTRY.INTERNAL_ERROR.
+ */
+export function internalServerError(details?: string): ApiErrorResponse {
+ const registryEntry = ERROR_CODE_REGISTRY.INTERNAL_ERROR!;
+ return {
+ success: false,
+ error: {
+ code: registryEntry.statusCode,
+ type: 'INTERNAL_SERVER_ERROR',
+ message: registryEntry.meaning,
+ ...(details && process.env.NODE_ENV === 'development' ? { details } : {}),
+ },
+ };
+}
+
+/**
+ * Creates an ApiErrorResponse for 502 Bad Gateway.
+ * Default message and status code are sourced from ERROR_CODE_REGISTRY.BAD_GATEWAY.
+ */
+export function badGatewayError(details?: string): ApiErrorResponse {
+ const registryEntry = ERROR_CODE_REGISTRY.BAD_GATEWAY!;
+ return {
+ success: false,
+ error: {
+ code: registryEntry.statusCode,
+ type: 'BAD_GATEWAY',
+ message: registryEntry.meaning,
+ ...(details && process.env.NODE_ENV === 'development' ? { details } : {}),
+ },
+ };
+}
+
+/**
+ * Creates an ApiErrorResponse for 503 Service Unavailable.
+ * Default message and status code are sourced from ERROR_CODE_REGISTRY.SERVICE_UNAVAILABLE.
+ */
+export function serviceUnavailableError(retryAfter = 30, details?: string): ApiErrorResponse {
+ const registryEntry = ERROR_CODE_REGISTRY.SERVICE_UNAVAILABLE!;
+ return {
+ success: false,
+ error: {
+ code: registryEntry.statusCode,
+ type: 'SERVICE_UNAVAILABLE',
+ message: registryEntry.meaning,
+ retryAfter,
+ ...(details && process.env.NODE_ENV === 'development' ? { details } : {}),
+ },
+ };
+}
+
+/**
+ * Creates an ApiErrorResponse for 504 Gateway Timeout.
+ * Default message and status code are sourced from ERROR_CODE_REGISTRY.GATEWAY_TIMEOUT.
+ */
+export function gatewayTimeoutError(details?: string): ApiErrorResponse {
+ const registryEntry = ERROR_CODE_REGISTRY.GATEWAY_TIMEOUT!;
+ return {
+ success: false,
+ error: {
+ code: registryEntry.statusCode,
+ type: 'GATEWAY_TIMEOUT',
+ message: registryEntry.meaning,
+ ...(details && process.env.NODE_ENV === 'development' ? { details } : {}),
+ },
+ };
+}
+
+// ─── Generic 5xx Resolver ─────────────────────────────────────────────────────
+
+export function resolveServerError(statusCode: number, details?: string): ApiErrorResponse {
+ switch (statusCode) {
+ case 502:
+ return badGatewayError(details);
+ case 503:
+ return serviceUnavailableError(30, details);
+ case 504:
+ return gatewayTimeoutError(details);
+ default:
+ return internalServerError(details);
+ }
+}
+
+// ─── HTTP Headers Helper ──────────────────────────────────────────────────────
+
+export function getErrorHeaders(error: ApiErrorResponse): Record {
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ };
+
+ if (error.error.retryAfter !== undefined) {
+ headers['Retry-After'] = String(error.error.retryAfter);
+ }
+
+ return headers;
+}
+
+export function normalizeApiError(error: unknown, status?: number): UiApiError {
+ const maybeErrorLike = error as Partial & { code?: string; message?: string };
+ const codeFromError = typeof maybeErrorLike?.code === 'string' ? maybeErrorLike.code : undefined;
+ const messageFromError =
+ typeof maybeErrorLike?.message === 'string' ? maybeErrorLike.message : undefined;
+ const inboundMessage =
+ messageFromError ||
+ (error instanceof Error ? error.message : undefined) ||
+ 'Something went wrong.';
+ const lower = inboundMessage.toLowerCase();
+
+ const code =
+ codeFromError?.toUpperCase() ||
+ (lower.includes('fetch') || lower.includes('network') ? 'NETWORK_ERROR' : undefined) ||
+ (lower.includes('timeout') || lower.includes('aborted') ? 'TIMEOUT' : undefined) ||
+ (lower.includes('not found') ? 'NOT_FOUND' : undefined) ||
+ (lower.includes('unauthorized') ? 'UNAUTHORIZED' : undefined) ||
+ (lower.includes('forbidden') ? 'FORBIDDEN' : undefined) ||
+ (lower.includes('rate limit') ? 'RATE_LIMIT_EXCEEDED' : undefined) ||
+ 'REQUEST_FAILED';
+
+ const message =
+ code === 'NETWORK_ERROR'
+ ? 'We could not reach the server. Please try again.'
+ : code === 'TIMEOUT'
+ ? 'The request took too long. Please try again.'
+ : code === 'NOT_FOUND'
+ ? 'The requested resource was not found.'
+ : code === 'UNAUTHORIZED'
+ ? 'You are not authorized to perform that action.'
+ : code === 'FORBIDDEN'
+ ? 'You do not have permission to do that.'
+ : code === 'RATE_LIMIT_EXCEEDED'
+ ? 'Too many requests. Please wait before trying again.'
+ : inboundMessage;
+
+ // Only attach optional fields when they are actually defined, to satisfy
+ // `exactOptionalPropertyTypes` (no explicit `undefined` on optional props).
+ const result: UiApiError = { code, message };
+ if (status !== undefined) {
+ result.status = status;
+ }
+ if (maybeErrorLike?.details !== undefined) {
+ result.details = maybeErrorLike.details;
+ }
+ if (maybeErrorLike?.retryAfterSeconds !== undefined) {
+ result.retryAfterSeconds = maybeErrorLike.retryAfterSeconds;
+ }
+ if (maybeErrorLike?.correlationId !== undefined) {
+ result.correlationId = maybeErrorLike.correlationId;
+ }
+ return result;
+}