Service add-ons

One-tap switches for gift wrap and priority handling with prices on the label and a running add-on total.

componentswitchanyaddon
$npx shadcn@latest add @storekit/cart-service-addon
cart-service-addon

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

Source

components/storekit/cart/cart-service-addon.tsx
91 lines
"use client"

import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { Gift, Zap, type LucideIcon } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* One-tap service add-ons for the cart: gift wrap, priority handling and
   the like. Each is a real switch with its price on the label, off by
   default, and the running add-on total is shown underneath. */

export type ServiceAddon = { id: string; title: string; detail: string; price: number; icon?: "gift" | "priority" }

const ICONS: Record<NonNullable<ServiceAddon["icon"]>, LucideIcon> = { gift: Gift, priority: Zap }

const SAMPLE: ServiceAddon[] = [
  { id: "gift-wrap", title: "Gift wrap", detail: "Recycled paper, no prices on the packing slip.", price: 500, icon: "gift" },
  { id: "priority", title: "Priority handling", detail: "Packed first, ships the same business day before 2pm ET.", price: 800, icon: "priority" },
]

export function CartServiceAddon({
  addons = SAMPLE,
  defaultSelected = [],
  onChange,
  className,
}: {
  addons?: ServiceAddon[]
  defaultSelected?: string[]
  onChange?: (selected: ServiceAddon[], total: number) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-service-addon")
  const [on, setOn] = React.useState<Record<string, boolean>>(() => Object.fromEntries(defaultSelected.map((id) => [id, true])))
  const total = addons.reduce((n, a) => n + (on[a.id] ? a.price : 0), 0)

  const toggle = (a: ServiceAddon) => {
    const nextOn = { ...on, [a.id]: !on[a.id] }
    setOn(nextOn)
    sk.emit("interact", `${a.id}_${nextOn[a.id] ? "on" : "off"}`, a.price)
    const sel = addons.filter((x) => nextOn[x.id])
    onChange?.(sel, sel.reduce((n, x) => n + x.price, 0))
  }

  return (
    <div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("w-full rounded-sk-card border border-sk-line bg-sk-card", className)}>
      <ul className="divide-y divide-sk-line">
        {addons.map((a) => {
          const checked = !!on[a.id]
          const Icon = a.icon ? ICONS[a.icon] : Gift
          return (
            <li key={a.id}>
              <button
                type="button"
                role="switch"
                aria-checked={checked}
                onClick={() => toggle(a)}
                className="flex min-h-[64px] w-full items-center gap-3 rounded-sk-card px-4 py-3 text-left transition-colors duration-200 hover:bg-sk-ink/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sk-accent active:scale-[0.99]"
              >
                <span className={cn("grid h-9 w-9 shrink-0 place-items-center rounded-sk-chip transition-colors duration-200", checked ? "bg-sk-accent/15 text-sk-accent" : "bg-sk-ink/[0.06] text-sk-muted")}>
                  <Icon size={16} />
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block text-sm font-medium text-sk-ink">
                    {a.title}{" "}
                    <span className="ml-1 whitespace-nowrap tabular-nums text-sk-muted">+{money(a.price)}</span>
                  </span>
                  <span className="block text-[13px] leading-[1.5] text-sk-muted">{a.detail}</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", checked ? "bg-sk-accent" : "bg-sk-ink/20")}>
                  <motion.span
                    initial={false}
                    animate={{ x: checked ? 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)]", checked ? "bg-sk-accent-ink" : "bg-sk-paper")}
                  />
                </span>
              </button>
            </li>
          )
        })}
      </ul>
      <div className="flex items-center justify-between border-t border-sk-line px-4 py-3 text-sm" aria-live="polite">
        <span className="text-sk-muted">Add-ons</span>
        <span className="font-medium tabular-nums">{total ? money(total) : "None"}</span>
      </div>
    </div>
  )
}

Usage

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

Service add-ons, StoreKit