Top products
The eight products the window moved most units of, ranked, each with its category; reads topProducts().
Preview
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>
)
}Install
$
npx shadcn@latest add @vibra/widget-ecommerce-overview-top-productsNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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