Mix and match builder

Pick a pack size, spread units across flavors with steppers, watch the meter fill and add when complete.

blockmix-matchbeveragesupplementsbundleatc
$npx shadcn@latest add @storekit/upsell-mix-match
upsell-mix-match

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

Source

components/storekit/upsell/upsell-mix-match.tsx
240 lines
"use client"

import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Shuffle } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { Stepper } from "@/components/storekit/ui/stepper"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Mix and match builder. Pick a pack size, then spread that many units
   across flavors with steppers. A meter tracks progress to the required
   count, the discount and total update live, and the add button unlocks
   only when the pack is full. Prices 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 Flavor = { id: string; name: string; note?: string; soldOut?: boolean }
type Pack = { size: number; discount: number }

const DEFAULT_FLAVORS: Flavor[] = [
  { id: "citrus", name: "Citrus Salt", note: "Lemon, lime, sea salt" },
  { id: "berry", name: "Wild Berry", note: "Blackberry, raspberry" },
  { id: "mango", name: "Mango Chili", note: "Ripe mango, mild heat" },
  { id: "plain", name: "Unflavored", note: "No sweetener" },
]

const DEFAULT_PACKS: Pack[] = [
  { size: 6, discount: 0.1 },
  { size: 12, discount: 0.15 },
]

export function UpsellMixMatch({
  unitPrice = 450,
  unitLabel = "stick",
  flavors = DEFAULT_FLAVORS,
  packs = DEFAULT_PACKS,
  onAddToCart,
  className,
}: {
  /** Price of one unit in integer cents before the pack discount. */
  unitPrice?: number
  unitLabel?: string
  flavors?: Flavor[]
  packs?: Pack[]
  onAddToCart?: (v: { pack: number; picks: Record<string, number>; total: number }) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("upsell", "upsell-mix-match")
  const [packIdx, setPackIdx] = useState(0)
  const [picks, setPicks] = useState<Record<string, number>>({})
  const [status, setStatus] = useState<"idle" | "loading" | "added">("idle")
  const packRefs = useRef<(HTMLButtonElement | null)[]>([])
  const timers = useRef<number[]>([])
  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])

  const pack = packs[packIdx] ?? packs[0]
  const count = Object.values(picks).reduce((a, b) => a + b, 0)
  const left = pack.size - count
  const complete = left === 0
  const full = unitPrice * pack.size
  const total = Math.round(full * (1 - pack.discount))
  const pct = Math.round(pack.discount * 100)

  const choosePack = (i: number, focus = false) => {
    if (i === packIdx) return
    sk.emit("interact", `pack_${packs[i].size}`, undefined)
    setPackIdx(i)
    // Trim picks that no longer fit, removing from the last flavor first.
    setPicks((prev) => {
      const next = { ...prev }
      let over = Object.values(next).reduce((a, b) => a + b, 0) - packs[i].size
      for (const f of [...flavors].reverse()) {
        if (over <= 0) break
        const take = Math.min(next[f.id] ?? 0, over)
        next[f.id] = (next[f.id] ?? 0) - take
        over -= take
      }
      return next
    })
    if (focus) packRefs.current[i]?.focus()
  }

  const setFlavor = (id: string, n: number) => {
    sk.emit("interact", `flavor_${id}`, n)
    setPicks((prev) => ({ ...prev, [id]: n }))
  }

  const fillEvenly = () => {
    const open = flavors.filter((f) => !f.soldOut)
    if (!open.length) return
    sk.emit("interact", "fill_evenly", undefined)
    const next: Record<string, number> = {}
    open.forEach((f, i) => {
      next[f.id] = Math.floor(pack.size / open.length) + (i < pack.size % open.length ? 1 : 0)
    })
    setPicks(next)
  }

  const add = () => {
    if (!complete || status !== "idle") return
    setStatus("loading")
    sk.emit("convert", "add_to_cart", total)
    timers.current.push(
      window.setTimeout(() => {
        setStatus("added")
        onAddToCart?.({ pack: pack.size, picks, total })
        timers.current.push(window.setTimeout(() => setStatus("idle"), 1200))
      }, 700)
    )
  }

  return (
    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-5 rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6", className)}>
      <div className="flex flex-col gap-2">
        <span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Build your pack</span>
        <div role="radiogroup" aria-label="Pack size" className="grid grid-cols-2 gap-2">
          {packs.map((p, i) => {
            const on = i === packIdx
            return (
              <button
                key={p.size}
                ref={(el) => {
                  packRefs.current[i] = el
                }}
                type="button"
                role="radio"
                aria-checked={on}
                tabIndex={on ? 0 : -1}
                onClick={() => choosePack(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()
                  choosePack((packIdx + (f ? 1 : -1) + packs.length) % packs.length, true)
                }}
                className={cn(
                  "flex h-14 flex-col items-center justify-center rounded-sk-btn border tabular-nums transition-[border-color,background-color,transform] duration-200 active:scale-[0.97]",
                  on ? "border-sk-accent bg-sk-accent/[0.08]" : "border-sk-line hover:border-sk-ink/30",
                  RING
                )}
              >
                <span className="text-sm font-medium text-sk-ink">
                  {p.size} {unitLabel}s
                </span>
                <span className="text-[12px] font-medium text-sk-savings">Save {Math.round(p.discount * 100)}%</span>
              </button>
            )
          })}
        </div>
      </div>

      <div className="flex flex-col gap-2">
        <div className="flex items-baseline justify-between gap-3 text-sm" aria-live="polite">
          <span className="font-medium text-sk-ink tabular-nums">
            {count} of {pack.size} picked
          </span>
          <span className="text-[13px] text-sk-muted tabular-nums">{complete ? "Pack complete" : `${left} to go`}</span>
        </div>
        <div className="h-1.5 overflow-hidden rounded-full bg-sk-surface" role="progressbar" aria-valuemin={0} aria-valuemax={pack.size} aria-valuenow={count} aria-label="Pack progress">
          <motion.div
            initial={false}
            animate={{ scaleX: pack.size ? count / pack.size : 0 }}
            transition={{ duration: reduce ? 0 : 0.4, ease: EASE }}
            className={cn("h-full w-full origin-left rounded-full transition-colors duration-300", complete ? "bg-sk-savings" : "bg-sk-accent")}
          />
        </div>
      </div>

      <ul className="flex flex-col divide-y divide-sk-line/70 border-y border-sk-line/70">
        {flavors.map((f) => {
          const n = picks[f.id] ?? 0
          return (
            <li key={f.id} className={cn("flex flex-wrap items-center gap-x-3 gap-y-2 py-3", f.soldOut && "opacity-60")}>
              <span aria-hidden className="grid h-10 w-10 shrink-0 place-items-center rounded-sk-btn bg-sk-surface font-sk-display text-sm font-semibold text-sk-ink">
                {f.name.slice(0, 1)}
              </span>
              <span className="flex min-w-[8rem] flex-1 basis-0 flex-col">
                <span className="text-sm font-medium leading-[1.35] text-sk-ink">{f.name}</span>
                <span className="text-[13px] leading-[1.35] text-sk-muted">{f.soldOut ? "Sold out" : f.note}</span>
              </span>
              {f.soldOut ? null : (
                <Stepper size="sm" label={`${f.name} quantity`} value={n} min={0} max={n + left} onChange={(v) => setFlavor(f.id, v)} className="ml-auto" />
              )}
            </li>
          )
        })}
      </ul>

      <div className="flex flex-col gap-3">
        <div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
          <div className="flex min-w-0 flex-col gap-0.5 tabular-nums">
            <span className="text-[13px] text-sk-muted">
              {pct}% off, {money(Math.round(total / pack.size))} / {unitLabel}
            </span>
            <span className="flex items-baseline gap-2">
              <span className="text-xl font-semibold text-sk-price">{money(total)}</span>
              <s className="text-sm text-sk-muted">{money(full)}</s>
            </span>
          </div>
          <button
            type="button"
            onClick={fillEvenly}
            className={cn("inline-flex h-11 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-sk-btn px-3 text-[13px] font-medium text-sk-muted transition-[color,background-color,transform] duration-200 hover:bg-sk-ink/[0.05] hover:text-sk-ink active:scale-[0.97]", RING)}
          >
            <Shuffle size={14} aria-hidden /> Fill evenly
          </button>
        </div>
        <Button size="lg" className="w-full active:scale-[0.97]" disabled={!complete} loading={status === "loading"} onClick={add}>
          <AnimatePresence mode="wait" initial={false}>
            <motion.span
              key={status === "added" ? "added" : complete ? "ready" : "wait"}
              initial={{ opacity: 0, y: reduce ? 0 : 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: reduce ? 0 : -6 }}
              transition={{ duration: reduce ? 0 : 0.2, ease: EASE }}
              className="inline-flex items-center gap-2 tabular-nums"
            >
              {status === "added" ? (
                <>
                  <Check size={16} strokeWidth={2.5} /> Added to cart
                </>
              ) : complete ? (
                <>Add pack of {pack.size} · {money(total)}</>
              ) : (
                <>
                  Pick {left} more to add
                </>
              )}
            </motion.span>
          </AnimatePresence>
        </Button>
      </div>
    </div>
  )
}

Usage

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

Mix and match builder, StoreKit