Generate a product listing at /shop and a page per product for the cart vela enable ecommerce added. The pages are written in your payments provider's own terms and they are yours: the generator never writes over a file that exists. Requires ecommerce to be enabled first.
import { error } from "@sveltejs/kit";
import { priceOptions } from "#lib/cart.svelte.js";
/** The fields of the synced catalog this page reads. */
interface ProductRecord {
id: string;
name?: string;
/** Stripe's boolean, stored by the sync in a text field. */
active?: unknown;
default_price?: string;
description?: string;
images?: unknown;
}
interface PriceRecord {
id: string;
nickname?: string;
unit_amount?: number;
currency?: string;
currency_options?: unknown;
}
const isActive = (product: ProductRecord) => String(product.active) !== "false";
const images = (product: ProductRecord): string[] =>
Array.isArray(product.images)
? product.images.filter(
(image): image is string => typeof image === "string",
)
: [];
/**
* One product and its active one-time prices (sizes, say), by its
* `metadata.slug` from the Stripe dashboard or its id.
*/
export const load = async ({ locals, params }) => {
const products = locals.admin.collection("stripe_products");
const product = await products
.getFirstListItem<ProductRecord>(
locals.admin.filter("metadata.slug = {:slug}", { slug: params.product }),
)
.catch(() => products.getOne<ProductRecord>(params.product))
.catch(() => null);
if (!product || !isActive(product)) error(404, "Not found");
const prices = await locals.admin
.collection("stripe_prices")
.getFullList<PriceRecord>({
filter: locals.admin.filter(
'product = {:product} && active = true && type = "one_time"',
{
product: product.id,
},
),
sort: "unit_amount",
});
const priced = prices.filter(
(price): price is PriceRecord & { currency: string } =>
Boolean(price.currency),
);
if (priced.length === 0) error(404, "Not found");
const name = product.name ?? product.id;
return {
product: {
id: product.id,
name,
description: product.description ?? null,
images: images(product),
defaultPrice: product.default_price ?? null,
},
prices: priced.map((price) => ({
price: price.id,
name,
// What the dashboard calls this price, when there is more than one.
nickname: price.nickname ?? null,
unitAmount: price.unit_amount ?? 0,
currency: price.currency,
options: priceOptions(price.currency_options),
})),
};
};
<script lang="ts">
import { Button } from "#lib/components/ui/button/index.js";
import AddToCart from "#lib/components/cart/add-to-cart.svelte";
import { formatAmount } from "#lib/cart.svelte.js";
let { data } = $props();
// The default price first, or the cheapest; with one price there is nothing to pick.
let selectedId = $state(
data.prices.find((price) => price.price === data.product.defaultPrice)
?.price ?? data.prices[0].price,
);
const selected = $derived(
data.prices.find((price) => price.price === selectedId) ?? data.prices[0],
);
</script>
<section data-role="content" class="grid gap-8 md:grid-cols-2">
<div class="flex flex-col gap-3">
{#each data.product.images.slice(0, 1) as image (image)}
<img
src={image}
alt=""
class="aspect-square w-full rounded-lg object-cover"
/>
{/each}
</div>
<div class="flex flex-col gap-6">
<div>
<p class="text-muted-foreground mb-2 text-sm"><a href="/shop">Shop</a></p>
<h1 class="text-2xl font-semibold">{data.product.name}</h1>
<p class="mt-2 text-lg">
{formatAmount(selected.unitAmount, selected.currency, selected.options)}
</p>
</div>
{#if data.product.description}
<p class="text-muted-foreground">{data.product.description}</p>
{/if}
{#if data.prices.length > 1}
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label="Option">
{#each data.prices as price (price.price)}
<Button
variant={price.price === selectedId ? "default" : "outline"}
size="sm"
role="radio"
aria-checked={price.price === selectedId}
onclick={() => (selectedId = price.price)}
>
{price.nickname ??
formatAmount(price.unitAmount, price.currency, price.options)}
</Button>
{/each}
</div>
{/if}
<AddToCart price={selected} size="lg" />
</div>
</section>
import { priceOptions } from "#lib/cart.svelte.js";
/** The fields of the synced catalog this page reads. */
interface ProductRecord {
id: string;
name?: string;
/** Stripe's boolean, stored by the sync in a text field. */
active?: unknown;
default_price?: string;
description?: string;
images?: unknown;
metadata?: unknown;
}
interface PriceRecord {
id: string;
product?: string;
unit_amount?: number;
currency?: string;
currency_options?: unknown;
}
const isActive = (product: ProductRecord) => String(product.active) !== "false";
const slugOf = (product: ProductRecord): string => {
const metadata = product.metadata as { slug?: unknown } | null | undefined;
return typeof metadata?.slug === "string" && metadata.slug
? metadata.slug
: product.id;
};
const firstImage = (product: ProductRecord): string | null =>
Array.isArray(product.images) && typeof product.images[0] === "string"
? product.images[0]
: null;
/**
* The catalog, as the sync copied it from Stripe: every active product with
* its default one-time price (or its first active one). A product's
* `metadata.slug`, when set in the Stripe dashboard, names its page.
*/
export const load = async ({ locals }) => {
const [products, prices] = await Promise.all([
locals.admin
.collection("stripe_products")
.getFullList<ProductRecord>({ sort: "name" }),
locals.admin.collection("stripe_prices").getFullList<PriceRecord>({
filter: 'active = true && type = "one_time"',
}),
]);
return {
products: products.filter(isActive).flatMap((product) => {
const price =
prices.find((candidate) => candidate.id === product.default_price) ??
prices.find((candidate) => candidate.product === product.id);
if (!price?.currency) return [];
const name = product.name ?? product.id;
return [
{
id: product.id,
slug: slugOf(product),
name,
description: product.description ?? null,
image: firstImage(product),
price: {
price: price.id,
name,
unitAmount: price.unit_amount ?? 0,
currency: price.currency,
options: priceOptions(price.currency_options),
},
},
];
}),
};
};
<script lang="ts">
import AddToCart from "#lib/components/cart/add-to-cart.svelte";
import { formatAmount } from "#lib/cart.svelte.js";
let { data } = $props();
</script>
<section data-role="content">
<h1 class="mb-6 text-2xl font-semibold">Shop</h1>
{#if data.products.length === 0}
<p class="text-muted-foreground">
Nothing for sale yet. Products and prices come from your Stripe dashboard;
the catalog syncs every ten minutes.
</p>
{:else}
<ul class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each data.products as product (product.id)}
<li class="flex flex-col gap-3 rounded-lg border p-4">
<a href="/shop/{product.slug}" class="flex flex-col gap-3">
{#if product.image}
<img
src={product.image}
alt=""
class="aspect-square w-full rounded object-cover"
/>
{/if}
<div>
<h2 class="font-medium">{product.name}</h2>
<p class="text-muted-foreground text-sm">
{formatAmount(
product.price.unitAmount,
product.price.currency,
product.price.options,
)}
</p>
</div>
</a>
<AddToCart price={product.price} class="mt-auto" />
</li>
{/each}
</ul>
{/if}
</section>