From 8262ea09f99ea024b560ee3900e20423538780da Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 11:21:43 +0100 Subject: [PATCH 1/5] Update web sample cart demo Assisted-By: devx/96a590ff-685a-49ce-8199-0810f0ac2379 --- platforms/web/sample/README.md | 56 ++- platforms/web/sample/cart.test.ts | 191 ++++++++ platforms/web/sample/cart.ts | 209 +++++++++ platforms/web/sample/index.html | 231 ++++++---- platforms/web/sample/main.ts | 618 ++++++++++++++++++++++--- platforms/web/sample/styles.css | 727 ++++++++++++++++++++++++------ platforms/web/vite.config.ts | 2 +- 7 files changed, 1730 insertions(+), 304 deletions(-) create mode 100644 platforms/web/sample/cart.test.ts create mode 100644 platforms/web/sample/cart.ts diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index cb6c77823..17e47c789 100644 --- a/platforms/web/sample/README.md +++ b/platforms/web/sample/README.md @@ -1,9 +1,6 @@ # Web Component Playground -A development harness for the `` 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 `` 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 @@ -12,18 +9,46 @@ 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 ``. 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 300 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 ``. 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 @@ -31,5 +56,4 @@ mostly the overlay scrim while checkout is open in a separate window or tab. 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`). diff --git a/platforms/web/sample/cart.test.ts b/platforms/web/sample/cart.test.ts new file mode 100644 index 000000000..8dfef06f1 --- /dev/null +++ b/platforms/web/sample/cart.test.ts @@ -0,0 +1,191 @@ +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("kieran-osgood.myshopify.com")).toBe( + "kieran-osgood.myshopify.com", + ); + }); + + it("extracts the host from full storefront URLs", () => { + expect(normalizeStorefrontDomain("https://KIERAN-OSGOOD.myshopify.com/products/book")).toBe( + "kieran-osgood.myshopify.com", + ); + }); +}); + +describe("isLikelyStorefrontDomain", () => { + it("accepts bare domains and full URLs", () => { + expect(isLikelyStorefrontDomain("kieran-osgood.myshopify.com")).toBe(true); + expect(isLikelyStorefrontDomain("https://kieran-osgood.myshopify.com/products/book")).toBe( + true, + ); + }); + + it("waits for a complete domain before auto-loading products", () => { + expect(isLikelyStorefrontDomain("")).toBe(false); + expect(isLikelyStorefrontDomain("kieran-osgood")).toBe(false); + expect(isLikelyStorefrontDomain("not a domain")).toBe(false); + }); +}); + +describe("buildProductsJsonUrl", () => { + it("points at the public products JSON endpoint", () => { + expect(buildProductsJsonUrl("https://kieran-osgood.myshopify.com/")).toBe( + "https://kieran-osgood.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("kieran-osgood.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://kieran-osgood.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("kieran-osgood.myshopify.com", fetcher)).rejects.toThrow( + "Could not load products from https://kieran-osgood.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: "54888789213532", quantity: 1 }, + { variantId: "54888789246300", quantity: 2 }, + ]; + + expect(buildCartPermalink("https://kieran-osgood.myshopify.com", lines)).toBe( + "https://kieran-osgood.myshopify.com/cart/54888789213532:1,54888789246300: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("kieran-osgood.myshopify.com", lines)).toBe( + "https://kieran-osgood.myshopify.com/cart/1:999,2:1", + ); + }); +}); diff --git a/platforms/web/sample/cart.ts b/platforms/web/sample/cart.ts new file mode 100644 index 000000000..6d4157ad2 --- /dev/null +++ b/platforms/web/sample/cart.ts @@ -0,0 +1,209 @@ +export interface CartLine { + variantId: string; + quantity: number; +} + +export interface ProductVariantOption { + id: string; + title: string; + productTitle: string; + variantTitle: string; + vendor: string; + price: string; + available: boolean; + imageUrl?: string; +} + +type ProductsJsonFetcher = (url: string) => Promise; + +interface ProductsJsonResponseLike { + ok: boolean; + status: number; + json(): Promise; +} + +interface ProductsJsonResponse { + products?: ProductJson[]; +} + +interface ProductJson { + title?: string; + vendor?: string; + images?: ProductImageJson[]; + image?: ProductImageJson; + variants?: ProductVariantJson[]; +} + +interface ProductImageJson { + src?: string; +} + +interface ProductVariantJson { + id?: string | number; + title?: string; + price?: string | number; + available?: boolean; +} + +export function normalizeStorefrontDomain(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + + try { + const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + return url.hostname.toLowerCase(); + } catch { + const fallbackDomain = trimmed.replace(/^https?:\/\//i, "").split("/")[0] ?? ""; + return fallbackDomain.replace(/\/+$/, "").toLowerCase(); + } +} + +export function isLikelyStorefrontDomain(value: string): boolean { + const domain = normalizeStorefrontDomain(value); + return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(domain); +} + +export function buildProductsJsonUrl(storefrontDomain: string): string { + const domain = normalizeStorefrontDomain(storefrontDomain); + if (!domain) { + throw new Error("Enter a storefront domain before loading products."); + } + + return `https://${domain}/products.json`; +} + +export async function fetchProductVariants( + storefrontDomain: string, + fetcher: ProductsJsonFetcher = fetch, +): Promise { + const productsUrl = buildProductsJsonUrl(storefrontDomain); + let response: ProductsJsonResponseLike; + + try { + response = await fetcher(productsUrl); + } catch (error) { + throw new Error( + `Could not load products from ${productsUrl}. Confirm the storefront domain is reachable and try again.`, + { cause: error }, + ); + } + + if (!response.ok) { + throw new Error( + `Could not load products from ${productsUrl} (HTTP ${response.status}). Confirm the storefront domain is correct and products are published to the Online Store channel.`, + ); + } + + const variants = flattenProductVariants(await response.json()); + if (variants.length === 0) { + throw new Error( + `No product variants were found at ${productsUrl}. Add products, publish them to the Online Store channel, and try again.`, + ); + } + + return variants; +} + +export function flattenProductVariants(data: ProductsJsonResponse): ProductVariantOption[] { + const variants = (data.products ?? []).flatMap((product) => { + const productTitle = product.title ?? "Untitled product"; + const vendor = product.vendor ?? ""; + const imageUrl = product.images?.[0]?.src ?? product.image?.src; + + return (product.variants ?? []) + .filter((variant) => variant.id !== undefined && variant.id !== null) + .map((variant) => { + const variantTitle = variant.title ?? "Default Title"; + const title = + variantTitle && variantTitle !== "Default Title" + ? `${productTitle} - ${variantTitle}` + : productTitle; + + return { + id: String(variant.id), + title, + productTitle, + variantTitle, + vendor, + price: String(variant.price ?? ""), + available: variant.available !== false, + imageUrl, + }; + }); + }); + + variants.sort((first, second) => { + if (first.available !== second.available) { + return first.available ? -1 : 1; + } + + return first.title.localeCompare(second.title, undefined, { sensitivity: "base" }); + }); + + return variants; +} + +export function normalizeQuantity(value: unknown): number { + const quantity = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10); + if (!Number.isFinite(quantity)) return 1; + return Math.min(999, Math.max(1, Math.floor(quantity))); +} + +export function upsertCartLine( + lines: readonly CartLine[], + variantId: string, + quantity: unknown, +): CartLine[] { + const normalizedVariantId = variantId.trim(); + const nextQuantity = normalizeQuantity(quantity); + const nextLines = lines.filter((line) => line.variantId !== normalizedVariantId); + + if (!normalizedVariantId || Number(quantity) <= 0) { + return normalizeCartLines(nextLines); + } + + return normalizeCartLines([ + ...nextLines, + { variantId: normalizedVariantId, quantity: nextQuantity }, + ]); +} + +export function cartLineTotalQuantity(lines: readonly CartLine[]): number { + return normalizeCartLines(lines).reduce((total, line) => total + line.quantity, 0); +} + +export function normalizeCartLines(lines: readonly CartLine[]): CartLine[] { + const merged = new Map(); + + for (const line of lines) { + const variantId = line.variantId.trim(); + if (!variantId) continue; + + const existing = merged.get(variantId); + const quantity = normalizeQuantity(line.quantity); + merged.set(variantId, { + variantId, + quantity: Math.min(999, (existing?.quantity ?? 0) + quantity), + }); + } + + return [...merged.values()]; +} + +export function buildCartPermalink(storefrontDomain: string, lines: readonly CartLine[]): string { + const domain = normalizeStorefrontDomain(storefrontDomain); + if (!domain) { + throw new Error("Enter a storefront domain before generating a cart permalink."); + } + + const normalizedLines = normalizeCartLines(lines); + if (normalizedLines.length === 0) { + throw new Error("Select at least one product before generating a cart permalink."); + } + + const permalinkLines = normalizedLines + .map((line) => `${encodeURIComponent(line.variantId)}:${line.quantity}`) + .join(","); + + return `https://${domain}/cart/${permalinkLines}`; +} diff --git a/platforms/web/sample/index.html b/platforms/web/sample/index.html index e28f0a624..9ab7d29a9 100644 --- a/platforms/web/sample/index.html +++ b/platforms/web/sample/index.html @@ -12,106 +12,172 @@

Checkout Kit

Web Component Playground

-

- Loads the real <shopify-checkout> from - @shopify/checkout-kit (dev alias). Use a valid checkout URL to try - Buy now. -

-
- -
-

Options

+
+
+
+

Settings

+ +
- - - - - - -
- Methods -
- - - -
+
+ Flow + + +
+ +
+ Storefront + +

+ Saved in this browser. Products auto-load from /products.json after you + stop typing. +

+
+ +
+ Presentation + + +
-
-

Demo Storefront

+
+
+
+
+

Cart

+

Add products to start a multi-item cart.

+
+ 0 items +
-
+ +
+
+

Demo storefront

+

Products loaded from the storefront domain in Settings.

+
+ Waiting for domain
-
-

Acme Goods

-

Studio Plant Pot

-

$29.00 USD

+

+ Enter a storefront domain to load products automatically. +

-

- A hand-thrown ceramic pot for your favorite green friend. Glazed inside, raw outside. - Fits a 4″ nursery pot. +

+ +

No products loaded yet

+

+ Enter a storefront domain in Settings and products will load automatically once the + domain looks complete.

+
-
- - - -
+
+
    +
    +
    -
    - - + - + +

    + Paste a checkout source to enable checkout. +

    +
    + +
    -
    +
    +
    Component state
    checkout
    @@ -124,11 +190,8 @@

    Studio Plant Pot

    -
    - -
    -
    +

    Events

    diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts index f0cd48afd..51a08874c 100644 --- a/platforms/web/sample/main.ts +++ b/platforms/web/sample/main.ts @@ -1,10 +1,30 @@ -// Registers `` (same entry as published `@shopify/checkout-kit`). import "@shopify/checkout-kit"; import type { ShopifyCheckout } from "@shopify/checkout-kit"; +import { + buildCartPermalink, + cartLineTotalQuantity, + fetchProductVariants, + isLikelyStorefrontDomain, + normalizeQuantity, + normalizeStorefrontDomain, + upsertCartLine, + type CartLine, + type ProductVariantOption, +} from "./cart"; import "./styles.css"; -// ───── Helpers ───────────────────────────────────────────────────────────── +type SourceMode = "build" | "manual"; +type NoticeTone = "info" | "success" | "error"; + +const PRODUCT_LOAD_DEBOUNCE_MS = 300; +const STORAGE_KEYS = { + sourceMode: "checkout-kit:web-demo:source-mode", + storefrontDomain: "checkout-kit:web-demo:storefront-domain", + target: "checkout-kit:web-demo:target", + debug: "checkout-kit:web-demo:debug", + settingsCollapsed: "checkout-kit:web-demo:settings-collapsed", +}; function $(selector: string): T { const el = document.querySelector(selector); @@ -29,37 +49,40 @@ function timestamp(): string { return `${hh}:${mm}:${ss}.${ms}`; } -// ───── DOM references ────────────────────────────────────────────────────── - -const form = $("#options-form"); -const eventLog = $("#event-log"); -const clearLogButton = $("#clear-log"); -const buyNowButton = $("#buy-now"); -const buyHint = $("#buy-hint"); - -const stateNodes = { - checkout: $("#state-checkout"), - error: $("#state-error"), - target: $("#state-target"), - debug: $("#state-debug"), -}; - -// ───── Mount the component (off-layout) ─────────────────────────────────── -// -// In real merchant integrations the element lives wherever -// it makes sense in the page. For popup / auto targets it has no visible UI -// of its own beyond a transient dialog scrim that appears when open() is -// called, so we attach it to and leave the storefront panel free for -// the merchant's product UI. +function readStorage(key: string): string { + try { + return localStorage.getItem(key) ?? ""; + } catch { + return ""; + } +} -const checkout = document.createElement("shopify-checkout") as ShopifyCheckout; -document.body.append(checkout); +function writeStorage(key: string, value: string): void { + try { + if (value) { + localStorage.setItem(key, value); + } else { + localStorage.removeItem(key); + } + } catch { + return; + } +} -const checkoutEl: HTMLElement = checkout; +function sourceMode(): SourceMode { + const checked = form.querySelector("input[name='source-mode']:checked"); + return checked?.value === "manual" ? "manual" : "build"; +} -// ───── Form ↔ attributes ────────────────────────────────────────────────── +function activeSourceUrl(): string { + return sourceMode() === "manual" ? manualSrcInput.value.trim() : generatedCartUrl; +} -function setStringAttribute(el: HTMLElement, name: string, value: FormDataEntryValue | null): void { +function setStringAttribute( + el: HTMLElement, + name: string, + value: FormDataEntryValue | string | null, +): void { if (typeof value === "string" && value.length > 0) { el.setAttribute(name, value); } else { @@ -67,67 +90,531 @@ function setStringAttribute(el: HTMLElement, name: string, value: FormDataEntryV } } +function selectedCartLines(): CartLine[] { + return cartLines; +} + +function variantForLine(line: CartLine): ProductVariantOption | undefined { + return variants.find((variant) => variant.id === line.variantId); +} + +function showCartStatus(message: string, tone: NoticeTone = "info"): void { + cartStatus.hidden = tone === "success"; + cartStatus.textContent = message; + cartStatus.dataset["tone"] = tone; +} + +function setSettingsCollapsed(collapsed: boolean): void { + layout.classList.toggle("settings-collapsed", collapsed); + settingsToggle.setAttribute("aria-expanded", String(!collapsed)); + settingsToggle.textContent = collapsed ? "Show" : "Hide"; + writeStorage(STORAGE_KEYS.settingsCollapsed, collapsed ? "1" : ""); +} + function syncAttributes(): void { const data = new FormData(form); - setStringAttribute(checkout, "src", data.get("src")); - setStringAttribute(checkout, "target", data.get("target")); + const target = String(data.get("target") ?? "popup"); + + setStringAttribute(checkout, "src", activeSourceUrl()); + setStringAttribute(checkout, "target", target); + if (data.has("debug")) { checkout.setAttribute("debug", ""); } else { checkout.removeAttribute("debug"); } - refreshBuyButton(data.get("src")); + writeStorage(STORAGE_KEYS.sourceMode, sourceMode()); + writeStorage(STORAGE_KEYS.target, target); + writeStorage(STORAGE_KEYS.debug, data.has("debug") ? "1" : ""); + + updateSourceVisibility(); + refreshCheckoutButtons(); + refreshState(); +} + +function updateSourceVisibility(): void { + const isManual = sourceMode() === "manual"; + storefrontSourceFields.hidden = isManual; + buildWorkspace.hidden = isManual; + manualWorkspace.hidden = !isManual; +} + +function refreshCheckoutButtons(): void { + const hasSrc = activeSourceUrl().length > 0; + cartCheckoutButton.disabled = sourceMode() !== "build" || !hasSrc; + manualCheckoutButton.disabled = sourceMode() !== "manual" || !hasSrc; + cartCheckoutHint.hidden = cartCheckoutButton.disabled === false; + manualCheckoutHint.hidden = manualCheckoutButton.disabled === false; } -function refreshBuyButton(src: FormDataEntryValue | null): void { - const hasSrc = typeof src === "string" && src.length > 0; - buyNowButton.disabled = !hasSrc; - buyHint.style.display = hasSrc ? "none" : ""; +function clearGeneratedCart(): void { + generatedCartUrl = ""; + generatedSrcLink.removeAttribute("href"); + generatedSrcLink.textContent = "Add products to derive a cart permalink"; + generatedSrcLink.dataset["empty"] = "true"; } +function updateDerivedCartPermalink(): void { + const lines = selectedCartLines(); + if (lines.length === 0) { + clearGeneratedCart(); + syncAttributes(); + return; + } + + try { + generatedCartUrl = buildCartPermalink(storefrontInput.value, lines); + generatedSrcLink.href = generatedCartUrl; + generatedSrcLink.textContent = generatedCartUrl; + generatedSrcLink.dataset["empty"] = "false"; + } catch { + clearGeneratedCart(); + } + + syncAttributes(); +} + +function resetCart(): void { + cartLines = []; + clearGeneratedCart(); +} + +function resetLoadedProducts(): void { + resetCart(); + variants = []; + renderProducts(); + refreshBuildState(); +} + +function cancelScheduledProductLoad(): void { + if (productLoadTimer !== undefined) { + window.clearTimeout(productLoadTimer); + productLoadTimer = undefined; + } + productLoadRequestId += 1; +} + +function scheduleProductLoad(): void { + cancelScheduledProductLoad(); + resetLoadedProducts(); + + const domain = normalizeStorefrontDomain(storefrontInput.value); + writeStorage(STORAGE_KEYS.storefrontDomain, domain); + + if (!domain) { + loadState.textContent = "Waiting for domain"; + showCartStatus("Enter a storefront domain to load products automatically."); + return; + } + + if (!isLikelyStorefrontDomain(domain)) { + loadState.textContent = "Waiting for domain"; + showCartStatus("Keep typing a full storefront domain, for example your-store.myshopify.com."); + return; + } + + const requestId = productLoadRequestId; + loadState.textContent = "Loading soon"; + showCartStatus(`Waiting to load products from https://${domain}/products.json...`); + productLoadTimer = window.setTimeout(() => { + void loadProducts(domain, requestId); + }, PRODUCT_LOAD_DEBOUNCE_MS); +} + +function productQuantity(variantId: string): number { + return cartLines.find((line) => line.variantId === variantId)?.quantity ?? 0; +} + +function renderProducts(): void { + productList.replaceChildren(); + productEmpty.style.display = variants.length > 0 ? "none" : ""; + + for (const variant of variants) { + const quantity = productQuantity(variant.id); + const item = document.createElement("li"); + item.className = "product-card"; + item.dataset["variantId"] = variant.id; + + const image = document.createElement("div"); + image.className = "product-image"; + if (variant.imageUrl) { + const img = document.createElement("img"); + img.src = variant.imageUrl; + img.alt = ""; + image.append(img); + } else { + image.textContent = "📦"; + } + + const details = document.createElement("div"); + details.className = "product-info"; + + const vendor = document.createElement("p"); + vendor.className = "product-vendor"; + vendor.textContent = variant.vendor || "Storefront product"; + details.append(vendor); + + const title = document.createElement("h3"); + title.className = "product-title"; + title.textContent = variant.title; + details.append(title); + + const meta = document.createElement("p"); + meta.className = "product-meta"; + meta.textContent = `Variant ID: ${variant.id}`; + details.append(meta); + + const price = document.createElement("p"); + price.className = "product-price"; + price.textContent = variant.price ? `$${variant.price}` : "—"; + details.append(price); + + const actions = document.createElement("div"); + actions.className = "product-card-actions"; + + if (!variant.available) { + const unavailable = document.createElement("span"); + unavailable.className = "unavailable"; + unavailable.textContent = "Unavailable"; + actions.append(unavailable); + } else if (quantity > 0) { + const controls = document.createElement("div"); + controls.className = "quantity-controls"; + controls.append(quantityButton("−", "decrement", variant.title)); + + const input = document.createElement("input"); + input.type = "number"; + input.className = "cart-line-quantity"; + input.min = "1"; + input.max = "999"; + input.value = String(quantity); + input.setAttribute("aria-label", `Quantity for ${variant.title}`); + controls.append(input); + + controls.append(quantityButton("+", "increment", variant.title)); + actions.append(controls); + } else { + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.className = "secondary-action"; + addButton.dataset["cartAction"] = "add"; + addButton.textContent = "Add to cart"; + actions.append(addButton); + } + + details.append(actions); + item.append(image, details); + productList.append(item); + } +} + +function quantityButton(label: string, action: string, title: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "quantity-button"; + button.dataset["cartAction"] = action; + button.textContent = label; + button.setAttribute("aria-label", `${label === "+" ? "Increase" : "Decrease"} ${title}`); + return button; +} + +function renderCartSummary(): void { + const lines = selectedCartLines(); + const totalQuantity = cartLineTotalQuantity(lines); + + cartCount.textContent = totalQuantity === 1 ? "1 item" : `${totalQuantity} items`; + selectedLines.replaceChildren(); + + if (lines.length === 0) { + cartSummaryText.textContent = "Add products to start a multi-item cart."; + return; + } + + cartSummaryText.textContent = `${lines.length} ${lines.length === 1 ? "variant" : "variants"}, ${totalQuantity} total`; + + for (const line of lines) { + const variant = variantForLine(line); + const item = document.createElement("li"); + item.className = "cart-line"; + item.dataset["variantId"] = line.variantId; + + const image = document.createElement("div"); + image.className = "cart-line-image"; + if (variant?.imageUrl) { + const img = document.createElement("img"); + img.src = variant.imageUrl; + img.alt = ""; + image.append(img); + } else { + image.textContent = "📦"; + } + item.append(image); + + const details = document.createElement("div"); + details.className = "cart-line-details"; + + const name = document.createElement("strong"); + name.className = "cart-line-title"; + name.textContent = variant?.title ?? line.variantId; + details.append(name); + + const meta = document.createElement("span"); + meta.className = "cart-line-meta"; + meta.textContent = variant?.price ? `$${variant.price}` : `Variant ID: ${line.variantId}`; + details.append(meta); + item.append(details); + + const controls = document.createElement("div"); + controls.className = "cart-line-controls"; + controls.append(quantityButton("−", "decrement", variant?.title ?? line.variantId)); + + const quantityInput = document.createElement("input"); + quantityInput.type = "number"; + quantityInput.className = "cart-line-summary-quantity"; + quantityInput.min = "1"; + quantityInput.max = "999"; + quantityInput.value = String(line.quantity); + quantityInput.setAttribute("aria-label", `Quantity for ${variant?.title ?? line.variantId}`); + controls.append(quantityInput); + + controls.append(quantityButton("+", "increment", variant?.title ?? line.variantId)); + item.append(controls); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "remove-line-button"; + removeButton.dataset["cartAction"] = "remove"; + removeButton.textContent = "×"; + removeButton.setAttribute("aria-label", `Remove ${variant?.title ?? line.variantId}`); + item.append(removeButton); + + selectedLines.append(item); + } +} + +function refreshBuildState(): void { + renderCartSummary(); + updateDerivedCartPermalink(); +} + +async function loadProducts(domain: string, requestId: number): Promise { + if (requestId !== productLoadRequestId) return; + + storefrontInput.value = domain; + productLoadTimer = undefined; + loadState.textContent = "Loading"; + showCartStatus(`Loading products from https://${domain}/products.json...`); + + try { + const loadedVariants = await fetchProductVariants(domain); + if (requestId !== productLoadRequestId) return; + + variants = loadedVariants; + loadState.textContent = `${variants.length} loaded`; + showCartStatus("Products loaded.", "success"); + } catch (error) { + if (requestId !== productLoadRequestId) return; + + loadState.textContent = "Load failed"; + const message = error instanceof Error ? error.message : "Products could not be loaded."; + showCartStatus(message, "error"); + } finally { + if (requestId === productLoadRequestId) { + renderProducts(); + refreshBuildState(); + } + } +} + +function updateCartLine(variantId: string, quantity: unknown): void { + cartLines = upsertCartLine(cartLines, variantId, quantity); + renderProducts(); + refreshBuildState(); +} + +function openCheckout(): void { + checkout.open(); +} + +function restoreSettings(): void { + const storedMode = readStorage(STORAGE_KEYS.sourceMode); + const mode = storedMode === "manual" ? "manual" : "build"; + const modeInput = form.querySelector( + `input[name='source-mode'][value='${mode}']`, + ); + if (modeInput) modeInput.checked = true; + + storefrontInput.value = readStorage(STORAGE_KEYS.storefrontDomain); + + const storedTarget = readStorage(STORAGE_KEYS.target); + if (storedTarget) checkoutTarget.value = storedTarget; + + debugToggle.checked = readStorage(STORAGE_KEYS.debug) === "1"; + setSettingsCollapsed(readStorage(STORAGE_KEYS.settingsCollapsed) === "1"); +} + +const layout = $("#layout"); +const form = $("#options-form"); +const eventLog = $("#event-log"); +const clearLogButton = $("#clear-log"); +const settingsToggle = $("#toggle-settings"); +const storefrontSourceFields = $("#storefront-source-fields"); +const buildWorkspace = $("#build-workspace"); +const manualWorkspace = $("#manual-workspace"); +const storefrontInput = $("#storefront-domain"); +const checkoutTarget = $("#checkout-target"); +const debugToggle = $("#debug-toggle"); +const manualSrcInput = $("#manual-src"); +const manualCheckoutButton = $("#manual-checkout"); +const manualCheckoutHint = $("#manual-checkout-hint"); +const productEmpty = $("#product-empty"); +const productList = $("#product-list"); +const cartStatus = $("#cart-status"); +const cartCount = $("#cart-count"); +const cartSummaryText = $("#cart-summary-text"); +const selectedLines = $("#selected-lines"); +const generatedSrcLink = $("#generated-src"); +const cartCheckoutButton = $("#cart-checkout"); +const cartCheckoutHint = $("#cart-checkout-hint"); +const loadState = $("#load-state"); + +const stateNodes = { + checkout: $("#state-checkout"), + error: $("#state-error"), + target: $("#state-target"), + debug: $("#state-debug"), +}; + +const checkout = document.createElement("shopify-checkout") as ShopifyCheckout; +document.body.append(checkout); + +const checkoutEl: HTMLElement = checkout; +let variants: ProductVariantOption[] = []; +let cartLines: CartLine[] = []; +let generatedCartUrl = ""; +let productLoadTimer: number | undefined; +let productLoadRequestId = 0; + +restoreSettings(); + +storefrontInput.addEventListener("input", scheduleProductLoad); + +settingsToggle.addEventListener("click", () => { + setSettingsCollapsed(!layout.classList.contains("settings-collapsed")); +}); + +form.addEventListener("submit", (event) => { + event.preventDefault(); +}); + form.addEventListener("input", syncAttributes); -form.addEventListener("change", syncAttributes); -syncAttributes(); +form.addEventListener("change", (event) => { + syncAttributes(); + + const target = event.target; + if (target instanceof HTMLInputElement && target.name === "source-mode") { + if (sourceMode() === "manual") { + cancelScheduledProductLoad(); + return; + } -// ───── Methods (Buy now + manual debug buttons) ─────────────────────────── + if (variants.length === 0 && normalizeStorefrontDomain(storefrontInput.value)) { + scheduleProductLoad(); + } + } +}); -document.addEventListener("click", (event) => { +cartCheckoutButton.addEventListener("click", openCheckout); +manualCheckoutButton.addEventListener("click", openCheckout); + +productList.addEventListener("click", (event) => { const target = event.target; - if (!(target instanceof Element)) return; - const button = target.closest("button[data-method]"); - if (!button || button.disabled) return; + if (!(target instanceof HTMLElement)) return; + + const button = target.closest("button[data-cart-action]"); + if (!button) return; - switch (button.dataset["method"]) { - case "open": - checkout.open(); + const productCard = button.closest(".product-card"); + const variantId = productCard?.dataset["variantId"]; + if (!variantId) return; + + const currentQuantity = productQuantity(variantId); + switch (button.dataset["cartAction"]) { + case "add": + updateCartLine(variantId, 1); break; - case "close": - checkout.close(); + case "increment": + updateCartLine(variantId, currentQuantity + 1); break; - case "focus": - checkout.focus(); + case "decrement": + updateCartLine(variantId, currentQuantity - 1); break; default: break; } }); -// ───── Variant swatches (visual only) ───────────────────────────────────── +productList.addEventListener("change", (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement) || !target.classList.contains("cart-line-quantity")) { + return; + } -const swatches = document.querySelectorAll(".swatch"); -for (const swatch of swatches) { - swatch.addEventListener("click", () => { - for (const other of swatches) { - other.setAttribute("aria-pressed", "false"); - } - swatch.setAttribute("aria-pressed", "true"); - }); -} + const productCard = target.closest(".product-card"); + const variantId = productCard?.dataset["variantId"]; + if (!variantId) return; + + const quantity = normalizeQuantity(target.value); + target.value = String(quantity); + updateCartLine(variantId, quantity); +}); + +selectedLines.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + const button = target.closest("button[data-cart-action]"); + if (!button) return; + + const cartLine = button.closest(".cart-line"); + const variantId = cartLine?.dataset["variantId"]; + if (!variantId) return; -// ───── Event log ────────────────────────────────────────────────────────── + const currentQuantity = productQuantity(variantId); + switch (button.dataset["cartAction"]) { + case "increment": + updateCartLine(variantId, currentQuantity + 1); + break; + case "decrement": + updateCartLine(variantId, currentQuantity - 1); + break; + case "remove": + updateCartLine(variantId, 0); + break; + default: + break; + } +}); + +selectedLines.addEventListener("change", (event) => { + const target = event.target; + if ( + !(target instanceof HTMLInputElement) || + !target.classList.contains("cart-line-summary-quantity") + ) { + return; + } + + const cartLine = target.closest(".cart-line"); + const variantId = cartLine?.dataset["variantId"]; + if (!variantId) return; + + const quantity = normalizeQuantity(target.value); + target.value = String(quantity); + updateCartLine(variantId, quantity); +}); -/** Dispatched by `ShopifyCheckout` (see `src/checkout.ts`). */ const EVENT_TYPES = [ "ec.start", "ec.complete", @@ -188,3 +675,10 @@ function appendLog(type: string): void { li.append(header, pre); eventLog.prepend(li); } + +renderProducts(); +refreshBuildState(); +syncAttributes(); +if (sourceMode() === "build" && storefrontInput.value) { + scheduleProductLoad(); +} diff --git a/platforms/web/sample/styles.css b/platforms/web/sample/styles.css index b847da344..f99ba4058 100644 --- a/platforms/web/sample/styles.css +++ b/platforms/web/sample/styles.css @@ -2,15 +2,19 @@ color-scheme: light dark; --bg: #fafaf9; --surface: #ffffff; + --surface-subdued: #f8fafc; --border: #e5e7eb; --text: #0f172a; --text-muted: #64748b; --accent: #5a31f4; --accent-text: #ffffff; + --success: #047857; + --success-bg: #ecfdf5; + --error: #b91c1c; + --error-bg: #fef2f2; + --info-bg: #eff6ff; + --info-text: #1e40af; --code-bg: #f1f5f9; - --warn-bg: #fef3c7; - --warn-text: #78350f; - --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05); --shadow-md: 0 1px 3px rgba(15, 23, 42, 0.06), 0 4px 12px rgba(15, 23, 42, 0.04); --radius: 8px; --gap: 16px; @@ -23,34 +27,56 @@ :root { --bg: #0f172a; --surface: #1e293b; + --surface-subdued: #0f172a; --border: #334155; --text: #f1f5f9; --text-muted: #94a3b8; + --success: #34d399; + --success-bg: #052e1a; + --error: #fca5a5; + --error-bg: #450a0a; + --info-bg: #172554; + --info-text: #bfdbfe; --code-bg: #0b1220; - --warn-bg: #422006; - --warn-text: #fcd34d; - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-md: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.25); } } +html { + height: 100%; + overflow: hidden; +} + * { box-sizing: border-box; } +[hidden] { + display: none !important; +} + body { margin: 0; - min-height: 100dvh; + min-width: 0; + height: 100%; + max-height: 100dvh; display: flex; flex-direction: column; + overflow: hidden; font-family: var(--font-sans); background: var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; } -/* ───── Topbar ────────────────────────────────────────────── */ +button, +input, +select { + font: inherit; +} + .topbar { + flex: none; display: flex; align-items: center; justify-content: space-between; @@ -72,43 +98,48 @@ body { font-size: 13px; } -.topbar .status { - margin: 0; - padding: 8px 12px; - font-size: 12px; - background: var(--warn-bg); - color: var(--warn-text); - border-radius: 6px; - max-width: 480px; - line-height: 1.45; -} - -.topbar .status code { +code { font-family: var(--font-mono); background: rgba(0, 0, 0, 0.08); padding: 1px 4px; border-radius: 3px; } -/* ───── Layout ────────────────────────────────────────────── */ main { + width: 100%; + min-width: 0; + height: 0; display: grid; - grid-template-columns: 300px minmax(360px, 1fr) 380px; + grid-template-columns: minmax(260px, 340px) minmax(0, 1fr) minmax(320px, 560px); gap: var(--gap); padding: var(--gap); - flex: 1; + flex: 1 1 auto; min-height: 0; + overflow: hidden; } -@media (max-width: 1100px) { - main { +main.settings-collapsed { + grid-template-columns: 64px minmax(0, 1fr) minmax(340px, 640px); +} + +@media (max-width: 1180px) { + main, + main.settings-collapsed { grid-template-columns: 1fr; } + + .topbar { + align-items: flex-start; + flex-direction: column; + } } .panel { + min-width: 0; + height: 100%; display: flex; flex-direction: column; + overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); @@ -126,9 +157,11 @@ main { } .panel-header { + flex: none; display: flex; align-items: center; justify-content: space-between; + gap: 12px; margin-bottom: 12px; } @@ -136,19 +169,59 @@ main { margin: 0; } +.settings-header { + margin-bottom: 14px; +} + +.panel-toggle { + padding: 5px 8px; + color: var(--text-muted); + background: var(--surface-subdued); +} + +.settings-collapsed .settings-panel { + align-items: center; + padding: 12px 8px; +} + +.settings-collapsed .settings-header { + flex: 1; + flex-direction: column; + justify-content: flex-start; + margin: 0; +} + +.settings-collapsed .settings-header h2 { + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.settings-collapsed .panel-toggle { + writing-mode: vertical-rl; +} + +.settings-collapsed #options-form { + display: none; +} + +.panel-header p { + margin: 2px 0 0; +} + .muted { color: var(--text-muted); font-size: 13px; } -/* ───── Form ──────────────────────────────────────────────── */ #options-form { display: flex; flex-direction: column; - gap: 12px; + gap: 14px; } -#options-form label { +#options-form label, +.manual-card label, +.computed-source { display: flex; flex-direction: column; gap: 4px; @@ -161,11 +234,14 @@ main { align-items: center; } -#options-form input[type="text"], -#options-form input[type="url"], -#options-form select { +input[type="text"], +input[type="url"], +input[type="number"], +select { + width: 100%; + min-width: 0; + max-width: 100%; padding: 8px 10px; - font: inherit; font-size: 13px; color: var(--text); background: var(--bg); @@ -174,12 +250,29 @@ main { outline: none; } -#options-form input:focus, -#options-form select:focus { +input:focus, +select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); } +input[type="number"] { + appearance: textfield; + -moz-appearance: textfield; +} + +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { + margin: 0; + appearance: none; + -webkit-appearance: none; +} + +input[readonly] { + color: var(--text-muted); + background: var(--surface-subdued); +} + fieldset { margin: 0; padding: 12px; @@ -195,15 +288,51 @@ legend { color: var(--text-muted); } -.button-row { +.source-group { + background: var(--surface-subdued); +} + +.source-switcher, +.source-fields { display: flex; - flex-wrap: wrap; - gap: 6px; + flex-direction: column; + gap: 10px; +} + +.radio-card { + flex-direction: row !important; + align-items: flex-start; + gap: 10px !important; + padding: 10px; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; +} + +.radio-card:has(input:checked) { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.radio-card span { + display: flex; + flex-direction: column; + gap: 2px; +} + +.radio-card small, +.field-help { + color: var(--text-muted); + font-size: 12px; + line-height: 1.45; +} + +.field-help { + margin: -4px 0 0; } button { padding: 7px 12px; - font: inherit; font-size: 13px; color: var(--text); background: var(--surface); @@ -228,25 +357,332 @@ button:disabled { opacity: 0.55; } -/* ───── Storefront / product card ─────────────────────────── */ +.primary-action, +.secondary-action { + font-weight: 600; +} + +.primary-action { + background: var(--accent); + color: var(--accent-text); + border-color: var(--accent); +} + +.primary-action:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 88%, black); +} + +.secondary-action { + background: var(--surface); +} + +.storefront, +.workspace { + min-width: 0; + gap: 14px; +} + .storefront { + max-height: 100%; + overflow: hidden; +} + +.workspace { + display: flex; + flex: 1 1 auto; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.cart-banner { + min-width: 0; + flex: none; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border: 1px solid var(--border); + border-radius: 14px; + background: color-mix(in srgb, var(--surface) 94%, var(--accent)); + box-shadow: var(--shadow-md); +} + +.cart-banner-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.cart-banner h2, +.cart-banner p { + margin: 0; +} + +.pill, +.status-pill { + display: inline-flex; align-items: center; - justify-content: flex-start; + white-space: nowrap; + padding: 4px 8px; + border-radius: 999px; + font-size: 12px; + color: var(--success); + background: var(--success-bg); } -.storefront > h2 { - align-self: stretch; +.status-pill { + color: var(--info-text); + background: var(--info-bg); } -.product-card { +.cart-lines { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + color: var(--text-muted); + font-size: 13px; +} + +.cart-lines:empty { + display: none; +} + +.cart-line { + min-width: 0; + display: grid; + grid-template-columns: 44px minmax(0, 1fr) max-content 34px; + align-items: center; + gap: 10px; + padding: 8px; + border-radius: 10px; + background: var(--surface-subdued); +} + +.cart-line-image { + width: 44px; + height: 44px; + display: grid; + place-items: center; + overflow: hidden; + border-radius: 8px; + background: var(--code-bg); + font-size: 20px; +} + +.cart-line-image img { width: 100%; - max-width: 380px; + height: 100%; + object-fit: cover; +} + +.cart-line-details { + min-width: 0; display: flex; flex-direction: column; - background: var(--surface); + gap: 2px; +} + +.cart-line-title, +.cart-line-meta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cart-line-title { + color: var(--text); + font-size: 13px; +} + +.cart-line-meta { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 12px; +} + +.cart-line-controls { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.cart-line-controls .quantity-button { + width: 30px; + min-width: 30px; + height: 30px; + padding: 0; + font-size: 13px; + line-height: 1; +} + +.cart-line-summary-quantity { + width: 52px !important; + min-width: 52px !important; + padding: 5px 6px !important; + text-align: center; +} + +.remove-line-button { + width: 32px; + min-width: 32px; + height: 32px; + padding: 0; + color: var(--error); + border-color: color-mix(in srgb, var(--error) 35%, var(--border)); + font-size: 18px; + line-height: 1; +} + +.remove-line-button:hover:not(:disabled) { + background: var(--error-bg); +} + +.computed-source { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + font-weight: 500; +} + +.permalink-link { + min-width: 0; + display: block; + padding: 10px 12px; + overflow-wrap: anywhere; border: 1px solid var(--border); + border-radius: 8px; + color: var(--accent); + background: var(--surface); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.45; + text-decoration: none; +} + +.permalink-link:hover[href] { + border-color: var(--accent); + text-decoration: underline; +} + +.permalink-link[data-empty="true"] { + color: var(--text-muted); + font-family: var(--font-sans); +} + +.cart-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cart-actions .primary-action, +.manual-card .primary-action { + width: 100%; + padding: 11px 16px; + border-radius: 8px; +} + +.notice { + flex: none; + margin: 0; + padding: 10px 12px; + border-radius: 8px; + color: var(--info-text); + background: var(--info-bg); + font-size: 13px; + line-height: 1.45; +} + +.notice[data-tone="success"] { + color: var(--success); + background: var(--success-bg); +} + +.notice[data-tone="error"] { + color: var(--error); + background: var(--error-bg); +} + +.empty-state { + flex: none; + display: grid; + place-items: center; + gap: 8px; + padding: 44px 24px; + border: 1px dashed var(--border); border-radius: 12px; + color: var(--text-muted); + text-align: center; +} + +.empty-state div { + font-size: 42px; +} + +.empty-state h3, +.empty-state p { + margin: 0; +} + +.empty-state h3 { + color: var(--text); + font-size: 16px; +} + +.empty-state p { + max-width: 520px; + font-size: 13px; + line-height: 1.5; +} + +.product-scroll { + min-width: 0; + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + scrollbar-gutter: stable; + overscroll-behavior: contain; +} + +.product-scroll::-webkit-scrollbar { + width: 10px; +} + +.product-scroll::-webkit-scrollbar-thumb { + border: 2px solid var(--surface); + border-radius: 999px; + background: color-mix(in srgb, var(--text-muted) 45%, transparent); +} + +.product-grid { + min-width: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)); + align-content: start; + gap: 14px; + list-style: none; + margin: 0; + padding: 0 4px 4px 0; +} + +.product-card { + min-width: 0; + max-width: 100%; + display: flex; + flex-direction: column; overflow: hidden; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); box-shadow: var(--shadow-md); } @@ -254,157 +690,145 @@ button:disabled { aspect-ratio: 4 / 3; display: grid; place-items: center; + overflow: hidden; background: radial-gradient(circle at 28% 32%, #f9a8d4 0%, transparent 55%), radial-gradient(circle at 72% 68%, #a5b4fc 0%, transparent 55%), linear-gradient(135deg, #fce7f3 0%, #ede9fe 50%, #dbeafe 100%); + font-size: 48px; } -@media (prefers-color-scheme: dark) { - .product-image { - background: - radial-gradient(circle at 28% 32%, #831843 0%, transparent 55%), - radial-gradient(circle at 72% 68%, #312e81 0%, transparent 55%), - linear-gradient(135deg, #4c1d95 0%, #1e1b4b 50%, #0c4a6e 100%); - } -} - -.product-emoji { - font-size: 80px; - line-height: 1; - filter: drop-shadow(0 4px 12px rgba(15, 23, 42, 0.18)); +.product-image img { + width: 100%; + height: 100%; + object-fit: contain; } .product-info { - padding: 18px 20px 20px; display: flex; + flex: 1; flex-direction: column; - gap: 6px; + gap: 7px; + padding: 14px; } -.product-vendor { +.product-vendor, +.product-title, +.product-meta, +.product-price { margin: 0; +} + +.product-vendor { + color: var(--text-muted); font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; - color: var(--text-muted); } .product-title { - margin: 0; - font-size: 18px; - font-weight: 600; - letter-spacing: -0.01em; + font-size: 16px; + font-weight: 700; + line-height: 1.25; } -.product-price { - margin: 2px 0 0; - font-size: 15px; - font-weight: 500; +.product-meta { + color: var(--text-muted); + font-size: 12px; + line-height: 1.35; + word-break: break-word; } -.product-description { - margin: 8px 0 4px; - font-size: 13px; - line-height: 1.5; +.product-price { + margin-top: auto; + font-family: var(--font-mono); color: var(--text-muted); + font-size: 14px; } -.product-variants { +.product-card-actions { display: flex; + align-items: center; gap: 8px; - margin: 6px 0 8px; -} - -.swatch { - width: 24px; - height: 24px; - padding: 0; - border-radius: 50%; - border: 2px solid transparent; - cursor: pointer; - transition: transform 0.15s; - position: relative; + padding-top: 6px; } -.swatch::after { - content: ""; - position: absolute; - inset: -4px; - border-radius: 50%; - border: 2px solid transparent; - transition: border-color 0.15s; +.product-card-actions button { + width: 100%; } -.swatch:hover { - transform: scale(1.08); +.unavailable { + color: var(--error); + font-size: 13px; + font-weight: 700; } -.swatch[aria-pressed="true"]::after { - border-color: var(--accent); +.quantity-controls { + display: grid; + grid-template-columns: 36px minmax(58px, 1fr) 36px; + gap: 6px; + width: 100%; } -.swatch--clay { - background: #c2410c; -} -.swatch--moss { - background: #4d7c0f; -} -.swatch--sand { - background: #d6d3d1; +.quantity-button { + padding: 8px; + font-size: 16px; + font-weight: 700; } -.product-actions { - display: flex; - gap: 8px; - margin-top: 10px; +.cart-line-quantity { + min-width: 0; + text-align: center; } -.product-actions button { - flex: 1; - padding: 11px 16px; - font-size: 14px; - font-weight: 600; - border-radius: 8px; - transition: - background 0.15s, - transform 0.05s, - opacity 0.15s; +.manual-checkout { + justify-content: flex-start; } -.product-actions .primary { - background: var(--accent); - color: var(--accent-text); - border-color: var(--accent); +.manual-card { + width: min(560px, 100%); + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; + margin: 0 auto; + padding: 20px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + box-shadow: var(--shadow-md); } -.product-actions .primary:hover:not(:disabled) { - background: color-mix(in srgb, var(--accent) 88%, black); +.runtime-panel { + overflow-y: auto; + scrollbar-gutter: stable; + overscroll-behavior: contain; } -.product-actions .secondary { - background: var(--surface); - color: var(--text); - border-color: var(--border); +.runtime-panel::-webkit-scrollbar { + width: 10px; } -#buy-hint { - margin: 8px 0 0; - font-size: 12px; - text-align: center; +.runtime-panel::-webkit-scrollbar-thumb { + border: 2px solid var(--surface); + border-radius: 999px; + background: color-mix(in srgb, var(--text-muted) 45%, transparent); } -/* ───── Component state ───────────────────────────────────── */ #component-state { width: 100%; - max-width: 380px; - margin-top: 16px; + flex: none; + margin-bottom: 16px; padding: 10px 12px; background: var(--code-bg); border-radius: 6px; } +.events-header { + margin-top: 0; +} + #component-state summary { cursor: pointer; font-size: 11px; @@ -445,14 +869,11 @@ button:disabled { white-space: pre-wrap; } -/* ───── Events panel ──────────────────────────────────────── */ #event-log { list-style: none; margin: 0; padding: 0; - flex: 1; - min-height: 0; - overflow-y: auto; + flex: none; display: flex; flex-direction: column; gap: 8px; @@ -460,6 +881,7 @@ button:disabled { #event-empty { display: none; + overflow-wrap: anywhere; } #event-log:empty + #event-empty { @@ -506,3 +928,26 @@ button:disabled { white-space: pre-wrap; word-break: break-word; } + +@media (max-width: 720px) { + .cart-line { + grid-template-columns: 44px minmax(0, 1fr) 34px; + } + + .cart-line-controls { + grid-column: 2 / -1; + justify-self: start; + } +} + +@media (max-width: 640px) { + .cart-banner-header, + .panel-header { + align-items: flex-start; + flex-direction: column; + } + + .product-grid { + grid-template-columns: 1fr; + } +} diff --git a/platforms/web/vite.config.ts b/platforms/web/vite.config.ts index f07d1df09..da6d7e127 100644 --- a/platforms/web/vite.config.ts +++ b/platforms/web/vite.config.ts @@ -47,7 +47,7 @@ export default defineConfig({ }, }, globals: true, - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'sample/**/*.test.ts'], coverage: { provider: 'v8', reporter: ['text', 'json-summary', 'html', 'lcov'], From 8bb592e306148aa4851679eb754b36b0871eb041 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 14:52:02 +0100 Subject: [PATCH 2/5] Refine web sample storefront prompts Assisted-By: devx/96a590ff-685a-49ce-8199-0810f0ac2379 --- platforms/web/sample/index.html | 10 +--------- platforms/web/sample/main.ts | 10 ++++++++++ platforms/web/sample/styles.css | 11 +++++++++++ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/platforms/web/sample/index.html b/platforms/web/sample/index.html index 9ab7d29a9..37b57dffc 100644 --- a/platforms/web/sample/index.html +++ b/platforms/web/sample/index.html @@ -60,10 +60,6 @@

    Settings

    inputmode="url" /> -

    - Saved in this browser. Products auto-load from /products.json after you - stop typing. -

    @@ -84,7 +80,7 @@

    Settings

    -
    +
    @@ -120,10 +116,6 @@

    Cart

    -
    -

    Demo storefront

    -

    Products loaded from the storefront domain in Settings.

    -
    Waiting for domain
    diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts index 51a08874c..a2936270c 100644 --- a/platforms/web/sample/main.ts +++ b/platforms/web/sample/main.ts @@ -138,6 +138,14 @@ function updateSourceVisibility(): void { storefrontSourceFields.hidden = isManual; buildWorkspace.hidden = isManual; manualWorkspace.hidden = !isManual; + updateStorefrontValidation(); +} + +function updateStorefrontValidation(): void { + const isInvalid = + sourceMode() === "build" && normalizeStorefrontDomain(storefrontInput.value) === ""; + storefrontSourceFields.dataset["invalid"] = String(isInvalid); + storefrontInput.setAttribute("aria-invalid", String(isInvalid)); } function refreshCheckoutButtons(): void { @@ -202,6 +210,8 @@ function scheduleProductLoad(): void { const domain = normalizeStorefrontDomain(storefrontInput.value); writeStorage(STORAGE_KEYS.storefrontDomain, domain); + updateStorefrontValidation(); + if (!domain) { loadState.textContent = "Waiting for domain"; showCartStatus("Enter a storefront domain to load products automatically."); diff --git a/platforms/web/sample/styles.css b/platforms/web/sample/styles.css index f99ba4058..2624aa2dd 100644 --- a/platforms/web/sample/styles.css +++ b/platforms/web/sample/styles.css @@ -256,6 +256,12 @@ select:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); } +input[aria-invalid="true"] { + border-color: var(--error); + background: color-mix(in srgb, var(--error-bg) 70%, var(--bg)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--error) 14%, transparent); +} + input[type="number"] { appearance: textfield; -moz-appearance: textfield; @@ -292,6 +298,11 @@ legend { background: var(--surface-subdued); } +.source-group[data-invalid="true"] { + border-color: color-mix(in srgb, var(--error) 55%, var(--border)); + background: color-mix(in srgb, var(--error-bg) 45%, var(--surface-subdued)); +} + .source-switcher, .source-fields { display: flex; From b0c66034e17cd695861e0946838d12f283234deb Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 16:10:32 +0100 Subject: [PATCH 3/5] Limit storefront validation styling to input Assisted-By: devx/96a590ff-685a-49ce-8199-0810f0ac2379 --- platforms/web/sample/main.ts | 1 - platforms/web/sample/styles.css | 5 ----- 2 files changed, 6 deletions(-) diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts index a2936270c..d3255e994 100644 --- a/platforms/web/sample/main.ts +++ b/platforms/web/sample/main.ts @@ -144,7 +144,6 @@ function updateSourceVisibility(): void { function updateStorefrontValidation(): void { const isInvalid = sourceMode() === "build" && normalizeStorefrontDomain(storefrontInput.value) === ""; - storefrontSourceFields.dataset["invalid"] = String(isInvalid); storefrontInput.setAttribute("aria-invalid", String(isInvalid)); } diff --git a/platforms/web/sample/styles.css b/platforms/web/sample/styles.css index 2624aa2dd..116332749 100644 --- a/platforms/web/sample/styles.css +++ b/platforms/web/sample/styles.css @@ -298,11 +298,6 @@ legend { background: var(--surface-subdued); } -.source-group[data-invalid="true"] { - border-color: color-mix(in srgb, var(--error) 55%, var(--border)); - background: color-mix(in srgb, var(--error-bg) 45%, var(--surface-subdued)); -} - .source-switcher, .source-fields { display: flex; From 2b6338c0508409466bcd1b9854af5807be45b58a Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 17:18:09 +0100 Subject: [PATCH 4/5] Simplify empty storefront sample state Assisted-By: devx/96a590ff-685a-49ce-8199-0810f0ac2379 --- platforms/web/sample/main.ts | 17 +++++++++-------- platforms/web/sample/styles.css | 11 +++++++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts index d3255e994..58ddd91b6 100644 --- a/platforms/web/sample/main.ts +++ b/platforms/web/sample/main.ts @@ -147,6 +147,11 @@ function updateStorefrontValidation(): void { storefrontInput.setAttribute("aria-invalid", String(isInvalid)); } +function updateBuildWorkspaceVisibility(): void { + const domain = normalizeStorefrontDomain(storefrontInput.value); + buildWorkspace.dataset["ready"] = String(variants.length > 0 || isLikelyStorefrontDomain(domain)); +} + function refreshCheckoutButtons(): void { const hasSrc = activeSourceUrl().length > 0; cartCheckoutButton.disabled = sourceMode() !== "build" || !hasSrc; @@ -192,6 +197,7 @@ function resetLoadedProducts(): void { variants = []; renderProducts(); refreshBuildState(); + updateBuildWorkspaceVisibility(); } function cancelScheduledProductLoad(): void { @@ -210,16 +216,9 @@ function scheduleProductLoad(): void { writeStorage(STORAGE_KEYS.storefrontDomain, domain); updateStorefrontValidation(); - - if (!domain) { - loadState.textContent = "Waiting for domain"; - showCartStatus("Enter a storefront domain to load products automatically."); - return; - } + updateBuildWorkspaceVisibility(); if (!isLikelyStorefrontDomain(domain)) { - loadState.textContent = "Waiting for domain"; - showCartStatus("Keep typing a full storefront domain, for example your-store.myshopify.com."); return; } @@ -238,6 +237,7 @@ function productQuantity(variantId: string): number { function renderProducts(): void { productList.replaceChildren(); productEmpty.style.display = variants.length > 0 ? "none" : ""; + updateBuildWorkspaceVisibility(); for (const variant of variants) { const quantity = productQuantity(variant.id); @@ -687,6 +687,7 @@ function appendLog(type: string): void { renderProducts(); refreshBuildState(); +updateBuildWorkspaceVisibility(); syncAttributes(); if (sourceMode() === "build" && storefrontInput.value) { scheduleProductLoad(); diff --git a/platforms/web/sample/styles.css b/platforms/web/sample/styles.css index 116332749..12d808643 100644 --- a/platforms/web/sample/styles.css +++ b/platforms/web/sample/styles.css @@ -402,6 +402,17 @@ button:disabled { overflow: hidden; } +#build-workspace[data-ready="false"] .cart-banner, +#build-workspace[data-ready="false"] .storefront-header, +#build-workspace[data-ready="false"] .notice, +#build-workspace[data-ready="false"] .product-scroll { + display: none; +} + +#build-workspace[data-ready="false"] .empty-state { + flex: 1 1 auto; +} + .cart-banner { min-width: 0; flex: none; From f14896081ac47b42912dfdd5e41781b88992d779 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 8 Jul 2026 17:31:35 +0100 Subject: [PATCH 5/5] Avoid stretching empty product state Assisted-By: devx/96a590ff-685a-49ce-8199-0810f0ac2379 --- platforms/web/sample/README.md | 2 +- platforms/web/sample/cart.test.ts | 48 +- platforms/web/sample/cart.ts | 7 +- platforms/web/sample/dom.test.ts | 71 ++ platforms/web/sample/dom.ts | 106 +++ platforms/web/sample/index.html | 8 +- platforms/web/sample/main.ts | 843 +++++--------------- platforms/web/sample/product-loader.test.ts | 145 ++++ platforms/web/sample/product-loader.ts | 95 +++ platforms/web/sample/render.test.ts | 196 +++++ platforms/web/sample/render.ts | 13 + platforms/web/sample/shell.ts | 44 + platforms/web/sample/state.test.ts | 99 +++ platforms/web/sample/state.ts | 95 +++ platforms/web/sample/storage.test.ts | 124 +++ platforms/web/sample/storage.ts | 65 ++ platforms/web/sample/styles.css | 2 +- platforms/web/sample/views/cart.ts | 97 +++ platforms/web/sample/views/log.ts | 38 + platforms/web/sample/views/products.ts | 101 +++ platforms/web/sample/views/settings.ts | 32 + 21 files changed, 1560 insertions(+), 671 deletions(-) create mode 100644 platforms/web/sample/dom.test.ts create mode 100644 platforms/web/sample/dom.ts create mode 100644 platforms/web/sample/product-loader.test.ts create mode 100644 platforms/web/sample/product-loader.ts create mode 100644 platforms/web/sample/render.test.ts create mode 100644 platforms/web/sample/render.ts create mode 100644 platforms/web/sample/shell.ts create mode 100644 platforms/web/sample/state.test.ts create mode 100644 platforms/web/sample/state.ts create mode 100644 platforms/web/sample/storage.test.ts create mode 100644 platforms/web/sample/storage.ts create mode 100644 platforms/web/sample/views/cart.ts create mode 100644 platforms/web/sample/views/log.ts create mode 100644 platforms/web/sample/views/products.ts create mode 100644 platforms/web/sample/views/settings.ts diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index 17e47c789..626ceed83 100644 --- a/platforms/web/sample/README.md +++ b/platforms/web/sample/README.md @@ -18,7 +18,7 @@ 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 300 ms debounce, the demo automatically fetches `https://your-store.myshopify.com/products.json`. +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**. diff --git a/platforms/web/sample/cart.test.ts b/platforms/web/sample/cart.test.ts index 8dfef06f1..b1c481542 100644 --- a/platforms/web/sample/cart.test.ts +++ b/platforms/web/sample/cart.test.ts @@ -14,37 +14,33 @@ import { describe("normalizeStorefrontDomain", () => { it("accepts bare storefront domains", () => { - expect(normalizeStorefrontDomain("kieran-osgood.myshopify.com")).toBe( - "kieran-osgood.myshopify.com", - ); + expect(normalizeStorefrontDomain("your-store.myshopify.com")).toBe("your-store.myshopify.com"); }); it("extracts the host from full storefront URLs", () => { - expect(normalizeStorefrontDomain("https://KIERAN-OSGOOD.myshopify.com/products/book")).toBe( - "kieran-osgood.myshopify.com", + 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("kieran-osgood.myshopify.com")).toBe(true); - expect(isLikelyStorefrontDomain("https://kieran-osgood.myshopify.com/products/book")).toBe( - true, - ); + 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("kieran-osgood")).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://kieran-osgood.myshopify.com/")).toBe( - "https://kieran-osgood.myshopify.com/products.json", + expect(buildProductsJsonUrl("https://your-store.myshopify.com/")).toBe( + "https://your-store.myshopify.com/products.json", ); }); }); @@ -120,7 +116,7 @@ describe("fetchProductVariants", () => { }), }); - await expect(fetchProductVariants("kieran-osgood.myshopify.com", fetcher)).resolves.toEqual([ + await expect(fetchProductVariants("your-store.myshopify.com", fetcher)).resolves.toEqual([ { id: "1", title: "Physical Bundle - Blue", @@ -132,14 +128,14 @@ describe("fetchProductVariants", () => { imageUrl: undefined, }, ]); - expect(fetcher).toHaveBeenCalledWith("https://kieran-osgood.myshopify.com/products.json"); + 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("kieran-osgood.myshopify.com", fetcher)).rejects.toThrow( - "Could not load products from https://kieran-osgood.myshopify.com/products.json (HTTP 404). Confirm the storefront domain is correct and products are published to the Online Store channel.", + 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.", ); }); }); @@ -168,12 +164,12 @@ describe("cartLineTotalQuantity", () => { describe("buildCartPermalink", () => { it("builds a multi-line cart permalink", () => { const lines: CartLine[] = [ - { variantId: "54888789213532", quantity: 1 }, - { variantId: "54888789246300", quantity: 2 }, + { variantId: "123", quantity: 1 }, + { variantId: "456", quantity: 2 }, ]; - expect(buildCartPermalink("https://kieran-osgood.myshopify.com", lines)).toBe( - "https://kieran-osgood.myshopify.com/cart/54888789213532:1,54888789246300:2", + expect(buildCartPermalink("https://your-store.myshopify.com", lines)).toBe( + "https://your-store.myshopify.com/cart/123:1,456:2", ); }); @@ -184,8 +180,16 @@ describe("buildCartPermalink", () => { { variantId: "2", quantity: 0 }, ]; - expect(buildCartPermalink("kieran-osgood.myshopify.com", lines)).toBe( - "https://kieran-osgood.myshopify.com/cart/1:999,2:1", + 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:"); + }); }); diff --git a/platforms/web/sample/cart.ts b/platforms/web/sample/cart.ts index 6d4157ad2..eb5a51eba 100644 --- a/platforms/web/sample/cart.ts +++ b/platforms/web/sample/cart.ts @@ -205,5 +205,10 @@ export function buildCartPermalink(storefrontDomain: string, lines: readonly Car .map((line) => `${encodeURIComponent(line.variantId)}:${line.quantity}`) .join(","); - return `https://${domain}/cart/${permalinkLines}`; + const permalink = new URL(`https://${domain}/cart/${permalinkLines}`); + if (permalink.protocol !== "https:") { + throw new Error("Cart permalink must use HTTPS."); + } + + return permalink.toString(); } diff --git a/platforms/web/sample/dom.test.ts b/platforms/web/sample/dom.test.ts new file mode 100644 index 000000000..caeb90b9e --- /dev/null +++ b/platforms/web/sample/dom.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { $, formatValue, quantityButton, setStringAttribute, timestamp } from "./dom"; + +describe("$", () => { + it("returns the matching element", () => { + document.body.innerHTML = `
    `; + expect($("#target").id).toBe("target"); + }); + + it("throws when the element is missing", () => { + document.body.innerHTML = ""; + expect(() => $("#missing")).toThrow(/element not found/); + }); +}); + +describe("formatValue", () => { + it("renders an em dash for nullish values", () => { + expect(formatValue(undefined)).toBe("—"); + expect(formatValue(null)).toBe("—"); + }); + + it("passes strings through unchanged", () => { + expect(formatValue("popup")).toBe("popup"); + }); + + it("pretty-prints other values as JSON", () => { + expect(formatValue({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2)); + expect(formatValue(true)).toBe("true"); + }); +}); + +describe("timestamp", () => { + it("formats the current time as hh:mm:ss.mmm", () => { + expect(timestamp()).toMatch(/^\d{2}:\d{2}:\d{2}\.\d{3}$/); + }); +}); + +describe("setStringAttribute", () => { + it("sets the attribute for non-empty strings", () => { + const el = document.createElement("div"); + setStringAttribute(el, "src", "https://example.test"); + expect(el.getAttribute("src")).toBe("https://example.test"); + }); + + it("removes the attribute for empty or null values", () => { + const el = document.createElement("div"); + el.setAttribute("src", "https://example.test"); + setStringAttribute(el, "src", ""); + expect(el.hasAttribute("src")).toBe(false); + + el.setAttribute("src", "https://example.test"); + setStringAttribute(el, "src", null); + expect(el.hasAttribute("src")).toBe(false); + }); +}); + +describe("quantityButton", () => { + it("builds an increment button with an accessible label", () => { + const button = quantityButton("+", "increment", "Sample product"); + expect(button.dataset["cartAction"]).toBe("increment"); + expect(button.textContent).toBe("+"); + expect(button.getAttribute("aria-label")).toBe("Increase Sample product"); + }); + + it("builds a decrement button with an accessible label", () => { + const button = quantityButton("−", "decrement", "Sample product"); + expect(button.dataset["cartAction"]).toBe("decrement"); + expect(button.getAttribute("aria-label")).toBe("Decrease Sample product"); + }); +}); diff --git a/platforms/web/sample/dom.ts b/platforms/web/sample/dom.ts new file mode 100644 index 000000000..6fcaec797 --- /dev/null +++ b/platforms/web/sample/dom.ts @@ -0,0 +1,106 @@ +export type Refs = { + layout: HTMLElement; + form: HTMLFormElement; + eventLog: HTMLUListElement; + clearLogButton: HTMLButtonElement; + settingsToggle: HTMLButtonElement; + storefrontSourceFields: HTMLFieldSetElement; + buildWorkspace: HTMLDivElement; + manualWorkspace: HTMLDivElement; + storefrontInput: HTMLInputElement; + checkoutTarget: HTMLSelectElement; + debugToggle: HTMLInputElement; + manualSrcInput: HTMLInputElement; + manualCheckoutButton: HTMLButtonElement; + productEmpty: HTMLDivElement; + productList: HTMLUListElement; + cartStatus: HTMLParagraphElement; + cartCount: HTMLSpanElement; + cartSummaryText: HTMLParagraphElement; + selectedLines: HTMLOListElement; + generatedSrcLink: HTMLAnchorElement; + cartCheckoutButton: HTMLButtonElement; + cartCheckoutHint: HTMLParagraphElement; + loadState: HTMLSpanElement; + stateCheckout: HTMLElement; + stateError: HTMLElement; + stateTarget: HTMLElement; + stateDebug: HTMLElement; +}; + +export function $(selector: string): T { + const el = document.querySelector(selector); + if (!el) { + throw new Error(`[playground] element not found: ${selector}`); + } + return el; +} + +export function queryRefs(): Refs { + return { + layout: $("#layout"), + form: $("#options-form"), + eventLog: $("#event-log"), + clearLogButton: $("#clear-log"), + settingsToggle: $("#toggle-settings"), + storefrontSourceFields: $("#storefront-source-fields"), + buildWorkspace: $("#build-workspace"), + manualWorkspace: $("#manual-workspace"), + storefrontInput: $("#storefront-domain"), + checkoutTarget: $("#checkout-target"), + debugToggle: $("#debug-toggle"), + manualSrcInput: $("#manual-src"), + manualCheckoutButton: $("#manual-checkout"), + productEmpty: $("#product-empty"), + productList: $("#product-list"), + cartStatus: $("#cart-status"), + cartCount: $("#cart-count"), + cartSummaryText: $("#cart-summary-text"), + selectedLines: $("#selected-lines"), + generatedSrcLink: $("#generated-src"), + cartCheckoutButton: $("#cart-checkout"), + cartCheckoutHint: $("#cart-checkout-hint"), + loadState: $("#load-state"), + stateCheckout: $("#state-checkout"), + stateError: $("#state-error"), + stateTarget: $("#state-target"), + stateDebug: $("#state-debug"), + }; +} + +export function formatValue(value: unknown): string { + if (value === undefined || value === null) return "—"; + if (typeof value === "string") return value; + return JSON.stringify(value, null, 2); +} + +export function timestamp(): string { + const now = new Date(); + const hh = String(now.getHours()).padStart(2, "0"); + const mm = String(now.getMinutes()).padStart(2, "0"); + const ss = String(now.getSeconds()).padStart(2, "0"); + const ms = String(now.getMilliseconds()).padStart(3, "0"); + return `${hh}:${mm}:${ss}.${ms}`; +} + +export function setStringAttribute( + el: HTMLElement, + name: string, + value: FormDataEntryValue | string | null, +): void { + if (typeof value === "string" && value.length > 0) { + el.setAttribute(name, value); + } else { + el.removeAttribute(name); + } +} + +export function quantityButton(label: string, action: string, title: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "quantity-button"; + button.dataset["cartAction"] = action; + button.textContent = label; + button.setAttribute("aria-label", `${label === "+" ? "Increase" : "Decrease"} ${title}`); + return button; +} diff --git a/platforms/web/sample/index.html b/platforms/web/sample/index.html index 37b57dffc..9a1fb420b 100644 --- a/platforms/web/sample/index.html +++ b/platforms/web/sample/index.html @@ -141,7 +141,6 @@

    No products loaded yet

    Existing checkout source

    -

    Paste a full checkout URL or cart permalink and open checkout.

    @@ -156,14 +155,13 @@

    Existing checkout source

    />

    - A storefront homepage URL is not enough. Use a full checkout URL or cart permalink. + A storefront homepage URL is not enough. Use a full checkout URL + (https://your-store.myshopify.com/checkouts/cn/…) or a cart permalink + (https://your-store.myshopify.com/cart/123:1,456:2).

    -

    - Paste a checkout source to enable checkout. -

    diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts index 58ddd91b6..fbb4e4f07 100644 --- a/platforms/web/sample/main.ts +++ b/platforms/web/sample/main.ts @@ -1,694 +1,255 @@ import "@shopify/checkout-kit"; import type { ShopifyCheckout } from "@shopify/checkout-kit"; +import { normalizeQuantity, normalizeStorefrontDomain, upsertCartLine } from "./cart"; +import { queryRefs, timestamp } from "./dom"; +import { createProductLoader } from "./product-loader"; +import { renderApp } from "./render"; import { - buildCartPermalink, - cartLineTotalQuantity, - fetchProductVariants, - isLikelyStorefrontDomain, - normalizeQuantity, - normalizeStorefrontDomain, - upsertCartLine, - type CartLine, - type ProductVariantOption, -} from "./cart"; + createInitialState, + createStore, + type ComponentSnapshot, + type SettingsSlice, + type SourceMode, +} from "./state"; +import { loadPersistedSettings, persistSettings, type PersistedSettings } from "./storage"; import "./styles.css"; -type SourceMode = "build" | "manual"; -type NoticeTone = "info" | "success" | "error"; - -const PRODUCT_LOAD_DEBOUNCE_MS = 300; -const STORAGE_KEYS = { - sourceMode: "checkout-kit:web-demo:source-mode", - storefrontDomain: "checkout-kit:web-demo:storefront-domain", - target: "checkout-kit:web-demo:target", - debug: "checkout-kit:web-demo:debug", - settingsCollapsed: "checkout-kit:web-demo:settings-collapsed", -}; - -function $(selector: string): T { - const el = document.querySelector(selector); - if (!el) { - throw new Error(`[playground] element not found: ${selector}`); - } - return el; -} - -function formatValue(value: unknown): string { - if (value === undefined || value === null) return "—"; - if (typeof value === "string") return value; - return JSON.stringify(value, null, 2); -} - -function timestamp(): string { - const now = new Date(); - const hh = String(now.getHours()).padStart(2, "0"); - const mm = String(now.getMinutes()).padStart(2, "0"); - const ss = String(now.getSeconds()).padStart(2, "0"); - const ms = String(now.getMilliseconds()).padStart(3, "0"); - return `${hh}:${mm}:${ss}.${ms}`; -} - -function readStorage(key: string): string { - try { - return localStorage.getItem(key) ?? ""; - } catch { - return ""; - } -} - -function writeStorage(key: string, value: string): void { - try { - if (value) { - localStorage.setItem(key, value); - } else { - localStorage.removeItem(key); - } - } catch { - return; - } -} - -function sourceMode(): SourceMode { - const checked = form.querySelector("input[name='source-mode']:checked"); - return checked?.value === "manual" ? "manual" : "build"; -} - -function activeSourceUrl(): string { - return sourceMode() === "manual" ? manualSrcInput.value.trim() : generatedCartUrl; -} - -function setStringAttribute( - el: HTMLElement, - name: string, - value: FormDataEntryValue | string | null, -): void { - if (typeof value === "string" && value.length > 0) { - el.setAttribute(name, value); - } else { - el.removeAttribute(name); - } -} - -function selectedCartLines(): CartLine[] { - return cartLines; -} - -function variantForLine(line: CartLine): ProductVariantOption | undefined { - return variants.find((variant) => variant.id === line.variantId); -} - -function showCartStatus(message: string, tone: NoticeTone = "info"): void { - cartStatus.hidden = tone === "success"; - cartStatus.textContent = message; - cartStatus.dataset["tone"] = tone; -} - -function setSettingsCollapsed(collapsed: boolean): void { - layout.classList.toggle("settings-collapsed", collapsed); - settingsToggle.setAttribute("aria-expanded", String(!collapsed)); - settingsToggle.textContent = collapsed ? "Show" : "Hide"; - writeStorage(STORAGE_KEYS.settingsCollapsed, collapsed ? "1" : ""); -} - -function syncAttributes(): void { - const data = new FormData(form); - const target = String(data.get("target") ?? "popup"); - - setStringAttribute(checkout, "src", activeSourceUrl()); - setStringAttribute(checkout, "target", target); - - if (data.has("debug")) { - checkout.setAttribute("debug", ""); - } else { - checkout.removeAttribute("debug"); - } +const EVENT_TYPES = [ + "ec.start", + "ec.complete", + "ec.close", + "ec.error", + "ec.line_items.change", + "ec.totals.change", + "ec.messages.change", +] as const; - writeStorage(STORAGE_KEYS.sourceMode, sourceMode()); - writeStorage(STORAGE_KEYS.target, target); - writeStorage(STORAGE_KEYS.debug, data.has("debug") ? "1" : ""); +const refs = queryRefs(); - updateSourceVisibility(); - refreshCheckoutButtons(); - refreshState(); -} +const checkout = document.createElement("shopify-checkout") as ShopifyCheckout; +document.body.append(checkout); -function updateSourceVisibility(): void { - const isManual = sourceMode() === "manual"; - storefrontSourceFields.hidden = isManual; - buildWorkspace.hidden = isManual; - manualWorkspace.hidden = !isManual; - updateStorefrontValidation(); -} +const persisted = loadPersistedSettings(); +hydrateSettingsForm(persisted); -function updateStorefrontValidation(): void { - const isInvalid = - sourceMode() === "build" && normalizeStorefrontDomain(storefrontInput.value) === ""; - storefrontInput.setAttribute("aria-invalid", String(isInvalid)); -} +const store = createStore(createInitialState(readSettings(persisted.settingsCollapsed))); +const loader = createProductLoader({ + store, + setDomainInputValue: (domain) => { + refs.storefrontInput.value = domain; + }, +}); -function updateBuildWorkspaceVisibility(): void { - const domain = normalizeStorefrontDomain(storefrontInput.value); - buildWorkspace.dataset["ready"] = String(variants.length > 0 || isLikelyStorefrontDomain(domain)); -} +store.subscribe(() => renderApp(refs, store.getState(), checkout)); -function refreshCheckoutButtons(): void { - const hasSrc = activeSourceUrl().length > 0; - cartCheckoutButton.disabled = sourceMode() !== "build" || !hasSrc; - manualCheckoutButton.disabled = sourceMode() !== "manual" || !hasSrc; - cartCheckoutHint.hidden = cartCheckoutButton.disabled === false; - manualCheckoutHint.hidden = manualCheckoutButton.disabled === false; -} +attachListeners(); +renderApp(refs, store.getState(), checkout); -function clearGeneratedCart(): void { - generatedCartUrl = ""; - generatedSrcLink.removeAttribute("href"); - generatedSrcLink.textContent = "Add products to derive a cart permalink"; - generatedSrcLink.dataset["empty"] = "true"; +if (store.getState().sourceMode === "build" && store.getState().storefrontDomain) { + loader.schedule(store.getState().storefrontDomain); } -function updateDerivedCartPermalink(): void { - const lines = selectedCartLines(); - if (lines.length === 0) { - clearGeneratedCart(); - syncAttributes(); - return; - } - - try { - generatedCartUrl = buildCartPermalink(storefrontInput.value, lines); - generatedSrcLink.href = generatedCartUrl; - generatedSrcLink.textContent = generatedCartUrl; - generatedSrcLink.dataset["empty"] = "false"; - } catch { - clearGeneratedCart(); - } - - syncAttributes(); +function currentSourceMode(): SourceMode { + const checked = refs.form.querySelector("input[name='source-mode']:checked"); + return checked?.value === "manual" ? "manual" : "build"; } -function resetCart(): void { - cartLines = []; - clearGeneratedCart(); +function readSettings(settingsCollapsed: boolean): SettingsSlice { + const data = new FormData(refs.form); + return { + sourceMode: currentSourceMode(), + storefrontDomain: refs.storefrontInput.value, + target: String(data.get("target") ?? "popup"), + debug: data.has("debug"), + manualSrc: refs.manualSrcInput.value, + settingsCollapsed, + }; } -function resetLoadedProducts(): void { - resetCart(); - variants = []; - renderProducts(); - refreshBuildState(); - updateBuildWorkspaceVisibility(); -} +function hydrateSettingsForm(settings: PersistedSettings): void { + const modeInput = refs.form.querySelector( + `input[name='source-mode'][value='${settings.sourceMode}']`, + ); + if (modeInput) modeInput.checked = true; -function cancelScheduledProductLoad(): void { - if (productLoadTimer !== undefined) { - window.clearTimeout(productLoadTimer); - productLoadTimer = undefined; - } - productLoadRequestId += 1; + refs.storefrontInput.value = settings.storefrontDomain; + if (settings.target) refs.checkoutTarget.value = settings.target; + refs.debugToggle.checked = settings.debug; } -function scheduleProductLoad(): void { - cancelScheduledProductLoad(); - resetLoadedProducts(); - - const domain = normalizeStorefrontDomain(storefrontInput.value); - writeStorage(STORAGE_KEYS.storefrontDomain, domain); - - updateStorefrontValidation(); - updateBuildWorkspaceVisibility(); - - if (!isLikelyStorefrontDomain(domain)) { - return; - } - - const requestId = productLoadRequestId; - loadState.textContent = "Loading soon"; - showCartStatus(`Waiting to load products from https://${domain}/products.json...`); - productLoadTimer = window.setTimeout(() => { - void loadProducts(domain, requestId); - }, PRODUCT_LOAD_DEBOUNCE_MS); +function captureSettings(): void { + const settings = readSettings(store.getState().settingsCollapsed); + store.setState(settings); + persistSettings({ + sourceMode: settings.sourceMode, + target: settings.target, + debug: settings.debug, + }); } function productQuantity(variantId: string): number { - return cartLines.find((line) => line.variantId === variantId)?.quantity ?? 0; -} - -function renderProducts(): void { - productList.replaceChildren(); - productEmpty.style.display = variants.length > 0 ? "none" : ""; - updateBuildWorkspaceVisibility(); - - for (const variant of variants) { - const quantity = productQuantity(variant.id); - const item = document.createElement("li"); - item.className = "product-card"; - item.dataset["variantId"] = variant.id; - - const image = document.createElement("div"); - image.className = "product-image"; - if (variant.imageUrl) { - const img = document.createElement("img"); - img.src = variant.imageUrl; - img.alt = ""; - image.append(img); - } else { - image.textContent = "📦"; - } - - const details = document.createElement("div"); - details.className = "product-info"; - - const vendor = document.createElement("p"); - vendor.className = "product-vendor"; - vendor.textContent = variant.vendor || "Storefront product"; - details.append(vendor); - - const title = document.createElement("h3"); - title.className = "product-title"; - title.textContent = variant.title; - details.append(title); - - const meta = document.createElement("p"); - meta.className = "product-meta"; - meta.textContent = `Variant ID: ${variant.id}`; - details.append(meta); - - const price = document.createElement("p"); - price.className = "product-price"; - price.textContent = variant.price ? `$${variant.price}` : "—"; - details.append(price); - - const actions = document.createElement("div"); - actions.className = "product-card-actions"; - - if (!variant.available) { - const unavailable = document.createElement("span"); - unavailable.className = "unavailable"; - unavailable.textContent = "Unavailable"; - actions.append(unavailable); - } else if (quantity > 0) { - const controls = document.createElement("div"); - controls.className = "quantity-controls"; - controls.append(quantityButton("−", "decrement", variant.title)); - - const input = document.createElement("input"); - input.type = "number"; - input.className = "cart-line-quantity"; - input.min = "1"; - input.max = "999"; - input.value = String(quantity); - input.setAttribute("aria-label", `Quantity for ${variant.title}`); - controls.append(input); - - controls.append(quantityButton("+", "increment", variant.title)); - actions.append(controls); - } else { - const addButton = document.createElement("button"); - addButton.type = "button"; - addButton.className = "secondary-action"; - addButton.dataset["cartAction"] = "add"; - addButton.textContent = "Add to cart"; - actions.append(addButton); - } - - details.append(actions); - item.append(image, details); - productList.append(item); - } -} - -function quantityButton(label: string, action: string, title: string): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.className = "quantity-button"; - button.dataset["cartAction"] = action; - button.textContent = label; - button.setAttribute("aria-label", `${label === "+" ? "Increase" : "Decrease"} ${title}`); - return button; -} - -function renderCartSummary(): void { - const lines = selectedCartLines(); - const totalQuantity = cartLineTotalQuantity(lines); - - cartCount.textContent = totalQuantity === 1 ? "1 item" : `${totalQuantity} items`; - selectedLines.replaceChildren(); - - if (lines.length === 0) { - cartSummaryText.textContent = "Add products to start a multi-item cart."; - return; - } - - cartSummaryText.textContent = `${lines.length} ${lines.length === 1 ? "variant" : "variants"}, ${totalQuantity} total`; - - for (const line of lines) { - const variant = variantForLine(line); - const item = document.createElement("li"); - item.className = "cart-line"; - item.dataset["variantId"] = line.variantId; - - const image = document.createElement("div"); - image.className = "cart-line-image"; - if (variant?.imageUrl) { - const img = document.createElement("img"); - img.src = variant.imageUrl; - img.alt = ""; - image.append(img); - } else { - image.textContent = "📦"; - } - item.append(image); - - const details = document.createElement("div"); - details.className = "cart-line-details"; - - const name = document.createElement("strong"); - name.className = "cart-line-title"; - name.textContent = variant?.title ?? line.variantId; - details.append(name); - - const meta = document.createElement("span"); - meta.className = "cart-line-meta"; - meta.textContent = variant?.price ? `$${variant.price}` : `Variant ID: ${line.variantId}`; - details.append(meta); - item.append(details); - - const controls = document.createElement("div"); - controls.className = "cart-line-controls"; - controls.append(quantityButton("−", "decrement", variant?.title ?? line.variantId)); - - const quantityInput = document.createElement("input"); - quantityInput.type = "number"; - quantityInput.className = "cart-line-summary-quantity"; - quantityInput.min = "1"; - quantityInput.max = "999"; - quantityInput.value = String(line.quantity); - quantityInput.setAttribute("aria-label", `Quantity for ${variant?.title ?? line.variantId}`); - controls.append(quantityInput); - - controls.append(quantityButton("+", "increment", variant?.title ?? line.variantId)); - item.append(controls); - - const removeButton = document.createElement("button"); - removeButton.type = "button"; - removeButton.className = "remove-line-button"; - removeButton.dataset["cartAction"] = "remove"; - removeButton.textContent = "×"; - removeButton.setAttribute("aria-label", `Remove ${variant?.title ?? line.variantId}`); - item.append(removeButton); - - selectedLines.append(item); - } -} - -function refreshBuildState(): void { - renderCartSummary(); - updateDerivedCartPermalink(); -} - -async function loadProducts(domain: string, requestId: number): Promise { - if (requestId !== productLoadRequestId) return; - - storefrontInput.value = domain; - productLoadTimer = undefined; - loadState.textContent = "Loading"; - showCartStatus(`Loading products from https://${domain}/products.json...`); - - try { - const loadedVariants = await fetchProductVariants(domain); - if (requestId !== productLoadRequestId) return; - - variants = loadedVariants; - loadState.textContent = `${variants.length} loaded`; - showCartStatus("Products loaded.", "success"); - } catch (error) { - if (requestId !== productLoadRequestId) return; - - loadState.textContent = "Load failed"; - const message = error instanceof Error ? error.message : "Products could not be loaded."; - showCartStatus(message, "error"); - } finally { - if (requestId === productLoadRequestId) { - renderProducts(); - refreshBuildState(); - } - } + return store.getState().cartLines.find((line) => line.variantId === variantId)?.quantity ?? 0; } function updateCartLine(variantId: string, quantity: unknown): void { - cartLines = upsertCartLine(cartLines, variantId, quantity); - renderProducts(); - refreshBuildState(); + store.setState({ cartLines: upsertCartLine(store.getState().cartLines, variantId, quantity) }); } function openCheckout(): void { checkout.open(); } -function restoreSettings(): void { - const storedMode = readStorage(STORAGE_KEYS.sourceMode); - const mode = storedMode === "manual" ? "manual" : "build"; - const modeInput = form.querySelector( - `input[name='source-mode'][value='${mode}']`, +function recordEvent(type: string): void { + const snapshot: ComponentSnapshot = { checkout: checkout.checkout, error: checkout.error }; + const json = JSON.stringify( + { + checkout: checkout.checkout, + error: checkout.error, + target: checkout.target, + debug: checkout.debug, + }, + null, + 2, ); - if (modeInput) modeInput.checked = true; - - storefrontInput.value = readStorage(STORAGE_KEYS.storefrontDomain); - - const storedTarget = readStorage(STORAGE_KEYS.target); - if (storedTarget) checkoutTarget.value = storedTarget; - - debugToggle.checked = readStorage(STORAGE_KEYS.debug) === "1"; - setSettingsCollapsed(readStorage(STORAGE_KEYS.settingsCollapsed) === "1"); + store.setState({ + component: snapshot, + log: [{ type, time: timestamp(), snapshot: json }, ...store.getState().log], + }); } -const layout = $("#layout"); -const form = $("#options-form"); -const eventLog = $("#event-log"); -const clearLogButton = $("#clear-log"); -const settingsToggle = $("#toggle-settings"); -const storefrontSourceFields = $("#storefront-source-fields"); -const buildWorkspace = $("#build-workspace"); -const manualWorkspace = $("#manual-workspace"); -const storefrontInput = $("#storefront-domain"); -const checkoutTarget = $("#checkout-target"); -const debugToggle = $("#debug-toggle"); -const manualSrcInput = $("#manual-src"); -const manualCheckoutButton = $("#manual-checkout"); -const manualCheckoutHint = $("#manual-checkout-hint"); -const productEmpty = $("#product-empty"); -const productList = $("#product-list"); -const cartStatus = $("#cart-status"); -const cartCount = $("#cart-count"); -const cartSummaryText = $("#cart-summary-text"); -const selectedLines = $("#selected-lines"); -const generatedSrcLink = $("#generated-src"); -const cartCheckoutButton = $("#cart-checkout"); -const cartCheckoutHint = $("#cart-checkout-hint"); -const loadState = $("#load-state"); - -const stateNodes = { - checkout: $("#state-checkout"), - error: $("#state-error"), - target: $("#state-target"), - debug: $("#state-debug"), -}; - -const checkout = document.createElement("shopify-checkout") as ShopifyCheckout; -document.body.append(checkout); - -const checkoutEl: HTMLElement = checkout; -let variants: ProductVariantOption[] = []; -let cartLines: CartLine[] = []; -let generatedCartUrl = ""; -let productLoadTimer: number | undefined; -let productLoadRequestId = 0; - -restoreSettings(); - -storefrontInput.addEventListener("input", scheduleProductLoad); - -settingsToggle.addEventListener("click", () => { - setSettingsCollapsed(!layout.classList.contains("settings-collapsed")); -}); - -form.addEventListener("submit", (event) => { - event.preventDefault(); -}); +function attachListeners(): void { + refs.storefrontInput.addEventListener("input", () => { + loader.schedule(refs.storefrontInput.value); + }); + refs.manualSrcInput.addEventListener("input", captureSettings); -form.addEventListener("input", syncAttributes); -form.addEventListener("change", (event) => { - syncAttributes(); + refs.settingsToggle.addEventListener("click", () => { + store.setState({ settingsCollapsed: !store.getState().settingsCollapsed }); + persistSettings({ settingsCollapsed: store.getState().settingsCollapsed }); + }); - const target = event.target; - if (target instanceof HTMLInputElement && target.name === "source-mode") { - if (sourceMode() === "manual") { - cancelScheduledProductLoad(); - return; + refs.form.addEventListener("submit", (event) => { + event.preventDefault(); + }); + refs.form.addEventListener("input", captureSettings); + refs.form.addEventListener("change", (event) => { + captureSettings(); + + const target = event.target; + if (target instanceof HTMLInputElement && target.name === "source-mode") { + if (store.getState().sourceMode === "manual") { + loader.cancel(); + return; + } + + if ( + store.getState().variants.length === 0 && + normalizeStorefrontDomain(refs.storefrontInput.value) + ) { + loader.schedule(refs.storefrontInput.value); + } } + }); - if (variants.length === 0 && normalizeStorefrontDomain(storefrontInput.value)) { - scheduleProductLoad(); + refs.cartCheckoutButton.addEventListener("click", openCheckout); + refs.manualCheckoutButton.addEventListener("click", openCheckout); + + refs.productList.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + const button = target.closest("button[data-cart-action]"); + if (!button) return; + + const productCard = button.closest(".product-card"); + const variantId = productCard?.dataset["variantId"]; + if (!variantId) return; + + const currentQuantity = productQuantity(variantId); + switch (button.dataset["cartAction"]) { + case "add": + updateCartLine(variantId, 1); + break; + case "increment": + updateCartLine(variantId, currentQuantity + 1); + break; + case "decrement": + updateCartLine(variantId, currentQuantity - 1); + break; + default: + break; } - } -}); - -cartCheckoutButton.addEventListener("click", openCheckout); -manualCheckoutButton.addEventListener("click", openCheckout); - -productList.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) return; - - const button = target.closest("button[data-cart-action]"); - if (!button) return; - - const productCard = button.closest(".product-card"); - const variantId = productCard?.dataset["variantId"]; - if (!variantId) return; - - const currentQuantity = productQuantity(variantId); - switch (button.dataset["cartAction"]) { - case "add": - updateCartLine(variantId, 1); - break; - case "increment": - updateCartLine(variantId, currentQuantity + 1); - break; - case "decrement": - updateCartLine(variantId, currentQuantity - 1); - break; - default: - break; - } -}); - -productList.addEventListener("change", (event) => { - const target = event.target; - if (!(target instanceof HTMLInputElement) || !target.classList.contains("cart-line-quantity")) { - return; - } - - const productCard = target.closest(".product-card"); - const variantId = productCard?.dataset["variantId"]; - if (!variantId) return; - - const quantity = normalizeQuantity(target.value); - target.value = String(quantity); - updateCartLine(variantId, quantity); -}); - -selectedLines.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) return; - - const button = target.closest("button[data-cart-action]"); - if (!button) return; - - const cartLine = button.closest(".cart-line"); - const variantId = cartLine?.dataset["variantId"]; - if (!variantId) return; - - const currentQuantity = productQuantity(variantId); - switch (button.dataset["cartAction"]) { - case "increment": - updateCartLine(variantId, currentQuantity + 1); - break; - case "decrement": - updateCartLine(variantId, currentQuantity - 1); - break; - case "remove": - updateCartLine(variantId, 0); - break; - default: - break; - } -}); - -selectedLines.addEventListener("change", (event) => { - const target = event.target; - if ( - !(target instanceof HTMLInputElement) || - !target.classList.contains("cart-line-summary-quantity") - ) { - return; - } - - const cartLine = target.closest(".cart-line"); - const variantId = cartLine?.dataset["variantId"]; - if (!variantId) return; - - const quantity = normalizeQuantity(target.value); - target.value = String(quantity); - updateCartLine(variantId, quantity); -}); - -const EVENT_TYPES = [ - "ec.start", - "ec.complete", - "ec.close", - "ec.error", - "ec.line_items.change", - "ec.totals.change", - "ec.messages.change", -] as const; - -for (const type of EVENT_TYPES) { - checkoutEl.addEventListener(type, () => { - appendLog(type); - refreshState(); }); -} -clearLogButton.addEventListener("click", () => { - eventLog.replaceChildren(); -}); - -function snapshotState(): Record { - return { - checkout: checkout.checkout, - error: checkout.error, - target: checkout.target, - debug: checkout.debug, - }; -} + refs.productList.addEventListener("change", (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement) || !target.classList.contains("cart-line-quantity")) { + return; + } -function refreshState(): void { - stateNodes.checkout.textContent = formatValue(checkout.checkout); - stateNodes.error.textContent = formatValue(checkout.error); - stateNodes.target.textContent = formatValue(checkout.target); - stateNodes.debug.textContent = formatValue(checkout.debug); -} + const productCard = target.closest(".product-card"); + const variantId = productCard?.dataset["variantId"]; + if (!variantId) return; -function appendLog(type: string): void { - const li = document.createElement("li"); - li.className = "event-entry"; + const quantity = normalizeQuantity(target.value); + target.value = String(quantity); + updateCartLine(variantId, quantity); + }); - const header = document.createElement("header"); - header.className = "event-entry-header"; + refs.selectedLines.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + const button = target.closest("button[data-cart-action]"); + if (!button) return; + + const cartLine = button.closest(".cart-line"); + const variantId = cartLine?.dataset["variantId"]; + if (!variantId) return; + + const currentQuantity = productQuantity(variantId); + switch (button.dataset["cartAction"]) { + case "increment": + updateCartLine(variantId, currentQuantity + 1); + break; + case "decrement": + updateCartLine(variantId, currentQuantity - 1); + break; + case "remove": + updateCartLine(variantId, 0); + break; + default: + break; + } + }); - const name = document.createElement("span"); - name.className = "event-entry-name"; - name.textContent = type; - header.append(name); + refs.selectedLines.addEventListener("change", (event) => { + const target = event.target; + if ( + !(target instanceof HTMLInputElement) || + !target.classList.contains("cart-line-summary-quantity") + ) { + return; + } - const time = document.createElement("time"); - time.className = "event-entry-time"; - time.textContent = timestamp(); - header.append(time); + const cartLine = target.closest(".cart-line"); + const variantId = cartLine?.dataset["variantId"]; + if (!variantId) return; - const pre = document.createElement("pre"); - pre.textContent = JSON.stringify(snapshotState(), null, 2); + const quantity = normalizeQuantity(target.value); + target.value = String(quantity); + updateCartLine(variantId, quantity); + }); - li.append(header, pre); - eventLog.prepend(li); -} + refs.clearLogButton.addEventListener("click", () => { + store.setState({ log: [] }); + }); -renderProducts(); -refreshBuildState(); -updateBuildWorkspaceVisibility(); -syncAttributes(); -if (sourceMode() === "build" && storefrontInput.value) { - scheduleProductLoad(); + const checkoutEl: HTMLElement = checkout; + for (const type of EVENT_TYPES) { + checkoutEl.addEventListener(type, () => { + recordEvent(type); + }); + } } diff --git a/platforms/web/sample/product-loader.test.ts b/platforms/web/sample/product-loader.test.ts new file mode 100644 index 000000000..598dbbfdb --- /dev/null +++ b/platforms/web/sample/product-loader.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ProductVariantOption } from "./cart"; +import { createProductLoader } from "./product-loader"; +import { createInitialState, createStore, type SettingsSlice, type Store } from "./state"; + +function settings(overrides: Partial = {}): SettingsSlice { + return { + sourceMode: "build", + storefrontDomain: "", + target: "popup", + debug: false, + manualSrc: "", + settingsCollapsed: false, + ...overrides, + }; +} + +function variant(id: string): ProductVariantOption { + return { + id, + title: `Product ${id}`, + productTitle: `Product ${id}`, + variantTitle: "Default Title", + vendor: "Acme", + price: "10.00", + available: true, + }; +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +const DOMAIN = "your-store.myshopify.com"; +let store: Store; + +beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + store = createStore(createInitialState(settings())); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("createProductLoader", () => { + it("does not schedule a load for an incomplete domain", async () => { + const fetchVariants = vi.fn(); + const loader = createProductLoader({ store, setDomainInputValue: () => {}, fetchVariants }); + + loader.schedule("your-store"); + await vi.advanceTimersByTimeAsync(1000); + + expect(fetchVariants).not.toHaveBeenCalled(); + expect(store.getState().loadState).toBe("Waiting for domain"); + }); + + it("loads variants after the debounce and marks success", async () => { + const fetchVariants = vi.fn().mockResolvedValue([variant("1"), variant("2")]); + const setDomainInputValue = vi.fn(); + const loader = createProductLoader({ + store, + setDomainInputValue, + fetchVariants, + debounceMs: 500, + }); + + loader.schedule(`https://${DOMAIN}/products`); + expect(store.getState().loadState).toBe("Loading soon"); + + await vi.advanceTimersByTimeAsync(500); + + expect(fetchVariants).toHaveBeenCalledWith(DOMAIN); + expect(setDomainInputValue).toHaveBeenCalledWith(DOMAIN); + expect(store.getState().variants).toHaveLength(2); + expect(store.getState().loadState).toBe("2 loaded"); + expect(store.getState().cartStatus).toEqual({ message: "Products loaded.", tone: "success" }); + }); + + it("marks failure and preserves the error message", async () => { + const fetchVariants = vi.fn().mockRejectedValue(new Error("nope")); + const loader = createProductLoader({ store, setDomainInputValue: () => {}, fetchVariants }); + + loader.schedule(DOMAIN); + await vi.advanceTimersByTimeAsync(500); + + expect(store.getState().loadState).toBe("Load failed"); + expect(store.getState().cartStatus).toEqual({ message: "nope", tone: "error" }); + }); + + it("clears prior products and persists the normalized domain on schedule", async () => { + const loader = createProductLoader({ + store, + setDomainInputValue: () => {}, + fetchVariants: vi.fn(), + }); + store.setState({ variants: [variant("1")], cartLines: [{ variantId: "1", quantity: 1 }] }); + + loader.schedule(`https://${DOMAIN}`); + + expect(store.getState().variants).toEqual([]); + expect(store.getState().cartLines).toEqual([]); + expect(localStorage.getItem("checkout-kit:web-demo:storefront-domain")).toBe(DOMAIN); + }); + + it("only runs the latest scheduled load when rescheduled during the debounce", async () => { + const fetchVariants = vi.fn().mockResolvedValue([variant("1")]); + const loader = createProductLoader({ store, setDomainInputValue: () => {}, fetchVariants }); + + loader.schedule("first-store.myshopify.com"); + loader.schedule("second-store.myshopify.com"); + await vi.advanceTimersByTimeAsync(500); + + expect(fetchVariants).toHaveBeenCalledTimes(1); + expect(fetchVariants).toHaveBeenCalledWith("second-store.myshopify.com"); + }); + + it("ignores an in-flight response when a newer load is scheduled", async () => { + const first = deferred(); + const second = deferred(); + const fetchVariants = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const loader = createProductLoader({ store, setDomainInputValue: () => {}, fetchVariants }); + + loader.schedule("first-store.myshopify.com"); + await vi.advanceTimersByTimeAsync(500); + + loader.schedule("second-store.myshopify.com"); + await vi.advanceTimersByTimeAsync(500); + + first.resolve([variant("stale")]); + second.resolve([variant("fresh")]); + await vi.runAllTimersAsync(); + + expect(store.getState().variants.map((v) => v.id)).toEqual(["fresh"]); + }); +}); diff --git a/platforms/web/sample/product-loader.ts b/platforms/web/sample/product-loader.ts new file mode 100644 index 000000000..e60815e77 --- /dev/null +++ b/platforms/web/sample/product-loader.ts @@ -0,0 +1,95 @@ +import { + fetchProductVariants, + isLikelyStorefrontDomain, + normalizeStorefrontDomain, + type ProductVariantOption, +} from "./cart"; +import { STORAGE_KEYS, writeStorage } from "./storage"; +import type { Store } from "./state"; + +const PRODUCT_LOAD_DEBOUNCE_MS = 500; + +type ProductFetcher = (domain: string) => Promise; + +type ProductLoaderDeps = { + store: Store; + setDomainInputValue: (domain: string) => void; + fetchVariants?: ProductFetcher; + debounceMs?: number; +}; + +export type ProductLoader = { + schedule(rawDomain: string): void; + cancel(): void; +}; + +export function createProductLoader(deps: ProductLoaderDeps): ProductLoader { + const { store, setDomainInputValue } = deps; + const fetchVariants = deps.fetchVariants ?? ((domain) => fetchProductVariants(domain)); + const debounceMs = deps.debounceMs ?? PRODUCT_LOAD_DEBOUNCE_MS; + + let timer: number | undefined; + let requestId = 0; + + function cancel(): void { + if (timer !== undefined) { + window.clearTimeout(timer); + timer = undefined; + } + requestId += 1; + } + + async function load(domain: string, id: number): Promise { + if (id !== requestId) return; + + setDomainInputValue(domain); + timer = undefined; + store.setState({ + loadState: "Loading", + cartStatus: { + message: `Loading products from https://${domain}/products.json...`, + tone: "info", + }, + }); + + try { + const variants = await fetchVariants(domain); + if (id !== requestId) return; + store.setState({ + variants, + loadState: `${variants.length} loaded`, + cartStatus: { message: "Products loaded.", tone: "success" }, + }); + } catch (error) { + if (id !== requestId) return; + const message = error instanceof Error ? error.message : "Products could not be loaded."; + store.setState({ loadState: "Load failed", cartStatus: { message, tone: "error" } }); + } + } + + function schedule(rawDomain: string): void { + cancel(); + store.setState({ variants: [], cartLines: [] }); + + const domain = normalizeStorefrontDomain(rawDomain); + writeStorage(STORAGE_KEYS.storefrontDomain, domain); + + if (!isLikelyStorefrontDomain(domain)) { + return; + } + + const id = requestId; + store.setState({ + loadState: "Loading soon", + cartStatus: { + message: `Waiting to load products from https://${domain}/products.json...`, + tone: "info", + }, + }); + timer = window.setTimeout(() => { + void load(domain, id); + }, debounceMs); + } + + return { schedule, cancel }; +} diff --git a/platforms/web/sample/render.test.ts b/platforms/web/sample/render.test.ts new file mode 100644 index 000000000..85058723e --- /dev/null +++ b/platforms/web/sample/render.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { ProductVariantOption } from "./cart"; +import { queryRefs, type Refs } from "./dom"; +import { renderApp } from "./render"; +import { createInitialState, type AppState, type SettingsSlice } from "./state"; +import { SAMPLE_SHELL } from "./shell"; +import { renderCart } from "./views/cart"; +import { renderLog } from "./views/log"; +import { renderProducts } from "./views/products"; +import { renderSettings } from "./views/settings"; + +function mountShell(): Refs { + document.body.innerHTML = SAMPLE_SHELL; + return queryRefs(); +} + +function settings(overrides: Partial = {}): SettingsSlice { + return { + sourceMode: "build", + storefrontDomain: "your-store.myshopify.com", + target: "popup", + debug: false, + manualSrc: "", + settingsCollapsed: false, + ...overrides, + }; +} + +function state(overrides: Partial = {}): AppState { + return { ...createInitialState(settings()), ...overrides }; +} + +function variant(overrides: Partial = {}): ProductVariantOption { + return { + id: "123", + title: "Sample product", + productTitle: "Sample product", + variantTitle: "Default Title", + vendor: "Acme", + price: "10.00", + available: true, + ...overrides, + }; +} + +let refs: Refs; +beforeEach(() => { + refs = mountShell(); +}); + +describe("renderProducts", () => { + it("shows the empty state and load pill when there are no variants", () => { + renderProducts(refs, state({ loadState: "Waiting for domain" })); + expect(refs.productList.children).toHaveLength(0); + expect(refs.productEmpty.style.display).toBe(""); + expect(refs.loadState.textContent).toBe("Waiting for domain"); + }); + + it("renders a product card per variant and hides the empty state", () => { + renderProducts(refs, state({ variants: [variant(), variant({ id: "456", title: "Second" })] })); + expect(refs.productList.querySelectorAll(".product-card")).toHaveLength(2); + expect(refs.productEmpty.style.display).toBe("none"); + }); + + it("shows an add button for a variant not yet in the cart", () => { + renderProducts(refs, state({ variants: [variant()] })); + expect(refs.productList.querySelector("[data-cart-action='add']")).not.toBeNull(); + }); + + it("shows quantity controls for a variant already in the cart", () => { + renderProducts( + refs, + state({ variants: [variant()], cartLines: [{ variantId: "123", quantity: 3 }] }), + ); + const input = refs.productList.querySelector(".cart-line-quantity"); + expect(input?.value).toBe("3"); + }); + + it("reflects the cart status tone", () => { + renderProducts(refs, state({ cartStatus: { message: "Products loaded.", tone: "success" } })); + expect(refs.cartStatus.hidden).toBe(true); + expect(refs.cartStatus.dataset["tone"]).toBe("success"); + }); +}); + +describe("renderCart", () => { + it("shows the empty prompt and empty permalink when the cart is empty", () => { + renderCart(refs, state()); + expect(refs.cartCount.textContent).toBe("0 items"); + expect(refs.cartSummaryText.textContent).toBe("Add products to start a multi-item cart."); + expect(refs.generatedSrcLink.dataset["empty"]).toBe("true"); + expect(refs.generatedSrcLink.hasAttribute("href")).toBe(false); + }); + + it("renders cart lines and the derived permalink", () => { + renderCart( + refs, + state({ + variants: [variant()], + cartLines: [{ variantId: "123", quantity: 2 }], + }), + ); + expect(refs.selectedLines.querySelectorAll(".cart-line")).toHaveLength(1); + expect(refs.cartCount.textContent).toBe("2 items"); + expect(refs.generatedSrcLink.dataset["empty"]).toBe("false"); + expect(refs.generatedSrcLink.getAttribute("href")).toBe( + "https://your-store.myshopify.com/cart/123:2", + ); + }); +}); + +describe("renderSettings", () => { + it("shows the build workspace in build mode", () => { + const checkout = document.createElement("div"); + renderSettings(refs, state({ cartLines: [{ variantId: "123", quantity: 1 }] }), checkout); + expect(refs.buildWorkspace.hidden).toBe(false); + expect(refs.manualWorkspace.hidden).toBe(true); + expect(refs.cartCheckoutButton.disabled).toBe(false); + expect(checkout.getAttribute("src")).toBe("https://your-store.myshopify.com/cart/123:1"); + expect(checkout.getAttribute("target")).toBe("popup"); + }); + + it("shows the manual workspace and toggles the debug attribute", () => { + const checkout = document.createElement("div"); + renderSettings( + refs, + state({ + sourceMode: "manual", + manualSrc: "https://your-store.myshopify.com/cart/1:1", + debug: true, + }), + checkout, + ); + expect(refs.storefrontSourceFields.hidden).toBe(true); + expect(refs.manualWorkspace.hidden).toBe(false); + expect(refs.manualCheckoutButton.disabled).toBe(false); + expect(checkout.hasAttribute("debug")).toBe(true); + }); + + it("flags an invalid storefront domain in build mode", () => { + const checkout = document.createElement("div"); + renderSettings(refs, state({ storefrontDomain: "" }), checkout); + expect(refs.storefrontInput.getAttribute("aria-invalid")).toBe("true"); + }); + + it("collapses the settings panel", () => { + const checkout = document.createElement("div"); + renderSettings(refs, state({ settingsCollapsed: true }), checkout); + expect(refs.layout.classList.contains("settings-collapsed")).toBe(true); + expect(refs.settingsToggle.textContent).toBe("Show"); + }); +}); + +describe("renderLog", () => { + it("formats the component state panel", () => { + renderLog( + refs, + state({ target: "auto", debug: true, component: { checkout: undefined, error: "boom" } }), + ); + expect(refs.stateTarget.textContent).toBe("auto"); + expect(refs.stateDebug.textContent).toBe("true"); + expect(refs.stateCheckout.textContent).toBe("—"); + expect(refs.stateError.textContent).toBe("boom"); + }); + + it("renders log entries in stored order", () => { + renderLog( + refs, + state({ + log: [ + { type: "ec.close", time: "00:00:02.000", snapshot: "{}" }, + { type: "ec.start", time: "00:00:01.000", snapshot: "{}" }, + ], + }), + ); + const names = [...refs.eventLog.querySelectorAll(".event-entry-name")].map( + (el) => el.textContent, + ); + expect(names).toEqual(["ec.close", "ec.start"]); + }); +}); + +describe("renderApp", () => { + it("renders every panel from a single state object", () => { + const checkout = document.createElement("div"); + renderApp( + refs, + state({ variants: [variant()], cartLines: [{ variantId: "123", quantity: 1 }] }), + checkout, + ); + expect(refs.productList.querySelectorAll(".product-card")).toHaveLength(1); + expect(refs.selectedLines.querySelectorAll(".cart-line")).toHaveLength(1); + expect(checkout.getAttribute("src")).toBe("https://your-store.myshopify.com/cart/123:1"); + }); +}); diff --git a/platforms/web/sample/render.ts b/platforms/web/sample/render.ts new file mode 100644 index 000000000..5934398ce --- /dev/null +++ b/platforms/web/sample/render.ts @@ -0,0 +1,13 @@ +import type { Refs } from "./dom"; +import type { AppState } from "./state"; +import { renderCart } from "./views/cart"; +import { renderLog } from "./views/log"; +import { renderProducts } from "./views/products"; +import { renderSettings } from "./views/settings"; + +export function renderApp(refs: Refs, state: AppState, checkout: HTMLElement): void { + renderSettings(refs, state, checkout); + renderProducts(refs, state); + renderCart(refs, state); + renderLog(refs, state); +} diff --git a/platforms/web/sample/shell.ts b/platforms/web/sample/shell.ts new file mode 100644 index 000000000..5b935cdcd --- /dev/null +++ b/platforms/web/sample/shell.ts @@ -0,0 +1,44 @@ +export const SAMPLE_SHELL = ` +
    +
    + + +
    + +
    + + +
    + + +
    +

    + 0 items +
      + Add products to derive a cart permalink + +

      + Waiting for domain +

      +
      +
        +
        + + + +
        +
        +
        +
        +
        +
        + +
          +
          +`; diff --git a/platforms/web/sample/state.test.ts b/platforms/web/sample/state.test.ts new file mode 100644 index 000000000..9e2627cf6 --- /dev/null +++ b/platforms/web/sample/state.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { CartLine } from "./cart"; +import { + createInitialState, + createStore, + selectActiveSourceUrl, + selectGeneratedCartUrl, + type SettingsSlice, +} from "./state"; + +function settings(overrides: Partial = {}): SettingsSlice { + return { + sourceMode: "build", + storefrontDomain: "", + target: "popup", + debug: false, + manualSrc: "", + settingsCollapsed: false, + ...overrides, + }; +} + +const lines: CartLine[] = [{ variantId: "123", quantity: 2 }]; + +describe("createInitialState", () => { + it("seeds settings from the provided slice and empty runtime data", () => { + const state = createInitialState(settings({ storefrontDomain: "your-store.myshopify.com" })); + expect(state.storefrontDomain).toBe("your-store.myshopify.com"); + expect(state.target).toBe("popup"); + expect(state.variants).toEqual([]); + expect(state.cartLines).toEqual([]); + expect(state.log).toEqual([]); + expect(state.cartStatus.tone).toBe("info"); + expect(state.component).toEqual({ checkout: undefined, error: undefined }); + }); +}); + +describe("createStore", () => { + it("shallow-merges partial updates", () => { + const store = createStore(createInitialState(settings())); + store.setState({ loadState: "Loading" }); + expect(store.getState().loadState).toBe("Loading"); + expect(store.getState().target).toBe("popup"); + }); + + it("notifies every subscriber on each update", () => { + const store = createStore(createInitialState(settings())); + const first = vi.fn(); + const second = vi.fn(); + store.subscribe(first); + store.subscribe(second); + + store.setState({ debug: true }); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); +}); + +describe("selectGeneratedCartUrl", () => { + it("returns an empty string when there are no cart lines", () => { + const state = createInitialState(settings({ storefrontDomain: "your-store.myshopify.com" })); + expect(selectGeneratedCartUrl(state)).toBe(""); + }); + + it("derives a cart permalink from the domain and lines", () => { + const state = { + ...createInitialState(settings({ storefrontDomain: "your-store.myshopify.com" })), + cartLines: lines, + }; + expect(selectGeneratedCartUrl(state)).toBe("https://your-store.myshopify.com/cart/123:2"); + }); + + it("returns an empty string when the domain is missing", () => { + const state = { ...createInitialState(settings({ storefrontDomain: "" })), cartLines: lines }; + expect(selectGeneratedCartUrl(state)).toBe(""); + }); +}); + +describe("selectActiveSourceUrl", () => { + it("uses the derived permalink in build mode", () => { + const state = { + ...createInitialState(settings({ storefrontDomain: "your-store.myshopify.com" })), + cartLines: lines, + }; + expect(selectActiveSourceUrl(state)).toBe("https://your-store.myshopify.com/cart/123:2"); + }); + + it("uses the trimmed manual source in manual mode", () => { + const state = createInitialState( + settings({ + sourceMode: "manual", + manualSrc: " https://your-store.myshopify.com/cart/1:1 ", + }), + ); + expect(selectActiveSourceUrl(state)).toBe("https://your-store.myshopify.com/cart/1:1"); + }); +}); diff --git a/platforms/web/sample/state.ts b/platforms/web/sample/state.ts new file mode 100644 index 000000000..849600c99 --- /dev/null +++ b/platforms/web/sample/state.ts @@ -0,0 +1,95 @@ +import { buildCartPermalink, type CartLine, type ProductVariantOption } from "./cart"; +import type { SourceMode } from "./storage"; + +export type { SourceMode }; +export type NoticeTone = "info" | "success" | "error"; + +export type LogEntry = { + type: string; + time: string; + snapshot: string; +}; + +export type ComponentSnapshot = { + checkout: unknown; + error: unknown; +}; + +export type CartStatus = { + message: string; + tone: NoticeTone; +}; + +export type SettingsSlice = { + sourceMode: SourceMode; + storefrontDomain: string; + target: string; + debug: boolean; + manualSrc: string; + settingsCollapsed: boolean; +}; + +export type AppState = SettingsSlice & { + variants: ProductVariantOption[]; + cartLines: CartLine[]; + loadState: string; + cartStatus: CartStatus; + component: ComponentSnapshot; + log: LogEntry[]; +}; + +export const INITIAL_LOAD_STATE = "Waiting for domain"; +export const INITIAL_CART_STATUS: CartStatus = { + message: "Enter a storefront domain to load products automatically.", + tone: "info", +}; + +export function createInitialState(settings: SettingsSlice): AppState { + return { + ...settings, + variants: [], + cartLines: [], + loadState: INITIAL_LOAD_STATE, + cartStatus: INITIAL_CART_STATUS, + component: { checkout: undefined, error: undefined }, + log: [], + }; +} + +export type Store = { + getState(): AppState; + setState(partial: Partial): void; + subscribe(listener: () => void): void; +}; + +export function createStore(initial: AppState): Store { + let state = initial; + const listeners = new Set<() => void>(); + + return { + getState: () => state, + setState(partial) { + state = { ...state, ...partial }; + for (const listener of listeners) { + listener(); + } + }, + subscribe(listener) { + listeners.add(listener); + }, + }; +} + +export function selectGeneratedCartUrl(state: AppState): string { + if (state.cartLines.length === 0) return ""; + + try { + return buildCartPermalink(state.storefrontDomain, state.cartLines); + } catch { + return ""; + } +} + +export function selectActiveSourceUrl(state: AppState): string { + return state.sourceMode === "manual" ? state.manualSrc.trim() : selectGeneratedCartUrl(state); +} diff --git a/platforms/web/sample/storage.test.ts b/platforms/web/sample/storage.test.ts new file mode 100644 index 000000000..9b17b4715 --- /dev/null +++ b/platforms/web/sample/storage.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + STORAGE_KEYS, + loadPersistedSettings, + persistSettings, + readStorage, + writeStorage, +} from "./storage"; + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("readStorage", () => { + it("returns the stored value", () => { + localStorage.setItem("key", "value"); + expect(readStorage("key")).toBe("value"); + }); + + it("returns an empty string for missing keys", () => { + expect(readStorage("missing")).toBe(""); + }); + + it("swallows errors and returns an empty string", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + expect(readStorage("key")).toBe(""); + }); +}); + +describe("writeStorage", () => { + it("stores non-empty values", () => { + writeStorage("key", "value"); + expect(localStorage.getItem("key")).toBe("value"); + }); + + it("removes the key when the value is empty", () => { + localStorage.setItem("key", "value"); + writeStorage("key", ""); + expect(localStorage.getItem("key")).toBeNull(); + }); + + it("swallows errors", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + expect(() => writeStorage("key", "value")).not.toThrow(); + }); +}); + +describe("loadPersistedSettings", () => { + it("returns defaults when nothing is stored", () => { + expect(loadPersistedSettings()).toEqual({ + sourceMode: "build", + storefrontDomain: "", + target: "", + debug: false, + settingsCollapsed: false, + }); + }); + + it("reflects stored values", () => { + localStorage.setItem(STORAGE_KEYS.sourceMode, "manual"); + localStorage.setItem(STORAGE_KEYS.storefrontDomain, "your-store.myshopify.com"); + localStorage.setItem(STORAGE_KEYS.target, "auto"); + localStorage.setItem(STORAGE_KEYS.debug, "1"); + localStorage.setItem(STORAGE_KEYS.settingsCollapsed, "1"); + + expect(loadPersistedSettings()).toEqual({ + sourceMode: "manual", + storefrontDomain: "your-store.myshopify.com", + target: "auto", + debug: true, + settingsCollapsed: true, + }); + }); + + it("falls back to build for unrecognized source modes", () => { + localStorage.setItem(STORAGE_KEYS.sourceMode, "nonsense"); + expect(loadPersistedSettings().sourceMode).toBe("build"); + }); +}); + +describe("persistSettings", () => { + it("writes each provided field to its storage key", () => { + persistSettings({ + sourceMode: "manual", + storefrontDomain: "your-store.myshopify.com", + target: "auto", + debug: true, + settingsCollapsed: true, + }); + + expect(localStorage.getItem(STORAGE_KEYS.sourceMode)).toBe("manual"); + expect(localStorage.getItem(STORAGE_KEYS.storefrontDomain)).toBe("your-store.myshopify.com"); + expect(localStorage.getItem(STORAGE_KEYS.target)).toBe("auto"); + expect(localStorage.getItem(STORAGE_KEYS.debug)).toBe("1"); + expect(localStorage.getItem(STORAGE_KEYS.settingsCollapsed)).toBe("1"); + }); + + it("removes keys for falsey boolean and empty string fields", () => { + localStorage.setItem(STORAGE_KEYS.debug, "1"); + localStorage.setItem(STORAGE_KEYS.settingsCollapsed, "1"); + localStorage.setItem(STORAGE_KEYS.storefrontDomain, "your-store.myshopify.com"); + + persistSettings({ debug: false, settingsCollapsed: false, storefrontDomain: "" }); + + expect(localStorage.getItem(STORAGE_KEYS.debug)).toBeNull(); + expect(localStorage.getItem(STORAGE_KEYS.settingsCollapsed)).toBeNull(); + expect(localStorage.getItem(STORAGE_KEYS.storefrontDomain)).toBeNull(); + }); + + it("only writes the provided fields", () => { + localStorage.setItem(STORAGE_KEYS.target, "auto"); + persistSettings({ storefrontDomain: "your-store.myshopify.com" }); + expect(localStorage.getItem(STORAGE_KEYS.target)).toBe("auto"); + }); +}); diff --git a/platforms/web/sample/storage.ts b/platforms/web/sample/storage.ts new file mode 100644 index 000000000..3c18cdc85 --- /dev/null +++ b/platforms/web/sample/storage.ts @@ -0,0 +1,65 @@ +export type SourceMode = "build" | "manual"; + +export type PersistedSettings = { + sourceMode: SourceMode; + storefrontDomain: string; + target: string; + debug: boolean; + settingsCollapsed: boolean; +}; + +export const STORAGE_KEYS = { + sourceMode: "checkout-kit:web-demo:source-mode", + storefrontDomain: "checkout-kit:web-demo:storefront-domain", + target: "checkout-kit:web-demo:target", + debug: "checkout-kit:web-demo:debug", + settingsCollapsed: "checkout-kit:web-demo:settings-collapsed", +} as const; + +export function readStorage(key: string): string { + try { + return localStorage.getItem(key) ?? ""; + } catch { + return ""; + } +} + +export function writeStorage(key: string, value: string): void { + try { + if (value) { + localStorage.setItem(key, value); + } else { + localStorage.removeItem(key); + } + } catch { + return; + } +} + +export function loadPersistedSettings(): PersistedSettings { + return { + sourceMode: readStorage(STORAGE_KEYS.sourceMode) === "manual" ? "manual" : "build", + storefrontDomain: readStorage(STORAGE_KEYS.storefrontDomain), + target: readStorage(STORAGE_KEYS.target), + debug: readStorage(STORAGE_KEYS.debug) === "1", + settingsCollapsed: readStorage(STORAGE_KEYS.settingsCollapsed) === "1", + }; +} + +export function persistSettings(settings: Partial): void { + if (settings.sourceMode !== undefined) { + writeStorage(STORAGE_KEYS.sourceMode, settings.sourceMode); + } + if (settings.storefrontDomain !== undefined) { + writeStorage(STORAGE_KEYS.storefrontDomain, settings.storefrontDomain); + } + if (settings.target !== undefined) { + writeStorage(STORAGE_KEYS.target, settings.target); + } + if (settings.debug !== undefined) { + writeStorage(STORAGE_KEYS.debug, settings.debug ? "1" : ""); + } + if (settings.settingsCollapsed !== undefined) { + writeStorage(STORAGE_KEYS.settingsCollapsed, settings.settingsCollapsed ? "1" : ""); + } +} diff --git a/platforms/web/sample/styles.css b/platforms/web/sample/styles.css index 12d808643..e5578cb66 100644 --- a/platforms/web/sample/styles.css +++ b/platforms/web/sample/styles.css @@ -410,7 +410,7 @@ button:disabled { } #build-workspace[data-ready="false"] .empty-state { - flex: 1 1 auto; + flex: none; } .cart-banner { diff --git a/platforms/web/sample/views/cart.ts b/platforms/web/sample/views/cart.ts new file mode 100644 index 000000000..f69d8a7af --- /dev/null +++ b/platforms/web/sample/views/cart.ts @@ -0,0 +1,97 @@ +import { cartLineTotalQuantity, type CartLine, type ProductVariantOption } from "../cart"; +import { quantityButton, type Refs } from "../dom"; +import { selectGeneratedCartUrl, type AppState } from "../state"; + +function variantForLine(state: AppState, line: CartLine): ProductVariantOption | undefined { + return state.variants.find((variant) => variant.id === line.variantId); +} + +function renderGeneratedPermalink(refs: Refs, state: AppState): void { + const url = selectGeneratedCartUrl(state); + if (url) { + refs.generatedSrcLink.href = url; + refs.generatedSrcLink.textContent = url; + refs.generatedSrcLink.dataset["empty"] = "false"; + } else { + refs.generatedSrcLink.removeAttribute("href"); + refs.generatedSrcLink.textContent = "Add products to derive a cart permalink"; + refs.generatedSrcLink.dataset["empty"] = "true"; + } +} + +export function renderCart(refs: Refs, state: AppState): void { + const lines = state.cartLines; + const totalQuantity = cartLineTotalQuantity(lines); + + refs.cartCount.textContent = totalQuantity === 1 ? "1 item" : `${totalQuantity} items`; + refs.selectedLines.replaceChildren(); + + if (lines.length === 0) { + refs.cartSummaryText.textContent = "Add products to start a multi-item cart."; + renderGeneratedPermalink(refs, state); + return; + } + + refs.cartSummaryText.textContent = `${lines.length} ${lines.length === 1 ? "variant" : "variants"}, ${totalQuantity} total`; + + for (const line of lines) { + const variant = variantForLine(state, line); + const item = document.createElement("li"); + item.className = "cart-line"; + item.dataset["variantId"] = line.variantId; + + const image = document.createElement("div"); + image.className = "cart-line-image"; + if (variant?.imageUrl) { + const img = document.createElement("img"); + img.src = variant.imageUrl; + img.alt = ""; + image.append(img); + } else { + image.textContent = "📦"; + } + item.append(image); + + const details = document.createElement("div"); + details.className = "cart-line-details"; + + const name = document.createElement("strong"); + name.className = "cart-line-title"; + name.textContent = variant?.title ?? line.variantId; + details.append(name); + + const meta = document.createElement("span"); + meta.className = "cart-line-meta"; + meta.textContent = variant?.price ? `$${variant.price}` : `Variant ID: ${line.variantId}`; + details.append(meta); + item.append(details); + + const controls = document.createElement("div"); + controls.className = "cart-line-controls"; + controls.append(quantityButton("−", "decrement", variant?.title ?? line.variantId)); + + const quantityInput = document.createElement("input"); + quantityInput.type = "number"; + quantityInput.className = "cart-line-summary-quantity"; + quantityInput.min = "1"; + quantityInput.max = "999"; + quantityInput.value = String(line.quantity); + quantityInput.setAttribute("aria-label", `Quantity for ${variant?.title ?? line.variantId}`); + controls.append(quantityInput); + + controls.append(quantityButton("+", "increment", variant?.title ?? line.variantId)); + item.append(controls); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "remove-line-button"; + removeButton.dataset["cartAction"] = "remove"; + removeButton.textContent = "×"; + removeButton.setAttribute("aria-label", `Remove ${variant?.title ?? line.variantId}`); + item.append(removeButton); + + refs.selectedLines.append(item); + } + + renderGeneratedPermalink(refs, state); +} diff --git a/platforms/web/sample/views/log.ts b/platforms/web/sample/views/log.ts new file mode 100644 index 000000000..17bd8d63f --- /dev/null +++ b/platforms/web/sample/views/log.ts @@ -0,0 +1,38 @@ +import { formatValue, type Refs } from "../dom"; +import type { AppState, LogEntry } from "../state"; + +function buildLogEntry(entry: LogEntry): HTMLLIElement { + const li = document.createElement("li"); + li.className = "event-entry"; + + const header = document.createElement("header"); + header.className = "event-entry-header"; + + const name = document.createElement("span"); + name.className = "event-entry-name"; + name.textContent = entry.type; + header.append(name); + + const time = document.createElement("time"); + time.className = "event-entry-time"; + time.textContent = entry.time; + header.append(time); + + const pre = document.createElement("pre"); + pre.textContent = entry.snapshot; + + li.append(header, pre); + return li; +} + +export function renderLog(refs: Refs, state: AppState): void { + refs.stateCheckout.textContent = formatValue(state.component.checkout); + refs.stateError.textContent = formatValue(state.component.error); + refs.stateTarget.textContent = formatValue(state.target); + refs.stateDebug.textContent = formatValue(state.debug); + + refs.eventLog.replaceChildren(); + for (const entry of state.log) { + refs.eventLog.append(buildLogEntry(entry)); + } +} diff --git a/platforms/web/sample/views/products.ts b/platforms/web/sample/views/products.ts new file mode 100644 index 000000000..3d65061a5 --- /dev/null +++ b/platforms/web/sample/views/products.ts @@ -0,0 +1,101 @@ +import { isLikelyStorefrontDomain, normalizeStorefrontDomain } from "../cart"; +import { quantityButton, type Refs } from "../dom"; +import type { AppState } from "../state"; + +function productQuantity(state: AppState, variantId: string): number { + return state.cartLines.find((line) => line.variantId === variantId)?.quantity ?? 0; +} + +export function renderProducts(refs: Refs, state: AppState): void { + refs.loadState.textContent = state.loadState; + + refs.cartStatus.hidden = state.cartStatus.tone === "success"; + refs.cartStatus.textContent = state.cartStatus.message; + refs.cartStatus.dataset["tone"] = state.cartStatus.tone; + + const domain = normalizeStorefrontDomain(state.storefrontDomain); + refs.buildWorkspace.dataset["ready"] = String( + state.variants.length > 0 || isLikelyStorefrontDomain(domain), + ); + + refs.productList.replaceChildren(); + refs.productEmpty.style.display = state.variants.length > 0 ? "none" : ""; + + for (const variant of state.variants) { + const quantity = productQuantity(state, variant.id); + const item = document.createElement("li"); + item.className = "product-card"; + item.dataset["variantId"] = variant.id; + + const image = document.createElement("div"); + image.className = "product-image"; + if (variant.imageUrl) { + const img = document.createElement("img"); + img.src = variant.imageUrl; + img.alt = ""; + image.append(img); + } else { + image.textContent = "📦"; + } + + const details = document.createElement("div"); + details.className = "product-info"; + + const vendor = document.createElement("p"); + vendor.className = "product-vendor"; + vendor.textContent = variant.vendor || "Storefront product"; + details.append(vendor); + + const title = document.createElement("h3"); + title.className = "product-title"; + title.textContent = variant.title; + details.append(title); + + const meta = document.createElement("p"); + meta.className = "product-meta"; + meta.textContent = `Variant ID: ${variant.id}`; + details.append(meta); + + const price = document.createElement("p"); + price.className = "product-price"; + price.textContent = variant.price ? `$${variant.price}` : "—"; + details.append(price); + + const actions = document.createElement("div"); + actions.className = "product-card-actions"; + + if (!variant.available) { + const unavailable = document.createElement("span"); + unavailable.className = "unavailable"; + unavailable.textContent = "Unavailable"; + actions.append(unavailable); + } else if (quantity > 0) { + const controls = document.createElement("div"); + controls.className = "quantity-controls"; + controls.append(quantityButton("−", "decrement", variant.title)); + + const input = document.createElement("input"); + input.type = "number"; + input.className = "cart-line-quantity"; + input.min = "1"; + input.max = "999"; + input.value = String(quantity); + input.setAttribute("aria-label", `Quantity for ${variant.title}`); + controls.append(input); + + controls.append(quantityButton("+", "increment", variant.title)); + actions.append(controls); + } else { + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.className = "secondary-action"; + addButton.dataset["cartAction"] = "add"; + addButton.textContent = "Add to cart"; + actions.append(addButton); + } + + details.append(actions); + item.append(image, details); + refs.productList.append(item); + } +} diff --git a/platforms/web/sample/views/settings.ts b/platforms/web/sample/views/settings.ts new file mode 100644 index 000000000..bdf0af3ad --- /dev/null +++ b/platforms/web/sample/views/settings.ts @@ -0,0 +1,32 @@ +import { normalizeStorefrontDomain } from "../cart"; +import { setStringAttribute, type Refs } from "../dom"; +import { selectActiveSourceUrl, type AppState } from "../state"; + +export function renderSettings(refs: Refs, state: AppState, checkout: HTMLElement): void { + const isManual = state.sourceMode === "manual"; + refs.storefrontSourceFields.hidden = isManual; + refs.buildWorkspace.hidden = isManual; + refs.manualWorkspace.hidden = !isManual; + + const isInvalid = + state.sourceMode === "build" && normalizeStorefrontDomain(state.storefrontDomain) === ""; + refs.storefrontInput.setAttribute("aria-invalid", String(isInvalid)); + + const activeUrl = selectActiveSourceUrl(state); + const hasSrc = activeUrl.length > 0; + refs.cartCheckoutButton.disabled = state.sourceMode !== "build" || !hasSrc; + refs.manualCheckoutButton.disabled = state.sourceMode !== "manual" || !hasSrc; + refs.cartCheckoutHint.hidden = refs.cartCheckoutButton.disabled === false; + + refs.layout.classList.toggle("settings-collapsed", state.settingsCollapsed); + refs.settingsToggle.setAttribute("aria-expanded", String(!state.settingsCollapsed)); + refs.settingsToggle.textContent = state.settingsCollapsed ? "Show" : "Hide"; + + setStringAttribute(checkout, "src", activeUrl); + setStringAttribute(checkout, "target", state.target); + if (state.debug) { + checkout.setAttribute("debug", ""); + } else { + checkout.removeAttribute("debug"); + } +}