Menu

Change language

GitHub repo

Checkout Flow

blockcommerce

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.

Installation

$npx uipkge-ng@latest add checkout-flow
Manifest:https://uipkge.dev/r/angular/checkout-flow.jsonInstalls to:src/app/components/blocks/checkout-flow/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

CartItem
interface CartItem {
  id: string
  name: string
  image?: string
  qty: number
  price: number
}
Particle
interface Particle {
  id: number
  x: number
  y: number
  color: string
  size: number
  rot: number
}

Files installed (2)

  • src/app/components/blocks/checkout-flow/checkout-flow.component.ts16.3 kB
    // 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` shown on the
    // success screen. Port of the React block 1:1.
    import {
      ChangeDetectionStrategy,
      Component,
      DestroyRef,
      EventEmitter,
      Input,
      Output,
      computed,
      inject,
      signal,
    } from '@angular/core'
    import { ArrowLeft, LucideAngularModule, Minus, Plus, Trash2 } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiPaymentCardComponent } from '@/ui/payment-card/payment-card.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiStepperComponent, type StepperStep } from '@/ui/stepper/stepper.component'
    import { UiPaymentFormComponent, type PaymentPayload } from '../payment-form/payment-form.component'
    
    export type CheckoutCardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'
    
    export interface CartItem {
      id: string
      name: string
      image?: string
      qty: number
      price: number
    }
    
    export type CheckoutStep = 'cart' | 'payment' | 'confirm' | 'success' | 'error'
    
    interface Particle {
      id: number
      x: number
      y: number
      color: string
      size: number
      rot: number
    }
    
    const stepperSteps: StepperStep[] = [
      { id: 'cart', title: 'Cart' },
      { id: 'payment', title: 'Payment' },
      { id: 'confirm', title: 'Confirm' },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-checkout-flow, [ui-checkout-flow]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiButtonComponent,
        UiPaymentCardComponent,
        UiPaymentFormComponent,
        UiSeparatorComponent,
        UiStepperComponent,
      ],
      template: `
        <div data-slot="checkout-flow" [class]="rootClass">
          <!-- Stepper header (hidden on end states) -->
          @if (step() !== 'success' && step() !== 'error') {
            <div class="bg-muted/30 border-b px-6 py-4">
              <ui-stepper [steps]="stepperSteps" [value]="stepIndex()" />
            </div>
          }
    
          <div class="relative min-h-[420px]">
            <!-- CART -->
            @if (step() === 'cart') {
              <div class="p-6">
                <h3 class="mb-4 text-lg font-semibold">Your cart</h3>
    
                @if (cart().length === 0) {
                  <div class="text-muted-foreground py-12 text-center text-sm">Your cart is empty.</div>
                } @else {
                  <ul class="divide-border divide-y">
                    @for (item of cart(); track item.id) {
                      <li class="flex items-center gap-3 py-3">
                        <div class="bg-muted size-14 shrink-0 overflow-hidden rounded-md">
                          @if (item.image) {
                            <img [src]="item.image" [alt]="item.name" class="size-full object-cover" />
                          }
                        </div>
                        <div class="min-w-0 flex-1">
                          <p class="truncate text-sm font-medium">{{ item.name }}</p>
                          <p class="text-muted-foreground text-xs">{{ fmt(item.price) }} each</p>
                        </div>
                        <div class="flex items-center gap-1">
                          <button ui-button variant="outline" size="icon-sm" aria-label="Decrease" (click)="dec(item)">
                            <lucide-icon [img]="Minus" class="size-3.5" />
                          </button>
                          <span class="w-8 text-center text-sm tabular-nums">{{ item.qty }}</span>
                          <button ui-button variant="outline" size="icon-sm" aria-label="Increase" (click)="inc(item)">
                            <lucide-icon [img]="Plus" class="size-3.5" />
                          </button>
                        </div>
                        <div class="w-20 text-right text-sm font-medium tabular-nums">{{ fmt(item.qty * item.price) }}</div>
                        <button ui-button variant="ghost" size="icon-sm" aria-label="Remove" (click)="removeItem(item)">
                          <lucide-icon [img]="Trash2" class="text-muted-foreground size-4" />
                        </button>
                      </li>
                    }
                  </ul>
                }
    
                <ui-separator class="my-4" />
    
                <dl class="space-y-1.5 text-sm">
                  <div class="flex justify-between">
                    <dt class="text-muted-foreground">Subtotal</dt>
                    <dd>{{ fmt(subtotal()) }}</dd>
                  </div>
                  @if (shippingFee() > 0) {
                    <div class="flex justify-between">
                      <dt class="text-muted-foreground">Shipping</dt>
                      <dd>{{ fmt(shippingFee()) }}</dd>
                    </div>
                  }
                  @if (taxRate() > 0) {
                    <div class="flex justify-between">
                      <dt class="text-muted-foreground">Tax</dt>
                      <dd>{{ fmt(tax()) }}</dd>
                    </div>
                  }
                  <div class="flex justify-between border-t pt-2 text-base font-semibold">
                    <dt>Total</dt>
                    <dd>{{ fmt(total()) }}</dd>
                  </div>
                </dl>
    
                <div class="mt-5 flex justify-between">
                  <button ui-button variant="ghost" (click)="cancel.emit()">Cancel</button>
                  <button ui-button [disabled]="cart().length === 0" (click)="go('payment')">Continue to payment →</button>
                </div>
              </div>
            }
    
            <!-- PAYMENT -->
            @if (step() === 'payment') {
              <div class="p-6">
                <button
                  type="button"
                  class="text-muted-foreground hover:text-foreground mb-3 inline-flex items-center gap-1 text-sm"
                  (click)="go('cart')"
                >
                  <lucide-icon [img]="ArrowLeft" class="size-3.5" /> Back to cart
                </button>
                <ui-payment-form
                  [amount]="total()"
                  [currency]="currency"
                  [onSubmit]="captureForConfirm"
                  (success)="onPaymentSuccess()"
                />
              </div>
            }
    
            <!-- CONFIRM -->
            @if (step() === 'confirm') {
              <div class="p-6">
                <h3 class="mb-4 text-lg font-semibold">Review your order</h3>
    
                @if (paymentPayload(); as payload) {
                  <div class="mb-4 flex items-start gap-4 rounded-lg border p-4">
                    <ui-payment-card
                      [number]="payload.number"
                      [name]="payload.name"
                      [expiry]="payload.expiry"
                      [brand]="payload.brand"
                      variant="compact"
                      [flip]="false"
                    />
                    <div class="min-w-0 flex-1 text-sm">
                      <p class="font-medium">Paying with card ending {{ lastFour(payload.number) }}</p>
                      <p class="text-muted-foreground">{{ payload.name }} · expires {{ payload.expiry }}</p>
                      <button class="text-primary mt-2 text-xs underline" (click)="go('payment')">
                        Change payment method
                      </button>
                    </div>
                  </div>
                }
    
                <ul class="divide-border divide-y border-y">
                  @for (item of cart(); track item.id) {
                    <li class="flex justify-between py-2 text-sm">
                      <span class="truncate">{{ item.qty }} × {{ item.name }}</span>
                      <span class="tabular-nums">{{ fmt(item.qty * item.price) }}</span>
                    </li>
                  }
                </ul>
    
                <dl class="mt-3 space-y-1 text-sm">
                  <div class="flex justify-between">
                    <dt class="text-muted-foreground">Subtotal</dt>
                    <dd>{{ fmt(subtotal()) }}</dd>
                  </div>
                  @if (shippingFee() > 0) {
                    <div class="flex justify-between">
                      <dt class="text-muted-foreground">Shipping</dt>
                      <dd>{{ fmt(shippingFee()) }}</dd>
                    </div>
                  }
                  @if (taxRate() > 0) {
                    <div class="flex justify-between">
                      <dt class="text-muted-foreground">Tax</dt>
                      <dd>{{ fmt(tax()) }}</dd>
                    </div>
                  }
                  <div class="flex justify-between border-t pt-2 text-base font-semibold">
                    <dt>Total</dt>
                    <dd>{{ fmt(total()) }}</dd>
                  </div>
                </dl>
    
                <div class="mt-5 flex justify-between">
                  <button ui-button variant="outline" (click)="go('payment')">
                    <lucide-icon [img]="ArrowLeft" class="mr-1.5 size-4" /> Back
                  </button>
                  <button ui-button [disabled]="placing()" (click)="placeOrder()">
                    {{ placing() ? 'Placing order…' : 'Place order · ' + fmt(total()) }}
                  </button>
                </div>
              </div>
            }
    
            <!-- SUCCESS -->
            @if (step() === 'success') {
              <div class="relative flex flex-col items-center px-6 py-14 text-center">
                <!-- Confetti layer -->
                <div class="pointer-events-none absolute inset-0 overflow-hidden">
                  @for (p of particles(); track p.id) {
                    <div
                      class="absolute"
                      [style.left]="p.x + '%'"
                      [style.top]="p.y + '%'"
                      [style.width]="p.size + 'px'"
                      [style.height]="p.size + 'px'"
                      [style.background]="p.color"
                      [style.transform]="'rotate(' + p.rot + 'deg)'"
                      style="border-radius: 2px;"
                    ></div>
                  }
                </div>
    
                <div class="bg-primary/10 ring-primary/30 relative grid size-20 place-items-center rounded-full ring-2">
                  <svg
                    viewBox="0 0 52 52"
                    class="text-primary size-12"
                    fill="none"
                    stroke="currentColor"
                    stroke-width="4"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    aria-hidden="true"
                  >
                    <path d="M14 27 L23 36 L40 18" />
                  </svg>
                </div>
                <h3 class="mt-5 text-xl font-semibold">Order placed</h3>
                <p class="text-muted-foreground mt-1 text-sm">Thanks — a confirmation will be sent shortly.</p>
                <div class="bg-muted mt-4 inline-flex rounded-md px-3 py-1.5 font-mono text-sm">#{{ orderId() }}</div>
                <button ui-button variant="outline" class="mt-6" (click)="cancel.emit()">Back to home</button>
              </div>
            }
    
            <!-- ERROR -->
            @if (step() === 'error') {
              <div class="flex flex-col items-center px-6 py-14 text-center">
                <div
                  class="bg-destructive/10 ring-destructive/30 relative grid size-20 place-items-center rounded-full ring-2"
                >
                  <svg
                    viewBox="0 0 52 52"
                    class="text-destructive size-12"
                    fill="none"
                    stroke="currentColor"
                    stroke-width="4"
                    stroke-linecap="round"
                    aria-hidden="true"
                  >
                    <path d="M16 16 L36 36 M36 16 L16 36" />
                  </svg>
                </div>
                <h3 class="mt-5 text-xl font-semibold">Payment failed</h3>
                <p class="text-muted-foreground mt-1 max-w-xs text-sm">{{ errorMessage() }}</p>
                <div class="mt-6 flex gap-2">
                  <button ui-button variant="outline" (click)="cancel.emit()">Cancel</button>
                  <button ui-button (click)="go('payment')">Try again</button>
                </div>
              </div>
            }
          </div>
        </div>
      `,
    })
    export class UiCheckoutFlowComponent {
      protected readonly ArrowLeft = ArrowLeft
      protected readonly Minus = Minus
      protected readonly Plus = Plus
      protected readonly Trash2 = Trash2
    
      protected readonly stepperSteps = stepperSteps
    
      private readonly _items = signal<CartItem[]>([])
      @Input() set items(v: CartItem[]) {
        const list = (v ?? []).map((i) => ({ ...i }))
        this._items.set(list)
        this.cart.set(list.map((i) => ({ ...i })))
      }
      private readonly _currency = signal('USD')
      @Input() set currency(v: string) {
        if (v) this._currency.set(v)
      }
      get currency(): string {
        return this._currency()
      }
      readonly shippingFee = signal(0)
      @Input('shippingFee') set shippingFeeInput(v: number) {
        this.shippingFee.set(v ?? 0)
      }
      readonly taxRate = signal(0)
      @Input('taxRate') set taxRateInput(v: number) {
        this.taxRate.set(v ?? 0)
      }
      @Input() onSubmit?: (data: PaymentPayload) => Promise<{ orderId: string }>
      @Input('class') className?: string
    
      @Output() readonly complete = new EventEmitter<{ orderId: string }>()
      @Output() readonly cancel = new EventEmitter<void>()
    
      get rootClass(): string {
        return cn(
          'bg-card text-card-foreground mx-auto w-full max-w-3xl overflow-hidden rounded-xl border shadow-sm',
          this.className,
        )
      }
    
      readonly step = signal<CheckoutStep>('cart')
      readonly cart = signal<CartItem[]>([])
      readonly paymentPayload = signal<PaymentPayload | null>(null)
      readonly orderId = signal<string | null>(null)
      readonly lastError = signal<Error | null>(null)
      readonly placing = signal(false)
      readonly particles = signal<Particle[]>([])
    
      private confettiTimer: ReturnType<typeof setTimeout> | undefined
    
      constructor() {
        inject(DestroyRef).onDestroy(() => clearTimeout(this.confettiTimer))
      }
    
      readonly subtotal = computed(() => this.cart().reduce((s, i) => s + i.qty * i.price, 0))
      readonly tax = computed(() => this.subtotal() * this.taxRate())
      readonly total = computed(() => this.subtotal() + this.shippingFee() + this.tax())
    
      readonly stepIndex = computed(() => {
        const s = this.step()
        if (s === 'cart') return 1
        if (s === 'payment') return 2
        return 3
      })
    
      readonly errorMessage = computed(() => this.lastError()?.message || 'Something went wrong while charging your card.')
    
      fmt(n: number): string {
        try {
          return new Intl.NumberFormat(undefined, { style: 'currency', currency: this._currency() }).format(n)
        } catch {
          return `${this._currency()} ${n.toFixed(2)}`
        }
      }
    
      lastFour(number: string): string {
        return number.replace(/\D/g, '').slice(-4)
      }
    
      go(next: CheckoutStep): void {
        this.step.set(next)
        if (next === 'success') this.fireConfetti()
      }
    
      dec(item: CartItem): void {
        if (item.qty > 1) {
          item.qty--
          this.cart.update((prev) => [...prev])
        }
      }
    
      inc(item: CartItem): void {
        item.qty++
        this.cart.update((prev) => [...prev])
      }
    
      removeItem(item: CartItem): void {
        this.cart.update((prev) => prev.filter((i) => i.id !== item.id))
      }
    
      /** PaymentForm hook: capture the payload and defer the real charge to the confirm step. */
      readonly captureForConfirm = async (payload: PaymentPayload): Promise<void> => {
        this.paymentPayload.set(payload)
      }
    
      onPaymentSuccess(): void {
        this.go('confirm')
      }
    
      async placeOrder(): Promise<void> {
        if (!this.paymentPayload() || this.placing()) return
        this.placing.set(true)
        try {
          if (this.onSubmit) {
            const res = await this.onSubmit(this.paymentPayload()!)
            this.orderId.set(res?.orderId ?? Math.random().toString(36).slice(2, 10).toUpperCase())
          } else {
            this.orderId.set(Math.random().toString(36).slice(2, 10).toUpperCase())
          }
          this.complete.emit({ orderId: this.orderId()! })
          this.go('success')
        } catch (err) {
          this.lastError.set(err instanceof Error ? err : new Error(String(err)))
          this.go('error')
        } finally {
          this.placing.set(false)
        }
      }
    
      private fireConfetti(): void {
        if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return
        const colors = ['#22c55e', '#3b82f6', '#eab308', '#ec4899', '#f97316', '#8b5cf6']
        this.particles.set(
          Array.from({ length: 60 }, (_, id) => ({
            id,
            x: 50 + (Math.random() - 0.5) * 20,
            y: 40 + (Math.random() - 0.5) * 10,
            color: colors[Math.floor(Math.random() * colors.length)]!,
            size: 4 + Math.random() * 6,
            rot: Math.random() * 360,
          })),
        )
        clearTimeout(this.confettiTimer)
        this.confettiTimer = setTimeout(() => this.particles.set([]), 1800)
      }
    }
    
  • src/app/components/blocks/checkout-flow/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/checkout-flow.json