Menu

Change language

GitHub repo

Checkout Flow

blockcommerce

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.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/checkout-flow.json
Manifest:https://uipkge.dev/r/lit/checkout-flow.jsonInstalls to:src/uip/blocks/checkout-flow/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
currencystringUSDoptional
shipping-feenumber0optional
tax-ratenumber0optional
simulate-errorbooleanfalseoptional

Schema

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

PaymentPayload
interface PaymentPayload {
  number: string
  name: string
  expiry: string
  cvc: string
  brand: CardBrand
}
CheckoutCartItem
interface CheckoutCartItem {
  id: string
  name: string
  image?: string
  qty: number
  price: number
}
Particle
interface Particle {
  id: number
  x: number
  y: number
  vx: number
  vy: number
  rot: number
  vr: number
  color: string
  size: number
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-checkout-flow': UipCheckoutFlow
  }
}

Files installed (1)

  • src/uip/blocks/checkout-flow/checkout-flow.ts25.6 kB
    import { LitElement, css, html, nothing } from 'lit'
    import { ArrowLeft, Minus, Plus, Trash2 } from 'lucide'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/button/button'
    import '../../components/stepper/stepper'
    import '../../components/separator/separator'
    import '../../components/payment-card/payment-card'
    import '../../components/input/input'
    import type { UipInput } from '../../components/input/input'
    import '../../components/label/label'
    
    type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'
    
    export interface PaymentPayload {
      number: string
      name: string
      expiry: string
      cvc: string
      brand: CardBrand
    }
    
    export interface CheckoutCartItem {
      id: string
      name: string
      image?: string
      qty: number
      price: number
    }
    
    type Step = 'cart' | 'payment' | 'confirm' | 'success' | 'error'
    
    interface Particle {
      id: number
      x: number
      y: number
      vx: number
      vy: number
      rot: number
      vr: number
      color: string
      size: number
    }
    
    function detectBrand(raw: string): CardBrand {
      const n = raw.replace(/\D/g, '')
      if (!n) return 'unknown'
      if (/^4/.test(n)) return 'visa'
      if (/^(5[1-5]|2[2-7])/.test(n)) return 'mastercard'
      if (/^3[47]/.test(n)) return 'amex'
      if (/^(6011|65|64[4-9])/.test(n)) return 'discover'
      return 'unknown'
    }
    
    function luhn(digits: string): boolean {
      let sum = 0
      let alt = false
      for (let i = digits.length - 1; i >= 0; i--) {
        let d = Number(digits[i])
        if (alt) {
          d *= 2
          if (d > 9) d -= 9
        }
        sum += d
        alt = !alt
      }
      return sum % 10 === 0 && digits.length >= 13
    }
    
    const stepperSteps = [
      { id: 'cart', title: 'Cart' },
      { id: 'payment', title: 'Payment' },
      { id: 'confirm', title: 'Confirm' },
    ]
    
    /**
     * <uip-checkout-flow> — the registry CheckoutFlow block as a web component.
     *
     * Class strings, step keyframes, check-draw animation, and the confetti
     * physics are React's verbatim (keyframes are the one allowed extra-CSS
     * exception). React state becomes { state: true } fields; `items` is a
     * property (attribute: false — set it from JS). `onSubmit` (payload → orderId)
     * / `onComplete` ({ orderId }) / `onCancel` become `submit` / `complete` /
     * `cancel` CustomEvents (bubbles, composed; payloads in `detail`). Like
     * login-01, the block simulates the processor round-trip itself (1s) and then
     * notifies; `simulate-error` forces the rejection path so the demo can show it
     * without a callback prop.
     *
     * One deliberate departure: React embeds the PaymentForm *block* in the
     * payment step, but blocks never import sibling blocks (one block = one
     * install unit), so the payment step is an inline card form built from
     * uip-input + uip-payment-card with the same validation as uip-payment-form.
     */
    export class UipCheckoutFlow extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class; step + check-draw keyframes are
      // React's injected <style> copied verbatim (allowed keyframes exception).
      static styles = [
        tailwind,
        css`
          :host {
            display: block;
          }
          .checkout-flow-anim-forward,
          .checkout-flow-anim-back {
            animation-duration: 280ms;
            animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
            animation-fill-mode: both;
          }
          .checkout-flow-anim-forward {
            animation-name: checkout-flow-forward;
          }
          .checkout-flow-anim-back {
            animation-name: checkout-flow-back;
          }
          @keyframes checkout-flow-forward {
            from {
              transform: translateX(24px);
              opacity: 0;
            }
            to {
              transform: translateX(0);
              opacity: 1;
            }
          }
          @keyframes checkout-flow-back {
            from {
              transform: translateX(-24px);
              opacity: 0;
            }
            to {
              transform: translateX(0);
              opacity: 1;
            }
          }
          .checkout-flow-check-draw {
            stroke-dasharray: 100;
            stroke-dashoffset: 100;
            animation: checkout-flow-check-draw 700ms cubic-bezier(0.4, 0, 0.2, 1) 120ms forwards;
          }
          @keyframes checkout-flow-check-draw {
            to {
              stroke-dashoffset: 0;
            }
          }
          @media (prefers-reduced-motion: reduce) {
            .checkout-flow-anim-forward,
            .checkout-flow-anim-back {
              animation-duration: 0ms;
            }
            .checkout-flow-check-draw {
              animation: none;
              stroke-dashoffset: 0;
            }
          }
        `,
      ]
    
      static properties = {
        items: { attribute: false },
        currency: {},
        shippingFee: { type: Number, attribute: 'shipping-fee' },
        taxRate: { type: Number, attribute: 'tax-rate' },
        simulateError: { type: Boolean, attribute: 'simulate-error' },
        step: { state: true },
        direction: { state: true },
        cart: { state: true },
        paymentPayload: { state: true },
        orderId: { state: true },
        lastError: { state: true },
        placing: { state: true },
        particles: { state: true },
        cardNumber: { state: true },
        cardName: { state: true },
        cardExpiry: { state: true },
        cardCvc: { state: true },
        cardErrors: { state: true },
      }
    
      items: CheckoutCartItem[] = []
      currency = 'USD'
      shippingFee = 0
      taxRate = 0
      simulateError = false
    
      private step: Step = 'cart'
      private direction: 'forward' | 'back' = 'forward'
      private cart: CheckoutCartItem[] = []
      private paymentPayload: PaymentPayload | null = null
      private orderId: string | null = null
      private lastError: Error | null = null
      private placing = false
      private particles: Particle[] = []
      private cardNumber = ''
      private cardName = ''
      private cardExpiry = ''
      private cardCvc = ''
      private cardErrors: Record<string, string | null> = { number: null, name: null, expiry: null, cvc: null }
      private confettiRaf: number | null = null
      private timer?: number
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'checkout-flow')
      }
    
      disconnectedCallback() {
        super.disconnectedCallback()
        if (this.confettiRaf) cancelAnimationFrame(this.confettiRaf)
        if (this.timer) window.clearTimeout(this.timer)
      }
    
      willUpdate(changed: Map<string, unknown>) {
        if (changed.has('items')) {
          this.cart = (this.items ?? []).map((i) => ({ ...i }))
        }
      }
    
      updated(changed: Map<string, unknown>) {
        if (changed.has('step') && this.step === 'success') this.fireConfetti()
      }
    
      private go(next: Step) {
        const order: Record<Step, number> = { cart: 0, payment: 1, confirm: 2, success: 3, error: 3 }
        this.direction = order[next] >= order[this.step] ? 'forward' : 'back'
        this.step = next
      }
    
      private get subtotal() {
        return this.cart.reduce((s, i) => s + i.qty * i.price, 0)
      }
    
      private get tax() {
        return this.subtotal * this.taxRate
      }
    
      private get total() {
        return this.subtotal + this.shippingFee + this.tax
      }
    
      private fmt(n: number) {
        try {
          return new Intl.NumberFormat(undefined, { style: 'currency', currency: this.currency }).format(n)
        } catch {
          return `${this.currency} ${n.toFixed(2)}`
        }
      }
    
      private get stepIndex() {
        return this.step === 'cart' ? 1 : this.step === 'payment' ? 2 : 3
      }
    
      private dec(item: CheckoutCartItem) {
        if (item.qty > 1) this.cart = this.cart.map((i) => (i.id === item.id ? { ...i, qty: i.qty - 1 } : i))
      }
    
      private inc(item: CheckoutCartItem) {
        this.cart = this.cart.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i))
      }
    
      private removeItem(item: CheckoutCartItem) {
        this.cart = this.cart.filter((i) => i.id !== item.id)
      }
    
      private get cardBrand(): CardBrand {
        return detectBrand(this.cardNumber)
      }
    
      private formatCardNumber(raw: string, b: CardBrand): string {
        const digits = raw.replace(/\D/g, '').slice(0, b === 'amex' ? 15 : 16)
        if (b === 'amex') {
          return digits.replace(/^(\d{0,4})(\d{0,6})(\d{0,5}).*$/, (_m, a, bb, c) => [a, bb, c].filter(Boolean).join(' '))
        }
        return digits.replace(/(.{4})/g, '$1 ').trim()
      }
    
      private formatCardExpiry(raw: string): string {
        const d = raw.replace(/\D/g, '').slice(0, 4)
        if (d.length <= 2) return d
        return `${d.slice(0, 2)}/${d.slice(2)}`
      }
    
      private validateCard(): boolean {
        const digits = this.cardNumber.replace(/\D/g, '')
        const expectedLen = this.cardBrand === 'amex' ? 15 : 16
        const next: Record<string, string | null> = { number: null, name: null, expiry: null, cvc: null }
        next.number = !digits
          ? 'Card number required'
          : digits.length !== expectedLen
            ? `Card number must be ${expectedLen} digits`
            : !luhn(digits)
              ? 'Card number is invalid'
              : null
        next.name = !this.cardName.trim() ? 'Name required' : this.cardName.trim().length < 2 ? 'Name too short' : null
        const expOk = /^(0[1-9]|1[0-2])\/\d{2}$/.test(this.cardExpiry)
        if (!expOk) {
          next.expiry = 'Use MM/YY'
        } else {
          const [m, y] = this.cardExpiry.split('/').map(Number)
          const now = new Date()
          const expDate = new Date(2000 + (y ?? 0), (m ?? 1) - 1, 1)
          expDate.setMonth(expDate.getMonth() + 1)
          next.expiry = expDate <= now ? 'Card has expired' : null
        }
        const cvcLen = this.cardBrand === 'amex' ? 4 : 3
        next.cvc = this.cardCvc.length !== cvcLen ? `CVC must be ${cvcLen} digits` : null
        this.cardErrors = next
        return Object.values(next).every((e) => !e)
      }
    
      private submitCardForm(e: Event) {
        e.preventDefault()
        if (!this.validateCard()) return
        this.paymentPayload = {
          number: this.cardNumber.replace(/\D/g, ''),
          name: this.cardName.trim(),
          expiry: this.cardExpiry,
          cvc: this.cardCvc,
          brand: this.cardBrand,
        }
        this.go('confirm')
      }
    
      private placeOrder() {
        if (!this.paymentPayload || this.placing) return
        this.placing = true
        this.timer = window.setTimeout(() => {
          this.placing = false
          if (this.simulateError) {
            this.lastError = new Error('Issuer rejected the charge. Please contact your bank.')
            this.go('error')
            return
          }
          const nextOrderId = Math.random().toString(36).slice(2, 10).toUpperCase()
          this.orderId = nextOrderId
          this.dispatchEvent(
            new CustomEvent('submit', { detail: { ...this.paymentPayload }, bubbles: true, composed: true }),
          )
          this.dispatchEvent(new CustomEvent('complete', { detail: { orderId: nextOrderId }, bubbles: true, composed: true }))
          this.go('success')
        }, 1000)
      }
    
      private fireConfetti() {
        if (typeof window === 'undefined' || !window.matchMedia) return
        if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
        // Pull theme tokens at runtime so confetti follows the active palette
        // (light/dark mode + any consumer theme override).
        const cs = getComputedStyle(document.documentElement)
        const tok = (name: string, fallback: string) => cs?.getPropertyValue(name).trim() || fallback
        const colors = [
          tok('--chart-1', 'oklch(0.65 0.18 145)'),
          tok('--chart-2', 'oklch(0.6 0.22 260)'),
          tok('--chart-3', 'oklch(0.7 0.16 75)'),
          tok('--chart-4', 'oklch(0.65 0.2 330)'),
          tok('--chart-5', 'oklch(0.6 0.22 30)'),
          tok('--primary', 'oklch(0.55 0.22 260)'),
        ]
        this.particles = Array.from({ length: 60 }, (_, id) => ({
          id,
          x: 50,
          y: 40,
          vx: (Math.random() - 0.5) * 12,
          vy: -Math.random() * 14 - 4,
          rot: Math.random() * 360,
          vr: (Math.random() - 0.5) * 20,
          color: colors[Math.floor(Math.random() * colors.length)] ?? colors[0]!,
          size: 4 + Math.random() * 6,
        }))
        const start = performance.now()
        const tick = (now: number) => {
          const elapsed = now - start
          if (elapsed > 1800) {
            this.particles = []
            this.confettiRaf = null
            return
          }
          this.particles = this.particles.map((p) => ({
            ...p,
            x: p.x + p.vx * 0.25,
            y: p.y + p.vy * 0.25,
            vy: p.vy + 0.45,
            rot: p.rot + p.vr,
          }))
          this.confettiRaf = requestAnimationFrame(tick)
        }
        this.confettiRaf = requestAnimationFrame(tick)
      }
    
      render() {
        const animClass = this.direction === 'forward' ? 'checkout-flow-anim-forward' : 'checkout-flow-anim-back'
        return html`
          <div data-slot="checkout-flow" class="bg-card text-card-foreground mx-auto w-full max-w-3xl overflow-hidden rounded-xl border shadow-sm">
            ${this.step !== 'success' && this.step !== 'error'
              ? html`<div class="bg-muted/30 border-b px-6 py-4">
                  <uip-stepper .steps=${stepperSteps} value=${this.stepIndex}></uip-stepper>
                </div>`
              : nothing}
    
            <div class="relative min-h-[420px]">
              ${this.step === 'cart' ? this.renderCart(animClass) : nothing}
              ${this.step === 'payment' ? this.renderPayment(animClass) : nothing}
              ${this.step === 'confirm' ? this.renderConfirm(animClass) : nothing}
              ${this.step === 'success' ? this.renderSuccess(animClass) : nothing}
              ${this.step === 'error' ? this.renderError(animClass) : nothing}
            </div>
          </div>
        `
      }
    
      private renderCart(animClass: string) {
        return html`<div class=${`p-6 ${animClass}`}>
          <h3 class="mb-4 text-lg font-semibold">Your cart</h3>
          ${this.cart.length === 0
            ? html`<div class="text-muted-foreground py-12 text-center text-sm">Your cart is empty.</div>`
            : html`<ul class="divide-border divide-y">
                ${this.cart.map(
                  (item) => html`<li class="flex items-center gap-3 py-3">
                    <div class="bg-muted size-14 shrink-0 overflow-hidden rounded-md">
                      ${item.image ? html`<img src=${item.image} alt=${item.name} class="size-full object-cover" />` : nothing}
                    </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">${this.fmt(item.price)} each</p>
                    </div>
                    <div class="flex items-center gap-1">
                      <uip-button variant="outline" size="icon-sm" aria-label="Decrease" @click=${() => this.dec(item)}>
                        ${icon(Minus, 'minus', 'size-3.5')}
                      </uip-button>
                      <span class="w-8 text-center text-sm tabular-nums">${item.qty}</span>
                      <uip-button variant="outline" size="icon-sm" aria-label="Increase" @click=${() => this.inc(item)}>
                        ${icon(Plus, 'plus', 'size-3.5')}
                      </uip-button>
                    </div>
                    <div class="w-20 text-right text-sm font-medium tabular-nums">${this.fmt(item.qty * item.price)}</div>
                    <uip-button variant="ghost" size="icon-sm" aria-label="Remove" @click=${() => this.removeItem(item)}>
                      ${icon(Trash2, 'trash-2', 'text-muted-foreground size-4')}
                    </uip-button>
                  </li>`,
                )}
              </ul>`}
          <uip-separator class="my-4"></uip-separator>
          <dl class="space-y-1.5 text-sm">
            <div class="flex justify-between">
              <dt class="text-muted-foreground">Subtotal</dt>
              <dd>${this.fmt(this.subtotal)}</dd>
            </div>
            ${this.shippingFee > 0
              ? html`<div class="flex justify-between">
                  <dt class="text-muted-foreground">Shipping</dt>
                  <dd>${this.fmt(this.shippingFee)}</dd>
                </div>`
              : nothing}
            ${this.taxRate > 0
              ? html`<div class="flex justify-between">
                  <dt class="text-muted-foreground">Tax</dt>
                  <dd>${this.fmt(this.tax)}</dd>
                </div>`
              : nothing}
            <div class="flex justify-between border-t pt-2 text-base font-semibold">
              <dt>Total</dt>
              <dd>${this.fmt(this.total)}</dd>
            </div>
          </dl>
          <div class="mt-5 flex justify-between">
            <uip-button
              variant="ghost"
              @click=${() => this.dispatchEvent(new CustomEvent('cancel', { bubbles: true, composed: true }))}
            >
              Cancel
            </uip-button>
            <uip-button ?disabled=${this.cart.length === 0} @click=${() => this.go('payment')}>Continue to payment →</uip-button>
          </div>
        </div>`
      }
    
      private renderPayment(animClass: string) {
        return html`<div class=${`p-6 ${animClass}`}>
          <button
            type="button"
            class="text-muted-foreground hover:text-foreground mb-3 inline-flex items-center gap-1 text-sm"
            @click=${() => this.go('cart')}
          >
            ${icon(ArrowLeft, 'arrow-left', 'size-3.5')} Back to cart
          </button>
          <div class="mb-4 flex justify-center">
            <uip-payment-card
              number=${this.cardNumber}
              name=${this.cardName}
              expiry=${this.cardExpiry}
              cvc=${this.cardCvc}
              size="md"
            ></uip-payment-card>
          </div>
          <form class="space-y-3" @submit=${(e: Event) => this.submitCardForm(e)}>
            <div class="grid gap-1.5">
              <uip-label for="co-cc-number">Card number</uip-label>
              <uip-input
                id="co-cc-number"
                type="text"
                autocomplete="cc-number"
                placeholder="1234 5678 9012 3456"
                .value=${this.cardNumber}
                status=${this.cardErrors.number ? 'error' : nothing}
                @input=${(e: Event) => {
                  const el = e.target as UipInput
                  const b = detectBrand(el.value ?? '')
                  this.cardNumber = this.formatCardNumber(el.value ?? '', b)
                  this.cardErrors = { ...this.cardErrors, number: null }
                }}
              ></uip-input>
              ${this.cardErrors.number
                ? html`<p class="text-destructive text-xs" role="alert">${this.cardErrors.number}</p>`
                : nothing}
            </div>
            <div class="grid gap-1.5">
              <uip-label for="co-cc-name">Name on card</uip-label>
              <uip-input
                id="co-cc-name"
                type="text"
                autocomplete="cc-name"
                placeholder="Jane Doe"
                .value=${this.cardName}
                status=${this.cardErrors.name ? 'error' : nothing}
                @input=${(e: Event) => {
                  this.cardName = (e.target as UipInput).value ?? ''
                }}
              ></uip-input>
              ${this.cardErrors.name ? html`<p class="text-destructive text-xs" role="alert">${this.cardErrors.name}</p>` : nothing}
            </div>
            <div class="grid grid-cols-2 gap-3">
              <div class="grid gap-1.5">
                <uip-label for="co-cc-exp">Expiry</uip-label>
                <uip-input
                  id="co-cc-exp"
                  type="text"
                  autocomplete="cc-exp"
                  placeholder="MM/YY"
                  .value=${this.cardExpiry}
                  status=${this.cardErrors.expiry ? 'error' : nothing}
                  @input=${(e: Event) => {
                    this.cardExpiry = this.formatCardExpiry((e.target as UipInput).value ?? '')
                    this.cardErrors = { ...this.cardErrors, expiry: null }
                  }}
                ></uip-input>
                ${this.cardErrors.expiry
                  ? html`<p class="text-destructive text-xs" role="alert">${this.cardErrors.expiry}</p>`
                  : nothing}
              </div>
              <div class="grid gap-1.5">
                <uip-label for="co-cc-cvc">CVC</uip-label>
                <uip-input
                  id="co-cc-cvc"
                  type="text"
                  autocomplete="cc-csc"
                  placeholder=${this.cardBrand === 'amex' ? '1234' : '123'}
                  .value=${this.cardCvc}
                  status=${this.cardErrors.cvc ? 'error' : nothing}
                  @input=${(e: Event) => {
                    const max = this.cardBrand === 'amex' ? 4 : 3
                    this.cardCvc = ((e.target as UipInput).value ?? '').replace(/\D/g, '').slice(0, max)
                    this.cardErrors = { ...this.cardErrors, cvc: null }
                  }}
                ></uip-input>
                ${this.cardErrors.cvc ? html`<p class="text-destructive text-xs" role="alert">${this.cardErrors.cvc}</p>` : nothing}
              </div>
            </div>
            <uip-button type="submit" class="h-11 w-full text-base">Pay ${this.fmt(this.total)}</uip-button>
          </form>
        </div>`
      }
    
      private renderConfirm(animClass: string) {
        const p = this.paymentPayload
        return html`<div class=${`p-6 ${animClass}`}>
          <h3 class="mb-4 text-lg font-semibold">Review your order</h3>
          <div class="mb-4 flex items-start gap-4 rounded-lg border p-4">
            ${p
              ? html`<uip-payment-card
                  number=${p.number}
                  name=${p.name}
                  expiry=${p.expiry}
                  brand=${p.brand}
                  variant="compact"
                  flip="false"
                ></uip-payment-card>`
              : nothing}
            <div class="min-w-0 flex-1 text-sm">
              <p class="font-medium">Paying with card ending ${p?.number.slice(-4)}</p>
              <p class="text-muted-foreground">${p?.name} · expires ${p?.expiry}</p>
              <button class="text-primary mt-2 text-xs underline" @click=${() => this.go('payment')}>
                Change payment method
              </button>
            </div>
          </div>
          <ul class="divide-border divide-y border-y">
            ${this.cart.map(
              (item) => html`<li class="flex justify-between py-2 text-sm">
                <span class="truncate">${item.qty} × ${item.name}</span>
                <span class="tabular-nums">${this.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>${this.fmt(this.subtotal)}</dd>
            </div>
            ${this.shippingFee > 0
              ? html`<div class="flex justify-between">
                  <dt class="text-muted-foreground">Shipping</dt>
                  <dd>${this.fmt(this.shippingFee)}</dd>
                </div>`
              : nothing}
            ${this.taxRate > 0
              ? html`<div class="flex justify-between">
                  <dt class="text-muted-foreground">Tax</dt>
                  <dd>${this.fmt(this.tax)}</dd>
                </div>`
              : nothing}
            <div class="flex justify-between border-t pt-2 text-base font-semibold">
              <dt>Total</dt>
              <dd>${this.fmt(this.total)}</dd>
            </div>
          </dl>
          <div class="mt-5 flex justify-between">
            <uip-button variant="outline" @click=${() => this.go('payment')}>
              ${icon(ArrowLeft, 'arrow-left', 'mr-1.5 size-4')} Back
            </uip-button>
            <uip-button ?disabled=${this.placing} @click=${() => this.placeOrder()}>
              ${this.placing ? 'Placing order…' : `Place order · ${this.fmt(this.total)}`}
            </uip-button>
          </div>
        </div>`
      }
    
      private renderSuccess(animClass: string) {
        return html`<div class=${`relative flex flex-col items-center px-6 py-14 text-center ${animClass}`}>
          <div class="pointer-events-none absolute inset-0 overflow-hidden">
            ${this.particles.map(
              (pt) => html`<div
                class="absolute"
                style="left:${pt.x}%;top:${pt.y}%;width:${pt.size}px;height:${pt.size}px;background:${pt.color};transform:rotate(${pt.rot}deg);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" pathLength="100" class="checkout-flow-check-draw" />
            </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">#${this.orderId}</div>
          <uip-button
            variant="outline"
            class="mt-6"
            @click=${() => this.dispatchEvent(new CustomEvent('cancel', { bubbles: true, composed: true }))}
          >
            Back to home
          </uip-button>
        </div>`
      }
    
      private renderError(animClass: string) {
        return html`<div class=${`flex flex-col items-center px-6 py-14 text-center ${animClass}`}>
          <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" pathLength="100" class="checkout-flow-check-draw" />
            </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">
            ${this.lastError?.message || 'Something went wrong while charging your card.'}
          </p>
          <div class="mt-6 flex gap-2">
            <uip-button
              variant="outline"
              @click=${() => this.dispatchEvent(new CustomEvent('cancel', { bubbles: true, composed: true }))}
            >
              Cancel
            </uip-button>
            <uip-button @click=${() => this.go('payment')}>Try again</uip-button>
          </div>
        </div>`
      }
    }
    
    customElements.get('uip-checkout-flow') || customElements.define('uip-checkout-flow', UipCheckoutFlow)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-checkout-flow': UipCheckoutFlow
      }
    }
    

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