Automatic best offer
Prices every eligible offer against the cart, applies the best one without a code and explains why in a disclosure.
componentsummaryanydiscount
$
npx shadcn@latest add @storekit/cart-auto-discountcart-auto-discount
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-auto-discount$
pnpm dlx shadcn@latest add @storekit/cart-auto-discount$
yarn dlx shadcn@latest add @storekit/cart-auto-discount$
bunx --bun shadcn@latest add @storekit/cart-auto-discountSource
components/storekit/cart/cart-auto-discount.tsx
140 lines
"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { BadgePercent, Check, ChevronDown } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Automatic best discount. Every eligible offer is priced against the
current cart, the best one is applied without a code, and a disclosure
explains why it won and what the alternatives were worth. Offers do not
stack unless your backend says so. */
export type AutoOffer = {
id: string
label: string
/** Percent off the subtotal. */
percentOff?: number
/** Fixed cents off. */
amountOff?: number
/** Minimum subtotal in cents. */
minSubtotal?: number
/** Minimum item count. */
minItems?: number
}
const SAMPLE_OFFERS: AutoOffer[] = [
{ id: "ten-over-150", label: "10% off orders over $150", percentOff: 10, minSubtotal: 15000 },
{ id: "twenty-three-items", label: "$20 off 3 or more items", amountOff: 2000, minItems: 3 },
{ id: "fifteen-over-250", label: "15% off orders over $250", percentOff: 15, minSubtotal: 25000 },
]
export function CartAutoDiscount({
subtotal = 20700,
itemCount = 3,
offers = SAMPLE_OFFERS,
shipping,
className,
}: {
subtotal?: number
itemCount?: number
offers?: AutoOffer[]
/** Shipping in cents. Omit to show "At checkout". */
shipping?: number
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("cart", "cart-auto-discount")
const [open, setOpen] = React.useState(false)
const regionId = React.useId()
const priced = offers.map((o) => {
const eligible = subtotal >= (o.minSubtotal ?? 0) && itemCount >= (o.minItems ?? 0)
const value = Math.min(subtotal, (o.percentOff ? Math.round((subtotal * o.percentOff) / 100) : 0) + (o.amountOff ?? 0))
const gap = o.minSubtotal && subtotal < o.minSubtotal ? o.minSubtotal - subtotal : 0
const itemsGap = o.minItems && itemCount < o.minItems ? o.minItems - itemCount : 0
return { ...o, eligible, value, gap, itemsGap }
})
const best = priced.filter((o) => o.eligible).sort((a, b) => b.value - a.value)[0]
const total = subtotal - (best?.value ?? 0) + (shipping ?? 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 p-5", className)}>
<dl className="grid grid-cols-1 gap-2 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-sk-muted">Subtotal</dt>
<dd className="tabular-nums">{money(subtotal)}</dd>
</div>
{best ? (
<div className="flex justify-between gap-4 text-sk-savings">
<dt className="flex min-w-0 items-center gap-1.5">
<BadgePercent size={14} className="shrink-0" />
<span className="truncate">{best.label}</span>
</dt>
<dd className="shrink-0 tabular-nums">-{money(best.value)}</dd>
</div>
) : null}
<div className="flex justify-between gap-4">
<dt className="text-sk-muted">Shipping</dt>
<dd className={cn("tabular-nums", shipping === undefined && "text-sk-muted")}>{shipping === undefined ? "At checkout" : shipping === 0 ? "Free" : money(shipping)}</dd>
</div>
<div className="mt-1 flex items-baseline justify-between gap-4 border-t border-sk-line pt-3">
<dt className="font-medium">Total</dt>
<dd className="font-sk-display text-lg font-semibold tabular-nums text-sk-price">{money(total)}</dd>
</div>
</dl>
<div className="mt-3 rounded-sk-btn bg-sk-savings/[0.08]">
<button
type="button"
aria-expanded={open}
aria-controls={regionId}
onClick={() => {
setOpen((o) => !o)
if (!open) sk.emit("interact", "why_this_offer")
}}
className="flex min-h-11 w-full items-center gap-2 rounded-sk-btn px-3 text-left text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.99]"
>
<Check size={14} className="shrink-0 text-sk-savings" />
<span className="min-w-0 flex-1 text-sk-ink">
{best ? "Best offer applied. No code needed." : "No offer applies yet."}
</span>
<span className="shrink-0 text-sk-muted">Why</span>
<ChevronDown size={14} className={cn("shrink-0 text-sk-muted transition-transform duration-200", open && "rotate-180")} />
</button>
<AnimatePresence initial={false}>
{open ? (
<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"
>
<div className="px-3 pb-3">
<p className="text-xs leading-[1.5] text-sk-muted">Offers do not combine. We compared each one against this cart and kept the largest saving.</p>
<ul className="mt-2 grid gap-1.5">
{priced.map((o) => (
<li key={o.id} className="flex items-start justify-between gap-3 text-xs">
<span className={cn("min-w-0", o.id === best?.id ? "font-medium text-sk-ink" : "text-sk-muted")}>{o.label}</span>
<span className={cn("shrink-0 text-right tabular-nums", o.id === best?.id ? "font-medium text-sk-savings" : "text-sk-muted")}>
{o.eligible
? `${o.id === best?.id ? "Applied " : ""}-${money(o.value)}`
: o.gap
? `${money(o.gap)} away`
: `${o.itemsGap} more ${o.itemsGap === 1 ? "item" : "items"}`}
</span>
</li>
))}
</ul>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
)
}
Usage
Import from "@/components/storekit/cart/cart-auto-discount" and pass your product data. The demo above exercises the full prop surface with sample values.
