Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 40 additions & 16 deletions platforms/web/sample/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
# Web Component Playground

A development harness for the `<shopify-checkout>` web component. It imports
the same entry as published consumers (`@shopify/checkout-kit`, aliased to
`../src/index.ts` in dev), registers the custom element, and logs `ec.*`
events.
A development harness for the `<shopify-checkout>` web component. It imports the same entry as published consumers (`@shopify/checkout-kit`, aliased to `../src/index.ts` in dev), registers the custom element, and logs `ec.*` events.

## Run locally

Expand All @@ -12,24 +9,51 @@ cd platforms/web
pnpm sample
```

Vite serves at `http://localhost:5173`. The page has three panels:
Vite serves at `http://localhost:5173`.

- **Options** — form for `src`, `target` (`auto` | `popup`), and `debug`,
plus buttons for `open()`, `close()`, and `focus()`.
- **Demo Storefront** — a mocked product card with **Buy now** calling
`checkout.open()`. The button stays disabled until a checkout URL is set.
The collapsible readout shows `checkout`, `error`, `target`, and `debug`.
- **Events** — log of dispatched events with a JSON snapshot of state at fire
time.
## What the demo shows

The element is mounted on `<body>`. For `popup` / `auto`, the visible UI is
mostly the overlay scrim while checkout is open in a separate window or tab.
The default flow highlights a multi-item cart use case:

1. Choose **Build cart permalink** in Settings.
2. Enter a storefront domain, for example `your-store.myshopify.com`.
3. The domain, selected flow, target, and debug setting are saved in local storage for the next page load.
4. After a 500 ms debounce, the demo automatically fetches `https://your-store.myshopify.com/products.json`.
5. Add multiple available variants from the storefront-style product cards.
6. The cart banner at the top of the center workspace shows selected lines, the derived cart permalink, and **Open checkout**.

The derived permalink looks like:

```text
https://your-store.myshopify.com/cart/123:1,456:2
```

You can also choose **Use existing checkout source** in Settings. In that mode, the storefront and cart builder are hidden, and the center workspace shows a manual URL flow with its own **Open checkout** button.

## Panels

- **Settings** — persisted storefront domain, flow, target (`popup` | `auto`), and debug settings. The storefront domain appears first because the cart builder cannot load products without it.
- **Center workspace** — build mode shows a storefront-style product grid plus sticky cart banner; manual mode shows a focused checkout URL/cart permalink input.
- **Runtime** — shows component state above the `ec.*` event log, with a JSON snapshot of component state at fire time.

The element is mounted on `<body>`. For `popup` / `auto`, the visible UI is mostly the overlay scrim while checkout is open in a separate window or tab.

## Troubleshooting product loading

The demo relies on the public `/products.json` endpoint. If product loading fails:

- Confirm the domain is a storefront domain, not a checkout URL.
- Confirm the domain is complete, for example `your-store.myshopify.com`.
- Confirm the store has products published to the Online Store channel.
- Confirm the storefront is reachable from your browser.
- Use **Use existing checkout source** if you already have a checkout URL or cart permalink and do not need product loading.

This sample does not currently call Storefront API `cartCreate`; it uses cart permalinks so the multi-item flow can be exercised without a Storefront access token.

## Build

```bash
pnpm sample:build # outputs to sample/dist/
```

CI runs this on every PR (see `.github/workflows/web.yml`). The sample is not
published to npm (`files` allowlist in `platforms/web/package.json`).
CI runs this on every PR (see `.github/workflows/web.yml`). The sample is not published to npm (`files` allowlist in `platforms/web/package.json`).
195 changes: 195 additions & 0 deletions platforms/web/sample/cart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { describe, expect, it, vi } from "vitest";

import {
buildCartPermalink,
buildProductsJsonUrl,
fetchProductVariants,
flattenProductVariants,
cartLineTotalQuantity,
isLikelyStorefrontDomain,
normalizeStorefrontDomain,
upsertCartLine,
type CartLine,
} from "./cart";

describe("normalizeStorefrontDomain", () => {
it("accepts bare storefront domains", () => {
expect(normalizeStorefrontDomain("your-store.myshopify.com")).toBe("your-store.myshopify.com");
});

it("extracts the host from full storefront URLs", () => {
expect(normalizeStorefrontDomain("https://YOUR-STORE.myshopify.com/products/book")).toBe(
"your-store.myshopify.com",
);
});
});

describe("isLikelyStorefrontDomain", () => {
it("accepts bare domains and full URLs", () => {
expect(isLikelyStorefrontDomain("your-store.myshopify.com")).toBe(true);
expect(isLikelyStorefrontDomain("https://your-store.myshopify.com/products/book")).toBe(true);
});

it("waits for a complete domain before auto-loading products", () => {
expect(isLikelyStorefrontDomain("")).toBe(false);
expect(isLikelyStorefrontDomain("your-store")).toBe(false);
expect(isLikelyStorefrontDomain("not a domain")).toBe(false);
});
});

describe("buildProductsJsonUrl", () => {
it("points at the public products JSON endpoint", () => {
expect(buildProductsJsonUrl("https://your-store.myshopify.com/")).toBe(
"https://your-store.myshopify.com/products.json",
);
});
});

describe("flattenProductVariants", () => {
it("flattens products.json products into selectable variants", () => {
const variants = flattenProductVariants({
products: [
{
title: "Physical Bundle",
vendor: "Checkout Kit Test Shop",
images: [{ src: "https://cdn.example.com/physical.png" }],
variants: [
{ id: 1, title: "Blue", price: "25.00", available: true },
{ id: 2, title: "Red", price: "25.00", available: false },
],
},
{
title: "Digital Bundle",
vendor: "Checkout Kit Test Shop",
variants: [{ id: 3, title: "Default Title", price: "10.00" }],
},
],
});

expect(variants).toEqual([
{
id: "3",
title: "Digital Bundle",
productTitle: "Digital Bundle",
variantTitle: "Default Title",
vendor: "Checkout Kit Test Shop",
price: "10.00",
available: true,
imageUrl: undefined,
},
{
id: "1",
title: "Physical Bundle - Blue",
productTitle: "Physical Bundle",
variantTitle: "Blue",
vendor: "Checkout Kit Test Shop",
price: "25.00",
available: true,
imageUrl: "https://cdn.example.com/physical.png",
},
{
id: "2",
title: "Physical Bundle - Red",
productTitle: "Physical Bundle",
variantTitle: "Red",
vendor: "Checkout Kit Test Shop",
price: "25.00",
available: false,
imageUrl: "https://cdn.example.com/physical.png",
},
]);
});
});

describe("fetchProductVariants", () => {
it("fetches and flattens products.json variants", async () => {
const fetcher = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
products: [
{
title: "Physical Bundle",
variants: [{ id: 1, title: "Blue", price: "25.00" }],
},
],
}),
});

await expect(fetchProductVariants("your-store.myshopify.com", fetcher)).resolves.toEqual([
{
id: "1",
title: "Physical Bundle - Blue",
productTitle: "Physical Bundle",
variantTitle: "Blue",
vendor: "",
price: "25.00",
available: true,
imageUrl: undefined,
},
]);
expect(fetcher).toHaveBeenCalledWith("https://your-store.myshopify.com/products.json");
});

it("returns a useful error when the storefront does not expose products.json", async () => {
const fetcher = vi.fn().mockResolvedValue({ ok: false, status: 404 });

await expect(fetchProductVariants("your-store.myshopify.com", fetcher)).rejects.toThrow(
"Could not load products from https://your-store.myshopify.com/products.json (HTTP 404). Confirm the storefront domain is correct and products are published to the Online Store channel.",
);
});
});

describe("upsertCartLine", () => {
it("adds, updates, and removes lines by quantity", () => {
expect(upsertCartLine([], "1", 2)).toEqual([{ variantId: "1", quantity: 2 }]);
expect(upsertCartLine([{ variantId: "1", quantity: 2 }], "1", 3)).toEqual([
{ variantId: "1", quantity: 3 },
]);
expect(upsertCartLine([{ variantId: "1", quantity: 2 }], "1", 0)).toEqual([]);
});
});

describe("cartLineTotalQuantity", () => {
it("sums selected line quantities", () => {
expect(
cartLineTotalQuantity([
{ variantId: "1", quantity: 2 },
{ variantId: "2", quantity: 3 },
]),
).toBe(5);
});
});

describe("buildCartPermalink", () => {
it("builds a multi-line cart permalink", () => {
const lines: CartLine[] = [
{ variantId: "123", quantity: 1 },
{ variantId: "456", quantity: 2 },
];

expect(buildCartPermalink("https://your-store.myshopify.com", lines)).toBe(
"https://your-store.myshopify.com/cart/123:1,456:2",
);
});

it("merges duplicate variant IDs and clamps quantities", () => {
const lines: CartLine[] = [
{ variantId: "1", quantity: 1 },
{ variantId: "1", quantity: 999 },
{ variantId: "2", quantity: 0 },
];

expect(buildCartPermalink("your-store.myshopify.com", lines)).toBe(
"https://your-store.myshopify.com/cart/1:999,2:1",
);
});

it("always produces an https permalink", () => {
const permalink = buildCartPermalink("your-store.myshopify.com", [
{ variantId: "123", quantity: 1 },
]);

expect(new URL(permalink).protocol).toBe("https:");
});
});
Loading
Loading