# Enable ecommerce
Adds a cart to the navbar and a checkout, on the project's payments provider: a browser cart that checks out through Stripe Checkout, a Shopify Storefront cart that hands off to Shopify's checkout, or a browser cart whose checkout opens a WhatsApp chat with the business and records the order to a hosted VelaStack form.
Tags: sveltekit, ecommerce, cart, checkout, stripe, shopify, whatsapp, velastack

$ vela enable ecommerce

## src/lib/cart.svelte.ts

```ts
import { browser } from "$app/env";
import { currency, money } from "#lib/currency.svelte.js";

export interface CartItem {
  /** The Stripe price (`price_...`). */
  price: string;
  name: string;
  /** Stripe's `unit_amount`: the price in the currency's smallest unit. */
  unitAmount: number;
  currency: string;
  /**
   * Fixed amounts in other currencies, from the price's `currency_options` in
   * the dashboard, by upper-cased code: `{ EUR: 1800 }`. A visitor reading
   * prices in one of them sees and pays that amount, not an estimate.
   */
  options?: Record<string, number>;
  quantity: number;
}

const STORAGE_KEY = "cart";

/**
 * The cart is a list of Stripe prices kept in the browser. Stripe sees it only
 * at checkout, where it prices the session itself, so what is stored here is
 * for display and nothing in it can change what is charged.
 */
class Cart {
  items = $state<CartItem[]>([]);
  /** Whether the cart sheet is open. */
  open = $state(false);
  count = $derived(
    this.items.reduce((total, item) => total + item.quantity, 0),
  );
  subtotal = $derived(
    this.items.reduce(
      (total, item) => total + item.unitAmount * item.quantity,
      0,
    ),
  );
  /** The cart's currency: Stripe refuses a mixed cart, so the first item's. `null` while empty. */
  currency = $derived<string | null>(this.items[0]?.currency ?? null);
  /** Fixed subtotals in the currencies every item has a dashboard amount in. */
  options = $derived.by<Record<string, number>>(() => {
    const [first, ...rest] = this.items;
    if (!first?.options) return {};
    const totals: Record<string, number> = {};
    for (const code of Object.keys(first.options)) {
      if (rest.every((item) => item.options?.[code] !== undefined)) {
        totals[code] = this.items.reduce(
          (total, item) => total + item.options![code] * item.quantity,
          0,
        );
      }
    }
    return totals;
  });

  #loaded = false;

  /** Reads the saved cart, once, in the browser. */
  load() {
    if (!browser || this.#loaded) return;
    this.#loaded = true;
    try {
      const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
      if (Array.isArray(saved)) this.items = saved;
    } catch {
      // Unreadable or blocked storage starts an empty cart.
    }
  }

  add(item: Omit<CartItem, "quantity">, quantity = 1) {
    this.load();
    const line = this.items.find((line) => line.price === item.price);
    if (line) line.quantity += quantity;
    else this.items.push({ ...item, quantity });
    this.#save();
  }

  /** A quantity of 0 removes the item. */
  setQuantity(price: string, quantity: number) {
    if (quantity <= 0) return this.remove(price);
    const line = this.items.find((line) => line.price === price);
    if (!line) return;
    line.quantity = quantity;
    this.#save();
  }

  remove(price: string) {
    this.items = this.items.filter((line) => line.price !== price);
    this.#save();
  }

  clear() {
    this.items = [];
    this.#save();
  }

  #save() {
    try {
      localStorage.setItem(
        STORAGE_KEY,
        JSON.stringify($state.snapshot(this.items)),
      );
    } catch {
      // Storage can be full or blocked; the cart still works for this visit.
    }
  }
}

export const cart = new Cart();

/** Stripe amounts are in the currency's smallest unit; Intl knows how many digits that is (none for JPY). */
function major(amount: number, code: string): number {
  const digits =
    new Intl.NumberFormat("en", {
      style: "currency",
      currency: code,
    }).resolvedOptions().maximumFractionDigits ?? 2;
  return amount / 10 ** digits;
}

/**
 * Formats a Stripe amount the way the visitor reads prices: the fixed amount
 * in the currency they picked when the dashboard set one (`options`), else
 * the price's own, converted and marked as an estimate when they picked another.
 */
export function formatAmount(
  amount: number,
  code: string,
  options?: Record<string, number>,
): string {
  const fixed = options?.[currency.current];
  if (fixed !== undefined && currency.current !== code) {
    return money(major(fixed, currency.current), currency.current);
  }
  return currency.format(major(amount, code), code);
}

/**
 * A synced price's `currency_options` as the cart keeps them: upper-cased
 * code to `unit_amount`, leaving out options priced another way (tiers, a
 * customer-chosen amount).
 */
export function priceOptions(raw: unknown): Record<string, number> | undefined {
  if (!raw || typeof raw !== "object") return undefined;
  const options: Record<string, number> = {};
  for (const [code, option] of Object.entries(
    raw as Record<string, { unit_amount?: unknown }>,
  )) {
    if (typeof option?.unit_amount === "number")
      options[code.toUpperCase()] = option.unit_amount;
  }
  return Object.keys(options).length > 0 ? options : undefined;
}
```

## src/lib/components/cart/add-to-cart.svelte

```svelte
<script lang="ts">
  import { Button } from "#lib/components/ui/button/index.js";
  import type { ButtonProps } from "#lib/components/ui/button/index.js";
  import { cart, type CartItem } from "#lib/cart.svelte.js";

  interface Props {
    /** The Stripe price, as the page selling it loaded it. */
    price: Omit<CartItem, "quantity">;
    quantity?: number;
    label?: string;
  }

  let {
    price,
    quantity = 1,
    label = "Add to cart",
    ...rest
  }: Props & ButtonProps = $props();

  let added = $state(false);

  function add() {
    cart.add(price, quantity);
    added = true;
    cart.open = true;
    setTimeout(() => (added = false), 1500);
  }
</script>

<Button onclick={add} {...rest}>
  {added ? "Added" : label}
</Button>
```

## src/lib/components/cart/cart-button.svelte

```svelte
<script lang="ts">
  import { onMount } from "svelte";
  import ShoppingCartIcon from "@lucide/svelte/icons/shopping-cart";
  import { Badge } from "#lib/components/ui/badge/index.js";
  import { Button } from "#lib/components/ui/button/index.js";
  import { cart } from "#lib/cart.svelte.js";
  import CartSheet from "./cart-sheet.svelte";

  // The cart lives in the browser, so the server renders it empty and it
  // fills in here.
  onMount(() => cart.load());
</script>

<Button
  variant="ghost"
  size="icon"
  class="relative"
  aria-label="Cart, {cart.count} items"
  onclick={() => (cart.open = true)}
>
  <ShoppingCartIcon />
  {#if cart.count > 0}
    <Badge
      class="absolute -top-1 -right-1 h-5 min-w-5 rounded-full px-1 tabular-nums"
    >
      {cart.count}
    </Badge>
  {/if}
</Button>

<CartSheet />
```

## src/lib/components/cart/cart-items.svelte

```svelte
<script lang="ts">
  import MinusIcon from "@lucide/svelte/icons/minus";
  import PlusIcon from "@lucide/svelte/icons/plus";
  import XIcon from "@lucide/svelte/icons/x";
  import { Button } from "#lib/components/ui/button/index.js";
  import { Separator } from "#lib/components/ui/separator/index.js";
  import { cart, formatAmount } from "#lib/cart.svelte.js";

  let { class: className = "" }: { class?: string } = $props();

  // What the checkout action reads: the prices and quantities, nothing the
  // browser could misstate.
  const items = $derived(
    JSON.stringify(
      cart.items.map(({ price, quantity }) => ({ price, quantity })),
    ),
  );
</script>

<div class="flex flex-col gap-4 {className}">
  {#if cart.items.length === 0}
    <p class="text-muted-foreground text-sm">Your cart is empty.</p>
  {:else}
    <ul class="divide-y">
      {#each cart.items as item (item.price)}
        <li class="flex items-center gap-3 py-3">
          <div class="min-w-0 flex-1">
            <p class="truncate text-sm font-medium">{item.name}</p>
            <p class="text-muted-foreground text-sm">
              {formatAmount(item.unitAmount, item.currency, item.options)}
            </p>
          </div>
          <div class="flex items-center gap-1">
            <Button
              variant="outline"
              size="icon"
              class="size-7"
              aria-label="One fewer {item.name}"
              onclick={() => cart.setQuantity(item.price, item.quantity - 1)}
            >
              <MinusIcon />
            </Button>
            <span class="w-6 text-center text-sm tabular-nums"
              >{item.quantity}</span
            >
            <Button
              variant="outline"
              size="icon"
              class="size-7"
              aria-label="One more {item.name}"
              onclick={() => cart.setQuantity(item.price, item.quantity + 1)}
            >
              <PlusIcon />
            </Button>
          </div>
          <Button
            variant="ghost"
            size="icon"
            class="size-7"
            aria-label="Remove {item.name}"
            onclick={() => cart.remove(item.price)}
          >
            <XIcon />
          </Button>
        </li>
      {/each}
    </ul>

    <Separator />

    {#if cart.currency}
      <div class="flex justify-between text-sm font-medium">
        <span>Subtotal</span>
        <span>{formatAmount(cart.subtotal, cart.currency, cart.options)}</span>
      </div>
    {/if}

    <!-- A plain form post: checkout works before the page has hydrated. -->
    <form method="POST" action="/cart?/checkout">
      <input type="hidden" name="items" value={items} />
      <Button type="submit" class="w-full">Check out</Button>
    </form>
  {/if}
</div>
```

## src/lib/components/cart/cart-sheet.svelte

```svelte
<script lang="ts">
  import * as Sheet from "#lib/components/ui/sheet/index.js";
  import CartItems from "./cart-items.svelte";
  import { cart } from "#lib/cart.svelte.js";
</script>

<Sheet.Root bind:open={cart.open}>
  <Sheet.Content side="right" class="flex flex-col">
    <Sheet.Header>
      <Sheet.Title>Cart</Sheet.Title>
    </Sheet.Header>
    <CartItems class="flex-1 overflow-y-auto px-4 pb-4" />
  </Sheet.Content>
</Sheet.Root>
```

## src/routes/(public)/cart/+page.server.ts

```ts
import { fail, redirect } from "@sveltejs/kit";
import Stripe from "stripe";
import stripe from "#lib/stripe.js";
import { priceOptions } from "#lib/cart.svelte.js";

/** What the cart posts: prices and quantities. Stripe supplies everything else. */
function readItems(
  raw: FormDataEntryValue | null,
): { price: string; quantity: number }[] | null {
  try {
    const items: unknown = JSON.parse(typeof raw === "string" ? raw : "");
    if (!Array.isArray(items) || items.length === 0 || items.length > 100)
      return null;
    const valid = items.every(
      (item) =>
        typeof item?.price === "string" &&
        Number.isInteger(item?.quantity) &&
        item.quantity >= 1 &&
        item.quantity <= 99,
    );
    return valid ? items : null;
  } catch {
    return null;
  }
}

/**
 * The currency to charge in: the one the visitor read prices in, when every
 * price has a fixed amount in it (`currency_options`, set in the dashboard);
 * else the prices' own, and nothing is converted at checkout.
 */
async function chargeCurrency(
  locals: App.Locals,
  items: { price: string }[],
): Promise<string | undefined> {
  const wanted = locals.currency?.toLowerCase();
  if (!wanted) return undefined;
  const prices = await Promise.all(
    items.map((item) =>
      locals.admin
        .collection("stripe_prices")
        .getOne<{ currency?: string; currency_options?: unknown }>(item.price)
        .catch(() => null),
    ),
  );
  const priced = prices.every(
    (price) =>
      price &&
      (price.currency === wanted ||
        priceOptions(price.currency_options)?.[wanted.toUpperCase()] !==
          undefined),
  );
  // Prices already in that currency are charged in it without being told.
  const needed = prices.some((price) => price && price.currency !== wanted);
  return priced && needed ? wanted : undefined;
}

export const actions = {
  checkout: async ({ request, url, locals }) => {
    const items = readItems((await request.formData()).get("items"));
    if (!items) return fail(400, { message: "Your cart could not be read." });

    const currency = await chargeCurrency(locals, items);

    let session: Stripe.Checkout.Session;
    try {
      session = await stripe.checkout.sessions.create({
        mode: "payment",
        currency,
        line_items: items,
        allow_promotion_codes: true,
        success_url: `${url.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
        cancel_url: `${url.origin}/cart`,
      });
    } catch (error) {
      // An inactive or recurring price, or mixed currencies: Stripe says which.
      if (error instanceof Stripe.errors.StripeError) {
        return fail(400, { message: error.message });
      }
      throw error;
    }

    // SvelteKit 3 redirects off-site only when told to.
    redirect(303, session.url!, { external: true });
  },
};
```

## src/routes/(public)/cart/+page.svelte

```svelte
<script lang="ts">
  import CartItems from "#lib/components/cart/cart-items.svelte";

  let { form } = $props();
</script>

<section data-role="content" class="max-w-xl">
  <h1 class="mb-6 text-2xl font-semibold">Cart</h1>
  {#if form?.message}
    <p class="text-destructive mb-4 text-sm">{form.message}</p>
  {/if}
  <CartItems />
</section>
```

## src/routes/(public)/cart/server.test.ts

```ts
import { describe, it, expect, beforeAll, type TestContext } from "vitest";
import * as devalue from "devalue";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";
import stripe from "#lib/stripe.js";
import type Stripe from "stripe";

describe("POST /cart?/checkout", () => {
  let price: Stripe.Price;
  let inactivePrice: Stripe.Price;

  beforeAll(async () => {
    const name = "Test Product - Cart";
    const found = await stripe.products.search({
      query: `name:"${name}" AND metadata["test"]:"true"`,
      limit: 1,
    });
    const product =
      found.data[0] ??
      (await stripe.products.create({ name, metadata: { test: "true" } }));

    const prices = async (active: boolean) =>
      (await stripe.prices.list({ product: product.id, active, limit: 1 }))
        .data[0];
    price =
      (await prices(true)) ??
      (await stripe.prices.create({
        product: product.id,
        unit_amount: 1500,
        currency: "usd",
      }));
    inactivePrice =
      (await prices(false)) ??
      (await stripe.prices.create({
        product: product.id,
        unit_amount: 2500,
        currency: "usd",
        active: false,
      }));
  });

  const checkout = (context: TestContext, items: unknown) =>
    context.request
      .post(`${"/cart" satisfies Match<RouteId>}?/checkout`)
      .type("form")
      .send({
        items: typeof items === "string" ? items : JSON.stringify(items),
      });

  it("redirects to a Stripe Checkout Session", async (context) => {
    const response = await checkout(context, [
      { price: price.id, quantity: 2 },
    ]);
    expect(response.body.status).toBe(303);
    expect(response.body.location).toMatch(
      /^https:\/\/checkout\.stripe\.com\//,
    );
  });

  it("refuses a cart it cannot read", async (context) => {
    for (const items of [
      "not json",
      [],
      [{ price: price.id }],
      [{ quantity: 1 }],
    ]) {
      const response = await checkout(context, items);
      expect(response.body.status).toBe(400);
    }
  });

  it("refuses quantities outside 1 to 99", async (context) => {
    for (const quantity of [0, 100, 1.5]) {
      const response = await checkout(context, [{ price: price.id, quantity }]);
      expect(response.body.status).toBe(400);
    }
  });

  it("passes on Stripe's reason for a price it won't sell", async (context) => {
    const response = await checkout(context, [
      { price: inactivePrice.id, quantity: 1 },
    ]);
    expect(response.body.status).toBe(400);
    expect(devalue.parse(response.body.data).message).toMatch(
      /inactive|not active/i,
    );
  });
});
```

## src/routes/(public)/checkout/success/+page.server.ts

```ts
import { error } from "@sveltejs/kit";
import stripe from "#lib/stripe.js";

export const load = async ({ url }) => {
  const id = url.searchParams.get("session_id");
  if (!id) error(404, "Not found");

  try {
    const session = await stripe.checkout.sessions.retrieve(id);
    return {
      amountTotal: session.amount_total,
      currency: session.currency,
      paid: session.payment_status === "paid",
    };
  } catch {
    error(404, "Not found");
  }
};
```

## src/routes/(public)/checkout/success/+page.svelte

```svelte
<script lang="ts">
  import { onMount } from "svelte";
  import { cart, formatAmount } from "#lib/cart.svelte.js";

  let { data } = $props();

  onMount(() => {
    cart.load();
    if (data.paid) cart.clear();
  });
</script>

<section data-role="content" class="max-w-xl">
  {#if data.paid}
    <h1 class="mb-2 text-2xl font-semibold">Thank you</h1>
    <p class="text-muted-foreground">
      {#if data.amountTotal !== null && data.currency}
        Your payment of {formatAmount(data.amountTotal, data.currency)} went through.
      {:else}
        Your payment went through.
      {/if}
    </p>
  {:else}
    <h1 class="mb-2 text-2xl font-semibold">Payment pending</h1>
    <p class="text-muted-foreground">
      Your order is placed; the payment has not cleared yet.
    </p>
  {/if}
</section>
```
