Checkout Flow
blockcommerceThree-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
$pnpm dlx uipkge-ng@latest add checkout-flow$npx uipkge-ng@latest add checkout-flow$yarn dlx uipkge-ng@latest add checkout-flow$bunx uipkge-ng@latest add checkout-flowManifest:
https://uipkge.dev/r/angular/checkout-flow.jsonInstalls to:src/app/components/blocks/checkout-flow/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
CartIteminterface CartItem {
id: string
name: string
image?: string
qty: number
price: number
}Particleinterface Particle {
id: number
x: number
y: number
color: string
size: number
rot: number
}npm dependencies
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
export { UiCheckoutFlowComponent, type CartItem, type CheckoutCardBrand, type CheckoutStep, } from './checkout-flow.component'
Raw manifest:https://uipkge.dev/r/angular/checkout-flow.json