Production-grade withdrawal UI built with Next.js 15, TypeScript, Feature-Sliced Design (FSD), Zustand, and shadcn/ui.
git clone https://github.com/palveeen22/withdraw-app.git
cd withdraw-apppnpm install
pnpm devOpen http://localhost:3000 — redirects to /withdraw.
pnpm test # run all tests
pnpm test:watch # watch mode
pnpm test:coverage # with coverage reportsrc/
├── app/ # Next.js App Router
│ ├── api/v1/withdrawals/
│ │ ├── _store.ts # Shared singleton (POST + GET use same Map)
│ │ ├── route.ts # POST /v1/withdrawals
│ │ └── [id]/route.ts # GET /v1/withdrawals/:id (+ status progression)
│ ├── error.tsx # Global React error boundary
│ ├── not-found.tsx
│ └── withdraw/page.tsx
│
├── widgets/withdraw-page/ # Page assembly widget
│
├── features/withdraw-form/ # User-facing feature
│ ├── model/
│ │ ├── schema.ts # Zod validation
│ │ ├── withdrawStore.ts # Zustand state machine (form + polling)
│ │ └── useWithdrawSubmit.ts # Submit logic + polling effect
│ └── ui/WithdrawForm.tsx
│
├── entities/withdrawal/ # Domain: types, API calls, StatusBadge, WithdrawalCard
└── shared/ # fetch utility, config, sessionStorage cache, shadcn/ui
UUID generated once per attempt and stored in Zustand. On network error, the same key is reused on retry — prevents duplicate charges regardless of how many times the user retries.
- UI layer: submit button
disabled+aria-busy=trueduring loading - Hook layer:
isSubmittingRef(useRef) blocks concurrent calls before React re-renders
Both are necessary: the button alone can be bypassed if two submits fire before the first re-render.
idle ──submit──▶ loading ──success──▶ success ──reset──▶ idle
└──error──▶ error ──retry──▶ loading
└─cancel─▶ idle
Polling runs as a parallel concern on top of the form state:
idle ──startPolling──▶ polling ──terminal status──▶ done
└──max attempts───▶ timeout
After a successful POST, the app polls GET /v1/withdrawals/:id every 3 seconds (max 10 attempts = 30s). The WithdrawalCard shows a live spinner while polling and transitions to a confirmed state when status reaches completed or failed. This reflects how real async blockchain confirmations work.
POST → pending
↓ poll (3s) → processing
↓ poll (8s) → completed ✓
Both API routes (POST and GET /[id]) import from _store.ts, which exports module-level Map singletons. This means GET /[id] can actually find what POST created — a real bug in many test assignments.
| Error | Behavior |
|---|---|
| 409 Conflict | "A duplicate withdrawal request was detected" |
| NetworkError | "Network error — form preserved, retry safely" |
| Other API | Request failed: <message> |
| Polling error | Non-fatal — increments attempt counter, retries next tick |
Form values are never cleared on error.
This demo uses mock auth (no token). In production:
- Access tokens live in
httpOnly,Secure,SameSite=Strictcookies — neverlocalStorage - The Next.js BFF (API routes) reads the cookie server-side and attaches the
Authorizationheader before forwarding to the upstream service - The client-side JS never has access to the raw token value
- CSRF protection comes from
SameSite=Strict+Originvalidation on the API
After success, the withdrawal is stored in sessionStorage (not localStorage) with a 5-minute TTL. On reload within that window, the last withdrawal is restored. sessionStorage was chosen because it is tab-scoped and cleared when the tab is closed — appropriate for short-lived UI state, not sensitive credentials.
app/error.tsx catches any unhandled runtime errors in the React tree and renders a recovery UI. In production this hook should report to an error tracking service (Sentry, Datadog, etc.) via the useEffect.
All text rendered via React's safe interpolation. No raw HTML injection anywhere.
| File | Cases |
|---|---|
WithdrawForm.test.tsx |
Happy path, error display, retry UX, double-submit (3 cases), validation (2 cases) |
useWithdrawSubmit.test.ts |
Success + polling starts, correct payload, poll tick, terminal stops polling, poll timeout, 409, network error, idempotency key on retry, double-submit (2 cases) |
Headers: Idempotency-Key: <uuid>
Body: { amount: number, destination: string, currency: string }
201 → Withdrawal object (status: "pending")
409 → { message: "Withdrawal already exists with ID ..." }
422 → Validation error
200 → Withdrawal (status auto-progresses: pending → processing → completed)
404 → { message: "Withdrawal :id not found" }
Status progression in mock:
0–3s→pending3–8s→processing>8s→completed