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..d994a05a --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/[id]/route.ts @@ -0,0 +1,22 @@ +import { cardById, parseCardStatus, transitionCard } from "@/data/cards" +import { NextRequest, NextResponse } from "next/server" + +type Context = { params: Promise<{ id: string }> } + +export async function GET(_request: NextRequest, { params }: Context) { + const card = cardById((await params).id) + if (!card) return NextResponse.json({ error: "Card not found." }, { status: 404 }) + return NextResponse.json({ card }) +} + +/** Status changes only; limit edits are NWP-202. */ +export async function PATCH(request: NextRequest, { params }: Context) { + const parsed = parseCardStatus(await request.json().catch(() => null)) + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 }) + const result = transitionCard((await params).id, parsed.value) + if (!result.ok) { + const status = result.reason === "not_found" ? 404 : 409 + return NextResponse.json({ error: result.error }, { 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..8c961b21 --- /dev/null +++ b/build-battle/merchant-console/src/app/api/cards/route.ts @@ -0,0 +1,24 @@ +import { issueCardOnce, listCards, parseIdempotencyKey, parseIssueCard } from "@/data/cards" +import { NextRequest, NextResponse } from "next/server" + +/** Stored cards carry the last four only, never a number. */ +export function GET() { + return NextResponse.json({ cards: listCards() }) +} + +/** The one response with a full number. A replayed key gets 409 without it. */ +export async function POST(request: NextRequest) { + const key = parseIdempotencyKey(request.headers.get("idempotency-key")) + if (!key.ok) return NextResponse.json({ error: key.error }, { status: 400 }) + const parsed = parseIssueCard(await request.json().catch(() => null)) + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 }) + + const result = issueCardOnce(parsed.value, key.value) + if (result.replayed) { + const error = "This card was already issued. Its number is not shown again." + return NextResponse.json({ error, card: result.card }, { status: 409 }) + } + + const { card, number } = result + return NextResponse.json({ card, number }, { status: 201, headers: { "cache-control": "no-store" } }) +} 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..01dbc5e1 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link" + +export default function CardNotFound() { + return ( +
+

This card does not exist

+

+ Check the link, or find it in the list. + Cards issued before the console last restarted are not kept. +

+
+ ) +} 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..31233a9d --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/[id]/page.tsx @@ -0,0 +1,89 @@ +import { Divider } from "@/components/Divider" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { cardById } from "@/data/cards" +import { merchantById } from "@/data/merchants" +import { CARD_CATEGORY_LABELS, maskCardNumber, spendProgress } from "@/lib/cards" +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" + +export const dynamic = "force-dynamic" + +const EVENT_LABELS = { issued: "Card issued", frozen: "Frozen", unfrozen: "Unfrozen", cancelled: "Cancelled" } +const heading = "mt-6 text-sm font-semibold text-gray-900 dark:text-gray-50" + +export default async function CardDetail({ params }: { params: Promise<{ id: string }> }) { + const card = cardById((await params).id) + if (!card) notFound() + + const merchant = merchantById(card.merchantId)! + const money = (minor: number) => formatMoney(minor, card.currency) + const { percent, nearLimit } = spendProgress(card.spent, card.spendLimit) + const fields: [label: string, value: string, mono?: boolean][] = [ + ["Merchant", `${merchant.name} · ${merchant.country}`], + ["Currency", card.currency], + ["Category lock", card.categoryLock ? CARD_CATEGORY_LABELS[card.categoryLock] : "None: any category"], + ["Spend limit", money(card.spendLimit)], + ["Spent", money(card.spent)], + ["Remaining", money(Math.max(0, card.spendLimit - card.spent))], + ["Reference", card.reference, true], + [`Created (${merchant.timezone})`, formatInZone(card.createdAt, merchant.timezone)], + ] + + return ( +
+ + ← All cards + +
+

{card.nickname}

+ +
+

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

+ + +
+ {fields.map(([label, value, mono]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+ +

Spend

+

+ {money(card.spent)} of {money(card.spendLimit)} spent + {percent}% used +

+ + {card.spent === 0 && ( +

+ No spend recorded. The console is not connected to a card network yet, so spend stays at + zero until authorizations exist. +

+ )} + +

History

+
    + {card.events.map((event, index) => ( +
  1. +

    {EVENT_LABELS[event.type]}

    +

    {formatInZone(event.at, merchant.timezone)}

    +
  2. + ))} +
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/card-actions.tsx b/build-battle/merchant-console/src/app/cards/card-actions.tsx new file mode 100644 index 00000000..282b1300 --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/card-actions.tsx @@ -0,0 +1,78 @@ +"use client" + +import { Button } from "@/components/Button" +import type { CardStatus } from "@/data/types" +import { useRouter } from "next/navigation" +import { useState } from "react" + +/** Freeze, unfreeze and a two-step cancel. */ +export function CardActions(props: { id: string; nickname: string; status: CardStatus }) { + const router = useRouter() + const [pending, setPending] = useState(false) + const [confirming, setConfirming] = useState(false) + const [error, setError] = useState(null) + + if (props.status === "cancelled") { + return — + } + + const update = async (status: CardStatus) => { + setPending(true) + setError(null) + try { + const response = await fetch(`/api/cards/${props.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ status }), + }) + if (!response.ok) { + const body = await response.json().catch(() => null) + return setError(body?.error ?? "The card could not be updated. Try again.") + } + setConfirming(false) + router.refresh() + } catch { + setError("The card could not be updated. Check your connection and try again.") + } finally { + setPending(false) + } + } + + const action = ( + label: string, + name: string, + onClick: () => void, + variant: "secondary" | "ghost" | "destructive" = "secondary", + ) => ( + + ) + + return ( +
+
+ {confirming ? ( + <> + {action("Keep card", "Keep", () => setConfirming(false))} + {action("Confirm cancel", "Confirm cancel", () => update("cancelled"), "destructive")} + + ) : ( + <> + {props.status === "active" + ? action("Freeze", "Freeze", () => update("frozen")) + : action("Unfreeze", "Unfreeze", () => update("active"))} + {action("Cancel card", "Cancel card", () => setConfirming(true), "ghost")} + + )} +
+ {error &&

{error}

} +
+ ) +} 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..604e5e4f --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/issue-card-drawer.tsx @@ -0,0 +1,226 @@ +"use client" + +import { Button } from "@/components/Button" +import { + Drawer, + DrawerBody, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/Drawer" +import { Input } from "@/components/Input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/Select" +import type { Currency } from "@/data/types" +import { CARD_CATEGORIES, CARD_CATEGORY_LABELS } from "@/lib/cards" +import { parseAmountToMinorUnits } from "@/lib/money" +import { Plus } from "lucide-react" +import { useRouter } from "next/navigation" +import { useState } from "react" + +/** Radix Select has no empty value, so "no lock" is a sentinel. */ +const NO_LOCK = "none" +const EMPTY = { nickname: "", merchantId: "", limit: "", category: NO_LOCK } + +function Field(props: { id: string; label: string; note?: string | null; error?: boolean; children: React.ReactNode }) { + return ( +
+ + {props.children} + {props.note && ( +

+ {props.note} +

+ )} +
+ ) +} + +function Choice(props: { + id: string + value: string + onChange: (value: string) => void + options: [value: string, label: string][] + placeholder?: string + noted?: boolean +}) { + return ( + + ) +} + +export function IssueCardDrawer(props: { + merchants: { id: string; name: string; currency: Currency }[] + maxNicknameLength: number +}) { + const router = useRouter() + const [open, setOpen] = useState(false) + const [form, setForm] = useState(EMPTY) + const [limitError, setLimitError] = useState(null) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + // The number lives here only until the drawer closes. + const [issued, setIssued] = useState<{ nickname: string; number: string } | null>(null) + // One key per form: a retry or double click issues at most one card. + const [idempotencyKey, setIdempotencyKey] = useState(() => crypto.randomUUID()) + + const set = (changes: Partial) => setForm((f) => ({ ...f, ...changes })) + const currency = props.merchants.find((m) => m.id === form.merchantId)?.currency + + const onOpenChange = (next: boolean) => { + // Closing mid-request would drop the one response that carries the number. + if (!next && submitting) return + setOpen(next) + if (next) return + if (issued) router.refresh() + setForm(EMPTY) + setLimitError(null) + setError(null) + setIssued(null) + setIdempotencyKey(crypto.randomUUID()) + } + + const submit = async (event: React.FormEvent) => { + event.preventDefault() + if (submitting) return + setError(null) + setLimitError(null) + if (!form.nickname.trim()) return setError("Nickname is required.") + if (!currency) return setError("Choose a merchant.") + // Converted once, at the boundary; the server re-validates. + const spendLimit = parseAmountToMinorUnits(form.limit) + if (!spendLimit) return setLimitError("Enter an amount like 250.00, greater than zero.") + + setSubmitting(true) + try { + const { nickname, merchantId, category } = form + const response = await fetch("/api/cards", { + method: "POST", + headers: { "content-type": "application/json", "idempotency-key": idempotencyKey }, + body: JSON.stringify({ + nickname, + merchantId, + spendLimit, + currency, + categoryLock: category === NO_LOCK ? null : category, + }), + }) + const body = await response.json().catch(() => null) + if (response.ok && body?.number) setIssued({ nickname: body.card.nickname, number: body.number }) + else setError(body?.error ?? "The card could not be issued. Try again.") + } catch { + setError("The card could not be issued. Check your connection and try again.") + } finally { + setSubmitting(false) + } + } + + return ( + + + + + + + {issued ? "Card issued" : "Issue card"} + {issued ? "Copy the number now." : "One merchant, one limit."} + + + {issued ? ( + <> + +

{issued.nickname}

+
+

Card number

+

+ {issued.number.replace(/(\d{4})(?=\d)/g, "$1 ")} +

+
+

+ The full number is shown only this once. +

+
+ + + + + ) : ( +
+ + + set({ nickname: e.target.value })} + /> + + + [m.id, m.name])} + onChange={(merchantId) => set({ merchantId })} + noted={!!currency} + /> + + + set({ limit: e.target.value })} + hasError={!!limitError} + aria-invalid={!!limitError} + aria-describedby={limitError ? "card-limit-note" : undefined} + /> + + + [c, CARD_CATEGORY_LABELS[c]] as [string, string])]} + onChange={(category) => set({ category })} + noted + /> + + {error &&

{error}

} +
+ + + +
+ )} +
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/cards/page.tsx b/build-battle/merchant-console/src/app/cards/page.tsx new file mode 100644 index 00000000..dde5156b --- /dev/null +++ b/build-battle/merchant-console/src/app/cards/page.tsx @@ -0,0 +1,92 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRoot, + TableRow, +} from "@/components/Table" +import { StatusBadge } from "@/components/ui/payments/StatusBadge" +import { listCards, MAX_NICKNAME_LENGTH } from "@/data/cards" +import { merchantById, merchants } from "@/data/merchants" +import { CARD_CATEGORY_LABELS, maskCardNumber } from "@/lib/cards" +import { formatDate } from "@/lib/dates" +import { formatMoney } from "@/lib/money" +import Link from "next/link" +import { CardActions } from "./card-actions" +import { IssueCardDrawer } from "./issue-card-drawer" + +export const dynamic = "force-dynamic" + +const COLUMNS = ["Card", "Merchant", "Number", "Category", "Spend limit", "Status", "Created", ""] + +export default function CardsPage() { + const cards = listCards() + + return ( +
+
+

Virtual cards

+ +
+ + + + + + {COLUMNS.map((column) => ( + + {column || Actions} + + ))} + + + + {cards.length === 0 && ( + + +

No cards issued yet

+

+ Use Issue card to create a virtual card for a merchant. +

+
+
+ )} + {cards.map((card) => ( + + + + {card.nickname} + + + {merchantById(card.merchantId)?.name} + {maskCardNumber(card.last4)} + + {card.categoryLock ? CARD_CATEGORY_LABELS[card.categoryLock] : "Any"} + + + {formatMoney(card.spendLimit, card.currency)} + + + + + {formatDate(card.createdAt)} + + + + + ))} +
+
+
+ +

+ {cards.length} {cards.length === 1 ? "card" : "cards"} +

+
+ ) +} diff --git a/build-battle/merchant-console/src/app/siteConfig.ts b/build-battle/merchant-console/src/app/siteConfig.ts index c59e5da2..08c5d3d7 100644 --- a/build-battle/merchant-console/src/app/siteConfig.ts +++ b/build-battle/merchant-console/src/app/siteConfig.ts @@ -7,6 +7,7 @@ export const siteConfig = { payments: "/payments", disputes: "/disputes", payouts: "/payouts", + cards: "/cards", }, } diff --git a/build-battle/merchant-console/src/components/Drawer.tsx b/build-battle/merchant-console/src/components/Drawer.tsx index fc9a5539..85114676 100644 --- a/build-battle/merchant-console/src/components/Drawer.tsx +++ b/build-battle/merchant-console/src/components/Drawer.tsx @@ -112,6 +112,7 @@ const DrawerHeader = React.forwardRef<