This document is the frontend's system-design reference. It is grounded in the actual
files under src/ — every component, hook, and provider named below is a real export.
The intent: a reviewer should be able to read this in five minutes and understand how a user click becomes a network request, how a wallet signature flows back into the UI, and where the boundaries of responsibility sit.
flowchart TB
Boot["main.tsx<br/>(StrictMode + ReactDOM root)"] --> App["App.tsx"]
App --> EB["ErrorBoundary<br/>(class component, catches render errors)"]
EB --> WP["WalletProvider<br/>(connection lifecycle)"]
WP --> Router["BrowserRouter"]
Router --> Header["Header<br/>(NavLink + WalletButton)"]
Router --> Main["main element"]
Main --> R1["/ Dashboard<br/>pages/Dashboard.tsx"]
Main --> R2["/transactions<br/>pages/TransactionHistory.tsx"]
Main --> R3["/draw-credit<br/>pages/DrawCreditPage.tsx"]
Main --> R4["/open-credit<br/>pages/RequestEvaluation.tsx"]
Main --> R5["* NotFound<br/>pages/NotFound.tsx"]
R1 --> C1["StatusBadge<br/>Skeleton<br/>CopyToClipboard<br/>RiskGauge<br/>(src/components/RiskGauge.tsx)"]
R3 --> C2["CreditLineSelector ▸<br/>AmountInput ▸<br/>PreviewSection ▸<br/>ConfirmationStep ▸<br/>TransactionStatus"]
Header --> C3["WalletButton ▸<br/>WalletConnectionModal ▸<br/>OnboardingFlow"]
C3 -.->|composes| H1["useFocusTrap"]
C3 -.->|composes| H2["useBodyScrollLock"]
C3 -.->|composes| H3["useInertBackdrop"]
classDef route fill:#1c2230,stroke:#58a6ff,color:#e6edf3;
class R1,R2,R3,R4,R5 route;
The header is rendered outside <Routes> so it persists across navigation. The
ErrorBoundary is wrapped outside WalletProvider so that even a failure inside the
wallet reducer renders the error page instead of a blank screen.
The app is a client-rendered SPA. There is no SSR. Soroban contract calls are signed client-side by the connected wallet extension; the backend exposes read-only views over the indexer.
sequenceDiagram
actor User
participant UI as Page / Component
participant Ctx as WalletContext
participant Wallet as Wallet extension<br/>(Freighter / Albedo / xBull / Rabet)
participant API as Creditra Backend
participant Soroban as Soroban contract
User->>UI: Click "Connect"
UI->>Ctx: connect("freighter")
Ctx->>Wallet: window.freighter.getPublicKey()
Wallet-->>Ctx: publicKey + network
Ctx-->>UI: status = "connected", wallet = {…}
UI->>API: GET /risk?address=<pk>
API-->>UI: { score, limit, lines[] }
UI->>UI: render Dashboard / CreditLines
User->>UI: Submit draw
UI->>API: POST /draw (build envelope)
API-->>UI: unsignedTxXdr
UI->>Wallet: signTransaction(xdr)
Wallet->>User: confirm in extension
Wallet-->>UI: signedXdr
UI->>Soroban: Horizon submit
Soroban-->>API: emit event
API-->>UI: poll → updated balance
- It never holds a secret key. All signing happens inside the wallet extension; the frontend only ever sees public keys and signed XDR envelopes.
- It never re-derives risk. The risk score is authoritative on the backend; the UI renders it but cannot influence it.
- It never trusts client time for amounts. Validation in
src/utils/amountValidation.tsis a UX guard rail only; the contract is the source of truth.
The store is deliberately small. We do not use Redux, Zustand, Recoil, or React Query.
| State | Lives in | Persistence |
|---|---|---|
| Wallet connection lifecycle | src/context/WalletContext.tsx |
localStorage via src/utils/wallet.ts (saveWalletPreference, getStoredWallet) |
| Toasts and banners | src/context/NotificationContext.tsx |
In-memory; preferences and inbox persisted to localStorage |
| Colour-scheme theme | src/context/ThemeContext.tsx |
localStorage key creditra-theme via src/utils/storage.ts |
| High-contrast override | src/context/ContrastContext.tsx |
localStorage key creditra-contrast via src/utils/storage.ts |
| Page-local form state | The page component (e.g. pages/DrawCreditPage.tsx) |
None — destroyed on navigation |
| Wizard step | useState in the wizard root (DrawCreditPage) |
URL parameters drive the success state via useLocation().state |
Why no global state library? The state graph is shallow. Wallet info, notification
queue, and UI ephemera don't share enough surface area to justify a reducer framework. The
trade-off is documented in docs/UX_RATIONALE.md.
WalletContext exposes a tiny surface:
interface WalletContextType {
wallet: WalletInfo | null;
status: 'disconnected' | 'connecting' | 'connected' | 'error';
error: WalletError | null;
connect: (type: WalletType) => Promise<void>;
disconnect: () => void;
clearError: () => void;
}WalletError is a discriminated union — consumers branch on error.type to render
specific recovery UI:
type WalletError =
| { type: 'not_found'; message: string }
| { type: 'connection_failed'; message: string }
| { type: 'wrong_network'; message: string }
| { type: 'user_rejected'; message: string };NotificationContext is more ambitious. It holds:
toasts— transient stack, auto-dismissedbanners— page-level alerts, persist until dismissednotifications— inbox, persisted tolocalStoragecapped at 100 entriespreferences— per-category mute switches (transaction,credit_line,risk_score,rate_change,system)unreadCount,markAsRead,markAllAsRead,clearAllisPanelOpen+ open/close handlers for theNotificationCenter
Each notification carries a category so users can mute classes (e.g. silence rate-change
notifications while keeping transaction confirmations).
| Path | Element | Notes |
|---|---|---|
/ |
<Dashboard /> |
Default landing for a connected wallet |
/transactions |
<TransactionHistory /> |
Sortable, filterable ledger |
/credit-lines |
route is rendered in the nav but currently delegates to pages/CreditLines.tsx; wiring happens via App.tsx updates |
|
/draw-credit |
<DrawCreditPage /> |
4-step wizard |
/draw-credit/success |
<DrawCreditPage /> |
Same component, success branch driven by useLocation().state.transaction |
/open-credit |
<RequestEvaluation /> |
New-applicant intake |
* |
<NotFound /> |
Semantic 404 with link back to / |
The current build emits a single bundle; the routes above are imported eagerly in
App.tsx. The infrastructure for per-route splitting is in place — every page is a
default-exportable component — and the recommended next step is to convert each route
import to lazy(() => import(...)) and wrap <Routes> in <Suspense fallback={<Skeleton/>}>.
See PERFORMANCE.md for the rollout plan and per-route bundle budgets.
Every screen that fetches data follows the same four-state pattern:
stateDiagram-v2
[*] --> Loading
Loading --> Empty: data.length === 0
Loading --> Ready: data.length > 0
Loading --> Error: fetch threw
Error --> Loading: retry
Empty --> Ready: data arrives
| State | Visual | Component |
|---|---|---|
| Loading | Shimmer matching the final layout | components/Skeleton.tsx (Skeleton.css animation, prefers-reduced-motion disables shimmer) |
| Empty | Illustration + primary CTA to populate | inline per page (e.g. Dashboard's "no credit lines" state) |
| Error | Banner + retry, or full-page ErrorBoundary if render-time |
components/ErrorBoundary.tsx for render errors, BannerAlert for fetch errors |
| Ready | Real data | the screen |
The ErrorBoundary lives at the very top of App.tsx so a panicking render anywhere in
the tree falls back to a labelled error page with a "Go back" and "Reload" pair (see
src/pages/ErrorPage.css).
Three hooks compose to form every modal/sheet contract.
useFocusTrap({ isActive, triggerRef, onEscape })— moves focus into the container on activation, cycles Tab/Shift+Tab within it, callsonEscapeon the Escape key, and returns focus totriggerRef(or the previously focused element) on close.useBodyScrollLock({ isLocked })— freezes background scroll by stashing the scroll position intobody.style.topand restoring it on unmount.useInertBackdrop({ isInert, modalId })— walks the DOM and applies the nativeinertattribute to every element outside the modal container; falls back toaria-hidden="true"+pointer-events: nonefor older browsers and restores prior attribute state on cleanup.
The WalletConnectionModal composes all three. Any future modal must too — failing to
do so is a review blocker.
Tokens are co-located in two places:
- Runtime CSS variables in
src/index.css(--bg,--surface,--accent,--space-*,--radius-*,--lh-*). These are the values components read at paint time. - TypeScript token module at
src/utils/tokens.ts. ExportsCOLOR,UTIL_COLOR,STATUS_COLOR,RISK_COLOR, ready-madebtnstyle objects, and formatters (fmt,fmtDate,fmtDateTime). Imported by inline-styled components likeDashboard's risk gauge that need values in JS.
The Figma source-of-truth lives in Design System/tokens.md.
See DESIGN_SYSTEM.md for the full catalogue and theming model.
src/
├── App.tsx Routes + global providers (ErrorBoundary > WalletProvider > Router)
├── main.tsx ReactDOM bootstrap inside StrictMode
├── index.css Token definitions + base styles + utility classes
├── pages/ Route-level components
│ ├── Dashboard.tsx Risk gauge, summary, recent tx
│ ├── CreditLines.tsx Sortable credit-line list
│ ├── DrawCreditPage.tsx 4-step draw wizard
│ ├── TransactionHistory.tsx
│ ├── RequestEvaluation.tsx
│ ├── NotFound.tsx
│ └── (auth) LoginPage / RegisterPage / ForgotPasswordPage / ResetPasswordPage
├── components/
│ ├── notifications/ ToastContainer, BannerAlert, NotificationBell, NotificationCenter
│ ├── (modals) WalletConnectionModal, RepayModal, OnboardingFlow
│ ├── (inputs) FormField, FormMessage, AmountInput, PendingButton
│ ├── (status) StatusBadge, Skeleton, SuccessState, TransactionStatus, RiskGauge
│ ├── (a11y) AccessibleTooltip, CopyToClipboard
│ └── ErrorBoundary Top-level render-error catcher
├── context/
│ ├── WalletContext.tsx
│ └── NotificationContext.tsx
├── hooks/
│ ├── useFocusTrap.ts
│ ├── useBodyScrollLock.ts
│ └── useInertBackdrop.ts
├── utils/
│ ├── tokens.ts Color/spacing tokens + formatters
│ ├── wallet.ts Wallet provider glue
│ ├── amountValidation.ts Draw/repay input validation
│ ├── currency.ts / dates.ts / format-address.ts
│ ├── classnames.ts / clipboard.ts / storage.ts / password-strength.ts
├── types/
│ ├── wallet.ts / creditLine.ts / draw-credit.types.ts / notification.ts / auth.types.ts
├── lib/ External adapters and mock data
├── data/ Static fixtures (mirrors backend shape)
└── test/ Vitest setup