diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 32822bd..ea0875b 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -11,6 +11,7 @@ --accent-foreground: #082f49; --success: #10b981; --danger: #ef4444; + --warning: #f59e0b; --card: #111827; } @@ -25,6 +26,7 @@ --color-accent-foreground: var(--accent-foreground); --color-success: var(--success); --color-danger: var(--danger); + --color-warning: var(--warning); --color-card: var(--card); --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index b1e83ce..136defd 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -40,6 +40,10 @@ export default function Home() { Log in +
+ Takes under a minute — pick importer or surety admin, add an email + password. No CBP + credentials or real funds needed on testnet. +
auto_top_up) moves the
shortfall from reserve to collateral atomically. No paperwork. No re-underwriting. No
port hold.
- clawback — all funds
move to surety wallet, account freezes. Bond stays good.
- +
MVP runs on Stellar testnet with synthetic CBP data. Live ACE API + surety partner integration + real BENJI flow + mainnet config are scoped roadmap items.
@@ -114,3 +118,28 @@ function Card({ title, children }: { title: string; children: React.ReactNode }) ); } + +function Step({ + number, + last = false, + children, +}: { + number: number; + last?: boolean; + children: React.ReactNode; +}) { + return ( +{children}
++ Metrics unavailable. Portfolio totals couldn't be loaded — the importer list below + is unaffected. +
) : null} {error ? ( diff --git a/apps/web/components/ErrorBanner.tsx b/apps/web/components/ErrorBanner.tsx index f7fad49..00f8f38 100644 --- a/apps/web/components/ErrorBanner.tsx +++ b/apps/web/components/ErrorBanner.tsx @@ -1,9 +1,44 @@ 'use client'; import { useState } from 'react'; -import { formatApiError, type FormattedError } from '@/lib/error-formatter'; +import { formatApiError, type ErrorSeverity, type FormattedError } from '@/lib/error-formatter'; -export function ErrorBanner({ error, className = '' }: { error: unknown; className?: string }) { +/** + * Error banner severity tiers — see the `ErrorSeverity` doc comment in + * lib/error-formatter.ts for the full convention. In short: + * - 'warning': the user can plausibly fix this and retry (validation, + * insufficient balance, temporary locks, rate limiting). + * - 'danger': a hard rejection or failure (compliance/business-rule + * rejections, state conflicts, technical/network failures). + * + * `severity` is optional and only needed to override the tier that + * `formatApiError` already infers from the error content — most callers + * (deposit, withdraw, registration, tariff updates) don't need to pass it. + * Use the override for banners that aren't wrapping an API error at all, + * e.g. a hardcoded "account frozen by clawback" notice that should always + * render as 'danger' regardless of how it's triggered. + */ +const TIER_STYLES: Record{formatted.userMessage}
diff --git a/apps/web/lib/error-formatter.test.ts b/apps/web/lib/error-formatter.test.ts index 12a4913..d88bb37 100644 --- a/apps/web/lib/error-formatter.test.ts +++ b/apps/web/lib/error-formatter.test.ts @@ -67,3 +67,56 @@ describe('Issue #1067 — User-friendly error formatting and technical fallbacks assert.equal(isTechnicalErrorMessage('Please enter a valid amount'), false); }); }); + +describe('Issue #1068 — Tiered error banner severity', () => { + it('tags recoverable validation/input errors as warning', () => { + const insufficientFunds = new ApiError(400, 'insufficient collateral balance'); + assert.equal(formatApiError(insufficientFunds).severity, 'warning'); + + const exceedsExcess = new ApiError(400, 'withdraw amount exceeds available excess collateral'); + assert.equal(formatApiError(exceedsExcess).severity, 'warning'); + + const htsValidation = new ApiError( + 400, + 'HTS rate validation failed: one or more line items are underreported' + ); + assert.equal(formatApiError(htsValidation).severity, 'warning'); + + const invalidInput = new ApiError(400, 'invalid input: amount must be positive'); + assert.equal(formatApiError(invalidInput).severity, 'warning'); + }); + + it('tags business-rule rejections and compliance failures as danger', () => { + const sanctions = new ApiError(403, 'Importer failed OFAC sanctions screening'); + assert.equal(formatApiError(sanctions).severity, 'danger'); + + const alreadyRegistered = new ApiError(409, 'importer already registered'); + assert.equal(formatApiError(alreadyRegistered).severity, 'danger'); + + const kyc = new ApiError(403, 'KYC approval required'); + assert.equal(formatApiError(kyc).severity, 'danger'); + }); + + it('tags technical/system failures as danger', () => { + const rawSql = new Error('duplicate key value violates unique constraint "importers_ein_key"'); + assert.equal(formatApiError(rawSql).severity, 'danger'); + + const rawConn = new Error('connect ECONNREFUSED 127.0.0.1:5432'); + assert.equal(formatApiError(rawConn).severity, 'danger'); + }); + + it('defaults unclassified errors to danger as the safer fallback', () => { + const unknown = new Error('something unexpected happened'); + assert.equal(formatApiError(unknown).severity, 'danger'); + }); + + it('passes through an already-formatted error unchanged, including its severity', () => { + const already = { + userMessage: 'Custom message', + rawMessage: 'raw', + isTechnical: false, + severity: 'warning' as const, + }; + assert.deepEqual(formatApiError(already), already); + }); +}); diff --git a/apps/web/lib/error-formatter.ts b/apps/web/lib/error-formatter.ts index 0349c49..abded2c 100644 --- a/apps/web/lib/error-formatter.ts +++ b/apps/web/lib/error-formatter.ts @@ -1,102 +1,152 @@ import { ApiError } from './api'; +/** + * Error banner severity tiers (see ErrorBanner.tsx for the rendering + * convention this drives): + * + * - 'warning': the user can plausibly fix the input and retry right away — + * validation failures, insufficient balance, temporary locks, expired + * windows, rate limiting. Nothing is broken; they just need to adjust + * something. + * - 'danger': a hard rejection or failure the user can't resolve by + * retrying — compliance/business-rule rejections (sanctions, AML, KYC), + * state conflicts (already registered/signed), not-found, and any + * technical/network/system failure. This is also the default for any + * unmatched error, since assuming the more severe tier is the safer + * fallback. + */ +export type ErrorSeverity = 'warning' | 'danger'; + export interface FormattedError { userMessage: string; rawMessage: string; isTechnical: boolean; + severity: ErrorSeverity; } -const KNOWN_ERROR_MAPPINGS: Array<{ pattern: RegExp | string; friendly: string }> = [ - // Registration & Sanctions +const KNOWN_ERROR_MAPPINGS: Array<{ + pattern: RegExp | string; + friendly: string; + severity: ErrorSeverity; +}> = [ + // Registration & Sanctions — compliance/business-rule rejections, not + // fixable by re-entering data, so these stay 'danger'. { pattern: /only importer accounts can register/i, friendly: 'Only registered importer accounts can perform this action.', + severity: 'danger', }, { pattern: /importer already registered/i, friendly: 'An importer entity has already been registered for this user account.', + severity: 'danger', }, { pattern: /sanctions screening/i, friendly: 'Registration could not be completed because compliance screening requirements were not met.', + severity: 'danger', }, { pattern: /OFAC/i, friendly: 'Registration could not be completed because compliance screening requirements were not met.', + severity: 'danger', }, { pattern: /high risk by AML/i, friendly: 'Registration could not be completed due to account compliance policies.', + severity: 'danger', }, { pattern: /Bond validation failed/i, friendly: 'Your customs bond details could not be validated. Please check the bond number and try again.', + severity: 'warning', }, - // KYC & Compliance + // KYC & Compliance — also not user-fixable in the moment. { pattern: /KYC approval required/i, friendly: 'KYC verification must be completed before performing this action.', + severity: 'danger', }, { pattern: /pending AML review/i, friendly: 'This transaction is temporarily paused for standard compliance review. Please try again shortly.', + severity: 'danger', }, - // Deposits & Top-ups & Withdrawals + // Deposits & Top-ups & Withdrawals — the user can adjust the amount and + // retry, so these are 'warning'. { pattern: /insufficient (collateral|balance|funds)/i, friendly: 'Your account does not have sufficient balance for this transaction.', + severity: 'warning', }, { pattern: /exceeds available excess/i, friendly: 'The requested withdrawal amount exceeds your available excess collateral.', + severity: 'warning', }, { pattern: /cannot withdraw below required/i, friendly: 'Withdrawal cannot reduce your balance below the required collateral threshold.', + severity: 'warning', }, { pattern: /collateral is locked/i, friendly: 'Withdrawals are currently restricted while active customs claims are pending.', + severity: 'warning', }, - // Tariffs & HTS + // Tariffs & HTS — validation failures the user can correct and resubmit. { pattern: /HTS rate validation failed/i, friendly: 'Tariff data validation failed: one or more HTS rates appear to be underreported.', + severity: 'warning', }, { pattern: /underreported/i, friendly: 'Tariff entry failed: one or more duty rate items were flagged as underreported.', + severity: 'warning', }, { pattern: /CBP validation failed/i, friendly: 'Customs & Border Protection (CBP) rate validation failed. Please review your tariff entries.', + severity: 'warning', }, // Signatures & Deadlines { pattern: /72-hour signing deadline/i, friendly: 'The 72-hour signature window has expired. Please request a new document.', + severity: 'warning', }, { pattern: /already has a completed signature/i, friendly: 'This bond document has already been signed and completed.', + severity: 'danger', }, // Common inputs & limits - { pattern: /invalid input/i, friendly: 'Please check the entered information and try again.' }, + { + pattern: /invalid input/i, + friendly: 'Please check the entered information and try again.', + severity: 'warning', + }, { pattern: /rate limit exceeded|too many auth attempts/i, friendly: 'Too many requests. Please wait a few minutes before trying again.', + severity: 'warning', + }, + { + pattern: /not found/i, + friendly: 'The requested entity or record could not be found.', + severity: 'danger', }, - { pattern: /not found/i, friendly: 'The requested entity or record could not be found.' }, ]; const TECHNICAL_PATTERNS = [ @@ -149,6 +199,7 @@ export function formatApiError(error: unknown): FormattedError { userMessage: mapping.friendly, rawMessage, isTechnical: false, + severity: mapping.severity, }; } } @@ -160,13 +211,17 @@ export function formatApiError(error: unknown): FormattedError { 'An unexpected system error occurred. Please try again or contact support if the issue persists.', rawMessage, isTechnical: true, + severity: 'danger', }; } - // 3. Fallback: Return raw message if it's already user-understandable + // 3. Fallback: Return raw message if it's already user-understandable. + // Unclassified errors default to 'danger' — the more visually severe + // tier is the safer assumption when we don't know what actually failed. return { userMessage: rawMessage, rawMessage, isTechnical: false, + severity: 'danger', }; }