Shipping protection

Opt-in shipping protection switch, off by default, with a computed fee and plain-language terms.

componentswitchanyaddonprotection
$npx shadcn@latest add @storekit/cart-protection
cart-protection

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

Source

components/storekit/cart/cart-protection.tsx
117 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ChevronDown, ShieldCheck } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Shipping protection as a true opt-in. Off by default, price on the label,
   and the terms written in plain language right under it, with the full
   policy one tap away. */

export function CartProtection({
  subtotal = 14800,
  rate = 2,
  minFee = 195,
  defaultOn = false,
  terms = [
    "Covers loss, theft and damage while in transit.",
    "Report a problem within 30 days of the delivery date.",
    "We reship the order or refund it, your choice.",
  ],
  policyHref = "#",
  onChange,
  className,
}: {
  /** Cart subtotal in cents. */
  subtotal?: number
  /** Percent of subtotal. */
  rate?: number
  /** Minimum fee in cents. */
  minFee?: number
  defaultOn?: boolean
  terms?: string[]
  policyHref?: string
  onChange?: (on: boolean, fee: number) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-protection")
  const [on, setOn] = React.useState(defaultOn)
  const [details, setDetails] = React.useState(false)
  const fee = Math.max(minFee, Math.round((subtotal * rate) / 100))
  const regionId = React.useId()

  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)}>
      <button
        type="button"
        role="switch"
        aria-checked={on}
        onClick={() => {
          setOn(!on)
          onChange?.(!on, fee)
          sk.emit(on ? "interact" : "convert", on ? "protection_off" : "protection_on", fee)
        }}
        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", on ? "bg-sk-savings/15 text-sk-savings" : "bg-sk-ink/[0.06] text-sk-muted")}>
          <ShieldCheck size={17} />
        </span>
        <span className="min-w-0 flex-1">
          <span className="flex flex-wrap items-baseline gap-x-2 text-sm font-medium text-sk-ink">
            Shipping protection
            <span className="tabular-nums text-sk-muted">{money(fee)}</span>
          </span>
          <span className="block text-[13px] text-sk-muted">{on ? "Added. Your order is covered in transit." : "Optional. Not added unless you turn it on."}</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>
      <div className="border-t border-sk-line px-4">
        <button
          type="button"
          aria-expanded={details}
          aria-controls={regionId}
          onClick={() => setDetails((d) => !d)}
          className="flex h-11 w-full items-center justify-between rounded-sk-btn text-[13px] text-sk-muted transition-colors duration-200 hover:text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent"
        >
          What it covers
          <ChevronDown size={14} className={cn("transition-transform duration-200", details && "rotate-180")} />
        </button>
        <AnimatePresence initial={false}>
          {details ? (
            <motion.div
              id={regionId}
              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"
            >
              <ul className="grid gap-1.5 pb-3 text-[13px] leading-[1.5] text-sk-ink">
                {terms.map((t) => (
                  <li key={t} className="flex gap-2">
                    <span aria-hidden className="mt-[0.6em] h-1 w-1 shrink-0 rounded-sk-chip bg-sk-muted" />
                    {t}
                  </li>
                ))}
              </ul>
              <a href={policyHref} className="mb-3 inline-block rounded-sk-btn text-[13px] font-medium text-sk-ink underline underline-offset-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent">
                Read the full policy
              </a>
            </motion.div>
          ) : null}
        </AnimatePresence>
      </div>
    </div>
  )
}

Usage

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

Shipping protection, StoreKit