Quickview
A product in a dialog over the listing, with its options and an add-to-cart.
Quick view from a grid
quickview-01One department's bestsellers as cards, each with a Quick view that opens the product in a wide dialog — its picture beside its score, price, stock and sales, a quantity stepper and Add to cart, and the way to its full page.
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 { QuickLook, QuickViewButton } from "./quickview-01.client"
import { DEPARTMENT, SHELF_DAYS, quickLook } from "./quickview-01.data"
export type Quickview01Props = {
/** Opens the quick look on the first product on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A quick look from the grid: one department's four bestsellers as cards,
* each one link to its page and a Quick view button that opens the product in
* a dialog — its picture beside its score, price, stock and sales, a quantity
* and Add to cart, and the way to its full page — without leaving the grid.
*
* A server component: the products come from `./quickview-01.data`; every
* picture, the cards' and the dialog's, is drawn here and handed over by id.
*/
export function Quickview01({ defaultOpen = false }: Quickview01Props) {
const titleId = React.useId()
const products = quickLook(4)
const art = Object.fromEntries(
products.map((product) => [product.id, <ProductArt key={product.id} name={product.name} category={product.category} />])
)
return (
<section
data-section="quickview-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<p className="type-eyebrow">{DEPARTMENT}</p>
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
Take a closer look
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
The four {DEPARTMENT.toLowerCase()} products bought most over the last {SHELF_DAYS} days. Open any of them
here, choose how many and add it, without leaving the page.
</p>
</div>
<QuickLook products={products} art={art} days={SHELF_DAYS} defaultOpen={defaultOpen}>
<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.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>
<Price amount={product.priceCents / 100} size="sm" className="mt-auto pt-1" />
{/* Above the stretched link, so a press opens the quick look
rather than following the card. */}
<div className="relative z-10 pt-2">
<QuickViewButton id={product.id} name={product.name} />
</div>
</CardFooter>
</Card>
</li>
))}
</ul>
</QuickLook>
</div>
</section>
)
}
export default Quickview01"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, EyeIcon, MinusIcon, PlusIcon, ShoppingBagIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { Price } from "@/components/ui/price"
import { Rating } from "@/components/ui/rating"
import type { QuickProduct } from "./quickview-01.data"
const STOCK = { in: "In stock", low: "Low stock", out: "Sold out" } as const
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const QuickLookContext = React.createContext<((id: string) => void) | null>(null)
/** The Quick view button on a card: opens the dialog on its product. */
export function QuickViewButton({ id, name }: { id: string; name: string }) {
const open = React.useContext(QuickLookContext)
if (!open) throw new Error("QuickViewButton sits inside QuickLook")
return (
<Button variant="outline" size="sm" className="w-full" onClick={() => open(id)}>
<EyeIcon data-icon="inline-start" />
Quick view<span className="sr-only">, {name}</span>
</Button>
)
}
/** One product in the dialog: its picture beside what it is, what it costs, a quantity and Add to cart. */
function QuickLookBody({ product, picture, days }: { product: QuickProduct; picture: React.ReactNode; days: number }) {
const [quantity, setQuantity] = React.useState(1)
const [message, setMessage] = React.useState("")
const labelId = React.useId()
const limitId = React.useId()
const full = quantity >= product.stock
return (
<div className="grid sm:grid-cols-2">
<div className="p-2">{picture}</div>
<div className="flex flex-col gap-4 p-4 sm:p-6 sm:pl-3">
<div className="flex flex-col gap-1.5 pr-8">
<p className="text-xs text-muted-foreground">
{product.category} · {product.subcategory}
</p>
<DialogTitle className="type-display text-2xl text-balance">{product.name}</DialogTitle>
{product.ratings ? (
<div className="flex items-center gap-1.5">
<Rating value={Math.round(product.rating)} size="sm" aria-hidden="true" />
<span aria-hidden="true" className="text-sm text-muted-foreground tabular-nums">
{score(product.rating)} from {product.ratings} ratings
</span>
<span className="sr-only">
Rated {score(product.rating)} out of 5 from {product.ratings} ratings
</span>
</div>
) : null}
</div>
<div className="flex flex-col gap-1">
<Price amount={product.priceCents / 100} size="lg" />
<DialogDescription className="tabular-nums">
{STOCK[product.stockState]}
{product.stockState === "low" ? `, ${formatNumber(product.stock)} left` : null}. Bought{" "}
{formatNumber(product.sold)} times in the last {days} days.
</DialogDescription>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-4">
<div className="flex flex-wrap items-center gap-3">
<span id={labelId} className="sr-only">
Quantity
</span>
<div role="group" aria-labelledby={labelId} className="inline-flex h-10 items-center rounded-lg bg-card p-0.5 ring-1 ring-border">
<Button
variant="ghost"
size="icon"
aria-label="Decrease quantity"
disabled={quantity <= 1}
focusableWhenDisabled
onClick={() => setQuantity((current) => Math.max(1, current - 1))}
>
<MinusIcon />
</Button>
<span aria-live="polite" className="w-9 text-center text-sm font-medium text-foreground tabular-nums">
{quantity}
</span>
<Button
variant="ghost"
size="icon"
aria-label="Increase quantity"
aria-describedby={full ? limitId : undefined}
disabled={full}
focusableWhenDisabled
onClick={() => setQuantity((current) => Math.min(product.stock, current + 1))}
>
<PlusIcon />
</Button>
</div>
<Button
size="lg"
className="h-10 flex-1"
data-autofocus
disabled={product.stock <= 0}
onClick={() =>
setMessage(quantity === 1 ? `Added the ${product.name} to your cart` : `Added ${quantity} of the ${product.name} to your cart`)
}
>
<ShoppingBagIcon data-icon="inline-start" />
Add to cart
</Button>
</div>
{full ? (
<p id={limitId} className="text-xs text-muted-foreground tabular-nums">
All {product.stock} in stock
</p>
) : null}
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{message}
</p>
</div>
<Link href={product.href} className={cn(buttonVariants({ variant: "link" }), "group/all mt-auto w-fit px-0")}>
View full details
<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>
</div>
)
}
/**
* The band's quick look: the cards the server drew, each with a Quick view
* button, and one dialog they open on their product — the kit's Dialog, so
* focus moves into it, Escape and the close button shut it, and focus goes
* back to the button that opened it. Every picture arrives drawn, as `art`.
* `defaultOpen` opens it on the first product, for a preview, with focus on
* the dialog itself rather than ringing its first control.
*/
export function QuickLook({
products,
art,
days,
defaultOpen = false,
children,
}: {
products: QuickProduct[]
art: Record<string, React.ReactNode>
days: number
defaultOpen?: boolean
children: React.ReactNode
}) {
const [open, setOpen] = React.useState(defaultOpen)
// Kept when the dialog closes, so it does not empty while it fades out.
const [shownId, setShownId] = React.useState(products[0]?.id)
const openedByDefault = React.useRef(defaultOpen)
const popupRef = React.useRef<HTMLDivElement>(null)
const product = products.find((entry) => entry.id === shownId)
const openOn = React.useCallback((id: string) => {
openedByDefault.current = false
setShownId(id)
setOpen(true)
}, [])
return (
<QuickLookContext.Provider value={openOn}>
{children}
<Dialog
open={open}
onOpenChange={(next) => {
openedByDefault.current = false
setOpen(next)
}}
>
<DialogContent
ref={popupRef}
// A preview opens with focus on the dialog itself; a reader who
// opened it lands on the control they came for.
initialFocus={() =>
openedByDefault.current ? popupRef.current : (popupRef.current?.querySelector<HTMLElement>("[data-autofocus]") ?? true)
}
className="max-h-[calc(100svh-2rem)] gap-0 overflow-y-auto p-0 sm:max-w-3xl"
>
{product ? <QuickLookBody key={product.id} product={product} picture={art[product.id]} days={days} /> : null}
</DialogContent>
</Dialog>
</QuickLookContext.Provider>
)
}/**
* What `quickview-01` reads: one department's four bestsellers off the
* storefront's shelf, each with what a quick look needs — its aisle, price,
* stock, what it sold and what buyers rated it — and where its full page is.
*
* 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,
storeDepartments,
type ShelfProduct,
} from "@/lib/dashboards/ecommerce/storefront"
export { SHELF_DAYS }
/** The department the band shows. */
export const DEPARTMENT = "Home"
/** A product as a quick look shows it. */
export type QuickProduct = Pick<
ShelfProduct,
"id" | "name" | "category" | "subcategory" | "priceCents" | "stock" | "stockState" | "sold" | "rating" | "ratings" | "href"
>
/** The department's `count` bestsellers, the most sold first. */
export function quickLook(count = 4): QuickProduct[] {
const department = storeDepartments().find((entry) => entry.name === DEPARTMENT)
if (!department) throw new Error(`quickview-01 shows "${DEPARTMENT}", which has no product on the shelf`)
return department.products
.slice(0, count)
.map(({ id, name, category, subcategory, priceCents, stock, stockState, sold, rating, ratings, href }) => ({
id,
name,
category,
subcategory,
priceCents,
stock,
stockState,
sold,
rating,
ratings,
href,
}))
}npx shadcn@latest add @vibra/quickview-01Quick view with sizes
quickview-02The bestselling clothes and shoes as ruled rows that say which sizes have sold out, each opening a narrow dialog — the picture on top, the sizes, a quantity bounded by the size chosen, and Add to bag or Notify me.
import * as React from "react"
import Link from "next/link"
import { Card, CardContent } from "@/components/ui/card"
import { Price } from "@/components/ui/price"
import { ProductArt } from "@/components/ui/product-art"
import { QuickViewButton, SizedQuickLook } from "./quickview-02.client"
import { sizedQuickLook } from "./quickview-02.data"
export type Quickview02Props = {
/** Opens the quick look on the first product on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A quick look from a list: the bestselling clothes and shoes as ruled rows —
* each its picture, name, how many sizes it comes in and which have sold
* out, and its price — and a Quick view button that opens a narrow dialog to
* choose a size and add it, without leaving the list.
*
* A server component: the products and their stock by size come from
* `./quickview-02.data`; every picture is drawn here and handed over by id.
*/
export function Quickview02({ defaultOpen = false }: Quickview02Props) {
const titleId = React.useId()
const products = sizedQuickLook(3)
// A 16:10 tile for the top of the dialog, its drawing sized by the width: 45% of it is 72% of the height.
const art = Object.fromEntries(
products.map((product) => [
product.id,
<ProductArt
key={product.id}
name={product.name}
category={product.category}
className="aspect-[16/10]"
/>,
])
)
return (
<section
data-section="quickview-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="mx-auto flex max-w-3xl flex-col gap-2">
<p className="type-eyebrow">Apparel</p>
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
Check your size is left
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Our bestselling clothes and shoes. Open one to see which sizes remain, and add yours from here.
</p>
</div>
<SizedQuickLook products={products} art={art} defaultOpen={defaultOpen}>
<Card className="mx-auto mt-8 max-w-3xl md:mt-10">
<CardContent className="p-0">
<ul className="divide-y divide-border">
{products.map((product) => {
const soldOut = product.sizes.filter((entry) => entry.units === 0).map((entry) => entry.size)
return (
<li key={product.id} className="flex items-center gap-3 p-3 md:gap-4 md:p-4">
<ProductArt name={product.name} category={product.category} className="w-14 shrink-0 rounded-md md:w-16" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<h3 className="text-sm font-semibold text-foreground">
<Link href={product.href} className="focus-ring rounded-sm hover:underline">
{product.name}
</Link>
</h3>
<p className="text-xs text-muted-foreground">
{product.sizes.length} sizes
{soldOut.length ? `, ${soldOut.join(" and ")} sold out` : ", all in stock"}
</p>
<Price amount={product.priceCents / 100} size="sm" className="sm:hidden" />
</div>
<Price amount={product.priceCents / 100} size="sm" className="max-sm:hidden" />
<QuickViewButton id={product.id} name={product.name} />
</li>
)
})}
</ul>
</CardContent>
</Card>
</SizedQuickLook>
</div>
</section>
)
}
export default Quickview02"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, BellIcon, BellRingIcon, MinusIcon, PlusIcon, ShoppingBagIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { Price } from "@/components/ui/price"
import { Rating } from "@/components/ui/rating"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import type { SizedQuick } from "./quickview-02.data"
/** How few units of a size are worth counting out loud. */
const FEW = 5
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const OpenContext = React.createContext<((id: string) => void) | null>(null)
/** A row's Quick view button: opens the dialog on its product. */
export function QuickViewButton({ id, name }: { id: string; name: string }) {
const open = React.useContext(OpenContext)
if (!open) throw new Error("QuickViewButton sits inside SizedQuickLook")
return (
<Button variant="outline" size="sm" onClick={() => open(id)}>
Quick view<span className="sr-only">, {name}</span>
</Button>
)
}
/** One product in the dialog: its picture over its name, price, sizes and the way to buy the size chosen. */
function SizedBody({ product, picture }: { product: SizedQuick; picture: React.ReactNode }) {
const [size, setSize] = React.useState<string | null>(null)
const [quantity, setQuantity] = React.useState(1)
const [message, setMessage] = React.useState<string | null>(null)
const [watching, setWatching] = React.useState(false)
const groupRef = React.useRef<HTMLDivElement>(null)
const sizesId = React.useId()
const quantityId = React.useId()
const chosen = product.sizes.find((entry) => entry.size === size)
const cap = chosen ? chosen.units : 1
const stockLine = !chosen
? `Choose a ${product.sizeLabel.toLowerCase()}`
: chosen.units === 0
? `${chosen.size} is sold out`
: chosen.units <= FEW
? `Only ${chosen.units} left in ${chosen.size}`
: `${chosen.size} is in stock`
function choose(next: string) {
const entry = product.sizes.find((candidate) => candidate.size === next)
if (!entry) return
setSize(next)
setWatching(false)
setQuantity((current) => Math.max(1, Math.min(current, entry.units)))
setMessage(null)
}
function add() {
if (!chosen) {
setMessage(`Choose a ${product.sizeLabel.toLowerCase()} first`)
groupRef.current?.querySelector<HTMLButtonElement>("button")?.focus()
return
}
setMessage(
quantity === 1
? `Added the ${product.name} in ${chosen.size} to your bag`
: `Added ${quantity} of the ${product.name} in ${chosen.size} to your bag`
)
}
return (
<div className="flex flex-col">
<div className="p-2">{picture}</div>
<div className="flex flex-col gap-4 px-4 pt-2 pb-5 sm:px-5">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 flex-col gap-1">
<p className="text-xs text-muted-foreground">{product.subcategory}</p>
<DialogTitle className="type-display text-xl text-balance">{product.name}</DialogTitle>
{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}
</div>
<Price amount={product.priceCents / 100} className="shrink-0" />
</div>
<DialogDescription className="sr-only">Choose a size and how many, then add it to your bag.</DialogDescription>
<div className="flex flex-col gap-2">
<span id={sizesId} className="text-sm font-medium text-foreground">
{product.sizeLabel}
</span>
<ToggleGroup
ref={groupRef}
aria-labelledby={sizesId}
variant="outline"
size="sm"
spacing={1}
value={size ? [size] : []}
onValueChange={(next) => {
const [picked] = next
if (picked) choose(picked)
}}
className="flex-wrap"
>
{product.sizes.map((entry, index) => (
<ToggleGroupItem
key={entry.size}
value={entry.size}
data-autofocus={index === 0 || undefined}
data-sold-out={entry.units === 0 || undefined}
className="min-w-10 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"
>
{entry.size}
{entry.units === 0 ? <span className="sr-only">, sold out</span> : null}
</ToggleGroupItem>
))}
</ToggleGroup>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{message ?? stockLine}
</p>
</div>
{chosen?.units === 0 ? (
<Button
size="lg"
variant="outline"
aria-pressed={watching}
className="h-10 aria-pressed:border-transparent aria-pressed:bg-brand-muted"
onClick={() => {
setWatching(!watching)
setMessage(watching ? `You won't be emailed about ${chosen.size}` : `We'll email you when ${chosen.size} is back`)
}}
>
{watching ? <BellRingIcon data-icon="inline-start" /> : <BellIcon data-icon="inline-start" />}
Notify me when {chosen.size} is back
</Button>
) : (
<div className="flex items-center gap-3">
<span id={quantityId} className="sr-only">
Quantity
</span>
<div role="group" aria-labelledby={quantityId} className="inline-flex h-10 items-center rounded-lg bg-card p-0.5 ring-1 ring-border">
<Button
variant="ghost"
size="icon"
aria-label="Decrease quantity"
disabled={quantity <= 1}
focusableWhenDisabled
onClick={() => setQuantity((current) => Math.max(1, current - 1))}
>
<MinusIcon />
</Button>
<span aria-live="polite" className="w-9 text-center text-sm font-medium text-foreground tabular-nums">
{quantity}
</span>
<Button
variant="ghost"
size="icon"
aria-label="Increase quantity"
disabled={quantity >= cap}
focusableWhenDisabled
onClick={() => setQuantity((current) => Math.min(cap, current + 1))}
>
<PlusIcon />
</Button>
</div>
<Button size="lg" className="h-10 flex-1" onClick={add}>
<ShoppingBagIcon data-icon="inline-start" />
Add to bag
</Button>
</div>
)}
<Link href={product.href} className={cn(buttonVariants({ variant: "link" }), "group/all w-fit px-0")}>
View full details
<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>
</div>
)
}
/**
* The list's quick look: the rows the server drew, each with a Quick view
* button, and one narrow dialog they open — the picture on top, then the
* sizes, each saying when it has sold out, a quantity bounded by the size
* chosen, and Add to bag. The kit's Dialog moves focus in, closes on Escape
* and returns focus to the button. `defaultOpen` opens it on the first
* product, for a preview, with focus on the dialog itself.
*/
export function SizedQuickLook({
products,
art,
defaultOpen = false,
children,
}: {
products: SizedQuick[]
art: Record<string, React.ReactNode>
defaultOpen?: boolean
children: React.ReactNode
}) {
const [open, setOpen] = React.useState(defaultOpen)
const [shownId, setShownId] = React.useState(products[0]?.id)
const openedByDefault = React.useRef(defaultOpen)
const popupRef = React.useRef<HTMLDivElement>(null)
const product = products.find((entry) => entry.id === shownId)
const openOn = React.useCallback((id: string) => {
openedByDefault.current = false
setShownId(id)
setOpen(true)
}, [])
return (
<OpenContext.Provider value={openOn}>
{children}
<Dialog
open={open}
onOpenChange={(next) => {
openedByDefault.current = false
setOpen(next)
}}
>
<DialogContent
ref={popupRef}
// A preview opens with focus on the dialog itself; a reader who
// opened it lands on the control they came for.
initialFocus={() =>
openedByDefault.current ? popupRef.current : (popupRef.current?.querySelector<HTMLElement>("[data-autofocus]") ?? true)
}
className="max-h-[calc(100svh-2rem)] gap-0 overflow-y-auto p-0 sm:max-w-md"
>
{product ? <SizedBody key={product.id} product={product} picture={art[product.id]} /> : null}
</DialogContent>
</Dialog>
</OpenContext.Provider>
)
}/**
* What `quickview-02` reads: the bestselling products that come in sizes,
* off the storefront's shelf, each with its stock split across its sizes by
* the storefront's rule (`sizesOf`) — the run's usual curve shared out in
* whole units that add up to the product's one count — and what buyers rated
* it. A size the rule leaves at none is sold out.
*
* 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 sized quick look shows it. */
export type SizedQuick = Pick<ShelfProduct, "id" | "name" | "category" | "subcategory" | "priceCents" | "rating" | "ratings" | "href"> & {
/** What the picker 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 sizedQuickLook(count = 3): SizedQuick[] {
return storeShelf()
.flatMap((product) => {
const run = sizeRunOf(product)
if (!run) return []
const { id, name, category, subcategory, priceCents, rating, ratings, href } = product
return [{ id, name, category, subcategory, priceCents, rating, ratings, href, sizeLabel: run.label, sizes: sizesOf(product, run) }]
})
.slice(0, count)
}npx shadcn@latest add @vibra/quickview-02Quick view that pages through the list
quickview-03One department as a table with a Quick view on every row; the dialog it opens steps through the whole list with Previous and Next, named by the product each leads to, without closing — each product with its facts, a quantity and Add to cart.
import * as React from "react"
import Link from "next/link"
import { formatCurrency } from "@/lib/format"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ProductArt } from "@/components/ui/product-art"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { PagedQuickLook, QuickViewButton } from "./quickview-03.client"
import { DEPARTMENT, SHELF_DAYS, pagedList } from "./quickview-03.data"
export type Quickview03Props = {
/** Opens the quick look on the first product on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A quick look that pages: one department as a table — each product's
* picture and name, its aisle and its price — with a Quick view on every
* row that opens the product in a dialog, from which Previous and Next step
* through the whole list in its order without closing it.
*
* A server component: the list comes from `./quickview-03.data`; every
* picture, the rows' and the dialog's, is drawn here and handed over by id.
*/
export function Quickview03({ defaultOpen = false }: Quickview03Props) {
const titleId = React.useId()
const products = pagedList()
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="quickview-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<p className="type-eyebrow">{DEPARTMENT}</p>
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
Look through the whole aisle
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Every {DEPARTMENT.toLowerCase()} product, bestselling first. Open one, then step to the next without going
back to the list.
</p>
</div>
<PagedQuickLook products={products} art={art} days={SHELF_DAYS} label={DEPARTMENT} defaultOpen={defaultOpen}>
<Card className="mt-8 md:mt-10">
<CardHeader>
<CardTitle>
<h3>
{DEPARTMENT}, {products.length} products
</h3>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="pl-4">Product</TableHead>
<TableHead className="max-sm:hidden">Aisle</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="w-28 pr-4">
<span className="sr-only">Quick view</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product, index) => (
<TableRow key={product.id}>
<TableCell className="pl-4">
<div className="flex items-center gap-3">
<ProductArt name={product.name} category={product.category} className="w-10 shrink-0 rounded-md" />
<Link href={product.href} className="focus-ring rounded-sm font-medium text-foreground hover:underline">
{product.name}
</Link>
</div>
</TableCell>
<TableCell className="text-muted-foreground max-sm:hidden">{product.subcategory}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(product.priceCents / 100, "USD")}</TableCell>
<TableCell className="pr-4 text-right">
<QuickViewButton index={index} name={product.name} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</PagedQuickLook>
</div>
</section>
)
}
export default Quickview03"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, ChevronLeftIcon, ChevronRightIcon, ShoppingBagIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { NumberInput } from "@/components/ui/number-input"
import { Price } from "@/components/ui/price"
import { Rating } from "@/components/ui/rating"
import type { PagedProduct } from "./quickview-03.data"
const STOCK = { in: "In stock", low: "Low stock", out: "Sold out" } as const
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const OpenContext = React.createContext<((index: number) => void) | null>(null)
/** A row's Quick view button: opens the dialog at its product's place in the list. */
export function QuickViewButton({ index, name }: { index: number; name: string }) {
const open = React.useContext(OpenContext)
if (!open) throw new Error("QuickViewButton sits inside PagedQuickLook")
return (
<Button variant="ghost" size="sm" onClick={() => open(index)}>
Quick view<span className="sr-only">, {name}</span>
</Button>
)
}
/**
* The list's quick look, which pages through the list without closing:
* Previous and Next, named by the product each leads to, step through it in
* the list's order and stay focusable, marked unavailable, at either end. The
* dialog shows the product's picture beside its facts, the kit's NumberInput
* for how many — bounded by the stock — and Add to cart; one always-mounted
* status line says where the reader is and what the last press did. The kit's
* Dialog moves focus in, closes on Escape and gives focus back to the row's
* button. `defaultOpen` opens it on the first product, for a preview.
*/
export function PagedQuickLook({
products,
art,
days,
label,
defaultOpen = false,
children,
}: {
products: PagedProduct[]
art: Record<string, React.ReactNode>
days: number
/** What the list is called in the dialog: "Outdoor". */
label: string
defaultOpen?: boolean
children: React.ReactNode
}) {
const [open, setOpen] = React.useState(defaultOpen)
const [index, setIndex] = React.useState(0)
const [quantity, setQuantity] = React.useState<number | null>(1)
const [message, setMessage] = React.useState("")
const openedByDefault = React.useRef(defaultOpen)
const popupRef = React.useRef<HTMLDivElement>(null)
const quantityId = React.useId()
const product = products[index]
const previous = products[index - 1]
const next = products[index + 1]
const show = (at: number) => {
setIndex(at)
setQuantity(1)
}
const openAt = React.useCallback((at: number) => {
openedByDefault.current = false
setIndex(at)
setQuantity(1)
setMessage("")
setOpen(true)
}, [])
function page(step: -1 | 1) {
const to = products[index + step]
if (!to) return
show(index + step)
setMessage(`${index + step + 1} of ${products.length}: ${to.name}`)
}
function add() {
if (!product) return
const count = Math.min(product.stock, Math.max(1, Math.round(quantity ?? 1)))
setQuantity(count)
setMessage(count === 1 ? `Added the ${product.name} to your cart` : `Added ${count} of the ${product.name} to your cart`)
}
return (
<OpenContext.Provider value={openAt}>
{children}
<Dialog
open={open}
onOpenChange={(value) => {
openedByDefault.current = false
setOpen(value)
}}
>
<DialogContent
ref={popupRef}
// A preview opens with focus on the dialog itself; a reader who
// opened it lands on the control they came for.
initialFocus={() =>
openedByDefault.current ? popupRef.current : (popupRef.current?.querySelector<HTMLElement>("[data-autofocus]") ?? true)
}
className="max-h-[calc(100svh-2rem)] gap-0 overflow-y-auto p-0 sm:max-w-2xl"
>
{product ? (
<div className="flex flex-col">
<div className="flex items-center gap-1 border-b border-rule py-2 pr-12 pl-2">
<Button
variant="ghost"
size="icon-sm"
aria-label={previous ? `Previous product: ${previous.name}` : "Previous product"}
disabled={!previous}
focusableWhenDisabled
onClick={() => page(-1)}
>
<ChevronLeftIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={next ? `Next product: ${next.name}` : "Next product"}
disabled={!next}
focusableWhenDisabled
onClick={() => page(1)}
>
<ChevronRightIcon />
</Button>
<p className="ml-1 text-xs text-muted-foreground tabular-nums">
{index + 1} of {products.length} in {label}
</p>
</div>
<div className="grid gap-4 p-4 sm:grid-cols-[13rem_minmax(0,1fr)] sm:gap-6 sm:p-6">
<div className="overflow-hidden rounded-lg">{art[product.id]}</div>
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-col gap-1.5">
<p className="text-xs text-muted-foreground">{product.subcategory}</p>
<DialogTitle className="type-display text-2xl text-balance">{product.name}</DialogTitle>
{product.ratings ? (
<div className="flex items-center gap-1.5">
<Rating value={Math.round(product.rating)} size="sm" aria-hidden="true" />
<span aria-hidden="true" className="text-sm text-muted-foreground tabular-nums">
{score(product.rating)} from {product.ratings} ratings
</span>
<span className="sr-only">
Rated {score(product.rating)} out of 5 from {product.ratings} ratings
</span>
</div>
) : null}
</div>
<Price amount={product.priceCents / 100} size="lg" />
<DialogDescription className="tabular-nums">
{STOCK[product.stockState]}
{product.stockState === "low" ? `, ${formatNumber(product.stock)} left` : null} · Ships from{" "}
{product.supplier} · {formatNumber(product.sold)} sold in the last {days} days
</DialogDescription>
<div className="flex flex-wrap items-end gap-3 border-t border-border pt-4">
<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={Math.max(1, product.stock)}
className="h-10"
/>
</div>
<Button size="lg" className="h-10 flex-1" data-autofocus disabled={product.stock <= 0} onClick={add}>
<ShoppingBagIcon data-icon="inline-start" />
Add to cart
</Button>
</div>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{message}
</p>
<Link href={product.href} className={cn(buttonVariants({ variant: "link" }), "group/all w-fit px-0")}>
View full details
<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>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
</OpenContext.Provider>
)
}/**
* What `quickview-03` reads: every product of one department off the
* storefront's shelf, bestselling first — the order the quick look pages
* through — each with its aisle, price, stock, supplier, what it sold and
* what buyers rated it, and where its full page is.
*
* 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,
storeDepartments,
type ShelfProduct,
} from "@/lib/dashboards/ecommerce/storefront"
export { SHELF_DAYS }
/** The department the band lists. */
export const DEPARTMENT = "Outdoor"
/** A product as the paged quick look shows it. */
export type PagedProduct = Pick<
ShelfProduct,
| "id"
| "name"
| "category"
| "subcategory"
| "supplier"
| "priceCents"
| "stock"
| "stockState"
| "sold"
| "rating"
| "ratings"
| "href"
>
/** Every product of `DEPARTMENT`, bestselling first. */
export function pagedList(): PagedProduct[] {
const department = storeDepartments().find((entry) => entry.name === DEPARTMENT)
if (!department) throw new Error(`quickview-03 lists "${DEPARTMENT}", which has no product on the shelf`)
return department.products.map(
({ id, name, category, subcategory, supplier, priceCents, stock, stockState, sold, rating, ratings, href }) => ({
id,
name,
category,
subcategory,
supplier,
priceCents,
stock,
stockState,
sold,
rating,
ratings,
href,
})
)
}npx shadcn@latest add @vibra/quickview-03