Subscription-only buy box

No one-time option: cadence, next charge date, how to cancel and the guarantee all sit above the button.

blocksubscription-onlysupplementsanysubscribeatc
$npx shadcn@latest add @storekit/upsell-subscription-only
upsell-subscription-only

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/upsell-subscription-only
$pnpm dlx shadcn@latest add @storekit/upsell-subscription-only
$yarn dlx shadcn@latest add @storekit/upsell-subscription-only
$bunx --bun shadcn@latest add @storekit/upsell-subscription-only

Source

components/storekit/upsell/upsell-subscription-only.tsx
177 lines
"use client"

import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { CalendarClock, Check, RotateCcw, ShieldCheck, XCircle } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { Price } from "@/components/storekit/ui/price"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* A subscription-only buy box. There is no one-time option, so the terms
   do the persuading: price per delivery, cadence, when the next charge
   lands, how to cancel and the guarantee all sit above the button. The next
   charge date is computed after mount so server and client markup match. */

const EASE = [0.23, 1, 0.32, 1] as const
const RING = "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"

function arrows(e: React.KeyboardEvent, n: number, current: number, pick: (i: number) => void) {
  if (e.key === "ArrowRight" || e.key === "ArrowDown") {
    e.preventDefault()
    pick((current + 1) % n)
  } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
    e.preventDefault()
    pick((current - 1 + n) % n)
  }
}

export function UpsellSubscriptionOnly({
  title = "Daily Foundation",
  price = 4900,
  servings = 30,
  cadences = [30, 60, 90],
  guaranteeDays = 30,
  cancelNote = "Cancel online in two clicks from your account. No calls, no fees.",
  onSubscribe,
  className,
}: {
  title?: string
  /** Price per delivery in integer cents. */
  price?: number
  /** Servings per delivery, used for the per-serving line. */
  servings?: number
  cadences?: number[]
  guaranteeDays?: number
  cancelNote?: string
  onSubscribe?: (v: { everyDays: number; price: number }) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("upsell", "upsell-subscription-only")
  const [every, setEvery] = useState(cadences[0] ?? 30)
  const [nextDate, setNextDate] = useState<string | null>(null)
  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)), [])

  useEffect(() => {
    const d = new Date()
    d.setDate(d.getDate() + every)
    setNextDate(d.toLocaleDateString(undefined, { month: "short", day: "numeric" }))
  }, [every])

  const choose = (i: number, focus = false) => {
    if (cadences[i] !== every) sk.emit("interact", `every_${cadences[i]}d`, undefined)
    setEvery(cadences[i])
    if (focus) refs.current[i]?.focus()
  }

  const subscribe = () => {
    if (status !== "idle") return
    setStatus("loading")
    sk.emit("convert", "subscribe", price)
    timers.current.push(
      window.setTimeout(() => {
        setStatus("added")
        onSubscribe?.({ everyDays: every, price })
        timers.current.push(window.setTimeout(() => setStatus("idle"), 1200))
      }, 700)
    )
  }

  const terms = [
    { icon: CalendarClock, label: "Next charge", value: nextDate ? `${nextDate}, then every ${every} days` : `In ${every} days, then every ${every} days` },
    { icon: XCircle, label: "Cancel", value: cancelNote },
    { icon: RotateCcw, label: "Change", value: "Skip a delivery, swap cadence or pause anytime." },
  ]

  return (
    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-5 rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6", className)}>
      <div className="flex flex-col gap-2">
        <span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Subscription</span>
        <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
          <span className="font-sk-display text-xl font-semibold leading-[1.1] tracking-[-0.02em] text-sk-ink">{title}</span>
          <Price amount={price} suffix="/ delivery" per={servings ? { amount: Math.round(price / servings), unit: "serving" } : undefined} />
        </div>
      </div>

      <div className="flex flex-col gap-2">
        <span className="text-sm font-medium text-sk-ink">Deliver every</span>
        <div role="radiogroup" aria-label="Delivery cadence" className="grid grid-cols-3 gap-2">
          {cadences.map((c, i) => {
            const on = c === every
            return (
              <button
                key={c}
                ref={(el) => {
                  refs.current[i] = el
                }}
                type="button"
                role="radio"
                aria-checked={on}
                tabIndex={on ? 0 : -1}
                onClick={() => choose(i)}
                onKeyDown={(e) => arrows(e, cadences.length, cadences.indexOf(every), (n) => choose(n, true))}
                className={cn(
                  "h-11 rounded-sk-btn border text-sm font-medium tabular-nums transition-[border-color,background-color,color,transform] duration-200 active:scale-[0.97]",
                  on ? "border-sk-accent bg-sk-accent/[0.08] text-sk-ink" : "border-sk-line text-sk-ink hover:border-sk-ink/30",
                  RING
                )}
              >
                {c} days
              </button>
            )
          })}
        </div>
      </div>

      <dl className="flex flex-col divide-y divide-sk-line/70 rounded-sk-card bg-sk-surface px-4">
        {terms.map(({ icon: Icon, label, value }) => (
          <div key={label} className="flex items-start gap-3 py-3">
            <Icon size={16} className="mt-0.5 shrink-0 text-sk-muted" aria-hidden />
            <div className="flex min-w-0 flex-col gap-0.5 sm:flex-row sm:gap-3">
              <dt className="w-24 shrink-0 text-sm font-medium text-sk-ink">{label}</dt>
              <dd className="text-sm leading-[1.5] text-sk-muted tabular-nums">{value}</dd>
            </div>
          </div>
        ))}
      </dl>

      <div className="flex items-start gap-3 rounded-sk-card border border-sk-savings/30 bg-sk-savings/[0.06] p-4">
        <ShieldCheck size={18} className="mt-0.5 shrink-0 text-sk-savings" aria-hidden />
        <p className="text-sm leading-[1.5] text-sk-ink">
          <span className="font-medium">{guaranteeDays}-day guarantee.</span> <span className="text-sk-muted">If your first delivery is not right for you, we refund it in full. Keep the product.</span>
        </p>
      </div>

      <div className="flex flex-col gap-2">
        <Button size="lg" className="w-full active:scale-[0.97]" loading={status === "loading"} onClick={subscribe}>
          <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} /> Subscription added
                </>
              ) : (
                <>Start subscription · {money(price)}</>
              )}
            </motion.span>
          </AnimatePresence>
        </Button>
        <p className="text-center text-[12px] text-sk-muted tabular-nums">
          Charged {money(price)} today and every {every} days until you cancel.
        </p>
      </div>
    </div>
  )
}

Usage

Import from "@/components/storekit/upsell/upsell-subscription-only" and pass your product data. The demo above exercises the full prop surface with sample values.

Subscription-only buy box, StoreKit