{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "checkout-flow",
  "title": "Checkout Flow",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-angular/blocks/checkout-flow/checkout-flow.component.ts",
      "content": "// Three-step checkout (cart → payment → confirm) with an inline PaymentForm, a success\n// screen that fires a confetti burst, and an error screen with retry. Async submit is\n// forwarded to the consumer-provided `onSubmit`, which returns an `orderId` shown on the\n// success screen. Port of the React block 1:1.\nimport {\n  ChangeDetectionStrategy,\n  Component,\n  DestroyRef,\n  EventEmitter,\n  Input,\n  Output,\n  computed,\n  inject,\n  signal,\n} from '@angular/core'\nimport { ArrowLeft, LucideAngularModule, Minus, Plus, Trash2 } from 'lucide-angular'\nimport { cn } from '@/lib/utils'\nimport { UiButtonComponent } from '@/ui/button/button.component'\nimport { UiPaymentCardComponent } from '@/ui/payment-card/payment-card.component'\nimport { UiSeparatorComponent } from '@/ui/separator/separator.component'\nimport { UiStepperComponent, type StepperStep } from '@/ui/stepper/stepper.component'\nimport { UiPaymentFormComponent, type PaymentPayload } from '../payment-form/payment-form.component'\n\nexport type CheckoutCardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'\n\nexport interface CartItem {\n  id: string\n  name: string\n  image?: string\n  qty: number\n  price: number\n}\n\nexport type CheckoutStep = 'cart' | 'payment' | 'confirm' | 'success' | 'error'\n\ninterface Particle {\n  id: number\n  x: number\n  y: number\n  color: string\n  size: number\n  rot: number\n}\n\nconst stepperSteps: StepperStep[] = [\n  { id: 'cart', title: 'Cart' },\n  { id: 'payment', title: 'Payment' },\n  { id: 'confirm', title: 'Confirm' },\n]\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-checkout-flow, [ui-checkout-flow]',\n  standalone: true,\n  // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.\n  host: { '[attr.class]': '\"contents\"' },\n  imports: [\n    LucideAngularModule,\n    UiButtonComponent,\n    UiPaymentCardComponent,\n    UiPaymentFormComponent,\n    UiSeparatorComponent,\n    UiStepperComponent,\n  ],\n  template: `\n    <div data-slot=\"checkout-flow\" [class]=\"rootClass\">\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          <ui-stepper [steps]=\"stepperSteps\" [value]=\"stepIndex()\" />\n        </div>\n      }\n\n      <div class=\"relative min-h-[420px]\">\n        <!-- CART -->\n        @if (step() === 'cart') {\n          <div 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                @for (item of cart(); track 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                      }\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 ui-button variant=\"outline\" size=\"icon-sm\" aria-label=\"Decrease\" (click)=\"dec(item)\">\n                        <lucide-icon [img]=\"Minus\" class=\"size-3.5\" />\n                      </button>\n                      <span class=\"w-8 text-center text-sm tabular-nums\">{{ item.qty }}</span>\n                      <button ui-button variant=\"outline\" size=\"icon-sm\" aria-label=\"Increase\" (click)=\"inc(item)\">\n                        <lucide-icon [img]=\"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 ui-button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Remove\" (click)=\"removeItem(item)\">\n                      <lucide-icon [img]=\"Trash2\" class=\"text-muted-foreground size-4\" />\n                    </button>\n                  </li>\n                }\n              </ul>\n            }\n\n            <ui-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              }\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              }\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 ui-button variant=\"ghost\" (click)=\"cancel.emit()\">Cancel</button>\n              <button ui-button [disabled]=\"cart().length === 0\" (click)=\"go('payment')\">Continue to payment →</button>\n            </div>\n          </div>\n        }\n\n        <!-- PAYMENT -->\n        @if (step() === 'payment') {\n          <div 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              <lucide-icon [img]=\"ArrowLeft\" class=\"size-3.5\" /> Back to cart\n            </button>\n            <ui-payment-form\n              [amount]=\"total()\"\n              [currency]=\"currency\"\n              [onSubmit]=\"captureForConfirm\"\n              (success)=\"onPaymentSuccess()\"\n            />\n          </div>\n        }\n\n        <!-- CONFIRM -->\n        @if (step() === 'confirm') {\n          <div class=\"p-6\">\n            <h3 class=\"mb-4 text-lg font-semibold\">Review your order</h3>\n\n            @if (paymentPayload(); as payload) {\n              <div class=\"mb-4 flex items-start gap-4 rounded-lg border p-4\">\n                <ui-payment-card\n                  [number]=\"payload.number\"\n                  [name]=\"payload.name\"\n                  [expiry]=\"payload.expiry\"\n                  [brand]=\"payload.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 {{ lastFour(payload.number) }}</p>\n                  <p class=\"text-muted-foreground\">{{ payload.name }} · expires {{ payload.expiry }}</p>\n                  <button class=\"text-primary mt-2 text-xs underline\" (click)=\"go('payment')\">\n                    Change payment method\n                  </button>\n                </div>\n              </div>\n            }\n\n            <ul class=\"divide-border divide-y border-y\">\n              @for (item of cart(); track 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              }\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              }\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              }\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 ui-button variant=\"outline\" (click)=\"go('payment')\">\n                <lucide-icon [img]=\"ArrowLeft\" class=\"mr-1.5 size-4\" /> Back\n              </button>\n              <button ui-button [disabled]=\"placing()\" (click)=\"placeOrder()\">\n                {{ placing() ? 'Placing order…' : 'Place order · ' + fmt(total()) }}\n              </button>\n            </div>\n          </div>\n        }\n\n        <!-- SUCCESS -->\n        @if (step() === 'success') {\n          <div class=\"relative flex flex-col items-center px-6 py-14 text-center\">\n            <!-- Confetti layer -->\n            <div class=\"pointer-events-none absolute inset-0 overflow-hidden\">\n              @for (p of particles(); track p.id) {\n                <div\n                  class=\"absolute\"\n                  [style.left]=\"p.x + '%'\"\n                  [style.top]=\"p.y + '%'\"\n                  [style.width]=\"p.size + 'px'\"\n                  [style.height]=\"p.size + 'px'\"\n                  [style.background]=\"p.color\"\n                  [style.transform]=\"'rotate(' + p.rot + 'deg)'\"\n                  style=\"border-radius: 2px;\"\n                ></div>\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\" />\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 ui-button variant=\"outline\" class=\"mt-6\" (click)=\"cancel.emit()\">Back to home</button>\n          </div>\n        }\n\n        <!-- ERROR -->\n        @if (step() === 'error') {\n          <div 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\" />\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\">{{ errorMessage() }}</p>\n            <div class=\"mt-6 flex gap-2\">\n              <button ui-button variant=\"outline\" (click)=\"cancel.emit()\">Cancel</button>\n              <button ui-button (click)=\"go('payment')\">Try again</button>\n            </div>\n          </div>\n        }\n      </div>\n    </div>\n  `,\n})\nexport class UiCheckoutFlowComponent {\n  protected readonly ArrowLeft = ArrowLeft\n  protected readonly Minus = Minus\n  protected readonly Plus = Plus\n  protected readonly Trash2 = Trash2\n\n  protected readonly stepperSteps = stepperSteps\n\n  private readonly _items = signal<CartItem[]>([])\n  @Input() set items(v: CartItem[]) {\n    const list = (v ?? []).map((i) => ({ ...i }))\n    this._items.set(list)\n    this.cart.set(list.map((i) => ({ ...i })))\n  }\n  private readonly _currency = signal('USD')\n  @Input() set currency(v: string) {\n    if (v) this._currency.set(v)\n  }\n  get currency(): string {\n    return this._currency()\n  }\n  readonly shippingFee = signal(0)\n  @Input('shippingFee') set shippingFeeInput(v: number) {\n    this.shippingFee.set(v ?? 0)\n  }\n  readonly taxRate = signal(0)\n  @Input('taxRate') set taxRateInput(v: number) {\n    this.taxRate.set(v ?? 0)\n  }\n  @Input() onSubmit?: (data: PaymentPayload) => Promise<{ orderId: string }>\n  @Input('class') className?: string\n\n  @Output() readonly complete = new EventEmitter<{ orderId: string }>()\n  @Output() readonly cancel = new EventEmitter<void>()\n\n  get rootClass(): string {\n    return cn(\n      'bg-card text-card-foreground mx-auto w-full max-w-3xl overflow-hidden rounded-xl border shadow-sm',\n      this.className,\n    )\n  }\n\n  readonly step = signal<CheckoutStep>('cart')\n  readonly cart = signal<CartItem[]>([])\n  readonly paymentPayload = signal<PaymentPayload | null>(null)\n  readonly orderId = signal<string | null>(null)\n  readonly lastError = signal<Error | null>(null)\n  readonly placing = signal(false)\n  readonly particles = signal<Particle[]>([])\n\n  private confettiTimer: ReturnType<typeof setTimeout> | undefined\n\n  constructor() {\n    inject(DestroyRef).onDestroy(() => clearTimeout(this.confettiTimer))\n  }\n\n  readonly subtotal = computed(() => this.cart().reduce((s, i) => s + i.qty * i.price, 0))\n  readonly tax = computed(() => this.subtotal() * this.taxRate())\n  readonly total = computed(() => this.subtotal() + this.shippingFee() + this.tax())\n\n  readonly stepIndex = computed(() => {\n    const s = this.step()\n    if (s === 'cart') return 1\n    if (s === 'payment') return 2\n    return 3\n  })\n\n  readonly errorMessage = computed(() => this.lastError()?.message || 'Something went wrong while charging your card.')\n\n  fmt(n: number): string {\n    try {\n      return new Intl.NumberFormat(undefined, { style: 'currency', currency: this._currency() }).format(n)\n    } catch {\n      return `${this._currency()} ${n.toFixed(2)}`\n    }\n  }\n\n  lastFour(number: string): string {\n    return number.replace(/\\D/g, '').slice(-4)\n  }\n\n  go(next: CheckoutStep): void {\n    this.step.set(next)\n    if (next === 'success') this.fireConfetti()\n  }\n\n  dec(item: CartItem): void {\n    if (item.qty > 1) {\n      item.qty--\n      this.cart.update((prev) => [...prev])\n    }\n  }\n\n  inc(item: CartItem): void {\n    item.qty++\n    this.cart.update((prev) => [...prev])\n  }\n\n  removeItem(item: CartItem): void {\n    this.cart.update((prev) => prev.filter((i) => i.id !== item.id))\n  }\n\n  /** PaymentForm hook: capture the payload and defer the real charge to the confirm step. */\n  readonly captureForConfirm = async (payload: PaymentPayload): Promise<void> => {\n    this.paymentPayload.set(payload)\n  }\n\n  onPaymentSuccess(): void {\n    this.go('confirm')\n  }\n\n  async placeOrder(): Promise<void> {\n    if (!this.paymentPayload() || this.placing()) return\n    this.placing.set(true)\n    try {\n      if (this.onSubmit) {\n        const res = await this.onSubmit(this.paymentPayload()!)\n        this.orderId.set(res?.orderId ?? Math.random().toString(36).slice(2, 10).toUpperCase())\n      } else {\n        this.orderId.set(Math.random().toString(36).slice(2, 10).toUpperCase())\n      }\n      this.complete.emit({ orderId: this.orderId()! })\n      this.go('success')\n    } catch (err) {\n      this.lastError.set(err instanceof Error ? err : new Error(String(err)))\n      this.go('error')\n    } finally {\n      this.placing.set(false)\n    }\n  }\n\n  private fireConfetti(): void {\n    if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return\n    const colors = ['#22c55e', '#3b82f6', '#eab308', '#ec4899', '#f97316', '#8b5cf6']\n    this.particles.set(\n      Array.from({ length: 60 }, (_, id) => ({\n        id,\n        x: 50 + (Math.random() - 0.5) * 20,\n        y: 40 + (Math.random() - 0.5) * 10,\n        color: colors[Math.floor(Math.random() * colors.length)]!,\n        size: 4 + Math.random() * 6,\n        rot: Math.random() * 360,\n      })),\n    )\n    clearTimeout(this.confettiTimer)\n    this.confettiTimer = setTimeout(() => this.particles.set([]), 1800)\n  }\n}\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/checkout-flow/checkout-flow.component.ts"
    },
    {
      "path": "packages/registry-angular/blocks/checkout-flow/index.ts",
      "content": "export {\n  UiCheckoutFlowComponent,\n  type CartItem,\n  type CheckoutCardBrand,\n  type CheckoutStep,\n} from './checkout-flow.component'\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/checkout-flow/index.ts"
    }
  ],
  "dependencies": [
    "lucide-angular"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/payment-card.json",
    "https://uipkge.dev/r/angular/payment-form.json",
    "https://uipkge.dev/r/angular/button.json",
    "https://uipkge.dev/r/angular/stepper.json",
    "https://uipkge.dev/r/angular/separator.json"
  ],
  "description": "Three-step checkout (cart → payment → confirm) with an inline PaymentForm, a success screen that fires a confetti burst, 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"
  ]
}