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-points
cart-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-points

Source

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.

Loyalty points, StoreKit