Skip to contentVibraUI

Customer

Who placed the order, where it ships, how many orders they have placed and what they have spent on the ones they kept; reads orderRecord(id).

Preview

Install

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

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

Source

app/ecommerce/orders/[id]/components/order-customer.tsx
"use client"

import Link from "next/link"
import { ArrowUpRightIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { customerHref, isSold } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatCurrency, formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { DescriptionList } from "@/components/ui/description-list"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"

import { type OrderRecord } from "../data"
import { useOrder } from "./order-context"

export type OrderCustomerProps = Pick<OrderRecord, "customer" | "lifetime"> & {
  /** This order's total in whole dollars, which Spent counts while the order is kept. */
  total: number
}

/**
 * Who placed the order: their face, their account, where it ships, and what
 * they have done with the store — every order they have placed, and what
 * they spent on the ones they kept, paid or fulfilled. The other orders are
 * read when the page is; this one counts by its live status, so a refund
 * made on this page takes it out of Spent in the same render as the badge.
 */
export function OrderCustomer({ customer, lifetime, total }: OrderCustomerProps) {
  const { state } = useOrder()

  if (!customer) {
    return (
      <Widget data-widget="widget-ecommerce-order-order-customer" title="Customer" description="Not on the books">
        <p className="text-sm text-muted-foreground">The account that placed this order has been removed.</p>
      </Widget>
    )
  }

  const spent = lifetime.spentOnOthers + (isSold(state) ? total : 0)

  return (
    <Widget
      data-widget="widget-ecommerce-order-order-customer"
      title="Customer"
      description={customer.company}
      footer={
        <Link
          href={customerHref(customer.id)}
          className={cn(buttonVariants({ variant: "link", size: "sm" }), "h-auto px-0")}
        >
          Open customer record
          <ArrowUpRightIcon data-icon="inline-end" aria-hidden="true" />
        </Link>
      }
    >
      <div className="flex flex-col gap-4">
        <UserCell name={customer.name} description={customer.email} src={customer.avatarUrl} />
        <DescriptionList
          size="sm"
          items={[
            { term: "Ships to", description: customer.country },
            { term: "Orders", description: <span className="tabular-nums">{formatNumber(lifetime.orders)}</span> },
            {
              term: "Spent",
              description: <span className="tabular-nums">{formatCurrency(spent, "USD", { maximumFractionDigits: 0 })}</span>,
            },
          ]}
        />
      </div>
    </Widget>
  )
}
app/ecommerce/orders/[id]/data.ts
/**
 * What one order's page reads. The order is a `db.orders` row; its lines are
 * resolved against `db.products` by id when the page is read — a product the
 * catalogue no longer holds prints as a discontinued item at the price the
 * order stored — the parcel is its `db.shipments` row, the customer their
 * `db.customers` row, and the card on file their default `db.paymentMethods`
 * row. Every line is priced at what the order charged, never today's list
 * price, so the lines always add up to the order's total. "Now" is
 * `REFERENCE_DATE`; nothing here reads a clock.
 */
import { isSold } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import {
  db,
  REFERENCE_DATE,
  type Member,
  type Order,
  type ShipmentException,
  type ShipmentStageName,
} from "@/lib/sample-data"

/** What the page prints for a line whose product has left the catalogue. */
export const DISCONTINUED = "Discontinued item"

/** One line of the order, as the items table prints it. */
export type OrderLine = {
  productId: string
  name: string
  /** Absent for a discontinued item: the catalogue no longer knows it. */
  sku?: string
  category?: string
  qty: number
  /** What one unit cost on this order, in whole dollars. */
  unit: number
  total: number
}

export type OrderParcel = {
  id: string
  carrier: string
  service: "standard" | "express" | "overnight"
  tracking?: string
  destination: string
  weightGrams: number
  stages: { name: ShipmentStageName; at?: Date }[]
  stage: ShipmentStageName
  exception?: ShipmentException
}

/** The order and everything the page says about it, gathered in one read. */
export type OrderRecord = {
  order: {
    id: string
    number: string
    status: Order["status"]
    placedAt: Date
    fulfilledAt?: Date
    paymentMethod: Order["paymentMethod"]
    country: string
    /** In whole dollars. */
    total: number
    /** What the lines came to before any discount or tax, in whole dollars. */
    subtotal: number
    /** Taken off by a code at the till, in whole dollars; absent when nothing was. */
    discount?: number
    /** Sales tax charged at the till, in whole dollars; absent on a web order. */
    tax?: number
  }
  customer?: { id: string; name: string; company: string; email: string; avatarUrl?: string; country: string }
  /** The default card on file, when the order was paid by card and one is on file. */
  card?: { brand: string; last4: string }
  lines: OrderLine[]
  parcel?: OrderParcel
  /** The customer's other orders, newest first — five at most. */
  others: { id: string; number: string; status: Order["status"]; placedAt: Date; total: number }[]
  /**
   * The customer's account with the store, read when the page is: `orders`
   * is every order they have placed, this one included, whatever became of
   * it; `spentOnOthers` is what the others they kept — paid or fulfilled —
   * came to, in whole dollars. This order's own total is left out, because
   * the page adds it from the order's live status: a refund made there takes
   * it out of what they spent in the same render.
   */
  lifetime: { orders: number; spentOnOthers: number }
}

/** The customer's other orders the card lists. */
const OTHERS_SHOWN = 5

/**
 * The order the page falls back to when it is rendered with no route param —
 * which is what the docs preview does: the newest paid order whose parcel is
 * still on its way and not held, because that is an order both buttons have
 * something to do to. Read per request, like every row here, so an order
 * refunded or fulfilled since hands the preview on to the next one.
 */
export function fallbackId(): string {
  const parcels = new Map(db.shipments.all().map((parcel) => [parcel.orderId, parcel]))
  const orders = db.orders.all()
  const paid = orders
    .filter((order) => order.status === "paid")
    .sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
  const moving = paid.find((order) => {
    const parcel = parcels.get(order.id)
    return parcel && parcel.stage !== "delivered" && !parcel.exception
  })
  return (moving ?? paid[0] ?? orders[0]).id
}

/** Every order id, for `generateStaticParams`. */
export function orderIds(): string[] {
  return db.orders.all().map((order) => order.id)
}

/** The whole record, or undefined when the id names no order. */
export async function orderRecord(id: string): Promise<OrderRecord | undefined> {
  const order = await db.orders.get(id)
  if (!order) return undefined

  // Read when the page is, not when the module loads: a product archived or
  // removed since then has to show up on the very next render.
  const products = new Map(db.products.all().map((product) => [product.id, product]))
  const lines = order.items.map((item): OrderLine => {
    const product = products.get(item.productId)
    return {
      productId: item.productId,
      name: product?.name ?? DISCONTINUED,
      sku: product?.sku,
      category: product?.category,
      qty: item.qty,
      unit: item.unitCents / 100,
      total: (item.qty * item.unitCents) / 100,
    }
  })

  const customer = db.customers.all().find((row) => row.id === order.customerId)
  const card =
    order.paymentMethod === "card"
      ? db.paymentMethods
          .all()
          .filter((method) => method.customerId === order.customerId)
          .sort((a, b) => Number(b.default) - Number(a.default))[0]
      : undefined
  const parcel = db.shipments.all().find((row) => row.orderId === order.id)
  // A walk-in sale at the till has no customer, and walk-ins are not one person.
  const theirs = db.orders
    .all()
    .filter((row) => (order.customerId ? row.customerId === order.customerId : row.id === order.id))
    .sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
  const keptElsewhere = theirs.filter((row) => row.id !== order.id && isSold(row))

  return {
    order: {
      id: order.id,
      number: order.number,
      status: order.status,
      placedAt: order.placedAt,
      fulfilledAt: order.fulfilledAt,
      paymentMethod: order.paymentMethod,
      country: order.country,
      total: order.totalCents / 100,
      subtotal: order.items.reduce((sum, item) => sum + item.qty * item.unitCents, 0) / 100,
      discount: order.discountCents ? order.discountCents / 100 : undefined,
      tax: order.taxCents ? order.taxCents / 100 : undefined,
    },
    customer: customer
      ? {
          id: customer.id,
          name: customer.name,
          company: customer.company,
          email: customer.email,
          avatarUrl: customer.avatarUrl,
          country: customer.country,
        }
      : undefined,
    card: card ? { brand: card.brand, last4: card.last4 } : undefined,
    lines,
    parcel: parcel
      ? {
          id: parcel.id,
          carrier: parcel.carrier,
          service: parcel.service,
          tracking: parcel.tracking,
          destination: parcel.destination,
          weightGrams: parcel.weightGrams,
          stages: parcel.stages,
          stage: parcel.stage,
          exception: parcel.exception,
        }
      : undefined,
    others: theirs
      .filter((row) => row.id !== order.id)
      .slice(0, OTHERS_SHOWN)
      .map((row) => ({
        id: row.id,
        number: row.number,
        status: row.status,
        placedAt: row.placedAt,
        total: row.totalCents / 100,
      })),
    lifetime: {
      orders: theirs.length,
      spentOnOthers: keptElsewhere.reduce((sum, row) => sum + row.totalCents, 0) / 100,
    },
  }
}

/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
  return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
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
}

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