Back in stock capture
For a sold out variant the add to cart becomes an email or SMS capture tied to that variant, with a confirmation state.
componentrestockanyvariantsatccapture
$
npx shadcn@latest add @storekit/pdp-back-in-stockpdp-back-in-stock
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-back-in-stock$
pnpm dlx shadcn@latest add @storekit/pdp-back-in-stock$
yarn dlx shadcn@latest add @storekit/pdp-back-in-stock$
bunx --bun shadcn@latest add @storekit/pdp-back-in-stockSource
components/storekit/pdp/pdp-back-in-stock.tsx
264 lines
"use client"
import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { BellRing, Check } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Back-in-stock capture. When the chosen variant is sold out, the add to
cart is replaced, in place, by an email or SMS capture tied to that exact
variant. Sold out pills stay selectable so the shopper can ask for the one
they actually want. Submitting calls `onSubscribe` with the variant id,
channel and contact, then shows a confirmation that names the variant. */
type Variant = { id: string; label: string; price: number; soldOut?: boolean; restock?: string }
type Channel = "email" | "sms"
const RING = "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
const EASE = [0.16, 1, 0.3, 1] as const
const DEFAULT_VARIANTS: Variant[] = [
{ id: "30", label: "30 servings", price: 4900 },
{ id: "60", label: "60 servings", price: 8900 },
{ id: "bundle", label: "3-pouch bundle", price: 12900, soldOut: true, restock: "Expected early November" },
]
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
export function PdpBackInStock({
variants = DEFAULT_VARIANTS,
defaultValue,
channels = ["email", "sms"],
onSubscribe,
onAddToCart,
className,
}: {
variants?: Variant[]
defaultValue?: string
channels?: Channel[]
onSubscribe?: (req: { variantId: string; channel: Channel; contact: string }) => void
onAddToCart?: (variantId: string) => void
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const sk = useSkBlock("pdp", "pdp-back-in-stock")
const [value, setValue] = useState(defaultValue ?? variants.find((v) => v.soldOut)?.id ?? variants[0]?.id)
const [channel, setChannel] = useState<Channel>(channels[0] ?? "email")
const [contact, setContact] = useState("")
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState<Record<string, Channel>>({})
const [status, setStatus] = useState<"idle" | "loading" | "added">("idle")
const refs = useRef<(HTMLButtonElement | null)[]>([])
const timers = useRef<number[]>([])
useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])
const vi = Math.max(0, variants.findIndex((v) => v.id === value))
const v = variants[vi]
const t = { duration: reduce ? 0 : 0.3, ease: EASE }
const select = (i: number, focus = false) => {
const next = variants[i]
if (!next) return
if (next.id !== value) sk.emit("interact", "variant", undefined)
setValue(next.id)
setError(null)
if (focus) refs.current[i]?.focus()
}
const onKeyDown = (e: React.KeyboardEvent) => {
const n = variants.length
let next = -1
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (vi + 1) % n
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (vi - 1 + n) % n
if (next < 0) return
e.preventDefault()
select(next, true)
}
const submit = (e: React.FormEvent) => {
e.preventDefault()
const c = contact.trim()
const ok = channel === "email" ? EMAIL.test(c) : c.replace(/\D/g, "").length >= 10
if (!ok) return setError(channel === "email" ? "Enter a valid email address." : "Enter a 10 digit mobile number.")
setError(null)
sk.emit("convert", "back_in_stock", undefined)
onSubscribe?.({ variantId: v.id, channel, contact: c })
setDone((d) => ({ ...d, [v.id]: channel }))
setContact("")
}
const add = () => {
if (status !== "idle") return
setStatus("loading")
sk.emit("convert", "add_to_cart", v.price)
timers.current.push(
window.setTimeout(() => {
setStatus("added")
onAddToCart?.(v.id)
timers.current.push(window.setTimeout(() => setStatus("idle"), 1200))
}, 700)
)
}
const mode = !v.soldOut ? "atc" : done[v.id] ? "done" : "form"
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-4", className)}>
<fieldset className="flex flex-col gap-2.5">
<legend className="mb-2.5 flex w-full items-center justify-between text-sm">
<span className="font-medium text-sk-ink">Size</span>
<span className="text-sk-muted">{v.soldOut ? `${v.label}, sold out` : v.label}</span>
</legend>
<div role="radiogroup" aria-label="Size" className="flex flex-wrap gap-2">
{variants.map((x, i) => {
const sel = x.id === value
return (
<button
key={x.id}
ref={(el) => {
refs.current[i] = el
}}
type="button"
role="radio"
aria-checked={sel}
aria-label={x.soldOut ? `${x.label}, sold out` : x.label}
tabIndex={sel ? 0 : -1}
onClick={() => select(i)}
onKeyDown={onKeyDown}
className={cn(
"h-11 min-w-[72px] rounded-sk-btn border px-4 text-sm font-medium tabular-nums transition-[border-color,background-color,color,transform] duration-200 ease-sk-out active:scale-[0.97]",
sel ? "border-sk-accent bg-sk-accent/[0.08] text-sk-ink" : "border-sk-line text-sk-ink hover:border-sk-ink/40",
x.soldOut && "border-dashed text-sk-muted line-through decoration-sk-muted/70",
RING
)}
>
{x.label}
</button>
)
})}
</div>
</fieldset>
<div className="relative" aria-live="polite">
<AnimatePresence mode="wait" initial={false}>
{mode === "atc" ? (
<motion.div key="atc" initial={{ opacity: 0, y: reduce ? 0 : 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : -8 }} transition={t}>
<Button size="lg" className="h-12 w-full active:scale-[0.97]" loading={status === "loading"} onClick={add}>
{status === "added" ? (
<>
<Check size={16} strokeWidth={2.5} /> Added to cart
</>
) : (
<span className="tabular-nums">Add to cart · {money(v.price)}</span>
)}
</Button>
</motion.div>
) : mode === "done" ? (
<motion.div
key={`done-${v.id}`}
initial={{ opacity: 0, y: reduce ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -8 }}
transition={t}
className="flex items-start gap-3 rounded-sk-card border border-sk-line bg-sk-card p-4"
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-sk-savings/[0.12] text-sk-savings">
<Check size={16} strokeWidth={2.5} />
</span>
<span className="flex flex-col gap-0.5 text-sm">
<span className="font-medium text-sk-ink">You are on the list for {v.label}</span>
<span className="text-sk-muted">
One {done[v.id] === "sms" ? "text" : "email"} when it is back. Nothing else.
</span>
</span>
</motion.div>
) : (
<motion.form
key={`form-${v.id}`}
onSubmit={submit}
noValidate
initial={{ opacity: 0, y: reduce ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -8 }}
transition={t}
className="flex flex-col gap-3 rounded-sk-card border border-sk-line bg-sk-card p-4 sm:p-5"
>
<div className="flex items-start gap-3">
<BellRing size={18} aria-hidden className="mt-0.5 shrink-0 text-sk-ink" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium text-sk-ink">{v.label} is sold out</span>
<span className="text-[13px] text-sk-muted">{v.restock ? `${v.restock}. ` : ""}Get one message when it is back.</span>
</div>
</div>
{channels.length > 1 ? (
<div role="radiogroup" aria-label="Notify by" className="grid grid-cols-2 gap-1 rounded-sk-btn bg-sk-surface p-1">
{channels.map((c) => {
const on = c === channel
return (
<button
key={c}
type="button"
role="radio"
aria-checked={on}
onClick={() => {
setChannel(c)
setError(null)
setContact("")
}}
className={cn(
"relative h-10 rounded-[calc(var(--sk-radius-btn)*0.8)] text-[13px] font-medium transition-colors duration-200",
on ? "text-sk-ink" : "text-sk-muted hover:text-sk-ink",
RING
)}
>
{on ? (
<motion.span layoutId={`${uid}-ch`} transition={t} className="absolute inset-0 rounded-[calc(var(--sk-radius-btn)*0.8)] border border-sk-line bg-sk-card" />
) : null}
<span className="relative">{c === "email" ? "Email" : "Text message"}</span>
</button>
)
})}
</div>
) : null}
<div className="flex flex-col gap-2 sm:flex-row">
<label htmlFor={`${uid}-contact`} className="sr-only">
{channel === "email" ? "Email address" : "Mobile number"}
</label>
<input
id={`${uid}-contact`}
type={channel === "email" ? "email" : "tel"}
inputMode={channel === "email" ? "email" : "tel"}
autoComplete={channel === "email" ? "email" : "tel"}
placeholder={channel === "email" ? "you@example.com" : "(555) 123 4567"}
value={contact}
onChange={(e) => setContact(e.target.value)}
aria-invalid={!!error || undefined}
aria-describedby={error ? `${uid}-err` : undefined}
className={cn(
"h-11 w-full min-w-0 rounded-sk-btn border bg-sk-paper sm:flex-1 px-3.5 text-base text-sk-ink outline-none sm:text-sm transition-[border-color,box-shadow] duration-200 placeholder:text-sk-muted/70",
"focus:border-sk-accent focus:ring-[3px] focus:ring-sk-accent/15",
error ? "border-sk-sale" : "border-sk-line"
)}
/>
<Button type="submit" className="h-11 w-full shrink-0 active:scale-[0.97] sm:w-auto">
Notify me
</Button>
</div>
{error ? (
<span id={`${uid}-err`} className="text-xs text-sk-sale">
{error}
</span>
) : (
<span className="text-xs text-sk-muted">Only about {v.label}. No marketing, no list.</span>
)}
</motion.form>
)}
</AnimatePresence>
</div>
</div>
)
}
Usage
Wire `onSubscribe` to your Klaviyo or Shopify back in stock list with the variant id.
