Product/block
Buy box
The buy box: brand, name, a rating that links to the reviews, the price (the variant's own when it has one), stock-aware variant swatches, a quantity stepper, and an add-to-bag button that carries the live total — "Add to bag — $840". Adding is optimistic and rolls back with an inline message if your handler rejects. A missing size is asked for, not greyed out; a sold-out pick becomes "Notify me". Delivery is a date range from business days and a dispatch cut-off, never "ships soon". Labels, price and notices morph with Motion (a fade under reduced motion). Compound: the selected variant lives in one context, read through useProductBuyBox().
$ npx shadcn@latest add @ecomcn/product-buy-boxOlder shadcn CLI, or want the registry pinned?
Register the namespace yourself, once per project, then run the same add command. {name} is literal — the CLI fills it in.
$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.jsonUsage
On the product pageimport { ProductBuyBox, type BuyBoxProduct } from "@/components/ecomcn/product-buy-box"
// options and variants are variant-swatches' own; a variant may carry its own price.
const product: BuyBoxProduct = {
id: "vester",
brand: "Lindqvist",
name: "Vester Chelsea Boot",
price: 420,
rating: 4.6,
reviewCount: 312,
options,
variants, // [{ id: "tan-41", options: { Colour: "Tan", Size: "41" }, stock: 2, price?: 380 }]
}
<ProductBuyBox
product={product}
defaultValue={{ Colour: "Tan" }}
// Shows "Added" at once. If this rejects, the button rolls back and says so.
onAddToBag={async ({ variant, quantity }) => {
bag.increment(quantity) // your bag count, optimistic too
try {
await cart.add(variant!.id, quantity)
} catch (error) {
bag.decrement(quantity) // roll your count back…
throw error // …and rethrow, or the button thinks it worked
}
}}
onNotify={({ variant }) => openBackInStock(variant)}
delivery={{ minDays: 2, maxDays: 4, cutoffHour: 15, label: "Free delivery" }}
/>Composed — the gallery reads the same pickimport {
ProductBuyBox,
ProductBuyBoxActions,
ProductBuyBoxDelivery,
ProductBuyBoxHeader,
ProductBuyBoxPrice,
ProductBuyBoxVariants,
useProductBuyBox,
} from "@/components/ecomcn/product-buy-box"
<ProductBuyBox product={product} onAddToBag={add} className="grid md:grid-cols-2 gap-12">
<Photo />
<div className="flex flex-col gap-8">
<ProductBuyBoxHeader />
<ProductBuyBoxPrice />
<ProductBuyBoxVariants sizeGuide={<SizeGuideDialog … />} />
<ProductBuyBoxActions />
<ProductBuyBoxDelivery />
</div>
</ProductBuyBox>
function Photo() {
const { selection, variant } = useProductBuyBox()
return <img src={photos[selection.Colour ?? "Tan"]} alt="" />
}Props & context
Read straight out of the source at build time, so this cannot drift from the file you install.
ProductBuyBoxContextValueproduct: BuyBoxProduct
selection: VariantSelection
setSelection: (selection: VariantSelection) => void
/** The variant the picks point at; undefined until every option has one. */
variant: BuyBoxVariant | undefined
/** The first option still to pick — "Size" — or undefined. */
missing: string | undefined
soldOut: boolean
quantity: number
setQuantity: (quantity: number) => void
/** The most the shopper can add: the variant's stock, capped at `maxQuantity`. */
maxQuantity: number
unitPrice: number
compareAt?: number
/** Unit price × quantity. */
total: number
/** The price in the store's currency and the shopper's locale. */
formatMoney: (amount: number) => string
currency: string
/** The locale formatting uses: yours, or the visitor's once hydrated. */
locale?: string
addState: AddState
/** Adds, optimistically; or asks for the missing option; or notifies for a sold-out one. */
addToBag: () => void
/** What the line under the button says, or null. */
notice: { tone: "hint" | "error"; text: string } | null
canNotify: boolean
delivery?: BuyBoxDelivery
reviewsHref: string
loading: booleanProductBuyBoxPropsproduct: BuyBoxProduct
currency?: string
locale?: string
/** The picks, by option name: `{ Colour: "Tan", Size: "41" }`. */
value?: VariantSelection
defaultValue?: VariantSelection
onValueChange?: (value: VariantSelection) => void
quantity?: number
defaultQuantity?: number
onQuantityChange?: (quantity: number) => void
/** The most one line can hold, before stock. Defaults to 10. */
maxQuantity?: number
/**
* Add the line to your bag. The button shows "Added" at once; return a
* promise, and if it rejects the button rolls back and says so.
*/
onAddToBag?: (line: BuyBoxLine) => void | Promise<void>
/** A sold-out pick turns the button into "Notify me", which calls this. */
onNotify?: (line: BuyBoxLine) => void
delivery?: BuyBoxDelivery
/** Where the rating links to. Defaults to "#reviews". */
reviewsHref?: string
/** The product name's heading level. A product page's name is its h1. */
headingLevel?: "h1" | "h2"
loading?: booleanSource
components/ecomcn/product-buy-box.tsx"use client"
import * as React from "react"
import { Check, Minus, Plus, Star, Truck } from "lucide-react"
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PriceTag } from "@/components/ecomcn/price-tag"
import {
VariantSwatches,
type Variant,
type VariantOption,
type VariantSelection,
type VariantSwatchesProps,
} from "@/components/ecomcn/variant-swatches"
import { cn } from "@/lib/utils"
/*
* The buy box: name, price, rating, variants, quantity, and an add-to-bag
* button that carries the live total — "Add to bag — $840".
*
* <ProductBuyBox product={product} onAddToBag={addToBag} delivery={{ minDays: 2, maxDays: 4 }} />
*
* <ProductBuyBox product={product} onAddToBag={addToBag}> or composed
* <ProductBuyBoxHeader />
* <ProductBuyBoxPrice />
* <ProductBuyBoxVariants sizeGuide={<SizeGuideDialog … />} />
* <ProductBuyBoxActions />
* <ProductBuyBoxDelivery />
* </ProductBuyBox>
*
* Adding is optimistic: the button says "Added" the moment it is pressed,
* and if your `onAddToBag` rejects, it rolls back — the button returns, and a
* line under it says what went wrong. (The wishlist heart lives on the
* product card, where it has room; the buy box gives its width to the button.)
* Delivery is a date range worked out from business days and a cut-off
* ("Arrives Thu 9 – Mon 13 Oct"), never "ships soon". The selected variant
* lives in one context, so your own parts — a gallery, a sticky bar — read
* the same pick through `useProductBuyBox()`.
*/
export interface BuyBoxVariant extends Variant {
/** This variant's price, when it differs from the product's. */
price?: number
compareAt?: number
}
export interface BuyBoxProduct {
id: string
name: string
brand?: string
price: number
compareAt?: number
rating?: number
reviewCount?: number
options?: VariantOption[]
variants?: BuyBoxVariant[]
}
export interface BuyBoxLine {
product: BuyBoxProduct
/** Undefined for a product with no options. */
variant: BuyBoxVariant | undefined
quantity: number
}
export interface BuyBoxDelivery {
/** Business days from dispatch to the door, at the soonest and the latest. */
minDays: number
maxDays: number
/** Orders before this hour (0–23, the shopper's clock) dispatch the same day. */
cutoffHour?: number
/** Before the dates: "Free delivery", "Express". */
label?: React.ReactNode
/** Skip Saturdays and Sundays. Defaults to true. */
businessDaysOnly?: boolean
}
type AddState = "idle" | "adding" | "added" | "error"
/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */
const MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const
const subscribeNothing = () => () => {}
/**
* The locale to format with. Yours when you pass one. Otherwise the
* visitor's — but only once hydrated: the server cannot know it, and its HTML
* ("Sep 21, 2026") must match the first client render, not the browser's
* "21 Sept 2026". Until then, en-US.
*/
function useFormatLocale(locale?: string) {
const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)
return locale ?? (hydrated ? undefined : "en-US")
}
function useControllableState<T>(
value: T | undefined,
defaultValue: T,
onChange?: (next: T) => void
) {
const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
const controlled = value !== undefined
const set = React.useCallback(
(next: T) => {
if (!controlled) setUncontrolled(next)
onChange?.(next)
},
[controlled, onChange]
)
return [controlled ? value : uncontrolled, set] as const
}
/* ─── context ──────────────────────────────────────────────────────────── */
export interface ProductBuyBoxContextValue {
product: BuyBoxProduct
selection: VariantSelection
setSelection: (selection: VariantSelection) => void
/** The variant the picks point at; undefined until every option has one. */
variant: BuyBoxVariant | undefined
/** The first option still to pick — "Size" — or undefined. */
missing: string | undefined
soldOut: boolean
quantity: number
setQuantity: (quantity: number) => void
/** The most the shopper can add: the variant's stock, capped at `maxQuantity`. */
maxQuantity: number
unitPrice: number
compareAt?: number
/** Unit price × quantity. */
total: number
/** The price in the store's currency and the shopper's locale. */
formatMoney: (amount: number) => string
currency: string
/** The locale formatting uses: yours, or the visitor's once hydrated. */
locale?: string
addState: AddState
/** Adds, optimistically; or asks for the missing option; or notifies for a sold-out one. */
addToBag: () => void
/** What the line under the button says, or null. */
notice: { tone: "hint" | "error"; text: string } | null
canNotify: boolean
delivery?: BuyBoxDelivery
reviewsHref: string
loading: boolean
}
const ProductBuyBoxContext = React.createContext<ProductBuyBoxContextValue | null>(null)
export function useProductBuyBox() {
const context = React.useContext(ProductBuyBoxContext)
if (!context) {
throw new Error("useProductBuyBox must be used within <ProductBuyBox>.")
}
return context
}
/* ─── root ─────────────────────────────────────────────────────────────── */
export interface ProductBuyBoxProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
product: BuyBoxProduct
currency?: string
locale?: string
/** The picks, by option name: `{ Colour: "Tan", Size: "41" }`. */
value?: VariantSelection
defaultValue?: VariantSelection
onValueChange?: (value: VariantSelection) => void
quantity?: number
defaultQuantity?: number
onQuantityChange?: (quantity: number) => void
/** The most one line can hold, before stock. Defaults to 10. */
maxQuantity?: number
/**
* Add the line to your bag. The button shows "Added" at once; return a
* promise, and if it rejects the button rolls back and says so.
*/
onAddToBag?: (line: BuyBoxLine) => void | Promise<void>
/** A sold-out pick turns the button into "Notify me", which calls this. */
onNotify?: (line: BuyBoxLine) => void
delivery?: BuyBoxDelivery
/** Where the rating links to. Defaults to "#reviews". */
reviewsHref?: string
/** The product name's heading level. A product page's name is its h1. */
headingLevel?: "h1" | "h2"
loading?: boolean
}
export function ProductBuyBox({
product,
currency = "USD",
locale: localeProp,
value: valueProp,
defaultValue = {},
onValueChange,
quantity: quantityProp,
defaultQuantity = 1,
onQuantityChange,
maxQuantity: maxQuantityProp = 10,
onAddToBag,
onNotify,
delivery,
reviewsHref = "#reviews",
headingLevel = "h1",
loading = false,
className,
children,
...props
}: ProductBuyBoxProps) {
const locale = useFormatLocale(localeProp)
const root = React.useRef<HTMLDivElement>(null)
const [selection, setSelectionState] = useControllableState(valueProp, defaultValue, onValueChange)
const [quantity, setQuantityState] = useControllableState(quantityProp, defaultQuantity, onQuantityChange)
const [addState, setAddState] = React.useState<AddState>("idle")
const [notice, setNotice] = React.useState<ProductBuyBoxContextValue["notice"]>(null)
const resetTimer = React.useRef<number | undefined>(undefined)
React.useEffect(() => () => window.clearTimeout(resetTimer.current), [])
const options = React.useMemo(() => product.options ?? [], [product.options])
// A one-value option ("One size") counts as picked.
const resolved = React.useMemo(() => {
const filled: VariantSelection = {}
for (const option of options) {
if (option.values.length === 1) {
const only = option.values[0]
filled[option.name] = typeof only === "string" ? only : only.value
}
}
return { ...filled, ...selection }
}, [options, selection])
const missing = options.find((o) => resolved[o.name] === undefined)?.name
const variant = missing
? undefined
: product.variants?.find((v) => options.every((o) => v.options[o.name] === resolved[o.name]))
const soldOut =
!missing && options.length > 0 && (product.variants ? !variant || variant.stock === 0 : false)
const stockCap = variant?.stock && variant.stock > 0 ? variant.stock : Infinity
const maxQuantity = Math.max(1, Math.min(maxQuantityProp, stockCap))
const clampedQuantity = Math.min(Math.max(1, quantity), maxQuantity)
const unitPrice = variant?.price ?? product.price
const compareAt = variant?.price !== undefined ? variant.compareAt : (variant?.compareAt ?? product.compareAt)
const formatMoney = React.useMemo(() => {
const whole = new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 })
const cents = new Intl.NumberFormat(locale, { style: "currency", currency })
return (amount: number) => (Number.isInteger(amount) ? whole : cents).format(amount)
}, [locale, currency])
const setSelection = React.useCallback(
(next: VariantSelection) => {
setNotice(null)
setAddState((state) => (state === "error" ? "idle" : state))
setSelectionState(next)
},
[setSelectionState]
)
const addToBag = React.useCallback(() => {
if (addState === "adding") return
if (missing) {
setNotice({ tone: "hint", text: `Choose a ${missing.toLowerCase()} first.` })
// Take the shopper to the option they skipped.
root.current
?.querySelector<HTMLElement>(
`[data-slot="variant-swatches-option"][data-option="${CSS.escape(missing)}"] [role="radio"][tabindex="0"]`
)
?.focus()
return
}
const line: BuyBoxLine = { product, variant, quantity: clampedQuantity }
if (soldOut) {
onNotify?.(line)
return
}
window.clearTimeout(resetTimer.current)
setNotice(null)
// Optimistic: "Added" now, settled or rolled back when the promise is.
setAddState("adding")
Promise.resolve()
.then(() => onAddToBag?.(line))
.then(
() => {
setAddState("added")
resetTimer.current = window.setTimeout(() => setAddState("idle"), 2400)
},
() => {
setAddState("error")
setNotice({ tone: "error", text: "That didn't reach your bag. Try again." })
}
)
}, [addState, missing, product, variant, clampedQuantity, soldOut, onNotify, onAddToBag])
const context = React.useMemo<ProductBuyBoxContextValue>(
() => ({
product,
selection,
setSelection,
variant,
missing,
soldOut,
quantity: clampedQuantity,
setQuantity: (n) => setQuantityState(Math.min(Math.max(1, Math.round(n) || 1), maxQuantity)),
maxQuantity,
unitPrice,
compareAt,
total: unitPrice * clampedQuantity,
formatMoney,
currency,
locale,
addState,
addToBag,
notice,
canNotify: Boolean(onNotify),
delivery,
reviewsHref,
loading,
}),
[product, selection, setSelection, variant, missing, soldOut, clampedQuantity, setQuantityState, maxQuantity, unitPrice, compareAt, formatMoney, currency, locale, addState, addToBag, notice, onNotify, delivery, reviewsHref, loading]
)
return (
<ProductBuyBoxContext.Provider value={context}>
<MotionConfig reducedMotion="user">
<div
ref={root}
data-slot="product-buy-box"
aria-busy={loading || undefined}
className={cn("flex flex-col gap-8", className)}
{...props}
>
{children ?? (
<>
<div className="flex flex-col gap-4">
<ProductBuyBoxHeader headingLevel={headingLevel} />
<ProductBuyBoxPrice />
</div>
<ProductBuyBoxVariants />
<ProductBuyBoxActions />
<ProductBuyBoxDelivery />
</>
)}
</div>
</MotionConfig>
</ProductBuyBoxContext.Provider>
)
}
/* ─── parts ────────────────────────────────────────────────────────────── */
/** Brand, name, and the rating — a link down to the reviews. */
export function ProductBuyBoxHeader({
headingLevel = "h1",
className,
...props
}: { headingLevel?: "h1" | "h2" } & React.HTMLAttributes<HTMLDivElement>) {
const { product, reviewsHref, locale, loading } = useProductBuyBox()
const Heading = headingLevel
if (loading) {
return (
<div aria-hidden className={cn("flex flex-col gap-3", className)}>
<span className="h-3 w-20 animate-pulse bg-muted" />
<span className="h-9 w-3/4 animate-pulse bg-muted" />
<span className="h-3 w-28 animate-pulse bg-muted" />
</div>
)
}
const score = product.rating
return (
<div data-slot="product-buy-box-header" className={cn("flex flex-col gap-3", className)} {...props}>
{product.brand ? (
<p className="text-xs font-medium tracking-[0.14em] text-muted-foreground uppercase">{product.brand}</p>
) : null}
<Heading className="text-4xl leading-[1.05] tracking-tight text-balance sm:text-5xl">{product.name}</Heading>
{score !== undefined ? (
<a
href={reviewsHref}
className="flex w-fit items-center gap-2 text-sm text-muted-foreground underline decoration-border underline-offset-4 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<span aria-hidden className="relative inline-flex">
<span className="flex gap-0.5 text-border">
{Array.from({ length: 5 }, (_, i) => (
<Star key={i} className="size-3.5 fill-current" />
))}
</span>
<span className="absolute inset-y-0 left-0 flex gap-0.5 overflow-hidden text-foreground" style={{ width: `${(score / 5) * 100}%` }}>
{Array.from({ length: 5 }, (_, i) => (
<Star key={i} className="size-3.5 shrink-0 fill-current" />
))}
</span>
</span>
<span className="tabular-nums">
{new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(score)}
{product.reviewCount !== undefined ? (
<> ({new Intl.NumberFormat(locale).format(product.reviewCount)} reviews)</>
) : null}
</span>
<span className="sr-only">, rated out of 5. Read the reviews</span>
</a>
) : null}
</div>
)
}
/** The price — the variant's, when it has its own — fading over when it changes. */
export function ProductBuyBoxPrice({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { unitPrice, compareAt, currency, locale, loading } = useProductBuyBox()
const reduce = useReducedMotion()
if (loading) return <span aria-hidden className={cn("block h-8 w-24 animate-pulse bg-muted", className)} />
return (
<div data-slot="product-buy-box-price" className={cn("relative", className)} {...props}>
<AnimatePresence initial={false} mode="popLayout">
<motion.div
key={`${unitPrice}-${compareAt ?? ""}`}
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -6 }}
transition={reduce ? { duration: 0.15 } : MORPH}
>
<PriceTag price={unitPrice} compareAt={compareAt} currency={currency} locale={locale} />
</motion.div>
</AnimatePresence>
</div>
)
}
/**
* The product's options, on variant-swatches: stock-aware swatches and size
* grid, controlled by the buy box. Any other VariantSwatches prop passes
* through — `sizeGuide`, `messages`, `lowStockThreshold`.
*/
export function ProductBuyBoxVariants(
props: Omit<VariantSwatchesProps, "options" | "variants" | "value" | "defaultValue" | "onValueChange">
) {
const { product, selection, setSelection, loading } = useProductBuyBox()
if (!product.options?.length) return null
return (
<VariantSwatches
options={product.options}
variants={product.variants}
value={selection}
onValueChange={setSelection}
loading={loading}
{...props}
/>
)
}
/** Quantity and add to bag on one line; the notice under them. */
export function ProductBuyBoxActions({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div data-slot="product-buy-box-actions" className={cn("flex flex-col", className)} {...props}>
<div className="flex gap-2">
{children ?? (
<>
<ProductBuyBoxQuantity />
<ProductBuyBoxAddToBag className="min-w-0 flex-1" />
</>
)}
</div>
<ProductBuyBoxNotice />
</div>
)
}
/** − [ 2 ] + as one ruled block. Typing works too; it settles on blur or Enter. */
export function ProductBuyBoxQuantity({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { quantity, setQuantity, maxQuantity, loading, soldOut } = useProductBuyBox()
const [draft, setDraft] = React.useState<string | null>(null)
const commit = () => {
if (draft !== null) setQuantity(Number.parseInt(draft, 10))
setDraft(null)
}
const off = loading || soldOut
return (
<div
role="group"
aria-label="Quantity"
data-slot="product-buy-box-quantity"
className={cn("flex h-12 shrink-0 items-stretch border", className)}
{...props}
>
<Button
type="button"
variant="ghost"
aria-label="One fewer"
disabled={off || quantity <= 1}
onClick={() => setQuantity(quantity - 1)}
className="h-full w-10 rounded-none"
>
<Minus aria-hidden />
</Button>
<Input
aria-label="Quantity"
inputMode="numeric"
pattern="[0-9]*"
disabled={off}
value={draft ?? String(quantity)}
onChange={(event) => setDraft(event.target.value.replace(/\D/g, "").slice(0, 3))}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") commit()
}}
className="h-full w-10 rounded-none border-0 px-0 text-center tabular-nums shadow-none focus-visible:ring-2 focus-visible:ring-inset"
/>
<Button
type="button"
variant="ghost"
aria-label="One more"
disabled={off || quantity >= maxQuantity}
onClick={() => setQuantity(quantity + 1)}
className="h-full w-10 rounded-none"
>
<Plus aria-hidden />
</Button>
</div>
)
}
/**
* The button carries the total: "Add to bag — $840". It asks for a missing
* size rather than greying out, becomes "Notify me" when the pick is sold
* out, and morphs its label between states with Motion.
*/
// className only: the Button is your primitive, and its other props differ
// between Radix and Base UI.
export function ProductBuyBoxAddToBag({ className }: { className?: string }) {
const { total, formatMoney, missing, soldOut, canNotify, addState, addToBag, loading } = useProductBuyBox()
const reduce = useReducedMotion()
const label =
addState === "adding" || addState === "added"
? { key: "added", text: "Added to bag", icon: true }
: missing
? { key: "missing", text: `Select a ${missing.toLowerCase()}`, icon: false }
: soldOut
? { key: "sold-out", text: canNotify ? "Notify me" : "Sold out", icon: false }
: { key: `add-${total}`, text: `Add to bag — ${formatMoney(total)}`, icon: false }
return (
<Button
type="button"
size="lg"
variant={soldOut ? "outline" : "default"}
data-state={addState}
// Not disabled while a size is missing — pressing it says which — but
// a sold-out pick with nobody to notify can only be explained.
disabled={loading || (soldOut && !canNotify)}
onClick={addToBag}
className={cn(
"relative h-auto min-h-12 overflow-hidden rounded-none py-2 text-xs font-medium tracking-[0.14em] whitespace-normal uppercase shadow-none",
className
)}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={label.key}
initial={{ opacity: 0, y: reduce ? 0 : 14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -14 }}
transition={reduce ? { duration: 0.15 } : MORPH}
// Wraps to a second line before it would ever overflow.
className="flex min-w-0 items-center justify-center gap-2 text-center leading-snug text-balance tabular-nums"
>
{label.icon ? <Check aria-hidden className="size-4" /> : null}
{label.text}
</motion.span>
</AnimatePresence>
</Button>
)
}
/**
* The line under the button: which option to pick, or what failed. A status
* region that is always in the page, so each new line is read out; the line
* grows open to its height.
*/
export function ProductBuyBoxNotice({ className, ...props }: Omit<React.HTMLAttributes<HTMLDivElement>, "children">) {
const { notice } = useProductBuyBox()
const reduce = useReducedMotion()
return (
<div
role="status"
data-slot="product-buy-box-notice"
className={className}
{...props}
>
<AnimatePresence initial={false}>
{notice ? (
<motion.div
key={notice.text}
initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={reduce ? { duration: 0.15 } : MORPH}
className="overflow-hidden"
>
<p
className={cn(
"flex items-baseline gap-2.5 pt-3 text-sm",
notice.tone === "error" ? "text-[var(--sale,var(--destructive))]" : "text-foreground"
)}
>
<span aria-hidden className="h-px w-4 shrink-0 translate-y-[-0.3em] bg-current" />
{notice.text}
</p>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
/* ─── delivery ─────────────────────────────────────────────────────────── */
const subscribeMinute = (onChange: () => void) => {
const timer = window.setInterval(onChange, 30_000)
return () => window.clearInterval(timer)
}
const currentMinute = () => Math.floor(Date.now() / 60_000)
const serverMinute = () => null
/** `days` business (or calendar) days after `from`. */
function addDays(from: Date, days: number, businessOnly: boolean) {
const date = new Date(from)
let left = days
while (left > 0) {
date.setDate(date.getDate() + 1)
const day = date.getDay()
if (!businessOnly || (day !== 0 && day !== 6)) left -= 1
}
return date
}
/**
* When it arrives, as a date range — "Arrives Thu 9 – Mon 13 Oct" — with a
* countdown to the dispatch cut-off. Worked out on the shopper's clock after
* hydration (the server cannot know their time zone), with a placeholder of
* the same height until then.
*/
export function ProductBuyBoxDelivery({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { delivery, locale, soldOut } = useProductBuyBox()
const minute = React.useSyncExternalStore(subscribeMinute, currentMinute, serverMinute)
if (!delivery || soldOut) return null
let text: React.ReactNode = null
let countdown: string | null = null
if (minute !== null) {
const now = new Date(minute * 60_000)
const businessOnly = delivery.businessDaysOnly ?? true
const cutoff = delivery.cutoffHour
// Past the cut-off, or on a day nothing dispatches: it goes out next working day.
let dispatch = new Date(now)
const closed = businessOnly && (now.getDay() === 0 || now.getDay() === 6)
if (closed || (cutoff !== undefined && now.getHours() >= cutoff)) {
dispatch = addDays(now, 1, businessOnly)
} else if (cutoff !== undefined) {
const left = cutoff * 60 - (now.getHours() * 60 + now.getMinutes())
countdown = `${Math.floor(left / 60)}h ${String(left % 60).padStart(2, "0")}m`
}
const first = addDays(dispatch, delivery.minDays, businessOnly)
const last = addDays(dispatch, delivery.maxDays, businessOnly)
const format = new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short" })
text = delivery.minDays === delivery.maxDays ? format.format(first) : format.formatRange(first, last)
}
return (
<div
data-slot="product-buy-box-delivery"
className={cn("flex items-start gap-3 border-t pt-5 text-sm", className)}
{...props}
>
<Truck aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{text === null ? (
<span aria-hidden className="flex flex-col gap-2 py-0.5">
<span className="h-3.5 w-48 animate-pulse bg-muted" />
<span className="h-3 w-36 animate-pulse bg-muted" />
</span>
) : (
<p className="flex flex-col gap-1">
<span>
{delivery.label ? <>{delivery.label} · </> : null}
Arrives <span className="font-medium tabular-nums">{text}</span>
</span>
{countdown ? (
<span className="text-muted-foreground tabular-nums">
Order within {countdown} for dispatch today
</span>
) : null}
</p>
)}
</div>
)
}