{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "checkout-flow",
  "title": "Checkout Flow",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-vue/blocks/checkout-flow/CheckoutFlow.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, onUnmounted, ref, watch } from 'vue'\nimport { ArrowLeft, Check, Minus, Plus, Trash2, X } from 'lucide-vue-next'\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.vue'\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\nconst props = withDefaults(\n  defineProps<{\n    items: CartItem[]\n    currency?: string\n    shippingFee?: number\n    taxRate?: number\n    onSubmit?: (data: PaymentPayload) => Promise<{ orderId: string }>\n  }>(),\n  {\n    currency: 'USD',\n    shippingFee: 0,\n    taxRate: 0,\n  },\n)\n\nconst emit = defineEmits<{\n  complete: [payload: { orderId: string }]\n  cancel: []\n}>()\n\ntype Step = 'cart' | 'payment' | 'confirm' | 'success' | 'error'\nconst step = ref<Step>('cart')\nconst direction = ref<'forward' | 'back'>('forward')\n\nconst cart = ref<CartItem[]>(props.items.map((i) => ({ ...i })))\nconst paymentPayload = ref<PaymentPayload | null>(null)\nconst orderId = ref<string | null>(null)\nconst lastError = ref<Error | null>(null)\nconst placing = ref(false)\n\nwatch(\n  () => props.items,\n  (next) => {\n    cart.value = next.map((i) => ({ ...i }))\n  },\n)\n\nfunction go(next: Step) {\n  const order: Record<Step, number> = { cart: 0, payment: 1, confirm: 2, success: 3, error: 3 }\n  direction.value = order[next] >= order[step.value] ? 'forward' : 'back'\n  step.value = next\n}\n\nconst subtotal = computed(() => cart.value.reduce((s, i) => s + i.qty * i.price, 0))\nconst tax = computed(() => subtotal.value * props.taxRate)\nconst total = computed(() => subtotal.value + props.shippingFee + tax.value)\n\nconst fmt = (n: number) => {\n  try {\n    return new Intl.NumberFormat(undefined, { style: 'currency', currency: props.currency }).format(n)\n  } catch {\n    return `${props.currency} ${n.toFixed(2)}`\n  }\n}\n\nconst stepIndex = computed(() => {\n  if (step.value === 'cart') return 1\n  if (step.value === 'payment') return 2\n  return 3\n})\n\nconst stepperSteps = [{ title: 'Cart' }, { title: 'Payment' }, { title: 'Confirm' }]\n\nfunction dec(item: CartItem) {\n  if (item.qty > 1) item.qty--\n}\nfunction inc(item: CartItem) {\n  item.qty++\n}\nfunction remove(item: CartItem) {\n  cart.value = cart.value.filter((i) => i.id !== item.id)\n}\n\nasync function placeOrder() {\n  if (!paymentPayload.value || placing.value) return\n  placing.value = true\n  try {\n    if (props.onSubmit) {\n      const res = await props.onSubmit(paymentPayload.value)\n      orderId.value = res?.orderId ?? Math.random().toString(36).slice(2, 10).toUpperCase()\n    } else {\n      orderId.value = Math.random().toString(36).slice(2, 10).toUpperCase()\n    }\n    emit('complete', { orderId: orderId.value })\n    go('success')\n  } catch (err) {\n    lastError.value = err instanceof Error ? err : new Error(String(err))\n    go('error')\n  } finally {\n    placing.value = false\n  }\n}\n\nfunction onPaymentSuccess() {\n  go('confirm')\n}\n\nfunction captureFormPayload(payload: PaymentPayload) {\n  paymentPayload.value = payload\n}\n\n// Confetti — 50 particles, simple physics, ~1.4s\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}\nconst particles = ref<Particle[]>([])\nlet confettiRaf: number | null = null\nconst reducedMotion =\n  typeof window !== 'undefined' && window.matchMedia\n    ? window.matchMedia('(prefers-reduced-motion: reduce)').matches\n    : false\n\nfunction 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.value = 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.value = []\n      confettiRaf = null\n      return\n    }\n    particles.value = particles.value.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\nwatch(step, (s) => {\n  if (s === 'success') fireConfetti()\n})\n\nonUnmounted(() => {\n  if (confettiRaf) cancelAnimationFrame(confettiRaf)\n})\n\nconst transitionName = computed(() => (direction.value === 'forward' ? 'flow-forward' : 'flow-back'))\n</script>\n\n<template>\n  <div class=\"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    <div v-if=\"step !== 'success' && step !== 'error'\" class=\"bg-muted/30 border-b px-6 py-4\">\n      <Stepper :steps=\"stepperSteps\" :model-value=\"stepIndex\" />\n    </div>\n\n    <div class=\"relative min-h-[420px]\">\n      <Transition :name=\"transitionName\" mode=\"out-in\">\n        <!-- CART -->\n        <div v-if=\"step === 'cart'\" key=\"cart\" class=\"p-6\">\n          <h3 class=\"mb-4 text-lg font-semibold\">Your cart</h3>\n\n          <div v-if=\"cart.length === 0\" class=\"text-muted-foreground py-12 text-center text-sm\">\n            Your cart is empty.\n          </div>\n\n          <ul v-else class=\"divide-border divide-y\">\n            <li v-for=\"item in cart\" :key=\"item.id\" class=\"flex items-center gap-3 py-3\">\n              <div class=\"bg-muted size-14 shrink-0 overflow-hidden rounded-md\">\n                <img v-if=\"item.image\" :src=\"item.image\" :alt=\"item.name\" class=\"size-full object-cover\" />\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\" @click=\"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\" @click=\"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\" @click=\"remove(item)\">\n                <Trash2 class=\"text-muted-foreground size-4\" />\n              </Button>\n            </li>\n          </ul>\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            <div v-if=\"shippingFee > 0\" class=\"flex justify-between\">\n              <dt class=\"text-muted-foreground\">Shipping</dt>\n              <dd>{{ fmt(shippingFee) }}</dd>\n            </div>\n            <div v-if=\"taxRate > 0\" class=\"flex justify-between\">\n              <dt class=\"text-muted-foreground\">Tax</dt>\n              <dd>{{ fmt(tax) }}</dd>\n            </div>\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\" @click=\"emit('cancel')\">Cancel</Button>\n            <Button :disabled=\"cart.length === 0\" @click=\"go('payment')\">Continue to payment →</Button>\n          </div>\n        </div>\n\n        <!-- PAYMENT -->\n        <div v-else-if=\"step === 'payment'\" key=\"payment\" 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            @click=\"go('cart')\"\n          >\n            <ArrowLeft class=\"size-3.5\" /> Back to cart\n          </button>\n          <PaymentForm\n            :amount=\"total\"\n            :currency=\"currency\"\n            @brand-change=\"\n              (b) => {\n                /* parent could react */ void b\n              }\n            \"\n            @success=\"onPaymentSuccess\"\n            :on-submit=\"\n              async (payload) => {\n                captureFormPayload(payload) /* defer real call to confirm step */\n              }\n            \"\n          />\n        </div>\n\n        <!-- CONFIRM -->\n        <div v-else-if=\"step === 'confirm'\" key=\"confirm\" 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            <PaymentCard\n              v-if=\"paymentPayload\"\n              :number=\"paymentPayload.number\"\n              :name=\"paymentPayload.name\"\n              :expiry=\"paymentPayload.expiry\"\n              :brand=\"paymentPayload.brand\"\n              variant=\"compact\"\n              :flip=\"false\"\n            />\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\" @click=\"go('payment')\">Change payment method</button>\n            </div>\n          </div>\n\n          <ul class=\"divide-border divide-y border-y\">\n            <li v-for=\"item in cart\" :key=\"item.id\" 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          </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            <div v-if=\"shippingFee > 0\" class=\"flex justify-between\">\n              <dt class=\"text-muted-foreground\">Shipping</dt>\n              <dd>{{ fmt(shippingFee) }}</dd>\n            </div>\n            <div v-if=\"taxRate > 0\" class=\"flex justify-between\">\n              <dt class=\"text-muted-foreground\">Tax</dt>\n              <dd>{{ fmt(tax) }}</dd>\n            </div>\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\" @click=\"go('payment')\"><ArrowLeft class=\"mr-1.5 size-4\" /> Back</Button>\n            <Button :disabled=\"placing\" @click=\"placeOrder\">{{\n              placing ? 'Placing order…' : `Place order · ${fmt(total)}`\n            }}</Button>\n          </div>\n        </div>\n\n        <!-- SUCCESS -->\n        <div\n          v-else-if=\"step === 'success'\"\n          key=\"success\"\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            <div\n              v-for=\"p in particles\"\n              :key=\"p.id\"\n              class=\"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          </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\" @click=\"emit('cancel')\">Back to home</Button>\n        </div>\n\n        <!-- ERROR -->\n        <div v-else-if=\"step === 'error'\" key=\"error\" class=\"flex flex-col items-center px-6 py-14 text-center\">\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\" @click=\"emit('cancel')\">Cancel</Button>\n            <Button @click=\"go('payment')\">Try again</Button>\n          </div>\n        </div>\n      </Transition>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n.flow-forward-enter-active,\n.flow-forward-leave-active,\n.flow-back-enter-active,\n.flow-back-leave-active {\n  transition:\n    transform 280ms cubic-bezier(0.4, 0, 0.2, 1),\n    opacity 220ms ease;\n}\n.flow-forward-enter-from {\n  transform: translateX(24px);\n  opacity: 0;\n}\n.flow-forward-leave-to {\n  transform: translateX(-24px);\n  opacity: 0;\n}\n.flow-back-enter-from {\n  transform: translateX(-24px);\n  opacity: 0;\n}\n.flow-back-leave-to {\n  transform: translateX(24px);\n  opacity: 0;\n}\n\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  .flow-forward-enter-active,\n  .flow-forward-leave-active,\n  .flow-back-enter-active,\n  .flow-back-leave-active {\n    transition-duration: 0ms;\n  }\n  .check-draw {\n    animation: none;\n    stroke-dashoffset: 0;\n  }\n}\n</style>\n",
      "type": "registry:block",
      "target": "~/app/components/blocks/CheckoutFlow.vue"
    }
  ],
  "dependencies": [
    "lucide-vue-next"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/payment-card.json",
    "https://uipkge.dev/r/vue/payment-form.json",
    "https://uipkge.dev/r/vue/button.json",
    "https://uipkge.dev/r/vue/input.json",
    "https://uipkge.dev/r/vue/label.json",
    "https://uipkge.dev/r/vue/stepper.json",
    "https://uipkge.dev/r/vue/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"
  ]
}