Skip to content
Closed
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
40 changes: 40 additions & 0 deletions build-battle/merchant-console/src/app/api/cards/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { transitionCard } from "@/data/cards"
import { CARD_STATUSES } from "@/lib/cards"
import { CardStatus } from "@/data/types"
import { NextRequest, NextResponse } from "next/server"

/**
* Moves a card through the status state machine: active ⇄ frozen, either to
* cancelled, and cancelled is terminal. The transition is guarded here, not
* only in the UI.
*/
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 NextResponse.json(
{ error: "Request body must be JSON." },
{ status: 400 },
)
}

const status = (body as { status?: unknown } | null)?.status
if (!CARD_STATUSES.includes(status as CardStatus)) {
return NextResponse.json(
{ error: "Status must be active, frozen, or cancelled." },
{ status: 400 },
)
}

const result = transitionCard(id, status as CardStatus)
if ("error" in result) {
return NextResponse.json({ error: result.error }, { status: result.status })
}
return NextResponse.json({ card: result.card })
}
34 changes: 34 additions & 0 deletions build-battle/merchant-console/src/app/api/cards/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createCard, listCards } from "@/data/cards"
import { parseCardInput } from "@/lib/cards"
import { NextRequest, NextResponse } from "next/server"

/** Every issued card, masked. Card records never carry a full number. */
export function GET() {
return NextResponse.json({ rows: listCards() })
}

/**
* Issues a virtual card (NWP-201). The body is validated against allowlists
* before it reaches the store, and the full number is returned here and
* nowhere else. A repeated requestId returns the existing card without the
* number, so a double submit cannot mint two cards.
*/
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
{ error: "Request body must be JSON." },
{ status: 400 },
)
}

const parsed = parseCardInput(body)
if ("error" in parsed) {
return NextResponse.json({ error: parsed.error }, { status: 400 })
}

const { card, number, created } = createCard(parsed.input)
return NextResponse.json({ card, number }, { status: created ? 201 : 200 })
}
141 changes: 141 additions & 0 deletions build-battle/merchant-console/src/app/cards/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Divider } from "@/components/Divider"
import { StatusBadge } from "@/components/ui/payments/StatusBadge"
import { cardById } from "@/data/cards"
import { merchantById } from "@/data/merchants"
import { CardEvent } from "@/data/types"
import { CATEGORY_LABELS, maskCard } from "@/lib/cards"
import { formatInZone } from "@/lib/dates"
import { formatMoney } from "@/lib/money"
import Link from "next/link"
import { notFound } from "next/navigation"
import { CardActions } from "../card-actions"
import { SpendBar } from "../spend-bar"

export const dynamic = "force-dynamic"

const EVENT_LABELS: Record<CardEvent["type"], string> = {
issued: "Card issued",
frozen: "Frozen",
unfrozen: "Unfrozen",
cancelled: "Cancelled",
}

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 (
<div className="p-4 sm:p-6">
<Link
href="/cards"
className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-gray-50"
>
← All cards
</Link>

<div className="mt-4 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
{card.nickname}
</h1>
<span className="font-mono text-sm text-gray-500">
{maskCard(card.last4)}
</span>
<StatusBadge status={card.status} />
</div>
<p className="mt-1 font-mono text-sm text-gray-500">{card.id}</p>

<div className="mt-4">
<CardActions
cardId={card.id}
status={card.status}
nickname={card.nickname}
/>
</div>

<Divider />

<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-50">
Spend
</h2>
<div className="mt-4 max-w-xl">
<SpendBar
spent={card.spent}
limit={card.limit}
currency={card.currency}
/>
</div>

<Divider />

<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<Field label="Merchant">
{merchant.name}
<span className="ml-2 text-gray-500">{merchant.country}</span>
</Field>
<Field label="Category lock">{CATEGORY_LABELS[card.category]}</Field>
<Field label="Number">
<span className="font-mono">{maskCard(card.last4)}</span>
</Field>
<Field label="Spend limit">
<span className="tabular-nums">
{formatMoney(card.limit, card.currency)}
</span>
</Field>
<Field label="Created (UTC)">
<span className="font-mono text-sm">{card.createdAt}</span>
</Field>
<Field label={`Created (${merchant.timezone})`}>
{formatInZone(card.createdAt, merchant.timezone)}
</Field>
</dl>

<Divider />

<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-50">
History
</h2>
<ol className="mt-4 space-y-4">
{card.events.map((event, index) => (
<li key={index} className="flex gap-3">
<span
className="mt-1.5 size-2 shrink-0 rounded-full bg-blue-500"
aria-hidden="true"
/>
<div>
<p className="text-sm text-gray-900 dark:text-gray-50">
{EVENT_LABELS[event.type]}
</p>
<p className="text-sm text-gray-500">
{formatInZone(event.at, merchant.timezone)}
</p>
</div>
</li>
))}
</ol>
</div>
)
}

function Field({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div>
<dt className="text-sm text-gray-500">{label}</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-50">
{children}
</dd>
</div>
)
}
128 changes: 128 additions & 0 deletions build-battle/merchant-console/src/app/cards/card-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"use client"

import { Button } from "@/components/Button"
import { CardStatus } from "@/data/types"
import { canTransition } from "@/lib/cards"
import { useRouter } from "next/navigation"
import { useState, useTransition } from "react"

/**
* Freeze, unfreeze, and cancel a card in place. Each action goes through the
* guarded PATCH route and then refreshes the server-rendered page data
* without a full reload. Cancel asks for confirmation first, because it is
* terminal.
*/
export function CardActions({
cardId,
status,
nickname,
}: {
cardId: string
status: CardStatus
nickname: string
}) {
const router = useRouter()
const [pending, startTransition] = useTransition()
const [busy, setBusy] = useState(false)
const [confirmingCancel, setConfirmingCancel] = useState(false)
const [error, setError] = useState<string | null>(null)

if (status === "cancelled") {
return <span className="text-sm text-gray-500">No actions</span>
}

const transition = async (to: CardStatus) => {
setBusy(true)
setError(null)
try {
const response = await fetch(`/api/cards/${cardId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: to }),
})
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
error?: string
} | null
setError(body?.error ?? "The card could not be updated. Try again.")
return
}
setConfirmingCancel(false)
startTransition(() => router.refresh())
} catch {
setError(
"Could not reach the server. Check your connection and try again.",
)
} finally {
setBusy(false)
}
}

const working = busy || pending

if (confirmingCancel) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-700 dark:text-gray-300">
Cancel {nickname}? This cannot be undone.
</span>
<Button
variant="destructive"
className="py-1"
isLoading={working}
loadingText="Cancelling"
onClick={() => transition("cancelled")}
>
Confirm cancel
</Button>
<Button
variant="secondary"
className="py-1"
disabled={working}
onClick={() => {
setConfirmingCancel(false)
setError(null)
}}
>
Keep card
</Button>
{error && (
<p role="alert" className="text-sm text-red-600 dark:text-red-500">
{error}
</p>
)}
</div>
)
}

const next: CardStatus = status === "active" ? "frozen" : "active"

return (
<div className="flex flex-wrap items-center gap-2">
{canTransition(status, next) && (
<Button
variant="secondary"
className="py-1"
isLoading={working}
loadingText={next === "frozen" ? "Freezing" : "Unfreezing"}
onClick={() => transition(next)}
>
{next === "frozen" ? "Freeze" : "Unfreeze"}
</Button>
)}
<Button
variant="ghost"
className="py-1 text-red-600 dark:text-red-500"
disabled={working}
onClick={() => setConfirmingCancel(true)}
>
Cancel card
</Button>
{error && (
<p role="alert" className="text-sm text-red-600 dark:text-red-500">
{error}
</p>
)}
</div>
)
}
Loading