Cart subscribe switch

Per-line Subscribe and save switch that updates the line to the per-delivery price and reveals a cadence picker.

componentsubscribeanysubscribecart
$npx shadcn@latest add @storekit/cart-subscribe-switch
cart-subscribe-switch

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/cart-subscribe-switch
$pnpm dlx shadcn@latest add @storekit/cart-subscribe-switch
$yarn dlx shadcn@latest add @storekit/cart-subscribe-switch
$bunx --bun shadcn@latest add @storekit/cart-subscribe-switch

Source

components/storekit/cart/cart-subscribe-switch.tsx
138 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Repeat } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Per-line subscribe and save in the cart. A switch converts the line to a
   subscription, the line price drops to the per-delivery price with the
   one-time price struck through, and a cadence picker appears. Terms are
   stated plainly under it. */

export type SubscribeLine = { id: string; title: string; variant?: string; price: number; image: string; quantity: number; subscribed?: boolean; every?: number }

const SAMPLE: SubscribeLine[] = [
  { id: "magnesium-night", title: "Magnesium Night", variant: "60 gummies", price: 2900, image: "/brands/morrow/magnesium.webp", quantity: 1, subscribed: true, every: 30 },
  { id: "electrolytes", title: "Electrolytes", variant: "30 sticks", price: 3200, image: "/brands/morrow/electrolytes.webp", quantity: 1 },
]

export function CartSubscribeSwitch({
  lines: initial = SAMPLE,
  percentOff = 15,
  cadences = [30, 60, 90],
  onChange,
  className,
}: {
  lines?: SubscribeLine[]
  percentOff?: number
  /** Delivery cadences in days. */
  cadences?: number[]
  onChange?: (lines: SubscribeLine[]) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-subscribe-switch")
  const [lines, setLines] = React.useState(initial)
  const sub = (cents: number) => Math.round((cents * (100 - percentOff)) / 100)
  const total = lines.reduce((n, l) => n + (l.subscribed ? sub(l.price) : l.price) * l.quantity, 0)

  const update = (id: string, patch: Partial<SubscribeLine>) => {
    const next = lines.map((l) => (l.id === id ? { ...l, ...patch } : l))
    setLines(next)
    onChange?.(next)
  }

  return (
    <div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("w-full", className)}>
      <ul className="divide-y divide-sk-line border-y border-sk-line">
        {lines.map((l) => {
          const on = !!l.subscribed
          const unit = on ? sub(l.price) : l.price
          const every = l.every ?? cadences[0]
          return (
            <li key={l.id} className="py-4">
              <div className="flex gap-3">
                <ProductImage src={l.image} alt={l.title} className="h-16 w-16 shrink-0 rounded-sk-card border border-sk-line" fit="cover" zoomOnHover={false} />
                <div className="min-w-0 flex-1">
                  <div className="flex items-start justify-between gap-3">
                    <div className="min-w-0">
                      <p className="truncate text-sm font-medium">{l.title}</p>
                      <p className="text-[13px] text-sk-muted">
                        {l.variant ? `${l.variant} / ` : null}Qty <span className="tabular-nums">{l.quantity}</span>
                      </p>
                    </div>
                    <p className="shrink-0 text-right text-sm tabular-nums">
                      <span className={cn("block font-semibold", on ? "text-sk-savings" : "text-sk-price")}>{money(unit * l.quantity)}</span>
                      {on ? <s className="block text-xs text-sk-muted">{money(l.price * l.quantity)}</s> : null}
                    </p>
                  </div>
                  <button
                    type="button"
                    role="switch"
                    aria-checked={on}
                    onClick={() => {
                      update(l.id, { subscribed: !on, every })
                      sk.emit(on ? "interact" : "convert", on ? "subscribe_off" : "subscribe_on", sub(l.price) * l.quantity)
                    }}
                    className="sk-btn mt-3 flex min-h-11 w-full items-center gap-3 rounded-sk-btn border border-sk-line px-3 py-2 text-left transition-colors duration-200 hover:bg-sk-ink/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.99]"
                  >
                    <Repeat size={15} className={cn("shrink-0", on ? "text-sk-accent" : "text-sk-muted")} />
                    <span className="min-w-0 flex-1 text-[13px] font-medium text-sk-ink">
                      Subscribe and save <span className="tabular-nums">{percentOff}%</span>
                    </span>
                    <span aria-hidden className={cn("relative flex h-6 w-10 shrink-0 items-center rounded-sk-chip p-0.5 transition-colors duration-200", on ? "bg-sk-accent" : "bg-sk-ink/20")}>
                      <motion.span
                        initial={false}
                        animate={{ x: on ? 16 : 0 }}
                        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 34 }}
                        className={cn("h-5 w-5 rounded-sk-chip shadow-[0_1px_2px_rgb(0_0_0/0.25)]", on ? "bg-sk-accent-ink" : "bg-sk-paper")}
                      />
                    </span>
                  </button>
                  <AnimatePresence initial={false}>
                    {on ? (
                      <motion.div
                        initial={{ height: 0, opacity: 0 }}
                        animate={{ height: "auto", opacity: 1 }}
                        exit={{ height: 0, opacity: 0 }}
                        transition={{ duration: reduce ? 0 : 0.28, ease: [0.16, 1, 0.3, 1] }}
                        className="overflow-hidden"
                      >
                        <div className="flex flex-wrap items-center gap-x-3 gap-y-2 pt-3">
                          <label htmlFor={`${l.id}-every`} className="text-[13px] text-sk-muted">
                            Deliver every
                          </label>
                          <select
                            id={`${l.id}-every`}
                            value={every}
                            onChange={(e) => update(l.id, { every: Number(e.target.value) })}
                            className="sk-btn h-10 rounded-sk-btn border border-sk-line bg-sk-card px-2 text-[13px] text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent"
                          >
                            {cadences.map((c) => (
                              <option key={c} value={c}>
                                {c} days
                              </option>
                            ))}
                          </select>
                          <span className="text-xs tabular-nums text-sk-muted">{money(unit)} per delivery. Skip or cancel any time.</span>
                        </div>
                      </motion.div>
                    ) : null}
                  </AnimatePresence>
                </div>
              </div>
            </li>
          )
        })}
      </ul>
      <div className="flex items-baseline justify-between pt-3 text-sm">
        <span className="text-sk-muted">Subtotal</span>
        <span className="font-semibold tabular-nums">{money(total)}</span>
      </div>
    </div>
  )
}

Usage

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

Cart subscribe switch, StoreKit