{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "checkout-flow",
  "title": "Checkout Flow",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-svelte/blocks/checkout-flow/CheckoutFlow.svelte",
      "content": "<script lang=\"ts\" module>\n  export type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'\n\n  export interface PaymentPayload {\n    number: string\n    name: string\n    expiry: string\n    cvc: string\n    brand: CardBrand\n  }\n\n  export interface CartItem {\n    id: string\n    name: string\n    image?: string\n    qty: number\n    price: number\n  }\n\n  export 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\n  type Step = 'cart' | 'payment' | 'confirm' | 'success' | 'error'\n\n  interface 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</script>\n\n<script lang=\"ts\">\n  import { onDestroy } from 'svelte'\n  import { fly } from 'svelte/transition'\n  import { ArrowLeft, Check, Minus, Plus, Trash2, X } from '@lucide/svelte'\n  import { PaymentCard } from '$lib/components/ui/payment-card'\n  import { Button } from '$lib/components/ui/button'\n  import { Stepper } from '$lib/components/ui/stepper'\n  import { Separator } from '$lib/components/ui/separator'\n  import PaymentForm from '$lib/components/blocks/payment-form/PaymentForm.svelte'\n\n  let {\n    items,\n    currency = 'USD',\n    shippingFee = 0,\n    taxRate = 0,\n    onSubmit,\n    oncomplete,\n    oncancel,\n  }: CheckoutFlowProps = $props()\n\n  let step = $state<Step>('cart')\n  let direction = $state<'forward' | 'back'>('forward')\n\n  let cart = $state<CartItem[]>(items.map((i) => ({ ...i })))\n  let paymentPayload = $state<PaymentPayload | null>(null)\n  let orderId = $state<string | null>(null)\n  let lastError = $state<Error | null>(null)\n  let placing = $state(false)\n\n  $effect(() => {\n    cart = items.map((i) => ({ ...i }))\n  })\n\n  function go(next: Step) {\n    const order: Record<Step, number> = { cart: 0, payment: 1, confirm: 2, success: 3, error: 3 }\n    direction = order[next] >= order[step] ? 'forward' : 'back'\n    step = next\n  }\n\n  const subtotal = $derived(cart.reduce((s, i) => s + i.qty * i.price, 0))\n  const tax = $derived(subtotal * taxRate)\n  const total = $derived(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 = $derived(step === 'cart' ? 1 : step === 'payment' ? 2 : 3)\n\n  const stepperSteps = [\n    { id: 'cart', title: 'Cart' },\n    { id: 'payment', title: 'Payment' },\n    { id: 'confirm', title: 'Confirm' },\n  ]\n\n  function dec(item: CartItem) {\n    if (item.qty > 1) item.qty--\n  }\n  function inc(item: CartItem) {\n    item.qty++\n  }\n  function remove(item: CartItem) {\n    cart = cart.filter((i) => i.id !== item.id)\n  }\n\n  async function placeOrder() {\n    if (!paymentPayload || placing) return\n    placing = true\n    try {\n      if (onSubmit) {\n        const res = await onSubmit(paymentPayload)\n        orderId = res?.orderId ?? Math.random().toString(36).slice(2, 10).toUpperCase()\n      } else {\n        orderId = Math.random().toString(36).slice(2, 10).toUpperCase()\n      }\n      oncomplete?.({ orderId })\n      go('success')\n    } catch (err) {\n      lastError = err instanceof Error ? err : new Error(String(err))\n      go('error')\n    } finally {\n      placing = false\n    }\n  }\n\n  function onPaymentSuccess() {\n    go('confirm')\n  }\n\n  function captureFormPayload(payload: PaymentPayload) {\n    paymentPayload = payload\n  }\n\n  // Confetti — 60 particles, simple physics, ~1.8s\n  let particles = $state<Particle[]>([])\n  let confettiRaf: number | null = null\n  const reducedMotion =\n    typeof window !== 'undefined' && window.matchMedia\n      ? window.matchMedia('(prefers-reduced-motion: reduce)').matches\n      : false\n\n  function fireConfetti() {\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    particles = 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    const start = performance.now()\n    const tick = (now: number) => {\n      const elapsed = now - start\n      if (elapsed > 1800) {\n        particles = []\n        confettiRaf = null\n        return\n      }\n      particles = particles.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      confettiRaf = requestAnimationFrame(tick)\n    }\n    confettiRaf = requestAnimationFrame(tick)\n  }\n\n  $effect(() => {\n    if (step === 'success') fireConfetti()\n  })\n\n  onDestroy(() => {\n    if (confettiRaf) cancelAnimationFrame(confettiRaf)\n  })\n\n  const flyX = $derived(direction === 'forward' ? 24 : -24)\n</script>\n\n<div\n  data-slot=\"checkout-flow\"\n  class=\"bg-card text-card-foreground mx-auto w-full max-w-3xl overflow-hidden rounded-xl border shadow-sm\"\n>\n  <!-- Stepper header (hidden on end states) -->\n  {#if step !== 'success' && step !== 'error'}\n    <div class=\"bg-muted/30 border-b px-6 py-4\">\n      <Stepper\n        steps={stepperSteps}\n        value={stepIndex}\n        onValueChange={(n) => go(n === 1 ? 'cart' : n === 2 ? 'payment' : 'confirm')}\n      />\n    </div>\n  {/if}\n\n  <div class=\"relative min-h-[420px]\">\n    {#if step === 'cart'}\n      <!-- CART -->\n      <div transition:fly={{ x: flyX, duration: reducedMotion ? 0 : 280 }} class=\"p-6\">\n        <h3 class=\"mb-4 text-lg font-semibold\">Your cart</h3>\n\n        {#if cart.length === 0}\n          <div class=\"text-muted-foreground py-12 text-center text-sm\">Your cart is empty.</div>\n        {:else}\n          <ul class=\"divide-border divide-y\">\n            {#each cart as item (item.id)}\n              <li class=\"flex items-center gap-3 py-3\">\n                <div class=\"bg-muted size-14 shrink-0 overflow-hidden rounded-md\">\n                  {#if item.image}\n                    <img src={item.image} alt={item.name} class=\"size-full object-cover\" />\n                  {/if}\n                </div>\n                <div class=\"min-w-0 flex-1\">\n                  <p class=\"truncate text-sm font-medium\">{item.name}</p>\n                  <p class=\"text-muted-foreground text-xs\">{fmt(item.price)} each</p>\n                </div>\n                <div class=\"flex items-center gap-1\">\n                  <Button variant=\"outline\" size=\"icon-sm\" aria-label=\"Decrease\" onclick={() => dec(item)}>\n                    <Minus class=\"size-3.5\" />\n                  </Button>\n                  <span class=\"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 class=\"size-3.5\" />\n                  </Button>\n                </div>\n                <div class=\"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 class=\"text-muted-foreground size-4\" />\n                </Button>\n              </li>\n            {/each}\n          </ul>\n        {/if}\n\n        <Separator class=\"my-4\" />\n\n        <dl class=\"space-y-1.5 text-sm\">\n          <div class=\"flex justify-between\">\n            <dt class=\"text-muted-foreground\">Subtotal</dt>\n            <dd>{fmt(subtotal)}</dd>\n          </div>\n          {#if shippingFee > 0}\n            <div class=\"flex justify-between\">\n              <dt class=\"text-muted-foreground\">Shipping</dt>\n              <dd>{fmt(shippingFee)}</dd>\n            </div>\n          {/if}\n          {#if taxRate > 0}\n            <div class=\"flex justify-between\">\n              <dt class=\"text-muted-foreground\">Tax</dt>\n              <dd>{fmt(tax)}</dd>\n            </div>\n          {/if}\n          <div class=\"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 class=\"mt-5 flex justify-between\">\n          <Button variant=\"ghost\" onclick={() => oncancel?.()}>Cancel</Button>\n          <Button disabled={cart.length === 0} onclick={() => go('payment')}>Continue to payment →</Button>\n        </div>\n      </div>\n    {:else if step === 'payment'}\n      <!-- PAYMENT -->\n      <div transition:fly={{ x: flyX, duration: reducedMotion ? 0 : 280 }} class=\"p-6\">\n        <button\n          type=\"button\"\n          class=\"text-muted-foreground hover:text-foreground mb-3 inline-flex items-center gap-1 text-sm\"\n          onclick={() => go('cart')}\n        >\n          <ArrowLeft class=\"size-3.5\" /> Back to cart\n        </button>\n        <PaymentForm\n          amount={total}\n          {currency}\n          onSuccess={onPaymentSuccess}\n          onSubmit={async (payload: PaymentPayload) => {\n            captureFormPayload(payload) /* defer real call to confirm step */\n          }}\n        />\n      </div>\n    {:else if step === 'confirm'}\n      <!-- CONFIRM -->\n      <div transition:fly={{ x: flyX, duration: reducedMotion ? 0 : 280 }} class=\"p-6\">\n        <h3 class=\"mb-4 text-lg font-semibold\">Review your order</h3>\n\n        <div class=\"mb-4 flex items-start gap-4 rounded-lg border p-4\">\n          {#if 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          {/if}\n          <div class=\"min-w-0 flex-1 text-sm\">\n            <p class=\"font-medium\">Paying with card ending {paymentPayload?.number.slice(-4)}</p>\n            <p class=\"text-muted-foreground\">{paymentPayload?.name} · expires {paymentPayload?.expiry}</p>\n            <button class=\"text-primary mt-2 text-xs underline\" onclick={() => go('payment')}>\n              Change payment method\n            </button>\n          </div>\n        </div>\n\n        <ul class=\"divide-border divide-y border-y\">\n          {#each cart as item (item.id)}\n            <li class=\"flex justify-between py-2 text-sm\">\n              <span class=\"truncate\">{item.qty} × {item.name}</span>\n              <span class=\"tabular-nums\">{fmt(item.qty * item.price)}</span>\n            </li>\n          {/each}\n        </ul>\n\n        <dl class=\"mt-3 space-y-1 text-sm\">\n          <div class=\"flex justify-between\">\n            <dt class=\"text-muted-foreground\">Subtotal</dt>\n            <dd>{fmt(subtotal)}</dd>\n          </div>\n          {#if shippingFee > 0}\n            <div class=\"flex justify-between\">\n              <dt class=\"text-muted-foreground\">Shipping</dt>\n              <dd>{fmt(shippingFee)}</dd>\n            </div>\n          {/if}\n          {#if taxRate > 0}\n            <div class=\"flex justify-between\">\n              <dt class=\"text-muted-foreground\">Tax</dt>\n              <dd>{fmt(tax)}</dd>\n            </div>\n          {/if}\n          <div class=\"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 class=\"mt-5 flex justify-between\">\n          <Button variant=\"outline\" onclick={() => go('payment')}>\n            <ArrowLeft class=\"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    {:else if step === 'success'}\n      <!-- SUCCESS -->\n      <div\n        transition:fly={{ x: flyX, duration: reducedMotion ? 0 : 280 }}\n        class=\"relative flex flex-col items-center px-6 py-14 text-center\"\n      >\n        <!-- Confetti layer -->\n        <div class=\"pointer-events-none absolute inset-0 overflow-hidden\">\n          {#each particles as p (p.id)}\n            <div\n              class=\"absolute\"\n              style={`left:${p.x}%;top:${p.y}%;width:${p.size}px;height:${p.size}px;background:${p.color};transform:rotate(${p.rot}deg);border-radius:2px`}\n            ></div>\n          {/each}\n        </div>\n\n        <div class=\"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            class=\"text-primary size-12\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"4\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            aria-hidden=\"true\"\n          >\n            <path d=\"M14 27 L23 36 L40 18\" pathLength=\"100\" class=\"check-draw\" />\n          </svg>\n        </div>\n        <h3 class=\"mt-5 text-xl font-semibold\">Order placed</h3>\n        <p class=\"text-muted-foreground mt-1 text-sm\">Thanks — a confirmation will be sent shortly.</p>\n        <div class=\"bg-muted mt-4 inline-flex rounded-md px-3 py-1.5 font-mono text-sm\">#{orderId}</div>\n        <Button variant=\"outline\" class=\"mt-6\" onclick={() => oncancel?.()}>Back to home</Button>\n      </div>\n    {:else if step === 'error'}\n      <!-- ERROR -->\n      <div\n        transition:fly={{ x: flyX, duration: reducedMotion ? 0 : 280 }}\n        class=\"flex flex-col items-center px-6 py-14 text-center\"\n      >\n        <div\n          class=\"bg-destructive/10 ring-destructive/30 relative grid size-20 place-items-center rounded-full ring-2\"\n        >\n          <svg\n            viewBox=\"0 0 52 52\"\n            class=\"text-destructive size-12\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"4\"\n            stroke-linecap=\"round\"\n            aria-hidden=\"true\"\n          >\n            <path d=\"M16 16 L36 36 M36 16 L16 36\" pathLength=\"100\" class=\"check-draw\" />\n          </svg>\n        </div>\n        <h3 class=\"mt-5 text-xl font-semibold\">Payment failed</h3>\n        <p class=\"text-muted-foreground mt-1 max-w-xs text-sm\">\n          {lastError?.message || 'Something went wrong while charging your card.'}\n        </p>\n        <div class=\"mt-6 flex gap-2\">\n          <Button variant=\"outline\" onclick={() => oncancel?.()}>Cancel</Button>\n          <Button onclick={() => go('payment')}>Try again</Button>\n        </div>\n      </div>\n    {/if}\n  </div>\n</div>\n\n<style>\n  .check-draw {\n    stroke-dasharray: 100;\n    stroke-dashoffset: 100;\n    animation: check-draw 700ms cubic-bezier(0.4, 0, 0.2, 1) 120ms forwards;\n  }\n  @keyframes check-draw {\n    to {\n      stroke-dashoffset: 0;\n    }\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    .check-draw {\n      animation: none;\n      stroke-dashoffset: 0;\n    }\n  }\n</style>\n",
      "type": "registry:block",
      "target": "~/src/lib/components/blocks/checkout-flow/CheckoutFlow.svelte"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/svelte/payment-card.json",
    "https://uipkge.dev/r/svelte/payment-form.json",
    "https://uipkge.dev/r/svelte/button.json",
    "https://uipkge.dev/r/svelte/stepper.json",
    "https://uipkge.dev/r/svelte/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"
  ]
}