Gift unlock

Locked row that names the amount left, then opens a choose-one-of-three gift picker at the threshold.

componentgiftanygiftshipping-threshold
$npx shadcn@latest add @storekit/cart-gift-unlock
cart-gift-unlock

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

Source

components/storekit/cart/cart-gift-unlock.tsx
134 lines
"use client"

import * as React 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 { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Gift unlock. Below the threshold it is a quiet locked row with the amount
   left. At the threshold a chooser opens with three gifts; the shopper picks
   one and it joins the order at no charge. Dropping below the threshold
   releases the pick. */

export type CartGift = { id: string; title: string; detail?: string; value: number; image: string }

const SAMPLE_GIFTS: CartGift[] = [
  { id: "travel-sticks", title: "Electrolyte sticks", detail: "5 sticks", value: 1200, image: "/brands/morrow/electrolytes.webp" },
  { id: "greens-travel", title: "Daily Greens", detail: "Travel size", value: 1800, image: "/brands/morrow/greens.webp" },
  { id: "magnesium-trial", title: "Magnesium Night", detail: "7-night pack", value: 1500, image: "/brands/morrow/magnesium.webp" },
]

export function CartGiftUnlock({
  subtotal = 16200,
  threshold = 15000,
  gifts = SAMPLE_GIFTS,
  defaultGiftId,
  onChoose,
  className,
}: {
  subtotal?: number
  threshold?: number
  gifts?: CartGift[]
  defaultGiftId?: string
  onChoose?: (gift: CartGift | null) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-gift-unlock")
  const [picked, setPicked] = React.useState<string | undefined>(defaultGiftId)
  const unlocked = subtotal >= threshold
  const remaining = Math.max(0, threshold - subtotal)
  const groupId = React.useId()

  React.useEffect(() => {
    if (!unlocked && picked) {
      setPicked(undefined)
      onChoose?.(null)
    }
  }, [unlocked, picked, onChoose])

  const choose = (g: CartGift) => {
    setPicked(g.id)
    sk.emit("convert", "gift_select", g.value)
    onChoose?.(g)
  }

  return (
    <div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("w-full overflow-hidden rounded-sk-card border border-sk-line bg-sk-card", className)}>
      <div className="flex items-center gap-3 p-4">
        <span className={cn("grid h-9 w-9 shrink-0 place-items-center rounded-sk-chip transition-colors duration-300", unlocked ? "bg-sk-accent text-sk-accent-ink" : "bg-sk-ink/[0.06] text-sk-muted")}>
          {unlocked ? <Gift size={16} /> : <Lock size={15} />}
        </span>
        <div className="min-w-0 flex-1" aria-live="polite">
          {unlocked ? (
            <>
              <p id={groupId} className="text-sm font-medium text-sk-ink">Choose your free gift</p>
              <p className="text-[13px] text-sk-muted">{picked ? "Added to your order at no charge." : "Pick one. It ships with this order."}</p>
            </>
          ) : (
            <>
              <p className="text-sm font-medium text-sk-ink">
                Spend <span className="tabular-nums">{money(remaining)}</span> more for a free gift
              </p>
              <p className="text-[13px] text-sk-muted">You will choose from {gifts.length} options.</p>
            </>
          )}
        </div>
      </div>

      <AnimatePresence initial={false}>
        {unlocked ? (
          <motion.div
            key="gifts"
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: reduce ? 0 : 0.4, ease: [0.16, 1, 0.3, 1] }}
            className="overflow-hidden"
          >
            <div role="radiogroup" aria-labelledby={groupId} className="grid grid-cols-3 gap-2 border-t border-sk-line p-3 sm:gap-3 sm:p-4">
              {gifts.slice(0, 3).map((g, i) => {
                const on = picked === g.id
                return (
                  <motion.button
                    key={g.id}
                    type="button"
                    role="radio"
                    aria-checked={on}
                    onClick={() => choose(g)}
                    initial={reduce ? false : { opacity: 0, y: 8 }}
                    animate={{ opacity: 1, y: 0 }}
                    transition={{ duration: reduce ? 0 : 0.4, delay: reduce ? 0 : 0.08 + i * 0.05, ease: [0.16, 1, 0.3, 1] }}
                    className={cn(
                      "relative flex min-w-0 flex-col rounded-sk-card border p-2 text-left transition-[border-color,background-color,transform] duration-200 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-card",
                      on ? "border-sk-accent bg-sk-accent/[0.06]" : "border-sk-line hover:border-sk-ink/30"
                    )}
                  >
                    <ProductImage src={g.image} alt="" className="w-full rounded-sk-card" zoomOnHover={false} />
                    <span className="mt-2 line-clamp-2 hyphens-auto break-words text-[13px] font-medium leading-tight text-sk-ink">{g.title}</span>
                    {g.detail ? <span className="text-xs text-sk-muted">{g.detail}</span> : null}
                    <span className="mt-1 text-xs tabular-nums text-sk-muted">
                      <s>{money(g.value)}</s> <span className="font-medium text-sk-savings">Free</span>
                    </span>
                    <span
                      aria-hidden
                      className={cn(
                        "absolute right-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-sk-chip border transition-colors duration-200",
                        on ? "border-sk-accent bg-sk-accent text-sk-accent-ink" : "border-sk-line bg-sk-card"
                      )}
                    >
                      {on ? <Check size={12} strokeWidth={3} /> : null}
                    </span>
                  </motion.button>
                )
              })}
            </div>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  )
}

Usage

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

Gift unlock, StoreKit