Gift with purchase chooser

Locked gift options with the exact remainder to spend, then a single pick once the threshold is met.

componentgift-chooserbeautyanygwp
$npx shadcn@latest add @storekit/upsell-gift-chooser
upsell-gift-chooser

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

Source

components/storekit/upsell/upsell-gift-chooser.tsx
166 lines
"use client"

import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Gift, Lock } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Gift with purchase. Below the threshold the gift options are visible but
   locked, with the exact remainder to spend. Once the subtotal crosses it
   the options unlock and the shopper picks one variant. If the subtotal
   drops back under, the pick is released. Amounts are integer cents. */

const EASE = [0.23, 1, 0.32, 1] as const
const RING = "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"

type GiftOption = { id: string; name: string; detail?: string; image?: string; soldOut?: boolean }

const DEFAULT_GIFTS: GiftOption[] = [
  { id: "travel-sticks", name: "Electrolyte sticks", detail: "5 sticks" },
  { id: "pill-case", name: "Weekly pill case", detail: "7 day" },
  { id: "mixing-kit", name: "Mixing kit", detail: "Syringes, swabs" },
]

export function UpsellGiftChooser({
  subtotal = 12800,
  threshold = 10000,
  giftValue = 1800,
  gifts = DEFAULT_GIFTS,
  onSelect,
  className,
}: {
  /** Current cart subtotal in integer cents. */
  subtotal?: number
  threshold?: number
  /** Retail value of the gift, shown so the offer is concrete. */
  giftValue?: number
  gifts?: GiftOption[]
  onSelect?: (giftId: string | null) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("upsell", "upsell-gift-chooser")
  const unlocked = subtotal >= threshold
  const remaining = Math.max(0, threshold - subtotal)
  const [picked, setPicked] = useState<string | null>(null)
  const refs = useRef<(HTMLButtonElement | null)[]>([])
  const wasUnlocked = useRef(unlocked)

  useEffect(() => {
    if (wasUnlocked.current && !unlocked && picked) {
      setPicked(null)
      onSelect?.(null)
    }
    if (!wasUnlocked.current && unlocked) sk.emit("interact", "gift_unlocked", subtotal)
    wasUnlocked.current = unlocked
  }, [unlocked])

  const choose = (i: number, focus = false) => {
    const g = gifts[i]
    if (!unlocked || !g || g.soldOut) return
    if (g.id !== picked) sk.emit("convert", "gift_selected", 0)
    setPicked(g.id)
    onSelect?.(g.id)
    if (focus) refs.current[i]?.focus()
  }
  const current = Math.max(0, gifts.findIndex((g) => g.id === picked))

  return (
    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-4 rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6", className)}>
      <div className="flex items-start gap-3">
        <span className={cn("grid h-10 w-10 shrink-0 place-items-center rounded-sk-btn transition-colors duration-300", unlocked ? "bg-sk-accent text-sk-accent-ink" : "bg-sk-surface text-sk-muted")}>
          <Gift size={18} aria-hidden />
        </span>
        <div className="flex min-w-0 flex-1 flex-col gap-0.5" aria-live="polite">
          <span className="text-sm font-medium text-sk-ink tabular-nums">
            {unlocked ? `Choose your free gift, a ${money(giftValue)} value` : `Spend ${money(remaining)} more for a free gift`}
          </span>
          <span className="text-[13px] text-sk-muted tabular-nums">
            {unlocked ? (picked ? "Added to your order at no charge." : "Pick one. It ships with this order.") : `On orders over ${money(threshold)}. One per order.`}
          </span>
        </div>
      </div>

      <div className="h-1.5 overflow-hidden rounded-full bg-sk-surface" role="progressbar" aria-label="Progress to free gift" aria-valuemin={0} aria-valuemax={threshold} aria-valuenow={Math.min(subtotal, threshold)}>
        <motion.div
          initial={false}
          animate={{ scaleX: Math.min(1, subtotal / threshold) }}
          transition={{ duration: reduce ? 0 : 0.45, ease: EASE }}
          className={cn("h-full w-full origin-left rounded-full transition-colors duration-300", unlocked ? "bg-sk-savings" : "bg-sk-accent")}
        />
      </div>

      <div role="radiogroup" aria-label="Free gift" aria-disabled={!unlocked || undefined} className="grid grid-cols-3 gap-2">
        {gifts.map((g, i) => {
          const on = g.id === picked
          const disabled = !unlocked || g.soldOut
          return (
            <button
              key={g.id}
              ref={(el) => {
                refs.current[i] = el
              }}
              type="button"
              role="radio"
              aria-checked={on}
              aria-disabled={disabled || undefined}
              tabIndex={unlocked && i === current ? 0 : -1}
              onClick={() => choose(i)}
              onKeyDown={(e) => {
                const f = e.key === "ArrowRight" || e.key === "ArrowDown"
                const b = e.key === "ArrowLeft" || e.key === "ArrowUp"
                if (!f && !b) return
                e.preventDefault()
                for (let s = 1; s <= gifts.length; s++) {
                  const n = (current + (f ? s : -s) + gifts.length * 2) % gifts.length
                  if (!gifts[n].soldOut) return choose(n, true)
                }
              }}
              className={cn(
                "relative flex flex-col items-center gap-2 rounded-sk-card border p-2.5 pb-3 text-center transition-[border-color,background-color,opacity,transform] duration-200",
                on ? "border-sk-accent bg-sk-accent/[0.06]" : "border-sk-line",
                disabled ? "cursor-not-allowed opacity-55" : "hover:border-sk-ink/30 active:scale-[0.97]",
                RING
              )}
            >
              <span className="relative grid aspect-square w-full place-items-center overflow-hidden rounded-sk-btn bg-sk-surface">
                {g.image ? <img src={g.image} alt="" className="h-full w-full object-cover" /> : <Gift size={22} className="text-sk-muted" aria-hidden />}
                <AnimatePresence initial={false}>
                  {!unlocked ? (
                    <motion.span
                      key="lock"
                      initial={{ opacity: 0 }}
                      animate={{ opacity: 1 }}
                      exit={{ opacity: 0 }}
                      transition={{ duration: reduce ? 0 : 0.25 }}
                      className="absolute right-1.5 top-1.5 grid h-6 w-6 place-items-center rounded-full bg-sk-card text-sk-muted"
                    >
                      <Lock size={12} aria-hidden />
                    </motion.span>
                  ) : on ? (
                    <motion.span
                      key="check"
                      initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
                      animate={{ opacity: 1, scale: 1 }}
                      exit={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
                      transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}
                      className="absolute right-1.5 top-1.5 grid h-6 w-6 place-items-center rounded-full bg-sk-accent text-sk-accent-ink"
                    >
                      <Check size={13} strokeWidth={2.75} aria-hidden />
                    </motion.span>
                  ) : null}
                </AnimatePresence>
              </span>
              <span className="flex flex-col">
                <span className="text-[13px] font-medium leading-tight text-sk-ink">{g.name}</span>
                <span className="text-[12px] text-sk-muted">{g.soldOut ? "Gone" : g.detail}</span>
              </span>
            </button>
          )
        })}
      </div>
    </div>
  )
}

Usage

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

Gift with purchase chooser, StoreKit