Skip to contentVibraUI

Store headline

Gross sales, orders, new customers and refunds over the window, each against the window before; reads storeStats().

Preview

Install

npx shadcn@latest add @vibra/widget-ecommerce-overview-store-stats

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

Source

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

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 Storefront page