# Bag Checkout

A three-step checkout with delivery, payment, and review steps beside an order summary.

Bag Checkout is an open-source block from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/blocks/bag-checkout

**React**

**Example**

```tsx title="components/blocks/bag-checkout.tsx"
"use client"

import { CheckIcon, CreditCardIcon, WalletIcon } from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=200&q=80&auto=format&fit=crop`

const items = [
  {
    name: "Phone Pro",
    variant: "256 GB · Cosmic Orange",
    price: 1099,
    image: photo("1714972384975-fbd4f19b1bb3"),
  },
  {
    name: "Pods Studio",
    variant: "Midnight",
    price: 549,
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Phone Pro Clear Case",
    variant: "With MagCharge",
    price: 49,
    image: photo("1703676311066-34caf2cbee33"),
  },
]

const money = (value: number) =>
  value.toLocaleString("en-US", { style: "currency", currency: "USD" })

const choiceClass =
  "flex cursor-pointer items-center gap-3 rounded-[0.875rem] px-4 py-3.5 text-base font-normal shadow-[inset_0_0_0_1px_var(--color-input)] transition-shadow duration-200 has-data-checked:shadow-[inset_0_0_0_2px_var(--color-primary)] has-focus-visible:ring-3 has-focus-visible:ring-ring/50"

function stepState(index: number, current: number) {
  if (current > index) return "done"
  if (current === index) return "open"
  return "idle"
}

function Step({
  index,
  current,
  title,
  summary,
  onEdit,
  children,
}: {
  index: number
  current: number
  title: string
  summary?: string
  onEdit: () => void
  children: React.ReactNode
}) {
  const open = current === index
  const done = current > index

  return (
    <section
      aria-current={open ? "step" : undefined}
      className="border-b border-border py-4.5"
    >
      <div className="flex items-center gap-2.5">
        <span
          data-state={stepState(index, current)}
          className="flex size-6.5 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-muted-foreground data-[state=done]:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_35%)] data-[state=done]:text-white data-[state=open]:bg-foreground data-[state=open]:text-background dark:data-[state=done]:bg-[color-mix(in_oklab,var(--color-green-500),black_35%)]"
        >
          {done ? (
            <CheckIcon className="size-3.25" />
          ) : (
            index + 1
          )}
        </span>
        <h2
          data-idle={!open && !done ? "" : undefined}
          className="flex-1 text-2xl font-semibold data-idle:text-muted-foreground"
        >
          {title}
        </h2>
        {done && (
          <button
            type="button"
            onClick={onEdit}
            aria-label={`Edit ${title.toLowerCase()}`}
            className="rounded-xs text-sm text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
          >
            Edit ›
          </button>
        )}
      </div>
      {done && summary && (
        <p className="ps-9 pt-1.5 text-sm text-muted-foreground">{summary}</p>
      )}
      <div
        data-open={open ? "" : undefined}
        inert={!open}
        className="grid grid-rows-[0fr] transition-[grid-template-rows] duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] data-open:grid-rows-[1fr] motion-reduce:transition-none"
      >
        <div className="min-h-0 overflow-hidden">
          <div className="flex flex-col gap-2.5 ps-9 pt-4 pb-1">{children}</div>
        </div>
      </div>
    </section>
  )
}

export function BagCheckout() {
  const [step, setStep] = React.useState(0)
  const [shipping, setShipping] = React.useState("standard")
  const [payment, setPayment] = React.useState("card")
  const [placed, setPlaced] = React.useState(false)
  const total =
    items.reduce((sum, item) => sum + item.price, 0) +
    (shipping === "express" ? 19 : 0)

  if (placed) {
    return (
      <section className="flex min-h-160 items-center justify-center p-6">
        <div className="flex max-w-105 flex-col items-center gap-3.5 text-center transition-[opacity,translate] duration-1000 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-4.5 starting:opacity-0 motion-reduce:transition-none">
          <span className="flex size-19 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_25%)] text-white dark:bg-[color-mix(in_oklab,var(--color-green-500),black_35%)]">
            <CheckIcon className="size-9.5" />
          </span>
          <h1 className="text-4xl font-semibold tracking-tight">
            Thank you. Your order is in.
          </h1>
          <p className="text-lg text-muted-foreground">
            Your order number is W1049. We sent a confirmation email.
          </p>
          <button
            type="button"
            onClick={() => {
              setPlaced(false)
              setStep(0)
            }}
            className="rounded-xs text-lg text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
          >
            Start over ›
          </button>
        </div>
      </section>
    )
  }

  return (
    <section className="grid min-h-160 bg-background md:grid-cols-[minmax(0,1.4fr)_minmax(16.25rem,1fr)]">
      <div className="px-5 py-6 md:px-9">
        <h1 className="mb-2 text-3xl font-semibold tracking-tight">Checkout</h1>
        <Step
          index={0}
          current={step}
          title="Delivery"
          summary={
            shipping === "express"
              ? "Express · Tomorrow"
              : "Standard · 3–5 business days"
          }
          onEdit={() => setStep(0)}
        >
          <RadioGroup
            value={shipping}
            onValueChange={(value) => setShipping(value as string)}
            aria-label="Delivery"
            className="gap-2.5"
          >
            <Label className={choiceClass}>
              <RadioGroupItem
                value="standard"
                className="size-5 focus-visible:ring-0"
              />
              <span className="flex-1">Standard · 3–5 business days</span>
              <span>Free</span>
            </Label>
            <Label className={choiceClass}>
              <RadioGroupItem
                value="express"
                className="size-5 focus-visible:ring-0"
              />
              <span className="flex-1">Express · Tomorrow</span>
              <span>$19</span>
            </Label>
          </RadioGroup>
          <Button size="sm" className="self-start" onClick={() => setStep(1)}>
            Continue to Payment
          </Button>
        </Step>
        <Step
          index={1}
          current={step}
          title="Payment"
          summary={payment === "card" ? "Card •••• 4242" : "suiss Pay"}
          onEdit={() => setStep(1)}
        >
          <RadioGroup
            value={payment}
            onValueChange={(value) => setPayment(value as string)}
            aria-label="Payment"
            className="gap-2.5"
          >
            <Label className={choiceClass}>
              <RadioGroupItem
                value="card"
                className="size-5 focus-visible:ring-0"
              />
              <CreditCardIcon className="size-4.5" />
              <span className="flex-1">Credit card · •••• 4242</span>
            </Label>
            <Label className={choiceClass}>
              <RadioGroupItem
                value="pay"
                className="size-5 focus-visible:ring-0"
              />
              <WalletIcon className="size-4.5" />
              <span className="flex-1">suiss Pay</span>
            </Label>
          </RadioGroup>
          <Button size="sm" className="self-start" onClick={() => setStep(2)}>
            Continue to Review
          </Button>
        </Step>
        <Step index={2} current={step} title="Review" onEdit={() => setStep(2)}>
          <p className="text-sm leading-normal text-muted-foreground">
            By placing your order, you agree to the Sales Policy.
          </p>
          <Button className="self-start" onClick={() => setPlaced(true)}>
            Place Order
          </Button>
        </Step>
      </div>
      <aside
        aria-label="Order summary"
        className="flex flex-col gap-3.5 bg-muted px-6.5 py-7"
      >
        <h2 className="text-lg font-semibold">Order summary</h2>
        <ul className="flex flex-col gap-3.5">
          {items.map((item) => (
            <li key={item.name} className="flex items-center gap-2.5">
              <img
                src={item.image}
                alt=""
                className="size-12 shrink-0 rounded-[0.625rem] bg-secondary object-cover"
              />
              <span className="flex min-w-0 flex-1 flex-col">
                <span className="text-sm font-semibold">{item.name}</span>
                <span className="text-xs text-muted-foreground">
                  {item.variant}
                </span>
              </span>
              <span className="text-sm tabular-nums">{money(item.price)}</span>
            </li>
          ))}
        </ul>
        <div className="flex items-center border-t border-border pt-3">
          <span className="flex-1 text-base font-semibold">Total</span>
          <span
            className="text-lg font-semibold tabular-nums"
            aria-live="polite"
          >
            {money(total)}
          </span>
        </div>
      </aside>
    </section>
  )
}
```

A checkout that walks the shopper through delivery, payment and review one step at a time, with the order summary and running total alongside. Finished steps collapse to a short summary with an Edit button, and placing the order swaps the form for a confirmation. The summary sits beside the steps on desktop and stacks into a single column on mobile. Use it as the base for any multi-step purchase or booking flow.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/bag-checkout
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks bag-checkout
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Label](https://ui.suiss.app/docs/components/label), [Radio Group](https://ui.suiss.app/docs/components/radio-group).

3. Copy the source into `components/blocks/bag-checkout.tsx`:

```tsx title="components/blocks/bag-checkout.tsx"
"use client"

import { CheckIcon, CreditCardIcon, WalletIcon } from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=200&q=80&auto=format&fit=crop`

const items = [
  {
    name: "Phone Pro",
    variant: "256 GB · Cosmic Orange",
    price: 1099,
    image: photo("1714972384975-fbd4f19b1bb3"),
  },
  {
    name: "Pods Studio",
    variant: "Midnight",
    price: 549,
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Phone Pro Clear Case",
    variant: "With MagCharge",
    price: 49,
    image: photo("1703676311066-34caf2cbee33"),
  },
]

const money = (value: number) =>
  value.toLocaleString("en-US", { style: "currency", currency: "USD" })

const choiceClass =
  "flex cursor-pointer items-center gap-3 rounded-[0.875rem] px-4 py-3.5 text-base font-normal shadow-[inset_0_0_0_1px_var(--color-input)] transition-shadow duration-200 has-data-checked:shadow-[inset_0_0_0_2px_var(--color-primary)] has-focus-visible:ring-3 has-focus-visible:ring-ring/50"

function stepState(index: number, current: number) {
  if (current > index) return "done"
  if (current === index) return "open"
  return "idle"
}

function Step({
  index,
  current,
  title,
  summary,
  onEdit,
  children,
}: {
  index: number
  current: number
  title: string
  summary?: string
  onEdit: () => void
  children: React.ReactNode
}) {
  const open = current === index
  const done = current > index

  return (
    <section
      aria-current={open ? "step" : undefined}
      className="border-b border-border py-4.5"
    >
      <div className="flex items-center gap-2.5">
        <span
          data-state={stepState(index, current)}
          className="flex size-6.5 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-muted-foreground data-[state=done]:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_35%)] data-[state=done]:text-white data-[state=open]:bg-foreground data-[state=open]:text-background dark:data-[state=done]:bg-[color-mix(in_oklab,var(--color-green-500),black_35%)]"
        >
          {done ? (
            <CheckIcon className="size-3.25" />
          ) : (
            index + 1
          )}
        </span>
        <h2
          data-idle={!open && !done ? "" : undefined}
          className="flex-1 text-2xl font-semibold data-idle:text-muted-foreground"
        >
          {title}
        </h2>
        {done && (
          <button
            type="button"
            onClick={onEdit}
            aria-label={`Edit ${title.toLowerCase()}`}
            className="rounded-xs text-sm text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
          >
            Edit ›
          </button>
        )}
      </div>
      {done && summary && (
        <p className="ps-9 pt-1.5 text-sm text-muted-foreground">{summary}</p>
      )}
      <div
        data-open={open ? "" : undefined}
        inert={!open}
        className="grid grid-rows-[0fr] transition-[grid-template-rows] duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] data-open:grid-rows-[1fr] motion-reduce:transition-none"
      >
        <div className="min-h-0 overflow-hidden">
          <div className="flex flex-col gap-2.5 ps-9 pt-4 pb-1">{children}</div>
        </div>
      </div>
    </section>
  )
}

export function BagCheckout() {
  const [step, setStep] = React.useState(0)
  const [shipping, setShipping] = React.useState("standard")
  const [payment, setPayment] = React.useState("card")
  const [placed, setPlaced] = React.useState(false)
  const total =
    items.reduce((sum, item) => sum + item.price, 0) +
    (shipping === "express" ? 19 : 0)

  if (placed) {
    return (
      <section className="flex min-h-160 items-center justify-center p-6">
        <div className="flex max-w-105 flex-col items-center gap-3.5 text-center transition-[opacity,translate] duration-1000 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-4.5 starting:opacity-0 motion-reduce:transition-none">
          <span className="flex size-19 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_25%)] text-white dark:bg-[color-mix(in_oklab,var(--color-green-500),black_35%)]">
            <CheckIcon className="size-9.5" />
          </span>
          <h1 className="text-4xl font-semibold tracking-tight">
            Thank you. Your order is in.
          </h1>
          <p className="text-lg text-muted-foreground">
            Your order number is W1049. We sent a confirmation email.
          </p>
          <button
            type="button"
            onClick={() => {
              setPlaced(false)
              setStep(0)
            }}
            className="rounded-xs text-lg text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
          >
            Start over ›
          </button>
        </div>
      </section>
    )
  }

  return (
    <section className="grid min-h-160 bg-background md:grid-cols-[minmax(0,1.4fr)_minmax(16.25rem,1fr)]">
      <div className="px-5 py-6 md:px-9">
        <h1 className="mb-2 text-3xl font-semibold tracking-tight">Checkout</h1>
        <Step
          index={0}
          current={step}
          title="Delivery"
          summary={
            shipping === "express"
              ? "Express · Tomorrow"
              : "Standard · 3–5 business days"
          }
          onEdit={() => setStep(0)}
        >
          <RadioGroup
            value={shipping}
            onValueChange={(value) => setShipping(value as string)}
            aria-label="Delivery"
            className="gap-2.5"
          >
            <Label className={choiceClass}>
              <RadioGroupItem
                value="standard"
                className="size-5 focus-visible:ring-0"
              />
              <span className="flex-1">Standard · 3–5 business days</span>
              <span>Free</span>
            </Label>
            <Label className={choiceClass}>
              <RadioGroupItem
                value="express"
                className="size-5 focus-visible:ring-0"
              />
              <span className="flex-1">Express · Tomorrow</span>
              <span>$19</span>
            </Label>
          </RadioGroup>
          <Button size="sm" className="self-start" onClick={() => setStep(1)}>
            Continue to Payment
          </Button>
        </Step>
        <Step
          index={1}
          current={step}
          title="Payment"
          summary={payment === "card" ? "Card •••• 4242" : "suiss Pay"}
          onEdit={() => setStep(1)}
        >
          <RadioGroup
            value={payment}
            onValueChange={(value) => setPayment(value as string)}
            aria-label="Payment"
            className="gap-2.5"
          >
            <Label className={choiceClass}>
              <RadioGroupItem
                value="card"
                className="size-5 focus-visible:ring-0"
              />
              <CreditCardIcon className="size-4.5" />
              <span className="flex-1">Credit card · •••• 4242</span>
            </Label>
            <Label className={choiceClass}>
              <RadioGroupItem
                value="pay"
                className="size-5 focus-visible:ring-0"
              />
              <WalletIcon className="size-4.5" />
              <span className="flex-1">suiss Pay</span>
            </Label>
          </RadioGroup>
          <Button size="sm" className="self-start" onClick={() => setStep(2)}>
            Continue to Review
          </Button>
        </Step>
        <Step index={2} current={step} title="Review" onEdit={() => setStep(2)}>
          <p className="text-sm leading-normal text-muted-foreground">
            By placing your order, you agree to the Sales Policy.
          </p>
          <Button className="self-start" onClick={() => setPlaced(true)}>
            Place Order
          </Button>
        </Step>
      </div>
      <aside
        aria-label="Order summary"
        className="flex flex-col gap-3.5 bg-muted px-6.5 py-7"
      >
        <h2 className="text-lg font-semibold">Order summary</h2>
        <ul className="flex flex-col gap-3.5">
          {items.map((item) => (
            <li key={item.name} className="flex items-center gap-2.5">
              <img
                src={item.image}
                alt=""
                className="size-12 shrink-0 rounded-[0.625rem] bg-secondary object-cover"
              />
              <span className="flex min-w-0 flex-1 flex-col">
                <span className="text-sm font-semibold">{item.name}</span>
                <span className="text-xs text-muted-foreground">
                  {item.variant}
                </span>
              </span>
              <span className="text-sm tabular-nums">{money(item.price)}</span>
            </li>
          ))}
        </ul>
        <div className="flex items-center border-t border-border pt-3">
          <span className="flex-1 text-base font-semibold">Total</span>
          <span
            className="text-lg font-semibold tabular-nums"
            aria-live="polite"
          >
            {money(total)}
          </span>
        </div>
      </aside>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { BagCheckout } from "@/components/blocks/bag-checkout"
```

```tsx
<BagCheckout />
```

Swap the `items` array for your cart lines, each with a name, variant, price and image. The delivery and payment options and the express surcharge are hard-coded in the component, so drive them from your shipping and payment providers. Wire Place Order to your order API; today it only shows a confirmation with the fixed order number W1049.

## Notes

Collapsed steps are `inert`, so keyboard users only reach the open step, and the total is announced when it changes. Product photos are Unsplash placeholders.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-bag-checkout--default.light.webp)

```tsx title="uniwind/components/blocks/bag-checkout.tsx"
import { cn } from "cn"
import {
  CheckIcon,
  CreditCardIcon,
  type Icon as PhosphorIcon,
  WalletIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Image, Pressable, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import {
  Header,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

type Shipping = "standard" | "express"

type Payment = "card" | "pay"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=200&q=80&auto=format&fit=crop`

const ITEMS = [
  {
    name: "Phone Pro",
    variant: "256 GB · Cosmic Orange",
    price: 1099,
    image: photo("1714972384975-fbd4f19b1bb3"),
  },
  {
    name: "Pods Studio",
    variant: "Midnight",
    price: 549,
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Phone Pro Clear Case",
    variant: "With MagCharge",
    price: 49,
    image: photo("1703676311066-34caf2cbee33"),
  },
]

const SHIPPING: { value: Shipping; label: string; price: string }[] = [
  { value: "standard", label: "Standard · 3–5 business days", price: "Free" },
  { value: "express", label: "Express · Tomorrow", price: "$19" },
]

const PAYMENT: { value: Payment; label: string; icon: PhosphorIcon }[] = [
  { value: "card", label: "Credit card · •••• 4242", icon: CreditCardIcon },
  { value: "pay", label: "suiss Pay", icon: WalletIcon },
]

const ACTIONS = ["Continue to Payment", "Continue to Review", "Place Order"]

const THOUSANDS = /\B(?=(\d{3})+(?!\d))/g

function money(value: number, digits = 2) {
  try {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
      minimumFractionDigits: digits,
      maximumFractionDigits: digits,
    }).format(value)
  } catch {
    const [whole = "0", fraction] = value.toFixed(digits).split(".")
    return `$${whole.replace(THOUSANDS, ",")}${fraction ? `.${fraction}` : ""}`
  }
}

function Choice<Value extends string>({
  value,
  checked,
  label,
  onSelect,
  children,
}: {
  value: Value
  checked: boolean
  label: string
  onSelect: (value: Value) => void
  children: React.ReactNode
}) {
  return (
    <Pressable
      accessible={false}
      onPress={() => onSelect(value)}
      className={cn(
        "flex-row items-center gap-3 rounded-[0.875rem] active:opacity-80",
        checked
          ? "border-2 border-primary px-3.75 py-3.25"
          : "border border-input px-4 py-3.5"
      )}
    >
      <RadioGroupItem
        value={value}
        accessibilityLabel={label}
        className="size-5"
      />
      {children}
    </Pressable>
  )
}

function Step({
  index,
  current,
  title,
  summary,
  onEdit,
  children,
}: {
  index: number
  current: number
  title: string
  summary?: string
  onEdit: () => void
  children: React.ReactNode
}) {
  const open = current === index
  const done = current > index

  return (
    <View className="border-b border-border py-4.5">
      <View className="flex-row items-center gap-2.5">
        <View
          className={cn(
            "size-6.5 items-center justify-center rounded-full",
            done && "bg-green-500",
            !done && open && "bg-foreground",
            !(done || open) && "bg-secondary"
          )}
        >
          {done ? (
            <Icon
              as={CheckIcon}
              weight="bold"
              className="size-3.25 text-primary-foreground"
            />
          ) : (
            <Text
              className={cn(
                "text-sm font-semibold",
                open ? "text-background" : "text-muted-foreground"
              )}
            >
              {index + 1}
            </Text>
          )}
        </View>
        <Text
          role="heading"
          className={cn(
            "flex-1 text-2xl font-semibold",
            !open && !done && "text-muted-foreground"
          )}
        >
          {title}
        </Text>
        {done ? (
          <Pressable
            role="button"
            hitSlop={8}
            onPress={onEdit}
            accessibilityLabel={`Edit ${title.toLowerCase()}`}
            className="active:opacity-60"
          >
            <Text className="text-sm text-primary">Edit ›</Text>
          </Pressable>
        ) : null}
      </View>
      {done && summary ? (
        <Text className="ps-9 pt-1.5 text-sm text-muted-foreground">
          {summary}
        </Text>
      ) : null}
      {open ? (
        <AnimatedView
          entering={FadeIn.duration(450).reduceMotion(ReduceMotion.System)}
          className="gap-2.5 ps-9 pt-4 pb-1"
        >
          {children}
        </AnimatedView>
      ) : null}
    </View>
  )
}

function BagCheckout({
  onPlaceOrder,
  onStartOver,
}: {
  onPlaceOrder?: (order: {
    shipping: Shipping
    payment: Payment
    total: number
  }) => void
  onStartOver?: () => void
}) {
  const scroll = useHeaderScroll()
  const [step, setStep] = React.useState(0)
  const [shipping, setShipping] = React.useState<Shipping>("standard")
  const [payment, setPayment] = React.useState<Payment>("card")
  const [placed, setPlaced] = React.useState(false)
  const total =
    ITEMS.reduce((sum, item) => sum + item.price, 0) +
    (shipping === "express" ? 19 : 0)

  if (placed) {
    return (
      <Screen>
        <View className="flex-1 items-center justify-center p-6">
          <AnimatedView
            entering={FadeInDown.duration(1000).reduceMotion(
              ReduceMotion.System
            )}
            className="max-w-105 items-center gap-3.5"
          >
            <View className="size-19 items-center justify-center rounded-full bg-green-500">
              <Icon
                as={CheckIcon}
                weight="bold"
                className="size-9.5 text-primary-foreground"
              />
            </View>
            <Text role="heading" className="text-center text-3xl font-semibold">
              Thank you. Your order is in.
            </Text>
            <Text className="text-center text-lg text-muted-foreground">
              Your order number is W1049. We sent a confirmation email.
            </Text>
            <Pressable
              role="button"
              hitSlop={8}
              onPress={() => {
                onStartOver?.()
                setPlaced(false)
                setStep(0)
              }}
              className="active:opacity-60"
            >
              <Text className="text-lg text-primary">Start over ›</Text>
            </Pressable>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  const next = () => {
    if (step < 2) {
      setStep(step + 1)
      return
    }
    onPlaceOrder?.({ shipping, payment, total })
    setPlaced(true)
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <Header scroll={scroll}>
        <HeaderTitle>Checkout</HeaderTitle>
      </Header>
      <HeaderScrollView scroll={scroll}>
        <HeaderLargeTitle>Checkout</HeaderLargeTitle>
        <View className="px-5">
          <Step
            index={0}
            current={step}
            title="Delivery"
            summary={
              shipping === "express"
                ? "Express · Tomorrow"
                : "Standard · 3–5 business days"
            }
            onEdit={() => setStep(0)}
          >
            <RadioGroup
              value={shipping}
              onValueChange={setShipping}
              accessibilityLabel="Delivery"
              className="gap-2.5"
            >
              {SHIPPING.map((option) => (
                <Choice
                  key={option.value}
                  value={option.value}
                  checked={shipping === option.value}
                  label={option.label}
                  onSelect={setShipping}
                >
                  <Text className="flex-1 text-base">{option.label}</Text>
                  <Text className="text-base">{option.price}</Text>
                </Choice>
              ))}
            </RadioGroup>
          </Step>
          <Step
            index={1}
            current={step}
            title="Payment"
            summary={payment === "card" ? "Card •••• 4242" : "suiss Pay"}
            onEdit={() => setStep(1)}
          >
            <RadioGroup
              value={payment}
              onValueChange={setPayment}
              accessibilityLabel="Payment"
              className="gap-2.5"
            >
              {PAYMENT.map((option) => (
                <Choice
                  key={option.value}
                  value={option.value}
                  checked={payment === option.value}
                  label={option.label}
                  onSelect={setPayment}
                >
                  <Icon as={option.icon} className="size-4.5" />
                  <Text className="flex-1 text-base">{option.label}</Text>
                </Choice>
              ))}
            </RadioGroup>
          </Step>
          <Step
            index={2}
            current={step}
            title="Review"
            onEdit={() => setStep(2)}
          >
            <Text className="text-sm text-muted-foreground">
              By placing your order, you agree to the Sales Policy.
            </Text>
          </Step>
        </View>
        <View className="mx-4 mt-6 mb-8 gap-3.5 rounded-2xl bg-muted px-5 py-5">
          <Text role="heading" className="text-lg font-semibold">
            Order summary
          </Text>
          {ITEMS.map((item) => (
            <View key={item.name} className="flex-row items-center gap-2.5">
              <Image
                source={{ uri: item.image }}
                resizeMode="cover"
                accessibilityIgnoresInvertColors
                className="size-12 shrink-0 rounded-[0.625rem] bg-secondary"
              />
              <View className="min-w-0 flex-1">
                <Text className="text-sm font-semibold">{item.name}</Text>
                <Text className="text-xs text-muted-foreground">
                  {item.variant}
                </Text>
              </View>
              <Text className="text-sm tabular-nums">{money(item.price)}</Text>
            </View>
          ))}
          <View className="flex-row items-center border-t border-border pt-3">
            <Text className="flex-1 text-base font-semibold">Total</Text>
            <Text className="text-lg font-semibold tabular-nums">
              {money(total)}
            </Text>
          </View>
        </View>
      </HeaderScrollView>
      <BottomBar separator>
        <BottomBarContent className="flex-row items-center gap-3">
          <View accessibilityLiveRegion="polite" className="flex-1">
            <Text className="text-xs text-muted-foreground">Total</Text>
            <Text className="text-lg font-semibold tabular-nums">
              {money(total)}
            </Text>
          </View>
          <Button size="lg" onPress={next}>
            <Text>{ACTIONS[step]}</Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { BagCheckout }
```

```swift title="swift/Blocks/BagCheckout.swift"
import PhosphorSwift
import SwiftUI

public struct BagCheckout: View {
  struct Product: Identifiable {
    let name: String
    let variant: String
    let price: Double
    let image: URL?

    var id: String { name }
  }

  static func photo(_ id: String) -> URL? {
    URL(string: "https://images.unsplash.com/photo-\(id)?w=200&q=80&auto=format&fit=crop")
  }

  static var items: [Product] {
    [
      Product(name: "Phone Pro", variant: "256 GB · Cosmic Orange", price: 1099, image: photo("1714972384975-fbd4f19b1bb3")),
      Product(name: "Pods Studio", variant: "Midnight", price: 549, image: photo("1599669454699-248893623440")),
      Product(name: "Phone Pro Clear Case", variant: "With MagCharge", price: 49, image: photo("1703676311066-34caf2cbee33")),
    ]
  }

  private var onPlaceOrder: ((String, String, Double) -> Void)?
  private var onStartOver: (() -> Void)?
  @State private var step: Int
  @State private var shipping: String
  @State private var payment: String
  @State private var placed: Bool
  @Environment(\.locale) private var locale
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onPlaceOrder: ((_ shipping: String, _ payment: String, _ total: Double) -> Void)? = nil,
    onStartOver: (() -> Void)? = nil
  ) {
    self.init(step: 0)
    self.onPlaceOrder = onPlaceOrder
    self.onStartOver = onStartOver
  }

  init(step: Int, shipping: String = "standard", payment: String = "card", placed: Bool = false) {
    _step = State(initialValue: step)
    _shipping = State(initialValue: shipping)
    _payment = State(initialValue: payment)
    _placed = State(initialValue: placed)
  }

  private var total: Double {
    Self.items.reduce(0) { $0 + $1.price } + (shipping == "express" ? 19 : 0)
  }

  private func money(_ value: Double) -> String {
    value.formatted(.currency(code: "USD").locale(locale))
  }

  private var motion: Animation? {
    reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.45)
  }

  public var body: some View {
    Screen {
      if placed {
        thanks
          .transition(.opacity.combined(with: .offset(y: 18)))
      } else {
        HeaderScrollView {
          HeaderLargeTitle("Checkout")
          VStack(alignment: .leading, spacing: 0) {
            BagCheckoutStep(index: 0, current: step, title: "Delivery", summary: shipping == "express" ? "Express · Tomorrow" : "Standard · 3–5 business days") {
              go(0)
            } content: {
              RadioGroup(selection: $shipping) {
                BagCheckoutChoice(value: "standard", selection: $shipping) {
                  Text("Standard · 3–5 business days").frame(maxWidth: .infinity, alignment: .leading)
                  Text("Free")
                }
                BagCheckoutChoice(value: "express", selection: $shipping) {
                  Text("Express · Tomorrow").frame(maxWidth: .infinity, alignment: .leading)
                  Text(verbatim: money(19))
                }
              }
              .accessibilityLabel(Text("Delivery"))
            }
            BagCheckoutStep(index: 1, current: step, title: "Payment", summary: payment == "card" ? "Card •••• 4242" : "suiss Pay") {
              go(1)
            } content: {
              RadioGroup(selection: $payment) {
                BagCheckoutChoice(value: "card", selection: $payment) {
                  Ph.creditCard.regular.frame(width: 18, height: 18)
                  Text("Credit card · •••• 4242").frame(maxWidth: .infinity, alignment: .leading)
                }
                BagCheckoutChoice(value: "pay", selection: $payment) {
                  Ph.wallet.regular.frame(width: 18, height: 18)
                  Text(verbatim: "suiss Pay").frame(maxWidth: .infinity, alignment: .leading)
                }
              }
              .accessibilityLabel(Text("Payment"))
            }
            BagCheckoutStep(index: 2, current: step, title: "Review", summary: nil) {
              go(2)
            } content: {
              Text("By placing your order, you agree to the Sales Policy.")
                .scaledFont(.sm)
                .foregroundStyle(EsuissColor.mutedForeground)
                .fixedSize(horizontal: false, vertical: true)
            }
            summary
              .padding(.top, 28)
          }
          .padding(.horizontal, 16)
          .padding(.bottom, 24)
        } header: {
          Header {
            HeaderTitle("Checkout")
          }
        }
        BottomBar {
          BottomBarContent {
            HStack(spacing: 12) {
              VStack(alignment: .leading, spacing: 0) {
                Text("Total")
                  .scaledFont(.xs)
                  .foregroundStyle(EsuissColor.mutedForeground)
                Text(verbatim: money(total))
                  .scaledFont(.lg, weight: .semibold)
                  .monospacedDigit()
                  .contentTransition(.numericText())
              }
              .frame(maxWidth: .infinity, alignment: .leading)
              .accessibilityElement(children: .combine)
              Button(step == 0 ? "Continue to Payment" : step == 1 ? "Continue to Review" : "Place Order") {
                advance()
              }
              .buttonStyle(.variant(.default, size: .lg))
              .fixedSize()
            }
          }
        }
      }
    }
    .animation(motion, value: step)
    .animation(motion, value: shipping)
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 1), value: placed)
  }

  private func go(_ index: Int) {
    step = index
  }

  private func advance() {
    if step < 2 {
      step += 1
    } else {
      onPlaceOrder?(shipping, payment, total)
      placed = true
    }
  }

  private var summary: some View {
    VStack(alignment: .leading, spacing: 14) {
      Text("Order summary")
        .scaledFont(.lg, weight: .semibold)
        .accessibilityAddTraits(.isHeader)
      ForEach(Self.items) { item in
        HStack(spacing: 10) {
          EsuissColor.secondary
            .overlay {
              AsyncImage(url: item.image) { phase in
                if let image = phase.image {
                  image.resizable().scaledToFill()
                }
              }
            }
            .frame(width: 48, height: 48)
            .clipShape(RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
            .accessibilityHidden(true)
          VStack(alignment: .leading, spacing: 0) {
            Text(verbatim: item.name)
              .scaledFont(.sm, weight: .semibold)
            Text(verbatim: item.variant)
              .scaledFont(.xs)
              .foregroundStyle(EsuissColor.mutedForeground)
          }
          .frame(maxWidth: .infinity, alignment: .leading)
          Text(verbatim: money(item.price))
            .scaledFont(.sm)
            .monospacedDigit()
        }
        .accessibilityElement(children: .combine)
      }
      HStack {
        Text("Total")
          .scaledFont(.base, weight: .semibold)
          .frame(maxWidth: .infinity, alignment: .leading)
        Text(verbatim: money(total))
          .scaledFont(.lg, weight: .semibold)
          .monospacedDigit()
      }
      .padding(.top, 12)
      .overlay(alignment: .top) {
        EsuissColor.border.frame(height: 1)
      }
      .accessibilityElement(children: .combine)
    }
    .padding(20)
    .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
  }

  private var thanks: some View {
    VStack(spacing: 14) {
      Ph.check.bold
        .frame(width: 38, height: 38)
        .foregroundStyle(EsuissColor.primaryForeground)
        .frame(width: 76, height: 76)
        .background(Palette.green500, in: Circle())
        .accessibilityHidden(true)
      Text("Thank you. Your order is in.")
        .scaledTextStyle(.xl3, weight: .semibold)
        .accessibilityAddTraits(.isHeader)
      Text("Your order number is W1049. We sent a confirmation email.")
        .scaledTextStyle(.lg)
        .foregroundStyle(EsuissColor.mutedForeground)
      Button("Start over ›") {
        onStartOver?()
        placed = false
        step = 0
      }
      .buttonStyle(.variant(.link, size: .lg))
    }
    .multilineTextAlignment(.center)
    .fixedSize(horizontal: false, vertical: true)
    .padding(24)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
  }
}

private struct BagCheckoutStep<Content: View>: View {
  let index: Int
  let current: Int
  let title: LocalizedStringKey
  let summary: LocalizedStringKey?
  let onEdit: () -> Void
  let content: Content

  init(
    index: Int,
    current: Int,
    title: LocalizedStringKey,
    summary: LocalizedStringKey?,
    onEdit: @escaping () -> Void,
    @ViewBuilder content: () -> Content
  ) {
    self.index = index
    self.current = current
    self.title = title
    self.summary = summary
    self.onEdit = onEdit
    self.content = content()
  }

  var body: some View {
    let open = current == index
    let done = current > index
    VStack(alignment: .leading, spacing: 0) {
      HStack(spacing: 10) {
        Group {
          if done {
            Ph.check.bold.frame(width: 13, height: 13)
          } else {
            Text(verbatim: "\(index + 1)").scaledFont(.sm, weight: .semibold)
          }
        }
        .foregroundStyle(done ? EsuissColor.primaryForeground : open ? EsuissColor.background : EsuissColor.mutedForeground)
        .frame(width: 26, height: 26)
        .background(done ? Palette.green500 : open ? EsuissColor.foreground : EsuissColor.secondary, in: Circle())
        .accessibilityHidden(true)
        Text(title)
          .scaledFont(.xl, weight: .semibold)
          .foregroundStyle(!open && !done ? EsuissColor.mutedForeground : EsuissColor.foreground)
          .frame(maxWidth: .infinity, alignment: .leading)
          .accessibilityAddTraits(.isHeader)
          .accessibilityValue(done ? Text("Completed") : open ? Text("Current step") : Text(verbatim: ""))
        if done {
          Button("Edit ›", action: onEdit)
            .buttonStyle(.variant(.link, size: .sm))
            .accessibilityLabel(Text("Edit \(Text(title))"))
        }
      }
      .frame(minHeight: 32)
      if done, let summary {
        Text(summary)
          .scaledFont(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .padding(.leading, 36)
          .padding(.top, 2)
      }
      if open {
        VStack(alignment: .leading, spacing: 10) {
          content
        }
        .padding(.leading, 36)
        .padding(.top, 16)
        .padding(.bottom, 4)
        .transition(.opacity.combined(with: .move(edge: .top)))
      }
    }
    .padding(.vertical, 18)
    .frame(maxWidth: .infinity, alignment: .leading)
    .clipped()
    .overlay(alignment: .bottom) {
      EsuissColor.border.frame(height: 1)
    }
  }
}

private struct BagCheckoutChoice<Content: View>: View {
  let value: String
  @Binding var selection: String
  let content: Content

  init(value: String, selection: Binding<String>, @ViewBuilder content: () -> Content) {
    self.value = value
    _selection = selection
    self.content = content()
  }

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: 14, style: .continuous)
    let checked = value == selection
    RadioGroupItem(value: value) {
      HStack(spacing: 12) {
        content
      }
      .scaledFont(.base)
      .foregroundStyle(EsuissColor.foreground)
      .frame(maxWidth: .infinity, alignment: .leading)
    }
    .padding(.horizontal, 16)
    .padding(.vertical, 14)
    .overlay {
      shape.strokeBorder(checked ? EsuissColor.primary : EsuissColor.input, lineWidth: checked ? 2 : 1)
    }
    .contentShape(shape)
    .onTapGesture { selection = value }
  }
}
```

```kt title="kotlin/blocks/BagCheckout.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Light
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.Check
import com.adamglin.phosphoricons.light.DeviceMobile
import com.adamglin.phosphoricons.light.DeviceMobileCamera
import com.adamglin.phosphoricons.light.Headphones
import com.adamglin.phosphoricons.regular.CreditCard
import com.adamglin.phosphoricons.regular.Wallet
import com.esuiss.ui.components.BottomBar
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderLargeTitle
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.RadioGroup
import com.esuiss.ui.components.RadioGroupItem
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.rememberHeaderState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale

private val CheckoutRevealEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private class CheckoutItem(val name: String, val variant: String, val price: Double, val icon: ImageVector)

private val CheckoutItems = listOf(
    CheckoutItem("Phone Pro", "256 GB · Cosmic Orange", 1099.0, PhosphorIcons.Light.DeviceMobile),
    CheckoutItem("Pods Studio", "Midnight", 549.0, PhosphorIcons.Light.Headphones),
    CheckoutItem("Phone Pro Clear Case", "With MagCharge", 49.0, PhosphorIcons.Light.DeviceMobileCamera),
)

private fun checkoutMoney(value: Double): String =
    NumberFormat.getCurrencyInstance(Locale.US).apply { currency = Currency.getInstance("USD") }.format(value)

@Composable
fun BagCheckout(
    modifier: Modifier = Modifier,
    onPlaceOrder: (shipping: String, payment: String, total: Double) -> Unit = { _, _, _ -> },
    onStartOver: () -> Unit = {},
) {
    val colors = EsuissTheme.colors
    var step by rememberSaveable { mutableIntStateOf(0) }
    var shipping by rememberSaveable { mutableStateOf("Standard") }
    var payment by rememberSaveable { mutableStateOf("Card") }
    var placed by rememberSaveable { mutableStateOf(false) }
    val header = rememberHeaderState()
    val total = CheckoutItems.sumOf { it.price } + if (shipping == "Express") 19.0 else 0.0

    Screen(modifier) {
        if (placed) {
            CheckoutPlaced {
                onStartOver()
                placed = false
                step = 0
            }
            return@Screen
        }
        Column(
            Modifier
                .fillMaxSize()
                .nestedScroll(header.nestedScrollConnection),
        ) {
            Header(state = header) {
                HeaderTitle("Checkout")
                HeaderLargeTitle("Checkout")
            }
            Column(
                Modifier
                    .weight(1f)
                    .verticalScroll(rememberScrollState()),
            ) {
                Column(Modifier.padding(horizontal = 20.dp)) {
                    CheckoutStep(
                        index = 0,
                        current = step,
                        title = "Delivery",
                        summary = if (shipping == "Express") "Express · Tomorrow" else "Standard · 3–5 business days",
                        onEdit = { step = 0 },
                    ) {
                        RadioGroup(value = shipping, onValueChange = { shipping = it }, modifier = Modifier.semantics { contentDescription = "Delivery" }) {
                            CheckoutChoice("Standard", shipping == "Standard") {
                                BasicText("Standard · 3–5 business days", modifier = Modifier.weight(1f), style = Typography.base.copy(color = colors.foreground))
                                BasicText("Free", style = Typography.base.copy(color = colors.foreground))
                            }
                            CheckoutChoice("Express", shipping == "Express") {
                                BasicText("Express · Tomorrow", modifier = Modifier.weight(1f), style = Typography.base.copy(color = colors.foreground))
                                BasicText("$19", style = Typography.base.copy(color = colors.foreground))
                            }
                        }
                    }
                    CheckoutStep(
                        index = 1,
                        current = step,
                        title = "Payment",
                        summary = if (payment == "Card") "Card •••• 4242" else "suiss Pay",
                        onEdit = { step = 1 },
                    ) {
                        RadioGroup(value = payment, onValueChange = { payment = it }, modifier = Modifier.semantics { contentDescription = "Payment" }) {
                            CheckoutChoice("Card", payment == "Card") {
                                Icon(PhosphorIcons.Regular.CreditCard, contentDescription = null, size = 18.dp, tint = colors.foreground)
                                BasicText("Credit card · •••• 4242", modifier = Modifier.weight(1f), style = Typography.base.copy(color = colors.foreground))
                            }
                            CheckoutChoice("Pay", payment == "Pay") {
                                Icon(PhosphorIcons.Regular.Wallet, contentDescription = null, size = 18.dp, tint = colors.foreground)
                                BasicText("suiss Pay", modifier = Modifier.weight(1f), style = Typography.base.copy(color = colors.foreground))
                            }
                        }
                    }
                    CheckoutStep(index = 2, current = step, title = "Review", summary = null, onEdit = { step = 2 }) {
                        BasicText(
                            "By placing your order, you agree to the Sales Policy.",
                            style = Typography.sm.copy(color = colors.mutedForeground),
                        )
                    }
                }
                CheckoutSummary(total)
            }
            BottomBar(separator = true) {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 16.dp, vertical = 12.dp),
                    horizontalArrangement = Arrangement.spacedBy(12.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Column(
                        Modifier
                            .weight(1f)
                            .semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite },
                    ) {
                        BasicText("Total", style = Typography.xs.copy(color = colors.mutedForeground))
                        BasicText(
                            checkoutMoney(total),
                            style = Typography.lg.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                        )
                    }
                    when (step) {
                        0 -> Button("Continue to Payment", { step = 1 }, size = ButtonSize.Lg)
                        1 -> Button("Continue to Review", { step = 2 }, size = ButtonSize.Lg)
                        else -> Button(
                            "Place Order",
                            {
                                onPlaceOrder(shipping, payment, total)
                                placed = true
                            },
                            size = ButtonSize.Lg,
                        )
                    }
                }
            }
        }
    }
}

@Composable
private fun CheckoutStep(
    index: Int,
    current: Int,
    title: String,
    summary: String?,
    onEdit: () -> Unit,
    content: @Composable ColumnScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val open = current == index
    val done = current > index
    val badge by animateColorAsState(
        when {
            done -> Palette.green500
            open -> colors.foreground
            else -> colors.secondary
        },
        tween(250),
        label = "badge",
    )
    Column(Modifier.fillMaxWidth()) {
        Column(
            Modifier
                .fillMaxWidth()
                .padding(vertical = 18.dp),
        ) {
            Row(
                horizontalArrangement = Arrangement.spacedBy(10.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Box(
                    Modifier
                        .size(26.dp)
                        .background(badge, CircleShape)
                        .clearAndSetSemantics {},
                    contentAlignment = Alignment.Center,
                ) {
                    if (done) {
                        Icon(PhosphorIcons.Bold.Check, contentDescription = null, size = 13.dp, tint = colors.primaryForeground)
                    } else {
                        BasicText(
                            (index + 1).toString(),
                            style = Typography.sm.copy(
                                color = if (open) colors.background else colors.mutedForeground,
                                fontWeight = FontWeight.SemiBold,
                            ),
                        )
                    }
                }
                BasicText(
                    title,
                    modifier = Modifier
                        .weight(1f)
                        .semantics {
                            heading()
                            stateDescription = when {
                                open -> "Current step"
                                done -> "Done"
                                else -> "Not started"
                            }
                        },
                    style = Typography.xl.copy(
                        color = if (open || done) colors.foreground else colors.mutedForeground,
                        fontWeight = FontWeight.SemiBold,
                    ),
                )
                if (done) CheckoutLink("Edit ›", "Edit ${title.lowercase()}", onEdit)
            }
            if (done && summary != null) {
                BasicText(
                    summary,
                    modifier = Modifier.padding(start = 36.dp, top = 6.dp),
                    style = Typography.sm.copy(color = colors.mutedForeground),
                )
            }
            AnimatedVisibility(
                visible = open,
                enter = expandVertically(tween(450, easing = CheckoutRevealEasing)) + fadeIn(tween(300)),
                exit = shrinkVertically(tween(450, easing = CheckoutRevealEasing)) + fadeOut(tween(200)),
            ) {
                Column(
                    Modifier.padding(start = 36.dp, top = 16.dp, bottom = 4.dp),
                    verticalArrangement = Arrangement.spacedBy(10.dp),
                    content = content,
                )
            }
        }
        Box(
            Modifier
                .fillMaxWidth()
                .height(1.dp)
                .background(colors.border),
        )
    }
}

@Composable
private fun CheckoutChoice(value: String, selected: Boolean, content: @Composable RowScope.() -> Unit) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(14.dp)
    val border by animateColorAsState(if (selected) colors.primary else colors.input, tween(200), label = "border")
    RadioGroupItem(
        value = value,
        modifier = Modifier
            .fillMaxWidth()
            .border(if (selected) 2.dp else 1.dp, border, shape)
            .padding(horizontal = 16.dp, vertical = 14.dp),
        label = {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(start = 4.dp),
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.CenterVertically,
                content = content,
            )
        },
    )
}

@Composable
private fun CheckoutSummary(total: Double) {
    val colors = EsuissTheme.colors
    Column(
        Modifier
            .padding(top = 24.dp)
            .fillMaxWidth()
            .background(colors.muted)
            .padding(horizontal = 24.dp, vertical = 28.dp)
            .semantics { contentDescription = "Order summary" },
        verticalArrangement = Arrangement.spacedBy(14.dp),
    ) {
        BasicText(
            "Order summary",
            modifier = Modifier.semantics { heading() },
            style = Typography.lg.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
        )
        CheckoutItems.forEach { item ->
            Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
                Box(
                    Modifier
                        .size(48.dp)
                        .background(colors.secondary, RoundedCornerShape(10.dp)),
                    contentAlignment = Alignment.Center,
                ) {
                    Icon(item.icon, contentDescription = null, size = 26.dp, tint = colors.mutedForeground)
                }
                Column(Modifier.weight(1f)) {
                    BasicText(item.name, style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
                    BasicText(item.variant, style = Typography.xs.copy(color = colors.mutedForeground))
                }
                BasicText(checkoutMoney(item.price), style = Typography.sm.copy(color = colors.foreground, fontFeatureSettings = "tnum"))
            }
        }
        Box(
            Modifier
                .fillMaxWidth()
                .height(1.dp)
                .background(colors.border),
        )
        Row(verticalAlignment = Alignment.CenterVertically) {
            BasicText(
                "Total",
                modifier = Modifier.weight(1f),
                style = Typography.base.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
            )
            BasicText(
                checkoutMoney(total),
                style = Typography.lg.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
            )
        }
    }
}

@Composable
private fun CheckoutLink(text: String, description: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    BasicText(
        text,
        modifier = Modifier
            .graphicsLayer { alpha = if (pressed) 0.8f else 1f }
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick)
            .semantics { contentDescription = description }
            .padding(vertical = 8.dp),
        style = Typography.sm.copy(color = EsuissTheme.colors.primary),
    )
}

@Composable
private fun CheckoutPlaced(onStartOver: () -> Unit) {
    val colors = EsuissTheme.colors
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(1000, easing = CheckoutRevealEasing)) }
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp)
            .graphicsLayer {
                alpha = appear.value
                translationY = (1f - appear.value) * 18.dp.toPx()
            },
        verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Box(
            Modifier
                .size(76.dp)
                .background(Palette.green500, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            Icon(PhosphorIcons.Bold.Check, contentDescription = null, size = 38.dp, tint = colors.primaryForeground)
        }
        BasicText(
            "Thank you. Your order is in.",
            modifier = Modifier.semantics { heading() },
            style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
        )
        BasicText(
            "Your order number is W1049. We sent a confirmation email.",
            style = Typography.lg.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
        )
        BasicText(
            "Start over ›",
            modifier = Modifier
                .graphicsLayer { alpha = if (pressed) 0.8f else 1f }
                .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onStartOver),
            style = Typography.lg.copy(color = colors.primary),
        )
    }
}
```

A checkout built from `Screen`, `Header` with a collapsing large title, `RadioGroup`, `Button`, `Icon` and `BottomBar`. Delivery and payment are chosen with radio options, finished steps collapse to a one-line summary with an Edit link, and the bottom bar button advances through the steps. The total updates when express delivery is picked, and placing the order shows a confirmation screen with a Start over link.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/bag-checkout
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add bag-checkout
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add bag-checkout --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add bag-checkout --platform kotlin
```

The CLI also adds what it builds on: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Radio Group](https://ui.suiss.app/docs/components/radio-group), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Radio Group](https://ui.suiss.app/docs/components/radio-group), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/bag-checkout.tsx`:

- `uniwind/components/blocks/bag-checkout.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Radio Group](https://ui.suiss.app/docs/components/radio-group), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/bag-checkout.tsx`:

```tsx title="nativewind/components/blocks/bag-checkout.tsx"
import { cn } from "cn"
import {
  CheckIcon,
  CreditCardIcon,
  type Icon as PhosphorIcon,
  WalletIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Image, Pressable, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import {
  Header,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

type Shipping = "standard" | "express"

type Payment = "card" | "pay"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=200&q=80&auto=format&fit=crop`

const ITEMS = [
  {
    name: "Phone Pro",
    variant: "256 GB · Cosmic Orange",
    price: 1099,
    image: photo("1714972384975-fbd4f19b1bb3"),
  },
  {
    name: "Pods Studio",
    variant: "Midnight",
    price: 549,
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Phone Pro Clear Case",
    variant: "With MagCharge",
    price: 49,
    image: photo("1703676311066-34caf2cbee33"),
  },
]

const SHIPPING: { value: Shipping; label: string; price: string }[] = [
  { value: "standard", label: "Standard · 3–5 business days", price: "Free" },
  { value: "express", label: "Express · Tomorrow", price: "$19" },
]

const PAYMENT: { value: Payment; label: string; icon: PhosphorIcon }[] = [
  { value: "card", label: "Credit card · •••• 4242", icon: CreditCardIcon },
  { value: "pay", label: "suiss Pay", icon: WalletIcon },
]

const ACTIONS = ["Continue to Payment", "Continue to Review", "Place Order"]

const THOUSANDS = /\B(?=(\d{3})+(?!\d))/g

function money(value: number, digits = 2) {
  try {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
      minimumFractionDigits: digits,
      maximumFractionDigits: digits,
    }).format(value)
  } catch {
    const [whole = "0", fraction] = value.toFixed(digits).split(".")
    return `$${whole.replace(THOUSANDS, ",")}${fraction ? `.${fraction}` : ""}`
  }
}

function Choice<Value extends string>({
  value,
  checked,
  label,
  onSelect,
  children,
}: {
  value: Value
  checked: boolean
  label: string
  onSelect: (value: Value) => void
  children: React.ReactNode
}) {
  return (
    <Pressable
      accessible={false}
      onPress={() => onSelect(value)}
      className={cn(
        "flex-row items-center gap-3 rounded-[0.875rem] active:opacity-80",
        checked
          ? "border-2 border-primary px-[0.9375rem] py-[0.8125rem]"
          : "border border-input px-4 py-3.5"
      )}
    >
      <RadioGroupItem
        value={value}
        accessibilityLabel={label}
        className="size-5"
      />
      {children}
    </Pressable>
  )
}

function Step({
  index,
  current,
  title,
  summary,
  onEdit,
  children,
}: {
  index: number
  current: number
  title: string
  summary?: string
  onEdit: () => void
  children: React.ReactNode
}) {
  const open = current === index
  const done = current > index

  return (
    <View className="border-b border-border py-[1.125rem]">
      <View className="flex-row items-center gap-2.5">
        <View
          className={cn(
            "size-[1.625rem] items-center justify-center rounded-full",
            done && "bg-green-500",
            !done && open && "bg-foreground",
            !(done || open) && "bg-secondary"
          )}
        >
          {done ? (
            <Icon
              as={CheckIcon}
              weight="bold"
              className="size-[0.8125rem] text-primary-foreground"
            />
          ) : (
            <Text
              className={cn(
                "text-sm font-semibold",
                open ? "text-background" : "text-muted-foreground"
              )}
            >
              {index + 1}
            </Text>
          )}
        </View>
        <Text
          role="heading"
          className={cn(
            "flex-1 text-2xl font-semibold",
            !open && !done && "text-muted-foreground"
          )}
        >
          {title}
        </Text>
        {done ? (
          <Pressable
            role="button"
            hitSlop={8}
            onPress={onEdit}
            accessibilityLabel={`Edit ${title.toLowerCase()}`}
            className="active:opacity-60"
          >
            <Text className="text-sm text-primary">Edit ›</Text>
          </Pressable>
        ) : null}
      </View>
      {done && summary ? (
        <Text className="ps-9 pt-1.5 text-sm text-muted-foreground">
          {summary}
        </Text>
      ) : null}
      {open ? (
        <AnimatedView
          entering={FadeIn.duration(450).reduceMotion(ReduceMotion.System)}
          className="gap-2.5 ps-9 pt-4 pb-1"
        >
          {children}
        </AnimatedView>
      ) : null}
    </View>
  )
}

function BagCheckout({
  onPlaceOrder,
  onStartOver,
}: {
  onPlaceOrder?: (order: {
    shipping: Shipping
    payment: Payment
    total: number
  }) => void
  onStartOver?: () => void
}) {
  const scroll = useHeaderScroll()
  const [step, setStep] = React.useState(0)
  const [shipping, setShipping] = React.useState<Shipping>("standard")
  const [payment, setPayment] = React.useState<Payment>("card")
  const [placed, setPlaced] = React.useState(false)
  const total =
    ITEMS.reduce((sum, item) => sum + item.price, 0) +
    (shipping === "express" ? 19 : 0)

  if (placed) {
    return (
      <Screen>
        <View className="flex-1 items-center justify-center p-6">
          <AnimatedView
            entering={FadeInDown.duration(1000).reduceMotion(
              ReduceMotion.System
            )}
            className="max-w-105 items-center gap-3.5"
          >
            <View className="size-[4.75rem] items-center justify-center rounded-full bg-green-500">
              <Icon
                as={CheckIcon}
                weight="bold"
                className="size-[2.375rem] text-primary-foreground"
              />
            </View>
            <Text role="heading" className="text-center text-3xl font-semibold">
              Thank you. Your order is in.
            </Text>
            <Text className="text-center text-lg text-muted-foreground">
              Your order number is W1049. We sent a confirmation email.
            </Text>
            <Pressable
              role="button"
              hitSlop={8}
              onPress={() => {
                onStartOver?.()
                setPlaced(false)
                setStep(0)
              }}
              className="active:opacity-60"
            >
              <Text className="text-lg text-primary">Start over ›</Text>
            </Pressable>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  const next = () => {
    if (step < 2) {
      setStep(step + 1)
      return
    }
    onPlaceOrder?.({ shipping, payment, total })
    setPlaced(true)
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <Header scroll={scroll}>
        <HeaderTitle>Checkout</HeaderTitle>
      </Header>
      <HeaderScrollView scroll={scroll}>
        <HeaderLargeTitle>Checkout</HeaderLargeTitle>
        <View className="px-5">
          <Step
            index={0}
            current={step}
            title="Delivery"
            summary={
              shipping === "express"
                ? "Express · Tomorrow"
                : "Standard · 3–5 business days"
            }
            onEdit={() => setStep(0)}
          >
            <RadioGroup
              value={shipping}
              onValueChange={setShipping}
              accessibilityLabel="Delivery"
              className="gap-2.5"
            >
              {SHIPPING.map((option) => (
                <Choice
                  key={option.value}
                  value={option.value}
                  checked={shipping === option.value}
                  label={option.label}
                  onSelect={setShipping}
                >
                  <Text className="flex-1 text-base">{option.label}</Text>
                  <Text className="text-base">{option.price}</Text>
                </Choice>
              ))}
            </RadioGroup>
          </Step>
          <Step
            index={1}
            current={step}
            title="Payment"
            summary={payment === "card" ? "Card •••• 4242" : "suiss Pay"}
            onEdit={() => setStep(1)}
          >
            <RadioGroup
              value={payment}
              onValueChange={setPayment}
              accessibilityLabel="Payment"
              className="gap-2.5"
            >
              {PAYMENT.map((option) => (
                <Choice
                  key={option.value}
                  value={option.value}
                  checked={payment === option.value}
                  label={option.label}
                  onSelect={setPayment}
                >
                  <Icon as={option.icon} className="size-[1.125rem]" />
                  <Text className="flex-1 text-base">{option.label}</Text>
                </Choice>
              ))}
            </RadioGroup>
          </Step>
          <Step
            index={2}
            current={step}
            title="Review"
            onEdit={() => setStep(2)}
          >
            <Text className="text-sm text-muted-foreground">
              By placing your order, you agree to the Sales Policy.
            </Text>
          </Step>
        </View>
        <View className="mx-4 mt-6 mb-8 gap-3.5 rounded-2xl bg-muted px-5 py-5">
          <Text role="heading" className="text-lg font-semibold">
            Order summary
          </Text>
          {ITEMS.map((item) => (
            <View key={item.name} className="flex-row items-center gap-2.5">
              <Image
                source={{ uri: item.image }}
                resizeMode="cover"
                accessibilityIgnoresInvertColors
                className="size-12 shrink-0 rounded-[0.625rem] bg-secondary"
              />
              <View className="min-w-0 flex-1">
                <Text className="text-sm font-semibold">{item.name}</Text>
                <Text className="text-xs text-muted-foreground">
                  {item.variant}
                </Text>
              </View>
              <Text className="text-sm tabular-nums">{money(item.price)}</Text>
            </View>
          ))}
          <View className="flex-row items-center border-t border-border pt-3">
            <Text className="flex-1 text-base font-semibold">Total</Text>
            <Text className="text-lg font-semibold tabular-nums">
              {money(total)}
            </Text>
          </View>
        </View>
      </HeaderScrollView>
      <BottomBar separator>
        <BottomBarContent className="flex-row items-center gap-3">
          <View accessibilityLiveRegion="polite" className="flex-1">
            <Text className="text-xs text-muted-foreground">Total</Text>
            <Text className="text-lg font-semibold tabular-nums">
              {money(total)}
            </Text>
          </View>
          <Button size="lg" onPress={next}>
            <Text>{ACTIONS[step]}</Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { BagCheckout }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Radio Group](https://ui.suiss.app/docs/components/radio-group), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/bag-checkout.tsx`:

```tsx title="stylesheet/components/blocks/bag-checkout.tsx"
import {
  CheckIcon,
  CreditCardIcon,
  type Icon as PhosphorIcon,
  WalletIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Image, Pressable, StyleSheet, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import {
  Header,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { fontSize, palette, radius, useTheme } from "@/components/ui/theme"

type Shipping = "standard" | "express"

type Payment = "card" | "pay"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=200&q=80&auto=format&fit=crop`

const ITEMS = [
  {
    name: "Phone Pro",
    variant: "256 GB · Cosmic Orange",
    price: 1099,
    image: photo("1714972384975-fbd4f19b1bb3"),
  },
  {
    name: "Pods Studio",
    variant: "Midnight",
    price: 549,
    image: photo("1599669454699-248893623440"),
  },
  {
    name: "Phone Pro Clear Case",
    variant: "With MagCharge",
    price: 49,
    image: photo("1703676311066-34caf2cbee33"),
  },
]

const SHIPPING: { value: Shipping; label: string; price: string }[] = [
  { value: "standard", label: "Standard · 3–5 business days", price: "Free" },
  { value: "express", label: "Express · Tomorrow", price: "$19" },
]

const PAYMENT: { value: Payment; label: string; icon: PhosphorIcon }[] = [
  { value: "card", label: "Credit card · •••• 4242", icon: CreditCardIcon },
  { value: "pay", label: "suiss Pay", icon: WalletIcon },
]

const ACTIONS = ["Continue to Payment", "Continue to Review", "Place Order"]

const THOUSANDS = /\B(?=(\d{3})+(?!\d))/g

function money(value: number, digits = 2) {
  try {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
      minimumFractionDigits: digits,
      maximumFractionDigits: digits,
    }).format(value)
  } catch {
    const [whole = "0", fraction] = value.toFixed(digits).split(".")
    return `$${whole.replace(THOUSANDS, ",")}${fraction ? `.${fraction}` : ""}`
  }
}

const styles = StyleSheet.create({
  choice: {
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    borderRadius: 14,
  },
  choiceIdle: { borderWidth: 1, paddingHorizontal: 16, paddingVertical: 14 },
  choiceChecked: {
    borderWidth: 2,
    paddingHorizontal: 15,
    paddingVertical: 13,
  },
  radio: { width: 20, height: 20 },
  grow: { flex: 1 },
  step: { borderBottomWidth: 1, paddingVertical: 18 },
  stepRow: { flexDirection: "row", alignItems: "center", gap: 10 },
  badge: {
    width: 26,
    height: 26,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  badgeText: { ...fontSize.sm, fontWeight: "600" },
  stepTitle: { ...fontSize["2xl"], flex: 1, fontWeight: "600" },
  summary: { ...fontSize.sm, paddingStart: 36, paddingTop: 6 },
  stepBody: { gap: 10, paddingStart: 36, paddingTop: 16, paddingBottom: 4 },
  radios: { gap: 10 },
  done: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    padding: 24,
  },
  doneContent: { maxWidth: 420, alignItems: "center", gap: 14 },
  doneBadge: {
    width: 76,
    height: 76,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  doneTitle: { ...fontSize["3xl"], fontWeight: "600", textAlign: "center" },
  doneText: { ...fontSize.lg, textAlign: "center" },
  steps: { paddingHorizontal: 20 },
  order: {
    marginHorizontal: 16,
    marginTop: 24,
    marginBottom: 32,
    gap: 14,
    borderRadius: radius["2xl"],
    paddingHorizontal: 20,
    paddingVertical: 20,
  },
  orderTitle: { ...fontSize.lg, fontWeight: "600" },
  orderRow: { flexDirection: "row", alignItems: "center", gap: 10 },
  orderImage: { width: 48, height: 48, flexShrink: 0, borderRadius: 10 },
  orderText: { minWidth: 0, flex: 1 },
  orderName: { ...fontSize.sm, fontWeight: "600" },
  orderTotal: {
    flexDirection: "row",
    alignItems: "center",
    borderTopWidth: 1,
    paddingTop: 12,
  },
  totalLabel: { ...fontSize.base, flex: 1, fontWeight: "600" },
  totalValue: {
    ...fontSize.lg,
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
  bar: { flexDirection: "row", alignItems: "center", gap: 12 },
})

function Choice<Value extends string>({
  value,
  checked,
  label,
  onSelect,
  children,
}: {
  value: Value
  checked: boolean
  label: string
  onSelect: (value: Value) => void
  children: React.ReactNode
}) {
  const { colors } = useTheme()
  return (
    <Pressable
      accessible={false}
      onPress={() => onSelect(value)}
      style={({ pressed }) => [
        styles.choice,
        checked ? styles.choiceChecked : styles.choiceIdle,
        { borderColor: checked ? colors.primary : colors.input },
        pressed && { opacity: 0.8 },
      ]}
    >
      <RadioGroupItem
        value={value}
        accessibilityLabel={label}
        style={styles.radio}
      />
      {children}
    </Pressable>
  )
}

function Step({
  index,
  current,
  title,
  summary,
  onEdit,
  children,
}: {
  index: number
  current: number
  title: string
  summary?: string
  onEdit: () => void
  children: React.ReactNode
}) {
  const { colors } = useTheme()
  const open = current === index
  const done = current > index
  let badgeColor = colors.secondary
  if (done) badgeColor = palette.green500
  else if (open) badgeColor = colors.foreground

  return (
    <View style={[styles.step, { borderColor: colors.border }]}>
      <View style={styles.stepRow}>
        <View
          style={[
            styles.badge,
            {
              backgroundColor: badgeColor,
            },
          ]}
        >
          {done ? (
            <Icon
              as={CheckIcon}
              weight="bold"
              size={13}
              color={colors.primaryForeground}
            />
          ) : (
            <Text
              style={[
                styles.badgeText,
                { color: open ? colors.background : colors.mutedForeground },
              ]}
            >
              {index + 1}
            </Text>
          )}
        </View>
        <Text
          role="heading"
          style={[
            styles.stepTitle,
            !open && !done && { color: colors.mutedForeground },
          ]}
        >
          {title}
        </Text>
        {done ? (
          <Pressable
            role="button"
            hitSlop={8}
            onPress={onEdit}
            accessibilityLabel={`Edit ${title.toLowerCase()}`}
            style={({ pressed }) => pressed && { opacity: 0.6 }}
          >
            <Text style={[fontSize.sm, { color: colors.primary }]}>Edit ›</Text>
          </Pressable>
        ) : null}
      </View>
      {done && summary ? (
        <Text style={[styles.summary, { color: colors.mutedForeground }]}>
          {summary}
        </Text>
      ) : null}
      {open ? (
        <AnimatedView
          entering={FadeIn.duration(450).reduceMotion(ReduceMotion.System)}
          style={styles.stepBody}
        >
          {children}
        </AnimatedView>
      ) : null}
    </View>
  )
}

function BagCheckout({
  onPlaceOrder,
  onStartOver,
}: {
  onPlaceOrder?: (order: {
    shipping: Shipping
    payment: Payment
    total: number
  }) => void
  onStartOver?: () => void
}) {
  const { colors } = useTheme()
  const scroll = useHeaderScroll()
  const [step, setStep] = React.useState(0)
  const [shipping, setShipping] = React.useState<Shipping>("standard")
  const [payment, setPayment] = React.useState<Payment>("card")
  const [placed, setPlaced] = React.useState(false)
  const total =
    ITEMS.reduce((sum, item) => sum + item.price, 0) +
    (shipping === "express" ? 19 : 0)

  if (placed) {
    return (
      <Screen>
        <View style={styles.done}>
          <AnimatedView
            entering={FadeInDown.duration(1000).reduceMotion(
              ReduceMotion.System
            )}
            style={styles.doneContent}
          >
            <View
              style={[styles.doneBadge, { backgroundColor: palette.green500 }]}
            >
              <Icon
                as={CheckIcon}
                weight="bold"
                size={38}
                color={colors.primaryForeground}
              />
            </View>
            <Text role="heading" style={styles.doneTitle}>
              Thank you. Your order is in.
            </Text>
            <Text style={[styles.doneText, { color: colors.mutedForeground }]}>
              Your order number is W1049. We sent a confirmation email.
            </Text>
            <Pressable
              role="button"
              hitSlop={8}
              onPress={() => {
                onStartOver?.()
                setPlaced(false)
                setStep(0)
              }}
              style={({ pressed }) => pressed && { opacity: 0.6 }}
            >
              <Text style={[fontSize.lg, { color: colors.primary }]}>
                Start over ›
              </Text>
            </Pressable>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  const next = () => {
    if (step < 2) {
      setStep(step + 1)
      return
    }
    onPlaceOrder?.({ shipping, payment, total })
    setPlaced(true)
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <Header scroll={scroll}>
        <HeaderTitle>Checkout</HeaderTitle>
      </Header>
      <HeaderScrollView scroll={scroll}>
        <HeaderLargeTitle>Checkout</HeaderLargeTitle>
        <View style={styles.steps}>
          <Step
            index={0}
            current={step}
            title="Delivery"
            summary={
              shipping === "express"
                ? "Express · Tomorrow"
                : "Standard · 3–5 business days"
            }
            onEdit={() => setStep(0)}
          >
            <RadioGroup
              value={shipping}
              onValueChange={setShipping}
              accessibilityLabel="Delivery"
              style={styles.radios}
            >
              {SHIPPING.map((option) => (
                <Choice
                  key={option.value}
                  value={option.value}
                  checked={shipping === option.value}
                  label={option.label}
                  onSelect={setShipping}
                >
                  <Text style={[fontSize.base, styles.grow]}>
                    {option.label}
                  </Text>
                  <Text style={fontSize.base}>{option.price}</Text>
                </Choice>
              ))}
            </RadioGroup>
          </Step>
          <Step
            index={1}
            current={step}
            title="Payment"
            summary={payment === "card" ? "Card •••• 4242" : "suiss Pay"}
            onEdit={() => setStep(1)}
          >
            <RadioGroup
              value={payment}
              onValueChange={setPayment}
              accessibilityLabel="Payment"
              style={styles.radios}
            >
              {PAYMENT.map((option) => (
                <Choice
                  key={option.value}
                  value={option.value}
                  checked={payment === option.value}
                  label={option.label}
                  onSelect={setPayment}
                >
                  <Icon as={option.icon} size={18} />
                  <Text style={[fontSize.base, styles.grow]}>
                    {option.label}
                  </Text>
                </Choice>
              ))}
            </RadioGroup>
          </Step>
          <Step
            index={2}
            current={step}
            title="Review"
            onEdit={() => setStep(2)}
          >
            <Text style={[fontSize.sm, { color: colors.mutedForeground }]}>
              By placing your order, you agree to the Sales Policy.
            </Text>
          </Step>
        </View>
        <View style={[styles.order, { backgroundColor: colors.muted }]}>
          <Text role="heading" style={styles.orderTitle}>
            Order summary
          </Text>
          {ITEMS.map((item) => (
            <View key={item.name} style={styles.orderRow}>
              <Image
                source={{ uri: item.image }}
                resizeMode="cover"
                accessibilityIgnoresInvertColors
                style={[
                  styles.orderImage,
                  { backgroundColor: colors.secondary },
                ]}
              />
              <View style={styles.orderText}>
                <Text style={styles.orderName}>{item.name}</Text>
                <Text style={[fontSize.xs, { color: colors.mutedForeground }]}>
                  {item.variant}
                </Text>
              </View>
              <Text style={[fontSize.sm, { fontVariant: ["tabular-nums"] }]}>
                {money(item.price)}
              </Text>
            </View>
          ))}
          <View style={[styles.orderTotal, { borderColor: colors.border }]}>
            <Text style={styles.totalLabel}>Total</Text>
            <Text style={styles.totalValue}>{money(total)}</Text>
          </View>
        </View>
      </HeaderScrollView>
      <BottomBar separator>
        <BottomBarContent style={styles.bar}>
          <View accessibilityLiveRegion="polite" style={styles.grow}>
            <Text style={[fontSize.xs, { color: colors.mutedForeground }]}>
              Total
            </Text>
            <Text style={styles.totalValue}>{money(total)}</Text>
          </View>
          <Button size="lg" onPress={next}>
            <Text>{ACTIONS[step]}</Text>
          </Button>
        </BottomBarContent>
      </BottomBar>
    </Screen>
  )
}

export { BagCheckout }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Blocks/BagCheckout.swift`: shown above

- `swift/Components/BottomBar.swift`: see [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar.md)

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Header.swift`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Label.swift`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/RadioGroup.swift`: see [Radio Group](https://ui.suiss.app/docs/components/radio-group.md)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/blocks/BagCheckout.kt`: shown above

- `kotlin/components/BottomBar.kt`: see [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar.md)

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Header.kt`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/RadioGroup.kt`: see [Radio Group](https://ui.suiss.app/docs/components/radio-group.md)

- `kotlin/components/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { BagCheckout } from "@/components/blocks/bag-checkout"
```

```tsx
<BagCheckout />
```

Replace `ITEMS`, `SHIPPING` and `PAYMENT` with your cart lines and options. Pass `onPlaceOrder` to receive the chosen shipping, payment and total, and `onStartOver` to react when the confirmation is dismissed.

