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-cardsupsell-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-cardsSource
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.
