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-preorder
pdp-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-preorder

Source

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.

Pre-order state, StoreKit