Product details
One product in full: its pictures, price, options, stock and the buy button.
Details with angle chips
product-details-01One product in full without a photograph: a large drawing that three chips turn — front, side, close up — each on a tinted ground of its own, beside the trail to its aisle, its score, price, stock, the shop's words and a quantity and Add to cart.
import * as React from "react"
import Link from "next/link"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { departmentHref } from "@/lib/dashboards/ecommerce/storefront"
import { formatNumber } from "@/lib/format"
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Card } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { PRODUCT_ART_TINTS, ProductArt, productArtTint } from "@/components/ui/product-art"
import { Rating } from "@/components/ui/rating"
import { AngleViewer, BuyBox } from "./product-details-01.client"
import { COPY, productDetails } from "./product-details-01.data"
/** The ways the drawing is turned: mirrored for the side, enlarged to nearly twice the tile and cropped for a close look. */
const VIEWS = [
{ id: "front", label: "Front", words: "from the front", turn: "" },
{ id: "side", label: "Side", words: "from the side", turn: "[&>svg]:-scale-x-100" },
// 182% is 1.35², the close-up this view has always shown: the old `h-[135%]`
// took 135% of a row that had itself grown to 135% of the tile. It now sits
// centred on the tile, where the old crop sat low and to the right.
{ id: "close", label: "Close up", words: "close up", turn: "[&>svg]:size-[182%]" },
] as const
const STOCK = { in: "In stock", low: "Low stock", out: "Sold out" } as const
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/**
* One product in full, without a photograph: a large drawing that three
* chips turn — front, side, close up — each on its own tinted ground, beside
* the trail back to its aisle, its name, its score, its price and stock, the
* shop's words about it and the way to buy it.
*
* A server component: the product and every figure come from
* `./product-details-01.data`; every view is drawn here and handed to the
* island as a picture, so the drawings never ship as client code.
*/
export function ProductDetails01() {
const titleId = React.useId()
const product = productDetails()
const first = PRODUCT_ART_TINTS.indexOf(productArtTint(product.name))
const views = VIEWS.map((view, index) => {
const tint = PRODUCT_ART_TINTS[(first + index) % PRODUCT_ART_TINTS.length]
return {
id: view.id,
label: view.label,
picture: (
<ProductArt
name={product.name}
category={product.category}
label={`${product.name}, ${view.words}`}
tint={tint}
className={cn("rounded-none", view.turn)}
/>
),
}
})
return (
<section
data-section="product-details-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl items-start gap-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-14">
<Card className="gap-0 p-2">
<AngleViewer views={views} />
</Card>
<div className="flex flex-col gap-6 lg:pt-2">
<div className="flex flex-col gap-3">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Link href="/products" className="focus-ring rounded-sm hover:text-foreground">
Shop
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<Link href={departmentHref(product.category)} className="focus-ring rounded-sm hover:text-foreground">
{product.category}
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<Link
href={departmentHref(product.category, product.subcategory)}
className="focus-ring rounded-sm hover:text-foreground"
>
{product.subcategory}
</Link>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h2 id={titleId} className="type-display text-3xl text-balance md:text-4xl">
{product.name}
</h2>
{product.ratings ? (
<div className="flex items-center gap-1.5">
<Rating value={Math.round(product.rating)} size="sm" aria-hidden="true" />
<span aria-hidden="true" className="text-sm text-muted-foreground tabular-nums">
{score(product.rating)} from {product.ratings} ratings
</span>
<span className="sr-only">
Rated {score(product.rating)} out of 5 from {product.ratings} ratings
</span>
</div>
) : null}
</div>
<div className="flex flex-col gap-1">
<Price amount={product.priceCents / 100} size="lg" />
<p className="text-sm text-muted-foreground tabular-nums">
{STOCK[product.stockState]}
{product.stockState === "low" ? `, ${formatNumber(product.stock)} left` : null} · Ships from {product.supplier}
</p>
</div>
<p className="text-sm text-pretty text-muted-foreground md:text-base">{COPY.description}</p>
<ul className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
{COPY.facts.map((fact) => (
<li key={fact} className="flex gap-2">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{fact}
</li>
))}
</ul>
<div className="border-t border-border pt-6">
<BuyBox name={product.name} stock={product.stock} />
</div>
</div>
</div>
</section>
)
}
export default ProductDetails01"use client"
import * as React from "react"
import { MinusIcon, PlusIcon, ShoppingBagIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
/** One way of seeing the product: its chip's word and its picture, drawn on the server. */
export type View = { id: string; label: string; picture: React.ReactNode }
/**
* The picture without a gallery: one large drawing and a row of chips that
* turn it — front, side, close up — each on a ground of its own. The chips
* are one single-choice toggle group, moved through with the arrow keys; the
* chosen chip wears the brand tint and the 500 weight. Every picture arrives
* drawn; the island only chooses which is shown.
*/
export function AngleViewer({ views }: { views: View[] }) {
const [view, setView] = React.useState(views[0]?.id)
const labelId = React.useId()
const current = views.find((entry) => entry.id === view) ?? views[0]
if (!current) return null
return (
<div className="flex flex-col gap-2">
<div data-view={current.id} className="overflow-hidden rounded-lg ring-1 ring-border">
{current.picture}
</div>
<div className="flex items-center justify-between gap-3 px-1 py-1">
<span id={labelId} className="text-xs text-muted-foreground">
View
</span>
<ToggleGroup
aria-labelledby={labelId}
variant="outline"
size="sm"
spacing={1}
value={[current.id]}
// Pressing the chosen view again hands back an empty group; a view
// stays chosen until another is.
onValueChange={(next) => {
const [picked] = next
if (picked) setView(picked)
}}
>
{views.map((entry) => (
<ToggleGroupItem
key={entry.id}
value={entry.id}
className="rounded-full border-border px-3 aria-pressed:border-transparent aria-pressed:bg-brand-muted aria-pressed:font-medium aria-pressed:text-foreground"
>
{entry.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
</div>
)
}
/**
* How many, and Add to cart — the band's one filled button. The quantity is
* a stepper between two named buttons, bounded by one and by the stock on
* hand; at either end its button stays focusable, marked unavailable, and
* the upper one says why. The count is read out as it changes, and what the
* last press did goes in an always-mounted status line.
*/
export function BuyBox({ name, stock }: { name: string; stock: number }) {
const [quantity, setQuantity] = React.useState(1)
const [message, setMessage] = React.useState("")
const labelId = React.useId()
const limitId = React.useId()
const full = quantity >= stock
function add() {
setMessage(quantity === 1 ? `Added the ${name} to your cart` : `Added ${quantity} of the ${name} to your cart`)
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
<span id={labelId} className="sr-only">
Quantity
</span>
<div role="group" aria-labelledby={labelId} className="inline-flex h-10 items-center rounded-lg bg-card p-0.5 ring-1 ring-border">
<Button
variant="ghost"
size="icon"
aria-label="Decrease quantity"
disabled={quantity <= 1}
focusableWhenDisabled
onClick={() => setQuantity((current) => Math.max(1, current - 1))}
>
<MinusIcon />
</Button>
<span aria-live="polite" className="w-9 text-center text-sm font-medium text-foreground tabular-nums">
{quantity}
</span>
<Button
variant="ghost"
size="icon"
aria-label="Increase quantity"
aria-describedby={full ? limitId : undefined}
disabled={full}
focusableWhenDisabled
onClick={() => setQuantity((current) => Math.min(stock, current + 1))}
>
<PlusIcon />
</Button>
</div>
<Button size="lg" className="h-10 flex-1 sm:flex-none sm:px-6" disabled={stock <= 0} onClick={add}>
<ShoppingBagIcon data-icon="inline-start" />
Add to cart
</Button>
</div>
{full ? (
<p id={limitId} className="text-xs text-muted-foreground tabular-nums">
All {stock} in stock
</p>
) : null}
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{message}
</p>
</div>
)
}/**
* What `product-details-01` reads: one product in full, off the storefront's
* shelf — its aisle, price, stock, supplier and the score buyers gave it —
* and the shop's own words about it. The words are copy, kept here beside the
* name they describe; every figure is the store's.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes plain props; it never imports this module.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** The product the band shows, and what the shop says about it: change them together. */
export const PRODUCT = "Camping Tent 4-Person"
export const COPY = {
description:
"A dome for four with a door and a porch on each side, so nobody climbs over anybody to get out. The fly goes up first and the inner clips in beneath it, dry.",
facts: [
"Sleeps four on a 240 by 210 cm floor",
"Two doors, and a porch at each for boots and packs",
"Pitches in about ten minutes",
"Packs to 5.2 kg in its own bag",
],
} as const
/** The product as the band shows it. */
export type DetailsProduct = Pick<
ShelfProduct,
"id" | "sku" | "name" | "category" | "subcategory" | "supplier" | "priceCents" | "stock" | "stockState" | "rating" | "ratings" | "href"
>
/** `PRODUCT` off the shelf. */
export function productDetails(): DetailsProduct {
const product = storeShelf().find((row) => row.name === PRODUCT)
if (!product) throw new Error(`product-details-01 shows "${PRODUCT}", which is not on the shelf`)
const { id, sku, name, category, subcategory, supplier, priceCents, stock, stockState, rating, ratings, href } = product
return { id, sku, name, category, subcategory, supplier, priceCents, stock, stockState, rating, ratings, href }
}npx shadcn@latest add @vibra/product-details-01Details with a spec sheet
product-details-02One product by its specification: the product held in view on the left, and on the right its whole spec sheet in groups, with one control that shows every measure in metric or imperial, and the store's own record of it as the last group.
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { Card, CardContent } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { Rating } from "@/components/ui/rating"
import { AddToCart, SpecSheet } from "./product-details-02.client"
import { DESCRIPTION, SPECS, recordRows, specProduct } from "./product-details-02.data"
const STOCK = { in: "In stock", low: "Low stock", out: "Sold out" } as const
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/**
* One product by its specification: the product on the left — its picture,
* name, score, price and stock, and Add to cart — held in view from `lg` as
* the sheet beside it scrolls; on the right the whole spec sheet, grouped,
* with one control that shows every measure in metric or imperial, and the
* store's own record of the product as its last group.
*
* A server component: the product and its record come from
* `./product-details-02.data`, with the spec sheet the shop wrote for it.
*/
export function ProductDetails02() {
const titleId = React.useId()
const product = specProduct()
return (
<section
data-section="product-details-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl items-start gap-6 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-10">
<Card className="gap-2 p-2 lg:sticky lg:top-6">
<CardContent className="flex-none p-0">
<ProductArt name={product.name} category={product.category} className="aspect-[4/3] rounded-none" />
</CardContent>
<div className="flex flex-col gap-4 p-3 md:p-4">
<div className="flex flex-col gap-1.5">
<p className="text-xs text-muted-foreground">
{product.category} · <span className="font-mono">{product.sku}</span>
</p>
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
{product.name}
</h2>
{product.ratings ? (
<div className="flex items-center gap-1.5">
<Rating value={Math.round(product.rating)} size="sm" aria-hidden="true" />
<span aria-hidden="true" className="text-sm text-muted-foreground tabular-nums">
{score(product.rating)} from {product.ratings} ratings
</span>
<span className="sr-only">
Rated {score(product.rating)} out of 5 from {product.ratings} ratings
</span>
</div>
) : null}
</div>
<p className="text-sm text-pretty text-muted-foreground">{DESCRIPTION}</p>
<div className="flex flex-col gap-1">
<Price amount={product.priceCents / 100} size="lg" />
<p className="text-sm text-muted-foreground tabular-nums">
{STOCK[product.stockState]}
{product.stockState === "low" ? `, ${formatNumber(product.stock)} left` : null}
</p>
</div>
<AddToCart name={product.name} stock={product.stock} />
</div>
</Card>
<SpecSheet heading="Specifications" groups={[...SPECS, recordRows(product)]} />
</div>
</section>
)
}
export default ProductDetails02"use client"
import * as React from "react"
import { ShoppingBagIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { SegmentedControl } from "@/components/ui/segmented-control"
import type { Measure, SpecGroup, SpecValue } from "./product-details-02.data"
export type Units = "metric" | "imperial"
/** Each kind of measure in either system: its unit, and how a value taken in metric converts. */
const UNITS: Record<Measure["kind"], Record<Units, { unit: string; convert: (value: number) => number; digits: number }>> = {
length: {
metric: { unit: "cm", convert: (cm) => cm, digits: 1 },
imperial: { unit: "in", convert: (cm) => cm / 2.54, digits: 1 },
},
mass: {
metric: { unit: "kg", convert: (kg) => kg, digits: 1 },
imperial: { unit: "lb", convert: (kg) => kg * 2.204_62, digits: 0 },
},
speed: {
metric: { unit: "mm/s", convert: (mm) => mm, digits: 0 },
imperial: { unit: "in/s", convert: (mm) => mm / 25.4, digits: 1 },
},
}
/** A measure in the chosen system: "140 × 70 cm", read as "140 by 70 cm"; "71 to 121 cm". */
export function formatMeasure(measure: Measure, units: Units): React.ReactNode {
const { unit, convert, digits } = UNITS[measure.kind][units]
const values = measure.values.map((value) => formatNumber(convert(value), { maximumFractionDigits: digits }))
if (values.length === 1) return `${values[0]} ${unit}`
if (measure.join === "to") return `${values.join(" to ")} ${unit}`
return (
<>
{values.map((value, index) => (
<React.Fragment key={index}>
{index > 0 ? (
<>
{" "}
<span aria-hidden="true">×</span>
<span className="sr-only">by</span>{" "}
</>
) : null}
{value}
</React.Fragment>
))}{" "}
{unit}
</>
)
}
const show = (value: SpecValue, units: Units) => (typeof value === "string" ? value : formatMeasure(value, units))
/**
* The spec sheet: a card whose strip holds its title and one control that
* shows every measure in metric or imperial — converted from the one measure
* taken, never typed twice — and whose sheet holds every group under its own
* heading, each row a term and its value on a ruled line. The last group is
* the store's record, which no unit changes.
*/
export function SpecSheet({ groups, heading }: { groups: SpecGroup[]; heading: string }) {
const [units, setUnits] = React.useState<Units>("metric")
return (
<Card>
<CardHeader>
<CardTitle>
<h3>{heading}</h3>
</CardTitle>
<CardAction>
<SegmentedControl
aria-label="Units"
size="sm"
value={units}
onValueChange={(next) => setUnits(next as Units)}
options={[
{ value: "metric", label: "Metric" },
{ value: "imperial", label: "Imperial" },
]}
/>
</CardAction>
</CardHeader>
<CardContent data-units={units} className="flex flex-col gap-6 md:p-6">
{groups.map((group) => (
<div key={group.label} className="flex flex-col gap-1">
<h4 className="type-eyebrow">{group.label}</h4>
<dl className="divide-y divide-border">
{group.rows.map((row) => (
<div key={row.label} className="grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-4 py-2.5 text-sm">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className={cn("text-foreground tabular-nums", row.mono && "font-mono")}>{show(row.value, units)}</dd>
</div>
))}
</dl>
</div>
))}
</CardContent>
</Card>
)
}
/** Add to cart, and what it did in an always-mounted status line. */
export function AddToCart({ name, stock }: { name: string; stock: number }) {
const [message, setMessage] = React.useState("")
return (
<div className="flex flex-col gap-2">
<Button size="lg" className="h-10 w-full sm:w-fit sm:px-6" disabled={stock <= 0} onClick={() => setMessage(`Added the ${name} to your cart`)}>
<ShoppingBagIcon data-icon="inline-start" />
Add to cart
</Button>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{message}
</p>
</div>
)
}/**
* What `product-details-02` reads: one product and its specification. The
* shop's spec sheet is copy — the maker's measures and materials, kept here
* beside the name they describe, every measure in the unit it was taken in —
* and the last group is the store's own record of the product, read off the
* storefront's shelf: its SKU, where it ships from, the stock on hand, what
* it sold and what buyers rated it.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes plain props; it never imports this module.
*/
import { SHELF_DAYS, storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
import { formatNumber } from "@/lib/format"
/** A measure in the unit it was taken in: centimetres, kilograms, millimetres a second. */
export type Measure = {
kind: "length" | "mass" | "speed"
values: readonly number[]
/** How two values join: a size is one by the other, a range one to the other. */
join?: "by" | "to"
}
/** A spec row's value: a measure the reader may see in either system, or words. */
export type SpecValue = Measure | string
/** One row of the sheet; `mono` sets an identifier, a SKU, in the mono face. */
export type SpecRow = { label: string; value: SpecValue; mono?: boolean }
export type SpecGroup = { label: string; rows: readonly SpecRow[] }
/** The product the band shows, and the shop's words and spec sheet for it: change them together. */
export const PRODUCT = "Standing Desk"
export const DESCRIPTION =
"A desk that goes from sitting to standing height in under ten seconds, on two motors that keep the top level all the way up."
export const SPECS: readonly SpecGroup[] = [
{
label: "Size",
rows: [
{ label: "Desktop", value: { kind: "length", values: [140, 70], join: "by" } },
{ label: "Desktop thickness", value: { kind: "length", values: [2.5] } },
{ label: "Height", value: { kind: "length", values: [71, 121], join: "to" } },
{ label: "Weight", value: { kind: "mass", values: [32] } },
],
},
{
label: "Frame and motors",
rows: [
{ label: "Load it holds", value: { kind: "mass", values: [120] } },
{ label: "Lift speed", value: { kind: "speed", values: [38] } },
{ label: "Motors", value: "Two, one in each leg" },
{ label: "Height presets", value: "Four, on the keypad" },
{ label: "Noise while lifting", value: "Under 45 dB" },
],
},
{
label: "Materials and care",
rows: [
{ label: "Top", value: "Oak veneer on engineered wood" },
{ label: "Frame", value: "Powder-coated steel" },
{ label: "Care", value: "Wipe with a damp cloth; no polish" },
{ label: "Warranty", value: "Five years on the frame, two on the motors" },
],
},
]
/** The product as the band shows it. */
export type SpecProduct = Pick<
ShelfProduct,
"id" | "sku" | "name" | "category" | "supplier" | "priceCents" | "stock" | "stockState" | "sold" | "rating" | "ratings" | "href"
>
/** `PRODUCT` off the shelf. */
export function specProduct(): SpecProduct {
const product = storeShelf().find((row) => row.name === PRODUCT)
if (!product) throw new Error(`product-details-02 shows "${PRODUCT}", which is not on the shelf`)
const { id, sku, name, category, supplier, priceCents, stock, stockState, sold, rating, ratings, href } = product
return { id, sku, name, category, supplier, priceCents, stock, stockState, sold, rating, ratings, href }
}
/** The store's own record of the product, as spec rows: every figure read off the shelf. */
export function recordRows(product: SpecProduct): SpecGroup {
const mean = formatNumber(product.rating, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const score = product.ratings ? `${mean} out of 5, from ${product.ratings} ratings` : "Not rated yet"
return {
label: "From our records",
rows: [
{ label: "SKU", value: product.sku, mono: true },
{ label: "Ships from", value: product.supplier },
{ label: "In stock", value: `${formatNumber(product.stock)} ${product.stock === 1 ? "unit" : "units"}` },
{ label: `Sold, last ${SHELF_DAYS} days`, value: formatNumber(product.sold) },
{ label: "Rated", value: score },
],
}
}npx shadcn@latest add @vibra/product-details-02Details with sizes and stock
product-details-03One product that comes in sizes: every size a tile that says its own stock, a quantity bounded by the size chosen, Add to bag — and on a size that has sold out, a Notify me toggle.
import * as React from "react"
import Link from "next/link"
import { RulerIcon } from "lucide-react"
import { departmentHref } from "@/lib/dashboards/ecommerce/storefront"
import { formatNumber } from "@/lib/format"
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Card, CardContent } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { Rating } from "@/components/ui/rating"
import { SizeBuyBox } from "./product-details-03.client"
import { COPY, sizedDetails } from "./product-details-03.data"
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/**
* One product that comes in sizes: its picture beside the trail back to its
* aisle, its name, score, price and the shop's words about it, then every
* size as a tile that says its own stock, a quantity bounded by the size
* chosen, and Add to bag — or, on a size that has sold out, a Notify me
* toggle.
*
* A server component: the product and its stock by size come from
* `./product-details-03.data`; the island holds the choice of size, the
* quantity and the status line.
*/
export function ProductDetails03() {
const titleId = React.useId()
const product = sizedDetails()
return (
<section
data-section="product-details-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl items-start gap-8 lg:grid-cols-2 lg:gap-14">
<Card className="gap-0 p-2">
<CardContent className="p-0">
<ProductArt name={product.name} category={product.category} className="rounded-none" />
</CardContent>
</Card>
<div className="flex flex-col gap-6 lg:pt-2">
<div className="flex flex-col gap-3">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Link href="/products" className="focus-ring rounded-sm hover:text-foreground">
Shop
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<Link href={departmentHref(product.category)} className="focus-ring rounded-sm hover:text-foreground">
{product.category}
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<Link
href={departmentHref(product.category, product.subcategory)}
className="focus-ring rounded-sm hover:text-foreground"
>
{product.subcategory}
</Link>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h2 id={titleId} className="type-display text-3xl text-balance md:text-4xl">
{product.name}
</h2>
{product.ratings ? (
<div className="flex items-center gap-1.5">
<Rating value={Math.round(product.rating)} size="sm" aria-hidden="true" />
<span aria-hidden="true" className="text-sm text-muted-foreground tabular-nums">
{score(product.rating)} from {product.ratings} ratings
</span>
<span className="sr-only">
Rated {score(product.rating)} out of 5 from {product.ratings} ratings
</span>
</div>
) : null}
</div>
<Price amount={product.priceCents / 100} size="lg" />
<p className="text-sm text-pretty text-muted-foreground md:text-base">{COPY.description}</p>
<div className="border-t border-border pt-6">
<SizeBuyBox name={product.name} sizeLabel={product.sizeLabel} sizes={product.sizes} />
</div>
<div className="flex flex-col gap-3 border-t border-border pt-6 text-sm">
<p className="flex gap-2 text-foreground">
<RulerIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{COPY.fit}
</p>
<ul className="flex list-disc flex-col gap-1 pl-6 text-muted-foreground marker:text-muted-foreground">
{COPY.facts.map((fact) => (
<li key={fact}>{fact}</li>
))}
</ul>
</div>
</div>
</div>
</section>
)
}
export default ProductDetails03"use client"
import * as React from "react"
import { BellIcon, BellRingIcon, MinusIcon, PlusIcon, ShoppingBagIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import type { SizedDetails } from "./product-details-03.data"
/** How few units of a size are worth counting out loud; above it a size is simply in stock. */
const FEW = 5
const left = (units: number) => (units === 0 ? "Sold out" : units <= FEW ? `${units} left` : "In stock")
/**
* The sizes, each a tile that says its own stock, and the way to buy the one
* chosen. The tiles are one single-choice toggle group — the arrow keys move
* — the chosen one in the brand tint and the 500 weight; a size with none
* left is struck through and still choosable, because choosing it is how a
* shopper asks to hear when it is back. The quantity is bounded by the stock
* of the size chosen, and choosing a smaller size brings it down and says
* so. An always-mounted status line says the stock of the size chosen, then
* what the last press did.
*/
export function SizeBuyBox({ name, sizeLabel, sizes }: Pick<SizedDetails, "name" | "sizeLabel" | "sizes">) {
const [size, setSize] = React.useState<string | null>(null)
const [quantity, setQuantity] = React.useState(1)
const [message, setMessage] = React.useState<string | null>(null)
const [watching, setWatching] = React.useState<ReadonlySet<string>>(() => new Set())
const groupRef = React.useRef<HTMLDivElement>(null)
const labelId = React.useId()
const quantityId = React.useId()
const chosen = sizes.find((entry) => entry.size === size)
const cap = chosen ? chosen.units : Math.max(0, ...sizes.map((entry) => entry.units))
const stockLine = !chosen
? `Choose a ${sizeLabel.toLowerCase()}`
: chosen.units === 0
? `${chosen.size} is sold out`
: chosen.units <= FEW
? `Only ${chosen.units} left in ${chosen.size}`
: `${chosen.size} is in stock`
function choose(next: string) {
const entry = sizes.find((candidate) => candidate.size === next)
if (!entry) return
setSize(next)
if (entry.units > 0 && quantity > entry.units) {
setQuantity(entry.units)
setMessage(`Only ${entry.units} left in ${next}: the quantity is now ${entry.units}`)
} else setMessage(null)
}
function add() {
if (!chosen) {
setMessage(`Choose a ${sizeLabel.toLowerCase()} first`)
groupRef.current?.querySelector<HTMLButtonElement>("button")?.focus()
return
}
setMessage(
quantity === 1
? `Added the ${name} in ${chosen.size} to your bag`
: `Added ${quantity} of the ${name} in ${chosen.size} to your bag`
)
}
function toggleWatch() {
if (!chosen) return
const next = new Set(watching)
const on = !next.delete(chosen.size)
if (on) next.add(chosen.size)
setWatching(next)
setMessage(on ? `We'll email you when ${chosen.size} is back` : `You won't be emailed about ${chosen.size}`)
}
return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<span id={labelId} className="text-sm font-medium text-foreground">
{sizeLabel}
</span>
<ToggleGroup
ref={groupRef}
aria-labelledby={labelId}
variant="outline"
spacing={2}
value={size ? [size] : []}
// Pressing the chosen size again hands back an empty group; a size
// once chosen stays chosen until another is.
onValueChange={(next) => {
const [picked] = next
if (picked) choose(picked)
}}
className="grid w-full grid-cols-3 gap-2 sm:grid-cols-6"
>
{sizes.map((entry) => (
<ToggleGroupItem
key={entry.size}
value={entry.size}
data-sold-out={entry.units === 0 || undefined}
className="group/size h-auto w-full flex-col gap-0.5 border-border py-2 font-normal aria-pressed:border-transparent aria-pressed:bg-brand-muted"
>
<span className="text-sm text-foreground tabular-nums group-aria-pressed/size:font-semibold group-data-[sold-out]/size:text-muted-foreground group-data-[sold-out]/size:line-through">
{entry.size}
</span>
<span className="sr-only">,</span>{" "}
<span className="text-xs text-muted-foreground tabular-nums">{left(entry.units)}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{message ?? stockLine}
</p>
</div>
{chosen?.units === 0 ? (
<Button
size="lg"
variant="outline"
aria-pressed={watching.has(chosen.size)}
className="h-10 w-full sm:w-fit aria-pressed:border-transparent aria-pressed:bg-brand-muted"
onClick={toggleWatch}
>
{watching.has(chosen.size) ? <BellRingIcon data-icon="inline-start" /> : <BellIcon data-icon="inline-start" />}
Notify me when {chosen.size} is back
</Button>
) : (
<div className="flex flex-wrap items-center gap-3">
<span id={quantityId} className="sr-only">
Quantity
</span>
<div role="group" aria-labelledby={quantityId} className="inline-flex h-10 items-center rounded-lg bg-card p-0.5 ring-1 ring-border">
<Button
variant="ghost"
size="icon"
aria-label="Decrease quantity"
disabled={quantity <= 1}
focusableWhenDisabled
onClick={() => setQuantity((current) => Math.max(1, current - 1))}
>
<MinusIcon />
</Button>
<span aria-live="polite" className="w-9 text-center text-sm font-medium text-foreground tabular-nums">
{quantity}
</span>
<Button
variant="ghost"
size="icon"
aria-label="Increase quantity"
disabled={quantity >= cap}
focusableWhenDisabled
onClick={() => setQuantity((current) => Math.min(cap, current + 1))}
>
<PlusIcon />
</Button>
</div>
<Button size="lg" className="h-10 flex-1 sm:flex-none sm:px-6" onClick={add}>
<ShoppingBagIcon data-icon="inline-start" />
Add to bag
</Button>
</div>
)}
</div>
)
}/**
* What `product-details-03` reads: one product that comes in sizes, with its
* stock split across them. The catalogue keeps one stock count per product,
* so the split is the storefront's rule (`sizesOf`): the usual curve of the
* run, shared out in whole units that add up to the count exactly. A size it
* leaves at none is sold out, and a size with few left says how few. The
* shop's words about the product are copy, kept here beside its name.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes plain props; it never imports this module.
*/
import {
sizeRunOf,
sizesOf,
storeShelf,
type ShelfProduct,
type SizeStock,
} from "@/lib/dashboards/ecommerce/storefront"
/** The product the band shows, and what the shop says about it: change them together. */
export const PRODUCT = "Denim Jacket"
export const COPY = {
description:
"Rigid 13 oz denim cut straight, with room for a sweater underneath. It starts stiff and softens to your shape over a few weeks of wear.",
fit: "A straight fit. Between two sizes, take the larger.",
facts: ["100% cotton denim, 13 oz", "Two chest pockets, two hand pockets", "Machine wash cold, inside out"],
} as const
/** The product as the band shows it, with its stock size by size. */
export type SizedDetails = Pick<
ShelfProduct,
"id" | "sku" | "name" | "category" | "subcategory" | "priceCents" | "stock" | "rating" | "ratings" | "href"
> & {
/** What the picker calls a size: "Size", or "US size" for shoes. */
sizeLabel: string
sizes: SizeStock[]
}
/** `PRODUCT` off the shelf, with its stock by size. */
export function sizedDetails(): SizedDetails {
const product = storeShelf().find((row) => row.name === PRODUCT)
const run = product && sizeRunOf(product)
if (!product || !run) throw new Error(`product-details-03 shows "${PRODUCT}", which is not on the shelf in sizes`)
const { id, sku, name, category, subcategory, priceCents, stock, rating, ratings, href } = product
return { id, sku, name, category, subcategory, priceCents, stock, rating, ratings, href, sizeLabel: run.label, sizes: sizesOf(product, run) }
}npx shadcn@latest add @vibra/product-details-03Details with an added-to-cart confirmation
product-details-04One product and what adding it does: a quantity and Add to cart, then a confirmation in a status region — what went in, what the cart holds, how far it is from free delivery — with View cart, Checkout and Undo.
import * as React from "react"
import Link from "next/link"
import { departmentHref } from "@/lib/dashboards/ecommerce/storefront"
import { formatNumber } from "@/lib/format"
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Card, CardContent } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { Rating } from "@/components/ui/rating"
import { CartConfirmation } from "./product-details-04.client"
import { COPY, POLICY, cartProduct } from "./product-details-04.data"
const STOCK = { in: "In stock", low: "Low stock", out: "Sold out" } as const
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/**
* One product and what adding it does: its picture beside its name, score,
* price, stock, the shop's words and the four figures a buyer compares it by,
* then a quantity and Add to cart — and, once pressed, a confirmation of what
* went in, what the cart holds and how far it is from free delivery, with
* the ways on and an Undo.
*
* A server component: the product, its stock and the delivery terms come from
* `./product-details-04.data`; the picture on the confirmation is drawn here.
*/
export function ProductDetails04() {
const titleId = React.useId()
const product = cartProduct()
return (
<section
data-section="product-details-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl items-start gap-8 lg:grid-cols-2 lg:gap-14">
<Card className="gap-0 p-2">
<CardContent className="p-0">
<ProductArt name={product.name} category={product.category} className="rounded-none" />
</CardContent>
</Card>
<div className="flex flex-col gap-6 lg:pt-2">
<div className="flex flex-col gap-3">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Link href="/products" className="focus-ring rounded-sm hover:text-foreground">
Shop
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<Link href={departmentHref(product.category)} className="focus-ring rounded-sm hover:text-foreground">
{product.category}
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<Link
href={departmentHref(product.category, product.subcategory)}
className="focus-ring rounded-sm hover:text-foreground"
>
{product.subcategory}
</Link>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h2 id={titleId} className="type-display text-3xl text-balance md:text-4xl">
{product.name}
</h2>
{product.ratings ? (
<div className="flex items-center gap-1.5">
<Rating value={Math.round(product.rating)} size="sm" aria-hidden="true" />
<span aria-hidden="true" className="text-sm text-muted-foreground tabular-nums">
{score(product.rating)} from {product.ratings} ratings
</span>
<span className="sr-only">
Rated {score(product.rating)} out of 5 from {product.ratings} ratings
</span>
</div>
) : null}
</div>
<div className="flex flex-col gap-1">
<Price amount={product.priceCents / 100} size="lg" />
<p className="text-sm text-muted-foreground tabular-nums">
{STOCK[product.stockState]}
{product.stockState === "low" ? `, ${formatNumber(product.stock)} left` : null}
</p>
</div>
<p className="text-sm text-pretty text-muted-foreground md:text-base">{COPY.description}</p>
<dl className="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-border ring-1 ring-border sm:grid-cols-4">
{COPY.figures.map((figure) => (
<div key={figure.label} className="flex flex-col-reverse gap-0.5 bg-card px-3 py-2.5">
<dt className="text-xs text-muted-foreground">{figure.label}</dt>
<dd className="text-base font-semibold text-foreground tabular-nums">{figure.value}</dd>
</div>
))}
</dl>
<div className="border-t border-border pt-6">
<CartConfirmation
name={product.name}
priceCents={product.priceCents}
stock={product.stock}
freeFromCents={POLICY.freeFromCents}
thumb={<ProductArt name={product.name} category={product.category} className="rounded-none" />}
/>
</div>
</div>
</div>
</section>
)
}
export default ProductDetails04"use client"
import * as React from "react"
import Link from "next/link"
import { CircleCheckIcon, ShoppingBagIcon, Undo2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"
import { Button, buttonVariants } from "@/components/ui/button"
import { NumberInput } from "@/components/ui/number-input"
import { Progress } from "@/components/ui/progress"
const money = (cents: number) => formatCurrency(cents / 100, "USD")
type Last = { kind: "added" | "removed"; units: number } | null
/**
* The way to buy, and what it did. The quantity is the kit's NumberInput,
* bounded by one and by the stock on hand. Add to cart puts that many in the
* cart and opens the confirmation under the button: what went in and what it
* came to, what the cart now holds, and how far the cart is from free
* delivery — all of it inside one always-mounted `role="status"` region, so
* it is read out as it lands. Under it, View cart, Checkout, and Undo, which
* takes the last addition back out and hands focus to Add to cart.
*/
export function CartConfirmation({
name,
priceCents,
stock,
freeFromCents,
thumb,
}: {
name: string
priceCents: number
stock: number
freeFromCents: number
/** The product drawn small, for the confirmation's line. */
thumb: React.ReactNode
}) {
const [quantity, setQuantity] = React.useState<number | null>(1)
const [inCart, setInCart] = React.useState(0)
const [last, setLast] = React.useState<Last>(null)
const addRef = React.useRef<HTMLButtonElement>(null)
const quantityId = React.useId()
const cartCents = inCart * priceCents
const short = Math.max(0, freeFromCents - cartCents)
const room = Math.max(0, stock - inCart)
function add() {
const units = Math.min(room, Math.max(1, Math.round(quantity ?? 1)))
if (units <= 0) return
setQuantity(units)
setInCart(inCart + units)
setLast({ kind: "added", units })
}
function undo() {
if (last?.kind !== "added") return
setInCart(inCart - last.units)
setLast({ kind: "removed", units: last.units })
addRef.current?.focus()
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex w-28 flex-col gap-1.5">
<label htmlFor={quantityId} className="text-xs text-muted-foreground">
Quantity
</label>
<NumberInput
id={quantityId}
value={quantity}
onValueChange={setQuantity}
min={1}
max={Math.max(1, room)}
className="h-10"
/>
</div>
<Button ref={addRef} size="lg" className="h-10 flex-1 sm:flex-none sm:px-6" disabled={room <= 0} onClick={add}>
<ShoppingBagIcon data-icon="inline-start" />
Add to cart
</Button>
</div>
<div className={cn("flex flex-col gap-4", last?.kind === "added" && "panel p-4")}>
<div role="status" aria-live="polite" className="flex flex-col gap-3 text-sm">
{last?.kind === "added" ? (
<>
<p className="flex items-center gap-2 font-medium text-foreground">
<CircleCheckIcon aria-hidden="true" className="size-4 text-success" />
Added to your cart.
</p>
<div className="flex items-center gap-3">
<span className="w-10 shrink-0 overflow-hidden rounded-md">{thumb}</span>
<p className="min-w-0 flex-1 text-foreground">
{name} <span className="text-muted-foreground tabular-nums">× {last.units},</span>
</p>{" "}
<span className="font-medium text-foreground tabular-nums">{money(last.units * priceCents)}.</span>
</div>
<p className="text-muted-foreground tabular-nums">
Your cart: {inCart} {inCart === 1 ? "item" : "items"}, {money(cartCents)}.{" "}
{short > 0 ? `Add ${money(short)} more for free delivery.` : "Delivery is free on this order."}
</p>
</>
) : last?.kind === "removed" ? (
<p className="text-muted-foreground tabular-nums">
Took {last.units === 1 ? `the ${name}` : `${last.units} of the ${name}`} back out of your cart.
</p>
) : null}
</div>
{last?.kind === "added" ? (
<>
<Progress value={Math.min(100, (cartCents / freeFromCents) * 100)} aria-label="Towards free delivery" />
<div className="flex flex-wrap gap-2">
<Link href="/cart" className={buttonVariants({ variant: "outline" })}>
View cart
</Link>
<Link href="/checkout" className={buttonVariants({ variant: "ink" })}>
Checkout
</Link>
<Button variant="ghost" className="ml-auto" onClick={undo}>
<Undo2Icon data-icon="inline-start" />
Undo
</Button>
</div>
</>
) : null}
</div>
</div>
)
}/**
* What `product-details-04` reads: one product off the storefront's shelf —
* its price, its stock, which bounds the quantity, and the score buyers gave
* it — the shop's delivery terms, which the confirmation measures the cart
* against, and the shop's own words about the product, kept here as copy
* beside its name.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes plain props; it never imports this module.
*/
import { POLICY, storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
export { POLICY }
/** The product the band shows, and what the shop says about it: change them together. */
export const PRODUCT = "Portable SSD 1TB"
export const COPY = {
description:
"A terabyte in a case the size of a credit card, quick enough to edit video straight off it. It runs on the power of the port it plugs into, so there is no charger.",
/** The four figures a buyer compares drives by. */
figures: [
{ value: "1 TB", label: "Capacity" },
{ value: "1,050 MB/s", label: "Read speed" },
{ value: "58 g", label: "Weight" },
{ value: "USB-C", label: "Port, both cables in the box" },
],
} as const
/** The product as the band shows it. */
export type CartProduct = Pick<
ShelfProduct,
"id" | "sku" | "name" | "category" | "subcategory" | "priceCents" | "stock" | "stockState" | "rating" | "ratings" | "href"
>
/** `PRODUCT` off the shelf. */
export function cartProduct(): CartProduct {
const product = storeShelf().find((row) => row.name === PRODUCT)
if (!product) throw new Error(`product-details-04 shows "${PRODUCT}", which is not on the shelf`)
const { id, sku, name, category, subcategory, priceCents, stock, stockState, rating, ratings, href } = product
return { id, sku, name, category, subcategory, priceCents, stock, stockState, rating, ratings, href }
}npx shadcn@latest add @vibra/product-details-04Details with shipping and returns tabs
product-details-05One product with what a buyer asks before paying: under its buy box, tabs for the details, the shipping — measured delivery times, the day an order placed now arrives, its cost and carriers — and the returns, with how many of this product came back.
import * as React from "react"
import Link from "next/link"
import { departmentHref } from "@/lib/dashboards/ecommerce/storefront"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Card, CardContent } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { Rating } from "@/components/ui/rating"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { AddToCart } from "./product-details-05.client"
import { COPY, POLICY, deliveryWindow, returnsOf, shippingProduct } from "./product-details-05.data"
// Dates are the store's, read in UTC: an order placed now arrives on the store's days.
const DAY = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
const money = (cents: number) => formatCurrency(cents / 100, "USD")
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const STOCK = { in: "In stock", low: "Low stock", out: "Sold out" } as const
/** One measured or promised figure, large, over what it means. */
function Figure({ value, label }: { value: React.ReactNode; label: string }) {
return (
<div className="flex flex-col-reverse gap-1 bg-card p-4">
<dt className="text-xs text-pretty text-muted-foreground">{label}</dt>
<dd className="type-numeral text-xl text-foreground tabular-nums">{value}</dd>
</div>
)
}
/**
* One product with what a buyer asks before paying: its picture beside its
* name, score, price, stock and Add to cart, and under them three tabs. The
* details; the shipping — how long delivery takes, measured over the store's
* own deliveries, the day an order placed now would arrive, what it costs and
* who carries it; and the returns — the window, how many of this product
* came back, and how to send one.
*
* A server component: the product, the delivery window and the returns come
* from `./product-details-05.data`; the kit's Tabs keeps the tab shown.
*/
export function ProductDetails05() {
const titleId = React.useId()
const product = shippingProduct()
const delivery = deliveryWindow()
const returns = returnsOf(product.id)
const free = product.priceCents >= POLICY.freeFromCents
return (
<section
data-section="product-details-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto flex max-w-7xl flex-col gap-10 md:gap-14">
<div className="grid items-start gap-8 lg:grid-cols-2 lg:gap-14">
<Card className="gap-0 p-2">
<CardContent className="p-0">
<ProductArt name={product.name} category={product.category} className="aspect-[4/3] rounded-none" />
</CardContent>
</Card>
<div className="flex flex-col gap-6 lg:pt-2">
<div className="flex flex-col gap-3">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Link href="/products" className="focus-ring rounded-sm hover:text-foreground">
Shop
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<Link href={departmentHref(product.category)} className="focus-ring rounded-sm hover:text-foreground">
{product.category}
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<Link
href={departmentHref(product.category, product.subcategory)}
className="focus-ring rounded-sm hover:text-foreground"
>
{product.subcategory}
</Link>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h2 id={titleId} className="type-display text-3xl text-balance md:text-4xl">
{product.name}
</h2>
{product.ratings ? (
<div className="flex items-center gap-1.5">
<Rating value={Math.round(product.rating)} size="sm" aria-hidden="true" />
<span aria-hidden="true" className="text-sm text-muted-foreground tabular-nums">
{score(product.rating)} from {product.ratings} ratings
</span>
<span className="sr-only">
Rated {score(product.rating)} out of 5 from {product.ratings} ratings
</span>
</div>
) : null}
</div>
<div className="flex flex-col gap-1">
<Price amount={product.priceCents / 100} size="lg" />
<p className="text-sm text-muted-foreground tabular-nums">
{STOCK[product.stockState]} · {free ? "Free delivery" : `Delivery ${money(POLICY.standardCents)}`} · Arrives{" "}
<span className="whitespace-nowrap">{DAY.format(delivery.earliest)}</span> to{" "}
<span className="whitespace-nowrap">{DAY.format(delivery.latest)}</span>
</p>
</div>
<p className="text-sm text-pretty text-muted-foreground md:text-base">{COPY.description}</p>
<AddToCart name={product.name} stock={product.stock} />
</div>
</div>
<Tabs defaultValue="details" className="gap-6">
<div className="border-b border-rule">
<TabsList variant="line" aria-label={`About the ${product.name}`} activateOnFocus>
<TabsTrigger value="details" className="px-3">
Details
</TabsTrigger>
<TabsTrigger value="shipping" className="px-3">
Shipping
</TabsTrigger>
<TabsTrigger value="returns" className="px-3">
Returns
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="details" className="grid gap-6 md:grid-cols-2 md:gap-10">
<ul className="flex list-disc flex-col gap-2 pl-5 text-sm text-foreground marker:text-muted-foreground">
{COPY.facts.map((fact) => (
<li key={fact}>{fact}</li>
))}
</ul>
<dl className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">SKU</dt>
<dd className="font-mono text-foreground">{product.sku}</dd>
<dt className="text-muted-foreground">Ships from</dt>
<dd className="text-foreground">{product.supplier}</dd>
<dt className="text-muted-foreground">In stock</dt>
<dd className="text-foreground tabular-nums">{formatNumber(product.stock)}</dd>
</dl>
</TabsContent>
<TabsContent value="shipping" className="flex flex-col gap-4">
<dl className="grid gap-px overflow-hidden rounded-lg bg-border ring-1 ring-border sm:grid-cols-3">
<Figure value={`${delivery.fromDays} to ${delivery.toDays} days`} label="From order to door, for most orders" />
<Figure
value={`${DAY.format(delivery.earliest)} to ${DAY.format(delivery.latest)}`}
label="When an order placed today arrives"
/>
<Figure
value={free ? "Free" : money(POLICY.standardCents)}
label={`Standard delivery, free on orders from ${money(POLICY.freeFromCents)}`}
/>
</dl>
<p className="text-sm text-pretty text-muted-foreground">
Measured over our last {formatNumber(delivery.deliveries)} deliveries: half of them arrived inside this window.
We ship with {delivery.carriers.join(", ")}; the tracking number comes by email when the parcel leaves.
</p>
</TabsContent>
<TabsContent value="returns" className="grid gap-6 md:grid-cols-2 md:gap-10">
<dl className="grid grid-cols-2 gap-px self-start overflow-hidden rounded-lg bg-border ring-1 ring-border">
<Figure value={`${POLICY.returnDays} days`} label="From delivery to send it back" />
<Figure value={`${returns.returned} of ${returns.delivered}`} label="Orders of it that came back" />
</dl>
<ol className="flex list-decimal flex-col gap-2 pl-5 text-sm text-foreground marker:text-muted-foreground">
{COPY.returnSteps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</TabsContent>
</Tabs>
</div>
</section>
)
}
export default ProductDetails05"use client"
import * as React from "react"
import { ShoppingBagIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
/** Add to cart — the band's one filled button — and what it did, in an always-mounted status line. */
export function AddToCart({ name, stock }: { name: string; stock: number }) {
const [message, setMessage] = React.useState("")
return (
<div className="flex flex-col gap-2">
<Button
size="lg"
className="h-10 w-full sm:w-fit sm:px-6"
disabled={stock <= 0}
onClick={() => setMessage(`Added the ${name} to your cart`)}
>
<ShoppingBagIcon data-icon="inline-start" />
Add to cart
</Button>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{message}
</p>
</div>
)
}/**
* What `product-details-05` reads: one product off the storefront's shelf,
* and what the store knows about getting it to a buyer and back. The
* delivery window is measured, not promised: the middle half of the days
* between an order being placed and its parcel being delivered, across every
* delivered shipment, with the carriers that carried them. The return figure
* is the product's own — of the orders holding it that reached a buyer, how
* many were refunded. The terms (what delivery costs, how long a buyer has
* to send it back) are the storefront's `POLICY`; the shop's words about the
* product are copy, kept here beside its name.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { POLICY, storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
import { REFERENCE_DATE, db } from "@/lib/sample-data"
export { POLICY }
/** The product the band shows, and what the shop says about it: change them together. */
export const PRODUCT = "Bar Stool Set"
export const COPY = {
description:
"Two counter-height stools with a footrest ring, on a steel frame that stands square on an uneven kitchen floor.",
facts: ["A set of two", "Seat height 65 cm, for counters around 90 cm", "Steel frame, solid oak seat", "Ships in two boxes"],
returnSteps: [
"Start the return from your order page, within the window.",
"Pack the stools in their boxes; we email a label for each.",
"We refund the card you paid with the day they reach us.",
],
} as const
const DAY_MS = 86_400_000
/** How long delivery takes, measured over the delivered shipments, and when an order placed now would arrive. */
export type DeliveryWindow = {
/** The middle half of the deliveries took between these many days, rounded out to whole days. */
fromDays: number
toDays: number
/** How many deliveries the window is measured over. */
deliveries: number
/** The first and last day an order placed at `REFERENCE_DATE` would arrive, by the window. */
earliest: Date
latest: Date
/** The carriers that delivered them, the busiest first. */
carriers: string[]
}
/** The delivery window, measured over every shipment that has been delivered. */
export function deliveryWindow(): DeliveryWindow {
const placed = new Map(db.orders.all().map((order) => [order.id, order.placedAt.getTime()]))
const days: number[] = []
const carriers = new Map<string, number>()
for (const shipment of db.shipments.all()) {
const delivered = shipment.stages.find((stage) => stage.name === "delivered")?.at
const at = placed.get(shipment.orderId)
if (!delivered || at === undefined) continue
days.push((delivered.getTime() - at) / DAY_MS)
carriers.set(shipment.carrier, (carriers.get(shipment.carrier) ?? 0) + 1)
}
days.sort((a, b) => a - b)
const quantile = (share: number) => days[Math.floor(share * (days.length - 1))] ?? 0
const fromDays = Math.max(1, Math.round(quantile(0.25)))
const toDays = Math.max(fromDays + 1, Math.ceil(quantile(0.75)))
return {
fromDays,
toDays,
deliveries: days.length,
earliest: new Date(REFERENCE_DATE.getTime() + fromDays * DAY_MS),
latest: new Date(REFERENCE_DATE.getTime() + toDays * DAY_MS),
carriers: [...carriers].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([carrier]) => carrier),
}
}
/** Of the orders holding a product that reached a buyer, how many came back for a refund. */
export function returnsOf(productId: string): { delivered: number; returned: number } {
let delivered = 0
let returned = 0
for (const order of db.orders.all()) {
if (!order.items.some((item) => item.productId === productId)) continue
if (order.status === "refunded") returned += 1
if (order.status === "fulfilled" || order.status === "refunded") delivered += 1
}
return { delivered, returned }
}
/** The product as the band shows it. */
export type ShippingProduct = Pick<
ShelfProduct,
"id" | "sku" | "name" | "category" | "subcategory" | "supplier" | "priceCents" | "stock" | "stockState" | "rating" | "ratings" | "href"
>
/** `PRODUCT` off the shelf. */
export function shippingProduct(): ShippingProduct {
const product = storeShelf().find((row) => row.name === PRODUCT)
if (!product) throw new Error(`product-details-05 shows "${PRODUCT}", which is not on the shelf`)
const { id, sku, name, category, subcategory, supplier, priceCents, stock, stockState, rating, ratings, href } = product
return { id, sku, name, category, subcategory, supplier, priceCents, stock, stockState, rating, ratings, href }
}npx shadcn@latest add @vibra/product-details-05Details with a ratings summary
product-details-06One product and what its buyers gave it: the mean over a bar for each star, every bar a toggle that narrows the list of buyers under it — each with their face, their stars and the day their order arrived.
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { AddToCart, RatingsBrowser } from "./product-details-06.client"
import { COPY, reviewedProduct } from "./product-details-06.data"
const STOCK = { in: "In stock", low: "Low stock", out: "Sold out" } as const
/**
* One product and what its buyers gave it: the product on the left — its
* picture, name, price, stock, the shop's words and Add to cart — and on the
* right a card of its ratings: the mean over a bar for each star, every bar a
* toggle that narrows the list of buyers under it, each with their face,
* their stars and the day their order arrived.
*
* A server component: the product, the summary and every rating come from
* `./product-details-06.data`; the island holds the filter and the list.
*/
export function ProductDetails06() {
const titleId = React.useId()
const { product, summary, ratings } = reviewedProduct()
return (
<section
data-section="product-details-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl items-start gap-6 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-10">
<Card className="gap-2 p-2">
<CardContent className="flex-none p-0">
<ProductArt name={product.name} category={product.category} className="aspect-[4/3] rounded-none" />
</CardContent>
<div className="flex flex-col gap-4 p-3 md:p-4">
<div className="flex flex-col gap-1.5">
<p className="text-xs text-muted-foreground">
{product.category} · {product.subcategory}
</p>
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
{product.name}
</h2>
</div>
<p className="text-sm text-pretty text-muted-foreground">{COPY.description}</p>
<div className="flex flex-col gap-1">
<Price amount={product.priceCents / 100} size="lg" />
<p className="text-sm text-muted-foreground tabular-nums">
{STOCK[product.stockState]}
{product.stockState === "low" ? `, ${formatNumber(product.stock)} left` : null}
</p>
</div>
<AddToCart name={product.name} stock={product.stock} />
</div>
</Card>
<Card>
<CardHeader>
<CardTitle>
<h3>What buyers gave it</h3>
</CardTitle>
<CardDescription>Rated by the buyers it reached, each once, after it arrived.</CardDescription>
</CardHeader>
<CardContent className="md:p-6">
<RatingsBrowser summary={summary} ratings={ratings} />
</CardContent>
</Card>
</div>
</section>
)
}
export default ProductDetails06"use client"
import * as React from "react"
import { ShoppingBagIcon } from "lucide-react"
import type { RatingSummary } from "@/lib/dashboards/ecommerce/storefront-ratings"
import { avatarFor } from "@/lib/avatars"
import { formatNumber, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Rating } from "@/components/ui/rating"
import type { BuyerRating } from "./product-details-06.data"
// The day an order arrived, read in UTC: the store's day, not the reader's.
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
/** How many ratings the list shows before Show all. */
const SHOWN = 5
const STARS = [5, 4, 3, 2, 1] as const
const ratingsWord = (count: number) => (count === 1 ? "rating" : "ratings")
/**
* What buyers gave the product: the mean, large, over one bar for each star,
* each bar a toggle that narrows the list under it to the ratings of that
* star; the list shows each buyer's face, name, stars and the day their order
* arrived, the newest first, five at a time until Show all. An always-mounted
* status line says what the list is showing.
*/
export function RatingsBrowser({ summary, ratings }: { summary: RatingSummary; ratings: BuyerRating[] }) {
const [star, setStar] = React.useState<number | null>(null)
const [all, setAll] = React.useState(false)
const listId = React.useId()
const filtered = star === null ? ratings : ratings.filter((rating) => rating.stars === star)
const visible = all ? filtered : filtered.slice(0, SHOWN)
const status =
star === null
? `Showing ${visible.length} of all ${ratings.length} ${ratingsWord(ratings.length)}, the newest first`
: `Showing ${visible.length} of the ${filtered.length} ${ratingsWord(filtered.length)} of ${star} ${star === 1 ? "star" : "stars"}`
return (
<div className="flex flex-col gap-6">
<div className="grid gap-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center sm:gap-10">
<div className="flex flex-col gap-1">
<p className="type-numeral text-5xl tabular-nums">{formatNumber(summary.average, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}</p>
<Rating value={Math.round(summary.average)} size="sm" aria-hidden="true" />
<p className="text-sm text-muted-foreground tabular-nums">
{summary.count} {ratingsWord(summary.count)}
</p>
</div>
<ul aria-label="Ratings by star" className="flex flex-col gap-0.5">
{STARS.map((stars, index) => {
const count = summary.byStars[index]
const share = summary.count ? count / summary.count : 0
return (
<li key={stars}>
<button
type="button"
aria-pressed={star === stars}
aria-controls={listId}
aria-label={`${stars} ${stars === 1 ? "star" : "stars"}, ${count} ${ratingsWord(count)}`}
disabled={count === 0}
onClick={() => {
setStar(star === stars ? null : stars)
setAll(false)
}}
className="focus-ring grid w-full grid-cols-[3.5rem_minmax(0,1fr)_2rem] items-center gap-3 rounded-md px-2 py-1.5 text-sm transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted/60 disabled:cursor-default disabled:hover:bg-transparent aria-pressed:bg-brand-muted aria-pressed:font-medium"
>
<span className="text-left text-foreground tabular-nums">
{stars} {stars === 1 ? "star" : "stars"}
</span>
<span aria-hidden="true" className="h-2 overflow-hidden rounded-full bg-muted">
<span className="block h-full rounded-full bg-chart-1" style={{ width: `${share * 100}%` }} />
</span>
<span className="text-right text-muted-foreground tabular-nums">{count}</span>
</button>
</li>
)
})}
</ul>
</div>
<div className="flex flex-col gap-3 border-t border-border pt-4">
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{status}
</p>
<ul id={listId} aria-label="Ratings" className="flex flex-col divide-y divide-border">
{visible.map((rating) => (
<li key={rating.id} className="flex items-center gap-3 py-2.5">
<Avatar className="size-8">
<AvatarImage src={avatarFor(rating.buyer)} alt="" />
<AvatarFallback>{getInitials(rating.buyer)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium text-foreground">
{rating.shortName}
<span className="sr-only">,</span>
</span>{" "}
<span className="text-xs text-muted-foreground">
Received <time dateTime={rating.receivedAt}>{DAY.format(new Date(rating.receivedAt))}</time>
</span>
</div>{" "}
<Rating value={rating.stars} size="sm" aria-hidden="true" />
<span className="sr-only">
, {rating.stars} {rating.stars === 1 ? "star" : "stars"}
</span>
</li>
))}
</ul>
{filtered.length > SHOWN ? (
<Button variant="ghost" size="sm" className="-ml-2 w-fit" aria-expanded={all} aria-controls={listId} onClick={() => setAll(!all)}>
{all ? "Show fewer" : `Show all ${filtered.length}`}
</Button>
) : null}
</div>
</div>
)
}
/** Add to cart — the band's one filled button — and what it did, in an always-mounted status line. */
export function AddToCart({ name, stock }: { name: string; stock: number }) {
const [message, setMessage] = React.useState("")
return (
<div className="flex flex-col gap-2">
<Button size="lg" className="h-10 w-full sm:w-fit sm:px-6" disabled={stock <= 0} onClick={() => setMessage(`Added the ${name} to your cart`)}>
<ShoppingBagIcon data-icon="inline-start" />
Add to cart
</Button>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{message}
</p>
</div>
)
}/**
* What `product-details-06` reads: one product off the storefront's shelf and
* everything buyers gave it. A rating is the stars of a review a buyer wrote
* (`storeRatings`, reading `db.reviews`): each belongs to one real order,
* placed by one real customer and dated the day it arrived. The summary is
* counted from those ratings, so the bars add up to the total and the mean
* is theirs. The shop's words about the product are copy, kept here beside
* its name.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes plain props; it never imports this module.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
import { storeRatings, summarize, type RatingSummary } from "@/lib/dashboards/ecommerce/storefront-ratings"
import { db } from "@/lib/sample-data"
/** The product the band shows, and what the shop says about it: change them together. */
export const PRODUCT = "Console Table"
export const COPY = {
description:
"A narrow table for a hallway or the back of a sofa: 30 cm deep, with a drawer for keys and a shelf underneath for the post.",
} as const
/** One buyer's rating as the band lists it. */
export type BuyerRating = {
/** The order it belongs to: one rating per order line. */
id: string
/** The buyer's full name, which picks their face. */
buyer: string
/** How the list names them: a first name and an initial. */
shortName: string
stars: number
/** The day the order reached them, as an ISO date. */
receivedAt: string
}
export type ReviewedProduct = Pick<
ShelfProduct,
"id" | "sku" | "name" | "category" | "subcategory" | "priceCents" | "stock" | "stockState" | "href"
>
const shorten = (name: string) => {
const [first, ...rest] = name.split(" ")
const last = rest.at(-1)
return last ? `${first} ${last.charAt(0)}.` : first
}
/** `PRODUCT` off the shelf, its summary and its ratings, the most recent first. */
export function reviewedProduct(): { product: ReviewedProduct; summary: RatingSummary; ratings: BuyerRating[] } {
const row = storeShelf().find((entry) => entry.name === PRODUCT)
if (!row) throw new Error(`product-details-06 shows "${PRODUCT}", which is not on the shelf`)
const customers = new Map(db.customers.all().map((customer) => [customer.id, customer.name]))
const own = storeRatings()
.filter((rating) => rating.productId === row.id)
.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime() || a.orderId.localeCompare(b.orderId))
const { id, sku, name, category, subcategory, priceCents, stock, stockState, href } = row
return {
product: { id, sku, name, category, subcategory, priceCents, stock, stockState, href },
summary: summarize(own.map((rating) => rating.stars)),
ratings: own.map((rating) => {
const buyer = customers.get(rating.customerId) ?? "A buyer"
return {
id: rating.orderId,
buyer,
shortName: shorten(buyer),
stars: rating.stars,
receivedAt: rating.receivedAt.toISOString(),
}
}),
}
}npx shadcn@latest add @vibra/product-details-06