{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "checkout-flow",
  "title": "Checkout Flow",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-react/blocks/checkout-flow/CheckoutFlow.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { ArrowLeft, Minus, Plus, Trash2 } from 'lucide-react'\nimport { PaymentCard } from '@/components/ui/payment-card'\nimport { Button } from '@/components/ui/button'\nimport { Stepper } from '@/components/ui/stepper'\nimport { Separator } from '@/components/ui/separator'\nimport { PaymentForm } from '@/components/blocks/PaymentForm'\n\ntype CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'\ninterface PaymentPayload {\n  number: string\n  name: string\n  expiry: string\n  cvc: string\n  brand: CardBrand\n}\n\ninterface CartItem {\n  id: string\n  name: string\n  image?: string\n  qty: number\n  price: number\n}\n\nexport interface CheckoutFlowProps {\n  items: CartItem[]\n  currency?: string\n  shippingFee?: number\n  taxRate?: number\n  onSubmit?: (data: PaymentPayload) => Promise<{ orderId: string }>\n  onComplete?: (payload: { orderId: string }) => void\n  onCancel?: () => void\n}\n\ntype Step = 'cart' | 'payment' | 'confirm' | 'success' | 'error'\n\ninterface Particle {\n  id: number\n  x: number\n  y: number\n  vx: number\n  vy: number\n  rot: number\n  vr: number\n  color: string\n  size: number\n}\n\nconst STYLE_ID = 'checkout-flow-styles'\nconst STYLE_CONTENT = `\n.checkout-flow-anim-forward,\n.checkout-flow-anim-back {\n  animation-duration: 280ms;\n  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n  animation-fill-mode: both;\n}\n.checkout-flow-anim-forward {\n  animation-name: checkout-flow-forward;\n}\n.checkout-flow-anim-back {\n  animation-name: checkout-flow-back;\n}\n@keyframes checkout-flow-forward {\n  from { transform: translateX(24px); opacity: 0; }\n  to { transform: translateX(0); opacity: 1; }\n}\n@keyframes checkout-flow-back {\n  from { transform: translateX(-24px); opacity: 0; }\n  to { transform: translateX(0); opacity: 1; }\n}\n.checkout-flow-check-draw {\n  stroke-dasharray: 100;\n  stroke-dashoffset: 100;\n  animation: checkout-flow-check-draw 700ms cubic-bezier(0.4, 0, 0.2, 1) 120ms forwards;\n}\n@keyframes checkout-flow-check-draw {\n  to { stroke-dashoffset: 0; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .checkout-flow-anim-forward,\n  .checkout-flow-anim-back {\n    animation-duration: 0ms;\n  }\n  .checkout-flow-check-draw {\n    animation: none;\n    stroke-dashoffset: 0;\n  }\n}\n`\n\nconst stepperSteps = [{ id: 'cart', title: 'Cart' }, { id: 'payment', title: 'Payment' }, { id: 'confirm', title: 'Confirm' }]\n\nexport function CheckoutFlow({\n  items,\n  currency = 'USD',\n  shippingFee = 0,\n  taxRate = 0,\n  onSubmit,\n  onComplete,\n  onCancel,\n}: CheckoutFlowProps) {\n  const [step, setStep] = React.useState<Step>('cart')\n  const [direction, setDirection] = React.useState<'forward' | 'back'>('forward')\n\n  const [cart, setCart] = React.useState<CartItem[]>(() => items.map((i) => ({ ...i })))\n  const [paymentPayload, setPaymentPayload] = React.useState<PaymentPayload | null>(null)\n  const [orderId, setOrderId] = React.useState<string | null>(null)\n  const [lastError, setLastError] = React.useState<Error | null>(null)\n  const [placing, setPlacing] = React.useState(false)\n\n  // Mirror Vue's watch(() => props.items): reset the working cart when the\n  // incoming items prop changes.\n  React.useEffect(() => {\n    setCart(items.map((i) => ({ ...i })))\n  }, [items])\n\n  // Inject keyframes + reduced-motion rules once.\n  React.useEffect(() => {\n    if (typeof document === 'undefined') return\n    if (document.getElementById(STYLE_ID)) return\n    const el = document.createElement('style')\n    el.id = STYLE_ID\n    el.textContent = STYLE_CONTENT\n    document.head.appendChild(el)\n  }, [])\n\n  function go(next: Step) {\n    const order: Record<Step, number> = { cart: 0, payment: 1, confirm: 2, success: 3, error: 3 }\n    setDirection(order[next] >= order[step] ? 'forward' : 'back')\n    setStep(next)\n  }\n\n  const subtotal = React.useMemo(() => cart.reduce((s, i) => s + i.qty * i.price, 0), [cart])\n  const tax = React.useMemo(() => subtotal * taxRate, [subtotal, taxRate])\n  const total = React.useMemo(() => subtotal + shippingFee + tax, [subtotal, shippingFee, tax])\n\n  const fmt = (n: number) => {\n    try {\n      return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(n)\n    } catch {\n      return `${currency} ${n.toFixed(2)}`\n    }\n  }\n\n  const stepIndex = step === 'cart' ? 1 : step === 'payment' ? 2 : 3\n\n  function dec(item: CartItem) {\n    if (item.qty > 1) setCart((prev) => prev.map((i) => (i.id === item.id ? { ...i, qty: i.qty - 1 } : i)))\n  }\n  function inc(item: CartItem) {\n    setCart((prev) => prev.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i)))\n  }\n  function remove(item: CartItem) {\n    setCart((prev) => prev.filter((i) => i.id !== item.id))\n  }\n\n  async function placeOrder() {\n    if (!paymentPayload || placing) return\n    setPlacing(true)\n    try {\n      let nextOrderId: string\n      if (onSubmit) {\n        const res = await onSubmit(paymentPayload)\n        nextOrderId = res?.orderId ?? Math.random().toString(36).slice(2, 10).toUpperCase()\n      } else {\n        nextOrderId = Math.random().toString(36).slice(2, 10).toUpperCase()\n      }\n      setOrderId(nextOrderId)\n      onComplete?.({ orderId: nextOrderId })\n      go('success')\n    } catch (err) {\n      setLastError(err instanceof Error ? err : new Error(String(err)))\n      go('error')\n    } finally {\n      setPlacing(false)\n    }\n  }\n\n  function onPaymentSuccess() {\n    go('confirm')\n  }\n\n  function captureFormPayload(payload: PaymentPayload) {\n    setPaymentPayload(payload)\n  }\n\n  // Confetti — 60 particles, simple physics, ~1.8s\n  const [particles, setParticles] = React.useState<Particle[]>([])\n  const confettiRaf = React.useRef<number | null>(null)\n  const reducedMotion =\n    typeof window !== 'undefined' && window.matchMedia\n      ? window.matchMedia('(prefers-reduced-motion: reduce)').matches\n      : false\n\n  const fireConfetti = React.useCallback(() => {\n    if (reducedMotion) return\n    // Pull theme tokens at runtime so confetti follows the active palette\n    // (light/dark mode + any consumer theme override). Falls back to a\n    // sensible default if the variable isn't defined.\n    const cs = typeof window !== 'undefined' ? getComputedStyle(document.documentElement) : null\n    const tok = (name: string, fallback: string) => cs?.getPropertyValue(name).trim() || fallback\n    const colors = [\n      tok('--chart-1', 'oklch(0.65 0.18 145)'),\n      tok('--chart-2', 'oklch(0.6 0.22 260)'),\n      tok('--chart-3', 'oklch(0.7 0.16 75)'),\n      tok('--chart-4', 'oklch(0.65 0.2 330)'),\n      tok('--chart-5', 'oklch(0.6 0.22 30)'),\n      tok('--primary', 'oklch(0.55 0.22 260)'),\n    ]\n    const initial: Particle[] = Array.from({ length: 60 }, (_, id) => ({\n      id,\n      x: 50,\n      y: 40,\n      vx: (Math.random() - 0.5) * 12,\n      vy: -Math.random() * 14 - 4,\n      rot: Math.random() * 360,\n      vr: (Math.random() - 0.5) * 20,\n      color: colors[Math.floor(Math.random() * colors.length)],\n      size: 4 + Math.random() * 6,\n    }))\n    setParticles(initial)\n    const start = performance.now()\n    const tick = (now: number) => {\n      const elapsed = now - start\n      if (elapsed > 1800) {\n        setParticles([])\n        confettiRaf.current = null\n        return\n      }\n      setParticles((prev) =>\n        prev.map((p) => ({\n          ...p,\n          x: p.x + p.vx * 0.25,\n          y: p.y + p.vy * 0.25,\n          vy: p.vy + 0.45,\n          rot: p.rot + p.vr,\n        })),\n      )\n      confettiRaf.current = requestAnimationFrame(tick)\n    }\n    confettiRaf.current = requestAnimationFrame(tick)\n  }, [reducedMotion])\n\n  React.useEffect(() => {\n    if (step === 'success') fireConfetti()\n  }, [step, fireConfetti])\n\n  React.useEffect(() => {\n    return () => {\n      if (confettiRaf.current) cancelAnimationFrame(confettiRaf.current)\n    }\n  }, [])\n\n  const animClass = direction === 'forward' ? 'checkout-flow-anim-forward' : 'checkout-flow-anim-back'\n\n  return (\n    <div className=\"bg-card text-card-foreground mx-auto w-full max-w-3xl overflow-hidden rounded-xl border shadow-sm\">\n      {/* Stepper header (hidden on end states) */}\n      {step !== 'success' && step !== 'error' && (\n        <div className=\"bg-muted/30 border-b px-6 py-4\">\n          <Stepper steps={stepperSteps} value={stepIndex} />\n        </div>\n      )}\n\n      <div className=\"relative min-h-[420px]\">\n        {/* CART */}\n        {step === 'cart' && (\n          <div key=\"cart\" className={`p-6 ${animClass}`}>\n            <h3 className=\"mb-4 text-lg font-semibold\">Your cart</h3>\n\n            {cart.length === 0 ? (\n              <div className=\"text-muted-foreground py-12 text-center text-sm\">Your cart is empty.</div>\n            ) : (\n              <ul className=\"divide-border divide-y\">\n                {cart.map((item) => (\n                  <li key={item.id} className=\"flex items-center gap-3 py-3\">\n                    <div className=\"bg-muted size-14 shrink-0 overflow-hidden rounded-md\">\n                      {item.image && <img src={item.image} alt={item.name} className=\"size-full object-cover\" />}\n                    </div>\n                    <div className=\"min-w-0 flex-1\">\n                      <p className=\"truncate text-sm font-medium\">{item.name}</p>\n                      <p className=\"text-muted-foreground text-xs\">{fmt(item.price)} each</p>\n                    </div>\n                    <div className=\"flex items-center gap-1\">\n                      <Button variant=\"outline\" size=\"icon-sm\" aria-label=\"Decrease\" onClick={() => dec(item)}>\n                        <Minus className=\"size-3.5\" />\n                      </Button>\n                      <span className=\"w-8 text-center text-sm tabular-nums\">{item.qty}</span>\n                      <Button variant=\"outline\" size=\"icon-sm\" aria-label=\"Increase\" onClick={() => inc(item)}>\n                        <Plus className=\"size-3.5\" />\n                      </Button>\n                    </div>\n                    <div className=\"w-20 text-right text-sm font-medium tabular-nums\">{fmt(item.qty * item.price)}</div>\n                    <Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Remove\" onClick={() => remove(item)}>\n                      <Trash2 className=\"text-muted-foreground size-4\" />\n                    </Button>\n                  </li>\n                ))}\n              </ul>\n            )}\n\n            <Separator className=\"my-4\" />\n\n            <dl className=\"space-y-1.5 text-sm\">\n              <div className=\"flex justify-between\">\n                <dt className=\"text-muted-foreground\">Subtotal</dt>\n                <dd>{fmt(subtotal)}</dd>\n              </div>\n              {shippingFee > 0 && (\n                <div className=\"flex justify-between\">\n                  <dt className=\"text-muted-foreground\">Shipping</dt>\n                  <dd>{fmt(shippingFee)}</dd>\n                </div>\n              )}\n              {taxRate > 0 && (\n                <div className=\"flex justify-between\">\n                  <dt className=\"text-muted-foreground\">Tax</dt>\n                  <dd>{fmt(tax)}</dd>\n                </div>\n              )}\n              <div className=\"flex justify-between border-t pt-2 text-base font-semibold\">\n                <dt>Total</dt>\n                <dd>{fmt(total)}</dd>\n              </div>\n            </dl>\n\n            <div className=\"mt-5 flex justify-between\">\n              <Button variant=\"ghost\" onClick={() => onCancel?.()}>\n                Cancel\n              </Button>\n              <Button disabled={cart.length === 0} onClick={() => go('payment')}>\n                Continue to payment →\n              </Button>\n            </div>\n          </div>\n        )}\n\n        {/* PAYMENT */}\n        {step === 'payment' && (\n          <div key=\"payment\" className={`p-6 ${animClass}`}>\n            <button\n              type=\"button\"\n              className=\"text-muted-foreground hover:text-foreground mb-3 inline-flex items-center gap-1 text-sm\"\n              onClick={() => go('cart')}\n            >\n              <ArrowLeft className=\"size-3.5\" /> Back to cart\n            </button>\n            <PaymentForm\n              amount={total}\n              currency={currency}\n              onBrandChange={(b) => {\n                /* parent could react */ void b\n              }}\n              onSuccess={onPaymentSuccess}\n              onSubmit={async (payload) => {\n                captureFormPayload(payload) /* defer real call to confirm step */\n              }}\n            />\n          </div>\n        )}\n\n        {/* CONFIRM */}\n        {step === 'confirm' && (\n          <div key=\"confirm\" className={`p-6 ${animClass}`}>\n            <h3 className=\"mb-4 text-lg font-semibold\">Review your order</h3>\n\n            <div className=\"mb-4 flex items-start gap-4 rounded-lg border p-4\">\n              {paymentPayload && (\n                <PaymentCard\n                  number={paymentPayload.number}\n                  name={paymentPayload.name}\n                  expiry={paymentPayload.expiry}\n                  brand={paymentPayload.brand}\n                  variant=\"compact\"\n                  flip={false}\n                />\n              )}\n              <div className=\"min-w-0 flex-1 text-sm\">\n                <p className=\"font-medium\">Paying with card ending {paymentPayload?.number.slice(-4)}</p>\n                <p className=\"text-muted-foreground\">\n                  {paymentPayload?.name} · expires {paymentPayload?.expiry}\n                </p>\n                <button className=\"text-primary mt-2 text-xs underline\" onClick={() => go('payment')}>\n                  Change payment method\n                </button>\n              </div>\n            </div>\n\n            <ul className=\"divide-border divide-y border-y\">\n              {cart.map((item) => (\n                <li key={item.id} className=\"flex justify-between py-2 text-sm\">\n                  <span className=\"truncate\">\n                    {item.qty} × {item.name}\n                  </span>\n                  <span className=\"tabular-nums\">{fmt(item.qty * item.price)}</span>\n                </li>\n              ))}\n            </ul>\n\n            <dl className=\"mt-3 space-y-1 text-sm\">\n              <div className=\"flex justify-between\">\n                <dt className=\"text-muted-foreground\">Subtotal</dt>\n                <dd>{fmt(subtotal)}</dd>\n              </div>\n              {shippingFee > 0 && (\n                <div className=\"flex justify-between\">\n                  <dt className=\"text-muted-foreground\">Shipping</dt>\n                  <dd>{fmt(shippingFee)}</dd>\n                </div>\n              )}\n              {taxRate > 0 && (\n                <div className=\"flex justify-between\">\n                  <dt className=\"text-muted-foreground\">Tax</dt>\n                  <dd>{fmt(tax)}</dd>\n                </div>\n              )}\n              <div className=\"flex justify-between border-t pt-2 text-base font-semibold\">\n                <dt>Total</dt>\n                <dd>{fmt(total)}</dd>\n              </div>\n            </dl>\n\n            <div className=\"mt-5 flex justify-between\">\n              <Button variant=\"outline\" onClick={() => go('payment')}>\n                <ArrowLeft className=\"mr-1.5 size-4\" /> Back\n              </Button>\n              <Button disabled={placing} onClick={placeOrder}>\n                {placing ? 'Placing order…' : `Place order · ${fmt(total)}`}\n              </Button>\n            </div>\n          </div>\n        )}\n\n        {/* SUCCESS */}\n        {step === 'success' && (\n          <div key=\"success\" className={`relative flex flex-col items-center px-6 py-14 text-center ${animClass}`}>\n            {/* Confetti layer */}\n            <div className=\"pointer-events-none absolute inset-0 overflow-hidden\">\n              {particles.map((p) => (\n                <div\n                  key={p.id}\n                  className=\"absolute\"\n                  style={{\n                    left: p.x + '%',\n                    top: p.y + '%',\n                    width: p.size + 'px',\n                    height: p.size + 'px',\n                    background: p.color,\n                    transform: `rotate(${p.rot}deg)`,\n                    borderRadius: '2px',\n                  }}\n                />\n              ))}\n            </div>\n\n            <div className=\"bg-primary/10 ring-primary/30 relative grid size-20 place-items-center rounded-full ring-2\">\n              <svg\n                viewBox=\"0 0 52 52\"\n                className=\"text-primary size-12\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"4\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                aria-hidden=\"true\"\n              >\n                <path d=\"M14 27 L23 36 L40 18\" pathLength={100} className=\"checkout-flow-check-draw\" />\n              </svg>\n            </div>\n            <h3 className=\"mt-5 text-xl font-semibold\">Order placed</h3>\n            <p className=\"text-muted-foreground mt-1 text-sm\">Thanks — a confirmation will be sent shortly.</p>\n            <div className=\"bg-muted mt-4 inline-flex rounded-md px-3 py-1.5 font-mono text-sm\">#{orderId}</div>\n            <Button variant=\"outline\" className=\"mt-6\" onClick={() => onCancel?.()}>\n              Back to home\n            </Button>\n          </div>\n        )}\n\n        {/* ERROR */}\n        {step === 'error' && (\n          <div key=\"error\" className={`flex flex-col items-center px-6 py-14 text-center ${animClass}`}>\n            <div className=\"bg-destructive/10 ring-destructive/30 relative grid size-20 place-items-center rounded-full ring-2\">\n              <svg\n                viewBox=\"0 0 52 52\"\n                className=\"text-destructive size-12\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"4\"\n                strokeLinecap=\"round\"\n                aria-hidden=\"true\"\n              >\n                <path d=\"M16 16 L36 36 M36 16 L16 36\" pathLength={100} className=\"checkout-flow-check-draw\" />\n              </svg>\n            </div>\n            <h3 className=\"mt-5 text-xl font-semibold\">Payment failed</h3>\n            <p className=\"text-muted-foreground mt-1 max-w-xs text-sm\">\n              {lastError?.message || 'Something went wrong while charging your card.'}\n            </p>\n            <div className=\"mt-6 flex gap-2\">\n              <Button variant=\"outline\" onClick={() => onCancel?.()}>\n                Cancel\n              </Button>\n              <Button onClick={() => go('payment')}>Try again</Button>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:block",
      "target": "~/components/blocks/CheckoutFlow.tsx"
    }
  ],
  "dependencies": [
    "lucide-react"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/react/payment-card.json",
    "https://uipkge.dev/r/react/payment-form.json",
    "https://uipkge.dev/r/react/button.json",
    "https://uipkge.dev/r/react/input.json",
    "https://uipkge.dev/r/react/label.json",
    "https://uipkge.dev/r/react/stepper.json",
    "https://uipkge.dev/r/react/separator.json"
  ],
  "description": "Three-step checkout (cart → payment → confirm) with animated step transitions, an inline PaymentForm, a success screen that fires a confetti burst with an animated checkmark, and an error screen with retry. Async submit is forwarded to the consumer-provided `onSubmit`, which returns an `orderId` that ends up on the success screen.",
  "categories": [
    "commerce"
  ]
}