diff --git a/.claude/NWP-201-issue-cards.md b/.claude/NWP-201-issue-cards.md new file mode 100644 index 00000000..31dd2dec --- /dev/null +++ b/.claude/NWP-201-issue-cards.md @@ -0,0 +1,96 @@ +# SPEC · NWP-201 — Issue virtual cards from the console + +> Written before any code. Generated with `/spec`, then edited by a human. +> Load it as context when you build: `@docs/specs/NWP-201-issue-cards.md` + +**Ticket:** [NWP-201](../tickets/NWP-201.md) +**Author:** Krishna Koushik +**Status:** done + +## Problem + +Ops issues virtual cards by messaging the platform team, who create them by hand — hours of turnaround, 12-20 times a week, and a wrong spend limit slipped through last month because the request lived in a Slack thread. Put issuing in the console: a form, a list, a detail view, with limits enforced server-side from the moment a card exists. + +## Current state + +Before this ticket, nothing card-related existed in the codebase: + +- No `Card` type, no card store, no `/cards` route. `src/data/types.ts` had `Merchant`, `Payment`, `Refund`, `Dispute`, `Payout` only. +- No mutating route handler anywhere in `src/app/api/`. `src/app/api/payments/route.ts:4` and `src/app/api/payments/export/route.ts:11` export `GET` only — this ticket writes the repo's first `POST` and `PATCH`. +- No error envelope. There was no precedent to follow, so this ticket invents one (`src/lib/api.ts`) rather than matching an existing shape. +- No zod or any validation library in the repo; `src/data/queries.ts:18` (`parseFilters`) is the only prior example of hand-rolled input handling, and it allowlists-and-falls-back rather than rejecting. +- `Currency` (`src/data/types.ts:1`) is a type only, erased at runtime — nothing in the codebase could check a currency string against it before `src/lib/money.ts:63` (`isCurrency`) existed. + +## Domain rules + +| Rule | Source | What breaks if ignored | +| --- | --- | --- | +| "Money is integer minor units... No floats, no strings with currency symbols." | `CLAUDE.md` (merchant-console) | A `$250.00` limit stored as `250.00` drifts under arithmetic and fails the ticket's own validation range | +| "Test BIN only. Every generated number starts `4242` and carries a valid Luhn check digit." | `.claude/rules/cards.md` | A number that isn't test-BIN-shaped could be mistaken for a real PAN | +| "Reveal once. The full number appears in the creation response and nowhere else." | `.claude/rules/cards.md` | A re-readable number turns a display bug into a card-data leak | +| "Status is a state machine. `active ⇄ frozen`, either to `cancelled`, and `cancelled` is terminal." | `.claude/rules/cards.md` | A card frozen for fraud could be reactivated, or a cancelled card could take spend again | +| "Never store, compare, or accumulate an amount as a float." | `.claude/rules/money.md` | `spendLimit` comparisons (the 5,000,000 ceiling, spend-vs-limit) become approximate | +| "Never return a full card number from a list or detail route. The full number exists in exactly one response, the creation one." | `.claude/rules/api-routes.md` | The list/detail payloads would need scrubbing logic that could be wrong | +| "Validate everything from the client against an allowlist before it reaches the store... Client-side checks are a convenience, never the enforcement." | `.claude/rules/api-routes.md` | A hand-edited request bypasses the drawer's own checks and mints an illegal card | + +## Approach + +Split the domain in two, mirroring `src/lib/dates.ts`'s existing pattern of injecting non-deterministic inputs rather than reaching for them. `src/lib/cards.ts` holds everything isomorphic — Luhn generation and validation, the `4242` BIN, masking, the status transition table, and the `CardStatus`/`CardCategory` type guards — because `card-status-actions.tsx` is a client component that imports `allowedTransitions` to decide which buttons to render, so this file can never import `node:crypto` or touch the store. `src/data/cards.ts` holds everything server-only — the id allocator, `validateIssueCard`, `issueCard`, `setCardStatus`, `queryCards` — and is the only place `node:crypto` is used (`randomInt` for digits, `randomBytes` for `numberRef`). Two new route handlers, `POST /api/cards` and `PATCH /api/cards/[id]`, are the first mutating routes in the app and so define the error envelope (`src/lib/api.ts`) the rest of the API layer has none of yet. + +**Considered and rejected:** reusing `filterPayments`/`sortPayments` (`src/data/queries.ts:45,72`) for card queries — rejected because both are `Payment`-typed on every line (`store.payments`, `payment.status`, `payment.merchantId`), and widening them to a shared shape is a bigger, riskier diff than writing card-specific filtering. Only the already-generic `paginate` (`src/data/queries.ts:87`) is reused, unchanged, in `queryCards` (`src/data/cards.ts:57`). Also considered and rejected: adding a `src/components/Dialog.tsx`. `.claude/rules/components.md:9` claims one already exists ("Button, Input, Select, Dialog, Badge"); it does not — only `Drawer.tsx` does, wrapping `@radix-ui/react-dialog` — and `Drawer` already gives the issue flow an accessible modal on the same primitive, so a second dialog component would be a duplicate, not a fix. + +## File map + +| File | Add or change | Why | +| --- | --- | --- | +| `src/data/types.ts` | Add `Card`, `CardStatus`, `CardCategory`, `CardEvent`, `CardFilters` | The domain has no shape yet | +| `src/lib/cards.ts` | Add (new file) | Isomorphic Luhn, BIN, mask, transition table, type guards | +| `src/data/cards.ts` | Add (new file) | Server-only store access, validation, mutations | +| `src/lib/api.ts` | Add (new file) | The `{ error }` envelope every card route returns | +| `src/app/api/cards/route.ts` | Add (new file) | `POST` — issue a card | +| `src/app/api/cards/[id]/route.ts` | Add (new file) | `PATCH` — change status | +| `src/app/cards/page.tsx` | Add (new file) | Card list, filters, pagination | +| `src/app/cards/[id]/page.tsx` | Add (new file) | Card detail, spend progress, timeline | +| `src/app/cards/issue-card-drawer.tsx` | Add (new file) | Issue form + one-time reveal screen | +| `src/app/cards/card-status-actions.tsx` | Add (new file) | Freeze/unfreeze/cancel controls | +| `src/app/cards/filter-bar.tsx` | Add (new file) | Status/merchant filter controls | +| `src/app/cards/error.tsx`, `[id]/not-found.tsx` | Add (new files) | Written error/not-found states, not defaults | + +## Plan + +1. **Domain module (`src/lib/cards.ts`)** — done when: `luhnCheckDigit`, `isValidLuhn`, `generateCardNumber`, `maskCardNumber`, `canTransition`, `allowedTransitions`, and the two type guards exist and are covered by unit tests. +2. **Server data module (`src/data/cards.ts`)** — done when: `validateIssueCard` rejects every case in the ticket's validation list, `issueCard` and `setCardStatus` mutate the store correctly, and both are covered by unit tests. +3. **Route handlers** — done when: `POST /api/cards` returns 201 with `{ card, cardNumber }` and `PATCH /api/cards/[id]` returns 200/400/404/409 as specified, verified with `curl`. +4. **UI** — done when: `/cards` lists, filters, and paginates; `/cards/[id]` shows the full record and spend; the issue drawer shows the number exactly once; freeze/unfreeze/cancel work without a full reload. +5. **Verify** — done when: `npm test` and `npm run build` are both clean. + +## Verification + +| Acceptance criterion | How it is proven | +| --- | --- | +| Issue a card (nickname, merchant, limit, currency) | `issue-card-drawer.tsx` form → `POST /api/cards`; curl: `201` with card + `cardNumber` for a valid body | +| Card list at `/cards` (nickname, merchant, masked number, limit, status, created) | `src/app/cards/page.tsx:74-148` renders all six columns per row; `npm run build` shows `/cards` as `ƒ` (dynamic, server-rendered on demand) | +| Card detail (full record + spend vs. limit) | `src/app/cards/[id]/page.tsx`; `SpendProgress` (line 131) renders the bar and amber threshold | +| Generated numbers on `4242` BIN with valid Luhn | `src/lib/cards.test.ts` (25 tests, includes a round-trip property test and the three classic off-by-one vectors); `npm test` — all pass | +| Reveal once, mask forever | Structural: `Card` (`src/data/types.ts:105`) has no number field, so nothing to leak from list/detail; `issue-card-drawer.tsx:474-529` is the only screen that ever renders `cardNumber`, and a replayed issue omits it (`src/data/cards.ts:196-202`) | +| Server-side validation (missing merchant, ≤0 limit, >5,000,000 limit, bad currency) | `src/data/cards.test.ts` (28 tests) exercise `validateIssueCard`; curl: `400 invalid_field` for a bad currency, a zero limit, and a missing `merchantId` | +| Status transitions guarded server-side | `src/lib/cards.test.ts` covers `canTransition`/`allowedTransitions`; curl: cancelling a card then attempting `cancelled → frozen` returns `409 invalid_transition`; a same-status `PATCH` (`active → active`) returns `200` as a no-op | +| Unknown card | curl: `PATCH /api/cards/card_9999` returns `404 not_found` | +| Full suite | `npm test` — 83 tests pass (25 `src/lib/cards.test.ts`, 28 `src/data/cards.test.ts`, plus 30 pre-existing across `dates`/`csv`/`money`); `npm run build` compiles clean with every route including `/cards` and `/cards/[id]` listed dynamic (`ƒ`) | + +## Risks + +- **`spent` has no honest data source.** `Payment` (`src/data/types.ts:24`) has no `cardId`, and there is no card-transaction entity, so criterion 3's "spend against the limit" cannot be derived from real activity. Attributing merchant payments to a card would also be semantically inverted — those are payments the merchant *received*, not funds spent from an issued card. `spent` therefore lives directly on `Card` (`src/data/types.ts:121`, seeded/updated as a bare integer) rather than being computed. This is a modelling gap, not a display bug; it should close when card-transaction data exists (tracked as NWP-202/NWP-203 territory). +- **Two pieces of `.claude/rules/` and prior code are wrong** and were not trusted: `components.md:9`'s claim of an existing `Dialog` component, and `queries.ts:80`'s comment claiming `sortPayments` sorts by formatted amount (it does a lexicographic string compare of the raw number — a live bug, not copied here). + +## Out of scope + +- Persistence beyond the dev-server lifetime (NWP-203) — no database, ORM, or migration was added. +- Editing a card's limit after issue (NWP-202). +- Card-transaction modelling that would let `spent` be derived rather than stored (NWP-202/NWP-203). +- Authentication, roles, permissions, and real card-network calls — per the ticket's own out-of-scope list. + +## Open questions + +- When card-transaction data lands, should `spent` migrate to a computed value, and does that change the `Card` type's shape or just its source? +- Should a currency mismatch between a merchant's settlement currency and the chosen card currency (surfaced today only as a client-side warning in `issue-card-drawer.tsx:416-424`) be a hard server-side rejection instead? diff --git a/build-battle/merchant-console/package-lock.json b/build-battle/merchant-console/package-lock.json index bce9030c..859ca9eb 100644 --- a/build-battle/merchant-console/package-lock.json +++ b/build-battle/merchant-console/package-lock.json @@ -1,11 +1,11 @@ { - "name": "template-planner", + "name": "merchant-console", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "template-planner", + "name": "merchant-console", "version": "0.1.0", "dependencies": { "@radix-ui/react-accordion": "^1.2.12", diff --git a/build-battle/merchant-console/src/app/api/cards/[id]/route.ts b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts new file mode 100644 index 00000000..965288ee --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts @@ -0,0 +1,60 @@ +import { setCardStatus } from "@/data/cards" +import { isCardStatus } from "@/data/card-status" +import { jsonError } from "@/lib/api" +import { NextRequest, NextResponse } from "next/server" + +/** + * Moves a card between `active`, `frozen`, and `cancelled`. Body is + * `{ status }` only — `spendLimit` is NWP-202, not this ticket, so accepting + * it here would build that ticket by accident. + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return jsonError(400, { + code: "invalid_json", + message: "Request body must be valid JSON.", + }) + } + + const status = + typeof body === "object" && body !== null && !Array.isArray(body) + ? (body as Record).status + : undefined + + if (!isCardStatus(status)) { + return jsonError(400, { + code: "invalid_field", + field: "status", + message: "status must be one of active, frozen, cancelled.", + }) + } + + // Both `await`s (params, request.json()) already happened above, so the + // find-check-mutate inside `setCardStatus` below runs with no `await` in + // the middle — two concurrent PATCHes cannot interleave across it. + const result = setCardStatus(id, status, new Date()) + + if (!result.ok) { + if (result.reason === "not_found") { + return jsonError(404, { + code: "not_found", + message: `No card with id "${id}".`, + }) + } + return jsonError(409, { + code: "invalid_transition", + field: "status", + message: `Card ${id} is currently ${result.card.status} and cannot move to ${status}.`, + }) + } + + return NextResponse.json({ card: result.card }) +} diff --git a/build-battle/merchant-console/src/app/api/cards/route.ts b/build-battle/merchant-console/src/app/api/cards/route.ts new file mode 100644 index 00000000..67a9e250 --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/route.ts @@ -0,0 +1,44 @@ +import { issueCard, validateIssueCard } from "@/data/cards" +import { jsonError } from "@/lib/api" +import { NextRequest, NextResponse } from "next/server" + +/** + * Issues a virtual card. `201` with the full number, once, as a sibling of + * the card record — never a field on it. A replay of a prior `requestKey` + * comes back `200` with the existing card and no number, because reveal + * happens exactly once. + */ +export async function POST(request: NextRequest) { + let body: unknown + try { + body = await request.json() + } catch { + return jsonError(400, { + code: "invalid_json", + message: "Request body must be valid JSON.", + }) + } + + const result = validateIssueCard(body) + if (!result.ok) { + return jsonError(400, { + code: "invalid_field", + message: result.message, + field: result.field, + }) + } + + const issued = issueCard(result.value, new Date()) + + if ("replayed" in issued) { + return NextResponse.json({ card: issued.card, replayed: true }) + } + + return NextResponse.json( + { card: issued.card, cardNumber: issued.cardNumber }, + { + status: 201, + headers: { Location: `/api/cards/${issued.card.id}` }, + }, + ) +} diff --git a/build-battle/merchant-console/src/app/cards/[id]/not-found.tsx b/build-battle/merchant-console/src/app/cards/[id]/not-found.tsx new file mode 100644 index 00000000..1e0d8667 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/not-found.tsx @@ -0,0 +1,18 @@ +import { Button } from "@/components/Button" +import Link from "next/link" + +export default function CardNotFound() { + return ( +
+

+ Card not found +

+

+ It may have been removed, or the link is out of date. +

+ +
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/[id]/page.tsx b/build-battle/merchant-console/src/app/cards/[id]/page.tsx new file mode 100644 index 00000000..40f6d49f --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/page.tsx @@ -0,0 +1,201 @@ +import { Divider } from "@/components/Divider" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { cardById } from "@/data/cards" +import { maskCardNumber } from "@/data/card-number" +import { merchantById } from "@/data/merchants" +import { CardCategory, Currency } from "@/data/types" +import { formatInZone } from "@/lib/dates" +import { formatMoney } from "@/lib/money" +import { cx } from "@/lib/utils" +import Link from "next/link" +import { notFound } from "next/navigation" +import { CardStatusActions } from "../card-status-actions" + +const CATEGORY_LABELS: Record = { + advertising: "Advertising", + software: "Software", + travel: "Travel", + contractors: "Contractors", + utilities: "Utilities", +} + +export default async function CardDetail({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const card = cardById(id) + if (!card) notFound() + + const merchant = merchantById(card.merchantId)! + + return ( +
+ + ← All cards + + +
+

+ {card.nickname} +

+ + {maskCardNumber(card.last4)} + + +
+

{card.id}

+ +
+ +
+ + + +
+ + {merchant.name} + {merchant.country} + + + {formatMoney(card.spendLimit, card.currency)} + + + {card.categoryLock ? CATEGORY_LABELS[card.categoryLock] : "None"} + + + {card.createdAt} + + + {formatInZone(card.createdAt, merchant.timezone)} + +
+ + + +

+ Spend +

+
+ +
+ + + +

+ Timeline +

+
    + {card.events.map((entry, index) => ( +
  1. +
  2. + ))} +
+
+ ) +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +/** + * `` needs no inline style and is natively accessible, + * unlike a dynamic arbitrary Tailwind class (`w-[${pct}%]`), which the + * scanner cannot see and silently renders zero width. The bar's `value` is + * clamped to `limit` so the rendered width never exceeds 100%, while the + * text next to it always shows the true, uncapped percentage. + */ +function SpendProgress({ + spent, + limit, + currency, +}: { + spent: number + limit: number + currency: Currency +}) { + if (spent === 0) { + return

No spend yet.

+ } + + if (limit <= 0) { + return ( +

+ {formatMoney(spent, currency)} spent · no spend limit set +

+ ) + } + + // Integer comparison on purpose: comparing a rounded display percentage + // (e.g. 79.6% -> "80%") would flip the bar amber a shade early. + const isAmber = spent * 5 >= limit * 4 + const truePercent = Math.round((spent / limit) * 100) + const barValue = Math.min(spent, limit) + + return ( +
+
+ + {formatMoney(spent, currency)} of {formatMoney(limit, currency)} + + {truePercent}% +
+ + {spent > limit && ( +

+ Over limit +

+ )} +
+ ) +} + +function Field({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
+
{label}
+
{children}
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/card-status-actions.tsx b/build-battle/merchant-console/src/app/cards/card-status-actions.tsx new file mode 100644 index 00000000..6e36138b --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/card-status-actions.tsx @@ -0,0 +1,104 @@ +"use client" + +import { Button } from "@/components/Button" +import { allowedTransitions } from "@/data/card-status" +import { CardStatus } from "@/data/types" +import { useRouter } from "next/navigation" +import { useState, useTransition } from "react" + +/** + * The label describes the status the card is MOVING TO, so "frozen" reads as + * the "Freeze" action and "active" reads as "Unfreeze". Cancel gets its own + * two-step inline confirm below rather than a label lookup. + */ +const ACTION_LABELS: Partial> = { + active: "Unfreeze", + frozen: "Freeze", +} + +export function CardStatusActions({ + cardId, + status, +}: { + cardId: string + status: CardStatus +}) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + const [submitting, setSubmitting] = useState(false) + const [confirmingCancel, setConfirmingCancel] = useState(false) + const [error, setError] = useState(null) + + const targets = allowedTransitions(status) + + if (targets.length === 0) return null + + const applyStatus = async (next: CardStatus) => { + setSubmitting(true) + setError(null) + try { + const res = await fetch(`/api/cards/${cardId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: next }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + setError( + body?.error?.message ?? "Could not update this card. Try again.", + ) + return + } + startTransition(() => router.refresh()) + } catch { + setError("Could not reach the server. Check your connection and try again.") + } finally { + setSubmitting(false) + setConfirmingCancel(false) + } + } + + const busy = submitting || isPending + + return ( +
+ {targets.map((next) => + next === "cancelled" ? ( + + ) : ( + + ), + )} + {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/error.tsx b/build-battle/merchant-console/src/app/cards/error.tsx new file mode 100644 index 00000000..30ce12af --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/error.tsx @@ -0,0 +1,30 @@ +"use client" + +import { Button } from "@/components/Button" +import { useEffect } from "react" + +export default function CardsError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error(error) + }, [error]) + + return ( +
+

+ Something went wrong loading cards. +

+

+ Try again, or come back in a moment. +

+ +
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/filter-bar.tsx b/build-battle/merchant-console/src/app/cards/filter-bar.tsx new file mode 100644 index 00000000..3ef70f95 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/filter-bar.tsx @@ -0,0 +1,94 @@ +"use client" + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/Select" +import { useRouter } from "next/navigation" + +const LABELS: Record = { + all: "All statuses", + active: "Active", + frozen: "Frozen", + cancelled: "Cancelled", +} + +export function CardsFilterBar({ + statuses, + merchants, + current, +}: { + statuses: string[] + merchants: { id: string; name: string }[] + current: { status: string; merchantId: string } +}) { + const router = useRouter() + + const apply = (changes: Record) => { + const next = new URLSearchParams() + const merged = { ...current, ...changes } + if (merged.status && merged.status !== "all") next.set("status", merged.status) + if (merged.merchantId) next.set("merchantId", merged.merchantId) + router.push(`/cards?${next.toString()}`) + } + + return ( +
+
+ + +
+ +
+ + +
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx b/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx new file mode 100644 index 00000000..225deb05 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx @@ -0,0 +1,385 @@ +"use client" + +import { Button } from "@/components/Button" +import { + Drawer, DrawerBody, DrawerClose, DrawerContent, + DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, +} from "@/components/Drawer" +import { Input } from "@/components/Input" +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/Select" +import { CardCategory, Card, Currency } from "@/data/types" +import { MAX_SPEND_LIMIT_MINOR_UNITS, NICKNAME_MAX_LENGTH } from "@/data/card-number" +import { CURRENCIES, formatMoney, parseAmountToMinorUnits } from "@/lib/money" +import { Check, Copy, Plus } from "lucide-react" +import { useRouter } from "next/navigation" +import { useRef, useState } from "react" + +/** + * The client's one-shot key for the issue request, so a double submit (a + * slow network, a second click) returns the already-issued card instead of + * minting a second one. `crypto.randomUUID` is the Web Crypto API, available + * in the browser - not `node:crypto`, which this file may never import. + */ +function mintRequestKey(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID() + } + return `req_${Date.now()}_${Math.random().toString(36).slice(2)}` +} + +const CATEGORIES: { value: CardCategory; label: string }[] = [ + { value: "advertising", label: "Advertising" }, + { value: "software", label: "Software" }, + { value: "travel", label: "Travel" }, + { value: "contractors", label: "Contractors" }, + { value: "utilities", label: "Utilities" }, +] + +type Issued = { card: Card; cardNumber: string | null } + +export function IssueCardDrawer({ + merchants, +}: { + merchants: { id: string; name: string; currency: Currency }[] +}) { + const router = useRouter() + + const [open, setOpen] = useState(false) + const [phase, setPhase] = useState<"form" | "success">("form") + const [submitting, setSubmitting] = useState(false) + const [requestKey, setRequestKey] = useState(() => mintRequestKey()) + const [issued, setIssued] = useState(null) + const [copied, setCopied] = useState(false) + + const [nickname, setNickname] = useState("") + const [merchantId, setMerchantId] = useState("") + const [spendLimitInput, setSpendLimitInput] = useState("") + const [currency, setCurrency] = useState("") + const [categoryLock, setCategoryLock] = useState("none") + const [formError, setFormError] = useState(null) + const [fieldErrors, setFieldErrors] = useState>({}) + + // One focus target per field, keyed by name - replaces five separate refs. + const fieldRefs = useRef>({}) + const registerField = (field: string) => (el: HTMLElement | null) => { + fieldRefs.current[field] = el + } + const focusField = (field: string) => fieldRefs.current[field]?.focus() + + const selectedMerchant = merchants.find((m) => m.id === merchantId) + const currencyForDisplay = (currency || selectedMerchant?.currency || "USD") as Currency + const showCurrencyMismatch = + Boolean(selectedMerchant) && Boolean(currency) && currency !== selectedMerchant?.currency + + const resetFields = () => { + setNickname("") + setMerchantId("") + setSpendLimitInput("") + setCurrency("") + setCategoryLock("none") + setFormError(null) + setFieldErrors({}) + } + + const handleMerchantChange = (id: string) => { + setMerchantId(id) + const merchant = merchants.find((m) => m.id === id) + if (merchant) setCurrency(merchant.currency) + } + + const handleCopy = async () => { + if (!issued?.cardNumber) return + try { + await navigator.clipboard.writeText(issued.cardNumber) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Clipboard access can be denied by the browser; the number is still + // visible on screen to copy by hand. + } + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setFormError(null) + + const errors: Record = {} + const trimmedNickname = nickname.trim() + if (!trimmedNickname) errors.nickname = "Enter a nickname." + else if (trimmedNickname.length > NICKNAME_MAX_LENGTH) + errors.nickname = `Nicknames are at most ${NICKNAME_MAX_LENGTH} characters.` + + if (!merchantId) errors.merchantId = "Choose a merchant." + + const minorUnits = parseAmountToMinorUnits(spendLimitInput) + if (minorUnits === null) errors.spendLimit = "Enter an amount like 250 or 250.00." + else if (minorUnits <= 0) errors.spendLimit = "Enter an amount greater than 0." + else if (minorUnits > MAX_SPEND_LIMIT_MINOR_UNITS) + errors.spendLimit = `Spend limit cannot exceed ${formatMoney(MAX_SPEND_LIMIT_MINOR_UNITS, currencyForDisplay)}.` + + if (!currency) errors.currency = "Choose a currency." + + if (Object.keys(errors).length > 0) { + setFieldErrors(errors) + const firstField = ["nickname", "merchantId", "spendLimit", "currency"].find((f) => errors[f]) + if (firstField) focusField(firstField) + return + } + if (minorUnits === null || !currency) return // narrows for TS; unreachable given the checks above + + setFieldErrors({}) + setSubmitting(true) + try { + const res = await fetch("/api/cards", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nickname: trimmedNickname, + merchantId, + spendLimit: minorUnits, + currency, + categoryLock: categoryLock === "none" ? null : categoryLock, + requestKey, + }), + }) + + if (res.status === 201 || res.status === 200) { + const body = await res.json() + setIssued({ card: body.card, cardNumber: body.cardNumber ?? null }) + setPhase("success") + router.refresh() + return + } + + const body = await res.json().catch(() => null) + const apiError = body?.error + if (apiError?.field) { + setFieldErrors({ [apiError.field]: apiError.message }) + focusField(apiError.field) + } else { + setFormError(apiError?.message ?? "Could not issue this card. Try again.") + } + } catch { + setFormError("Could not reach the server. Check your connection and try again.") + } finally { + setSubmitting(false) + } + } + + return ( + { + setOpen(next) + if (!next) { + setIssued(null) + setPhase("form") + setCopied(false) + resetFields() + setRequestKey(mintRequestKey()) + } + }} + > + + + + + + + {phase === "form" ? "Issue card" : "Card issued"} + + + + {phase === "form" ? ( +
+ + {formError && ( +

+ {formError} +

+ )} + + + setNickname(event.target.value)} maxLength={NICKNAME_MAX_LENGTH} + hasError={Boolean(fieldErrors.nickname)} aria-invalid={Boolean(fieldErrors.nickname)} + aria-describedby={fieldErrors.nickname ? "card-nickname-error" : "card-nickname-help"} /> + + + +
+ setSpendLimitInput(event.target.value)} + placeholder="250.00" hasError={Boolean(fieldErrors.spendLimit)} + aria-invalid={Boolean(fieldErrors.spendLimit)} + aria-describedby={fieldErrors.spendLimit ? "card-spend-limit-error" : "card-spend-limit-help"} /> + {currency || selectedMerchant?.currency || ""} +
+
+ + ({ value: m.id, label: m.name }))} + fieldRef={registerField("merchantId")} error={fieldErrors.merchantId} errorId="card-merchant-error" + describedBy={fieldErrors.merchantId ? "card-merchant-error" : undefined} /> + + setCurrency(value as Currency)} + placeholder="Choose a currency" options={CURRENCIES.map((code) => ({ value: code, label: code }))} + fieldRef={registerField("currency")} error={fieldErrors.currency} errorId="card-currency-error" + noteId="card-currency-note" note={showCurrencyMismatch && selectedMerchant ? `${selectedMerchant.name} settles in ${selectedMerchant.currency}. This card cannot be issued in ${currency}.` : undefined} + describedBy={fieldErrors.currency ? "card-currency-error" : showCurrencyMismatch ? "card-currency-note" : undefined} /> + + setCategoryLock(value as CardCategory | "none")} + placeholder="No lock" options={[{ value: "none", label: "No lock" }, ...CATEGORIES]} + fieldRef={registerField("categoryLock")} describedBy="card-category-help" + help="What the card may be spent on. Not editable after issue." helpId="card-category-help" /> +
+ + + +
+ ) : ( + <> + +
+ {issued?.cardNumber ? ( +
+

+ This is the only time this number will be shown. Copy it now. +

+
+

+ {issued.cardNumber} +

+ +
+

+ {issued.card.nickname} · {formatMoney(issued.card.spendLimit, issued.card.currency)} limit +

+
+ ) : ( +

+ This card was already issued. Its number was shown once and cannot be shown again. +

+ )} +
+
+ + + + + + + )} +
+
+ ) +} + +/** + * Shared label/help/error/note scaffold for a form field. + * + * `htmlFor` pairs a native control's `