Pre-order state
Pre-order CTA with release date, ship window, charge timing, due today and cancel terms right above it.
componentpreorderanyatcpreorder
$
npx shadcn@latest add @storekit/pdp-preorderpdp-preorder
Live and interactive. Switch the brand in the frame header to see the same source under another token file.
Installation
$
npx shadcn@latest add @storekit/pdp-preorder$
pnpm dlx shadcn@latest add @storekit/pdp-preorder$
yarn dlx shadcn@latest add @storekit/pdp-preorder$
bunx --bun shadcn@latest add @storekit/pdp-preorderSource
components/storekit/pdp/pdp-preorder.tsx
118 lines
"use client"
import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { CalendarClock, Check } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Badge } from "@/components/storekit/ui/badge"
import { Button } from "@/components/storekit/ui/button"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Pre-order state for the buy box. The CTA says Pre-order, and the four
things a shopper needs before paying for something that does not exist
yet sit right above it: release date, ship window, when the card is
charged, and how to cancel. Dates are ISO strings, formatted in UTC so the
server and client render the same text. Prices are integer cents. */
const EASE = [0.16, 1, 0.3, 1] as const
function fmt(iso: string) {
return new Date(`${iso}T12:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
}
export function PdpPreorder({
price = 5900,
quantityLimit = 2,
releaseDate = "2026-11-03",
shipFrom = "2026-11-10",
shipTo = "2026-11-17",
charge = "ship",
cancelTerms = "Cancel any time before it ships for a full refund.",
onPreorder,
className,
}: {
price?: number
/** Per-customer cap, shown as a fact, not a nudge. 0 hides it. */
quantityLimit?: number
/** ISO dates, YYYY-MM-DD. */
releaseDate?: string
shipFrom?: string
shipTo?: string
/** "now" charges at checkout, "ship" authorizes now and charges on dispatch. */
charge?: "now" | "ship"
cancelTerms?: string
onPreorder?: () => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("pdp", "pdp-preorder")
const [status, setStatus] = useState<"idle" | "loading" | "added">("idle")
const timers = useRef<number[]>([])
useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])
const dueToday = charge === "now" ? price : 0
const rows = [
{ k: "Releases", v: fmt(releaseDate) },
{ k: "Ships", v: `${fmt(shipFrom)} to ${fmt(shipTo)}` },
{ k: "Charged", v: charge === "now" ? "Today, at checkout" : "When your order ships" },
{ k: "Due today", v: money(dueToday) },
]
const reserve = () => {
if (status !== "idle") return
setStatus("loading")
sk.emit("convert", "preorder", price)
timers.current.push(
window.setTimeout(() => {
setStatus("added")
onPreorder?.()
timers.current.push(window.setTimeout(() => setStatus("idle"), 1200))
}, 700)
)
}
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-4", className)}>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="ink">Pre-order</Badge>
<span className="text-[13px] text-sk-muted">First batch, limited run</span>
</div>
<dl className="divide-y divide-sk-line rounded-sk-card border border-sk-line bg-sk-card">
{rows.map((r) => (
<div key={r.k} className="flex items-center justify-between gap-4 px-4 py-3 text-sm">
<dt className="text-sk-muted">{r.k}</dt>
<dd className="text-right font-medium tabular-nums text-sk-ink">{r.v}</dd>
</div>
))}
</dl>
<Button size="lg" className="h-12 w-full active:scale-[0.97]" loading={status === "loading"} onClick={reserve}>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={status === "added" ? "added" : "idle"}
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -6 }}
transition={{ duration: reduce ? 0 : 0.2, ease: EASE }}
className="inline-flex items-center gap-2 tabular-nums"
>
{status === "added" ? (
<>
<Check size={16} strokeWidth={2.5} /> Reserved
</>
) : (
<>Pre-order · {money(price)}</>
)}
</motion.span>
</AnimatePresence>
</Button>
<p className="flex items-start gap-2 text-[13px] leading-[1.5] text-sk-muted">
<CalendarClock size={15} aria-hidden className="mt-0.5 shrink-0 text-sk-ink" />
<span>
{cancelTerms}
{quantityLimit > 0 ? ` Limit ${quantityLimit} per order.` : ""}
</span>
</p>
</div>
)
}
Usage
Dates are ISO strings formatted in UTC, so server and client render the same text.
