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-protectioncart-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-protectionSource
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.