Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
--accent-foreground: #082f49;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--card: #111827;
}

Expand All @@ -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,
Expand Down
61 changes: 45 additions & 16 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export default function Home() {
Log in
</Link>
</div>
<p className="mt-3 text-xs text-muted">
Takes under a minute — pick importer or surety admin, add an email + password. No CBP
credentials or real funds needed on testnet.
</p>
</section>

<section className="mt-16 grid gap-6 sm:grid-cols-3">
Expand All @@ -61,38 +65,38 @@ export default function Home() {

<section className="mt-16 rounded-lg border border-border bg-card p-6">
<h2 className="text-lg font-semibold">How a tariff spike plays out</h2>
<ol className="mt-3 space-y-2 text-sm list-decimal pl-5">
<li>
<ol className="mt-6 space-y-6">
<Step number={1}>
You sign up as an importer + register your CBP bond ID. Platform funds a Stellar
account for you on testnet via friendbot.
</li>
<li>
</Step>
<Step number={2}>
You upload your ACE Portal CSV (or synthetic data at MVP). The platform computes
required collateral from annual duties × 10% × 50%.
</li>
<li>
</Step>
<Step number={3}>
You deposit USDC into your <em>collateral</em> bucket + a margin into your{' '}
<em>reserve</em> bucket. Both held by the Soroban contract.
</li>
<li>
</Step>
<Step number={4}>
Tariff schedule changes (Section 301 hike, reciprocal regime, AD/CVD order). Your
required collateral updates on-chain.
</li>
<li>
</Step>
<Step number={5}>
One contract call (<code className="text-accent">auto_top_up</code>) moves the
shortfall from reserve to collateral atomically. No paperwork. No re-underwriting. No
port hold.
</li>
<li>
</Step>
<Step number={6}>
BENJI yield accrues to your account every period. Withdrawals (above required) are one
contract call.
</li>
<li>
</Step>
<Step number={7} last>
If you default, surety calls <code className="text-accent">clawback</code> — all funds
move to surety wallet, account freezes. Bond stays good.
</li>
</Step>
</ol>
<p className="mt-3 text-xs text-muted">
<p className="mt-6 text-xs text-muted">
MVP runs on Stellar testnet with synthetic CBP data. Live ACE API + surety partner
integration + real BENJI flow + mainnet config are scoped roadmap items.
</p>
Expand All @@ -114,3 +118,28 @@ function Card({ title, children }: { title: string; children: React.ReactNode })
</div>
);
}

function Step({
number,
last = false,
children,
}: {
number: number;
last?: boolean;
children: React.ReactNode;
}) {
return (
<li className="relative flex gap-4 pl-1">
{!last && (
<span
aria-hidden="true"
className="absolute left-[19px] top-9 bottom-[-24px] w-px bg-border"
/>
)}
<span className="relative z-10 flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-accent/40 bg-accent/10 text-sm font-semibold text-accent">
{number}
</span>
<p className="mt-1.5 text-sm text-muted leading-relaxed">{children}</p>
</li>
);
}
8 changes: 8 additions & 0 deletions apps/web/app/surety/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default function SuretyDashboard() {
const [importers, setImporters] = useState<Importer[] | null>(null);
const [metrics, setMetrics] = useState<ImporterMetrics | null>(null);
const [error, setError] = useState<string | null>(null);
const [metricsError, setMetricsError] = useState(false);
const [signupUrl, setSignupUrl] = useState('');
const [copied, setCopied] = useState(false);

Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -89,6 +92,11 @@ export default function SuretyDashboard() {
<MetricTile label="Avg. balance" value={`${stroopsToXlm(metrics.avgBalance)} XLM`} />
<MetricTile label="Compliance rate" value={`${metrics.complianceRate}%`} />
</div>
) : metricsError ? (
<p className="mt-6 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
Metrics unavailable. Portfolio totals couldn&apos;t be loaded — the importer list below
is unaffected.
</p>
) : null}

{error ? (
Expand Down
47 changes: 41 additions & 6 deletions apps/web/components/ErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<ErrorSeverity, { container: string; detailsButton: string }> = {
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;
Expand All @@ -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 (
<div
className={`rounded-md border border-danger/30 bg-danger/10 p-3 text-sm text-danger ${className}`}
>
<div className={`rounded-md border p-3 text-sm ${tier.container} ${className}`}>
<div className="flex items-start justify-between gap-2">
<p className="font-medium flex-1">{formatted.userMessage}</p>
<button
type="button"
onClick={() => setShowDetails(!showDetails)}
className="text-xs underline text-danger/80 hover:text-danger shrink-0 font-mono"
className={`text-xs underline shrink-0 font-mono ${tier.detailsButton}`}
>
{showDetails ? 'Hide details' : 'Details'}
</button>
Expand Down
53 changes: 53 additions & 0 deletions apps/web/lib/error-formatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading