Product category
The departments of a store as tiles, each leading to its own listing.
Category header with aisle chips
product-category-01The top of a department's page: the trail back to the shop, the department's name and what it holds, how many products, their prices and how many sold, and every aisle as a chip with its count.
import * as React from "react"
import Link from "next/link"
import { LayoutGridIcon } from "lucide-react"
import { formatCurrency, formatNumber } from "@/lib/format"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { ProductArt } from "@/components/ui/product-art"
import { DESCRIPTION, SHELF_DAYS, categoryHeader, type AisleLink } from "./product-category-01.data"
/** A price as a range end: whole dollars where the price is whole, cents where it is not. */
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: cents % 100 ? 2 : 0 })
/**
* A chip that leads to an aisle: a drawing of its bestseller (the whole
* department's chip wears a grid), its name, then how many products it
* holds, said as "Seating, 3 products".
*/
function AisleChip({ aisle, label, current }: { aisle: Omit<AisleLink, "lead"> & Partial<AisleLink>; label: string; current: boolean }) {
return (
<Link
href={aisle.href}
aria-current={current ? "page" : undefined}
className="focus-ring inline-flex h-9 items-center gap-2 rounded-full bg-card py-1 pr-3.5 pl-1 text-sm text-foreground ring-1 ring-border transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted/60 aria-[current=page]:bg-brand-muted aria-[current=page]:font-medium aria-[current=page]:ring-transparent"
>
{aisle.lead ? (
<ProductArt name={aisle.lead.name} category={aisle.lead.category} className="size-7 shrink-0 rounded-full" />
) : (
<span aria-hidden="true" className="grid size-7 shrink-0 place-items-center rounded-full bg-secondary">
<LayoutGridIcon className="size-3.5 text-muted-foreground" />
</span>
)}
{label}
<span className="sr-only">,</span>{" "}
<span className="text-muted-foreground tabular-nums">{aisle.count}</span>{" "}
<span className="sr-only">{aisle.count === 1 ? "product" : "products"}</span>
</Link>
)
}
export type ProductCategory01Props = {
/** The aisle the page is on; left out, the header opens the whole department. */
subcategory?: string
}
/**
* The top of a department's page: the trail back to the shop, the
* department's name and what it holds, three figures — how many products,
* what they cost and how many sold — and every aisle of it as a chip that
* says how many products it holds. The chip of the page the reader is on is
* marked current, in the brand tint and the 500 weight. The department's
* three bestsellers are drawn beside the title from `md`.
*
* A server component: every figure comes from `./product-category-01.data`,
* which reads the storefront's shelf.
*/
export function ProductCategory01({ subcategory }: ProductCategory01Props) {
const titleId = React.useId()
const header = categoryHeader(subcategory)
const title = header.subcategory ?? header.department
const lede = header.subcategory
? `${header.count} of the ${header.all.count} products in ${header.department}.`
: DESCRIPTION
return (
<section
data-section="product-category-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 flex-col gap-8 md:flex-row md:items-end md:justify-between">
<div className="flex max-w-2xl flex-col gap-5">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Link href="/products" className="focus-ring rounded-sm hover:text-foreground">
Shop
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
{header.subcategory ? (
<Link href={header.all.href} className="focus-ring rounded-sm hover:text-foreground">
{header.department}
</Link>
) : (
<BreadcrumbPage>{header.department}</BreadcrumbPage>
)}
</BreadcrumbItem>
{header.subcategory ? (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{header.subcategory}</BreadcrumbPage>
</BreadcrumbItem>
</>
) : null}
</BreadcrumbList>
</Breadcrumb>
<div className="flex flex-col gap-2">
<h2 id={titleId} className="type-display text-3xl text-balance md:text-4xl">
{title}
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">{lede}</p>
</div>
<dl className="grid grid-cols-3 gap-4 sm:flex sm:gap-x-10">
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">Products</dt>
<dd className="type-numeral text-lg tabular-nums sm:text-xl">{formatNumber(header.count)}</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">Prices</dt>
<dd className="type-numeral text-lg tabular-nums sm:text-xl">
{header.fromCents === header.toCents
? money(header.fromCents)
: `${money(header.fromCents)} to ${money(header.toCents)}`}
</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">Bought, {SHELF_DAYS} days</dt>
<dd className="type-numeral text-lg tabular-nums sm:text-xl">{formatNumber(header.sold)}</dd>
</div>
</dl>
</div>
{/* Decoration: the page's three bestsellers, whose names the grid
below the header carries. */}
<div aria-hidden="true" className="hidden w-72 shrink-0 grid-cols-3 gap-2 md:grid lg:w-[30rem] lg:gap-3">
{header.top.map((product) => (
<ProductArt key={product.id} name={product.name} category={product.category} />
))}
</div>
</div>
<nav aria-label={`Aisles of ${header.department}`} className="mt-8 border-t border-rule pt-5 md:mt-10">
<ul className="flex flex-wrap gap-2">
<li>
<AisleChip aisle={header.all} label={`All ${header.department.toLowerCase()}`} current={!header.subcategory} />
</li>
{header.aisles.map((aisle) => (
<li key={aisle.name}>
<AisleChip aisle={aisle} label={aisle.name} current={aisle.name === header.subcategory} />
</li>
))}
</ul>
</nav>
</div>
</section>
)
}
export default ProductCategory01/**
* What `product-category-01` reads: one department of the shop as the top of
* its own page — its products, what they cost, what they sold, and every
* aisle of it with how many products it holds. The aisles are the
* storefront's (`storeDepartments`), so a count here is the count every band
* prints for that aisle.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import {
SHELF_DAYS,
departmentHref,
storeDepartments,
type ShelfProduct,
} from "@/lib/dashboards/ecommerce/storefront"
export { SHELF_DAYS }
/** The department the header opens. */
export const DEPARTMENT = "Furniture"
/** What the department holds, in a line: the header's own copy. */
export const DESCRIPTION = "Tables, seating and shelving for the living room, the dining room and the study."
/** A product as the header draws it. */
export type Drawn = Pick<ShelfProduct, "id" | "name" | "category">
const drawn = ({ id, name, category }: ShelfProduct): Drawn => ({ id, name, category })
/** One aisle as a chip: its name, how many products it holds, its listing, and its bestseller to draw on the chip. */
export type AisleLink = { name: string; count: number; href: string; lead: Drawn }
/** The header of a department's page, or of one aisle of it. */
export type CategoryHeader = {
department: string
/** The aisle the page is on; undefined on the department's own page. */
subcategory?: string
/** The products the page lists. */
count: number
/** The cheapest and the dearest of them, in cents. */
fromCents: number
toCents: number
/** Units of them sold over the shelf's window. */
sold: number
/** The department's own listing and how many products it holds. */
/** The department's own listing: its name, how many products it holds and its link. */
all: Omit<AisleLink, "lead">
aisles: AisleLink[]
/** The three bestsellers of the page, drawn beside the title. */
top: Drawn[]
}
/**
* The header for `DEPARTMENT`, or for one aisle of it. An aisle the
* department does not have opens the department's own page.
*/
export function categoryHeader(subcategory?: string): CategoryHeader {
const department = storeDepartments().find((entry) => entry.name === DEPARTMENT)
if (!department) throw new Error(`product-category-01 opens "${DEPARTMENT}", which has no product on the shelf`)
const aisle = department.subcategories.find((entry) => entry.name === subcategory)
const products = aisle?.products ?? department.products
const prices = products.map((product) => product.priceCents)
return {
department: department.name,
subcategory: aisle?.name,
count: products.length,
fromCents: Math.min(...prices),
toCents: Math.max(...prices),
sold: products.reduce((sum, product) => sum + product.sold, 0),
all: { name: department.name, count: department.products.length, href: departmentHref(department.name) },
aisles: department.subcategories.map((entry) => ({
name: entry.name,
count: entry.products.length,
href: departmentHref(department.name, entry.name),
lead: drawn(entry.products[0]),
})),
top: products.slice(0, 3).map(drawn),
}
}npx shadcn@latest add @vibra/product-category-01Category grid with filters
product-category-02One department's products under filters for aisle, price and rating — a sidebar on a wide screen, a sheet on a phone — with the filters that are on shown as chips that take them off, a sort, and the count of what is left.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { FilteredGrid } from "./product-category-02.client"
import { HEADING, categoryGrid } from "./product-category-02.data"
/**
* A department's grid with its filters: every product of one department,
* bestselling first, narrowed by aisle, price and rating from a sidebar — a
* sheet on a phone — with what is on shown as chips that take it off, a
* sort, and the count of what is left said as it changes.
*
* A server component: it reads the rows through `./product-category-02.data`
* and draws every picture here, handing them to the island by product id,
* so the drawings never ship as client code.
*/
export function ProductCategory02() {
const titleId = React.useId()
const { department, aisles, products } = categoryGrid()
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-category-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">{department}</p>
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
{HEADING}
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
All {products.length} {department.toLowerCase()} products, bestselling first. Narrow them by aisle, by price
and by what buyers rated them.
</p>
</div>
<div className="mt-8 md:mt-10">
<FilteredGrid department={department} aisles={aisles} products={products} art={art} />
</div>
</div>
</section>
)
}
export default ProductCategory02"use client"
import * as React from "react"
import Link from "next/link"
import { SlidersHorizontalIcon, XIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Price } from "@/components/ui/price"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Rating } from "@/components/ui/rating"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { StatusBadge } from "@/components/ui/status-badge"
import type { GridProduct } from "./product-category-02.data"
/** The price bands a shopper narrows by, in cents: from, and up to but not including. */
export const PRICE_BANDS = [
{ id: "under-50", label: "Under $50", from: 0, to: 5_000 },
{ id: "50-150", label: "$50 to $150", from: 5_000, to: 15_000 },
{ id: "150-up", label: "$150 and up", from: 15_000, to: Infinity },
] as const
/** The least score a product may have: 0 lets every product through. */
export const RATING_FLOORS = [
{ value: 0, label: "Any rating" },
{ value: 4, label: "4 and up" },
{ value: 3, label: "3 and up" },
] as const
export const SORTS = {
bestselling: "Bestselling",
"price-asc": "Price, low to high",
"price-desc": "Price, high to low",
rating: "Highest rated",
} as const
type Sort = keyof typeof SORTS
type Filters = { aisles: ReadonlySet<string>; bands: ReadonlySet<string>; floor: number }
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: GridProduct, filters: Filters, skip?: Facet): boolean {
return (
(skip === "aisles" || !filters.aisles.size || filters.aisles.has(product.subcategory)) &&
(skip === "bands" || !filters.bands.size || filters.bands.has(bandOf(product.priceCents))) &&
(skip === "floor" || product.rating >= filters.floor)
)
}
// Every sort is stable, so ties keep the shelf's order: bestselling first.
const SORTERS: Record<Sort, (a: GridProduct, b: GridProduct) => number> = {
bestselling: () => 0,
"price-asc": (a, b) => a.priceCents - b.priceCents,
"price-desc": (a, b) => b.priceCents - a.priceCents,
rating: (a, b) => b.rating - a.rating,
}
const flip = (set: ReadonlySet<string>, value: string) => {
const next = new Set(set)
if (!next.delete(value)) next.add(value)
return next
}
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/** A count after a label, said as "Hiking, 3 products". */
function Count({ count }: { count: number }) {
return (
<>
<span className="sr-only">,</span>{" "}
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{count}</span>{" "}
<span className="sr-only">{count === 1 ? "product" : "products"}</span>
</>
)
}
/**
* The three facets as fieldsets. Every choice says how many products it
* would leave given the others, and one that would leave none is unavailable
* unless it is already on, so no combination empties the grid.
*/
function FilterGroups({ facets, filters, onChange }: {
facets: { aisles: Option[]; bands: Option[]; floors: Option[] }
filters: Filters
onChange: (next: Filters) => void
}) {
const boxes = (facet: "aisles" | "bands", options: Option[]) =>
options.map((option) => {
const on = filters[facet].has(option.value)
return (
<Label key={option.value} className="flex h-8 items-center gap-2.5 text-sm font-normal text-foreground">
<Checkbox
checked={on}
disabled={option.count === 0 && !on}
onCheckedChange={() => onChange({ ...filters, [facet]: flip(filters[facet], option.value) })}
/>
{option.label}
<Count count={option.count} />
</Label>
)
})
return (
<div className="flex flex-col gap-6">
<fieldset className="flex flex-col gap-1">
<legend className="mb-1.5 type-eyebrow">Aisle</legend>
{boxes("aisles", facets.aisles)}
</fieldset>
<fieldset className="flex flex-col gap-1">
<legend className="mb-1.5 type-eyebrow">Price</legend>
{boxes("bands", facets.bands)}
</fieldset>
<fieldset>
<legend className="mb-2.5 type-eyebrow">Rating</legend>
<RadioGroup value={String(filters.floor)} onValueChange={(value) => onChange({ ...filters, floor: Number(value) })} className="gap-1">
{facets.floors.map((option) => (
<Label key={option.value} className="flex h-8 items-center gap-2.5 text-sm font-normal text-foreground">
<RadioGroupItem value={option.value} disabled={option.count === 0 && String(filters.floor) !== option.value} />
{option.label}
<Count count={option.count} />
</Label>
))}
</RadioGroup>
</fieldset>
</div>
)
}
/**
* A department's grid with its filters: aisle, price and rating as a sidebar
* from `lg` and in a sheet from the left below it; what is on shows as chips
* over the grid, each one a button that takes it off; the count is an
* always-mounted status, and the sort keeps ties in the shelf's order.
* Every picture arrives drawn, as `art`.
*/
export function FilteredGrid({ department, aisles, products, art }: {
department: string
aisles: string[]
products: GridProduct[]
art: Record<string, React.ReactNode>
}) {
const [filters, setFilters] = React.useState<Filters>({ aisles: new Set(), bands: new Set(), floor: 0 })
const [sort, setSort] = React.useState<Sort>("bestselling")
const [sheetOpen, setSheetOpen] = React.useState(false)
const chipsRef = React.useRef<HTMLUListElement>(null)
const clearRef = React.useRef<HTMLButtonElement>(null)
const sortId = React.useId()
const results = products.filter((product) => passes(product, filters)).sort(SORTERS[sort])
const count = (facet: Facet, test: (product: GridProduct) => boolean) =>
products.filter((product) => test(product) && passes(product, filters, facet)).length
const facets = {
aisles: aisles.map((name) => ({ value: name, label: name, count: count("aisles", (p) => p.subcategory === name) })),
bands: PRICE_BANDS.map((band) => ({ value: band.id, label: band.label, count: count("bands", (p) => bandOf(p.priceCents) === band.id) })),
floors: RATING_FLOORS.map((floor) => ({ value: String(floor.value), label: floor.label, count: count("floor", (p) => p.rating >= floor.value) })),
}
const active = [
...[...filters.aisles].map((value) => ({ key: `aisle:${value}`, label: value, off: { ...filters, aisles: flip(filters.aisles, value) } })),
...[...filters.bands].map((value) => ({ key: `band:${value}`, label: PRICE_BANDS.find((band) => band.id === value)!.label, off: { ...filters, bands: flip(filters.bands, value) } })),
...(filters.floor ? [{ key: "floor", label: `Rated ${filters.floor} and up`, off: { ...filters, floor: 0 } }] : []),
]
// Taking a chip off moves focus to the chip that takes its place, or to
// Clear all once none is left, so the keyboard never falls off the page.
const refocus = React.useRef<number | null>(null)
React.useEffect(() => {
if (refocus.current === null) return
const chips = chipsRef.current?.querySelectorAll<HTMLButtonElement>("button") ?? []
;(chips[Math.min(refocus.current, chips.length - 1)] ?? clearRef.current)?.focus()
refocus.current = null
}, [filters])
function remove(index: number, next: Filters) {
refocus.current = index
setFilters(next)
}
return (
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-10">
<aside aria-label={`Filter ${department}`} className="hidden lg:block">
<FilterGroups facets={facets} filters={filters} onChange={setFilters} />
</aside>
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p role="status" aria-live="polite" className="text-sm font-medium text-foreground tabular-nums">
{results.length === products.length ? `${products.length} products` : `${results.length} of ${products.length} products`}
</p>
<div className="flex items-center gap-2">
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger render={<Button variant="outline" className="lg:hidden" />}>
<SlidersHorizontalIcon data-icon="inline-start" />
Filters
{active.length ? (
<>
<span className="sr-only">,</span>{" "}
<span className="text-muted-foreground tabular-nums">{active.length}</span>{" "}
<span className="sr-only">on</span>
</>
) : null}
</SheetTrigger>
<SheetContent side="left" className="w-80 gap-0">
<SheetHeader className="pr-12">
<SheetTitle>Filters</SheetTitle>
<SheetDescription>{`Narrow the ${products.length} ${department.toLowerCase()} products.`}</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
<FilterGroups facets={facets} filters={filters} onChange={setFilters} />
</div>
<SheetFooter className="border-t border-rule">
<Button onClick={() => setSheetOpen(false)}>Show {results.length} products</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<label htmlFor={sortId} className="text-sm text-muted-foreground max-sm:sr-only">
Sort by
</label>
<NativeSelect id={sortId} value={sort} onChange={(event) => setSort(event.target.value as Sort)}>
{Object.entries(SORTS).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 border-y border-border py-2.5">
<ul ref={chipsRef} aria-label="Filters on" className="flex flex-wrap gap-2 empty:hidden">
{active.map((chip, index) => (
<li key={chip.key}>
<Button variant="secondary" size="sm" aria-label={`Remove ${chip.label}`} onClick={() => remove(index, chip.off)} className="rounded-full">
{chip.label}
<XIcon data-icon="inline-end" />
</Button>
</li>
))}
</ul>
{active.length ? null : <p className="text-sm text-muted-foreground">No filters on</p>}
<Button
ref={clearRef}
variant="ghost"
size="sm"
disabled={!active.length}
focusableWhenDisabled
className="ml-auto"
onClick={() => setFilters({ aisles: new Set(), bands: new Set(), floor: 0 })}
>
Clear all
</Button>
</div>
<ul aria-label={`${department} products`} className="grid grid-cols-2 gap-3 md:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{results.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.subcategory}</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>
{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-xs text-muted-foreground tabular-nums">
{score(product.rating)} ({product.ratings})
</span>
<span className="sr-only">
Rated {score(product.rating)} out of 5 from {product.ratings} ratings
</span>
</div>
) : null}
<Price amount={product.priceCents / 100} size="sm" className="mt-auto pt-1" />
</CardFooter>
</Card>
</li>
))}
</ul>
</div>
</div>
)
}/**
* What `product-category-02` reads: one department's products with the three
* things its filters work on — the aisle each sits in, its price and what
* buyers rated it — in the shelf's order, bestselling first, which is the
* order the grid keeps under any filter until the reader sorts it. The aisles
* and the scores are the storefront's, so a product filed under Hiking here
* is under Hiking on every band, and its score is the score it wears on its
* own page.
*
* 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 { storeDepartments, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** The department the grid lists, and what the heading calls it: change them together. */
export const DEPARTMENT = "Outdoor"
export const HEADING = "Tents, packs and the camp kitchen"
/** A product as the filtered grid lists it. */
export type GridProduct = Pick<
ShelfProduct,
"id" | "name" | "category" | "subcategory" | "priceCents" | "stockState" | "rating" | "ratings" | "href"
>
/** The department's aisles, in the order the shop files them, and its products, bestselling first. */
export function categoryGrid(): { department: string; aisles: string[]; products: GridProduct[] } {
const department = storeDepartments().find((entry) => entry.name === DEPARTMENT)
if (!department) throw new Error(`product-category-02 lists "${DEPARTMENT}", which has no product on the shelf`)
return {
department: department.name,
aisles: department.subcategories.map((aisle) => aisle.name),
products: department.products.map(({ id, name, category, subcategory, priceCents, stockState, rating, ratings, href }) => ({
id,
name,
category,
subcategory,
priceCents,
stockState,
rating,
ratings,
href,
})),
}
}npx shadcn@latest add @vibra/product-category-02Department mega-grid
product-category-03The whole shop on one sheet: every department in a ruled cell with its product count, every aisle inside it with its own count, and its bestseller at the foot.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { ProductArt } from "@/components/ui/product-art"
import { shopMap, type Place } from "./product-category-03.data"
const products = (count: number) => (count === 1 ? "product" : "products")
/** A place and how many products it holds, said as "Seating, 3 products". */
function PlaceLink({ place, className }: { place: Place; className?: string }) {
return (
<Link
href={place.href}
className={cn(
"focus-ring -mx-2 flex h-8 items-center justify-between gap-3 rounded-md px-2 text-sm text-foreground transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted/60",
className
)}
>
<span className="min-w-0 truncate">{place.name}</span>
<span className="sr-only">,</span>{" "}
<span className="text-xs text-muted-foreground tabular-nums">{place.count}</span>{" "}
<span className="sr-only">{products(place.count)}</span>
</Link>
)
}
/**
* The whole shop on one sheet: every department in a cell — its name and how
* many products it holds, every aisle inside it with its count, and at the
* foot of the cell its bestseller — the cells ruled apart rather than boxed.
* Three across from `lg`, two from `sm`, one on a phone.
*
* A server component: the departments, the aisles and every count come from
* `./product-category-03.data`, which reads the storefront's shelf.
*/
export function ProductCategory03() {
const titleId = React.useId()
const departments = shopMap()
const total = departments.reduce((sum, department) => sum + department.count, 0)
return (
<section
data-section="product-category-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">Departments</p>
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
Everything we sell, aisle by aisle
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Every department and the aisles inside it, with how many products each holds and what sells best.
</p>
</div>
<Card className="mt-8 md:mt-10">
<CardContent className="p-0">
{/* Each cell draws its own right and bottom edge; the sheet clips
the last column's and the last row's, so the rules sit only
between cells whatever the count. */}
<ul className="-mr-px -mb-px grid sm:grid-cols-2 lg:grid-cols-3">
{departments.map((department) => (
<li key={department.name} className="flex flex-col gap-4 border-r border-b border-border p-4 md:p-5">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-base font-semibold">
<Link href={department.href} className="focus-ring rounded-sm hover:underline">
{department.name}
</Link>
</h3>
<p className="text-xs text-muted-foreground tabular-nums">
{department.count} {products(department.count)}
</p>
</div>
<ul aria-label={`Aisles of ${department.name}`} className="flex flex-col">
{department.aisles.map((aisle) => (
<li key={aisle.name}>
<PlaceLink place={aisle} />
</li>
))}
</ul>
<div className="mt-auto border-t border-border pt-3">
<div className="relative -m-1 flex items-center gap-3 rounded-lg p-1 transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted/60 has-[a:focus-visible]:focus-outline">
<ProductArt
name={department.bestseller.name}
category={department.bestseller.category}
className="w-12 shrink-0 rounded-md"
/>
<div className="flex min-w-0 flex-1 flex-col">
<p className="text-xs text-muted-foreground">Bestseller</p>
<Link
href={department.bestseller.href}
className="truncate text-sm font-medium text-foreground outline-none after:absolute after:inset-0"
>
{department.bestseller.name}
</Link>
</div>
<span className="shrink-0 text-sm font-medium text-foreground tabular-nums">
{formatCurrency(department.bestseller.priceCents / 100, "USD")}
</span>
</div>
</div>
</li>
))}
</ul>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3">
<p className="text-sm text-muted-foreground tabular-nums">
{total} products in {departments.length} departments
</p>
<Link href="/products" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "group/all")}>
Browse everything
<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>
</CardFooter>
</Card>
</div>
</section>
)
}
export default ProductCategory03/**
* What `product-category-03` reads: the whole shop as a map — every
* department with a product on the shelf, every aisle inside it with how many
* products it holds, and the department's bestseller. Departments and aisles
* are the storefront's (`storeDepartments`), so every count here is the one
* the other bands print.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import {
departmentHref,
storeDepartments,
type ShelfProduct,
} from "@/lib/dashboards/ecommerce/storefront"
/** A place in the shop a link leads to: a department or an aisle, with how many products it holds. */
export type Place = { name: string; count: number; href: string }
/** A department as the map draws it. */
export type MapDepartment = Place & {
aisles: Place[]
bestseller: Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "href">
}
/** Every department, A to Z, with its aisles in the order the shop files them. */
export function shopMap(): MapDepartment[] {
return storeDepartments().map((department) => {
const { id, name, category, priceCents, href } = department.products[0]
return {
name: department.name,
count: department.products.length,
href: departmentHref(department.name),
aisles: department.subcategories.map((aisle) => ({
name: aisle.name,
count: aisle.products.length,
href: departmentHref(department.name, aisle.name),
})),
bestseller: { id, name, category, priceCents, href },
}
})
}npx shadcn@latest add @vibra/product-category-03Shop by room or activity
product-category-04The shop cut across its departments: two tabs switch between four rooms and four activities, each a card drawn with its three bestsellers that says how many products it holds, from what price.
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { ProductArt } from "@/components/ui/product-art"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { shopBy, type PlaceTile } from "./product-category-04.data"
/** A place's cheapest product: whole dollars where the price is whole. */
const from = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: cents % 100 ? 2 : 0 })
/**
* One room or activity: its three bestsellers drawn as one picture — the
* dearest large, the other two stacked beside it — then its name, its line,
* and how many products it holds from what price. The whole card is the
* link.
*/
function PlaceCard({ tile }: { tile: PlaceTile }) {
const [first, ...rest] = tile.top
return (
<Card
variant="interactive"
className="relative w-full has-[a:focus-visible]:focus-outline"
>
<CardContent className="grid aspect-video flex-none grid-cols-3 grid-rows-2 gap-1 p-1 sm:aspect-[4/3]">
<ProductArt
name={first.name}
category={first.category}
className={cn("aspect-auto h-full rounded-md", rest.length === 2 ? "col-span-2 row-span-2" : "col-span-3 row-span-2")}
/>
{rest.length === 2
? rest.map((product) => (
<ProductArt key={product.id} name={product.name} category={product.category} className="aspect-auto h-full rounded-md" />
))
: 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={tile.href} className="outline-none after:absolute after:inset-0">
{tile.name}
</Link>
</h3>
<p className="text-xs text-pretty text-muted-foreground">{tile.blurb}</p>
<p className="mt-auto pt-2 text-xs text-foreground tabular-nums">
{tile.count} products <span className="text-muted-foreground">from {from(tile.fromCents)}</span>
</p>
</CardFooter>
</Card>
)
}
function PlaceGrid({ tiles, label }: { tiles: PlaceTile[]; label: string }) {
return (
<ul aria-label={label} className="grid gap-3 sm:grid-cols-2 md:gap-4 lg:grid-cols-4">
{tiles.map((tile) => (
<li key={tile.slug} className="flex">
<PlaceCard tile={tile} />
</li>
))}
</ul>
)
}
/**
* The shop cut across its departments: by the room a product goes in, or by
* what it is for. Two tabs switch between the four rooms and the four
* activities; each is a card drawn with its three bestsellers and says how
* many products it holds, from what price. Four across from `lg`, two from
* `sm`, one on a phone.
*
* A server component: the places, what belongs to each and every figure come
* from `./product-category-04.data`; the kit's Tabs keeps which set is shown.
*/
export function ProductCategory04() {
const titleId = React.useId()
const { rooms, activities } = shopBy()
return (
<section
data-section="product-category-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<Tabs defaultValue="rooms" className="mx-auto max-w-7xl gap-8 md:gap-10">
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
<div className="flex max-w-xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
Shop by room or activity
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Everything for one room, or for one thing you are doing, whichever department it comes from.
</p>
</div>
<TabsList aria-label="Shop by" activateOnFocus>
<TabsTrigger value="rooms" className="px-3">
Rooms
</TabsTrigger>
<TabsTrigger value="activities" className="px-3">
Activities
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="rooms">
<PlaceGrid tiles={rooms} label="Rooms" />
</TabsContent>
<TabsContent value="activities">
<PlaceGrid tiles={activities} label="Activities" />
</TabsContent>
</Tabs>
</section>
)
}
export default ProductCategory04/**
* What `product-category-04` reads: the shop cut across its departments, by
* the room a product goes in and by what it is for. The catalogue records
* neither, so a place is a rule over the product — the departments it takes
* whole and the words in a product's name it takes besides — and a product
* may belong to more than one: a throw is for the living room and the
* bedroom. Each place is counted and priced off the storefront's shelf, and
* drawn with its three bestsellers, the dearest of them large.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** A room or an activity: its name, its line of copy, and the rule for what belongs to it. */
export type PlaceRule = {
slug: string
name: string
blurb: string
/** Departments it takes whole. */
departments?: readonly string[]
/** Words in a product's name that put it here. */
words?: RegExp
}
export const ROOMS: readonly PlaceRule[] = [
{
slug: "living-room",
name: "Living room",
blurb: "Seats, shelves and somewhere to put the coffee down.",
words: /\b(armchair|coffee table|console table|tv stand|bookshelf|wall shelf|ottoman|throw|divider)\b/i,
},
{
slug: "kitchen",
name: "Kitchen",
blurb: "Pans, knives and what to keep the leftovers in.",
words: /\b(skillet|baking|kettle|cutting|knife|containers|mug|bar stool)\b/i,
},
{
slug: "home-office",
name: "Home office",
blurb: "A desk, a chair and everything that plugs in.",
departments: ["Office", "Electronics"],
words: /\bcorner desk\b/i,
},
{
slug: "bedroom-and-bath",
name: "Bedroom and bath",
blurb: "Towels, a throw and a place for the things on the floor.",
words: /\b(towel|blanket|ottoman|wall shelf)\b/i,
},
]
export const ACTIVITIES: readonly PlaceRule[] = [
{
slug: "camping",
name: "Camping",
blurb: "A night outside, with dinner and a light to read by.",
words: /\b(tent|sleeping|hammock|grill|cooler|camp chair|lantern)\b/i,
},
{
slug: "hiking",
name: "Hiking",
blurb: "A day on the trail, dry and warm at both ends.",
words: /\b(backpack|trekking|water bottle|rain jacket|running shoes|socks|beanie|merino)\b/i,
},
{
slug: "hosting",
name: "Hosting",
blurb: "Coffee for six, and somewhere for all six to sit.",
words: /\b(mug|bar stool|coffee table|throw|grill|cooler|kettle)\b/i,
},
{
slug: "getting-organized",
name: "Getting organized",
blurb: "Trays, shelves and a cabinet for the paperwork.",
words: /\b(organizer|cable management|filing|bookshelf|wall shelf|ottoman|containers)\b/i,
},
]
/** Whether a product belongs to a place. */
export function belongs(place: PlaceRule, product: Pick<ShelfProduct, "name" | "category">): boolean {
return Boolean(place.departments?.includes(product.category) || place.words?.test(product.name))
}
/** A place as a tile shows it. */
export type PlaceTile = Omit<PlaceRule, "departments" | "words"> & {
count: number
fromCents: number
href: string
/** Its three bestsellers, drawn on the tile, the dearest first. */
top: Pick<ShelfProduct, "id" | "name" | "category">[]
}
function tiles(places: readonly PlaceRule[], kind: "room" | "activity"): PlaceTile[] {
const shelf = storeShelf()
return places.flatMap(({ slug, name, blurb, ...rule }) => {
const products = shelf.filter((product) => belongs({ slug, name, blurb, ...rule }, product))
if (!products.length) return []
return [
{
slug,
name,
blurb,
count: products.length,
fromCents: Math.min(...products.map((product) => product.priceCents)),
href: `/products?${kind}=${slug}`,
// The three bestsellers, the dearest first: it is drawn large, and a
// room reads by its biggest piece, not its smallest.
top: products
.slice(0, 3)
.sort((a, b) => b.priceCents - a.priceCents)
.map(({ id, name: title, category }) => ({ id, name: title, category })),
},
]
})
}
/** The rooms and the activities, each with what belongs to it; a place nothing belongs to is left out. */
export function shopBy(): { rooms: PlaceTile[]; activities: PlaceTile[] } {
return { rooms: tiles(ROOMS, "room"), activities: tiles(ACTIVITIES, "activity") }
}npx shadcn@latest add @vibra/product-category-04Category with featured product
product-category-05Every department as a tab, the busiest first; in each, its bestseller large — picture, price, rating, units sold and stock — beside the next four as ranked rows and a link to the whole department.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } 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 { SHELF_DAYS, featuredDepartments, type FeaturedDepartment } from "./product-category-05.data"
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const STOCK = { in: "In stock", low: "Low stock", out: "Sold out" } as const
/** A department's bestseller, large: its picture beside what it is, what it costs, what it sold and what buyers gave it. */
function FeaturedCard({ department }: { department: FeaturedDepartment }) {
const product = department.featured
return (
<Card
variant="interactive"
className="relative gap-2 p-2 sm:flex-row has-[a:focus-visible]:focus-outline"
>
<CardContent className="flex-none p-0 sm:w-[46%]">
{/* Square on a phone; beside the details from sm it fills the
sheet's height, its drawing a little under the default: 64% of
the tile's shorter side. */}
<ProductArt
name={product.name}
category={product.category}
className="rounded-none sm:aspect-auto sm:h-full sm:min-h-72 sm:[&>svg]:size-[64%]"
/>
</CardContent>
<div className="flex min-w-0 flex-1 flex-col gap-4 p-3 md:p-5">
<div className="flex flex-col gap-1.5">
<p className="type-eyebrow">No. 1 in {department.name}</p>
<h3 className="type-display text-2xl text-balance">
<Link href={product.href} className="outline-none after:absolute after:inset-0">
{product.name}
</Link>
</h3>
<p className="text-sm text-muted-foreground">{product.subcategory}</p>
</div>
{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}
<Price amount={product.priceCents / 100} size="lg" />
<dl className="mt-auto grid grid-cols-2 gap-x-6 gap-y-3 border-t border-border pt-4 text-sm">
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">Sold, last {SHELF_DAYS} days</dt>
<dd className="font-medium text-foreground tabular-nums">{formatNumber(product.sold)}</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">Availability</dt>
<dd className="font-medium text-foreground tabular-nums">
{STOCK[product.stockState]}
{product.stockState === "low" ? `, ${formatNumber(product.stock)} left` : null}
</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>
</Card>
)
}
/** The products that follow the bestseller, as ruled rows, and the way to the whole department. */
function FollowingList({ department }: { department: FeaturedDepartment }) {
return (
<Card>
<CardHeader>
<CardTitle>
<h3>More in {department.name}</h3>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ol className="divide-y divide-border">
{department.following.map((product, index) => (
<li
key={product.id}
className="relative flex items-center gap-3 px-3 py-2.5 transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted/40 has-[a:focus-visible]:focus-outline-inset"
>
<span className="w-5 text-xs text-muted-foreground tabular-nums">{index + 2}</span>
<ProductArt name={product.name} category={product.category} className="w-12 shrink-0 rounded-md" />
<div className="flex min-w-0 flex-1 flex-col">
<Link href={product.href} className="truncate text-sm font-medium text-foreground outline-none after:absolute after:inset-0">
{product.name}
</Link>
<span className="truncate text-xs text-muted-foreground">{product.subcategory}</span>
</div>
<span className="shrink-0 text-sm font-medium text-foreground tabular-nums">
{formatCurrency(product.priceCents / 100, "USD")}
</span>
</li>
))}
</ol>
</CardContent>
<CardFooter>
<Link href={department.href} className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "group/all -ml-2")}>
All {department.count} in {department.name}
<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>
</CardFooter>
</Card>
)
}
/**
* Every department with its bestseller brought forward: one tab per
* department, the busiest first, and in each the most-bought product large
* — its picture, price, rating, what it sold and its stock, one link — beside
* the next four as ranked rows and a link to the whole department.
*
* A server component: every department, product and figure comes from
* `./product-category-05.data`; the kit's Tabs keeps which one is shown.
*/
export function ProductCategory05() {
const titleId = React.useId()
const departments = featuredDepartments()
return (
<section
data-section="product-category-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 text-balance md:text-3xl">
Start with the bestseller
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
The product bought most in each department over the last {SHELF_DAYS} days, and the ones close behind it.
</p>
</div>
<Tabs defaultValue={departments[0]?.name} className="mt-8 gap-6 md:mt-10">
{/* The six tabs outrun a phone's width: the row scrolls on its own,
edge to edge, and never the page. */}
<div className="-mx-4 overflow-x-auto border-b border-rule px-4 md:mx-0 md:px-0">
<TabsList variant="line" aria-label="Departments" activateOnFocus>
{departments.map((department) => (
<TabsTrigger key={department.name} value={department.name} className="px-3">
{department.name}
</TabsTrigger>
))}
</TabsList>
</div>
{departments.map((department) => (
<TabsContent key={department.name} value={department.name}>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] lg:gap-6">
<FeaturedCard department={department} />
<FollowingList department={department} />
</div>
</TabsContent>
))}
</Tabs>
</div>
</section>
)
}
export default ProductCategory05/**
* What `product-category-05` reads: every department with its bestseller
* brought forward — its aisle, price, stock, what it sold and what buyers
* rated it — and the next few bestsellers behind it. The departments come
* in the order of what they sold, the most first, so the band opens on the
* busiest one. Departments, sales and ratings are the storefront's.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import {
SHELF_DAYS,
departmentHref,
storeDepartments,
type ShelfProduct,
} from "@/lib/dashboards/ecommerce/storefront"
export { SHELF_DAYS }
/** How many products follow the featured one. */
export const FOLLOWING = 4
export type ListedProduct = Pick<ShelfProduct, "id" | "name" | "category" | "subcategory" | "priceCents" | "href">
/** A department as the band shows it. */
export type FeaturedDepartment = {
name: string
count: number
href: string
/** Units the whole department sold over the shelf's window. */
sold: number
featured: ListedProduct & Pick<ShelfProduct, "stock" | "stockState" | "sold" | "supplier" | "rating" | "ratings">
following: ListedProduct[]
}
const listed = ({ id, name, category, subcategory, priceCents, href }: ShelfProduct): ListedProduct => ({
id,
name,
category,
subcategory,
priceCents,
href,
})
/** Every department with its bestseller and the `FOLLOWING` behind it, the department that sold most first; a tie goes to the name. */
export function featuredDepartments(): FeaturedDepartment[] {
return storeDepartments()
.map((department) => {
const [lead, ...rest] = department.products
return {
name: department.name,
count: department.products.length,
href: departmentHref(department.name),
sold: department.products.reduce((sum, product) => sum + product.sold, 0),
featured: {
...listed(lead),
stock: lead.stock,
stockState: lead.stockState,
sold: lead.sold,
supplier: lead.supplier,
rating: lead.rating,
ratings: lead.ratings,
},
following: rest.slice(0, FOLLOWING).map(listed),
}
})
.sort((a, b) => b.sold - a.sold || a.name.localeCompare(b.name))
}npx shadcn@latest add @vibra/product-category-05Category tiles with product counts
product-category-06The departments as horizontal tiles: each its bestseller drawn at the left, and beside it its name, the aisles inside it, and how many products it holds from what price.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, ChevronRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { ProductArt } from "@/components/ui/product-art"
import { departmentTiles } from "./product-category-06.data"
/** A department's cheapest product: whole dollars where the price is whole. */
const from = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: cents % 100 ? 2 : 0 })
/**
* The departments as tiles: each its bestseller drawn at the left and, beside
* it, its name, the aisles inside it, and how many products it holds from
* what price. Each tile is one stretched link, three across from `lg`, two
* from `sm`, and one to a row on a phone, still on its side.
*
* A server component: every figure comes from `./product-category-06.data`,
* which reads the storefront's shelf.
*/
export function ProductCategory06() {
const titleId = React.useId()
const tiles = departmentTiles()
const total = tiles.reduce((sum, tile) => sum + tile.count, 0)
return (
<section
data-section="product-category-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 flex-wrap items-end justify-between gap-x-6 gap-y-4">
<div className="flex max-w-xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
Browse the departments
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Every department and what it holds, from the cheapest thing in it.
</p>
</div>
<Link href="/products" className={cn(buttonVariants({ variant: "outline" }), "group/all")}>
All {total} products
<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>
</div>
<ul className="mt-8 grid gap-3 sm:grid-cols-2 md:mt-10 md:gap-4 lg:grid-cols-3">
{tiles.map((tile) => (
<li key={tile.name} className="flex">
<Card
variant="interactive"
className="relative w-full flex-row gap-0 has-[a:focus-visible]:focus-outline"
>
<CardContent className="w-28 flex-none p-0 sm:w-32">
{/* The tile fills the sheet's height. */}
<ProductArt
name={tile.lead.name}
category={tile.lead.category}
className="h-full rounded-none"
/>
</CardContent>
<div className="flex min-w-0 flex-1 flex-col gap-1 py-3 pr-9 pl-4 sm:py-4">
<h3 className="text-base font-semibold text-foreground">
<Link href={tile.href} className="outline-none after:absolute after:inset-0">
{tile.name}
</Link>
</h3>
<p className="text-xs text-pretty text-muted-foreground">{tile.aisles.join(", ")}</p>
<p className="mt-auto pt-3 text-sm text-foreground tabular-nums">
{tile.count} products <span className="text-muted-foreground">from {from(tile.fromCents)}</span>
</p>
</div>
<ChevronRightIcon
aria-hidden="true"
className="absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/card:translate-x-0.5"
/>
</Card>
</li>
))}
</ul>
</div>
</section>
)
}
export default ProductCategory06/**
* What `product-category-06` reads: every department as a tile — how many
* products it holds, what the cheapest costs, the aisles inside it and its
* bestseller to draw. Departments and aisles are the storefront's, so a
* tile's count is the count every band prints for it.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import {
departmentHref,
storeDepartments,
type ShelfProduct,
} from "@/lib/dashboards/ecommerce/storefront"
/** A department as a tile shows it. */
export type DepartmentTile = {
name: string
count: number
href: string
/** Its cheapest product, in cents. */
fromCents: number
/** Its aisles, in the order the shop files them. */
aisles: string[]
/** Its bestseller, drawn on the tile. */
lead: Pick<ShelfProduct, "id" | "name" | "category">
}
/** Every department with a product on the shelf, A to Z. */
export function departmentTiles(): DepartmentTile[] {
return storeDepartments().map((department) => {
const { id, name, category } = department.products[0]
return {
name: department.name,
count: department.products.length,
href: departmentHref(department.name),
fromCents: Math.min(...department.products.map((product) => product.priceCents)),
aisles: department.subcategories.map((aisle) => aisle.name),
lead: { id, name, category },
}
})
}npx shadcn@latest add @vibra/product-category-06