From 7e29758f0e3ec5e9912df1c156bbdb0c7651f5e2 Mon Sep 17 00:00:00 2001 From: David Ejere Date: Fri, 28 Aug 2026 10:40:26 +0100 Subject: [PATCH 1/3] feat(landing): add step indicators to walkthrough, preview signup ask in CTA (#1075, #1074) Restyles the "how a tariff spike plays out" walkthrough with numbered badge step indicators and a connecting line instead of a plain ordered list, and adds brief copy near the "Try the demo" CTA describing what signup involves (role choice, email, password) so visitors know what they're getting into before clicking through. --- apps/web/app/page.tsx | 61 +++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 16 deletions(-) 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. +

@@ -61,38 +65,38 @@ export default function Home() {

How a tariff spike plays out

-
    -
  1. +
      + You sign up as an importer + register your CBP bond ID. Platform funds a Stellar account for you on testnet via friendbot. - -
    1. + + You upload your ACE Portal CSV (or synthetic data at MVP). The platform computes required collateral from annual duties × 10% × 50%. -
    2. -
    3. + + You deposit USDC into your collateral bucket + a margin into your{' '} reserve bucket. Both held by the Soroban contract. -
    4. -
    5. + + Tariff schedule changes (Section 301 hike, reciprocal regime, AD/CVD order). Your required collateral updates on-chain. -
    6. -
    7. + + One contract call (auto_top_up) moves the shortfall from reserve to collateral atomically. No paperwork. No re-underwriting. No port hold. -
    8. -
    9. + + BENJI yield accrues to your account every period. Withdrawals (above required) are one contract call. -
    10. -
    11. + + If you default, surety calls clawback — all funds move to surety wallet, account freezes. Bond stays good. -
    12. +
    -

    +

    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 ( +
  2. + {!last && ( +
  3. + ); +} From 66fa32b378bd0710fa8e34c946eb2ee9ce2bee6d Mon Sep 17 00:00:00 2001 From: David Ejere Date: Fri, 28 Aug 2026 10:40:46 +0100 Subject: [PATCH 2/3] fix(surety): show inline message when dashboard stats fail to load (#1070) api.getStats() failures previously only logged to console, leaving the metric tiles silently blank with no indication anything went wrong. Now shows a small warning-tier message in place of the tiles when the stats call fails, visually distinct from the danger-tier importer-list error banner below it. The importer list still renders independently either way since the two fetches are already isolated in separate try/catch blocks. Adds a --warning color token to globals.css alongside the existing success/danger tokens, used here and by the tiered ErrorBanner change. --- apps/web/app/globals.css | 2 ++ apps/web/app/surety/page.tsx | 8 ++++++++ 2 files changed, 10 insertions(+) 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/surety/page.tsx b/apps/web/app/surety/page.tsx index 96683e9..228cfc2 100644 --- a/apps/web/app/surety/page.tsx +++ b/apps/web/app/surety/page.tsx @@ -12,6 +12,7 @@ export default function SuretyDashboard() { const [importers, setImporters] = useState(null); const [metrics, setMetrics] = useState(null); const [error, setError] = useState(null); + const [metricsError, setMetricsError] = useState(false); const [signupUrl, setSignupUrl] = useState(''); const [copied, setCopied] = useState(false); @@ -56,8 +57,10 @@ export default function SuretyDashboard() { try { const s = await api.getStats(); setMetrics(s.metrics); + setMetricsError(false); } catch (e) { console.error('failed to load dashboard stats', e); + setMetricsError(true); } } @@ -89,6 +92,11 @@ export default function SuretyDashboard() { + ) : metricsError ? ( +

    + Metrics unavailable. Portfolio totals couldn't be loaded — the importer list below + is unaffected. +

    ) : null} {error ? ( From f1a466b809c84b3f134e9fb5e72e81582e2c3805 Mon Sep 17 00:00:00 2001 From: David Ejere Date: Fri, 28 Aug 2026 10:41:03 +0100 Subject: [PATCH 3/3] feat(errors): add severity tiers to shared ErrorBanner (#1068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All error banners rendered through the shared ErrorBanner component (deposit, withdraw, registration, and surety importer detail) used the same danger/red styling regardless of whether the error was a recoverable validation problem or a hard rejection/system failure. formatApiError() now classifies each known error into a 'warning' tier (the user can plausibly fix the input and retry — insufficient balance, validation failures, temporary locks, rate limiting) or a 'danger' tier (compliance/business-rule rejections, state conflicts, not-found, and technical/network failures). Unclassified errors default to 'danger' as the safer fallback, so existing behavior is preserved for anything not in the known-error table. ErrorBanner reads formatted.severity to pick its styling and also accepts an optional severity override prop for banners that aren't wrapping an API error (not needed by any current caller, but available for e.g. a hardcoded frozen-account notice). Error text content is unchanged — only the visual treatment adapts. The tiering convention is documented on the ErrorSeverity type and on ErrorBanner itself so future error banners follow the same pattern instead of duplicating danger-only styling. Extends error-formatter.test.ts with coverage for the new severity field across warning, danger, technical, and unclassified cases. --- apps/web/components/ErrorBanner.tsx | 47 +++++++++++++++--- apps/web/lib/error-formatter.test.ts | 53 +++++++++++++++++++++ apps/web/lib/error-formatter.ts | 71 ++++++++++++++++++++++++---- 3 files changed, 157 insertions(+), 14 deletions(-) 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 = { + warning: { + container: 'border-warning/30 bg-warning/10 text-warning', + detailsButton: 'text-warning/80 hover:text-warning', + }, + danger: { + container: 'border-danger/30 bg-danger/10 text-danger', + detailsButton: 'text-danger/80 hover:text-danger', + }, +}; + +export function ErrorBanner({ + error, + className = '', + severity, +}: { + error: unknown; + className?: string; + /** Override the severity tier instead of inferring it from `error`. */ + severity?: ErrorSeverity; +}) { const [showDetails, setShowDetails] = useState(false); if (!error) return null; @@ -13,16 +48,16 @@ export function ErrorBanner({ error, className = '' }: { error: unknown; classNa ? (error as FormattedError) : formatApiError(error); + const tier = TIER_STYLES[severity ?? formatted.severity]; + return ( -
    +

    {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', }; }