Skip to contentVibraUI

Payment

How the order was paid for, what was charged, refunded and kept, read off its live status; reads orderRecord(id).

Preview

Install

npx shadcn@latest add @vibra/widget-ecommerce-order-order-payment

Needs the @vibra registry in your components.json — set it up once.

Source

app/ecommerce/orders/[id]/components/order-payment.tsx
"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>
  )
}
app/ecommerce/orders/[id]/actions.ts
"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 },
  }
}
app/ecommerce/orders/[id]/components/order-context.tsx
"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
}
app/ecommerce/orders/[id]/components/order-vocabulary.ts
/**
 * 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