Skip to contentVibraUI
Part of the E-commerce dashboardinstalls at /ecommerce

Storefront

A shop's own dashboard: gross sales, orders, new customers and refunds against the month before, revenue by category as a ring beside its numbers, the funnel down to an order, what moved most, and every order in the window filtered from the URL.

Open the live page

The page is a server component inside AppShell: data.ts reads db.orders, db.customers and db.products at module scope, cuts the last 30 days against REFERENCE_DATE, and hands every panel a plain array. The window is compared with the 30 days immediately before it, so each headline number carries a real delta rather than an invented one — and refunds are the one card where a rise is bad news, which is what positiveIsGood says. Gross sales counts every order that was actually paid for, refunds included, because a refund is money that arrived and then left; the refunds card is what says how much of it left. Sales by category credits each category with only its own lines, and counts an order once per category it touched, so a mixed basket is not counted twice. Only the bottom step of the funnel is measured: it is the orders the window really holds, and the three steps above it are that number scaled back up through one fixed ratio each, drawn once from seeded("dashboard-ecommerce") — the footer says so out loud rather than letting four bars imply four measurements. The orders table is the block's one piece of URL state: `?q=` and `?status=paid,refunded` seed the table on mount, every change is written back with router.replace (scroll: false, and the query the page already had is preserved, so a framed preview keeps its palette), and the search column carries the order number, the customer and the company in one accessor so one box finds a row either way. Composes AppShell, PageHeader, StatCardGroup, StatCard, DashboardGrid, Widget, DonutChart, SimpleTable, MetricDelta, FunnelChart, RankList, DataTable, StatusBadge, NumberCell, CurrencyCell and DateCell.

Preview

Install

npx shadcn@latest add @vibra/ecommerce-overview

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

Source

app/ecommerce/page.tsx
import * as React from "react"

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 { Skeleton } from "@/components/ui/skeleton"

import { signOut } from "./actions"
import { ConversionFunnel } from "./components/conversion-funnel"
import { OrdersTable } from "./components/orders-table"
import { SalesByCategory } from "./components/sales-by-category"
import { StoreStats } from "./components/store-stats"
import { TopProducts } from "./components/top-products"
import {
  conversionFunnel,
  currentUser,
  lastUpdated,
  salesByCategory,
  shellNotifications,
  storeOrders,
  storeStats,
  topProducts,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"

/**
 * The storefront. A server component: it reads every row through `db` and hands
 * plain arrays down, so the only client islands are the ring beside its table
 * and the orders table that keeps its filters in the URL.
 */
export default function StorePage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.overview}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Storefront"
        description="What the shop sold, where it came from, and what is still moving."
        meta={lastUpdated()}
      />

      <StoreStats stats={storeStats()} />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
          <SalesByCategory rows={salesByCategory()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
          <ConversionFunnel steps={conversionFunnel()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 3 }}>
          <TopProducts products={topProducts()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={12}>
          {/* The table reads `?status=` and `?q=` off the URL, so it sits inside a
              Suspense boundary: a prerendered page that calls useSearchParams
              outside one fails the build. */}
          <React.Suspense fallback={<Skeleton className="h-96 w-full rounded-xl" />}>
            <OrdersTable rows={storeOrders()} />
          </React.Suspense>
        </DashboardGridItem>
      </DashboardGrid>
    </AppShell>
  )
}
app/ecommerce/data.ts
/**
 * What this page reads. Every row comes from `db`, so swapping a repository for
 * a real store is the whole migration. The one series with no entity behind it
 * — how many sessions reached a cart before they reached an order — is derived
 * from `seeded("dashboard-ecommerce")` and anchored on the orders that really
 * are in the window, so the bottom of the funnel is a fact and the steps above
 * it are a fixed, deterministic ratio rather than a number invented per render.
 */
import { getInitials } from "@/lib/format"
import {
  REFERENCE_DATE,
  daysAgo,
  db,
  seeded,
  type Member,
  type Order,
} from "@/lib/sample-data"

/** How much of the past this page is about. */
export const WINDOW_DAYS = 30

const WINDOW_START = daysAgo(WINDOW_DAYS)
const PREVIOUS_START = daysAgo(WINDOW_DAYS * 2)

/** What a period is called under a number that is compared with the one before it. */
export const COMPARE_LABEL = `vs previous ${WINDOW_DAYS} days`

/** An order counts as a sale unless it never happened. */
const SOLD: Order["status"][] = ["paid", "fulfilled", "refunded"]

// This window runs up to and including now — a sale rung up on the till is
// placed at REFERENCE_DATE, and it happened in the last 30 days — and the one
// before it up to where this one starts.
const inThisWindow = (at: Date) => at >= WINDOW_START && at <= REFERENCE_DATE
const inLastWindow = (at: Date) => at >= PREVIOUS_START && at < WINDOW_START

// Every read is per call, never held at module scope: the store is written
// while the server runs — a sale, a refund — and the page reads it as it is.
const thisWindow = (): Order[] => db.orders.all().filter((order) => inThisWindow(order.placedAt))
const lastWindow = (): Order[] => db.orders.all().filter((order) => inLastWindow(order.placedAt))
const productsById = () => new Map(db.products.all().map((product) => [product.id, product]))

const sold = (orders: Order[]) => orders.filter((order) => SOLD.includes(order.status))
const cents = (orders: Order[]) => orders.reduce((total, order) => total + order.totalCents, 0)

/** The change from `was` to `now` as a ratio; 0 when there was nothing to grow from. */
function growth(now: number, was: number): number {
  if (was === 0) return 0
  return (now - was) / was
}

export type StoreStat = {
  key: string
  label: string
  value: number
  /** "money" is in minor units; "count" is a plain number. */
  kind: "money" | "count"
  delta: number
  /** False where a rise is the bad news. */
  positiveIsGood: boolean
}

/**
 * The four headline numbers, each against the same span immediately before it.
 * Gross sales counts every order that was actually paid for, refunds included,
 * because a refund is money that arrived and then left — the refunds card is
 * what says how much of it left.
 */
export function storeStats(): StoreStat[] {
  const now = sold(thisWindow())
  const was = sold(lastWindow())
  const refundedNow = now.filter((order) => order.status === "refunded")
  const refundedWas = was.filter((order) => order.status === "refunded")

  const customers = db.customers.all()
  const newCustomers = customers.filter((customer) => inThisWindow(customer.createdAt)).length
  const newCustomersBefore = customers.filter((customer) => inLastWindow(customer.createdAt)).length

  return [
    {
      key: "gross",
      label: "Gross sales",
      value: cents(now),
      kind: "money",
      delta: growth(cents(now), cents(was)),
      positiveIsGood: true,
    },
    {
      key: "orders",
      label: "Orders",
      value: now.length,
      kind: "count",
      delta: growth(now.length, was.length),
      positiveIsGood: true,
    },
    {
      key: "customers",
      label: "New customers",
      value: newCustomers,
      kind: "count",
      delta: growth(newCustomers, newCustomersBefore),
      positiveIsGood: true,
    },
    {
      key: "refunds",
      label: "Refunds",
      value: cents(refundedNow),
      kind: "money",
      delta: growth(cents(refundedNow), cents(refundedWas)),
      positiveIsGood: false,
    },
  ]
}

export type CategorySales = {
  category: string
  orders: number
  revenueCents: number
  /** Revenue against the same span before this one, as a ratio. */
  growth: number
}

/** Revenue per line by category over a span, and how many orders touched it. */
function byCategory(orders: Order[]): Map<string, { orders: number; revenueCents: number }> {
  const totals = new Map<string, { orders: number; revenueCents: number }>()
  const products = productsById()

  for (const order of sold(orders)) {
    // An order can carry lines from several categories; each category is
    // credited once for the order and with only its own lines' money.
    const seen = new Set<string>()
    for (const item of order.items) {
      const category = products.get(item.productId)?.category
      if (!category) continue
      const entry = totals.get(category) ?? { orders: 0, revenueCents: 0 }
      entry.revenueCents += item.qty * item.unitCents
      if (!seen.has(category)) {
        entry.orders += 1
        seen.add(category)
      }
      totals.set(category, entry)
    }
  }

  return totals
}

/** What each category sold in the window, biggest first. */
export function salesByCategory(): CategorySales[] {
  const now = byCategory(thisWindow())
  const was = byCategory(lastWindow())

  return [...now.entries()]
    .map(([category, entry]) => ({
      category,
      orders: entry.orders,
      revenueCents: entry.revenueCents,
      growth: growth(entry.revenueCents, was.get(category)?.revenueCents ?? 0),
    }))
    .sort((a, b) => b.revenueCents - a.revenueCents)
}

// Each step of the funnel keeps this share of the one above it, drawn once from
// the block's own generator so the ladder is fixed rather than re-invented per
// render. Only the bottom step is measured — everything above it is scaled up
// from the orders that really are in the window.
const FUNNEL_LABELS = ["Sessions", "Carts", "Checkouts", "Orders"] as const
const FUNNEL_RATES = (() => {
  const rand = seeded("dashboard-ecommerce")
  return [0.28 + rand() * 0.06, 0.52 + rand() * 0.08, 0.61 + rand() * 0.08]
})()

/**
 * Sessions down to orders. The last step is the count of orders actually
 * placed in the window; each step above it is that number divided back up
 * through the fixed rates, so the funnel can only ever narrow.
 */
export function conversionFunnel(): { label: string; value: number }[] {
  const orders = sold(thisWindow()).length
  const values = [orders]
  for (const rate of [...FUNNEL_RATES].reverse()) {
    values.unshift(Math.round(values[0] / rate))
  }
  return FUNNEL_LABELS.map((label, index) => ({ label, value: values[index] }))
}

export type TopProduct = { id: string; name: string; category: string; units: number }

/** The eight products the window moved most of, by units. */
export function topProducts(): TopProduct[] {
  const units = new Map<string, number>()
  const products = productsById()
  for (const order of sold(thisWindow())) {
    for (const item of order.items) {
      units.set(item.productId, (units.get(item.productId) ?? 0) + item.qty)
    }
  }

  return [...units.entries()]
    .map(([id, count]) => {
      const product = products.get(id)
      return { id, name: product?.name ?? id, category: product?.category ?? "—", units: count }
    })
    .sort((a, b) => b.units - a.units)
    .slice(0, 8)
}

export type StoreOrderRow = {
  id: string
  number: string
  customer: string
  company: string
  status: Order["status"]
  units: number
  totalCents: number
  placedAt: Date
  country: string
}

/** Every order placed in the window, newest first, as plain rows for the table. */
export function storeOrders(): StoreOrderRow[] {
  const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
  return thisWindow()
    .sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
    .map((order) => {
      const customer = customers.get(order.customerId)
      return {
        id: order.id,
        number: order.number,
        // A sale the till rang up for nobody in particular has no customer.
        customer: customer?.name ?? (order.customerId || "Walk-in"),
        company: customer?.company ?? "—",
        status: order.status,
        units: order.items.reduce((total, item) => total + item.qty, 0),
        totalCents: order.totalCents,
        placedAt: order.placedAt,
        country: order.country,
      }
    })
}

/** 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 }))
}

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 }
}

// Fixed to UTC so the line reads the same wherever the page is rendered.
const UPDATED_AT = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium",
  timeStyle: "short",
  hourCycle: "h23",
  timeZone: "UTC",
})

/** The window these numbers cover, and when they were last collected. */
export function lastUpdated(): string {
  return `Last ${WINDOW_DAYS} days · updated ${UPDATED_AT.format(REFERENCE_DATE)} UTC`
}
app/ecommerce/actions.ts
"use server"

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

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/ecommerce/vocabulary.ts
/**
 * The words the table is written in, and nothing else.
 *
 * `data.ts` reads `db` at module scope, so anything a client island imports
 * from it for a *value* drags the whole sample-data store into the browser.
 * The status map and the filter options are exactly that kind of value — plain
 * vocabulary with no rows behind it — so they live here, where the only import
 * is a type and the module is free of the store.
 */
import { type Order } from "@/lib/sample-data"

/** How each status is toned wherever it is shown. */
export const STATUS_MAP = {
  fulfilled: "success",
  paid: "info",
  pending: "warning",
  refunded: "warning",
  cancelled: "danger",
} as const

/** The statuses the table can be narrowed to, in the order a reader reads them. */
export const STATUS_OPTIONS: { value: Order["status"]; label: string }[] = [
  { value: "pending", label: "Pending" },
  { value: "paid", label: "Paid" },
  { value: "fulfilled", label: "Fulfilled" },
  { value: "refunded", label: "Refunded" },
  { value: "cancelled", label: "Cancelled" },
]
app/ecommerce/components/conversion-funnel.tsx
import { formatCompact, formatPercent } from "@/lib/format"
import { FunnelChart } from "@/components/ui/funnel-chart"
import { Widget } from "@/components/ui/widget"

/**
 * Sessions down to orders. Only the last step is measured — it is the orders
 * the window actually holds — and the steps above it are that number scaled
 * back up through a fixed rate each, which the footer says out loud rather
 * than letting the chart imply four separate measurements.
 */
export function ConversionFunnel({ steps }: { steps: { label: string; value: number }[] }) {
  const top = steps[0]?.value ?? 0
  const bottom = steps[steps.length - 1]?.value ?? 0

  return (
    <Widget
      data-widget="widget-ecommerce-overview-conversion-funnel"
      title="Conversion funnel"
      description="How many sessions reached a cart, a checkout, and an order"
      className="h-full"
      footer={`${formatPercent(top === 0 ? 0 : bottom / top)} of sessions ended in an order. Sessions, carts and checkouts are modelled from the orders in the window.`}
    >
      <FunnelChart steps={steps} valueFormatter={formatCompact} showConversion />
    </Widget>
  )
}
app/ecommerce/components/order-columns.tsx
"use client"

import { DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { CurrencyCell, DateCell, NumberCell } from "@/components/ui/table-cells"

import { type StoreOrderRow } from "../data"
import { STATUS_MAP } from "../vocabulary"

/**
 * The order column carries both the number and who placed it, in one accessor,
 * so the toolbar's single search box finds an order either way — a reader who
 * has the customer in mind should not have to know the number first.
 */
export const ORDER_COLUMNS: DataTableColumnDef<StoreOrderRow>[] = [
  {
    id: "order",
    accessorFn: (row) => `${row.number} ${row.customer} ${row.company}`,
    header: ({ column }) => <DataTableColumnHeader column={column} title="Order" />,
    cell: ({ row }) => (
      <div className="flex min-w-0 flex-col">
        <span className="font-mono text-xs">{row.original.number}</span>
        <span className="truncate text-muted-foreground">{row.original.customer}</span>
      </div>
    ),
    meta: { label: "Order" },
  },
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => <StatusBadge status={row.original.status} map={STATUS_MAP} />,
    meta: { label: "Status" },
  },
  {
    accessorKey: "country",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Ships to" />,
    cell: ({ row }) => <span className="whitespace-nowrap">{row.original.country}</span>,
    meta: { label: "Ships to" },
  },
  {
    accessorKey: "units",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Units" />,
    cell: ({ row }) => <NumberCell value={row.original.units} />,
    meta: { align: "right", label: "Units" },
  },
  {
    accessorKey: "totalCents",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Total" />,
    cell: ({ row }) => <CurrencyCell value={row.original.totalCents / 100} />,
    meta: { align: "right", label: "Total" },
  },
  {
    accessorKey: "placedAt",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Placed" />,
    cell: ({ row }) => <DateCell date={row.original.placedAt} />,
    meta: { align: "right", label: "Placed" },
  },
]
app/ecommerce/components/orders-table.tsx
"use client"

import * as React from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import type { ColumnFiltersState, Updater } from "@tanstack/react-table"

import { DataTable } from "@/components/ui/data-table"
import { Widget } from "@/components/ui/widget"

import { type StoreOrderRow } from "../data"
import { STATUS_OPTIONS } from "../vocabulary"
import { ORDER_COLUMNS } from "./order-columns"

/** The two query keys this table owns; every other key on the URL is left alone. */
const SEARCH_KEY = "q"
const STATUS_KEY = "status"

/** `?q=` and `?status=paid,refunded` as the table's own filter state. */
function fromQuery(params: URLSearchParams): ColumnFiltersState {
  const filters: ColumnFiltersState = []
  const search = params.get(SEARCH_KEY)
  if (search) filters.push({ id: "order", value: search })
  const statuses = (params.get(STATUS_KEY) ?? "").split(",").filter(Boolean)
  if (statuses.length) filters.push({ id: "status", value: statuses })
  return filters
}

/** The filter state written back onto the query the page already had. */
function toQuery(params: URLSearchParams, filters: ColumnFiltersState): string {
  // A copy of what is already there: a framed preview carries its palette and
  // its density in the same query, and dropping those would repaint the page.
  const next = new URLSearchParams(params)
  const search = filters.find((filter) => filter.id === "order")?.value
  const statuses = filters.find((filter) => filter.id === "status")?.value

  if (typeof search === "string" && search) next.set(SEARCH_KEY, search)
  else next.delete(SEARCH_KEY)

  if (Array.isArray(statuses) && statuses.length) next.set(STATUS_KEY, statuses.join(","))
  else next.delete(STATUS_KEY)

  return next.toString()
}

const resolve = <T,>(updater: Updater<T>, current: T): T =>
  typeof updater === "function" ? (updater as (old: T) => T)(current) : updater

/**
 * Every order in the window, filtered in the browser and recorded in the URL.
 *
 * The filters are seeded from the query on mount and owned here afterwards, so
 * the table answers a keystroke without a round trip — which is also what makes
 * it work inside a docs frame, where nothing re-renders the server page. Each
 * change is written back with `replace`, so `?status=refunded&q=ORD-1002` is a
 * link a reader can send, and the page they open lands on the same view. It is
 * `replace` rather than `push` because narrowing a table is not a place in the
 * history a Back button should have to walk out of, and `scroll: false` because
 * the reader is already looking at the row they filtered.
 */
export function OrdersTable({ rows }: { rows: StoreOrderRow[] }) {
  // The title names the table too, so a screen reader announces it by name.
  const titleId = React.useId()
  const router = useRouter()
  const pathname = usePathname()
  const params = useSearchParams()

  const [filters, setFilters] = React.useState<ColumnFiltersState>(() => fromQuery(params))

  function apply(updater: Updater<ColumnFiltersState>) {
    const next = resolve(updater, filters)
    setFilters(next)
    const query = toQuery(params, next)
    router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
  }

  return (
    <Widget
      titleId={titleId}
      data-widget="widget-ecommerce-overview-orders-table"
      title="Orders"
      description="Everything placed in the window, newest first"
      contentClassName="px-0"
      footer="The status filter and the search box are both in the URL, so this view is a link."
    >
      <div className="px-4">
        <DataTable
          aria-labelledby={titleId}
          columns={ORDER_COLUMNS}
          data={rows}
          getRowId={(row) => row.id}
          searchKey="order"
          searchPlaceholder="Search orders"
          facets={[
            { columnId: "status", title: "Status", options: STATUS_OPTIONS.map(({ value, label }) => ({ value, label })) },
          ]}
          enableRowSelection={false}
          state={{ columnFilters: filters }}
          onColumnFiltersChange={apply}
          initialSorting={[{ id: "placedAt", desc: true }]}
          pageSize={10}
          emptyMessage="No orders match these filters."
        />
      </div>
    </Widget>
  )
}
app/ecommerce/components/sales-by-category.tsx
"use client"

import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { DonutChart } from "@/components/ui/donut-chart"
import { MetricDelta } from "@/components/ui/metric-delta"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { Widget } from "@/components/ui/widget"

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

const money = (value: number) => formatCurrency(value / 100, "USD", { maximumFractionDigits: 0 })

const COLUMNS: SimpleTableColumn<CategorySales>[] = [
  { key: "category", header: "Category" },
  {
    key: "orders",
    header: "Orders",
    align: "right",
    cell: (row) => formatNumber(row.orders),
  },
  {
    key: "revenueCents",
    header: "Revenue",
    align: "right",
    cell: (row) => money(row.revenueCents),
  },
  {
    key: "growth",
    header: "Growth",
    align: "right",
    // A category with nothing to grow from reads as new, not as flat: a delta
    // of 0 % would claim last month sold the same amount, and it sold nothing.
    cell: (row) =>
      row.growth === 0 ? (
        <span className="text-muted-foreground">New</span>
      ) : (
        <MetricDelta value={row.growth} format="percent" size="sm" />
      ),
  },
]

/**
 * Where the money came from: the ring for the shape of it, the table for the
 * numbers behind it. The chart is decorative next to the table — every slice
 * is a row below with its own revenue and its own change — so nothing here is
 * only available as colour.
 */
export function SalesByCategory({ rows }: { rows: CategorySales[] }) {
  const slices = rows.map((row) => ({ name: row.category, value: row.revenueCents }))
  const total = rows.reduce((sum, row) => sum + row.revenueCents, 0)

  return (
    <Widget
      data-widget="widget-ecommerce-overview-sales-by-category"
      title="Sales by category"
      description="Every line sold in the window, credited to the category it came from"
      className="h-full"
      contentClassName="flex flex-col gap-4 px-0"
      footer={`${formatPercent(rows[0] ? rows[0].revenueCents / total : 0)} of revenue came from ${rows[0]?.category ?? "no category"}.`}
    >
      <div className="px-4">
        <DonutChart
          data={slices}
          height={200}
          innerRadius={58}
          centerLabel="Revenue"
          centerValue={money(total)}
          valueFormatter={money}
          showLegend={false}
        />
      </div>

      <SimpleTable columns={COLUMNS} rows={rows} rowKey="category" size="sm" />
    </Widget>
  )
}
app/ecommerce/components/store-stats.tsx
import { formatCurrency, formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { COMPARE_LABEL, type StoreStat } from "../data"

const money = (value: number) => formatCurrency(value / 100, "USD", { maximumFractionDigits: 0 })

/** The four headline numbers, each beside its change against the span before. */
export function StoreStats({ stats }: { stats: StoreStat[] }) {
  return (
    <StatCardGroup data-widget="widget-ecommerce-overview-store-stats" columns={4}>
      {stats.map((stat) => (
        <StatCard
          key={stat.key}
          label={stat.label}
          value={stat.kind === "money" ? money(stat.value) : formatNumber(stat.value)}
          delta={stat.delta}
          deltaFormat="percent"
          positiveIsGood={stat.positiveIsGood}
          description={COMPARE_LABEL}
        />
      ))}
    </StatCardGroup>
  )
}
app/ecommerce/components/top-products.tsx
import { formatNumber } from "@/lib/format"
import { RankList } from "@/components/ui/rank-list"
import { Widget } from "@/components/ui/widget"

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

/** What moved, by units rather than by money: a ranking of one measure. */
export function TopProducts({ products }: { products: TopProduct[] }) {
  return (
    <Widget
      data-widget="widget-ecommerce-overview-top-products"
      title="Top products"
      description="The eight products the window moved most of"
      className="h-full"
      footer="Ranked by units shipped, not by revenue — a single expensive line would otherwise take the top."
    >
      <RankList
        items={products.map((product) => ({
          label: (
            <span className="flex min-w-0 flex-col">
              <span className="truncate">{product.name}</span>
              <span className="text-xs text-muted-foreground">{product.category}</span>
            </span>
          ),
          value: product.units,
        }))}
        format={(value) => `${formatNumber(value)} units`}
        showRank
      />
    </Widget>
  )
}