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-svelte@latest add https://uipkge.dev/r/svelte/checkout-flow.json
Manifest:https://uipkge.dev/r/svelte/checkout-flow.jsonInstalls to:src/lib/components/blocks/checkout-flow/

Variants

Loading interactive previews…

Files installed (1)

  • src/lib/components/blocks/checkout-flow/CheckoutFlow.svelte15.2 kB
    <script lang="ts" module>
      export type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'
    
      export interface PaymentPayload {
        number: string
        name: string
        expiry: string
        cvc: string
        brand: CardBrand
      }
    
      export interface CartItem {
        id: string
        name: string
        image?: string
        qty: number
        price: number
      }
    
      export interface CheckoutFlowProps {
        items: CartItem[]
        currency?: string
        shippingFee?: number
        taxRate?: number
        onSubmit?: (data: PaymentPayload) => Promise<{ orderId: string }>
        oncomplete?: (payload: { orderId: string }) => void
        oncancel?: () => void
      }
    
      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
      }
    </script>
    
    <script lang="ts">
      import { onDestroy } from 'svelte'
      import { fly } from 'svelte/transition'
      import { ArrowLeft, Check, Minus, Plus, Trash2, X } from '@lucide/svelte'
      import { PaymentCard } from '$lib/components/ui/payment-card'
      import { Button } from '$lib/components/ui/button'
      import { Stepper } from '$lib/components/ui/stepper'
      import { Separator } from '$lib/components/ui/separator'
      import PaymentForm from '$lib/components/blocks/payment-form/PaymentForm.svelte'
    
      let {
        items,
        currency = 'USD',
        shippingFee = 0,
        taxRate = 0,
        onSubmit,
        oncomplete,
        oncancel,
      }: CheckoutFlowProps = $props()
    
      let step = $state<Step>('cart')
      let direction = $state<'forward' | 'back'>('forward')
    
      let cart = $state<CartItem[]>(items.map((i) => ({ ...i })))
      let paymentPayload = $state<PaymentPayload | null>(null)
      let orderId = $state<string | null>(null)
      let lastError = $state<Error | null>(null)
      let placing = $state(false)
    
      $effect(() => {
        cart = items.map((i) => ({ ...i }))
      })
    
      function go(next: Step) {
        const order: Record<Step, number> = { cart: 0, payment: 1, confirm: 2, success: 3, error: 3 }
        direction = order[next] >= order[step] ? 'forward' : 'back'
        step = next
      }
    
      const subtotal = $derived(cart.reduce((s, i) => s + i.qty * i.price, 0))
      const tax = $derived(subtotal * taxRate)
      const total = $derived(subtotal + shippingFee + tax)
    
      const fmt = (n: number) => {
        try {
          return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(n)
        } catch {
          return `${currency} ${n.toFixed(2)}`
        }
      }
    
      const stepIndex = $derived(step === 'cart' ? 1 : step === 'payment' ? 2 : 3)
    
      const stepperSteps = [
        { id: 'cart', title: 'Cart' },
        { id: 'payment', title: 'Payment' },
        { id: 'confirm', title: 'Confirm' },
      ]
    
      function dec(item: CartItem) {
        if (item.qty > 1) item.qty--
      }
      function inc(item: CartItem) {
        item.qty++
      }
      function remove(item: CartItem) {
        cart = cart.filter((i) => i.id !== item.id)
      }
    
      async function placeOrder() {
        if (!paymentPayload || placing) return
        placing = true
        try {
          if (onSubmit) {
            const res = await onSubmit(paymentPayload)
            orderId = res?.orderId ?? Math.random().toString(36).slice(2, 10).toUpperCase()
          } else {
            orderId = Math.random().toString(36).slice(2, 10).toUpperCase()
          }
          oncomplete?.({ orderId })
          go('success')
        } catch (err) {
          lastError = err instanceof Error ? err : new Error(String(err))
          go('error')
        } finally {
          placing = false
        }
      }
    
      function onPaymentSuccess() {
        go('confirm')
      }
    
      function captureFormPayload(payload: PaymentPayload) {
        paymentPayload = payload
      }
    
      // Confetti — 60 particles, simple physics, ~1.8s
      let particles = $state<Particle[]>([])
      let confettiRaf: number | null = null
      const reducedMotion =
        typeof window !== 'undefined' && window.matchMedia
          ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
          : false
    
      function fireConfetti() {
        if (reducedMotion) return
        // Pull theme tokens at runtime so confetti follows the active palette
        // (light/dark mode + any consumer theme override). Falls back to a
        // sensible default if the variable isn't defined.
        const cs = typeof window !== 'undefined' ? getComputedStyle(document.documentElement) : null
        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)'),
        ]
        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)],
          size: 4 + Math.random() * 6,
        }))
        const start = performance.now()
        const tick = (now: number) => {
          const elapsed = now - start
          if (elapsed > 1800) {
            particles = []
            confettiRaf = null
            return
          }
          particles = 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,
          }))
          confettiRaf = requestAnimationFrame(tick)
        }
        confettiRaf = requestAnimationFrame(tick)
      }
    
      $effect(() => {
        if (step === 'success') fireConfetti()
      })
    
      onDestroy(() => {
        if (confettiRaf) cancelAnimationFrame(confettiRaf)
      })
    
      const flyX = $derived(direction === 'forward' ? 24 : -24)
    </script>
    
    <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"
    >
      <!-- Stepper header (hidden on end states) -->
      {#if step !== 'success' && step !== 'error'}
        <div class="bg-muted/30 border-b px-6 py-4">
          <Stepper
            steps={stepperSteps}
            value={stepIndex}
            onValueChange={(n) => go(n === 1 ? 'cart' : n === 2 ? 'payment' : 'confirm')}
          />
        </div>
      {/if}
    
      <div class="relative min-h-[420px]">
        {#if step === 'cart'}
          <!-- CART -->
          <div transition:fly={{ x: flyX, duration: reducedMotion ? 0 : 280 }} 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">
                {#each cart as item (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" />
                      {/if}
                    </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 variant="outline" size="icon-sm" aria-label="Decrease" onclick={() => dec(item)}>
                        <Minus class="size-3.5" />
                      </Button>
                      <span class="w-8 text-center text-sm tabular-nums">{item.qty}</span>
                      <Button variant="outline" size="icon-sm" aria-label="Increase" onclick={() => inc(item)}>
                        <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 variant="ghost" size="icon-sm" aria-label="Remove" onclick={() => remove(item)}>
                      <Trash2 class="text-muted-foreground size-4" />
                    </Button>
                  </li>
                {/each}
              </ul>
            {/if}
    
            <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}
              {#if taxRate > 0}
                <div class="flex justify-between">
                  <dt class="text-muted-foreground">Tax</dt>
                  <dd>{fmt(tax)}</dd>
                </div>
              {/if}
              <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 variant="ghost" onclick={() => oncancel?.()}>Cancel</Button>
              <Button disabled={cart.length === 0} onclick={() => go('payment')}>Continue to payment →</Button>
            </div>
          </div>
        {:else if step === 'payment'}
          <!-- PAYMENT -->
          <div transition:fly={{ x: flyX, duration: reducedMotion ? 0 : 280 }} class="p-6">
            <button
              type="button"
              class="text-muted-foreground hover:text-foreground mb-3 inline-flex items-center gap-1 text-sm"
              onclick={() => go('cart')}
            >
              <ArrowLeft class="size-3.5" /> Back to cart
            </button>
            <PaymentForm
              amount={total}
              {currency}
              onSuccess={onPaymentSuccess}
              onSubmit={async (payload: PaymentPayload) => {
                captureFormPayload(payload) /* defer real call to confirm step */
              }}
            />
          </div>
        {:else if step === 'confirm'}
          <!-- CONFIRM -->
          <div transition:fly={{ x: flyX, duration: reducedMotion ? 0 : 280 }} class="p-6">
            <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">
              {#if paymentPayload}
                <PaymentCard
                  number={paymentPayload.number}
                  name={paymentPayload.name}
                  expiry={paymentPayload.expiry}
                  brand={paymentPayload.brand}
                  variant="compact"
                  flip={false}
                />
              {/if}
              <div class="min-w-0 flex-1 text-sm">
                <p class="font-medium">Paying with card ending {paymentPayload?.number.slice(-4)}</p>
                <p class="text-muted-foreground">{paymentPayload?.name} · expires {paymentPayload?.expiry}</p>
                <button class="text-primary mt-2 text-xs underline" onclick={() => go('payment')}>
                  Change payment method
                </button>
              </div>
            </div>
    
            <ul class="divide-border divide-y border-y">
              {#each cart as item (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>
              {/each}
            </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}
              {#if taxRate > 0}
                <div class="flex justify-between">
                  <dt class="text-muted-foreground">Tax</dt>
                  <dd>{fmt(tax)}</dd>
                </div>
              {/if}
              <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 variant="outline" onclick={() => go('payment')}>
                <ArrowLeft class="mr-1.5 size-4" /> Back
              </Button>
              <Button disabled={placing} onclick={placeOrder}>
                {placing ? 'Placing order…' : `Place order · ${fmt(total)}`}
              </Button>
            </div>
          </div>
        {:else if step === 'success'}
          <!-- SUCCESS -->
          <div
            transition:fly={{ x: flyX, duration: reducedMotion ? 0 : 280 }}
            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">
              {#each particles as p (p.id)}
                <div
                  class="absolute"
                  style={`left:${p.x}%;top:${p.y}%;width:${p.size}px;height:${p.size}px;background:${p.color};transform:rotate(${p.rot}deg);border-radius:2px`}
                ></div>
              {/each}
            </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="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">#{orderId}</div>
            <Button variant="outline" class="mt-6" onclick={() => oncancel?.()}>Back to home</Button>
          </div>
        {:else if step === 'error'}
          <!-- ERROR -->
          <div
            transition:fly={{ x: flyX, duration: reducedMotion ? 0 : 280 }}
            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" pathLength="100" class="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">
              {lastError?.message || 'Something went wrong while charging your card.'}
            </p>
            <div class="mt-6 flex gap-2">
              <Button variant="outline" onclick={() => oncancel?.()}>Cancel</Button>
              <Button onclick={() => go('payment')}>Try again</Button>
            </div>
          </div>
        {/if}
      </div>
    </div>
    
    <style>
      .check-draw {
        stroke-dasharray: 100;
        stroke-dashoffset: 100;
        animation: check-draw 700ms cubic-bezier(0.4, 0, 0.2, 1) 120ms forwards;
      }
      @keyframes check-draw {
        to {
          stroke-dashoffset: 0;
        }
      }
    
      @media (prefers-reduced-motion: reduce) {
        .check-draw {
          animation: none;
          stroke-dashoffset: 0;
        }
      }
    </style>
    

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