Product list
A catalogue as rows or a dense grid, sorted and filtered, with prices and stock.
Faceted grid with counted chips
product-list-01The whole catalogue narrowed by department and price with chips that say how many products each choice leaves, over the first eight that pass and a link to the rest.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { FacetedListing } from "./product-list-01.client"
import { listing } from "./product-list-01.data"
/**
* A faceted grid: the whole catalogue, narrowed by department and by price
* with chips that say how many products each choice leaves, over the first
* eight that pass, bestselling first, and a link to the rest with the same
* filters on.
*
* A server component: it reads every row through `./product-list-01.data`
* and draws every picture here, handing them to the island by product id, so
* the drawings never ship as client code.
*/
export function ProductList01() {
const titleId = React.useId()
const products = listing()
const art = Object.fromEntries(
products.map((product) => [
product.id,
<ProductArt key={product.id} name={product.name} category={product.category} className="rounded-none" />,
])
)
return (
<section
data-section="product-list-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 max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Everything we sell
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
All {products.length} products, bestselling first. Narrow them by department and price; each choice
says how many it leaves.
</p>
</div>
<div className="mt-8 md:mt-10">
<FacetedListing products={products} art={art} shown={8} listHref="/products" />
</div>
</div>
</section>
)
}
export default ProductList01"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, CheckIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { StatusBadge } from "@/components/ui/status-badge"
import { Toggle } from "@/components/ui/toggle"
import type { ListingRow } from "./product-list-01.data"
/** The price bands a shopper narrows by, in cents: from, and up to but not including. */
export const PRICE_BANDS = [
{ id: "under-25", label: "Under $25", from: 0, to: 2_500 },
{ id: "25-100", label: "$25 to $100", from: 2_500, to: 10_000 },
{ id: "100-250", label: "$100 to $250", from: 10_000, to: 25_000 },
{ id: "250-up", label: "$250 and up", from: 25_000, to: Infinity },
] as const
type Filters = { departments: ReadonlySet<string>; bands: ReadonlySet<string> }
type Facet = keyof Filters
type Option = { value: string; label: string; count: number }
const bandOf = (cents: number) => PRICE_BANDS.find((band) => cents >= band.from && cents < band.to)?.id ?? ""
/** Whether a product passes every facet but `skip`, the one whose own counts are being worked out. */
function passes(product: ListingRow, filters: Filters, skip?: Facet): boolean {
const department = skip === "departments" || !filters.departments.size || filters.departments.has(product.category)
const band = skip === "bands" || !filters.bands.size || filters.bands.has(bandOf(product.priceCents))
return department && band
}
/** The full listing with the same filters on: the list's own route, the facets in its query. */
function hrefFor(base: string, filters: Filters): string {
const query = new URLSearchParams()
if (filters.departments.size) query.set("department", [...filters.departments].sort().join(","))
if (filters.bands.size) query.set("price", [...filters.bands].sort().join(","))
const search = query.toString()
return search ? `${base}?${search}` : base
}
const flip = (set: ReadonlySet<string>, value: string) => {
const next = new Set(set)
if (!next.delete(value)) next.add(value)
return next
}
/**
* One facet as a named group of chips. Each chip is a toggle that says how
* many products choosing it would leave, given the other facet; one that
* would leave none is unavailable unless it is already on, so the way back
* out of a choice is always there. A chosen chip wears the brand tint, the
* 500 weight and a tick — three cues, never a stroke.
*/
function FacetGroup({
label,
options,
selected,
onToggle,
}: {
label: string
options: Option[]
selected: ReadonlySet<string>
onToggle: (value: string) => void
}) {
const labelId = React.useId()
return (
<div role="group" aria-labelledby={labelId} className="flex flex-col gap-2 md:flex-row md:items-start md:gap-4">
<span id={labelId} className="text-xs font-medium text-muted-foreground md:w-20 md:shrink-0 md:pt-1.5">
{label}
</span>
<div className="flex flex-wrap gap-1.5">
{options.map((option) => {
const on = selected.has(option.value)
return (
// A chip on the filter bar: the card plane inside a ring, as the
// kit's chips are outlined — a ring, not a panel's border.
<Toggle
key={option.value}
size="sm"
pressed={on}
onPressedChange={() => onToggle(option.value)}
disabled={option.count === 0 && !on}
className="rounded-full bg-card px-3 font-normal text-foreground ring-1 ring-border aria-pressed:bg-brand-muted aria-pressed:font-medium aria-pressed:text-foreground aria-pressed:ring-transparent"
>
{on ? <CheckIcon data-icon="inline-start" /> : null}
{option.label}
<span className="sr-only">,</span>{" "}
<span className="text-muted-foreground tabular-nums">{option.count}</span>{" "}
<span className="sr-only">products</span>
</Toggle>
)
})}
</div>
</div>
)
}
/**
* The faceted grid: departments and price bands as chips over the products
* they leave, bestselling first. The count of what is left is an
* always-mounted status, read out as the chips change; Clear filters stays
* where it is, unavailable when there is nothing to clear, so focus never
* falls off the page. The grid shows the first `shown` and links to the full
* listing with the same filters on. Every picture arrives drawn, as `art`.
*/
export function FacetedListing({
products,
art,
shown = 8,
listHref,
}: {
products: ListingRow[]
art: Record<string, React.ReactNode>
shown?: number
listHref: string
}) {
const [departments, setDepartments] = React.useState<ReadonlySet<string>>(() => new Set())
const [bands, setBands] = React.useState<ReadonlySet<string>>(() => new Set())
const filters: Filters = { departments, bands }
const results = products.filter((product) => passes(product, filters))
const visible = results.slice(0, shown)
const departmentOptions = [...new Set(products.map((product) => product.category))].sort().map((name) => ({
value: name,
label: name,
count: products.filter((product) => product.category === name && passes(product, filters, "departments")).length,
}))
const bandOptions = PRICE_BANDS.map((band) => ({
value: band.id,
label: band.label,
count: products.filter((product) => bandOf(product.priceCents) === band.id && passes(product, filters, "bands")).length,
}))
return (
<div className="flex flex-col gap-6 md:gap-8">
<div className="frame flex flex-col gap-3 p-3 md:p-4">
<FacetGroup label="Department" options={departmentOptions} selected={departments} onToggle={(value) => setDepartments(flip(departments, value))} />
<FacetGroup label="Price" options={bandOptions} selected={bands} onToggle={(value) => setBands(flip(bands, value))} />
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<p role="status" aria-live="polite" className="text-sm font-medium text-foreground tabular-nums">
{results.length} {results.length === 1 ? "product" : "products"}
</p>
<Button
variant="ghost"
size="sm"
disabled={!departments.size && !bands.size}
focusableWhenDisabled
onClick={() => {
setDepartments(new Set())
setBands(new Set())
}}
>
<XIcon data-icon="inline-start" />
Clear filters
</Button>
</div>
</div>
<ul className="grid grid-cols-2 gap-3 md:gap-4 lg:grid-cols-4">
{visible.map((product) => (
<li key={product.id} className="flex">
<Card
variant="interactive"
className="relative w-full has-[a:focus-visible]:focus-outline"
>
<CardContent className="relative flex-none p-0">
{art[product.id]}
{product.stockState === "low" ? (
<StatusBadge status="low" variant="warning" label="Low stock" className="absolute top-2 left-2" />
) : null}
</CardContent>
<CardFooter className="mt-0 flex-1 flex-col items-stretch gap-1 pt-2 pb-3">
<p className="text-xs text-muted-foreground">{product.category}</p>
<h3 className="text-sm font-semibold text-foreground">
<Link href={product.href} className="outline-none after:absolute after:inset-0">
{product.name}
</Link>
</h3>
<Price amount={product.priceCents / 100} size="sm" className="mt-auto pt-1" />
</CardFooter>
</Card>
</li>
))}
</ul>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground tabular-nums">
Showing {visible.length} of {results.length}
</p>
{results.length > visible.length ? (
<Link href={hrefFor(listHref, filters)} className={cn(buttonVariants({ variant: "outline" }), "group/all")}>
See all {results.length}
<ArrowRightIcon
aria-hidden="true"
data-icon="inline-end"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/all:translate-x-0.5"
/>
</Link>
) : null}
</div>
</div>
)
}/**
* What `product-list-01` reads: every product still sold, with the things its
* filters work on — its department, its price and whether its stock is low —
* in the storefront shelf's order, bestselling first, which is the order the
* grid keeps under any filter.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** A product as the faceted grid lists it. */
export type ListingRow = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "stockState" | "href">
/** Every product still sold, bestselling first. */
export function listing(): ListingRow[] {
return storeShelf().map(({ id, name, category, priceCents, stockState, href }) => ({
id,
name,
category,
priceCents,
stockState,
href,
}))
}npx shadcn@latest add @vibra/product-list-01Sortable list with a list or grid view
product-list-02One department in a framed card: a sort and a list-or-grid switch on its strip, the products as ruled rows with SKU, units sold and stock, or as a ruled grid, and an Add on each.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { SortableList } from "./product-list-02.client"
import { DEPARTMENT, SHELF_DAYS, departmentList } from "./product-list-02.data"
/**
* A sortable list: one department's products in a framed card, the sort and
* the list-or-grid view on its strip, the products on its sheet — ruled rows
* with the SKU, the units sold and the stock, or a grid of cells ruled apart —
* and what the last change did on its footer strip.
*
* A server component: it reads the rows through `./product-list-02.data`
* and draws every picture here, handing them to the island by product id.
*/
export function ProductList02() {
const titleId = React.useId()
const products = departmentList()
const art = Object.fromEntries(
products.map((product) => [
product.id,
<ProductArt key={product.id} name={product.name} category={product.category} className="rounded-none" />,
])
)
return (
<section
data-section="product-list-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 max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<p className="type-eyebrow">{products.length} products</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
{DEPARTMENT}
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Everything we sell in {DEPARTMENT}, bestselling first. Sort it by price or name, and see it as a list
or a grid.
</p>
</div>
<div className="mt-8 md:mt-10">
<SortableList products={products} art={art} days={SHELF_DAYS} />
</div>
</div>
</section>
)
}
export default ProductList02"use client"
import * as React from "react"
import Link from "next/link"
import { LayoutGridIcon, ListIcon, PlusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Price } from "@/components/ui/price"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { StatusBadge } from "@/components/ui/status-badge"
import type { ListRow } from "./product-list-02.data"
type Compare = (a: ListRow, b: ListRow) => number
/** The orders a shopper can put the list in. The rows arrive bestselling first, and a stable sort keeps that order among ties. */
export const SORTS: readonly { value: string; label: string; spoken: string; compare: Compare }[] = [
{ value: "bestselling", label: "Bestselling", spoken: "bestselling first", compare: () => 0 },
{ value: "price-asc", label: "Price, low to high", spoken: "price, low to high", compare: (a, b) => a.priceCents - b.priceCents },
{ value: "price-desc", label: "Price, high to low", spoken: "price, high to low", compare: (a, b) => b.priceCents - a.priceCents },
{ value: "name", label: "Name, A to Z", spoken: "name, A to Z", compare: (a, b) => a.name.localeCompare(b.name) },
]
const VIEWS = [
{ value: "list", label: "List", icon: <ListIcon /> },
{ value: "grid", label: "Grid", icon: <LayoutGridIcon /> },
]
/** A product's add button: its word, and its product for a screen reader. Raised above the stretched link. */
function AddButton({ name, onAdd }: { name: string; onAdd: () => void }) {
return (
<Button variant="outline" size="sm" className="relative z-10" onClick={onAdd}>
<PlusIcon data-icon="inline-start" />
Add<span className="sr-only">, {name}</span>
</Button>
)
}
/**
* A department as one framed card: on the strip, the sort — a native select,
* so it works the way every phone expects — and the view, the kit's
* segmented control; on the sheet, the products, as ruled rows or as a grid
* of cells ruled apart; on the footer strip, one always-mounted status line
* saying what the last change did. Every row is one stretched link with its
* Add button raised above it. Every picture arrives drawn, as `art`.
*/
export function SortableList({
products,
art,
days,
}: {
products: ListRow[]
art: Record<string, React.ReactNode>
days: number
}) {
const [sort, setSort] = React.useState(SORTS[0].value)
const [view, setView] = React.useState("list")
const [message, setMessage] = React.useState("")
const sortId = React.useId()
const order = SORTS.find((entry) => entry.value === sort) ?? SORTS[0]
const rows = [...products].sort(order.compare)
const add = (row: ListRow) => setMessage(`Added ${row.name} to your cart`)
return (
<Card>
<CardHeader className="flex flex-wrap items-end justify-between gap-3 py-3">
<div className="flex items-center gap-2">
<label htmlFor={sortId} className="text-sm text-muted-foreground">
Sort by
</label>
<NativeSelect
id={sortId}
size="sm"
value={sort}
onChange={(event) => {
const next = SORTS.find((entry) => entry.value === event.target.value) ?? SORTS[0]
setSort(next.value)
setMessage(`Sorted by ${next.spoken}`)
}}
className="min-w-44"
>
{SORTS.map((entry) => (
<NativeSelectOption key={entry.value} value={entry.value}>
{entry.label}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<SegmentedControl aria-label="View" size="sm" options={VIEWS} value={view} onValueChange={setView} />
</CardHeader>
<CardContent className="p-0">
<ul
data-view={view}
// The grid's rules are each cell's right and bottom edges, the last
// of each pulled under the sheet's clip: a gap painted in the rule's
// colour would fill the empty slots of a short last row too.
className={cn(view === "grid" ? "-mr-px -mb-px grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5" : "divide-y divide-border")}
>
{rows.map((row) => (
<li
key={row.id}
className={cn(
"relative bg-card transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted/40 has-[a:focus-visible]:focus-outline-inset",
view === "grid" ? "flex flex-col gap-2 border-r border-b border-border p-3" : "flex items-center gap-3 p-3 sm:gap-4"
)}
>
<div className={cn("shrink-0 overflow-hidden rounded-md", view === "grid" ? "w-full" : "w-14 sm:w-16")}>
{art[row.id]}
</div>
<div className={cn("flex min-w-0 flex-1 flex-col gap-1", view === "list" && "sm:flex-row sm:items-center sm:gap-4")}>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<h3 className="text-sm font-semibold text-foreground">
<Link href={row.href} className="outline-none after:absolute after:inset-0">
{row.name}
</Link>
</h3>
{view === "list" ? (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="font-mono">{row.sku}</span>
<span aria-hidden="true">·</span>
<span className="tabular-nums">
{row.sold} sold in {days} days
</span>
{row.stockState === "low" ? (
<StatusBadge status="low" variant="warning" size="sm" label="Low stock" />
) : null}
</p>
) : null}
</div>
<div className={cn("flex items-center justify-between gap-3", view === "list" && "sm:justify-end")}>
<Price amount={row.priceCents / 100} size="sm" />
<AddButton name={row.name} onAdd={() => add(row)} />
</div>
</div>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{message || `${rows.length} products, ${order.spoken}`}
</p>
</CardFooter>
</Card>
)
}/**
* What `product-list-02` reads: one department's products still sold, with
* what a shopper sorts them by — the price, the name, and the units sold over
* the last 90 days, which is the order they arrive in — and what each row
* prints besides: the SKU and whether stock is low. Change `DEPARTMENT` to
* list another.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { SHELF_DAYS, storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** The window "sold" is counted over, in days: the shelf's own. */
export { SHELF_DAYS }
/** The department the list shows. */
export const DEPARTMENT = "Home"
/** A product as the list prints it. */
export type ListRow = Pick<ShelfProduct, "id" | "sku" | "name" | "category" | "priceCents" | "stockState" | "sold" | "href">
/** Every product still sold in `DEPARTMENT`, bestselling first. */
export function departmentList(): ListRow[] {
return storeShelf()
.filter((product) => product.category === DEPARTMENT)
.map(({ id, sku, name, category, priceCents, stockState, sold, href }) => ({
id,
sku,
name,
category,
priceCents,
stockState,
sold,
href,
}))
}npx shadcn@latest add @vibra/product-list-02Quick order table
product-list-03One department as a dense, sortable table with a quantity on every row, the order summed under it — lines, units, subtotal — and added to the cart in one go.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { QuickOrder } from "./product-list-03.client"
import { DEPARTMENT, orderSheet } from "./product-list-03.data"
/**
* A quick order sheet: one department's products as a dense, sortable table,
* a quantity on every row, the order summed under it and added in one go —
* for a buyer who knows what they need and wants it on one screen.
*
* A server component: it reads the lines through `./product-list-03.data`
* and draws every thumbnail here, handing them to the island by product id.
*/
export function ProductList03() {
const titleId = React.useId()
const lines = orderSheet()
const art = Object.fromEntries(
lines.map((line) => [
line.id,
<ProductArt key={line.id} name={line.name} category={line.category} className="rounded-none" />,
])
)
return (
<section
data-section="product-list-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 max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<p className="type-eyebrow">{DEPARTMENT}</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Quick order
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Everything in {DEPARTMENT} we have in stock, a line each. Fill in the quantities you need and add them in
one go.
</p>
</div>
<div className="mt-8 md:mt-10">
<QuickOrder lines={lines} art={art} />
</div>
</div>
</section>
)
}
export default ProductList03"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, ShoppingBagIcon } from "lucide-react"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { NumberInput } from "@/components/ui/number-input"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { OrderLine } from "./product-list-03.data"
type SortKey = "name" | "price" | "stock"
type Sort = { key: SortKey; direction: "ascending" | "descending" }
const COMPARE: Record<SortKey, (a: OrderLine, b: OrderLine) => number> = {
name: (a, b) => a.name.localeCompare(b.name),
price: (a, b) => a.priceCents - b.priceCents,
stock: (a, b) => a.stock - b.stock,
}
const money = (cents: number) => formatCurrency(cents / 100, "USD")
const plural = (count: number, word: string) => `${formatNumber(count)} ${word}${count === 1 ? "" : "s"}`
/** A column head that sorts by its column: pressed again, it turns the order round. `aria-sort` says which way. */
function SortHead({
label,
sortKey,
sort,
onSort,
className,
}: {
label: string
sortKey: SortKey
sort: Sort
onSort: (key: SortKey) => void
className?: string
}) {
const active = sort.key === sortKey
const Icon = !active ? ArrowUpDownIcon : sort.direction === "ascending" ? ArrowUpIcon : ArrowDownIcon
return (
<TableHead aria-sort={active ? sort.direction : "none"} className={className}>
<button
type="button"
onClick={() => onSort(sortKey)}
className="focus-ring inline-flex items-center gap-1 rounded-sm transition-colors hover:text-foreground"
>
{label}
<Icon aria-hidden="true" className={active ? "size-3.5" : "size-3.5 opacity-50"} />
</button>
</TableHead>
)
}
/**
* The order sheet: one row per product, each with a quantity field, and the
* order summed under the table — how many lines and units, what they come to
* — with one button that adds them all. The column heads sort. A line with a
* quantity is marked the way the kit marks a chosen row: the brand tint, a
* rail and the 500 weight. Quantities stop at the stock on hand. Adding
* empties the sheet and says what went in, in an always-mounted status line.
* On a phone the price and the SKU fold under the name, and the stock and
* the line total step aside.
*/
export function QuickOrder({ lines, art }: { lines: OrderLine[]; art: Record<string, React.ReactNode> }) {
const [sort, setSort] = React.useState<Sort>({ key: "name", direction: "ascending" })
const [quantities, setQuantities] = React.useState<Record<string, number | null>>({})
const [message, setMessage] = React.useState("")
const rows = [...lines].sort((a, b) => COMPARE[sort.key](a, b) * (sort.direction === "ascending" ? 1 : -1))
const quantityOf = (line: OrderLine) => Math.min(line.stock, Math.max(0, Math.round(quantities[line.id] ?? 0)))
const ordered = lines.filter((line) => quantityOf(line) > 0)
const units = ordered.reduce((sum, line) => sum + quantityOf(line), 0)
const cents = ordered.reduce((sum, line) => sum + quantityOf(line) * line.priceCents, 0)
const onSort = (key: SortKey) =>
setSort((current) => ({
key,
direction: current.key === key && current.direction === "ascending" ? "descending" : "ascending",
}))
function addAll() {
setMessage(`Added ${plural(units, "unit")} across ${plural(ordered.length, "line")} to your cart`)
setQuantities({})
}
return (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-surface">
<TableRow className="hover:bg-transparent">
<SortHead label="Product" sortKey="name" sort={sort} onSort={onSort} className="ps-4" />
<SortHead label="Price" sortKey="price" sort={sort} onSort={onSort} className="text-end max-sm:hidden" />
<SortHead label="In stock" sortKey="stock" sort={sort} onSort={onSort} className="max-sm:hidden" />
<TableHead className="max-sm:pe-4">Quantity</TableHead>
<TableHead className="pe-4 text-end max-sm:hidden">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((line) => {
const quantity = quantityOf(line)
return (
<TableRow key={line.id} data-state={quantity > 0 ? "selected" : undefined}>
<TableCell className="ps-4 whitespace-normal">
<div className="flex items-center gap-3">
<div className="w-10 shrink-0 overflow-hidden rounded-md">{art[line.id]}</div>
<div className="flex min-w-0 flex-col">
<Link href={line.href} className="focus-ring rounded-sm text-foreground hover:underline">
{line.name}
</Link>
<span className="text-xs font-normal text-muted-foreground">
<span className="font-mono">{line.sku}</span>
<span className="tabular-nums sm:hidden"> · {money(line.priceCents)}</span>
{line.stockState === "low" ? (
<StatusBadge status="low" variant="warning" size="sm" label="Low stock" className="ms-1.5 sm:hidden" />
) : null}
</span>
</div>
</div>
</TableCell>
<TableCell className="text-end tabular-nums max-sm:hidden">{money(line.priceCents)}</TableCell>
<TableCell className="max-sm:hidden">
<span className="inline-flex items-center gap-1.5 tabular-nums">
{formatNumber(line.stock)}
{line.stockState === "low" ? <StatusBadge status="low" variant="warning" size="sm" label="Low" /> : null}
</span>
</TableCell>
<TableCell className="w-28 max-sm:pe-4">
<NumberInput
size="sm"
aria-label={`Quantity of ${line.name}`}
min={0}
max={line.stock}
value={quantities[line.id] ?? null}
placeholder="0"
onValueChange={(value) => setQuantities((current) => ({ ...current, [line.id]: value }))}
className="w-24"
/>
</TableCell>
<TableCell className="pe-4 text-end tabular-nums max-sm:hidden">
{quantity > 0 ? money(quantity * line.priceCents) : <span className="text-muted-foreground">—</span>}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
<CardFooter className="flex-col items-stretch gap-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-0.5">
<p className="text-sm text-muted-foreground tabular-nums">
{plural(ordered.length, "line")}, {plural(units, "unit")}
</p>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-foreground">
{message}
</p>
</div>
<div className="flex items-center justify-between gap-4 sm:justify-end">
<p className="text-sm text-muted-foreground">
Subtotal <span className="type-numeral ms-1 text-lg text-foreground tabular-nums">{money(cents)}</span>
</p>
<Button disabled={ordered.length === 0} focusableWhenDisabled onClick={addAll}>
<ShoppingBagIcon data-icon="inline-start" />
Add to cart
</Button>
</div>
</CardFooter>
</Card>
)
}/**
* What `product-list-03` reads: one department's products still sold, as the
* lines of an order sheet — the SKU, the price, and the stock on hand, which
* caps how many of a line can be ordered. Change `DEPARTMENT` to order from
* another.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** The department the sheet orders from. */
export const DEPARTMENT = "Electronics"
/** A product as a line of the order sheet. */
export type OrderLine = Pick<ShelfProduct, "id" | "sku" | "name" | "category" | "priceCents" | "stock" | "stockState" | "href">
/** Every product still sold in `DEPARTMENT` that has stock to order. */
export function orderSheet(): OrderLine[] {
return storeShelf()
.filter((product) => product.category === DEPARTMENT && product.stock > 0)
.map(({ id, sku, name, category, priceCents, stock, stockState, href }) => ({
id,
sku,
name,
category,
priceCents,
stock,
stockState,
href,
}))
}npx shadcn@latest add @vibra/product-list-03Department rail beside a searchable grid
product-list-04The store's departments on a rail with their counts, one chosen at a time, its products as cards beside it, and a search that narrows them by name.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { DepartmentBrowser } from "./product-list-04.client"
import { railCatalogue } from "./product-list-04.data"
/**
* A department rail beside a grid: the store's departments with their
* counts, one chosen at a time, the chosen department's products as cards,
* and a search that narrows them by name. The rail stands beside the grid
* from `lg` and wraps into chips above it on a phone.
*
* A server component: it reads the departments and every product through
* `./product-list-04.data` and draws every picture here, handing them to the
* island by product id.
*/
export function ProductList04() {
const titleId = React.useId()
const { departments, products } = railCatalogue()
const art = Object.fromEntries(
products.map((product) => [
product.id,
<ProductArt key={product.id} name={product.name} category={product.category} className="rounded-none" />,
])
)
return (
<section
data-section="product-list-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 max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Shop by department
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Choose a department, then search within it. Each one lists what it holds, bestselling first.
</p>
</div>
<div className="mt-8 md:mt-10">
<DepartmentBrowser departments={departments} products={products} art={art} />
</div>
</div>
</section>
)
}
export default ProductList04"use client"
import * as React from "react"
import Link from "next/link"
import { SearchXIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { Price } from "@/components/ui/price"
import { SearchInput } from "@/components/ui/search-input"
import { StatusBadge } from "@/components/ui/status-badge"
import type { RailDepartment, RailProduct } from "./product-list-04.data"
/**
* The department rail and the grid it browses. The rail is a set of native
* radio buttons — one department at a time, the arrow keys moving between
* them the way every browser already does — drawn as the kit draws a chosen
* row: the brand tint, a rail and the 500 weight. From `lg` it stands beside
* the grid; on a phone it wraps into a row of chips above it. The search
* narrows the chosen department by name, and what is shown is said in an
* always-mounted status line. Every picture arrives drawn, as `art`.
*/
export function DepartmentBrowser({
departments,
products,
art,
}: {
departments: RailDepartment[]
products: RailProduct[]
art: Record<string, React.ReactNode>
}) {
const [department, setDepartment] = React.useState(departments[0]?.name ?? "")
const [query, setQuery] = React.useState("")
const group = React.useId()
const legendId = React.useId()
const searchId = React.useId()
const inDepartment = products.filter((product) => product.category === department)
const needle = query.trim().toLowerCase()
const shown = needle ? inDepartment.filter((product) => product.name.toLowerCase().includes(needle)) : inDepartment
const status = needle
? `${shown.length} of ${inDepartment.length} in ${department} match “${query.trim()}”`
: `${inDepartment.length} products in ${department}`
return (
<div className="flex flex-col gap-6 lg:flex-row lg:gap-10">
<div role="radiogroup" aria-labelledby={legendId} className="flex flex-col gap-2 lg:w-56 lg:shrink-0">
<span id={legendId} className="type-eyebrow">
Departments
</span>
<div className="flex flex-wrap gap-1.5 lg:flex-col lg:gap-0.5">
{departments.map((entry) => (
<label
key={entry.name}
className="flex cursor-pointer items-center justify-between gap-3 rounded-full px-3 py-1.5 text-sm text-muted-foreground ring-1 ring-border transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted hover:text-foreground has-checked:bg-brand-muted has-checked:font-medium has-checked:text-foreground has-checked:ring-transparent has-focus-visible:focus-outline lg:rounded-md lg:py-2 lg:ring-0 lg:has-checked:shadow-[inset_2px_0_0_var(--brand)]"
>
<input
type="radio"
name={group}
value={entry.name}
checked={department === entry.name}
onChange={() => setDepartment(entry.name)}
className="sr-only"
/>
<span>{entry.name}</span>
<span className="sr-only">,</span>{" "}
<span className="text-xs tabular-nums">{entry.count}</span>{" "}
<span className="sr-only">products</span>
</label>
))}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<SearchInput
id={searchId}
value={query}
onValueChange={setQuery}
aria-label={`Search ${department}`}
placeholder={`Search ${department}`}
className="sm:max-w-xs"
/>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{status}
</p>
</div>
{shown.length === 0 ? (
<EmptyState
variant="dashed"
icon={<SearchXIcon />}
title={`Nothing in ${department} matches “${query.trim()}”`}
description="Try another word, or another department."
action={
<Button
variant="outline"
onClick={() => {
setQuery("")
// The button goes with the empty state; the reader goes back to the search.
document.getElementById(searchId)?.focus()
}}
>
Show all of {department}
</Button>
}
/>
) : (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-4">
{shown.map((product) => (
<li key={product.id} className="flex">
<Card
variant="interactive"
className="relative w-full has-[a:focus-visible]:focus-outline"
>
<CardContent className="relative flex-none p-0">
{art[product.id]}
{product.stockState === "low" ? (
<StatusBadge status="low" variant="warning" label="Low stock" className="absolute top-2 left-2" />
) : null}
</CardContent>
<CardFooter className="mt-0 flex-1 flex-col items-stretch gap-1 pt-2 pb-3">
<h3 className="text-sm font-semibold text-foreground">
<Link href={product.href} className="outline-none after:absolute after:inset-0">
{product.name}
</Link>
</h3>
<Price amount={product.priceCents / 100} size="sm" className="mt-auto" />
</CardFooter>
</Card>
</li>
))}
</ul>
)}
</div>
</div>
)
}/**
* What `product-list-04` reads: every product still sold, bestselling first,
* and the store's departments A to Z with how many products each holds — the
* rail the grid is browsed by. Both come off the storefront shelf, so a
* department's count is exactly the cards it shows.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** A product as a card in the grid shows it. */
export type RailProduct = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "stockState" | "href">
/** A department on the rail, and how many products it holds. */
export type RailDepartment = { name: string; count: number }
/** The departments A to Z with their counts, and every product still sold, bestselling first. */
export function railCatalogue(): { departments: RailDepartment[]; products: RailProduct[] } {
const products = storeShelf().map(({ id, name, category, priceCents, stockState, href }) => ({
id,
name,
category,
priceCents,
stockState,
href,
}))
const counts = new Map<string, number>()
for (const product of products) counts.set(product.category, (counts.get(product.category) ?? 0) + 1)
const departments = [...counts]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name))
return { departments, products }
}npx shadcn@latest add @vibra/product-list-04Load more, ten at a time
product-list-05The whole catalogue, bestselling first, as a dense grid ten products at a time, with how far through it the reader is and a Load more that hands focus to the first product it brings.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { LoadMoreGrid } from "./product-list-05.client"
import { PAGE_SIZE, pagedShelf } from "./product-list-05.data"
/**
* A long list that loads as it goes: the whole catalogue, bestselling first,
* ten at a time, with how far through it the reader is and a Load more
* that hands focus to the first product it brings.
*
* A server component: it reads every row through `./product-list-05.data`
* and draws every picture here, handing them to the island by product id.
*/
export function ProductList05() {
const titleId = React.useId()
const products = pagedShelf()
const art = Object.fromEntries(
products.map((product) => [
product.id,
<ProductArt key={product.id} name={product.name} category={product.category} className="rounded-none" />,
])
)
return (
<section
data-section="product-list-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 max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
The whole shop
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
All {products.length} products, bestselling first, {PAGE_SIZE} at a time.
</p>
</div>
<div className="mt-8 md:mt-10">
<LoadMoreGrid products={products} art={art} pageSize={PAGE_SIZE} />
</div>
</div>
</section>
)
}
export default ProductList05"use client"
import * as React from "react"
import Link from "next/link"
import { ChevronDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { Progress } from "@/components/ui/progress"
import { StatusBadge } from "@/components/ui/status-badge"
import type { PagedRow } from "./product-list-05.data"
/**
* The grid and its Load more. A press adds the next page under the last and
* puts focus on the first product it brought, so a keyboard carries on where
* the new products start instead of back at the button; how far through the
* catalogue the grid is, is said in an always-mounted status line, with a bar
* beside it for the eye. At the end the button gives way to a line that says
* so. Every picture arrives drawn, as `art`.
*/
export function LoadMoreGrid({
products,
art,
pageSize = 10,
}: {
products: PagedRow[]
art: Record<string, React.ReactNode>
pageSize?: number
}) {
const [count, setCount] = React.useState(pageSize)
const links = React.useRef(new Map<number, HTMLAnchorElement | null>())
const firstNew = React.useRef<number | null>(null)
const total = products.length
const shown = products.slice(0, count)
const next = Math.min(pageSize, total - count)
React.useEffect(() => {
if (firstNew.current === null) return
links.current.get(firstNew.current)?.focus()
firstNew.current = null
}, [count])
return (
<div className="flex flex-col gap-8 md:gap-10">
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:gap-4 lg:grid-cols-5">
{shown.map((product, index) => (
<li key={product.id} className="flex">
<Card
variant="interactive"
className="relative w-full has-[a:focus-visible]:focus-outline"
>
<CardContent className="relative flex-none p-0">
{art[product.id]}
{product.stockState === "low" ? (
<StatusBadge status="low" variant="warning" label="Low stock" className="absolute top-2 left-2" />
) : null}
</CardContent>
<CardFooter className="mt-0 flex-1 flex-col items-stretch gap-1 pt-2 pb-3">
<p className="text-xs text-muted-foreground">{product.category}</p>
<h3 className="text-sm font-semibold text-foreground">
<Link
ref={(node) => {
links.current.set(index, node)
}}
href={product.href}
className="outline-none after:absolute after:inset-0"
>
{product.name}
</Link>
</h3>
<Price amount={product.priceCents / 100} size="sm" className="mt-auto pt-1" />
</CardFooter>
</Card>
</li>
))}
</ul>
<div className="mx-auto flex w-full max-w-xs flex-col items-center gap-3">
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
Showing {count} of {total}
</p>
<Progress value={(count / total) * 100} aria-hidden="true" className="w-full" />
{next > 0 ? (
<Button
variant="outline"
size="lg"
className="mt-1 px-4"
onClick={() => {
firstNew.current = count
setCount(count + next)
}}
>
Load {next} more
<ChevronDownIcon data-icon="inline-end" />
</Button>
) : (
<p className="mt-1 text-sm text-foreground">That is all {total}.</p>
)}
</div>
</div>
)
}/**
* What `product-list-05` reads: every product still sold, bestselling first —
* the whole catalogue the grid pages through, off the storefront shelf.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** How many products the grid opens on, and how many each press of Load more brings. */
export const PAGE_SIZE = 10
/** A product as the grid shows it. */
export type PagedRow = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "stockState" | "href">
/** Every product still sold, bestselling first. */
export function pagedShelf(): PagedRow[] {
return storeShelf().map(({ id, name, category, priceCents, stockState, href }) => ({
id,
name,
category,
priceCents,
stockState,
href,
}))
}npx shadcn@latest add @vibra/product-list-05Split list with a detail panel
product-list-06One department as a list of rows, and the product chosen from it in full beside the list — picture, price, stock, sales, supplier and Add to cart — or in a sheet on a phone.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { SplitBrowser } from "./product-list-06.client"
import { DEPARTMENT, SHELF_DAYS, splitList } from "./product-list-06.data"
/**
* A split view: one department's products as a list, and the one chosen in
* full beside it — its picture, price, stock, sales and supplier, with Add to
* cart and a link to its page. From `lg` the detail stays in view beside the
* list; on a phone it opens in a sheet from the bottom.
*
* A server component: it reads the rows through `./product-list-06.data`
* and draws every picture here — a thumbnail for each row and a 16:10 picture
* for the detail — handing both to the island by product id.
*/
export function ProductList06() {
const titleId = React.useId()
const products = splitList()
const thumbs = Object.fromEntries(
products.map((product) => [
product.id,
<ProductArt key={product.id} name={product.name} category={product.category} className="rounded-none" />,
])
)
// A 16:10 tile, its drawing sized by the width: 45% of it is 72% of the height.
const pictures = Object.fromEntries(
products.map((product) => [
product.id,
<ProductArt
key={product.id}
name={product.name}
category={product.category}
className="aspect-[16/10] rounded-none"
/>,
])
)
return (
<section
data-section="product-list-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 max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<p className="type-eyebrow">{products.length} products</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
{DEPARTMENT}, side by side
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Choose any product in the list to see it in full: its stock, how it sells and where it ships from.
</p>
</div>
<div className="mt-8 md:mt-10">
<SplitBrowser
products={products}
thumbs={thumbs}
pictures={pictures}
days={SHELF_DAYS}
label={`${DEPARTMENT} products`}
/>
</div>
</div>
</section>
)
}
export default ProductList06"use client"
import * as React from "react"
import Link from "next/link"
import { ChevronRightIcon, ShoppingBagIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { StatusBadge } from "@/components/ui/status-badge"
import type { SplitRow } from "./product-list-06.data"
/** Where the detail stands beside the list rather than opening over it: the kit's `lg`. */
const SIDE_BY_SIDE = "(min-width: 64rem)"
type Pictures = Record<string, React.ReactNode>
/** One product in full: its facts as a description list, its two actions and what the last press did. */
function Detail({
product,
days,
message,
onAdd,
}: {
product: SplitRow
days: number
message: string
onAdd: () => void
}) {
return (
<div className="flex flex-col gap-5">
<Price amount={product.priceCents / 100} size="lg" />
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">In stock</dt>
<dd className="flex items-center gap-1.5 font-medium text-foreground tabular-nums">
{formatNumber(product.stock)}
{product.stockState === "low" ? <StatusBadge status="low" variant="warning" size="sm" label="Low stock" /> : null}
</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">Sold, {days} days</dt>
<dd className="font-medium text-foreground tabular-nums">{formatNumber(product.sold)}</dd>
</div>
<div className="col-span-2 flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">Ships from</dt>
<dd className="font-medium text-foreground">{product.supplier}</dd>
</div>
</dl>
<div className="flex flex-wrap gap-2">
<Button size="lg" onClick={onAdd}>
<ShoppingBagIcon data-icon="inline-start" />
Add to cart
</Button>
<Link href={product.href} className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
View product
</Link>
</div>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{message}
</p>
</div>
)
}
/**
* A list and the product chosen from it. The list is one framed card of
* ruled rows, each a button marking the product it shows with
* `aria-current`, drawn as the kit draws a chosen row — the brand tint, a
* rail and the 500 weight. From `lg` the detail stands beside the list and
* stays in view as the list scrolls; below it, choosing a product opens the
* detail, picture and all, in a sheet from the bottom, which gives focus back
* to the row it came from when it closes. Every picture arrives drawn: `thumbs` for the
* rows, `pictures` for the detail.
*/
export function SplitBrowser({
products,
thumbs,
pictures,
days,
label,
}: {
products: SplitRow[]
thumbs: Pictures
pictures: Pictures
days: number
label: string
}) {
const [selectedId, setSelectedId] = React.useState(products[0]?.id)
const [sheetOpen, setSheetOpen] = React.useState(false)
const [message, setMessage] = React.useState("")
const titleId = React.useId()
const selected = products.find((product) => product.id === selectedId) ?? products[0]
if (!selected) return null
function choose(id: string) {
setSelectedId(id)
setMessage("")
if (!window.matchMedia(SIDE_BY_SIDE).matches) setSheetOpen(true)
}
const add = () => setMessage(`Added ${selected.name} to your cart`)
return (
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-8">
<Card>
<CardContent className="p-0">
<ul aria-label={label} className="divide-y divide-border">
{products.map((product) => {
const current = product.id === selected.id
return (
<li key={product.id}>
<button
type="button"
aria-current={current ? "true" : undefined}
onClick={() => choose(product.id)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted/50 focus-ring-inset aria-[current=true]:bg-brand-muted aria-[current=true]:shadow-[inset_2px_0_0_var(--brand)]"
>
<span className="w-12 shrink-0 overflow-hidden rounded-md">{thumbs[product.id]}</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className={cn("text-sm text-foreground", current && "font-medium")}>{product.name}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{product.stockState === "low" ? `Low stock, ${formatNumber(product.stock)} left` : "In stock"}
</span>
</span>
<span className="text-sm text-foreground tabular-nums">{formatCurrency(product.priceCents / 100, "USD")}</span>
<ChevronRightIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground lg:hidden" />
</button>
</li>
)
})}
</ul>
</CardContent>
</Card>
<section aria-labelledby={titleId} className="hidden lg:sticky lg:top-6 lg:block">
<Card className="gap-2 p-2">
<CardContent className="flex-none p-0">{pictures[selected.id]}</CardContent>
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">
{selected.category} · <span className="font-mono">{selected.sku}</span>
</p>
<h3 id={titleId} className="type-display text-2xl text-balance">
{selected.name}
</h3>
</div>
<Detail product={selected} days={days} message={message} onAdd={add} />
</div>
</Card>
</section>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent side="bottom" className="max-h-[85svh] gap-0 overflow-y-auto">
<SheetHeader className="pr-12">
<SheetTitle>{selected.name}</SheetTitle>
<SheetDescription>
{selected.category} · <span className="font-mono">{selected.sku}</span>
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-5 px-4 pb-6">
<div className="overflow-hidden rounded-lg ring-1 ring-border">{pictures[selected.id]}</div>
<Detail product={selected} days={days} message={message} onAdd={add} />
</div>
</SheetContent>
</Sheet>
</div>
)
}/**
* What `product-list-06` reads: one department's products still sold,
* bestselling first, each with everything its detail panel shows — the SKU,
* the stock on hand, where it ships from and the units sold over the last 90
* days — off the storefront shelf. Change `DEPARTMENT` to browse another.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { SHELF_DAYS, storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** The window "sold" is counted over, in days: the shelf's own. */
export { SHELF_DAYS }
/** The department the list browses. */
export const DEPARTMENT = "Furniture"
/** A product with everything its detail shows. */
export type SplitRow = Pick<
ShelfProduct,
"id" | "sku" | "name" | "category" | "supplier" | "priceCents" | "stock" | "stockState" | "sold" | "href"
>
/** Every product still sold in `DEPARTMENT`, bestselling first. */
export function splitList(): SplitRow[] {
return storeShelf().filter((product) => product.category === DEPARTMENT)
}npx shadcn@latest add @vibra/product-list-06