Skip to contentVibraUI
Part of the E-commerce dashboardinstalls at /ecommerce/products/[id]

Product record

One product's page: its price and stock, twelve weeks of units sold against their own average, the orders that hold it with each customer's face, and a form that edits the price and the stock — and refuses a negative.

Open the live page

A real dynamic route: the page installs at app/ecommerce/products/[id]/page.tsx and ships generateStaticParams over db.products.all(); an id the catalogue does not hold is notFound(). Its params are optional, so the docs preview — which renders the default export with no props — falls back to the active product that sold the most units in the last twelve weeks. Sales are read off db.orders lines by the dashboard's rule for a sale (vocabulary-ecommerce's salesBetween: paid and fulfilled orders only), and a line's revenue is what it charged at the time rather than today's price, so editing the price never rewrites history; the headline units and revenue are the sums of the chart's twelve weekly buckets, so the bars always add up to the tiles, measured against the twelve weeks before for their pills, and the chart's caption carries the weekly average. The Details card draws the product with ProductArt, and dates First sold from the oldest sale, not from the oldest order that holds it. The order list is every order that holds the product, whatever became of it, newest first, each number linking to the order's page. The form sends the price and stock as typed to updateProduct, a server action returning Result that checks them itself — a negative price or stock, a fraction of a unit and an empty box are refused with the field they were about, which the form marks aria-invalid and describes by the danger callout beside it — and on success the figures at the top take the saved values from a small provider. Composes AppShell, PageHeader, StatusBadge, StatCardGroup, StatCard, DashboardGrid, ChartCard, BarChart, Widget, ProductArt, DescriptionList, SimpleTable, UserCell, Field, FieldLabel, FieldDescription, Input, AsyncButton, Callout, ProductProvider (block-local) and ProductEdit (block-local).

Preview

Install

npx shadcn@latest add @vibra/ecommerce-product

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

Source

app/ecommerce/products/[id]/page.tsx
import { notFound } from "next/navigation"

import {
  isSold,
  PRODUCT_STATUS_LABELS,
  PRODUCT_STATUS_MAP,
  productHref,
} from "@/lib/dashboards/ecommerce/vocabulary"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { StatusBadge } from "@/components/ui/status-badge"

import { signOut } from "./actions"
import { ProductProvider } from "./components/product-context"
import { ProductDetails } from "./components/product-details"
import { ProductEdit } from "./components/product-edit"
import { ProductFigures } from "./components/product-figures"
import { ProductOrders } from "./components/product-orders"
import { UnitsChart } from "./components/units-chart"
import {
  currentUser,
  fallbackId,
  lastUpdated,
  productIds,
  productRecord,
  shellNotifications,
  WEEKS,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"

/** One static page per product, which is what makes this a real detail route. */
export function generateStaticParams() {
  return productIds().map((id) => ({ id }))
}

/**
 * One product. The route is `/ecommerce/products/[id]`, and `params` is
 * optional so the docs preview — which renders the default export with no
 * props at all — still has a product to show: without one it falls back to
 * the best seller of the last twelve weeks.
 */
export default async function ProductPage({ params }: { params?: Promise<{ id: string }> }) {
  const { id } = (await params) ?? { id: fallbackId() }
  const record = await productRecord(id)
  if (!record) notFound()

  const { product, orders } = record
  // The oldest order the sales figures count, not the oldest that holds it: a
  // cancelled cart or an unpaid order never sold anything.
  const firstSold = orders.filter(isSold).at(-1)?.placedAt

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.product}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
      breadcrumbs={[
        { title: NAV.brand.name, href: NAV.brand.href },
        { title: "Products", href: ROUTES.products },
        { title: product.name, href: productHref(product.id) },
      ]}
    >
      <ProductProvider initial={{ priceCents: product.priceCents, stock: product.stock }}>
        <PageHeader
          backHref={ROUTES.products}
          title={product.name}
          badge={
            <StatusBadge
              status={product.status}
              map={PRODUCT_STATUS_MAP}
              label={PRODUCT_STATUS_LABELS[product.status]}
            />
          }
          description={`${product.category} · supplied by ${product.supplier}`}
          meta={
            <>
              <span className="font-mono">{product.sku}</span>
              <span>{lastUpdated()}</span>
            </>
          }
        />

        <ProductFigures
          reorderPoint={product.reorderPoint}
          weeks={WEEKS}
          sales={record.sales}
          previous={record.previous}
        />

        <DashboardGrid>
          {/* The split starts at xl: at lg, with the sidebar open, a third of
              the page is too narrow for a term and its value side by side. */}
          <DashboardGridItem colSpan={{ base: 12, xl: 8 }}>
            <UnitsChart weeks={record.weeks} />
          </DashboardGridItem>
          <DashboardGridItem colSpan={{ base: 12, xl: 4 }}>
            <ProductDetails product={product} orderCount={orders.length} firstSold={firstSold} />
          </DashboardGridItem>

          <DashboardGridItem colSpan={{ base: 12, xl: 8 }}>
            <ProductOrders orders={orders} />
          </DashboardGridItem>
          <DashboardGridItem colSpan={{ base: 12, xl: 4 }}>
            <ProductEdit id={product.id} name={product.name} />
          </DashboardGridItem>
        </DashboardGrid>
      </ProductProvider>
    </AppShell>
  )
}
app/ecommerce/products/[id]/data.ts
/**
 * What one product's page reads. The product is a `db.products` row; what it
 * sold is read off the lines of `db.orders`, because the catalogue row keeps
 * no sales of its own, by the dashboard's own rule for a sale
 * (`salesBetween`): paid and fulfilled orders, so a cancelled cart and a
 * refund are not sales, while every order that holds the product is listed
 * whatever became of it. "Now" is `REFERENCE_DATE`; nothing here reads a clock.
 */
import { salesBetween } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Member, type Order, type Product } from "@/lib/sample-data"

const DAY_MS = 86_400_000
const WEEK_MS = 7 * DAY_MS

/** How many weeks the chart and the headline sales figures cover. */
export const WEEKS = 12
const SPAN_MS = WEEKS * WEEK_MS

/**
 * The product the page falls back to when it is rendered with no route param —
 * which is what the docs preview does: the active product that sold the most
 * units in the last twelve weeks, because a product page is worth looking at
 * when its chart has something on it. Read per request, like every row here.
 */
export function fallbackId(): string {
  const now = REFERENCE_DATE.getTime()
  const sales = salesBetween(db.orders.all(), now - SPAN_MS, now)
  const products = db.products.all()
  const active = products.filter((product) => product.status === "active")
  let best = active[0] ?? products[0]
  let most = -1
  for (const product of active) {
    const units = sales.get(product.id)?.units ?? 0
    if (units > most) {
      best = product
      most = units
    }
  }
  return best.id
}

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

/** One week of the chart: its first day, and the units sold in it. */
export type WeekPoint = { week: string; units: number }

/** One order that holds the product, with the line that holds it. */
export type HoldingOrder = {
  id: string
  number: string
  status: Order["status"]
  placedAt: Date
  customer: { name: string; company: string; avatarUrl?: string }
  qty: number
  /** What the line charged, in whole dollars — the price at the time, not today's. */
  lineTotal: number
}

/** The product and everything the page says about it, gathered in one read. */
export type ProductRecord = {
  product: Product
  /** The twelve weeks, oldest first, the last ending at "now". */
  weeks: WeekPoint[]
  sales: { units: number; revenue: number }
  /** The same figures over the twelve weeks before, for the pills. */
  previous: { units: number; revenue: number }
  orders: HoldingOrder[]
}

/** Midnight UTC on the day `ms` falls in. */
const dayOf = (ms: number) => {
  const date = new Date(ms)
  return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}

type WeekSales = { from: number; units: number; cents: number }

/**
 * One product's sales week by week, over the twelve weeks ending `offset`
 * spans before "now", oldest first. The chart draws these weeks and the
 * headline figures are their sums, so the bars always add up to the tile.
 */
function weeklySales(productId: string, offset: number): WeekSales[] {
  const end = REFERENCE_DATE.getTime() - offset * SPAN_MS
  const orders = db.orders.all()
  return Array.from({ length: WEEKS }, (_, index) => {
    const from = end - (WEEKS - index) * WEEK_MS
    const sale = salesBetween(orders, from, from + WEEK_MS).get(productId)
    return { from, units: sale?.units ?? 0, cents: sale?.cents ?? 0 }
  })
}

/** A run of weeks summed into the headline's units and revenue. */
function summed(weeks: WeekSales[]): { units: number; revenue: number } {
  return {
    units: weeks.reduce((sum, week) => sum + week.units, 0),
    revenue: weeks.reduce((sum, week) => sum + week.cents, 0) / 100,
  }
}

/**
 * The whole record, or undefined when the id names no product. The orders are
 * every one that holds it, newest first, whatever became of them — the list is
 * the product's history, and a refund is part of that.
 */
export async function productRecord(id: string): Promise<ProductRecord | undefined> {
  const product = await db.products.get(id)
  if (!product) return undefined

  const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
  const orders = db.orders
    .all()
    .filter((order) => order.items.some((item) => item.productId === id))
    .sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
    .map((order): HoldingOrder => {
      const line = order.items.find((item) => item.productId === id)!
      const customer = customers.get(order.customerId)
      return {
        id: order.id,
        number: order.number,
        status: order.status,
        placedAt: order.placedAt,
        customer: {
          // A sale the till rang up for nobody in particular has no customer.
          name: customer?.name ?? (order.customerId ? "Unknown customer" : "Walk-in"),
          company: customer?.company ?? "—",
          avatarUrl: customer?.avatarUrl,
        },
        qty: line.qty,
        lineTotal: (line.qty * line.unitCents) / 100,
      }
    })

  const weeks = weeklySales(id, 0)
  return {
    product,
    weeks: weeks.map((week) => ({
      week: new Date(dayOf(week.from + DAY_MS)).toISOString().slice(0, 10),
      units: week.units,
    })),
    sales: summed(weeks),
    previous: summed(weeklySales(id, 1)),
    orders,
  }
}

/** 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/products/[id]/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, fields, type Result } from "@/lib/sample-data"

/**
 * What one product's page changes. Both return `Result`, so the edit form
 * prints the reason a save was refused beside the fields it was about.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

export type ProductChange = { priceCents: number; stock: number }

/** The ceilings on what a form may write: $10,000,000 a unit, and a million units on hand. */
const MAX_PRICE_CENTS = 1_000_000_000
const MAX_STOCK = 1_000_000

/**
 * Writes a new list price and stock count. The form's numbers are checked
 * here, not trusted: a price is a whole number of cents at or above zero and
 * stock a whole number of units at or above zero, and anything else is
 * refused with the field it was about, so the form can mark it. The name in
 * the answer is the store's own, read off the row this action looked up.
 */
export async function updateProduct(
  id: string,
  change: ProductChange
): Promise<Result<{ id: string; name: string; priceCents: number; stock: number }>> {
  const product = await db.products.get(id)
  if (!product) {
    return { ok: false, error: { code: "not_found", message: `No product with id "${id}".` } }
  }

  const { priceCents, stock } = fields(change)
  if (typeof priceCents !== "number" || !Number.isFinite(priceCents)) {
    return { ok: false, error: { code: "invalid_price", message: "Enter a price.", field: "priceCents" } }
  }
  if (priceCents < 0) {
    return { ok: false, error: { code: "negative_price", message: "A price cannot be negative.", field: "priceCents" } }
  }
  if (priceCents > MAX_PRICE_CENTS) {
    return { ok: false, error: { code: "invalid_price", message: "A price runs to $10,000,000 at most.", field: "priceCents" } }
  }
  if (typeof stock !== "number" || !Number.isFinite(stock)) {
    return { ok: false, error: { code: "invalid_stock", message: "Enter a stock count.", field: "stock" } }
  }
  if (stock < 0) {
    return { ok: false, error: { code: "negative_stock", message: "Stock cannot be negative.", field: "stock" } }
  }
  if (!Number.isInteger(stock)) {
    return { ok: false, error: { code: "partial_stock", message: "Stock is counted in whole units.", field: "stock" } }
  }
  if (stock > MAX_STOCK) {
    return { ok: false, error: { code: "invalid_stock", message: "Stock runs to 1,000,000 units at most.", field: "stock" } }
  }

  const updated = await db.products.update(id, { priceCents: Math.round(priceCents), stock })
  if (!updated.ok) return updated
  return {
    ok: true,
    data: { id, name: updated.data.name, priceCents: updated.data.priceCents, stock: updated.data.stock },
  }
}
app/ecommerce/products/[id]/components/product-context.tsx
"use client"

import * as React from "react"

/** The two numbers the edit form can change, held once for every card that prints them. */
export type ProductFigures = { priceCents: number; stock: number }

type ProductContextValue = {
  figures: ProductFigures
  setFigures: (next: ProductFigures) => void
}

const ProductContext = React.createContext<ProductContextValue | null>(null)

/**
 * Holds the price and the stock the server last confirmed. The page renders
 * them from the row it read; a save replaces them with what the action wrote
 * back, so the figures at the top and the form at the bottom never disagree.
 */
export function ProductProvider({ initial, children }: { initial: ProductFigures; children: React.ReactNode }) {
  const [figures, setFigures] = React.useState(initial)
  const value = React.useMemo(() => ({ figures, setFigures }), [figures])
  return <ProductContext.Provider value={value}>{children}</ProductContext.Provider>
}

export function useProduct(): ProductContextValue {
  const context = React.useContext(ProductContext)
  if (!context) throw new Error("useProduct must be used inside <ProductProvider>.")
  return context
}
app/ecommerce/products/[id]/components/product-details.tsx
import { PRODUCT_STATUS_LABELS, PRODUCT_STATUS_MAP } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatDate, formatNumber } from "@/lib/format"
import { type Product } from "@/lib/sample-data"
import { DescriptionList } from "@/components/ui/description-list"
import { ProductArt } from "@/components/ui/product-art"
import { StatusBadge } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"

export type ProductDetailsProps = {
  product: Product
  orderCount: number
  /** When the oldest sale of it was placed; absent for a product nobody has bought and kept. */
  firstSold?: Date
}

/**
 * The product's drawing, the facts the catalogue row holds, and two the order
 * book adds to them: how many orders hold it, and when it first sold.
 */
export function ProductDetails({ product, orderCount, firstSold }: ProductDetailsProps) {
  return (
    <Widget data-widget="widget-ecommerce-product-product-details" title="Details" description="What the catalogue holds for it" className="h-full">
      {/* The drawing heads the facts in a narrow card and stands beside them in a wide one. */}
      <div className="@container/details">
        <div className="flex flex-col gap-4 @lg/details:flex-row @lg/details:items-start @lg/details:gap-6">
          <ProductArt
            name={product.name}
            category={product.category}
            className="w-20 shrink-0 rounded-md @lg/details:w-32"
          />
          <DescriptionList
            className="min-w-0 flex-1"
            items={[
              { term: "SKU", description: <span className="font-mono text-xs">{product.sku}</span> },
              { term: "Category", description: product.category },
              { term: "Supplier", description: product.supplier },
              {
                term: "Reorder point",
                description: <span className="tabular-nums">{formatNumber(product.reorderPoint)} units</span>,
              },
              {
                term: "Status",
                description: (
                  <StatusBadge
                    status={product.status}
                    map={PRODUCT_STATUS_MAP}
                    label={PRODUCT_STATUS_LABELS[product.status]}
                  />
                ),
              },
              { term: "Orders", description: <span className="tabular-nums">{formatNumber(orderCount)}</span> },
              {
                term: "First sold",
                description: firstSold ? formatDate(firstSold, "medium", { timeZone: "UTC" }) : "Not yet",
              },
            ]}
          />
        </div>
      </div>
    </Widget>
  )
}
app/ecommerce/products/[id]/components/product-edit.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber } from "@/lib/format"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Widget } from "@/components/ui/widget"

import { updateProduct } from "../actions"
import { useProduct } from "./product-context"

type Refusal = { message: string; field?: string }

/**
 * The price and the stock, edited in place. The numbers go to the server as
 * typed — the action, not the browser, decides what a valid price is — and
 * whatever it answers is printed here: a refusal in an alert above the button
 * with the field it was about marked invalid, a success in the status line
 * under it, and the figures at the top of the page take the saved values.
 */
export function ProductEdit({ id, name }: { id: string; name: string }) {
  const { figures, setFigures } = useProduct()
  const [price, setPrice] = React.useState(String(figures.priceCents / 100))
  const [stock, setStock] = React.useState(String(figures.stock))
  const [refusal, setRefusal] = React.useState<Refusal | null>(null)
  const [notice, setNotice] = React.useState("")

  async function save() {
    setRefusal(null)
    setNotice("")
    const result = await updateProduct(id, {
      // An empty box is not zero: it reaches the server as NaN and is refused.
      priceCents: price.trim() === "" ? Number.NaN : Math.round(Number(price) * 100),
      stock: stock.trim() === "" ? Number.NaN : Number(stock),
    })
    if (!result.ok) return setRefusal({ message: result.error.message, field: result.error.field })
    setFigures({ priceCents: result.data.priceCents, stock: result.data.stock })
    setPrice(String(result.data.priceCents / 100))
    setStock(String(result.data.stock))
    setNotice(
      `Saved. ${result.data.name} sells at ${formatCurrency(result.data.priceCents / 100)} with ${formatNumber(result.data.stock)} in stock.`
    )
  }

  const priceInvalid = refusal?.field === "priceCents"
  const stockInvalid = refusal?.field === "stock"
  // A field the refusal was about is described by it as well as by its hint,
  // so tabbing back to it says why it is invalid, not only that it is.
  const refusalId = `${id}-refusal`
  const describedBy = (field: string, invalid: boolean) => (invalid ? `${id}-${field}-hint ${refusalId}` : `${id}-${field}-hint`)

  return (
    <Widget data-widget="widget-ecommerce-product-product-edit" title="Price and stock" description={`What ${name} sells for, and what is on the shelf`} className="h-full">
      <form
        className="flex flex-col gap-4"
        onSubmit={(event) => {
          event.preventDefault()
        }}
        noValidate
      >
        <Field data-invalid={priceInvalid || undefined}>
          <FieldLabel htmlFor={`${id}-price`}>Price (USD)</FieldLabel>
          <Input
            id={`${id}-price`}
            name="price"
            type="number"
            inputMode="decimal"
            step="0.01"
            value={price}
            onChange={(event) => setPrice(event.target.value)}
            aria-invalid={priceInvalid || undefined}
            aria-describedby={describedBy("price", priceInvalid)}
            className="tabular-nums"
          />
          <FieldDescription id={`${id}-price-hint`}>The list price, before any discount at checkout.</FieldDescription>
        </Field>

        <Field data-invalid={stockInvalid || undefined}>
          <FieldLabel htmlFor={`${id}-stock`}>Units in stock</FieldLabel>
          <Input
            id={`${id}-stock`}
            name="stock"
            type="number"
            inputMode="numeric"
            step="1"
            value={stock}
            onChange={(event) => setStock(event.target.value)}
            aria-invalid={stockInvalid || undefined}
            aria-describedby={describedBy("stock", stockInvalid)}
            className="tabular-nums"
          />
          <FieldDescription id={`${id}-stock-hint`}>A count after a stocktake replaces the one on record.</FieldDescription>
        </Field>

        {refusal ? (
          <Callout id={refusalId} variant="danger" role="alert" title="Not saved">
            {refusal.message}
          </Callout>
        ) : null}

        <AsyncButton type="submit" onClick={save} className="self-start">
          Save changes
        </AsyncButton>

        <p
          data-slot="product-edit-status"
          role="status"
          aria-live="polite"
          className={cn("text-sm text-muted-foreground", !notice && "sr-only")}
        >
          {notice}
        </p>
      </form>
    </Widget>
  )
}
app/ecommerce/products/[id]/components/product-figures.tsx
"use client"

import { formatCurrency, formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { useProduct } from "./product-context"

export type ProductFiguresProps = {
  reorderPoint: number
  weeks: number
  sales: { units: number; revenue: number }
  previous: { units: number; revenue: number }
}

/** A change as a ratio, or nothing when there is no earlier figure to divide by — a rise from nothing is no percentage. */
const change = (now: number, before: number) => (before === 0 ? (now === 0 ? 0 : undefined) : now / before - 1)

/**
 * The four figures under the title. Price and stock come from the provider,
 * so a save in the form below moves them; units and revenue are history, read
 * once on the server and measured against the weeks before. The stock tile
 * tints its frame once the shelf is at or below the reorder point.
 */
export function ProductFigures({ reorderPoint, weeks, sales, previous }: ProductFiguresProps) {
  const { figures } = useProduct()
  const low = figures.stock <= reorderPoint
  const span = `last ${weeks} weeks`

  return (
    <StatCardGroup data-widget="widget-ecommerce-product-product-figures" columns={4}>
      <StatCard label="Price" value={formatCurrency(figures.priceCents / 100)} description="list price" />
      <StatCard
        label="In stock"
        value={formatNumber(figures.stock)}
        description={`reorder at ${reorderPoint}`}
        tone={low ? "warning" : "neutral"}
      />
      <StatCard
        label="Units sold"
        value={formatNumber(sales.units)}
        delta={change(sales.units, previous.units)}
        description={span}
      />
      <StatCard
        label="Revenue"
        value={formatCurrency(sales.revenue, "USD", { maximumFractionDigits: 0 })}
        delta={change(sales.revenue, previous.revenue)}
        description={`${span}, at the prices charged`}
      />
    </StatCardGroup>
  )
}
app/ecommerce/products/[id]/components/product-orders.tsx
import Link from "next/link"

import { ORDER_STATUS_MAP, orderHref } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatCurrency, formatDate, formatNumber } from "@/lib/format"
import { SimpleTable } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"

import { type HoldingOrder } from "../data"

/** How many of the newest orders the card lists before it counts the rest. */
const SHOWN = 8

/**
 * The orders that hold the product, newest first, whatever became of them:
 * the customer with their face, the quantity on the line and what the line
 * charged at the time. Each order number opens its own page.
 */
export function ProductOrders({ orders }: { orders: HoldingOrder[] }) {
  const shown = orders.slice(0, SHOWN)
  const count = `${formatNumber(orders.length)} order${orders.length === 1 ? "" : "s"}`

  return (
    <Widget
      data-widget="widget-ecommerce-product-product-orders"
      title="Orders"
      description={`${count} hold this product`}
      footer={orders.length > SHOWN ? `The ${SHOWN} newest of ${count}` : undefined}
      contentClassName="p-0"
      className="h-full"
    >
      <SimpleTable
        size="sm"
        rows={shown}
        rowKey="id"
        emptyMessage="No order has held this product yet."
        className="rounded-none border-0"
        columns={[
          {
            key: "number",
            header: "Order",
            cell: (order) => (
              <Link
                href={orderHref(order.id)}
                className="rounded-sm font-mono text-xs underline-offset-4 focus-ring hover:underline"
              >
                {order.number}
              </Link>
            ),
          },
          {
            key: "customer",
            header: "Customer",
            cell: (order) => (
              <UserCell
                size="sm"
                name={order.customer.name}
                description={order.customer.company}
                src={order.customer.avatarUrl}
              />
            ),
          },
          {
            key: "placedAt",
            header: "Placed",
            cell: (order) => (
              <time dateTime={order.placedAt.toISOString()} className="whitespace-nowrap text-muted-foreground">
                {formatDate(order.placedAt, "medium", { timeZone: "UTC" })}
              </time>
            ),
          },
          {
            key: "status",
            header: "Status",
            cell: (order) => <StatusBadge status={order.status} map={ORDER_STATUS_MAP} />,
          },
          { key: "qty", header: "Qty", align: "right" },
          {
            key: "lineTotal",
            header: "Line total",
            align: "right",
            cell: (order) => formatCurrency(order.lineTotal),
          },
        ]}
      />
    </Widget>
  )
}
app/ecommerce/products/[id]/components/units-chart.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"
import type { ChartSeries } from "@/components/ui/chart-core"

import { type WeekPoint } from "../data"

// Fixed to UTC so the axis reads the same wherever the page is rendered.
const WEEK_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

const SERIES: ChartSeries[] = [{ key: "units", label: "Units", color: "chart-1" }]

/**
 * Twelve weeks of units, one grey column a week — a single series, so no hue.
 * The caption carries the weekly average, which is what turns a row of
 * columns into "this week was a good one" without a rule drawn through them.
 */
export function UnitsChart({ weeks }: { weeks: WeekPoint[] }) {
  const total = weeks.reduce((sum, week) => sum + week.units, 0)
  const average = weeks.length === 0 ? 0 : total / weeks.length

  return (
    <ChartCard
      data-widget="widget-ecommerce-product-units-chart"
      title={`Units sold, last ${weeks.length} weeks`}
      description={`${formatNumber(total)} units, ${formatNumber(average, { maximumFractionDigits: 1 })} a week on average`}
      empty={total === 0}
      emptyMessage="Nothing sold in these weeks."
      height={288}
      className="h-full"
    >
      <BarChart
        data={weeks}
        index="week"
        series={SERIES}
        height={288}
        valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 1 })}
        indexFormatter={(value) => WEEK_LABEL.format(new Date(`${value}T00:00:00Z`))}
      />
    </ChartCard>
  )
}