Product cards
Products as a grid of cards: the picture, the name, the price and the way to buy.
Bestsellers grid
product-cards-01The four bestsellers on framed cards — the picture on the sheet, then department, name, price and units sold — each card one link.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { StatusBadge } from "@/components/ui/status-badge"
import { SALES_DAYS, bestsellers, type ProductCardRow } from "./product-cards-01.data"
/** What a card says about its stock: nothing when there is plenty. */
const STOCK: Record<ProductCardRow["stock"], { label: string; variant: "warning" | "neutral" } | undefined> = {
in: undefined,
low: { label: "Low stock", variant: "warning" },
out: { label: "Sold out", variant: "neutral" },
}
/**
* The bestsellers: four products on framed cards, each its picture on the
* inset sheet and its department, name, price and demand on the strip under
* it. The whole card is the link — one stop per product in the tab order,
* named by the product — and it lifts on hover the way the kit's interactive
* card does.
*
* A server component: it reads its rows through `./product-cards-01.data`,
* which reads `db`. A layout's heading and labels live here; the rows do not.
*/
export function ProductCards01() {
const titleId = React.useId()
const products = bestsellers(4)
return (
<section
data-section="product-cards-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-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 md:text-3xl">
Bestsellers
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
What sold the most over the last {SALES_DAYS} days.
</p>
</div>
<Link href="/products" className={cn(buttonVariants({ variant: "outline" }), "group/all")}>
All 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 grid-cols-2 gap-3 md:mt-10 md:gap-4 lg:grid-cols-4">
{products.map((product) => {
const stock = STOCK[product.stock]
return (
<li key={product.id} className="flex">
{/* The ring lands on the frame when the link inside is
focused: drawn on the link's own box it would be cut off
by the frame's rounded clip. */}
<Card
variant="interactive"
className="relative w-full has-[a:focus-visible]:focus-outline"
>
{/* The sheet holds the picture and nothing else, so it
never grows: a neighbour's two-line name is taken up by
the strip below, which keeps the prices on one line. */}
<CardContent className="relative flex-none p-0">
<ProductArt name={product.name} category={product.category} className="rounded-none" />
{stock ? (
<StatusBadge
status={product.stock}
variant={stock.variant}
label={stock.label}
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">
{/* The stretched link: its box covers the card, so the
whole card is one target and one tab stop. */}
<Link href={product.href} className="outline-none after:absolute after:inset-0">
{product.name}
</Link>
</h3>
<div className="mt-auto flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5 pt-1">
<Price amount={product.priceCents / 100} size="sm" />
<span className="text-xs text-muted-foreground tabular-nums">
{formatNumber(product.sold)} sold
</span>
</div>
</CardFooter>
</Card>
</li>
)
})}
</ul>
</div>
</section>
)
}
export default ProductCards01/**
* What `product-cards-01` reads: the store's bestsellers, off the storefront's
* one reading of the catalogue (`storeShelf`) — the products still sold, what
* each sold across the paid orders of the last 90 days by the E-commerce
* dashboard's rule for a sale, and what its stock reads as — so a bestseller
* here is one on every other storefront band and on the dashboard's catalogue.
* Swap the repositories under the storefront for a real store and the cards
* follow.
*
* Selectors only, reading the store inside functions and run on the server.
* An island takes these rows as props; it never imports this module.
*/
import { SHELF_DAYS, storeShelf, type StockState } from "@/lib/dashboards/ecommerce/storefront"
/** How far back a product's sales are counted: the shelf's own window. */
export const SALES_DAYS = SHELF_DAYS
/** A product as a card shows it. */
export type ProductCardRow = {
id: string
name: string
category: string
priceCents: number
/** Units sold in the last `SALES_DAYS`, from the orders that were paid for. */
sold: number
/** "low" at or under the reorder point, "out" at none. */
stock: StockState
/** Where the card leads: the product's page in your store. */
href: string
}
/**
* The store's bestsellers: the `count` products on the shelf that sold the
* most units in the last `days`, the most first. The shelf's own order, so a
* tie goes to the name that sorts first and the order is the same on every
* render.
*/
export function bestsellers(count = 4, days = SALES_DAYS): ProductCardRow[] {
return storeShelf(days)
.slice(0, count)
.map(({ id, name, category, priceCents, sold, stockState, href }) => ({
id,
name,
category,
priceCents,
sold,
stock: stockState,
href,
}))
}npx shadcn@latest add @vibra/product-cards-01Quick add with quantity steppers
product-cards-02Four bestselling products under $40, each added to the cart in place: Add to cart becomes a stepper on the brand tint, and one line beside the heading sums the cart.
import * as React from "react"
import Link from "next/link"
import { formatCurrency } from "@/lib/format"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { CartLine, QuickAdd, QuickAddCart } from "./product-cards-02.client"
import { CEILING_CENTS, SHELF_DAYS, addOns } from "./product-cards-02.data"
/**
* Quick add: the four bestselling products under a price ceiling, each with
* a control that adds it to the cart in place and becomes a stepper once it
* is there, and the cart summed in one line beside the heading. The cards
* are the kit's interactive Card, one stretched link each; the control sits
* above the link, so a press on it never follows the link.
*
* A server component: it reads its rows through `./product-cards-02.data`,
* draws every picture and price here, and hands the island only the ids,
* names, prices and stock the cart needs.
*/
export function ProductCards02() {
const titleId = React.useId()
const products = addOns(4)
const ceiling = formatCurrency(CEILING_CENTS / 100, "USD", { maximumFractionDigits: 0 })
return (
<section
data-section="product-cards-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">
<QuickAddCart items={products.map(({ id, name, priceCents, stock }) => ({ id, name, priceCents, stock }))}>
<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 md:text-3xl">
Add-ons under {ceiling}
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
The small things bought most in the last {SHELF_DAYS} days. Add them here and keep browsing.
</p>
</div>
<CartLine cartHref="/cart" />
</div>
<ul className="mt-8 grid grid-cols-2 gap-3 md:mt-10 md:gap-4 lg:grid-cols-4">
{products.map((product) => (
<li key={product.id} className="flex">
<Card
variant="interactive"
className="relative w-full has-[a:focus-visible]:focus-outline"
>
<CardContent className="flex-none p-0">
<ProductArt name={product.name} category={product.category} className="rounded-none" />
</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" />
{/* Above the stretched link, so the control is pressed
rather than the card followed. */}
<div className="relative z-10 pt-2">
<QuickAdd id={product.id} />
</div>
</CardFooter>
</Card>
</li>
))}
</ul>
</QuickAddCart>
</div>
</section>
)
}
export default ProductCards02"use client"
import * as React from "react"
import Link from "next/link"
import { MinusIcon, PlusIcon, ShoppingBagIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"
import { Button, buttonVariants } from "@/components/ui/button"
/** What the cart knows of a product: its name for the controls, its price for the total, its stock for the cap. */
export type QuickAddItem = { id: string; name: string; priceCents: number; stock: number }
type Cart = {
items: ReadonlyMap<string, QuickAddItem>
quantities: Readonly<Record<string, number>>
setQuantity: (id: string, quantity: number) => void
}
const CartContext = React.createContext<Cart | null>(null)
function useCart(): Cart {
const cart = React.useContext(CartContext)
if (!cart) throw new Error("QuickAdd and CartLine sit inside QuickAddCart")
return cart
}
/**
* The band's cart: how many of each product it holds. It wraps the cards the
* server drew, so only the steppers and the cart line are client code — the
* pictures and the prices stay server-rendered.
*/
export function QuickAddCart({ items, children }: { items: QuickAddItem[]; children: React.ReactNode }) {
const [quantities, setQuantities] = React.useState<Record<string, number>>({})
const byId = React.useMemo(() => new Map(items.map((item) => [item.id, item])), [items])
const setQuantity = React.useCallback((id: string, quantity: number) => {
setQuantities((current) => ({ ...current, [id]: quantity }))
}, [])
const cart = React.useMemo(() => ({ items: byId, quantities, setQuantity }), [byId, quantities, setQuantity])
return <CartContext.Provider value={cart}>{children}</CartContext.Provider>
}
/**
* The cart in one line: how many items and what they come to. Always mounted
* and polite, so every press of a stepper is read out once it lands.
*/
export function CartLine({ cartHref }: { cartHref: string }) {
const { items, quantities } = useCart()
let count = 0
let cents = 0
for (const item of items.values()) {
const quantity = quantities[item.id] ?? 0
count += quantity
cents += quantity * item.priceCents
}
return (
<div className="flex w-full items-center gap-3 rounded-lg bg-surface py-1.5 pr-1.5 pl-3 ring-1 ring-border sm:w-auto">
<ShoppingBagIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<p role="status" aria-live="polite" className="min-w-0 flex-1 text-sm text-foreground tabular-nums">
{count === 0
? "Your cart is empty"
: `${count} ${count === 1 ? "item" : "items"} in your cart, ${formatCurrency(cents / 100, "USD")}`}
</p>
<Link href={cartHref} className={cn(buttonVariants({ variant: "outline", size: "sm" }), "shrink-0")}>
View cart
</Link>
</div>
)
}
/**
* One product's control: "Add to cart" until it is in the cart, then a
* stepper on the brand tint — the product is chosen — with the count between
* two named buttons. Focus follows the control as it changes shape: adding
* lands on "Add one more", and taking the last one out lands back on "Add to
* cart", so a keyboard never falls to the top of the page. The stepper stops
* at the stock on hand and says why; that button stays focusable, so it can.
*/
export function QuickAdd({ id }: { id: string }) {
const { items, quantities, setQuantity } = useCart()
const item = items.get(id)
const quantity = quantities[id] ?? 0
const inCart = quantity > 0
const addRef = React.useRef<HTMLButtonElement>(null)
const moreRef = React.useRef<HTMLButtonElement>(null)
const nextFocus = React.useRef<"add" | "more" | null>(null)
const limitId = React.useId()
React.useEffect(() => {
const target = nextFocus.current === "more" ? moreRef.current : nextFocus.current === "add" ? addRef.current : null
nextFocus.current = null
target?.focus()
}, [inCart])
if (!item) return null
const full = quantity >= item.stock
if (!inCart) {
return (
<Button
ref={addRef}
variant="outline"
className="w-full"
onClick={() => {
nextFocus.current = "more"
setQuantity(id, 1)
}}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Add to cart<span className="sr-only">, {item.name}</span>
</Button>
)
}
return (
<div className="flex flex-col gap-1">
<div
role="group"
aria-label={`${item.name} in your cart`}
className="flex h-8 items-center justify-between rounded-lg bg-brand-muted px-0.5"
>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove one ${item.name}`}
className="hover:bg-background/60"
onClick={() => {
if (quantity === 1) nextFocus.current = "add"
setQuantity(id, quantity - 1)
}}
>
<MinusIcon />
</Button>
<span className="text-sm font-medium text-foreground tabular-nums">
{quantity}{" "}
<span className="sr-only">in your cart</span>
</span>
<Button
ref={moreRef}
variant="ghost"
size="icon-sm"
aria-label={`Add one more ${item.name}`}
aria-describedby={full ? limitId : undefined}
disabled={full}
focusableWhenDisabled
className="hover:bg-background/60"
onClick={() => setQuantity(id, Math.min(item.stock, quantity + 1))}
>
<PlusIcon />
</Button>
</div>
{full ? (
<p id={limitId} className="text-xs text-muted-foreground">
All {item.stock} in stock are in your cart
</p>
) : null}
</div>
)
}/**
* What `product-cards-02` reads: the small things a shopper adds to an order
* without stopping to think — the bestselling products at or under a price
* ceiling that are still in stock. The shelf comes from the storefront's one
* reading of the catalogue (`storeShelf`), so "bestselling" means units sold
* across the paid orders of the last 90 days, as it does on every band.
*
* 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 "bought most" is counted over, in days: the shelf's own. */
export { SHELF_DAYS }
/** The most a quick add costs, in cents. */
export const CEILING_CENTS = 4_000
/** A product the shopper can add without leaving the band. */
export type AddOnRow = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "stock" | "sold" | "href">
/**
* The `count` bestselling products at or under the ceiling that are still in
* stock, the most sold first. Stock is carried because it caps the stepper:
* nobody can put more in the cart than the shelf holds.
*/
export function addOns(count = 4): AddOnRow[] {
return storeShelf()
.filter((product) => product.priceCents <= CEILING_CENTS && product.stock > 0)
.slice(0, count)
.map(({ id, name, category, priceCents, stock, sold, href }) => ({ id, name, category, priceCents, stock, sold, href }))
}npx shadcn@latest add @vibra/product-cards-02Size picker with stock by size
product-cards-03The four bestselling clothes and shoes, each with its sizes on the card, the stock of the size chosen, and a Notify me toggle for a size that has sold out.
import * as React from "react"
import Link from "next/link"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { SizePicker } from "./product-cards-03.client"
import { sizedApparel } from "./product-cards-03.data"
/**
* Sized to order: the four bestselling clothes and shoes, each with its sizes
* on the card and the stock of the one chosen said under them. A size with
* none left is struck through and still choosable, and choosing it turns the
* button into a Notify me toggle. The cards are the kit's interactive Card,
* one stretched link each, with the picker raised above the link so a press
* between two sizes never follows it.
*
* A server component: it reads its rows through `./product-cards-03.data`,
* draws the pictures and prices here, and hands the island each product's
* name and sizes.
*/
export function ProductCards03() {
const titleId = React.useId()
const products = sizedApparel(4)
return (
<section
data-section="product-cards-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-xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Pick your size
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Our bestselling clothes and shoes, with the stock of every size. Sold out in yours? Ask to hear when it is back.
</p>
</div>
<ul className="mt-8 grid gap-3 sm:grid-cols-2 md:mt-10 md:gap-4 lg:grid-cols-4">
{products.map((product) => (
<li key={product.id} className="flex">
<Card
variant="interactive"
className="relative w-full has-[a:focus-visible]:focus-outline"
>
{/* Wide on a phone, so four cards with their sizes stay short;
square from sm. */}
<CardContent className="flex-none p-0">
<ProductArt
name={product.name}
category={product.category}
className="aspect-[16/10] rounded-none sm:aspect-square"
/>
</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" />
<div className="relative z-10 mt-auto pt-3">
<SizePicker product={{ name: product.name, sizeLabel: product.sizeLabel, sizes: product.sizes }} />
</div>
</CardFooter>
</Card>
</li>
))}
</ul>
</div>
</section>
)
}
export default ProductCards03"use client"
import * as React from "react"
import { BellIcon, BellRingIcon, ShoppingBagIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import type { SizedProduct } from "./product-cards-03.data"
/** How few units of a size are worth saying out loud. */
const FEW = 5
/**
* One product's sizes and what to do with the one chosen. The sizes are a
* single-choice toggle group — the arrow keys move between them — and the
* chosen one wears the brand tint and the 500 weight; a size with none left
* stays choosable, struck through and read as sold out, because choosing it
* is how a shopper asks to hear when it is back. The line under the sizes is
* an always-mounted status: the stock of the size chosen, then what the last
* press did. The button follows the size: Add to bag while there is stock,
* Notify me — a toggle — when there is none.
*/
export function SizePicker({ product }: { product: Pick<SizedProduct, "name" | "sizeLabel" | "sizes"> }) {
const [size, setSize] = React.useState<string | null>(null)
const [message, setMessage] = React.useState<string | null>(null)
const [watching, setWatching] = React.useState<ReadonlySet<string>>(new Set())
const groupRef = React.useRef<HTMLDivElement>(null)
const labelId = React.useId()
const chosen = product.sizes.find((entry) => entry.size === size)
const soldOut = chosen?.units === 0
const stockLine = !chosen
? "Choose a size"
: chosen.units === 0
? `${chosen.size} is sold out`
: chosen.units <= FEW
? `Only ${chosen.units} left in ${chosen.size}`
: `${chosen.size} is in stock`
function choose(next: string) {
setSize(next)
setMessage(null)
}
function add() {
if (!chosen) {
setMessage("Choose a size first")
groupRef.current?.querySelector<HTMLButtonElement>("button")?.focus()
return
}
setMessage(`Added the ${product.name} in ${chosen.size} to your bag`)
}
function toggleWatch() {
if (!chosen) return
const next = new Set(watching)
const on = !next.has(chosen.size)
if (on) next.add(chosen.size)
else next.delete(chosen.size)
setWatching(next)
setMessage(on ? `We'll email you when ${chosen.size} is back` : `You won't be emailed about ${chosen.size}`)
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1.5">
<span id={labelId} className="text-xs text-muted-foreground">
{product.sizeLabel}
</span>
<ToggleGroup
ref={groupRef}
aria-labelledby={labelId}
variant="outline"
size="sm"
spacing={1}
value={size ? [size] : []}
// Pressing the chosen size again hands back an empty group; a size
// once chosen stays chosen until another is.
onValueChange={(next) => {
const [picked] = next
if (picked) choose(picked)
}}
className="flex-wrap"
>
{product.sizes.map((entry) => (
<ToggleGroupItem
key={entry.size}
value={entry.size}
className="min-w-9 border-border text-foreground tabular-nums aria-pressed:border-transparent aria-pressed:bg-brand-muted aria-pressed:font-medium aria-pressed:text-foreground data-[sold-out]:text-muted-foreground data-[sold-out]:line-through"
data-sold-out={entry.units === 0 || undefined}
>
{entry.size}
{entry.units === 0 ? <span className="sr-only">, sold out</span> : null}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{message ?? stockLine}
</p>
{soldOut && chosen ? (
<Button
variant="outline"
aria-pressed={watching.has(chosen.size)}
className="w-full aria-pressed:border-transparent aria-pressed:bg-brand-muted"
onClick={toggleWatch}
>
{watching.has(chosen.size) ? <BellRingIcon data-icon="inline-start" /> : <BellIcon data-icon="inline-start" />}
Notify me when {chosen.size} is back
</Button>
) : (
<Button variant="outline" className="w-full" onClick={add}>
<ShoppingBagIcon data-icon="inline-start" />
Add to bag<span className="sr-only">, {product.name}</span>
</Button>
)}
</div>
)
}/**
* What `product-cards-03` reads: the bestselling clothes and shoes that come
* in sizes, each with its stock split across those sizes. The catalogue keeps
* one stock count per product, so the split is the storefront's rule over
* that count (`sizesOf`) — the usual size curve, shared out in whole units
* that add up to it exactly — and a size the curve leaves at none is sold
* out. Replace it with your own variants' stock and the cards follow, with
* every other band that sells a size.
*
* 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 {
sizeRunOf,
sizesOf,
storeShelf,
type ShelfProduct,
type SizeStock,
} from "@/lib/dashboards/ecommerce/storefront"
/** A product as the size picker shows it. */
export type SizedProduct = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "href"> & {
/** What the picker's label calls a size: "Size", or "US size" for shoes. */
sizeLabel: string
sizes: SizeStock[]
}
/** The `count` bestselling products that come in sizes, the most sold first. */
export function sizedApparel(count = 4): SizedProduct[] {
return storeShelf()
.flatMap((product) => {
const run = sizeRunOf(product)
if (!run) return []
const { id, name, category, priceCents, href } = product
return [{ id, name, category, priceCents, href, sizeLabel: run.label, sizes: sizesOf(product, run) }]
})
.slice(0, count)
}npx shadcn@latest add @vibra/product-cards-03Top rated, ranked
product-cards-04The three products buyers scored highest, as a ranked list of cards: rank and department, name, stars with the score said in words, and price.
import * as React from "react"
import Link from "next/link"
import { formatNumber } from "@/lib/format"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { Rating } from "@/components/ui/rating"
import { MIN_RATINGS, topRated } from "./product-cards-04.data"
/** A mean as a score: one decimal, always — 5.0, not 5. */
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/**
* Top rated: the three products buyers scored highest, as a ranked list. Each
* card is its picture, rank and department, name, stars and price, and one
* stretched link. The stars are a picture of the score rounded to a whole
* star, so they are hidden from assistive technology; the score and how many
* ratings it is of are said in full beside them. Three across from `sm`; on a
* phone each card lies on its side, picture first, so the three stay short.
*
* A server component: its rows come from `./product-cards-04.data`, which
* derives every rating from an order line that reached a buyer.
*/
export function ProductCards04() {
const titleId = React.useId()
const products = topRated(3)
return (
<section
data-section="product-cards-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">
Top rated
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Scored by the buyers who received them. A product is ranked once it has {MIN_RATINGS} ratings.
</p>
</div>
<ol className="mt-8 grid gap-3 sm:grid-cols-3 md:mt-10 md:gap-4">
{products.map((product, index) => (
<li key={product.id} className="flex">
<Card
variant="interactive"
className="relative w-full max-sm:flex-row has-[a:focus-visible]:focus-outline"
>
{/* A 4:3 tile from sm. */}
<CardContent className="flex-none p-0 max-sm:w-28">
<ProductArt
name={product.name}
category={product.category}
className="rounded-none max-sm:h-full sm:aspect-[4/3]"
/>
</CardContent>
<CardFooter className="mt-0 flex-1 flex-col items-stretch gap-1 py-3 sm:pt-2">
<p className="text-xs text-muted-foreground">
<span className="tabular-nums">No. {index + 1}</span> · {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>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<Rating value={Math.round(product.average)} size="sm" aria-hidden="true" />
<span aria-hidden="true" className="text-sm font-medium text-foreground tabular-nums">
{score(product.average)}
</span>
<span aria-hidden="true" className="text-xs text-muted-foreground tabular-nums">
{product.count} ratings
</span>
<span className="sr-only">
Rated {score(product.average)} out of 5 from {product.count} ratings
</span>
</div>
<Price amount={product.priceCents / 100} size="sm" className="mt-auto pt-1" />
</CardFooter>
</Card>
</li>
))}
</ol>
</div>
</section>
)
}
export default ProductCards04/**
* What `product-cards-04` reads: the products buyers rated highest. A rating
* is the stars of a review a buyer wrote (`ratingsOf`, in
* `storefront-ratings`, reading `db.reviews`): each belongs to one real
* purchase that reached them, and a product people send back scores lower.
* Every band that shows a score reads the same reviews, so a product ranked
* here carries the same score on its own page. Swap `db.reviews` for your
* reviews table and the cards follow.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
import { ratingsOf } from "@/lib/dashboards/ecommerce/storefront-ratings"
/** The fewest ratings a product needs before it is ranked. */
export const MIN_RATINGS = 5
/** A product as the top-rated cards show it. */
export type RatedProduct = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "href"> & {
/** The mean of its stars, unrounded. */
average: number
/** How many ratings the mean is of. */
count: number
}
/**
* The `count` products still sold with the highest average, among those with
* at least `MIN_RATINGS` ratings. A tie goes to the one with more ratings,
* then to the name.
*/
export function topRated(count = 3): RatedProduct[] {
const ratings = ratingsOf()
return storeShelf()
.flatMap((product) => {
const stars = ratings.get(product.id) ?? []
if (stars.length < MIN_RATINGS) return []
const { id, name, category, priceCents, href } = product
return [
{
id,
name,
category,
priceCents,
href,
average: stars.reduce((sum, star) => sum + star, 0) / stars.length,
count: stars.length,
},
]
})
.sort((a, b) => b.average - a.average || b.count - a.count || a.name.localeCompare(b.name))
.slice(0, count)
}npx shadcn@latest add @vibra/product-cards-04Clearance with its terms and deadline
product-cards-05One framed card that is the whole offer: how much off and until which day on the frame, and four marked-down products on the sheet, each with its sale price over the list price and what it saves.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, ClockIcon } 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 { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { StatusBadge } from "@/components/ui/status-badge"
import { CLEARANCE, clearance, clearanceEnds } from "./product-cards-05.data"
// Dates are read in UTC: the store's day, not the reader's.
const WEEKDAY = new Intl.DateTimeFormat("en-US", { weekday: "long", timeZone: "UTC" })
const DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
/** A saving in whole dollars: the sale price is rounded down to one. */
const dollars = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/**
* Clearance: one framed card that is the whole offer. On the frame, the terms
* — how much off, until when, and how many days that leaves — and a way to
* the rest of the sale; on the inset sheet, the four products marked down,
* divided by rules rather than boxed, each its picture, name, sale price
* over the list price it replaces, and what that saves. Every product is one
* stretched link across its cell. On a phone the terms sit above the sheet.
*
* A server component: the products, the prices and the last day all come
* from `./product-cards-05.data`.
*/
export function ProductCards05() {
const titleId = React.useId()
const products = clearance(4)
const ends = clearanceEnds()
const off = Math.round(CLEARANCE.off * 100)
return (
<section
data-section="product-cards-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">
<Card className="gap-2 p-2 lg:flex-row lg:gap-4">
<div className="flex flex-col items-start gap-3 p-3 md:p-5 lg:w-80 lg:shrink-0 lg:py-6">
<p className="type-eyebrow">Clearance</p>
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
{off}% off or more, until {WEEKDAY.format(ends)}
</h2>
<p className="text-sm text-pretty text-muted-foreground">
Marked down from their list prices until the end of {DAY.format(ends)}.
</p>
<StatusBadge status="ends" variant="neutral" className="mt-1">
<ClockIcon aria-hidden="true" className="size-3" />
<span className="tabular-nums">{CLEARANCE.days} days left</span>
</StatusBadge>
<Link
href="/clearance"
className={cn(buttonVariants({ variant: "outline" }), "group/all mt-auto max-lg:mt-2")}
>
See the whole clearance
<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>
<CardContent className="p-0">
{/* The gaps are the sheet's rules: one pixel of the border colour
between cells, so the four read as one sheet, not four boxes. */}
<ul className="grid h-full gap-px bg-border sm:grid-cols-2">
{products.map((product) => (
<li
key={product.id}
className="relative flex items-center gap-4 bg-card p-3 transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted/40 has-[a:focus-visible]:focus-outline-inset md:p-4"
>
<div className="relative w-24 shrink-0 md:w-28">
<ProductArt name={product.name} category={product.category} className="rounded-md" />
</div>
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
<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.saleCents / 100} compareAt={product.listCents / 100} size="sm" />
<StatusBadge status="saving" variant="success" className="mt-0.5 tabular-nums">
Save {dollars(product.savingCents)}
</StatusBadge>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</section>
)
}
export default ProductCards05/**
* What `product-cards-05` reads: a clearance of the stock that moves slowest.
* "Slowest" is a rule over the shelf — weeks of cover, the units on hand over
* the units a week sold across the last 90 days — and the four with the most
* cover go on sale. The discount and how long it runs are the promotion's own
* terms, set here; every price it prints is worked out from the catalogue's
* list price, never typed in.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { SHELF_DAYS, storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
import { daysAhead } from "@/lib/sample-data"
/** The promotion's terms: the least it takes off, and how many days it runs from today. */
export const CLEARANCE = { off: 0.3, days: 3 } as const
/**
* A list price with the discount taken off, rounded down to a whole dollar so
* the saving is never less than promised — and kept on a .99 ending when the
* list price has one, the way the catalogue prices that department.
*/
export function salePriceCents(listCents: number, off: number = CLEARANCE.off): number {
const dollars = Math.floor((listCents * (1 - off)) / 100) * 100
return listCents % 100 === 99 ? dollars - 1 : dollars
}
/** How many weeks the stock on hand lasts at the pace it sold over the shelf's window; forever when it sold none. */
export function weeksOfCover(product: Pick<ShelfProduct, "stock" | "sold">, days: number = SHELF_DAYS): number {
const perWeek = product.sold / (days / 7)
return perWeek === 0 ? Infinity : product.stock / perWeek
}
/** A product on clearance: its list price, its sale price and what the difference saves. */
export type ClearanceRow = Pick<ShelfProduct, "id" | "name" | "category" | "href"> & {
listCents: number
saleCents: number
savingCents: number
}
/** The `count` products in stock with the most weeks of cover, the slowest first; a tie goes to the name. */
export function clearance(count = 4): ClearanceRow[] {
return storeShelf()
.filter((product) => product.stock > 0)
.sort((a, b) => weeksOfCover(b) - weeksOfCover(a) || a.name.localeCompare(b.name))
.slice(0, count)
.map(({ id, name, category, href, priceCents }) => {
const saleCents = salePriceCents(priceCents)
return { id, name, category, href, listCents: priceCents, saleCents, savingCents: priceCents - saleCents }
})
}
/** When the clearance ends: its last day, counted from `REFERENCE_DATE`. */
export function clearanceEnds(): Date {
return daysAhead(CLEARANCE.days)
}npx shadcn@latest add @vibra/product-cards-05Spotlight on the product selling faster
product-cards-06One wide feature card for the product whose sales rose most: its picture, name and price, the rise as a figure and a pill over twelve weeks of bars, its stock and supplier, and a quantity, Add to cart and Save.
import * as React from "react"
import Link from "next/link"
import { TrendingUpIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { Card, CardContent } from "@/components/ui/card"
import { MetricDelta } from "@/components/ui/metric-delta"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { Sparkline } from "@/components/ui/sparkline"
import { SpotlightActions } from "./product-cards-06.client"
import { RISE_WEEKS, spotlight } from "./product-cards-06.data"
const STOCK_WORDS = { in: "In stock", low: "Low stock", out: "Sold out" } as const
/**
* A spotlight on one product: the one whose sales rose most over the last
* six weeks. One wide framed card — the picture on its inset sheet, and on
* the frame the name, the price, the rise as a figure and a pill over twelve
* weeks of bars, the stock and the supplier, then the buying controls. The
* picture sits beside the details from `lg` and above them on a phone.
*
* A server component: the product and its weeks come from
* `./product-cards-06.data`; the island holds only the quantity, the Save
* toggle and the status line.
*/
export function ProductCards06() {
const titleId = React.useId()
const captionId = React.useId()
const { product, weeks, recent, before } = spotlight()
// A running total rises faster where the weeks sold more, so the rise is the
// bend in the line rather than a few bars over empty weeks.
const toDate = weeks.map((_, index) => weeks.slice(0, index + 1).reduce((sum, units) => sum + units, 0))
return (
<section
data-section="product-cards-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 inline-flex items-center gap-1.5">
<TrendingUpIcon aria-hidden="true" className="size-3.5" />
Spotlight
</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Selling faster
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
The product whose sales rose most over the last {RISE_WEEKS} weeks, against the {RISE_WEEKS} before.
</p>
</div>
<Card className="mt-8 gap-2 p-2 md:mt-10 lg:flex-row">
<CardContent className="flex-none p-0 lg:w-[46%]">
{/* A 4:3 tile over the details; from lg the tile fills the sheet
beside them. */}
<ProductArt
name={product.name}
category={product.category}
className="aspect-[4/3] rounded-none lg:aspect-auto lg:h-full lg:min-h-96"
/>
</CardContent>
<div className="flex min-w-0 flex-1 flex-col gap-6 p-3 md:p-6 lg:p-8">
<div className="flex flex-col gap-2">
<p className="text-xs text-muted-foreground">
{product.category} · <span className="font-mono">{product.sku}</span>
</p>
<h3 className="type-display text-2xl text-balance md:text-3xl">
<Link href={product.href} className="focus-ring rounded-sm hover:underline">
{product.name}
</Link>
</h3>
<Price amount={product.priceCents / 100} size="lg" />
</div>
<figure aria-labelledby={captionId} className="flex flex-col gap-3 border-y border-border py-5">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<p className="text-sm text-muted-foreground">
<span className="type-numeral text-2xl text-foreground tabular-nums">{formatNumber(recent)}</span> sold in
the last {RISE_WEEKS} weeks
</p>
<MetricDelta value={recent - before} format="number" variant="pill" />
<p className="text-xs text-muted-foreground tabular-nums">against {formatNumber(before)} before</p>
</div>
<Sparkline data={toDate} type="area" height={64} />
<figcaption id={captionId} className="text-xs text-muted-foreground">
Units sold to date, week by week over the last {weeks.length} weeks
</figcaption>
</figure>
<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">Availability</dt>
<dd className="font-medium text-foreground tabular-nums">
{STOCK_WORDS[product.stockState]}, {formatNumber(product.stock)} left
</dd>
</div>
<div className="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="mt-auto">
<SpotlightActions name={product.name} stock={product.stock} />
</div>
</div>
</Card>
</div>
</section>
)
}
export default ProductCards06"use client"
import * as React from "react"
import { HeartIcon, ShoppingBagIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { NumberInput } from "@/components/ui/number-input"
/**
* The spotlight's buying controls: how many, Add to cart — the band's one
* filled button — and Save, a toggle that stays pressed while the product is
* saved. The quantity is the kit's NumberInput, bounded by the stock on hand
* and stepped with the arrow keys. What the last press did is said in one
* always-mounted status line under the buttons.
*/
export function SpotlightActions({ name, stock }: { name: string; stock: number }) {
const [quantity, setQuantity] = React.useState<number | null>(1)
const [saved, setSaved] = React.useState(false)
const [message, setMessage] = React.useState("")
const quantityId = React.useId()
function add() {
const count = Math.min(stock, Math.max(1, Math.round(quantity ?? 1)))
setQuantity(count)
setMessage(count === 1 ? `Added the ${name} to your cart` : `Added ${count} of the ${name} to your cart`)
}
function toggleSaved() {
setSaved(!saved)
setMessage(saved ? `Removed the ${name} from your saved items` : `Saved the ${name} for later`)
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-end gap-2">
<div className="flex w-24 flex-col gap-1.5">
<label htmlFor={quantityId} className="text-xs text-muted-foreground">
Quantity
</label>
<NumberInput
id={quantityId}
value={quantity}
onValueChange={setQuantity}
min={1}
max={stock}
className="h-9"
/>
</div>
<Button size="lg" className="flex-1 sm:flex-none sm:px-5" onClick={add} disabled={stock === 0}>
<ShoppingBagIcon data-icon="inline-start" />
Add to cart
</Button>
<Button
size="lg"
variant="outline"
aria-pressed={saved}
className="aria-pressed:border-transparent aria-pressed:bg-brand-muted"
onClick={toggleSaved}
>
<HeartIcon data-icon="inline-start" className={saved ? "fill-current" : undefined} />
Save
</Button>
</div>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{message}
</p>
</div>
)
}/**
* What `product-cards-06` reads: the product selling faster than it was. A
* rise is a rule over the orders — the units a product sold over the last
* six weeks against the six before — and the spotlight is the product still
* sold whose units rose the most. Its twelve weeks are counted one by one by
* the dashboard's rule for a sale, so the two figures on the card are the
* sums of the bars drawn under them.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { salesBetween } from "@/lib/dashboards/ecommerce/vocabulary"
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
import { REFERENCE_DATE, db } from "@/lib/sample-data"
/** How many weeks each half of the comparison covers. */
export const RISE_WEEKS = 6
const WEEK_MS = 7 * 86_400_000
/** The spotlit product, its units week by week and the two halves they add up to. */
export type Spotlight = {
product: ShelfProduct
/** Units a week over the last `2 × RISE_WEEKS` weeks, oldest first, the last ending at `REFERENCE_DATE`. */
weeks: number[]
/** Units over the last `RISE_WEEKS` weeks. */
recent: number
/** Units over the `RISE_WEEKS` weeks before those. */
before: number
}
const sum = (values: number[]) => values.reduce((total, value) => total + value, 0)
/** Every product's units a week over the two halves, oldest week first. */
function weekly(): Map<string, number[]> {
const span = RISE_WEEKS * 2
const end = REFERENCE_DATE.getTime()
const orders = db.orders.all()
const weeks = new Map<string, number[]>()
for (let index = 0; index < span; index++) {
const from = end - (span - index) * WEEK_MS
for (const [id, sale] of salesBetween(orders, from, from + WEEK_MS)) {
const row = weeks.get(id) ?? Array<number>(span).fill(0)
row[index] = sale.units
weeks.set(id, row)
}
}
return weeks
}
/**
* The product still sold whose units rose the most, the last `RISE_WEEKS`
* weeks against the ones before. A tie goes to the one that sold more lately,
* then to the name.
*/
export function spotlight(): Spotlight {
const weeks = weekly()
const [first] = storeShelf()
.map((product) => {
const series = weeks.get(product.id) ?? Array<number>(RISE_WEEKS * 2).fill(0)
return { product, weeks: series, before: sum(series.slice(0, RISE_WEEKS)), recent: sum(series.slice(RISE_WEEKS)) }
})
.sort(
(a, b) =>
b.recent - b.before - (a.recent - a.before) || b.recent - a.recent || a.product.name.localeCompare(b.product.name)
)
return first
}npx shadcn@latest add @vibra/product-cards-06Horizontal cards with specs
product-cards-07One department's six bestsellers as horizontal cards: the picture at the left, and beside it the name and price over the SKU, the stock, the supplier and the units sold.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { StatusBadge } from "@/components/ui/status-badge"
import { DEPARTMENT, SHELF_DAYS, departmentSpecs } from "./product-cards-07.data"
/**
* Spec cards: one department's bestsellers as horizontal cards, the picture
* on the inset sheet at the left and, on the frame beside it, the name and
* price over the four specs a purchase order asks for — SKU, stock, where it
* ships from and how many sold — as a description list, so each figure is
* heard with what it is. A product at or under its reorder point says so
* beside its count. Two across from `lg`; one card to a row below it, still
* on its side. Each card is one stretched link.
*
* A server component: its rows come from `./product-cards-07.data`.
*/
export function ProductCards07() {
const titleId = React.useId()
const products = departmentSpecs(6)
return (
<section
data-section="product-cards-07"
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 md:text-3xl">
For the office
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
The bestselling office products, with the SKU, stock and supplier a purchase order asks for.
</p>
</div>
<Link
href={`/products?category=${DEPARTMENT.toLowerCase()}`}
className={cn(buttonVariants({ variant: "outline" }), "group/all")}
>
All of {DEPARTMENT}
<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 md:mt-10 md:gap-4 lg:grid-cols-2">
{products.map((product) => (
<li key={product.id} 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-40">
{/* The tile fills the sheet's height. */}
<ProductArt
name={product.name}
category={product.category}
className="h-full rounded-none"
/>
</CardContent>
<div className="flex min-w-0 flex-1 flex-col gap-3 p-3 sm:p-4">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
<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" />
</div>
<dl className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs sm:gap-x-4">
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">SKU</dt>
<dd className="font-mono text-foreground">{product.sku}</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">In stock</dt>
<dd className="flex items-center gap-1.5 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-muted-foreground">Ships from</dt>
<dd className="text-foreground">{product.supplier}</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Sold, {SHELF_DAYS} days</dt>
<dd className="text-foreground tabular-nums">{formatNumber(product.sold)}</dd>
</div>
</dl>
</div>
</Card>
</li>
))}
</ul>
</div>
</section>
)
}
export default ProductCards07/**
* What `product-cards-07` reads: one department's bestsellers with the specs
* a buyer orders by — the SKU, the stock on hand, where it ships from and how
* many sold over the last 90 days — every one straight off the catalogue row
* and the storefront shelf. Change `DEPARTMENT` to spec another department.
*
* Selectors only, reading the store inside functions and run on the server.
*/
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 cards spec. */
export const DEPARTMENT = "Office"
/** A product with the specs its card lists. */
export type SpecRow = Pick<
ShelfProduct,
"id" | "sku" | "name" | "category" | "supplier" | "priceCents" | "stock" | "stockState" | "sold" | "href"
>
/** The `count` bestselling products still sold in `DEPARTMENT`, the most sold first. */
export function departmentSpecs(count = 6): SpecRow[] {
return storeShelf()
.filter((product) => product.category === DEPARTMENT)
.slice(0, count)
}npx shadcn@latest add @vibra/product-cards-07Carousel row with named controls
product-cards-08Every product in the department that sold the most, on a carousel two to a view on a phone and four from lg, with previous and next buttons named and set beside the heading.
import * as React from "react"
import Link from "next/link"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Carousel, CarouselContent, CarouselItem } from "@/components/ui/carousel"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { StatusBadge } from "@/components/ui/status-badge"
import { CarouselButtons } from "./product-cards-08.client"
import { popularDepartment } from "./product-cards-08.data"
/**
* A carousel row: every product in the department that sold the most, two to
* a view on a phone, three on a tablet and four from `lg`. It is the kit's
* Carousel — a region described as a carousel, each slide a group named by
* its place ("3 of 10"), the arrow keys moving the track — with its previous
* and next buttons named and set in the header beside the heading, where
* they cover no card. A product at or under its reorder point says so on its
* picture. Each card is one stretched link.
*
* A server component: the department and its products come from
* `./product-cards-08.data`; the island is only the two buttons, which need
* the carousel's own state.
*/
export function ProductCards08() {
const titleId = React.useId()
const { department, products } = popularDepartment()
return (
<section
data-section="product-cards-08"
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">
<Carousel opts={{ align: "start", slidesToScroll: "auto" }} aria-label={`${department} products`}>
<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 md:text-3xl">
Popular in {department}
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
The department that sold the most this quarter, from its bestseller down.
</p>
</div>
<CarouselButtons />
</div>
<CarouselContent className="mt-8 -ms-3 md:mt-10 md:-ms-4">
{products.map((product, index) => (
<CarouselItem
key={product.id}
aria-label={`${index + 1} of ${products.length}`}
className="flex basis-[46%] ps-3 sm:basis-1/2 md:basis-1/3 md:ps-4 lg:basis-1/4"
>
{/* The track clips whatever hangs outside it, so the frame's
ring, and the focus ring, are drawn inside the card, and the
sheet sits a step in from the frame so the ring shows. */}
<Card
variant="interactive"
className="relative w-full ring-inset has-[a:focus-visible]:focus-outline-inset"
>
<CardContent className="relative m-1 mb-0 flex-none p-0">
<ProductArt name={product.name} category={product.category} className="rounded-none" />
{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>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
</section>
)
}
export default ProductCards08"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { prefersReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { useCarousel } from "@/components/ui/carousel"
/**
* The carousel's previous and next buttons, set in the band's header rather
* than floating over the slides, where they would cover a card and hang off a
* phone's edge. Each is named for what it does, and at either end it stays
* focusable — marked unavailable rather than disabled — so a keyboard
* pressing Next to the last slide keeps its place. Under reduced motion the
* track jumps to the next page instead of sliding there.
*/
export function CarouselButtons() {
const { api, canScrollPrev, canScrollNext } = useCarousel()
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
aria-label="Previous products"
disabled={!canScrollPrev}
focusableWhenDisabled
onClick={(event) => api?.scrollPrev(prefersReducedMotion(event.currentTarget))}
>
<ChevronLeftIcon className="rtl:rotate-180" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Next products"
disabled={!canScrollNext}
focusableWhenDisabled
onClick={(event) => api?.scrollNext(prefersReducedMotion(event.currentTarget))}
>
<ChevronRightIcon className="rtl:rotate-180" />
</Button>
</div>
)
}/**
* What `product-cards-08` reads: the department that sold the most units over
* the last 90 days, and every product still sold in it, bestselling first —
* the storefront shelf's figures, so the department that leads here is the one
* whose products lead the other bands.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** A product as a slide shows it. */
export type SlideRow = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "stockState" | "href">
/**
* The department whose products sold the most units between them, and its
* products, the most sold first. A tie between departments goes to the name.
*/
export function popularDepartment(): { department: string; products: SlideRow[] } {
const shelf = storeShelf()
const units = new Map<string, number>()
for (const product of shelf) units.set(product.category, (units.get(product.category) ?? 0) + product.sold)
const [[department]] = [...units].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
return {
department,
products: shelf
.filter((product) => product.category === department)
.map(({ id, name, category, priceCents, stockState, href }) => ({ id, name, category, priceCents, stockState, href })),
}
}npx shadcn@latest add @vibra/product-cards-08Bought together, as a set
product-cards-09A product and the two things most often bought with it, joined by plus signs, each companion with how many orders held both and a checkbox, beside the set's total and one Add to cart.
import * as React from "react"
import Link from "next/link"
import { PlusIcon } from "lucide-react"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { BoughtTogetherSet, IncludeToggle, SetTotal } from "./product-cards-09.client"
import { boughtTogether, type Companion, type SetProduct } from "./product-cards-09.data"
/**
* Bought together: a product and the two things most often bought with it,
* as three cards joined by plus signs, and beside them the set's total with
* one Add to cart for it. Each companion says how many orders held it with
* the product and has a checkbox that takes it in or out of the set; the
* total and the button follow. The product on show is always in. On a phone
* the three cards lie on their side, stacked, over the total.
*
* A server component: the set comes from `./product-cards-09.data`; the
* island holds which companions are in, the total and the status line.
*/
export function ProductCards09() {
const titleId = React.useId()
const { product, companions } = boughtTogether()
const set: (SetProduct | Companion)[] = [product, ...companions]
return (
<section
data-section="product-cards-09"
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-xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Bought together
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
What our customers most often put in the same order as the {product.name}.
</p>
</div>
<BoughtTogetherSet items={set.map(({ id, name, priceCents }) => ({ id, name, priceCents }))}>
<div className="mt-8 grid items-start gap-6 md:mt-10 lg:grid-cols-[minmax(0,1fr)_18rem] lg:gap-8">
<ul className="grid gap-3 sm:grid-cols-3 sm:gap-6">
{set.map((item, index) => (
<li key={item.id} className="relative flex">
{index > 0 ? (
<span
aria-hidden="true"
className="absolute top-1/2 -left-6 z-10 hidden size-6 -translate-y-1/2 items-center justify-center rounded-full bg-background text-muted-foreground ring-1 ring-border sm:flex"
>
<PlusIcon className="size-3.5" />
</span>
) : null}
<Card
variant="interactive"
className="relative w-full max-sm:flex-row has-[a:focus-visible]:focus-outline"
>
<CardContent className="flex-none p-0 max-sm:w-24">
<ProductArt name={item.name} category={item.category} className="rounded-none max-sm:h-full" />
</CardContent>
<CardFooter className="mt-0 flex-1 flex-col items-stretch gap-1 py-3 sm:pt-2">
<p className="text-xs text-muted-foreground tabular-nums">
{"together" in item
? `In ${item.together} ${item.together === 1 ? "order" : "orders"} with it`
: "This item"}
</p>
<h3 className="text-sm font-semibold text-foreground">
<Link href={item.href} className="outline-none after:absolute after:inset-0">
{item.name}
</Link>
</h3>
<Price amount={item.priceCents / 100} size="sm" />
{"together" in item ? (
<div className="relative z-10 mt-auto pt-2">
<IncludeToggle id={item.id} />
</div>
) : null}
</CardFooter>
</Card>
</li>
))}
</ul>
<Card>
<CardHeader>
<CardTitle>
<h3>Your set</h3>
</CardTitle>
</CardHeader>
<CardContent>
<SetTotal />
</CardContent>
</Card>
</div>
</BoughtTogetherSet>
</div>
</section>
)
}
export default ProductCards09"use client"
import * as React from "react"
import { ShoppingBagIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
/** What the set knows of a product: its name for the controls and its price for the total. */
export type SetItem = { id: string; name: string; priceCents: number }
type SetState = { items: SetItem[]; included: ReadonlySet<string>; include: (id: string, on: boolean) => void }
const SetContext = React.createContext<SetState | null>(null)
function useSet(): SetState {
const set = React.useContext(SetContext)
if (!set) throw new Error("IncludeToggle and SetTotal sit inside BoughtTogetherSet")
return set
}
const AND = new Intl.ListFormat("en-US", { type: "conjunction" })
/**
* The set being put together: which of its products are in, all of them to
* start. It wraps the cards the server drew, so only the checkboxes and the
* total are client code.
*/
export function BoughtTogetherSet({ items, children }: { items: SetItem[]; children: React.ReactNode }) {
const [included, setIncluded] = React.useState<ReadonlySet<string>>(() => new Set(items.map((item) => item.id)))
const include = React.useCallback((id: string, on: boolean) => {
setIncluded((current) => {
const next = new Set(current)
if (on) next.add(id)
else next.delete(id)
return next
})
}, [])
const set = React.useMemo(() => ({ items, included, include }), [items, included, include])
return <SetContext.Provider value={set}>{children}</SetContext.Provider>
}
/** A companion's checkbox: in the set or out of it. Its name carries the product's. */
export function IncludeToggle({ id }: { id: string }) {
const { items, included, include } = useSet()
const item = items.find((entry) => entry.id === id)
if (!item) return null
return (
<Label className="w-fit text-sm font-normal text-foreground">
<Checkbox checked={included.has(id)} onCheckedChange={(checked) => include(id, checked === true)} />
Include{" "}
<span className="sr-only">the {item.name}</span>
</Label>
)
}
/**
* The set as a receipt: each product still in with its price, the total for
* them, and Add to cart for exactly those. A companion unticked leaves the
* list, the total and the button's count in the same render. What the button
* did is said in an always-mounted status line under it.
*/
export function SetTotal() {
const { items, included } = useSet()
const [message, setMessage] = React.useState("")
const chosen = items.filter((item) => included.has(item.id))
const cents = chosen.reduce((sum, item) => sum + item.priceCents, 0)
return (
<div className="flex flex-col gap-4">
<ul className="flex flex-col gap-2 text-sm">
{chosen.map((item) => (
<li key={item.id} className="flex items-baseline justify-between gap-3">
<span className="min-w-0 text-muted-foreground">{item.name}</span>
<span className="text-foreground tabular-nums">{formatCurrency(item.priceCents / 100, "USD")}</span>
</li>
))}
</ul>
<div className="flex items-baseline justify-between gap-3 border-t border-border pt-3">
<span className="text-sm text-muted-foreground">
Total for {chosen.length} {chosen.length === 1 ? "item" : "items"}
</span>
<span className="type-numeral text-2xl text-foreground tabular-nums">{formatCurrency(cents / 100, "USD")}</span>
</div>
<Button
size="lg"
className="w-full"
disabled={chosen.length === 0}
focusableWhenDisabled
onClick={() => setMessage(`Added ${AND.format(chosen.map((item) => item.name))} to your cart`)}
>
<ShoppingBagIcon data-icon="inline-start" />
{chosen.length === items.length ? `Add all ${chosen.length} to cart` : `Add ${chosen.length} to cart`}
</Button>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-pretty text-muted-foreground">
{message}
</p>
</div>
)
}/**
* What `product-cards-09` reads: a product and the two things most often
* bought with it. "Bought with" is read off the store's orders — two
* products in one sold order, by the dashboard's rule for a sale — and kept
* to the product's own department, so a desk is offered what furnishes the
* same room rather than whatever shared a basket with it once. The product on
* show is the one whose two companions were bought with it most often.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { isSold } from "@/lib/dashboards/ecommerce/vocabulary"
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
import { db } from "@/lib/sample-data"
/** A product as the set shows it. */
export type SetProduct = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "href">
/** A companion, and how many sold orders held it together with the product on show. */
export type Companion = SetProduct & { together: number }
/** How many sold orders held each pair of products, both ways round. */
export function boughtWith(): Map<string, Map<string, number>> {
const pairs = new Map<string, Map<string, number>>()
for (const order of db.orders.all()) {
if (!isSold(order)) continue
const ids = [...new Set(order.items.map((item) => item.productId))]
for (const a of ids) {
const row = pairs.get(a) ?? new Map<string, number>()
for (const b of ids) if (b !== a) row.set(b, (row.get(b) ?? 0) + 1)
pairs.set(a, row)
}
}
return pairs
}
const pick = ({ id, name, category, priceCents, href }: ShelfProduct): SetProduct => ({ id, name, category, priceCents, href })
/**
* The set: of the products still sold, the one whose two most-bought
* companions in its own department (in stock) were bought with it most often
* between them, and those two, the stronger first. Ties go to the product
* that sold more, then to the name.
*/
export function boughtTogether(): { product: SetProduct; companions: Companion[] } {
const shelf = storeShelf()
const byId = new Map(shelf.map((product) => [product.id, product]))
const pairs = boughtWith()
const [best] = shelf
.map((product) => {
const companions = [...(pairs.get(product.id) ?? new Map<string, number>())]
.flatMap(([id, together]) => {
const other = byId.get(id)
return other && other.category === product.category && other.stock > 0 ? [{ other, together }] : []
})
.sort((a, b) => b.together - a.together || a.other.name.localeCompare(b.other.name))
.slice(0, 2)
return { product, companions, strength: companions.reduce((sum, entry) => sum + entry.together, 0) }
})
.filter((candidate) => candidate.companions.length === 2)
.sort(
(a, b) => b.strength - a.strength || b.product.sold - a.product.sold || a.product.name.localeCompare(b.product.name)
)
return {
product: pick(best.product),
companions: best.companions.map(({ other, together }) => ({ ...pick(other), together })),
}
}npx shadcn@latest add @vibra/product-cards-09Compact grid with save hearts
product-cards-10Six bestsellers at $25 or under as frameless tiles, six across from lg, each with a heart that saves it for later and the saved count beside the heading.
import * as React from "react"
import Link from "next/link"
import { formatCurrency } from "@/lib/format"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { SaveButton, SavedLine, SavedProducts } from "./product-cards-10.client"
import { PRICE_POINT_CENTS, underPricePoint } from "./product-cards-10.data"
/**
* A compact grid: six small things at or under a price point, as frameless
* tiles — the picture, and under it the name and the price — six across from
* `lg`, three on a tablet, two on a phone. Each tile carries a heart that
* saves the product for later, kept in the browser, and the saved count sits
* beside the heading with the way to the saved list. Each tile is one
* stretched link; the heart sits above it.
*
* A server component: the tiles come from `./product-cards-10.data`; the
* island holds only the saved products.
*/
export function ProductCards10() {
const titleId = React.useId()
const products = underPricePoint(6)
const pricePoint = formatCurrency(PRICE_POINT_CENTS / 100, "USD", { maximumFractionDigits: 0 })
return (
<section
data-section="product-cards-10"
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">
<SavedProducts>
<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 md:text-3xl">
{pricePoint} and under
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Our bestsellers at {pricePoint} or less. Save any for later.
</p>
</div>
<SavedLine savedHref="/saved" />
</div>
<ul className="mt-8 grid grid-cols-2 gap-x-3 gap-y-6 sm:grid-cols-3 md:mt-10 md:gap-x-4 lg:grid-cols-6">
{products.map((product) => (
<li
key={product.id}
className="group/tile relative flex flex-col gap-2 rounded-xl has-[a:focus-visible]:focus-outline"
>
<div className="relative overflow-hidden rounded-xl ring-1 ring-border">
<ProductArt
name={product.name}
category={product.category}
className="rounded-none transition-transform duration-(--duration-base) ease-(--ease-standard) group-hover/tile:scale-[1.03]"
/>
<div className="absolute top-2 right-2 z-10">
<SaveButton id={product.id} name={product.name} />
</div>
</div>
<div className="flex flex-col gap-0.5 px-0.5">
<h3 className="text-sm font-medium text-foreground">
<Link
href={product.href}
className="outline-none group-hover/tile:underline after:absolute after:inset-0 after:rounded-xl"
>
{product.name}
</Link>
</h3>
<Price amount={product.priceCents / 100} size="sm" />
</div>
</li>
))}
</ul>
</SavedProducts>
</div>
</section>
)
}
export default ProductCards10"use client"
import * as React from "react"
import Link from "next/link"
import { HeartIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { useLocalStorage } from "@/hooks/use-local-storage"
import { Button, buttonVariants } from "@/components/ui/button"
/** Where the saved products are kept between visits. */
export const SAVED_KEY = "saved-products"
type Saved = { ids: readonly string[]; toggle: (id: string) => void }
const SavedContext = React.createContext<Saved | null>(null)
function useSaved(): Saved {
const saved = React.useContext(SavedContext)
if (!saved) throw new Error("SaveButton and SavedLine sit inside SavedProducts")
return saved
}
/**
* The shopper's saved products, kept in this browser under `SAVED_KEY` so a
* heart pressed today is still pressed tomorrow. Read after hydration, so the
* server's markup and the first client render agree. It wraps the tiles the
* server drew; only the hearts and the saved line are client code.
*/
export function SavedProducts({ children }: { children: React.ReactNode }) {
const [ids, setIds] = useLocalStorage<string[]>(SAVED_KEY, [])
const toggle = React.useCallback(
(id: string) => setIds((current) => (current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id])),
[setIds]
)
const saved = React.useMemo(() => ({ ids, toggle }), [ids, toggle])
return <SavedContext.Provider value={saved}>{children}</SavedContext.Provider>
}
/**
* A tile's heart: a toggle named for its product, pressed while the product
* is saved — filled, on the brand tint. It sits above the tile's link, so a
* press saves rather than follows.
*/
export function SaveButton({ id, name }: { id: string; name: string }) {
const { ids, toggle } = useSaved()
const saved = ids.includes(id)
return (
<Button
variant="outline"
size="icon-sm"
aria-label={`Save ${name}`}
aria-pressed={saved}
className="rounded-full border-transparent bg-background/90 shadow-(--shadow-float) aria-pressed:bg-brand-muted"
onClick={() => toggle(id)}
>
<HeartIcon className={cn(saved && "fill-current")} />
</Button>
)
}
/** How many products are saved, said whenever it changes, with the way to them. */
export function SavedLine({ savedHref }: { savedHref: string }) {
const { ids } = useSaved()
return (
<div className="flex w-full items-center gap-3 rounded-lg bg-surface py-1.5 pr-1.5 pl-3 ring-1 ring-border sm:w-auto">
<HeartIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<p role="status" aria-live="polite" className="min-w-0 flex-1 text-sm text-foreground tabular-nums">
{ids.length === 0 ? "Nothing saved yet" : `${ids.length} saved`}
</p>
<Link href={savedHref} className={cn(buttonVariants({ variant: "outline", size: "sm" }), "shrink-0")}>
View saved
</Link>
</div>
)
}/**
* What `product-cards-10` reads: the shop's bestsellers at or under a price
* point — small things, the most sold first, off the storefront shelf.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { storeShelf, type ShelfProduct } from "@/lib/dashboards/ecommerce/storefront"
/** The most a product on this shelf costs, in cents. */
export const PRICE_POINT_CENTS = 2_500
/** A product as a tile shows it. */
export type TileRow = Pick<ShelfProduct, "id" | "name" | "category" | "priceCents" | "href">
/** The `count` bestselling products at or under the price point, the most sold first. */
export function underPricePoint(count = 6): TileRow[] {
return storeShelf()
.filter((product) => product.priceCents <= PRICE_POINT_CENTS)
.slice(0, count)
.map(({ id, name, category, priceCents, href }) => ({ id, name, category, priceCents, href }))
}npx shadcn@latest add @vibra/product-cards-10