Prepaid duration cards

30, 60 and 90 day cards stating total today, the monthly equivalent and the saving.

blockprepaidsupplementsbeverageprepaidatc
$npx shadcn@latest add @storekit/upsell-prepaid-cards
upsell-prepaid-cards

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

Source

components/storekit/upsell/upsell-prepaid-cards.tsx
170 lines
"use client"

import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { 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"

/* Prepaid duration cards. Each plan states three numbers plainly: the total
   charged today, what that works out to per month, and the saving against
   paying month to month. Prices are integer cents. */

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"

type Plan = { id: string; days: number; discount: number; label?: string }

const DEFAULT_PLANS: Plan[] = [
  { id: "30", days: 30, discount: 0 },
  { id: "60", days: 60, discount: 0.08 },
  { id: "90", days: 90, discount: 0.15, label: "Lowest per month" },
]

export function UpsellPrepaidCards({
  monthlyPrice = 5900,
  plans = DEFAULT_PLANS,
  defaultPlan = "90",
  onAddToCart,
  className,
}: {
  /** Price of one 30-day supply in integer cents. */
  monthlyPrice?: number
  plans?: Plan[]
  defaultPlan?: string
  onAddToCart?: (v: { planId: string; days: number; total: number }) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("upsell", "upsell-prepaid-cards")
  const [sel, setSel] = useState(Math.max(0, plans.findIndex((p) => p.id === defaultPlan)))
  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 calc = (p: Plan) => {
    const months = p.days / 30
    const full = Math.round(monthlyPrice * months)
    const total = Math.round(full * (1 - p.discount))
    return { months, total, perMonth: Math.round(total / months), saved: full - total }
  }
  const plan = plans[sel] ?? plans[0]
  const cur = calc(plan)

  const choose = (i: number, focus = false) => {
    if (i !== sel) sk.emit("interact", `prepaid_${plans[i].days}d`, calc(plans[i]).total)
    setSel(i)
    if (focus) refs.current[i]?.focus()
  }

  const add = () => {
    if (status !== "idle") return
    setStatus("loading")
    sk.emit("convert", "add_to_cart", cur.total)
    timers.current.push(
      window.setTimeout(() => {
        setStatus("added")
        onAddToCart?.({ planId: plan.id, days: plan.days, total: cur.total })
        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 role="radiogroup" aria-label="Prepaid plan" className="grid gap-2.5 sm:grid-cols-3">
        {plans.map((p, i) => {
          const on = i === sel
          const c = calc(p)
          return (
            <button
              key={p.id}
              ref={(el) => {
                refs.current[i] = el
              }}
              type="button"
              role="radio"
              aria-checked={on}
              tabIndex={on ? 0 : -1}
              onClick={() => choose(i)}
              onKeyDown={(e) => {
                const f = e.key === "ArrowRight" || e.key === "ArrowDown"
                const b = e.key === "ArrowLeft" || e.key === "ArrowUp"
                if (!f && !b) return
                e.preventDefault()
                choose((sel + (f ? 1 : -1) + plans.length) % plans.length, true)
              }}
              className={cn(
                "relative flex flex-col gap-3 rounded-sk-card border p-4 text-left transition-[border-color,background-color,transform] duration-200 active:scale-[0.97]",
                on ? "border-sk-accent bg-sk-accent/[0.05]" : "border-sk-line bg-sk-card hover:border-sk-ink/30",
                RING
              )}
            >
              <span className="flex items-center justify-between gap-2">
                <span className="font-sk-display text-base font-semibold tracking-[-0.02em] text-sk-ink tabular-nums">{p.days}-day supply</span>
                <span className={cn("grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full border", on ? "border-sk-accent" : "border-sk-line")}>
                  <motion.span initial={false} animate={{ scale: on ? 1 : 0 }} transition={{ duration: reduce ? 0 : 0.2, ease: EASE }} className="h-2.5 w-2.5 rounded-full bg-sk-accent" />
                </span>
              </span>
              <span className="flex flex-col gap-0.5 tabular-nums">
                <span className="text-sm text-sk-muted">
                  <span className="font-semibold text-sk-price">{money(c.total)}</span> today
                </span>
                <span className="text-[13px] text-sk-muted">{money(c.perMonth)} / month</span>
              </span>
              <span className="flex flex-wrap items-center gap-1.5">
                {c.saved > 0 ? (
                  <span className="rounded-sk-chip bg-sk-savings/[0.12] px-2 py-0.5 text-[12px] font-semibold text-sk-savings tabular-nums">Save {money(c.saved)}</span>
                ) : (
                  <span className="rounded-sk-chip bg-sk-surface px-2 py-0.5 text-[12px] font-medium text-sk-muted">Standard price</span>
                )}
                {p.label ? <span className="text-[12px] text-sk-muted">{p.label}</span> : null}
              </span>
            </button>
          )
        })}
      </div>

      <div className="flex flex-col gap-3 rounded-sk-card bg-sk-surface p-4">
        <dl className="flex flex-col gap-1.5 text-sm tabular-nums">
          <div className="flex justify-between gap-4">
            <dt className="text-sk-muted">Total today</dt>
            <dd className="font-semibold text-sk-ink">{money(cur.total)}</dd>
          </div>
          <div className="flex justify-between gap-4">
            <dt className="text-sk-muted">Works out to</dt>
            <dd className="text-sk-ink">{money(cur.perMonth)} / month</dd>
          </div>
          <div className="flex justify-between gap-4">
            <dt className="text-sk-muted">Versus monthly</dt>
            <dd className={cur.saved > 0 ? "font-medium text-sk-savings" : "text-sk-muted"}>{cur.saved > 0 ? `${money(cur.saved)} saved` : "Same price"}</dd>
          </div>
        </dl>
        <Button size="lg" className="w-full active:scale-[0.97]" loading={status === "loading"} onClick={add}>
          <AnimatePresence mode="wait" initial={false}>
            <motion.span
              key={status === "added" ? "added" : plan.id}
              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} /> Added to cart
                </>
              ) : (
                <>Prepay {plan.days} days · {money(cur.total)}</>
              )}
            </motion.span>
          </AnimatePresence>
        </Button>
        <p className="text-center text-[12px] text-sk-muted">One charge. Does not renew automatically.</p>
      </div>
    </div>
  )
}

Usage

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

Prepaid duration cards, StoreKit