Generate a shop

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.

$ vela generate shop
src/routes/(public)/shop/[product]/+page.server.ts
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),
    })),
  };
};
src/routes/(public)/shop/[product]/+page.svelte
<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>
src/routes/(public)/shop/+page.server.ts
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),
          },
        },
      ];
    }),
  };
};
src/routes/(public)/shop/+page.svelte
<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>