Payment
How the order was paid for, what was charged, refunded and kept, read off its live status; reads orderRecord(id).
Preview
"use client"
import { formatCurrency, formatDate } from "@/lib/format"
import { DescriptionList } from "@/components/ui/description-list"
import { Widget } from "@/components/ui/widget"
import { useOrder } from "./order-context"
import { CARD_BRANDS, METHOD_LABELS } from "./order-vocabulary"
export type OrderPaymentProps = {
method: string
card?: { brand: string; last4: string }
total: number
placedAt: Date
}
/**
* How the order was paid for and where the money stands. The headline reads
* off the order's live status, so a refund here says what went back the
* moment the server confirms it; the net is what the store keeps.
*/
export function OrderPayment({ method, card, total, placedAt }: OrderPaymentProps) {
const { state } = useOrder()
const taken = state.status === "paid" || state.status === "fulfilled" || state.status === "refunded"
const refunded = state.status === "refunded"
const headline =
state.status === "pending"
? "Awaiting payment"
: state.status === "cancelled"
? "No payment taken"
: refunded
? `Refunded ${formatCurrency(total)}`
: `Captured ${formatCurrency(total)}`
return (
<Widget data-widget="widget-ecommerce-order-order-payment" title="Payment" description={headline}>
<DescriptionList
size="sm"
items={[
{
term: "Method",
description:
method === "card" && card ? (
<span className="flex items-center gap-2">
{CARD_BRANDS[card.brand] ?? card.brand}
<span className="font-mono text-xs">•••• {card.last4}</span>
</span>
) : (
(METHOD_LABELS[method] ?? method)
),
},
{ term: "Placed", description: formatDate(placedAt, "medium", { timeZone: "UTC" }) },
{ term: "Charged", description: <span className="tabular-nums">{taken ? formatCurrency(total) : "—"}</span> },
...(refunded
? [{ term: "Refunded", description: <span className="tabular-nums">−{formatCurrency(total)}</span> }]
: []),
{
term: "Kept",
description: (
<span className="font-medium tabular-nums">
{formatCurrency(taken && !refunded ? total : 0)}
</span>
),
},
]}
/>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-ecommerce-order-order-paymentNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import { formatCurrency, formatDate } from "@/lib/format"
import { DescriptionList } from "@/components/ui/description-list"
import { Widget } from "@/components/ui/widget"
import { useOrder } from "./order-context"
import { CARD_BRANDS, METHOD_LABELS } from "./order-vocabulary"
export type OrderPaymentProps = {
method: string
card?: { brand: string; last4: string }
total: number
placedAt: Date
}
/**
* How the order was paid for and where the money stands. The headline reads
* off the order's live status, so a refund here says what went back the
* moment the server confirms it; the net is what the store keeps.
*/
export function OrderPayment({ method, card, total, placedAt }: OrderPaymentProps) {
const { state } = useOrder()
const taken = state.status === "paid" || state.status === "fulfilled" || state.status === "refunded"
const refunded = state.status === "refunded"
const headline =
state.status === "pending"
? "Awaiting payment"
: state.status === "cancelled"
? "No payment taken"
: refunded
? `Refunded ${formatCurrency(total)}`
: `Captured ${formatCurrency(total)}`
return (
<Widget data-widget="widget-ecommerce-order-order-payment" title="Payment" description={headline}>
<DescriptionList
size="sm"
items={[
{
term: "Method",
description:
method === "card" && card ? (
<span className="flex items-center gap-2">
{CARD_BRANDS[card.brand] ?? card.brand}
<span className="font-mono text-xs">•••• {card.last4}</span>
</span>
) : (
(METHOD_LABELS[method] ?? method)
),
},
{ term: "Placed", description: formatDate(placedAt, "medium", { timeZone: "UTC" }) },
{ term: "Charged", description: <span className="tabular-nums">{taken ? formatCurrency(total) : "—"}</span> },
...(refunded
? [{ term: "Refunded", description: <span className="tabular-nums">−{formatCurrency(total)}</span> }]
: []),
{
term: "Kept",
description: (
<span className="font-medium tabular-nums">
{formatCurrency(taken && !refunded ? total : 0)}
</span>
),
},
]}
/>
</Widget>
)
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import {
db,
REFERENCE_DATE,
SHIPMENT_STAGES,
type Order,
type Result,
type ShipmentStage,
type ShipmentStageName,
} from "@/lib/sample-data"
/**
* The two things one order's page does to it, and the sign-out. Every answer
* is a `Result`, and every name in it — the order number, the amount — is the
* store's own, read off the row this action just looked up.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
const notFound = (id: string) => ({ ok: false as const, error: { code: "not_found", message: `No order with id "${id}".` } })
/**
* Sends the money back. Only an order that took a payment can be refunded: a
* pending one never took it, a cancelled one gave it up, and a refunded one
* has already had it returned.
*/
export async function refundOrder(id: string): Promise<Result<{ id: string; status: Order["status"]; refundedCents: number }>> {
const order = await db.orders.get(id)
if (!order) return notFound(id)
if (order.status === "refunded") {
return { ok: false, error: { code: "already_refunded", message: `${order.number} has already been refunded.` } }
}
if (order.status === "cancelled") {
return {
ok: false,
error: { code: "cancelled", message: `${order.number} was cancelled, so it never took a payment to send back.` },
}
}
if (order.status === "pending") {
return {
ok: false,
error: { code: "unpaid", message: `${order.number} has not been paid for, so there is nothing to send back.` },
}
}
const updated = await db.orders.update(id, { status: "refunded" })
if (!updated.ok) return updated
return { ok: true, data: { id, status: updated.data.status, refundedCents: updated.data.totalCents } }
}
const FULFIL_REFUSALS: Record<Exclude<Order["status"], "paid">, (number: string) => string> = {
pending: (number) => `${number} has not been paid for yet.`,
fulfilled: (number) => `${number} is already fulfilled.`,
refunded: (number) => `${number} was refunded, so there is nothing left to send.`,
cancelled: (number) => `${number} was cancelled, so there is nothing to send.`,
}
/** What a fulfilment answers: the order, and its parcel's stage when it has one. */
export type Fulfilment = {
id: string
status: Order["status"]
fulfilledAt: Date
stage?: ShipmentStageName
stages?: ShipmentStage[]
/** Whether this fulfilment moved the parcel on; false for a parcel that had already shipped. */
moved?: boolean
}
/**
* Says the goods have gone. Only a paid order can be fulfilled, and not while
* its parcel is held — an address that will not resolve, a label that never
* printed — because a held parcel has not gone anywhere. The order takes
* today's date as its fulfilment; its parcel, if there is one, is stamped
* through to "shipped" at the same instant, so the stage bar and the order
* never disagree about where the goods are. A parcel already at or past that
* stage keeps the stamps it has, and the answer says it was not moved.
*/
export async function markFulfilled(id: string): Promise<Result<Fulfilment>> {
const order = await db.orders.get(id)
if (!order) return notFound(id)
if (order.status !== "paid") {
return { ok: false, error: { code: "not_paid", message: FULFIL_REFUSALS[order.status](order.number) } }
}
const parcel = db.shipments.all().find((row) => row.orderId === id)
if (parcel?.exception) {
return {
ok: false,
error: { code: "held", message: `${order.number}'s parcel is held: ${parcel.exception.message}` },
}
}
const updated = await db.orders.update(id, { status: "fulfilled", fulfilledAt: REFERENCE_DATE })
if (!updated.ok) return updated
if (!parcel) return { ok: true, data: { id, status: updated.data.status, fulfilledAt: REFERENCE_DATE } }
const shipped = SHIPMENT_STAGES.indexOf("shipped")
const reached = SHIPMENT_STAGES.indexOf(parcel.stage)
if (reached >= shipped) {
return {
ok: true,
data: { id, status: updated.data.status, fulfilledAt: REFERENCE_DATE, stage: parcel.stage, stages: parcel.stages, moved: false },
}
}
const stages = parcel.stages.map((stage, index) =>
index <= shipped && !stage.at ? { name: stage.name, at: REFERENCE_DATE } : stage
)
const stamped = await db.shipments.update(parcel.id, { stages, stage: "shipped" })
if (!stamped.ok) return stamped
return {
ok: true,
data: { id, status: updated.data.status, fulfilledAt: REFERENCE_DATE, stage: stamped.data.stage, stages: stamped.data.stages, moved: true },
}
}"use client"
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import type { Order, ShipmentStage, ShipmentStageName } from "@/lib/sample-data"
import { markFulfilled, refundOrder } from "../actions"
/** The part of the order its two actions can change, held once for every card that prints it. */
export type OrderState = {
status: Order["status"]
fulfilledAt?: Date
stage?: ShipmentStageName
stages?: ShipmentStage[]
}
type OrderContextValue = {
state: OrderState
/** What the last action said: a success for the status line, a refusal for the alert. Never both. */
notice: string
refusal: string | null
refund: () => Promise<void>
fulfil: () => Promise<void>
}
const OrderContext = React.createContext<OrderContextValue | null>(null)
export type OrderProviderProps = {
id: string
number: string
initial: OrderState
children: React.ReactNode
}
/**
* Holds what the server last said about the order, and runs its two actions.
* Each action's `Result` is read back into this state — the badge, the payment
* card and the parcel's stage bar all print from here — so a refund or a
* fulfilment shows everywhere at once, and a refusal leaves every card as it was.
*/
export function OrderProvider({ id, number, initial, children }: OrderProviderProps) {
const [state, setState] = React.useState(initial)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState<string | null>(null)
const refund = React.useCallback(async () => {
setNotice("")
setRefusal(null)
const result = await refundOrder(id)
if (!result.ok) return setRefusal(result.error.message)
setState((current) => ({ ...current, status: result.data.status }))
setNotice(`Refunded ${formatCurrency(result.data.refundedCents / 100)} on ${number}. It is on its way back to the customer.`)
}, [id, number])
const fulfil = React.useCallback(async () => {
setNotice("")
setRefusal(null)
const result = await markFulfilled(id)
if (!result.ok) return setRefusal(result.error.message)
const { status, fulfilledAt, stage, stages, moved } = result.data
setState((current) => ({ ...current, status, fulfilledAt, ...(stage ? { stage, stages } : {}) }))
// Only a parcel this fulfilment moved is "marked"; one that had already
// gone is said to have, so the line never claims a change it did not make.
setNotice(
!stage
? `${number} is fulfilled.`
: moved
? `${number} is fulfilled, and its parcel is marked ${stage}.`
: `${number} is fulfilled. Its parcel was already ${stage}.`
)
}, [id, number])
const value = React.useMemo(
() => ({ state, notice, refusal, refund, fulfil }),
[state, notice, refusal, refund, fulfil]
)
return <OrderContext.Provider value={value}>{children}</OrderContext.Provider>
}
export function useOrder(): OrderContextValue {
const context = React.useContext(OrderContext)
if (!context) throw new Error("useOrder must be used inside <OrderProvider>.")
return context
}/**
* The words this page alone puts on an order's payment and its parcel. The
* words every E-commerce page shares — the status tones, the routes — come
* from the dashboard's vocabulary. Vocabulary, not data: it lives beside the
* islands that print it, because `data.ts` reads `db` and must never reach
* the browser.
*/
export const METHOD_LABELS: Record<string, string> = {
card: "Card",
wallet: "Wallet",
bank: "Bank transfer",
cash: "Cash",
voucher: "Voucher",
}
export const CARD_BRANDS: Record<string, string> = { visa: "Visa", mastercard: "Mastercard", amex: "Amex" }
export const STAGE_LABELS: Record<string, string> = {
picking: "Picking",
packing: "Packing",
shipped: "Shipped",
delivered: "Delivered",
}
export const SERVICE_LABELS: Record<string, string> = { standard: "Standard", express: "Express", overnight: "Overnight" }Its page
On its page the card sits among the rest of the dashboard and shares its range and its data with them.
From the Order record page