Loyalty points
Points balance and value, points earned on this order, and a redeem switch in fixed blocks.
componentloyaltyanyloyaltydiscount
$
npx shadcn@latest add @storekit/cart-pointscart-points
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-points$
pnpm dlx shadcn@latest add @storekit/cart-points$
yarn dlx shadcn@latest add @storekit/cart-points$
bunx --bun shadcn@latest add @storekit/cart-pointsSource
components/storekit/cart/cart-points.tsx
116 lines
"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Sparkles } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Loyalty points in the cart. Shows the balance and its cash value, the
points this order will earn, and a switch to redeem in fixed blocks.
Earned points recompute on the amount actually paid. */
export function CartPoints({
balance = 1240,
subtotal = 14800,
pointsPerDollar = 1,
centsPerPoint = 1,
redeemStep = 500,
defaultRedeem = false,
onChange,
className,
}: {
balance?: number
/** In cents. */
subtotal?: number
pointsPerDollar?: number
/** Value of one point in cents. */
centsPerPoint?: number
/** Points redeem in blocks of this size. */
redeemStep?: number
defaultRedeem?: boolean
onChange?: (v: { redeem: boolean; pointsUsed: number; discount: number }) => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("cart", "cart-points")
const [redeem, setRedeem] = React.useState(defaultRedeem)
const maxBySubtotal = Math.floor(subtotal / centsPerPoint)
const usable = Math.floor(Math.min(balance, maxBySubtotal) / redeemStep) * redeemStep
const canRedeem = usable > 0
const on = redeem && canRedeem
const discount = on ? usable * centsPerPoint : 0
const paid = subtotal - discount
const earned = Math.floor((paid / 100) * pointsPerDollar)
const fmt = (n: number) => n.toLocaleString("en-US")
const toggle = () => {
if (!canRedeem) return
const next = !redeem
setRedeem(next)
onChange?.({ redeem: next, pointsUsed: next ? usable : 0, discount: next ? usable * centsPerPoint : 0 })
sk.emit(next ? "convert" : "interact", next ? "points_redeem" : "points_unredeem", usable * centsPerPoint)
}
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)}>
<div className="grid grid-cols-2 divide-x divide-sk-line border-b border-sk-line">
<div className="p-4">
<p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Balance</p>
<p className="mt-1 font-sk-display text-xl font-semibold tabular-nums tracking-[-0.02em]">{fmt(on ? balance - usable : balance)}</p>
<p className="text-xs tabular-nums text-sk-muted">Worth {money((on ? balance - usable : balance) * centsPerPoint)}</p>
</div>
<div className="p-4">
<p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">This order earns</p>
<p className="mt-1 flex items-center gap-1.5 font-sk-display text-xl font-semibold tabular-nums tracking-[-0.02em] text-sk-savings">
<Sparkles size={15} />
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={earned}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] }}
>
+{fmt(earned)}
</motion.span>
</AnimatePresence>
</p>
<p className="text-xs text-sk-muted">Added after it ships</p>
</div>
</div>
<button
type="button"
role="switch"
aria-checked={on}
disabled={!canRedeem}
onClick={toggle}
className="flex min-h-[60px] w-full items-center gap-3 rounded-b-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] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent"
>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-sk-ink">
{canRedeem ? (
<>
Use <span className="tabular-nums">{fmt(usable)}</span> points for <span className="tabular-nums">{money(usable * centsPerPoint)}</span> off
</>
) : (
<>Redeem from {fmt(redeemStep)} points</>
)}
</span>
<span className="block text-[13px] text-sk-muted">
{canRedeem ? `Redeems in blocks of ${fmt(redeemStep)}.` : `You need ${fmt(redeemStep - balance)} more to redeem.`}
</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>
)
}
Usage
Import from "@/components/storekit/cart/cart-points" and pass your product data. The demo above exercises the full prop surface with sample values.