# Bag Review

A shopping bag review page with quantity steppers, removable line items, and a totals summary.

Bag Review 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-review

**React**

**Example**

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

import * as React from "react"

import {
  Stepper,
  StepperDecrement,
  StepperGroup,
  StepperIncrement,
  StepperInput,
  StepperSeparator,
} from "@/components/interactions/stepper"
import { Button } from "@/components/ui/button"

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

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

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

export function BagReview() {
  const [items, setItems] = React.useState(initialItems)
  const bag = items.filter((item) => item.quantity > 0)
  const subtotal = bag.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  )

  const setQuantity = (name: string, quantity: number) =>
    setItems((current) =>
      current.map((item) => (item.name === name ? { ...item, quantity } : item))
    )

  return (
    <section className="px-5 py-8 md:px-12">
      <div className="mx-auto flex max-w-205 flex-col">
        <h1
          aria-live="polite"
          className="text-center text-3xl font-semibold tracking-tight text-balance md:text-4xl"
        >
          {bag.length
            ? `Your bag total is ${money(subtotal)}.`
            : "Your bag is empty."}
        </h1>
        <p className="mt-2 mb-5 text-center text-base text-muted-foreground">
          Free delivery and free returns on every order.
        </p>
        {bag.length > 0 ? (
          <Button size="lg" className="mb-5 self-center">
            Check Out
          </Button>
        ) : (
          <Button
            size="lg"
            className="self-center"
            onClick={() => setItems(initialItems)}
          >
            Continue Shopping
          </Button>
        )}
        <ul>
          {bag.map((item) => (
            <li
              key={item.name}
              className="grid grid-cols-[5rem_minmax(0,1fr)] gap-x-4 gap-y-3 border-t border-border py-5.5 transition-[opacity,translate] duration-700 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none md:grid-cols-[7.5rem_minmax(0,1fr)_auto_7.5rem] md:gap-5"
            >
              <img
                src={item.image}
                alt=""
                className="size-20 rounded-xl bg-secondary object-cover md:row-span-2 md:size-30"
              />
              <div className="flex flex-col gap-1">
                <h2 className="text-xl font-semibold">{item.name}</h2>
                <p className="text-sm text-muted-foreground">{item.variant}</p>
                <a
                  href="#details"
                  className="mt-1.5 text-sm text-primary hover:underline"
                >
                  Show product details ›
                </a>
              </div>
              <Stepper
                value={item.quantity}
                onValueChange={(value) => setQuantity(item.name, value)}
                min={1}
                max={10}
                className="col-start-2 flex items-center gap-2 md:col-start-auto md:items-start"
              >
                <StepperInput
                  aria-label={`${item.name} quantity`}
                  className="w-8 text-center"
                />
                <StepperGroup>
                  <StepperDecrement />
                  <StepperSeparator />
                  <StepperIncrement />
                </StepperGroup>
              </Stepper>
              <div className="col-start-2 flex items-center justify-between gap-1.5 md:col-start-auto md:flex-col md:items-end md:justify-start">
                <span className="text-xl font-semibold tabular-nums">
                  {money(item.price * item.quantity)}
                </span>
                <Button
                  variant="link"
                  size="sm"
                  onClick={() => setQuantity(item.name, 0)}
                  aria-label={`Remove ${item.name}`}
                  className="h-auto px-0 text-sm text-primary"
                >
                  Remove
                </Button>
              </div>
            </li>
          ))}
        </ul>
        {bag.length > 0 && (
          <div className="flex flex-col gap-2.5 border-t border-border pt-5 md:ps-90">
            <div className="flex justify-between text-base">
              <span>Subtotal</span>
              <span className="tabular-nums">{money(subtotal)}</span>
            </div>
            <div className="flex justify-between text-base">
              <span>Shipping</span>
              <span>FREE</span>
            </div>
            <div className="flex justify-between border-t border-border pt-3 text-2xl font-semibold">
              <span>Total</span>
              <span className="tabular-nums">{money(subtotal)}</span>
            </div>
            <div className="mt-1 flex flex-wrap justify-end gap-2.5">
              <Button
                size="lg"
                className="bg-foreground text-background hover:bg-foreground/90"
              >
                Check Out with <span className="font-bold">suiss</span> Pay
              </Button>
              <Button size="lg">Check Out</Button>
            </div>
          </div>
        )}
      </div>
    </section>
  )
}
```

A shopping bag page that leads with the order total, then lists each item with a photo, a quantity stepper, its price and a Remove link, followed by subtotal, shipping and total. Line items use a compact two-column layout on mobile and a four-column row on desktop. Removing everything turns the headline into an empty-bag message. Reach for it for any cart or order review screen.

## Installation

### CLI

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

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-review
```

### Manual

1. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Stepper](https://ui.suiss.app/docs/interactions/stepper).

2. Copy the source into `components/blocks/bag-review.tsx`:

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

import * as React from "react"

import {
  Stepper,
  StepperDecrement,
  StepperGroup,
  StepperIncrement,
  StepperInput,
  StepperSeparator,
} from "@/components/interactions/stepper"
import { Button } from "@/components/ui/button"

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

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

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

export function BagReview() {
  const [items, setItems] = React.useState(initialItems)
  const bag = items.filter((item) => item.quantity > 0)
  const subtotal = bag.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  )

  const setQuantity = (name: string, quantity: number) =>
    setItems((current) =>
      current.map((item) => (item.name === name ? { ...item, quantity } : item))
    )

  return (
    <section className="px-5 py-8 md:px-12">
      <div className="mx-auto flex max-w-205 flex-col">
        <h1
          aria-live="polite"
          className="text-center text-3xl font-semibold tracking-tight text-balance md:text-4xl"
        >
          {bag.length
            ? `Your bag total is ${money(subtotal)}.`
            : "Your bag is empty."}
        </h1>
        <p className="mt-2 mb-5 text-center text-base text-muted-foreground">
          Free delivery and free returns on every order.
        </p>
        {bag.length > 0 ? (
          <Button size="lg" className="mb-5 self-center">
            Check Out
          </Button>
        ) : (
          <Button
            size="lg"
            className="self-center"
            onClick={() => setItems(initialItems)}
          >
            Continue Shopping
          </Button>
        )}
        <ul>
          {bag.map((item) => (
            <li
              key={item.name}
              className="grid grid-cols-[5rem_minmax(0,1fr)] gap-x-4 gap-y-3 border-t border-border py-5.5 transition-[opacity,translate] duration-700 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none md:grid-cols-[7.5rem_minmax(0,1fr)_auto_7.5rem] md:gap-5"
            >
              <img
                src={item.image}
                alt=""
                className="size-20 rounded-xl bg-secondary object-cover md:row-span-2 md:size-30"
              />
              <div className="flex flex-col gap-1">
                <h2 className="text-xl font-semibold">{item.name}</h2>
                <p className="text-sm text-muted-foreground">{item.variant}</p>
                <a
                  href="#details"
                  className="mt-1.5 text-sm text-primary hover:underline"
                >
                  Show product details ›
                </a>
              </div>
              <Stepper
                value={item.quantity}
                onValueChange={(value) => setQuantity(item.name, value)}
                min={1}
                max={10}
                className="col-start-2 flex items-center gap-2 md:col-start-auto md:items-start"
              >
                <StepperInput
                  aria-label={`${item.name} quantity`}
                  className="w-8 text-center"
                />
                <StepperGroup>
                  <StepperDecrement />
                  <StepperSeparator />
                  <StepperIncrement />
                </StepperGroup>
              </Stepper>
              <div className="col-start-2 flex items-center justify-between gap-1.5 md:col-start-auto md:flex-col md:items-end md:justify-start">
                <span className="text-xl font-semibold tabular-nums">
                  {money(item.price * item.quantity)}
                </span>
                <Button
                  variant="link"
                  size="sm"
                  onClick={() => setQuantity(item.name, 0)}
                  aria-label={`Remove ${item.name}`}
                  className="h-auto px-0 text-sm text-primary"
                >
                  Remove
                </Button>
              </div>
            </li>
          ))}
        </ul>
        {bag.length > 0 && (
          <div className="flex flex-col gap-2.5 border-t border-border pt-5 md:ps-90">
            <div className="flex justify-between text-base">
              <span>Subtotal</span>
              <span className="tabular-nums">{money(subtotal)}</span>
            </div>
            <div className="flex justify-between text-base">
              <span>Shipping</span>
              <span>FREE</span>
            </div>
            <div className="flex justify-between border-t border-border pt-3 text-2xl font-semibold">
              <span>Total</span>
              <span className="tabular-nums">{money(subtotal)}</span>
            </div>
            <div className="mt-1 flex flex-wrap justify-end gap-2.5">
              <Button
                size="lg"
                className="bg-foreground text-background hover:bg-foreground/90"
              >
                Check Out with <span className="font-bold">suiss</span> Pay
              </Button>
              <Button size="lg">Check Out</Button>
            </div>
          </div>
        )}
      </div>
    </section>
  )
}
```

3. Update the import paths to match your project.

## Usage

```tsx
import { BagReview } from "@/components/blocks/bag-review"
```

```tsx
<BagReview />
```

Replace `initialItems` with your cart lines, each with a name, variant, price, image and quantity. Persist quantity changes and removals to your cart service, and connect both Check Out buttons to your checkout. The product links point to `#details`, and the free delivery and shipping copy is placeholder text. In the empty state, Continue Shopping restores the sample items, so point it at your catalog instead.

## Notes

Quantities are limited to 1–10, and each stepper and Remove button is labeled with the product name. Product photos are Unsplash placeholders.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/bag-review.tsx"
import * as React from "react"
import { Image, Pressable, ScrollView, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import {
  Stepper,
  StepperDecrement,
  StepperGroup,
  StepperIncrement,
  StepperInput,
  StepperSeparator,
} from "@/components/interactions/stepper"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

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

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

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 BagReview({
  onCheckout,
  onShowDetails,
  onContinueShopping,
  onQuantityChange,
  onRemove,
}: {
  onCheckout?: (method: "suiss-pay" | "default") => void
  onShowDetails?: (name: string) => void
  onContinueShopping?: () => void
  onQuantityChange?: (name: string, quantity: number) => void
  onRemove?: (name: string) => void
}) {
  const [items, setItems] = React.useState(INITIAL_ITEMS)
  const bag = items.filter((item) => item.quantity > 0)
  const subtotal = bag.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  )

  const setQuantity = (name: string, quantity: number) =>
    setItems((current) =>
      current.map((item) => (item.name === name ? { ...item, quantity } : item))
    )

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerClassName="grow px-5 pt-8 pb-8">
        <Text
          role="heading"
          accessibilityLiveRegion="polite"
          className="text-center text-3xl font-semibold"
        >
          {bag.length
            ? `Your bag total is ${money(subtotal)}.`
            : "Your bag is empty."}
        </Text>
        <Text className="mt-2 mb-5 text-center text-base text-muted-foreground">
          Free delivery and free returns on every order.
        </Text>
        {bag.length === 0 ? (
          <AnimatedView
            entering={FadeIn.duration(400).reduceMotion(ReduceMotion.System)}
            className="items-center"
          >
            <Button
              size="lg"
              onPress={() => {
                onContinueShopping?.()
                setItems(INITIAL_ITEMS)
              }}
            >
              <Text>Continue Shopping</Text>
            </Button>
          </AnimatedView>
        ) : null}
        {bag.map((item) => (
          <AnimatedView
            key={item.name}
            entering={FadeInDown.duration(700).reduceMotion(
              ReduceMotion.System
            )}
            className="flex-row gap-3 border-t border-border py-4"
          >
            <Pressable
              role="link"
              accessibilityLabel={`Show details for ${item.name}`}
              onPress={() => onShowDetails?.(item.name)}
              className="active:opacity-60"
            >
              <Image
                source={{ uri: item.image }}
                resizeMode="cover"
                accessibilityIgnoresInvertColors
                className="size-16 rounded-lg bg-secondary"
              />
            </Pressable>
            <View className="min-w-0 flex-1">
              <View className="gap-0.5">
                <View className="flex-row items-start justify-between gap-3">
                  <Pressable
                    role="link"
                    accessibilityLabel={`Show details for ${item.name}`}
                    onPress={() => onShowDetails?.(item.name)}
                    className="min-w-0 shrink active:opacity-60"
                  >
                    <Text numberOfLines={1} className="text-base font-semibold">
                      {item.name}
                    </Text>
                  </Pressable>
                  <Text className="text-base font-semibold tabular-nums">
                    {money(item.price * item.quantity)}
                  </Text>
                </View>
                <Text
                  numberOfLines={1}
                  className="text-sm text-muted-foreground"
                >
                  {item.variant}
                </Text>
              </View>
              <View className="mt-2.5 flex-row items-center justify-between">
                <Stepper
                  value={item.quantity}
                  onValueChange={(value) => {
                    onQuantityChange?.(item.name, value)
                    setQuantity(item.name, value)
                  }}
                  min={1}
                  max={10}
                  className="gap-2"
                >
                  <StepperInput
                    accessibilityLabel={`${item.name} quantity`}
                    className="w-8 text-center"
                  />
                  <StepperGroup>
                    <StepperDecrement />
                    <StepperSeparator />
                    <StepperIncrement />
                  </StepperGroup>
                </Stepper>
                <Button
                  variant="link"
                  size="sm"
                  accessibilityLabel={`Remove ${item.name}`}
                  onPress={() => {
                    onRemove?.(item.name)
                    setQuantity(item.name, 0)
                  }}
                  className="h-auto px-0"
                >
                  <Text className="text-sm text-primary">Remove</Text>
                </Button>
              </View>
            </View>
          </AnimatedView>
        ))}
        {bag.length > 0 ? (
          <View className="gap-2.5 border-t border-border pt-5">
            <View className="flex-row justify-between">
              <Text className="text-base">Subtotal</Text>
              <Text className="text-base tabular-nums">{money(subtotal)}</Text>
            </View>
            <View className="flex-row justify-between">
              <Text className="text-base">Shipping</Text>
              <Text className="text-base">FREE</Text>
            </View>
            <View className="flex-row justify-between border-t border-border pt-3">
              <Text className="text-2xl font-semibold">Total</Text>
              <Text className="text-2xl font-semibold tabular-nums">
                {money(subtotal)}
              </Text>
            </View>
          </View>
        ) : null}
      </ScrollView>
      {bag.length > 0 ? (
        <BottomBar separator>
          <BottomBarContent>
            <Button
              size="lg"
              block
              onPress={() => onCheckout?.("suiss-pay")}
              className="bg-foreground active:opacity-80"
            >
              <Text className="text-background">
                Check Out with{" "}
                <Text className="font-bold text-background">suiss</Text> Pay
              </Text>
            </Button>
            <Button size="lg" block onPress={() => onCheckout?.("default")}>
              <Text>Check Out</Text>
            </Button>
          </BottomBarContent>
        </BottomBar>
      ) : null}
    </Screen>
  )
}

export { BagReview }
```

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

public struct BagReview: View {
  public enum CheckoutMethod: CaseIterable, Sendable {
    case suissPay, `default`
  }

  struct Product: Identifiable, Equatable {
    let name: String
    let variant: String
    let price: Double
    let image: URL?
    var quantity: Int

    var id: String { name }
  }

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

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

  private var onCheckout: ((CheckoutMethod) -> Void)?
  private var onShowDetails: ((String) -> Void)?
  private var onContinueShopping: (() -> Void)?
  private var onQuantityChange: ((String, Int) -> Void)?
  private var onRemove: ((String) -> Void)?
  @State private var items: [Product]
  @Environment(\.locale) private var locale
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onCheckout: ((_ method: CheckoutMethod) -> Void)? = nil,
    onShowDetails: ((_ name: String) -> Void)? = nil,
    onContinueShopping: (() -> Void)? = nil,
    onQuantityChange: ((_ name: String, _ quantity: Int) -> Void)? = nil,
    onRemove: ((_ name: String) -> Void)? = nil
  ) {
    self.init(items: Self.initialItems)
    self.onCheckout = onCheckout
    self.onShowDetails = onShowDetails
    self.onContinueShopping = onContinueShopping
    self.onQuantityChange = onQuantityChange
    self.onRemove = onRemove
  }

  init(items: [Product]) {
    _items = State(initialValue: items)
  }

  private var bag: [Product] {
    items.filter { $0.quantity > 0 }
  }

  private var subtotal: Double {
    bag.reduce(0) { $0 + $1.price * Double($1.quantity) }
  }

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

  public var body: some View {
    let bag = bag
    Screen {
      KeyboardScrollView {
        VStack(spacing: 0) {
          VStack(spacing: 8) {
            Group {
              if bag.isEmpty {
                Text("Your bag is empty.")
              } else {
                Text("Your bag total is \(money(subtotal)).")
              }
            }
            .scaledTextStyle(.xl2, weight: .semibold)
            .contentTransition(.numericText())
            .accessibilityAddTraits(.isHeader)
            Text("Free delivery and free returns on every order.")
              .scaledFont(.base)
              .foregroundStyle(EsuissColor.mutedForeground)
            if bag.isEmpty {
              Button("Continue Shopping") {
                onContinueShopping?()
                items = Self.initialItems
              }
              .buttonStyle(.variant(.default, size: .lg))
              .padding(.top, 12)
            }
          }
          .multilineTextAlignment(.center)
          .fixedSize(horizontal: false, vertical: true)
          .padding(.top, 32)
          .padding(.bottom, 20)
          ForEach(bag) { item in
            BagReviewRow(
              item: item,
              money: money,
              quantity: binding(item.name),
              onShowDetails: { onShowDetails?(item.name) },
              onRemove: {
                onRemove?(item.name)
                setQuantity(item.name, 0)
              }
            )
            .transition(.opacity.combined(with: .offset(y: 6)))
          }
          if !bag.isEmpty {
            VStack(spacing: 10) {
              BagReviewTotal(label: "Subtotal", value: Text(verbatim: money(subtotal)))
              BagReviewTotal(label: "Shipping", value: Text("FREE"))
              BagReviewTotal(label: "Total", value: Text(verbatim: money(subtotal)), large: true)
                .padding(.top, 12)
                .overlay(alignment: .top) {
                  EsuissColor.border.frame(height: 1)
                }
            }
            .padding(.top, 20)
            .overlay(alignment: .top) {
              EsuissColor.border.frame(height: 1)
            }
          }
        }
        .padding(.horizontal, 20)
        .padding(.bottom, 24)
      }
      if !bag.isEmpty {
        BottomBar {
          BottomBarContent {
            Button {
              onCheckout?(.suissPay)
            } label: {
              Text("Check Out with \(Text(verbatim: "suiss").bold()) Pay")
            }
            .buttonStyle(BagReviewPayStyle())
            Button("Check Out") {
              onCheckout?(.default)
            }
            .buttonStyle(.variant(.default, size: .lg, block: true))
          }
        }
        .transition(.move(edge: .bottom))
      }
    }
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.5), value: items)
  }

  private func binding(_ name: String) -> Binding<Int> {
    Binding(
      get: { items.first { $0.name == name }?.quantity ?? 0 },
      set: { value in
        onQuantityChange?(name, value)
        setQuantity(name, value)
      }
    )
  }

  private func setQuantity(_ name: String, _ quantity: Int) {
    guard let index = items.firstIndex(where: { $0.name == name }) else { return }
    items[index].quantity = quantity
  }
}

private struct BagReviewRow: View {
  let item: BagReview.Product
  let money: (Double) -> String
  @Binding var quantity: Int
  let onShowDetails: () -> Void
  let onRemove: () -> Void

  var body: some View {
    HStack(alignment: .top, spacing: 12) {
      Button(action: onShowDetails) {
        EsuissColor.secondary
          .overlay {
            AsyncImage(url: item.image) { phase in
              if let image = phase.image {
                image.resizable().scaledToFill()
              }
            }
          }
          .frame(width: 64, height: 64)
          .clipShape(RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
      }
      .buttonStyle(BagReviewPressStyle())
      .accessibilityLabel(Text("Show details for \(item.name)"))
      .accessibilityAddTraits(.isLink)
      VStack(alignment: .leading, spacing: 0) {
        VStack(alignment: .leading, spacing: 2) {
          HStack(alignment: .firstTextBaseline, spacing: 12) {
            Button(action: onShowDetails) {
              Text(verbatim: item.name)
                .scaledFont(.base, weight: .semibold)
                .lineLimit(1)
                .truncationMode(.tail)
            }
            .buttonStyle(BagReviewPressStyle())
            .accessibilityLabel(Text("Show details for \(item.name)"))
            .accessibilityAddTraits(.isLink)
            .frame(maxWidth: .infinity, alignment: .leading)
            Text(verbatim: money(item.price * Double(item.quantity)))
              .scaledFont(.base, weight: .semibold)
              .monospacedDigit()
              .contentTransition(.numericText())
              .fixedSize()
          }
          Text(verbatim: item.variant)
            .scaledFont(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
            .lineLimit(1)
        }
        HStack {
          ValueStepper(value: $quantity, in: 1...10) {
            StepperInput()
              .accessibilityLabel(Text("\(item.name) quantity"))
            StepperGroup {
              StepperDecrement()
              StepperSeparator()
              StepperIncrement()
            }
          }
          .frame(maxWidth: .infinity, alignment: .leading)
          Button("Remove", action: onRemove)
            .buttonStyle(BagReviewLinkStyle())
            .accessibilityLabel(Text("Remove \(item.name)"))
        }
        .padding(.top, 10)
      }
    }
    .padding(.vertical, 16)
    .overlay(alignment: .top) {
      EsuissColor.border.frame(height: 1)
    }
  }
}

private struct BagReviewTotal: View {
  let label: LocalizedStringKey
  let value: Text
  var large = false

  var body: some View {
    HStack {
      Text(label)
        .frame(maxWidth: .infinity, alignment: .leading)
      value.monospacedDigit()
    }
    .scaledFont(large ? .xl2 : .base, weight: large ? .semibold : .regular)
    .accessibilityElement(children: .combine)
  }
}

private struct BagReviewLinkStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .scaledFont(.sm)
      .foregroundStyle(EsuissColor.primary)
      .opacity(configuration.isPressed ? 0.8 : 1)
      .frame(minHeight: 32)
      .contentShape(Rectangle())
  }
}

private struct BagReviewPressStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
      .contentShape(Rectangle())
  }
}

private struct BagReviewPayStyle: ButtonStyle {
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .scaledFont(.base, weight: .medium)
      .foregroundStyle(EsuissColor.background)
      .frame(maxWidth: .infinity, minHeight: 44)
      .background(EsuissColor.foreground.opacity(configuration.isPressed ? 0.8 : 1), in: RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
      .scaleEffect(configuration.isPressed && !reduceMotion ? 0.98 : 1)
      .contentShape(Rectangle())
  }
}
```

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

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
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.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
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.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Light
import com.adamglin.phosphoricons.light.DeviceMobile
import com.adamglin.phosphoricons.light.DeviceMobileCamera
import com.adamglin.phosphoricons.light.Headphones
import com.esuiss.ui.components.BottomBar
import com.esuiss.ui.components.BottomBarContent
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Screen
import com.esuiss.ui.interactions.Stepper
import com.esuiss.ui.interactions.StepperDecrement
import com.esuiss.ui.interactions.StepperGroup
import com.esuiss.ui.interactions.StepperIncrement
import com.esuiss.ui.interactions.StepperInput
import com.esuiss.ui.interactions.StepperSeparator
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale

private class ReviewItem(val name: String, val variant: String, val price: Double, val icon: ImageVector, val quantity: Int)

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

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

@Composable
fun BagReview(
    modifier: Modifier = Modifier,
    onCheckout: (method: String) -> Unit = {},
    onShowDetails: (item: String) -> Unit = {},
    onContinueShopping: () -> Unit = {},
    onQuantityChange: (item: String, quantity: Int) -> Unit = { _, _ -> },
    onRemove: (item: String) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    val items = remember { mutableStateListOf<ReviewItem>().apply { addAll(ReviewItems) } }
    val bag = items.filter { it.quantity > 0 }
    val subtotal = bag.sumOf { it.price * it.quantity }

    fun setQuantity(name: String, quantity: Int) {
        val index = items.indexOfFirst { it.name == name }
        if (index < 0) return
        val item = items[index]
        if (item.quantity == quantity) return
        if (quantity == 0) onRemove(name) else onQuantityChange(name, quantity)
        items[index] = ReviewItem(item.name, item.variant, item.price, item.icon, quantity)
    }

    Screen(modifier) {
        Column(Modifier.fillMaxSize()) {
            Column(
                Modifier
                    .weight(1f)
                    .verticalScroll(rememberScrollState())
                    .padding(horizontal = 20.dp, vertical = 32.dp),
            ) {
                BasicText(
                    if (bag.isNotEmpty()) "Your bag total is ${reviewMoney(subtotal)}." else "Your bag is empty.",
                    modifier = Modifier
                        .fillMaxWidth()
                        .semantics {
                            heading()
                            liveRegion = LiveRegionMode.Polite
                        },
                    style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
                )
                BasicText(
                    "Free delivery and free returns on every order.",
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(top = 8.dp, bottom = 20.dp),
                    style = Typography.base.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                )
                if (bag.isEmpty()) {
                    Button(
                        "Continue Shopping",
                        {
                            onContinueShopping()
                            items.clear()
                            items.addAll(ReviewItems)
                        },
                        modifier = Modifier.align(Alignment.CenterHorizontally),
                        size = ButtonSize.Lg,
                    )
                }
                bag.forEach { item ->
                    key(item.name) {
                        ReviewRow(
                            item = item,
                            onQuantity = { setQuantity(item.name, it) },
                            onShowDetails = { onShowDetails(item.name) },
                        )
                    }
                }
                if (bag.isNotEmpty()) {
                    Box(
                        Modifier
                            .fillMaxWidth()
                            .height(1.dp)
                            .background(colors.border),
                    )
                    Column(
                        Modifier.padding(top = 20.dp),
                        verticalArrangement = Arrangement.spacedBy(10.dp),
                    ) {
                        ReviewTotalRow("Subtotal", reviewMoney(subtotal))
                        ReviewTotalRow("Shipping", "FREE")
                        Box(
                            Modifier
                                .fillMaxWidth()
                                .height(1.dp)
                                .background(colors.border),
                        )
                        Row(Modifier.padding(top = 2.dp)) {
                            BasicText(
                                "Total",
                                modifier = Modifier.weight(1f),
                                style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                            )
                            BasicText(
                                reviewMoney(subtotal),
                                style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                            )
                        }
                    }
                }
            }
            if (bag.isNotEmpty()) {
                BottomBar(separator = true) {
                    BottomBarContent {
                        ReviewPayButton { onCheckout("SuissPay") }
                        Button("Check Out", { onCheckout("Default") }, size = ButtonSize.Lg, block = true)
                    }
                }
            }
        }
    }
}

@Composable
private fun ReviewRow(item: ReviewItem, onQuantity: (Int) -> Unit, onShowDetails: () -> Unit) {
    val colors = EsuissTheme.colors
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(700)) }
    Column(
        Modifier.graphicsLayer {
            alpha = appear.value
            translationY = (1f - appear.value) * 6.dp.toPx()
        },
    ) {
        Box(
            Modifier
                .fillMaxWidth()
                .height(1.dp)
                .background(colors.border),
        )
        Row(
            modifier = Modifier.padding(vertical = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Box(
                Modifier
                    .size(64.dp)
                    .reviewDetailsLink(item.name, onShowDetails)
                    .background(colors.secondary, RoundedCornerShape(Radius.lg)),
                contentAlignment = Alignment.Center,
            ) {
                Icon(item.icon, contentDescription = null, size = 36.dp, tint = colors.mutedForeground)
            }
            Column(Modifier.weight(1f)) {
                Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
                    Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                        Box(Modifier.weight(1f)) {
                            BasicText(
                                item.name,
                                modifier = Modifier.reviewDetailsLink(item.name, onShowDetails),
                                style = Typography.base.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                                maxLines = 1,
                                overflow = TextOverflow.Ellipsis,
                            )
                        }
                        BasicText(
                            reviewMoney(item.price * item.quantity),
                            style = Typography.base.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                        )
                    }
                    BasicText(
                        item.variant,
                        style = Typography.sm.copy(color = colors.mutedForeground),
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                    )
                }
                Row(
                    Modifier
                        .fillMaxWidth()
                        .padding(top = 10.dp),
                    horizontalArrangement = Arrangement.SpaceBetween,
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Stepper(
                        value = item.quantity.toDouble(),
                        onValueChange = { onQuantity(it.toInt()) },
                        min = 1.0,
                        max = 10.0,
                    ) {
                        StepperInput(contentDescription = "${item.name} quantity")
                        StepperGroup {
                            StepperDecrement()
                            StepperSeparator()
                            StepperIncrement()
                        }
                    }
                    ReviewLink("Remove", Modifier.semantics { contentDescription = "Remove ${item.name}" }) { onQuantity(0) }
                }
            }
        }
    }
}

@Composable
private fun Modifier.reviewDetailsLink(name: String, onClick: () -> Unit): Modifier {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    return this
        .graphicsLayer { alpha = if (pressed) 0.8f else 1f }
        .clickable(interactionSource = interaction, indication = null, onClick = onClick)
        .clearAndSetSemantics {
            role = Role.Button
            contentDescription = "Show details for $name"
        }
}

@Composable
private fun ReviewTotalRow(label: String, value: String) {
    val colors = EsuissTheme.colors
    Row {
        BasicText(label, modifier = Modifier.weight(1f), style = Typography.base.copy(color = colors.foreground))
        BasicText(value, style = Typography.base.copy(color = colors.foreground, fontFeatureSettings = "tnum"))
    }
}

@Composable
private fun ReviewLink(text: String, modifier: Modifier = Modifier, 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)
            .padding(vertical = 4.dp),
        style = Typography.sm.copy(color = EsuissTheme.colors.primary),
    )
}

@Composable
private fun ReviewPayButton(onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val shape = RoundedCornerShape(Radius.lg)
    Box(
        Modifier
            .fillMaxWidth()
            .heightIn(min = ButtonSize.Lg.height)
            .graphicsLayer {
                val scale = if (pressed) 0.98f else 1f
                scaleX = scale
                scaleY = scale
            }
            .alpha(if (pressed) 0.8f else 1f)
            .background(colors.foreground, shape)
            .clickable(interactionSource = interaction, indication = null, onClick = onClick)
            .semantics(mergeDescendants = true) { role = Role.Button },
        contentAlignment = Alignment.Center,
    ) {
        BasicText(
            buildAnnotatedString {
                append("Check Out with ")
                withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("suiss") }
                append(" Pay")
            },
            style = Typography.base.copy(color = colors.background, fontWeight = FontWeight.Medium),
        )
    }
}
```

Composes `Screen`, `Stepper` from the interactions, `Button`, `Text` and `BottomBar`. Each compact row shows a thumbnail, the product name with its line price, the variant, a quantity stepper and a Remove link; tapping the thumbnail or the name opens the product details. The heading and totals update as the bag changes, and removing every item shows an empty state with a Continue Shopping button.

## Installation

**React Native**

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

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-review
```

**SwiftUI**

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

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add bag-review --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), [Screen](https://ui.suiss.app/docs/components/screen), [Stepper](https://ui.suiss.app/docs/interactions/stepper), [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 react-native-reanimated 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), [Screen](https://ui.suiss.app/docs/components/screen), [Stepper](https://ui.suiss.app/docs/interactions/stepper), [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-review.tsx`:

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

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated 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), [Screen](https://ui.suiss.app/docs/components/screen), [Stepper](https://ui.suiss.app/docs/interactions/stepper), [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-review.tsx`:

```tsx title="nativewind/components/blocks/bag-review.tsx"
import * as React from "react"
import { Image, Pressable, ScrollView, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import {
  Stepper,
  StepperDecrement,
  StepperGroup,
  StepperIncrement,
  StepperInput,
  StepperSeparator,
} from "@/components/interactions/stepper"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

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

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

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 BagReview({
  onCheckout,
  onShowDetails,
  onContinueShopping,
  onQuantityChange,
  onRemove,
}: {
  onCheckout?: (method: "suiss-pay" | "default") => void
  onShowDetails?: (name: string) => void
  onContinueShopping?: () => void
  onQuantityChange?: (name: string, quantity: number) => void
  onRemove?: (name: string) => void
}) {
  const [items, setItems] = React.useState(INITIAL_ITEMS)
  const bag = items.filter((item) => item.quantity > 0)
  const subtotal = bag.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  )

  const setQuantity = (name: string, quantity: number) =>
    setItems((current) =>
      current.map((item) => (item.name === name ? { ...item, quantity } : item))
    )

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerClassName="grow px-5 pt-8 pb-8">
        <Text
          role="heading"
          accessibilityLiveRegion="polite"
          className="text-center text-3xl font-semibold"
        >
          {bag.length
            ? `Your bag total is ${money(subtotal)}.`
            : "Your bag is empty."}
        </Text>
        <Text className="mt-2 mb-5 text-center text-base text-muted-foreground">
          Free delivery and free returns on every order.
        </Text>
        {bag.length === 0 ? (
          <AnimatedView
            entering={FadeIn.duration(400).reduceMotion(ReduceMotion.System)}
            className="items-center"
          >
            <Button
              size="lg"
              onPress={() => {
                onContinueShopping?.()
                setItems(INITIAL_ITEMS)
              }}
            >
              <Text>Continue Shopping</Text>
            </Button>
          </AnimatedView>
        ) : null}
        {bag.map((item) => (
          <AnimatedView
            key={item.name}
            entering={FadeInDown.duration(700).reduceMotion(
              ReduceMotion.System
            )}
            className="flex-row gap-3 border-t border-border py-4"
          >
            <Pressable
              role="link"
              accessibilityLabel={`Show details for ${item.name}`}
              onPress={() => onShowDetails?.(item.name)}
              className="active:opacity-60"
            >
              <Image
                source={{ uri: item.image }}
                resizeMode="cover"
                accessibilityIgnoresInvertColors
                className="size-16 rounded-lg bg-secondary"
              />
            </Pressable>
            <View className="min-w-0 flex-1">
              <View className="gap-0.5">
                <View className="flex-row items-start justify-between gap-3">
                  <Pressable
                    role="link"
                    accessibilityLabel={`Show details for ${item.name}`}
                    onPress={() => onShowDetails?.(item.name)}
                    className="min-w-0 shrink active:opacity-60"
                  >
                    <Text numberOfLines={1} className="text-base font-semibold">
                      {item.name}
                    </Text>
                  </Pressable>
                  <Text className="text-base font-semibold tabular-nums">
                    {money(item.price * item.quantity)}
                  </Text>
                </View>
                <Text
                  numberOfLines={1}
                  className="text-sm text-muted-foreground"
                >
                  {item.variant}
                </Text>
              </View>
              <View className="mt-2.5 flex-row items-center justify-between">
                <Stepper
                  value={item.quantity}
                  onValueChange={(value) => {
                    onQuantityChange?.(item.name, value)
                    setQuantity(item.name, value)
                  }}
                  min={1}
                  max={10}
                  className="gap-2"
                >
                  <StepperInput
                    accessibilityLabel={`${item.name} quantity`}
                    className="w-8 text-center"
                  />
                  <StepperGroup>
                    <StepperDecrement />
                    <StepperSeparator />
                    <StepperIncrement />
                  </StepperGroup>
                </Stepper>
                <Button
                  variant="link"
                  size="sm"
                  accessibilityLabel={`Remove ${item.name}`}
                  onPress={() => {
                    onRemove?.(item.name)
                    setQuantity(item.name, 0)
                  }}
                  className="h-auto px-0"
                >
                  <Text className="text-sm text-primary">Remove</Text>
                </Button>
              </View>
            </View>
          </AnimatedView>
        ))}
        {bag.length > 0 ? (
          <View className="gap-2.5 border-t border-border pt-5">
            <View className="flex-row justify-between">
              <Text className="text-base">Subtotal</Text>
              <Text className="text-base tabular-nums">{money(subtotal)}</Text>
            </View>
            <View className="flex-row justify-between">
              <Text className="text-base">Shipping</Text>
              <Text className="text-base">FREE</Text>
            </View>
            <View className="flex-row justify-between border-t border-border pt-3">
              <Text className="text-2xl font-semibold">Total</Text>
              <Text className="text-2xl font-semibold tabular-nums">
                {money(subtotal)}
              </Text>
            </View>
          </View>
        ) : null}
      </ScrollView>
      {bag.length > 0 ? (
        <BottomBar separator>
          <BottomBarContent>
            <Button
              size="lg"
              block
              onPress={() => onCheckout?.("suiss-pay")}
              className="bg-foreground active:opacity-80"
            >
              <Text className="text-background">
                Check Out with{" "}
                <Text className="font-bold text-background">suiss</Text> Pay
              </Text>
            </Button>
            <Button size="lg" block onPress={() => onCheckout?.("default")}>
              <Text>Check Out</Text>
            </Button>
          </BottomBarContent>
        </BottomBar>
      ) : null}
    </Screen>
  )
}

export { BagReview }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated 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), [Screen](https://ui.suiss.app/docs/components/screen), [Stepper](https://ui.suiss.app/docs/interactions/stepper), [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-review.tsx`:

```tsx title="stylesheet/components/blocks/bag-review.tsx"
import * as React from "react"
import { Image, Pressable, ScrollView, StyleSheet, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import {
  Stepper,
  StepperDecrement,
  StepperGroup,
  StepperIncrement,
  StepperInput,
  StepperSeparator,
} from "@/components/interactions/stepper"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Button } from "@/components/ui/button"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

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

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

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 withAlpha(color: string, alpha: number) {
  if (!color.startsWith("#") || color.length !== 7) return color
  const r = Number.parseInt(color.slice(1, 3), 16)
  const g = Number.parseInt(color.slice(3, 5), 16)
  const b = Number.parseInt(color.slice(5, 7), 16)
  return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

const styles = StyleSheet.create({
  content: {
    flexGrow: 1,
    paddingHorizontal: 20,
    paddingTop: 32,
    paddingBottom: 32,
  },
  title: { ...fontSize["3xl"], fontWeight: "600", textAlign: "center" },
  subtitle: {
    ...fontSize.base,
    marginTop: 8,
    marginBottom: 20,
    textAlign: "center",
  },
  center: { alignItems: "center" },
  row: {
    flexDirection: "row",
    gap: 12,
    borderTopWidth: 1,
    paddingVertical: 16,
  },
  image: { width: 64, height: 64, borderRadius: radius.lg },
  details: { minWidth: 0, flex: 1 },
  info: { gap: 2 },
  nameRow: {
    flexDirection: "row",
    alignItems: "flex-start",
    justifyContent: "space-between",
    gap: 12,
  },
  nameLink: { minWidth: 0, flexShrink: 1 },
  name: { ...fontSize.base, fontWeight: "600" },
  linePrice: {
    ...fontSize.base,
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
  controls: {
    marginTop: 10,
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
  },
  stepper: { gap: 8 },
  quantity: { width: 32, textAlign: "center" },
  remove: { height: "auto", paddingHorizontal: 0 },
  summary: { gap: 10, borderTopWidth: 1, paddingTop: 20 },
  line: { flexDirection: "row", justifyContent: "space-between" },
  totalLine: {
    flexDirection: "row",
    justifyContent: "space-between",
    borderTopWidth: 1,
    paddingTop: 12,
  },
  total: { ...fontSize["2xl"], fontWeight: "600" },
  tabular: { fontVariant: ["tabular-nums"] },
})

function BagReview({
  onCheckout,
  onShowDetails,
  onContinueShopping,
  onQuantityChange,
  onRemove,
}: {
  onCheckout?: (method: "suiss-pay" | "default") => void
  onShowDetails?: (name: string) => void
  onContinueShopping?: () => void
  onQuantityChange?: (name: string, quantity: number) => void
  onRemove?: (name: string) => void
}) {
  const { colors } = useTheme()
  const [items, setItems] = React.useState(INITIAL_ITEMS)
  const bag = items.filter((item) => item.quantity > 0)
  const subtotal = bag.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  )

  const setQuantity = (name: string, quantity: number) =>
    setItems((current) =>
      current.map((item) => (item.name === name ? { ...item, quantity } : item))
    )

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerStyle={styles.content}>
        <Text
          role="heading"
          accessibilityLiveRegion="polite"
          style={styles.title}
        >
          {bag.length
            ? `Your bag total is ${money(subtotal)}.`
            : "Your bag is empty."}
        </Text>
        <Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
          Free delivery and free returns on every order.
        </Text>
        {bag.length === 0 ? (
          <AnimatedView
            entering={FadeIn.duration(400).reduceMotion(ReduceMotion.System)}
            style={styles.center}
          >
            <Button
              size="lg"
              onPress={() => {
                onContinueShopping?.()
                setItems(INITIAL_ITEMS)
              }}
            >
              <Text>Continue Shopping</Text>
            </Button>
          </AnimatedView>
        ) : null}
        {bag.map((item) => (
          <AnimatedView
            key={item.name}
            entering={FadeInDown.duration(700).reduceMotion(
              ReduceMotion.System
            )}
            style={[styles.row, { borderColor: colors.border }]}
          >
            <Pressable
              role="link"
              accessibilityLabel={`Show details for ${item.name}`}
              onPress={() => onShowDetails?.(item.name)}
              style={({ pressed }) => pressed && { opacity: 0.6 }}
            >
              <Image
                source={{ uri: item.image }}
                resizeMode="cover"
                accessibilityIgnoresInvertColors
                style={[styles.image, { backgroundColor: colors.secondary }]}
              />
            </Pressable>
            <View style={styles.details}>
              <View style={styles.info}>
                <View style={styles.nameRow}>
                  <Pressable
                    role="link"
                    accessibilityLabel={`Show details for ${item.name}`}
                    onPress={() => onShowDetails?.(item.name)}
                    style={({ pressed }) => [
                      styles.nameLink,
                      pressed && { opacity: 0.6 },
                    ]}
                  >
                    <Text numberOfLines={1} style={styles.name}>
                      {item.name}
                    </Text>
                  </Pressable>
                  <Text style={styles.linePrice}>
                    {money(item.price * item.quantity)}
                  </Text>
                </View>
                <Text
                  numberOfLines={1}
                  style={[fontSize.sm, { color: colors.mutedForeground }]}
                >
                  {item.variant}
                </Text>
              </View>
              <View style={styles.controls}>
                <Stepper
                  value={item.quantity}
                  onValueChange={(value) => {
                    onQuantityChange?.(item.name, value)
                    setQuantity(item.name, value)
                  }}
                  min={1}
                  max={10}
                  style={styles.stepper}
                >
                  <StepperInput
                    accessibilityLabel={`${item.name} quantity`}
                    style={styles.quantity}
                  />
                  <StepperGroup>
                    <StepperDecrement />
                    <StepperSeparator />
                    <StepperIncrement />
                  </StepperGroup>
                </Stepper>
                <Button
                  variant="link"
                  size="sm"
                  accessibilityLabel={`Remove ${item.name}`}
                  onPress={() => {
                    onRemove?.(item.name)
                    setQuantity(item.name, 0)
                  }}
                  style={styles.remove}
                >
                  <Text style={[fontSize.sm, { color: colors.primary }]}>
                    Remove
                  </Text>
                </Button>
              </View>
            </View>
          </AnimatedView>
        ))}
        {bag.length > 0 ? (
          <View style={[styles.summary, { borderColor: colors.border }]}>
            <View style={styles.line}>
              <Text style={fontSize.base}>Subtotal</Text>
              <Text style={[fontSize.base, styles.tabular]}>
                {money(subtotal)}
              </Text>
            </View>
            <View style={styles.line}>
              <Text style={fontSize.base}>Shipping</Text>
              <Text style={fontSize.base}>FREE</Text>
            </View>
            <View style={[styles.totalLine, { borderColor: colors.border }]}>
              <Text style={styles.total}>Total</Text>
              <Text style={[styles.total, styles.tabular]}>
                {money(subtotal)}
              </Text>
            </View>
          </View>
        ) : null}
      </ScrollView>
      {bag.length > 0 ? (
        <BottomBar separator>
          <BottomBarContent>
            <Button
              size="lg"
              block
              onPress={() => onCheckout?.("suiss-pay")}
              style={({ pressed }) => ({
                backgroundColor: pressed
                  ? withAlpha(colors.foreground, 0.8)
                  : colors.foreground,
              })}
            >
              <Text style={{ color: colors.background }}>
                Check Out with{" "}
                <Text style={{ color: colors.background, fontWeight: "700" }}>
                  suiss
                </Text>{" "}
                Pay
              </Text>
            </Button>
            <Button size="lg" block onPress={() => onCheckout?.("default")}>
              <Text>Check Out</Text>
            </Button>
          </BottomBarContent>
        </BottomBar>
      ) : null}
    </Screen>
  )
}

export { BagReview }
```

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/BagReview.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/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/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.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)

- `swift/Interactions/Stepper.swift`: see [Stepper](https://ui.suiss.app/docs/interactions/stepper.md)

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/BagReview.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/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/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/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

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

- `kotlin/interactions/Stepper.kt`: see [Stepper](https://ui.suiss.app/docs/interactions/stepper.md)

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

## Usage

```tsx
import { BagReview } from "@/components/blocks/bag-review"
```

```tsx
<BagReview />
```

Replace `INITIAL_ITEMS` with your cart lines. Wire `onCheckout`, `onShowDetails`, `onContinueShopping`, `onQuantityChange` and `onRemove` to your cart logic.

