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

Source

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.

Back in stock capture, StoreKit