Add a cart and checkout to your SvelteKit app on the payments provider it already uses. Adds a cart button in the navbar that opens the cart in a sheet, an <AddToCart> component for pages of your own, and a checkout flow on the project's provider. Run vela enable payments first; vela generate shop writes a product listing and product pages to sell from.
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;
}
<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>
<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 />
<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>
<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>
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 });
},
};
<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>
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,
);
});
});
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");
}
};
<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>
<script lang="ts">
import type { Snippet } from "svelte";
import favicon from "#lib/assets/favicon.svg";
import { site } from "#lib/site.js";
import { toggleMode } from "mode-watcher";
import * as Navbar from "#lib/components/ui/navbar/index.js";
import { Button } from "#lib/components/ui/button/index.js";
import CartButton from "#lib/components/cart/cart-button.svelte";
// Layout data is passed through for navbar items a feature adds; enable auth
// reads the signed-in user from it.
let { children }: { children?: Snippet; data?: any } = $props();
</script>
<div class="min-h-dvh grid grid-rows-[max-content_1fr_max-content] root-layout">
<Navbar.Root>
<Navbar.Brand href="/">
<Navbar.Logo src={favicon} alt="Logo" />
</Navbar.Brand>
<Navbar.MobileToggle />
<Navbar.List>
<Navbar.Item>
<Button
class="w-full justify-start md:justify-center md:w-auto"
href="/"
variant="ghost"
>
Home
</Button>
</Navbar.Item>
<Navbar.Item>
<CartButton />
</Navbar.Item>
</Navbar.List>
</Navbar.Root>
{@render children?.()}
<footer class="border-t border-border py-4">
<div
class="mx-auto flex max-w-7xl px-4 flex-col items-center justify-between gap-3 sm:flex-row"
>
<p class="text-muted-foreground text-sm">
© {new Date().getFullYear()}
{site.name}. All rights reserved.
</p>
<nav class="flex items-center gap-4 text-sm">
<Button onclick={toggleMode} variant="ghost" size="icon">
<Navbar.Mode />
</Button>
<!-- Neither route exists yet: `vela legal privacy` and `vela legal terms` generate them. -->
<a href="/privacy" class="underline-offset-4 hover:underline">Privacy</a
>
<a href="/terms" class="underline-offset-4 hover:underline">Terms</a>
</nav>
</div>
</footer>
</div>
<style lang="postcss">
@reference "tailwindcss";
.root-layout :global(section[data-role="content"]) {
@apply container mx-auto max-w-7xl px-4 py-8 w-full;
}
</style>