Search for pages, actions, and quick links.
Order totals, product tiles, plans and payment methods. 8 blocks.
A total that shows its arithmetic, with tax on the discounted subtotal.
src/components/blocks/commerce/commerce-order-summary.tsx"use client";
import { Separator } from "@dashboardpack/core/components/ui/separator";
const LINES = [
{ label: "Pro Dashboard License × 2", value: 598 },
{ label: "Extended support, 12 months", value: 249 },
];
const DISCOUNT = 84.7;
const TAX_RATE = 0.2;
/**
* An order total that shows its arithmetic.
*
* Every figure below the lines is computed, not written down — the fastest way to lose a buyer's
* trust is a total that does not equal the sum above it, and hardcoding the total in a template
* guarantees that the moment someone edits a line.
*
* The discount is negative and coloured, and tax is calculated on the *discounted* subtotal,
* which is the order almost every jurisdiction requires and the one people get wrong.
*/
export function CommerceOrderSummary() {
const subtotal = LINES.reduce((sum, line) => sum + line.value, 0);
const taxable = subtotal - DISCOUNT;
const tax = taxable * TAX_RATE;
const total = taxable + tax;
const money = (value: number) =>
`$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
return (
<div className="space-y-3">
<ul className="space-y-2">
{LINES.map((line) => (
<li key={line.label} className="flex justify-between gap-4 text-sm">
<span className="min-w-0 text-muted-foreground">{line.label}</span>
<span className="shrink-0 tabular-nums">{money(line.value)}</span>
</li>
))}
</ul>
<Separator />
<dl className="space-y-2 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{money(subtotal)}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">Discount (LAUNCH10)</dt>
<dd className="tabular-nums text-success">−{money(DISCOUNT)}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">VAT at {TAX_RATE * 100}%</dt>
<dd className="tabular-nums">{money(tax)}</dd>
</div>
</dl>
<Separator />
<div className="flex items-baseline justify-between gap-4">
<span className="font-semibold">Total</span>
<span className="text-lg font-bold tabular-nums">{money(total)}</span>
</div>
</div>
);
}Three stock states, because collapsing 'low' loses the only one people act on.
src/components/blocks/commerce/commerce-product-card.tsx"use client";
import { Package, Star } from "lucide-react";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
const PRODUCTS = [
{ name: "Pro Dashboard License", category: "Templates", price: 299, rating: 4.8, reviews: 214, stock: 999 },
{ name: "UI Component Pack", category: "Components", price: 149, rating: 4.6, reviews: 96, stock: 12 },
{ name: "Enterprise License", category: "Templates", price: 1499, rating: 4.9, reviews: 41, stock: 0 },
];
/**
* A product tile with the states that matter.
*
* Three stock states, not two: in stock, low, and none. Collapsing "low" into "in stock" removes
* the only stock information anyone acts on, and the button changes with it — offering "Add to
* cart" for something unavailable is the version of this component people complain about.
*
* The rating is a number beside a single star rather than five drawn stars. Five glyphs at this
* size are hard to read, need a half-star case, and say less than "4.8".
*/
export function CommerceProductCard() {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{PRODUCTS.map((product) => {
const out = product.stock === 0;
const low = !out && product.stock < 20;
return (
<Card key={product.name} className="flex flex-col">
<CardContent className="flex flex-1 flex-col gap-3 p-4">
<div className="flex h-24 items-center justify-center rounded-lg bg-muted">
<Package className="size-7 text-muted-foreground" aria-hidden="true" />
</div>
<div className="flex-1">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
{product.category}
</p>
<p className="mt-0.5 font-medium leading-snug">{product.name}</p>
<div className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground">
<Star className="size-3 fill-warning text-warning" aria-hidden="true" />
<span className="tabular-nums">{product.rating}</span>
<span>({product.reviews})</span>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-lg font-bold tabular-nums">
${product.price.toLocaleString("en-US")}
</span>
{out ? (
<Badge variant="secondary" className="text-[11px]">Out of stock</Badge>
) : low ? (
<Badge variant="warning" className="text-[11px]">{product.stock} left</Badge>
) : (
<Badge variant="success" className="text-[11px]">In stock</Badge>
)}
</div>
<Button size="sm" className="w-full" disabled={out}>
{out ? "Notify me" : "Add to cart"}
</Button>
</CardContent>
</Card>
);
})}
</div>
);
}Pricing tiers with one recommended, without breaking the row's alignment.
src/components/blocks/commerce/commerce-plan-card.tsx"use client";
import { Check } from "lucide-react";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { cn } from "@dashboardpack/core/lib/utils";
const PLANS = [
{ name: "Starter", price: 0, blurb: "For trying things out.", features: ["1 project", "7-day history", "Community support"] },
{ name: "Team", price: 29, blurb: "For a working team.", features: ["10 projects", "90-day history", "Email support", "Shared views"], featured: true },
{ name: "Business", price: 79, blurb: "For an organisation.", features: ["Unlimited projects", "2-year history", "Priority support", "SSO and SCIM", "Audit log"] },
];
/**
* Plan cards with one recommended.
*
* The featured plan is raised with a ring and a badge rather than being made larger — a taller
* card breaks the row's alignment and makes the feature lists stop lining up, which is exactly
* what someone comparing plans is trying to do.
*
* "Free" rather than "$0": the price line is read as a label, and $0/month invites the question
* of what happens at the end of the month.
*/
export function CommercePlanCard() {
return (
<div className="grid gap-4 sm:grid-cols-3">
{PLANS.map((plan) => (
<Card
key={plan.name}
className={cn("relative flex flex-col", plan.featured && "ring-2 ring-primary")}
>
{plan.featured && (
<Badge className="absolute -top-2.5 start-4 text-[10px]">Recommended</Badge>
)}
<CardContent className="flex flex-1 flex-col gap-4 p-5">
<div>
<p className="font-semibold">{plan.name}</p>
<p className="mt-0.5 text-sm text-muted-foreground">{plan.blurb}</p>
</div>
<p className="flex items-baseline gap-1">
<span className="text-2xl font-bold tabular-nums">
{plan.price === 0 ? "Free" : `$${plan.price}`}
</span>
{plan.price > 0 && (
<span className="text-sm text-muted-foreground">/ user / month</span>
)}
</p>
<ul className="flex-1 space-y-2">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm">
<Check className="mt-0.5 size-3.5 shrink-0 text-success" aria-hidden="true" />
<span className="text-muted-foreground">{feature}</span>
</li>
))}
</ul>
<Button variant={plan.featured ? "default" : "outline"} size="sm" className="w-full">
{plan.price === 0 ? "Start free" : `Choose ${plan.name}`}
</Button>
</CardContent>
</Card>
))}
</div>
);
}Saved cards, with an expiring one called out in words rather than colour.
src/components/blocks/commerce/commerce-payment-methods.tsx"use client";
import { CreditCard, Plus } from "lucide-react";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Button } from "@dashboardpack/core/components/ui/button";
const CARDS = [
{ brand: "Visa", last4: "4242", expiry: "04 / 28", primary: true },
{ brand: "Mastercard", last4: "8210", expiry: "11 / 26", primary: false },
{ brand: "Amex", last4: "1005", expiry: "02 / 26", primary: false, expiringSoon: true },
];
/**
* Saved cards, with the one thing a billing page must surface.
*
* An expiring card is called out, because a payment that fails silently at renewal is the most
* expensive avoidable event on a billing page. It is flagged in words, not by colouring the
* expiry date — colour alone is invisible to anyone with reduced colour vision, and this is the
* warning that must not be missed.
*
* Only the last four digits, ever. A template that shows a full card number teaches the wrong
* pattern to whoever copies it.
*/
export function CommercePaymentMethods() {
return (
<div className="space-y-2">
<ul className="divide-y divide-border">
{CARDS.map((card) => (
<li key={card.last4} className="flex items-center gap-3 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<CreditCard className="size-4 text-muted-foreground" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 text-sm font-medium">
{card.brand} ending {card.last4}
{card.primary && (
<Badge variant="secondary" className="text-[10px]">Default</Badge>
)}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Expires {card.expiry}
{card.expiringSoon && (
<span className="ms-1.5 font-semibold text-foreground">· expiring soon</span>
)}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="shrink-0 text-xs"
aria-label={`Manage ${card.brand} ending ${card.last4}`}
>
Manage
</Button>
</li>
))}
</ul>
<Button variant="outline" size="sm" className="gap-1.5">
<Plus className="size-3.5" />
Add payment method
</Button>
</div>
);
}Real list semantics and a connector that mirrors under RTL.
src/components/blocks/commerce/order-timeline.tsx"use client";
import { cn } from "@dashboardpack/core/lib/utils";
import { Check, Circle, Truck } from "lucide-react";
/**
* Fulfilment progress as an ordered list, not a row of styled divs.
*
* It is an `<ol>` because the steps have an inherent order and a screen reader should say "3 of 5".
* The usual implementation — a flex row of divs with connecting borders — conveys the sequence
* visually and nothing else.
*
* The connector is a pseudo-element on each step rather than a separate element between steps, so
* there is no odd-one-out markup at the ends and no `index < length - 1` conditional. Because it is
* positioned with `inset-inline-start`, the whole timeline mirrors under RTL with no JavaScript.
*
* The current step carries `aria-current="step"`, which is what lets assistive tech report where in
* the process the order actually is — otherwise "in progress" is conveyed only by a ring colour.
*/
export function OrderTimeline() {
const steps = [
{ label: "Order placed", when: "12 Mar, 09:14", state: "done" as const },
{ label: "Payment captured", when: "12 Mar, 09:15", state: "done" as const },
{ label: "Picked and packed", when: "12 Mar, 14:02", state: "done" as const },
{ label: "In transit", when: "Expected 15 Mar", state: "current" as const },
{ label: "Delivered", when: "—", state: "todo" as const },
];
return (
<ol className="space-y-0">
{steps.map((step, index) => (
<li key={step.label} className="relative flex gap-4 pb-6 last:pb-0">
{/* The connector: a line from this marker down to the next, hidden on the last item.
`start-*` rather than `left-*`, so it stays under the marker in RTL. */}
{index < steps.length - 1 && (
<span
aria-hidden="true"
className={cn(
"absolute top-8 h-full w-px start-[15px]",
step.state === "done" ? "bg-success/40" : "bg-border",
)}
/>
)}
<span
className={cn(
"z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 bg-background",
step.state === "done" && "border-success text-success",
step.state === "current" && "border-primary text-primary",
step.state === "todo" && "border-border text-muted-foreground",
)}
>
{step.state === "done" ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : step.state === "current" ? (
<Truck className="h-4 w-4" aria-hidden="true" />
) : (
<Circle className="h-2 w-2 fill-current" aria-hidden="true" />
)}
</span>
<div
className="min-w-0 flex-1 pt-1"
aria-current={step.state === "current" ? "step" : undefined}
>
<p
className={cn(
"text-sm",
step.state === "todo" ? "text-muted-foreground" : "font-medium",
)}
>
{step.label}
</p>
<p className="text-xs text-muted-foreground">{step.when}</p>
</div>
</li>
))}
</ol>
);
}Derived from the line items, in integer cents rather than floats.
src/components/blocks/commerce/invoice-totals.tsx"use client";
/**
* An invoice total that adds up, computed rather than typed.
*
* Every figure below the line items is derived — subtotal from the items, tax from the subtotal,
* total from all three. Hardcoding these in a template is how a demo ships with a total that does
* not match its rows, which is the first thing anyone evaluating an invoice component checks.
*
* Money is held in integer cents and divided only at render. `0.1 + 0.2` is `0.30000000000000004`
* in IEEE-754, and an invoice is exactly where that surfaces: summing floats gives totals a cent off
* for no visible reason.
*
* The discount is negative and rendered with a leading minus rather than as a separate "you saved"
* line, so the arithmetic is followable down the column. `tabular-nums` keeps the decimal points in
* a single vertical line, which is the whole reason financial figures are set that way.
*/
export function InvoiceTotals() {
// Integer cents throughout; never floats.
const items = [
{ name: "Platform licence — annual", qty: 1, cents: 240000 },
{ name: "Additional seats", qty: 12, cents: 4500 },
{ name: "Onboarding (one-off)", qty: 1, cents: 75000 },
];
const subtotal = items.reduce((sum, item) => sum + item.qty * item.cents, 0);
const discount = -Math.round(subtotal * 0.1);
const taxable = subtotal + discount;
const tax = Math.round(taxable * 0.21);
const total = taxable + tax;
const money = (cents: number) =>
`${cents < 0 ? "−" : ""}$${(Math.abs(cents) / 100).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
const rows = [
{ label: "Subtotal", cents: subtotal },
{ label: "Volume discount (10%)", cents: discount },
{ label: "VAT (21%)", cents: tax },
];
return (
<div className="rounded-lg border">
<ul className="divide-y">
{items.map((item) => (
<li key={item.name} className="flex items-baseline gap-3 p-3">
<span className="min-w-0 flex-1 text-sm">{item.name}</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{item.qty} × {money(item.cents)}
</span>
<span className="w-24 shrink-0 text-end text-sm tabular-nums">
{money(item.qty * item.cents)}
</span>
</li>
))}
</ul>
<dl className="space-y-2 border-t p-3">
{rows.map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-3">
<dt className="text-sm text-muted-foreground">{row.label}</dt>
<dd className="text-sm tabular-nums">{money(row.cents)}</dd>
</div>
))}
<div className="flex items-baseline justify-between gap-3 border-t pt-2">
<dt className="text-sm font-semibold">Total due</dt>
<dd className="text-lg font-bold tabular-nums">{money(total)}</dd>
</div>
</dl>
</div>
);
}Totals in integer cents, and minus floors at one, not zero.
src/components/blocks/commerce/cart-summary.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Minus, Plus, Trash2 } from "lucide-react";
/**
* A cart whose quantity controls change the totals, in integer cents.
*
* Money is held as cents and divided only at render. Summing floats puts a cart a penny out for no
* visible reason — `0.1 + 0.2` is `0.30000000000000004` — and a cart total is precisely where that
* gets noticed.
*
* Decrement stops at 1 rather than reaching 0, because a quantity of zero is a removal and should
* go through the explicit remove control. Silently deleting a line when someone clicks minus once
* too often is a small hostile surprise.
*
* Each stepper button names its line — "Increase quantity of Aurora Desk Lamp" — since six buttons
* all labelled "+" are indistinguishable when a screen reader lists them.
*/
export function CartSummary() {
const [lines, setLines] = useState([
{ id: "lamp", name: "Aurora Desk Lamp", cents: 8900, qty: 2 },
{ id: "chair", name: "Ridge Task Chair", cents: 42000, qty: 1 },
{ id: "mat", name: "Felt Desk Mat", cents: 3200, qty: 3 },
]);
const subtotal = lines.reduce((sum, line) => sum + line.cents * line.qty, 0);
const shipping = subtotal > 50000 ? 0 : 995;
const tax = Math.round((subtotal + shipping) * 0.21);
const total = subtotal + shipping + tax;
const money = (cents: number) =>
`$${(cents / 100).toLocaleString("en-US", { minimumFractionDigits: 2 })}`;
function setQty(id: string, delta: number) {
setLines((previous) =>
previous.map((line) =>
// Floors at 1: zero is a removal, and that has its own button.
line.id === id ? { ...line, qty: Math.max(1, line.qty + delta) } : line,
),
);
}
return (
<div className="space-y-4 rounded-lg border p-4">
<ul className="divide-y">
{lines.map((line) => (
<li key={line.id} className="flex flex-wrap items-center gap-3 py-3 first:pt-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{line.name}</p>
<p className="text-xs tabular-nums text-muted-foreground">{money(line.cents)} each</p>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
aria-label={`Decrease quantity of ${line.name}`}
disabled={line.qty === 1}
onClick={() => setQty(line.id, -1)}
>
<Minus className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
<span className="w-8 text-center text-sm tabular-nums">{line.qty}</span>
<Button
variant="outline"
size="sm"
aria-label={`Increase quantity of ${line.name}`}
onClick={() => setQty(line.id, 1)}
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
<span className="w-20 text-end text-sm font-medium tabular-nums">
{money(line.cents * line.qty)}
</span>
<Button
variant="ghost"
size="sm"
aria-label={`Remove ${line.name}`}
onClick={() => setLines((previous) => previous.filter((entry) => entry.id !== line.id))}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</Button>
</li>
))}
</ul>
<dl className="space-y-1.5 border-t pt-3">
<div className="flex justify-between gap-2 text-sm">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{money(subtotal)}</dd>
</div>
<div className="flex justify-between gap-2 text-sm">
<dt className="text-muted-foreground">Shipping</dt>
<dd className="tabular-nums">
{shipping === 0 ? <span className="text-success">Free</span> : money(shipping)}
</dd>
</div>
<div className="flex justify-between gap-2 text-sm">
<dt className="text-muted-foreground">VAT (21%)</dt>
<dd className="tabular-nums">{money(tax)}</dd>
</div>
<div className="flex justify-between gap-2 border-t pt-2">
<dt className="text-sm font-semibold">Total</dt>
<dd className="text-lg font-bold tabular-nums">{money(total)}</dd>
</div>
</dl>
{shipping > 0 && (
<p className="text-xs text-muted-foreground">
Add {money(50000 - subtotal)} more for free shipping.
</p>
)}
<Button className="w-full">Checkout</Button>
</div>
);
}Annual prices derived, so the saving badge cannot lie.
src/components/blocks/commerce/plan-compare-cards.tsx"use client";
import { useState } from "react";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
import { Check, Minus } from "lucide-react";
/**
* Pricing cards with a billing-period switch that states the saving.
*
* Annual prices are derived from the monthly figure and a discount rate, so the "save 20%" badge
* and the number can never contradict each other — which they do in most pricing sections, because
* both are typed by hand.
*
* Absent features get an explicit dash rather than being omitted. Different-length lists make the
* cards impossible to compare row by row, which is the entire job of a pricing table.
*
* The period switch is a `radiogroup`: exactly one period applies, and two `aria-pressed` toggles
* would let assistive tech report both as active.
*/
export function PlanCompareCards() {
const [annual, setAnnual] = useState(true);
const DISCOUNT = 0.2;
const plans = [
{ name: "Starter", monthly: 19, blurb: "For a single project", features: [true, true, false, false] },
{ name: "Pro", monthly: 49, blurb: "For a small team", features: [true, true, true, false], popular: true },
{ name: "Scale", monthly: 149, blurb: "For an organisation", features: [true, true, true, true] },
];
const featureNames = ["Unlimited projects", "Saved views & export", "RBAC and audit log", "SSO and SLA"];
return (
<div className="space-y-4">
<div className="flex items-center justify-center gap-3">
<div role="radiogroup" aria-label="Billing period" className="inline-flex rounded-md bg-muted p-0.5">
{[
{ id: "monthly", label: "Monthly", value: false },
{ id: "annual", label: "Annual", value: true },
].map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={annual === option.value}
tabIndex={annual === option.value ? 0 : -1}
onClick={() => setAnnual(option.value)}
className={cn(
"rounded px-3 py-1 text-xs font-medium transition-colors",
annual === option.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
{annual && (
<Badge variant="success" className="text-[10px]">
Save {Math.round(DISCOUNT * 100)}%
</Badge>
)}
</div>
<div className="grid gap-4 sm:grid-cols-3">
{plans.map((plan) => {
// Derived, so the badge and the price cannot disagree.
const price = annual ? Math.round(plan.monthly * (1 - DISCOUNT)) : plan.monthly;
return (
<div
key={plan.name}
className={cn(
"flex flex-col gap-4 rounded-lg border p-4",
plan.popular && "border-primary shadow-sm",
)}
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">{plan.name}</p>
{plan.popular && <Badge className="text-[10px]">Most popular</Badge>}
</div>
<p className="text-xs text-muted-foreground">{plan.blurb}</p>
</div>
<p className="flex items-baseline gap-1">
<span className="text-3xl font-bold tabular-nums">${price}</span>
<span className="text-xs text-muted-foreground">/ month</span>
</p>
<ul className="flex-1 space-y-1.5">
{featureNames.map((feature, index) => (
<li key={feature} className="flex items-start gap-2 text-xs">
{plan.features[index] ? (
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-success" aria-hidden="true" />
) : (
<Minus className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground/50" aria-hidden="true" />
)}
<span className={plan.features[index] ? undefined : "text-muted-foreground"}>
{feature}
<span className="sr-only">
{plan.features[index] ? " — included" : " — not included"}
</span>
</span>
</li>
))}
</ul>
<Button variant={plan.popular ? "default" : "outline"} className="w-full">
Choose {plan.name}
</Button>
</div>
);
})}
</div>
</div>
);
}