From b849845b74d1c30b21ccc3d158276b2d0e2a19ac Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Mon, 28 Sep 2026 17:00:08 +0200 Subject: [PATCH] Add multi-shop Universal Checkout sample carts --- platforms/web/sample/README.md | 14 +- platforms/web/sample/index.html | 4 + platforms/web/sample/styles.css | 183 ++++++++ platforms/web/sample/tsconfig.json | 3 +- platforms/web/sample/universal-main.ts | 147 +++++++ platforms/web/sample/universal.html | 192 +++++++++ platforms/web/sample/universal/catalog.ts | 10 + .../web/sample/universal/controller.test.ts | 179 ++++++++ platforms/web/sample/universal/controller.ts | 190 +++++++++ platforms/web/sample/universal/domain.test.ts | 29 ++ platforms/web/sample/universal/domain.ts | 50 +++ platforms/web/sample/universal/state.ts | 175 ++++++++ .../web/sample/universal/storage.test.ts | 15 + platforms/web/sample/universal/storage.ts | 48 +++ platforms/web/sample/universal/views.test.ts | 161 +++++++ platforms/web/sample/universal/views.ts | 401 ++++++++++++++++++ platforms/web/sample/vite.config.ts | 18 +- 17 files changed, 1811 insertions(+), 8 deletions(-) create mode 100644 platforms/web/sample/universal-main.ts create mode 100644 platforms/web/sample/universal.html create mode 100644 platforms/web/sample/universal/catalog.ts create mode 100644 platforms/web/sample/universal/controller.test.ts create mode 100644 platforms/web/sample/universal/controller.ts create mode 100644 platforms/web/sample/universal/domain.test.ts create mode 100644 platforms/web/sample/universal/domain.ts create mode 100644 platforms/web/sample/universal/state.ts create mode 100644 platforms/web/sample/universal/storage.test.ts create mode 100644 platforms/web/sample/universal/storage.ts create mode 100644 platforms/web/sample/universal/views.test.ts create mode 100644 platforms/web/sample/universal/views.ts diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index ce330db4e..3712ebb7f 100644 --- a/platforms/web/sample/README.md +++ b/platforms/web/sample/README.md @@ -9,7 +9,15 @@ cd platforms/web pnpm sample ``` -Vite serves at `http://localhost:5173`. +Vite serves the single-checkout sample at `http://localhost:5173/` and the Universal Checkout sample at `http://localhost:5173/universal.html`. The topbar links the two pages. + +## Universal Checkout sample + +The Universal page uses the same three-panel layout and cart controls as the single-checkout page. Enter a storefront domain and choose **Add shop** to load its public `/products.json` catalog immediately. Each selected shop has its own cart, product loading status, Retry and Remove controls, and an individual cart permalink preview. You can add multiple shops, including products with the same variant ID in different shops; quantities remain separate. + +Use a bare domain such as `store-one.myshopify.com` or its HTTPS homepage. The form rejects credentials, ports, other schemes, paths, and duplicate normalized domains. The selected domains, carts, and links stay in memory. Only presentation settings, panel collapse choices, and panel widths use Universal-specific local storage keys. + +The public products endpoint does not supply a currency code through this sample's catalog helper, so prices are shown without a currency symbol and carts are counted by items rather than combined into a cross-shop monetary total. A failed or empty shop remains selected and must be retried or removed before a Universal Checkout URL can be created. The session creation and Open controls are added in the following sample increments; this page currently stops at cart previews. ## What the demo shows @@ -55,12 +63,12 @@ The demo relies on the public `/products.json` endpoint. If product loading fail - 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. +The single-checkout page does not call Storefront API `cartCreate`; it uses cart permalinks so the multi-item flow can be exercised without a Storefront access token. Universal session creation will use shop carts in its next increment. ## Build ```bash -pnpm sample:build # outputs to sample/dist/ +pnpm sample:build # outputs index.html and universal.html 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`). diff --git a/platforms/web/sample/index.html b/platforms/web/sample/index.html index f73c8fad2..4d786da3c 100644 --- a/platforms/web/sample/index.html +++ b/platforms/web/sample/index.html @@ -12,6 +12,10 @@

Checkout Kit

Web Component Playground

+
diff --git a/platforms/web/sample/styles.css b/platforms/web/sample/styles.css index 4e3aa427e..542531ad7 100644 --- a/platforms/web/sample/styles.css +++ b/platforms/web/sample/styles.css @@ -1119,3 +1119,186 @@ button:disabled { grid-template-columns: 1fr; } } + +.sample-nav { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.sample-nav a { + padding: 7px 11px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + font-size: 13px; + text-decoration: none; +} + +.sample-nav a:hover, +.sample-nav a:focus-visible, +.sample-nav a[aria-current="page"] { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--surface)); +} + +.universal-page .uc-add-row { + display: flex; + gap: 8px; +} + +.universal-page .uc-add-row input { + flex: 1 1 auto; +} + +.universal-page .uc-add-row button { + flex: none; +} + +.universal-page .uc-summary-list { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding-left: 18px; + color: var(--text-muted); + font-size: 13px; +} + +.universal-page .uc-summary-list:empty { + display: none; +} + +.universal-page .uc-shop-list { + display: flex; + flex-direction: column; + gap: 16px; + padding: 0 4px 4px 0; +} + +.universal-page .uc-shop { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; + padding: 16px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + box-shadow: var(--shadow-md); +} + +.universal-page .uc-shop-header { + display: flex; + align-items: center; + gap: 10px; +} + +.universal-page .uc-shop-header h3 { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + font-size: 16px; +} + +.universal-page .uc-shop-actions { + display: flex; + gap: 6px; + margin-left: auto; +} + +.universal-page .uc-shop-cart { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-subdued); +} + +.universal-page .uc-shop-cart h4 { + margin: 0; + font-size: 13px; +} + +.universal-page .uc-shop-cart .cart-lines { + max-height: 230px; + overflow-y: auto; +} + +.universal-page .uc-shop-cart .cart-line { + background: var(--surface); +} + +.universal-page .uc-shop-catalog { + min-width: 0; +} + +.universal-page #component-state p { + margin: 12px 0 0; + line-height: 1.45; +} + +.universal-page .events-collapsed #uc-runtime-notice { + display: none; +} + +@media (max-width: 1180px) { + html.universal-page { + height: auto; + min-height: 100%; + overflow: auto; + } + + body.universal-page { + height: auto; + max-height: none; + min-height: 100dvh; + overflow: visible; + } + + body.universal-page main, + body.universal-page main.settings-collapsed, + body.universal-page main.events-collapsed, + body.universal-page main.settings-collapsed.events-collapsed { + height: auto; + min-height: 0; + flex: none; + overflow: visible; + grid-template-columns: minmax(0, 1fr); + } + + body.universal-page .panel, + body.universal-page .workspace { + height: auto; + max-height: none; + min-height: 0; + overflow: visible; + } + + body.universal-page .product-scroll, + body.universal-page .runtime-scroll { + overflow: visible; + } + + body.universal-page .panel-collapse-toggle, + body.universal-page .col-resizer { + display: none; + } +} + +@media (max-width: 640px) { + .universal-page .uc-add-row, + .universal-page .uc-shop-header { + align-items: stretch; + flex-direction: column; + } + + .universal-page .uc-shop-actions { + margin-left: 0; + } +} diff --git a/platforms/web/sample/tsconfig.json b/platforms/web/sample/tsconfig.json index a7fc11dfd..df3fc5f04 100644 --- a/platforms/web/sample/tsconfig.json +++ b/platforms/web/sample/tsconfig.json @@ -6,7 +6,8 @@ "noEmit": true, "types": ["vite/client"], "paths": { - "@shopify/checkout-kit": ["../src/index.ts"] + "@shopify/checkout-kit": ["../src/index.ts"], + "@shopify/checkout-kit/universal": ["../src/universal.ts"] } }, "include": ["./**/*.ts", "./**/*.d.ts"] diff --git a/platforms/web/sample/universal-main.ts b/platforms/web/sample/universal-main.ts new file mode 100644 index 000000000..caee8537d --- /dev/null +++ b/platforms/web/sample/universal-main.ts @@ -0,0 +1,147 @@ +import { normalizeQuantity } from "./cart"; +import { createColumnResizer } from "./column-resizer"; +import { createUniversalController } from "./universal/controller"; +import { + createInitialState, + createUniversalStore, + selectQuantity, + selectShop, + type DisplayState, +} from "./universal/state"; +import { + loadUniversalDisplay, + persistUniversalDisplay, + persistUniversalWidth, + readUniversalWidth, +} from "./universal/storage"; +import { queryUniversalRefs, renderUniversalApp, renderUniversalChange } from "./universal/views"; +import "./styles.css"; + +const refs = queryUniversalRefs(); +const store = createUniversalStore(createInitialState(loadUniversalDisplay())); +const controller = createUniversalController({ store }); +const resizer = createColumnResizer({ + layout: refs.layout, + leftPanel: refs.settingsPanel, + rightPanel: refs.runtimePanel, + leftHandle: refs.resizeLeft, + rightHandle: refs.resizeRight, + readPersisted: readUniversalWidth, + persist: persistUniversalWidth, +}); + +const unsubscribe = store.subscribe((state, previous) => { + renderUniversalChange(refs, state, previous); + resizer.reposition(); +}); + +function updateDisplay(partial: Partial): void { + const display = { ...store.getState().display, ...partial }; + store.update((state) => ({ ...state, display })); + persistUniversalDisplay(display); +} + +refs.form.addEventListener("submit", (event) => { + event.preventDefault(); + if (controller.addShop(refs.domainInput.value)) { + refs.domainInput.value = ""; + refs.domainInput.focus(); + } +}); + +refs.domainInput.addEventListener("input", controller.clearAddError); + +refs.form.addEventListener("change", (event) => { + const target = event.target; + if (!(target instanceof HTMLSelectElement)) return; + if (target === refs.targetInput) { + updateDisplay({ target: target.value === "auto" ? "auto" : "popup" }); + } else if (target === refs.appearanceInput) { + updateDisplay({ appearance: target.value }); + } else if (target === refs.logLevelInput) { + const logLevel = target.value; + if ( + logLevel === "debug" || + logLevel === "warn" || + logLevel === "error" || + logLevel === "none" + ) { + updateDisplay({ logLevel }); + } + } +}); + +refs.settingsToggle.addEventListener("click", () => { + updateDisplay({ settingsCollapsed: !store.getState().display.settingsCollapsed }); +}); + +refs.eventsToggle.addEventListener("click", () => { + updateDisplay({ eventsCollapsed: !store.getState().display.eventsCollapsed }); +}); + +refs.shopList.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + const shopButton = target.closest("button[data-shop-action]"); + if (shopButton) { + const shopKey = shopButton.closest("[data-shop-key]")?.dataset["shopKey"]; + if (!shopKey) return; + if (shopButton.dataset["shopAction"] === "remove") controller.removeShop(shopKey); + if (shopButton.dataset["shopAction"] === "retry") controller.retryShop(shopKey); + return; + } + + const cartButton = target.closest("button[data-cart-action]"); + const item = cartButton?.closest("[data-shop-key][data-variant-id]"); + const shopKey = item?.dataset["shopKey"]; + const variantId = item?.dataset["variantId"]; + if (!cartButton || !shopKey || !variantId) return; + const shop = selectShop(store.getState(), shopKey); + if (!shop) return; + const quantity = selectQuantity(shop, variantId); + switch (cartButton.dataset["cartAction"]) { + case "add": + controller.setQuantity(shopKey, variantId, 1); + break; + case "increment": + controller.setQuantity(shopKey, variantId, quantity + 1); + break; + case "decrement": + controller.setQuantity(shopKey, variantId, quantity - 1); + break; + case "remove": + controller.setQuantity(shopKey, variantId, 0); + break; + default: + break; + } +}); + +refs.shopList.addEventListener("change", (event) => { + const target = event.target; + if ( + !(target instanceof HTMLInputElement) || + (!target.classList.contains("cart-line-quantity") && + !target.classList.contains("cart-line-summary-quantity")) + ) { + return; + } + const item = target.closest("[data-shop-key][data-variant-id]"); + const shopKey = item?.dataset["shopKey"]; + const variantId = item?.dataset["variantId"]; + if (!shopKey || !variantId) return; + const quantity = normalizeQuantity(target.value); + target.value = String(quantity); + controller.setQuantity(shopKey, variantId, quantity); +}); + +renderUniversalApp(refs, store.getState()); +resizer.applyWidths(); +window.addEventListener("resize", resizer.reposition); +window.addEventListener("pagehide", (event) => { + if (event.persisted) return; + unsubscribe(); + controller.dispose(); + window.removeEventListener("resize", resizer.reposition); +}); diff --git a/platforms/web/sample/universal.html b/platforms/web/sample/universal.html new file mode 100644 index 000000000..6b9b64ccd --- /dev/null +++ b/platforms/web/sample/universal.html @@ -0,0 +1,192 @@ + + + + + + Checkout Kit — Universal Checkout Playground + + + +
+
+

Checkout Kit

+

Universal Checkout Playground

+
+ +
+ +
+
+
+

Settings

+
+ +
+
+ Shops + +
+ + +
+

+ Add each shop separately. Its public products load from /products.json. +

+ +
+ +
+ Presentation + + + +
+
+ + +
+ + + +
+
+
+
+
+

Selected carts

+

+ Add shops and choose products to build independent carts. +

+
+ 0 items +
+
    +

    + Add a shop to start building carts. +

    +
    + +
    +

    Shop catalogs

    + 0 shops +
    + +
    +
    + +

    No shops selected

    +

    Add a storefront domain in Settings to load products for its own cart.

    +
    +
    +
    +
    +
    + + + +
    +
    +
    + Preparation +

    Add a shop to start building carts.

    +
    +
    +

    Events

    +
    +

    + Open a universal checkout to see received events. +

    +
      +

      Received checkout events appear here.

      +
      + + +
      +
      + + + + diff --git a/platforms/web/sample/universal/catalog.ts b/platforms/web/sample/universal/catalog.ts new file mode 100644 index 000000000..69d53cc1e --- /dev/null +++ b/platforms/web/sample/universal/catalog.ts @@ -0,0 +1,10 @@ +import { fetchProductVariants, type ProductVariantOption } from "../cart"; + +export type CatalogLoader = ( + domain: string, + signal: AbortSignal, +) => Promise; + +/** Keep the existing products.json mapping while allowing each shop to cancel its own request. */ +export const loadCatalog: CatalogLoader = (domain, signal) => + fetchProductVariants(domain, (url) => fetch(url, { signal })); diff --git a/platforms/web/sample/universal/controller.test.ts b/platforms/web/sample/universal/controller.test.ts new file mode 100644 index 000000000..abe5366b0 --- /dev/null +++ b/platforms/web/sample/universal/controller.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ProductVariantOption } from "../cart"; +import { createUniversalController } from "./controller"; +import { + createInitialState, + createUniversalStore, + selectCartReadiness, + selectShopCartPreview, +} from "./state"; + +function variant(id: string, available = true): ProductVariantOption { + return { + id, + title: `Product ${id}`, + productTitle: `Product ${id}`, + variantTitle: "Default Title", + vendor: "Synthetic shop", + price: "10.00", + available, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe("Universal shop controller", () => { + it("keeps identical variant IDs independent across two shops and derives separate previews", async () => { + const store = createUniversalStore(createInitialState()); + const controller = createUniversalController({ + store, + catalogLoader: vi.fn().mockResolvedValue([variant("123")]), + }); + expect(controller.addShop("SHOP-ONE.example.com")).toBe(true); + expect(controller.addShop("https://shop-two.example.com/")).toBe(true); + await vi.waitFor(() => + expect(store.getState().shops.every((shop) => shop.catalogStatus === "ready")).toBe(true), + ); + + const [first, second] = store.getState().shops; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + controller.setQuantity(first!.key, "123", 2); + controller.setQuantity(second!.key, "123", 3); + + expect(store.getState().shops.map((shop) => shop.cartLines)).toEqual([ + [{ variantId: "123", quantity: 2 }], + [{ variantId: "123", quantity: 3 }], + ]); + expect(store.getState().shops.map(selectShopCartPreview)).toEqual([ + "https://shop-one.example.com/cart/123:2", + "https://shop-two.example.com/cart/123:3", + ]); + expect(selectCartReadiness(store.getState())).toMatchObject({ + ready: true, + shopCount: 2, + itemCount: 5, + }); + expect(controller.addShop("https://SHOP-ONE.example.com/")).toBe(false); + expect(store.getState().shops).toHaveLength(2); + expect(store.getState().addShopError).toContain("already selected"); + controller.dispose(); + }); + + it("shows one shop failure, retries it, and suppresses raw loader errors", async () => { + const store = createUniversalStore(createInitialState()); + const catalogLoader = vi + .fn() + .mockRejectedValueOnce(new Error("private token must not appear")) + .mockResolvedValueOnce([variant("123")]); + const controller = createUniversalController({ store, catalogLoader }); + controller.addShop("shop-one.example.com"); + await vi.waitFor(() => expect(store.getState().shops[0]?.catalogStatus).toBe("error")); + expect(store.getState().shops[0]?.catalogError).not.toContain("private token"); + expect(selectCartReadiness(store.getState()).hint).toContain("Retry"); + + controller.retryShop(store.getState().shops[0]!.key); + await vi.waitFor(() => expect(store.getState().shops[0]?.catalogStatus).toBe("ready")); + expect(catalogLoader).toHaveBeenCalledTimes(2); + controller.dispose(); + }); + + it("ignores a late response after removal and re-addition of the same domain", async () => { + const oldLoad = deferred(); + const newLoad = deferred(); + const catalogLoader = vi + .fn() + .mockReturnValueOnce(oldLoad.promise) + .mockReturnValueOnce(newLoad.promise); + const store = createUniversalStore(createInitialState()); + const controller = createUniversalController({ store, catalogLoader }); + controller.addShop("shop-one.example.com"); + const oldKey = store.getState().shops[0]!.key; + const oldSignal = catalogLoader.mock.calls[0]?.[1] as AbortSignal; + controller.removeShop(oldKey); + expect(oldSignal.aborted).toBe(true); + controller.addShop("shop-one.example.com"); + const newKey = store.getState().shops[0]!.key; + expect(newKey).not.toBe(oldKey); + + oldLoad.resolve([variant("stale")]); + await Promise.resolve(); + expect(store.getState().shops[0]?.catalogStatus).toBe("loading"); + expect(store.getState().shops[0]?.variants).toEqual([]); + newLoad.resolve([variant("fresh")]); + await vi.waitFor(() => expect(store.getState().shops[0]?.catalogStatus).toBe("ready")); + expect(store.getState().shops[0]?.variants.map((item) => item.id)).toEqual(["fresh"]); + controller.dispose(); + }); + + it("clamps quantities, blocks unavailable variants, and invalidates prepared URLs on edits", async () => { + const store = createUniversalStore(createInitialState()); + const controller = createUniversalController({ + store, + catalogLoader: vi + .fn() + .mockResolvedValue([variant("unavailable", false), variant("available")]), + }); + controller.addShop("shop-one.example.com"); + await vi.waitFor(() => expect(store.getState().shops[0]?.catalogStatus).toBe("ready")); + const key = store.getState().shops[0]!.key; + expect(selectCartReadiness(store.getState()).ready).toBe(false); + controller.setQuantity(key, "unavailable", 1); + expect(store.getState().shops[0]?.cartLines).toEqual([]); + + controller.setQuantity(key, "available", 5000); + expect(store.getState().shops[0]?.cartLines).toEqual([ + { variantId: "available", quantity: 999 }, + ]); + const generation = store.getState().preparation.generation; + store.update((state) => ({ + ...state, + preparation: { + ...state.preparation, + phase: "ready", + url: "https://checkout.example.test/session", + readyGeneration: state.preparation.generation, + }, + })); + controller.setQuantity(key, "available", 999); + expect(store.getState().preparation.generation).toBe(generation); + controller.setQuantity(key, "available", 0); + expect(store.getState().preparation).toMatchObject({ + generation: generation + 1, + phase: "editing", + url: "", + readyGeneration: null, + }); + controller.dispose(); + }); + + it("keeps stale cart lines visible but blocks preview after a catalog refresh removes a variant", async () => { + const store = createUniversalStore(createInitialState()); + const controller = createUniversalController({ + store, + catalogLoader: vi + .fn() + .mockResolvedValueOnce([variant("selected")]) + .mockResolvedValueOnce([variant("replacement")]), + }); + controller.addShop("shop-one.example.com"); + await vi.waitFor(() => expect(store.getState().shops[0]?.catalogStatus).toBe("ready")); + const key = store.getState().shops[0]!.key; + controller.setQuantity(key, "selected", 1); + controller.retryShop(key); + await vi.waitFor(() => expect(store.getState().shops[0]?.variants[0]?.id).toBe("replacement")); + expect(store.getState().shops[0]?.cartLines).toEqual([{ variantId: "selected", quantity: 1 }]); + expect(selectShopCartPreview(store.getState().shops[0]!)).toBe(""); + expect(selectCartReadiness(store.getState()).hint).toContain("Remove unavailable"); + controller.dispose(); + }); +}); diff --git a/platforms/web/sample/universal/controller.ts b/platforms/web/sample/universal/controller.ts new file mode 100644 index 000000000..d967d928e --- /dev/null +++ b/platforms/web/sample/universal/controller.ts @@ -0,0 +1,190 @@ +import { normalizeQuantity, upsertCartLine } from "../cart"; +import { loadCatalog, type CatalogLoader } from "./catalog"; +import { parseShopDomain } from "./domain"; +import { + selectQuantity, + selectShop, + withInputChange, + type ShopState, + type UniversalStore, +} from "./state"; + +const CATALOG_FAILURE = + "Could not load public products. Check the domain and published products, then retry."; +const EMPTY_CATALOG = "No products were found. Publish products to this shop and retry."; + +export interface UniversalController { + addShop(rawDomain: string): boolean; + clearAddError(): void; + retryShop(key: string): void; + removeShop(key: string): void; + setQuantity(key: string, variantId: string, quantity: unknown): void; + setRuntimeNotice(notice: string): void; + dispose(): void; +} + +interface ControllerOptions { + store: UniversalStore; + catalogLoader?: CatalogLoader; +} + +export function createUniversalController(options: ControllerOptions): UniversalController { + const { store } = options; + const fetchCatalog = options.catalogLoader ?? loadCatalog; + const requests = new Map(); + let nextKey = 0; + let disposed = false; + + function clearAddError(): void { + store.update((state) => (state.addShopError ? { ...state, addShopError: "" } : state)); + } + + function loadShop(key: string): void { + if (disposed) return; + const initialShop = selectShop(store.getState(), key); + if (!initialShop) return; + + requests.get(key)?.abort(); + const request = new AbortController(); + requests.set(key, request); + + store.update((state) => { + const shop = selectShop(state, key); + if (!shop) return state; + if (shop.catalogStatus === "loading" && !shop.catalogError) return state; + const shops = state.shops.map((entry) => + entry.key === key + ? { ...entry, catalogStatus: "loading" as const, catalogError: "" } + : entry, + ); + return withInputChange(state, shops); + }); + + void fetchCatalog(initialShop.domain, request.signal) + .then((variants) => { + if (disposed || request.signal.aborted || requests.get(key) !== request) return undefined; + requests.delete(key); + return store.update((state) => { + if (!selectShop(state, key)) return state; + if (variants.length === 0) { + return { + ...state, + shops: state.shops.map((shop) => + shop.key === key + ? { ...shop, catalogStatus: "error" as const, catalogError: EMPTY_CATALOG } + : shop, + ), + }; + } + return { + ...state, + shops: state.shops.map((shop) => + shop.key === key + ? { ...shop, catalogStatus: "ready" as const, catalogError: "", variants } + : shop, + ), + }; + }); + }) + .catch(() => { + if (disposed || request.signal.aborted || requests.get(key) !== request) return; + requests.delete(key); + store.update((state) => { + if (!selectShop(state, key)) return state; + return { + ...state, + shops: state.shops.map((shop) => + shop.key === key + ? { ...shop, catalogStatus: "error" as const, catalogError: CATALOG_FAILURE } + : shop, + ), + }; + }); + }); + } + + function addShop(rawDomain: string): boolean { + if (disposed) return false; + const parsed = parseShopDomain(rawDomain); + if (!parsed.ok) { + store.update((state) => ({ ...state, addShopError: parsed.message })); + return false; + } + if (store.getState().shops.some((shop) => shop.domain === parsed.domain)) { + store.update((state) => ({ + ...state, + addShopError: `${parsed.domain} is already selected.`, + })); + return false; + } + + const key = `shop-${++nextKey}`; + const shop: ShopState = { + key, + domain: parsed.domain, + catalogStatus: "loading", + catalogError: "", + variants: [], + cartLines: [], + cartRevision: 0, + }; + store.update((state) => ({ + ...withInputChange(state, [...state.shops, shop]), + addShopError: "", + })); + loadShop(key); + return true; + } + + function retryShop(key: string): void { + const shop = selectShop(store.getState(), key); + if (!shop || shop.catalogStatus === "loading") return; + loadShop(key); + } + + function removeShop(key: string): void { + if (!selectShop(store.getState(), key)) return; + requests.get(key)?.abort(); + requests.delete(key); + store.update((state) => + withInputChange( + state, + state.shops.filter((shop) => shop.key !== key), + ), + ); + } + + function setQuantity(key: string, variantId: string, quantity: unknown): void { + const normalizedId = variantId.trim(); + if (!normalizedId) return; + store.update((state) => { + const shop = selectShop(state, key); + if (!shop) return state; + const nextQuantity = Number(quantity) <= 0 ? 0 : normalizeQuantity(quantity); + const currentQuantity = selectQuantity(shop, normalizedId); + if (currentQuantity === nextQuantity) return state; + if (nextQuantity > 0) { + const variant = shop.variants.find((entry) => entry.id === normalizedId); + if (shop.catalogStatus !== "ready" || !variant?.available) return state; + } + + const cartLines = upsertCartLine(shop.cartLines, normalizedId, nextQuantity); + const shops = state.shops.map((entry) => + entry.key === key ? { ...entry, cartLines, cartRevision: entry.cartRevision + 1 } : entry, + ); + return withInputChange(state, shops); + }); + } + + function setRuntimeNotice(notice: string): void { + store.update((state) => ({ ...state, runtime: { notice } })); + } + + function dispose(): void { + disposed = true; + for (const request of requests.values()) request.abort(); + requests.clear(); + } + + return { addShop, clearAddError, retryShop, removeShop, setQuantity, setRuntimeNotice, dispose }; +} diff --git a/platforms/web/sample/universal/domain.test.ts b/platforms/web/sample/universal/domain.test.ts new file mode 100644 index 000000000..d2d4db197 --- /dev/null +++ b/platforms/web/sample/universal/domain.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { parseShopDomain } from "./domain"; + +describe("parseShopDomain", () => { + it("normalizes bare domains and HTTPS homepages for duplicate detection", () => { + expect(parseShopDomain(" SHOP-ONE.example.com ")).toEqual({ + ok: true, + domain: "shop-one.example.com", + }); + expect(parseShopDomain("https://shop-one.example.com/")).toEqual({ + ok: true, + domain: "shop-one.example.com", + }); + }); + + it.each([ + "http://shop-one.example.com", + "https://user:secret@shop-one.example.com", + "shop-one.example.com:443", + "https://shop-one.example.com/products/book", + "https://shop-one.example.com/?token=secret", + "shop-one..example.com", + "localhost", + "//shop-one.example.com", + ])("rejects ambiguous or unsafe input %s before normalizing it", (raw) => { + expect(parseShopDomain(raw).ok).toBe(false); + }); +}); diff --git a/platforms/web/sample/universal/domain.ts b/platforms/web/sample/universal/domain.ts new file mode 100644 index 000000000..138ebea53 --- /dev/null +++ b/platforms/web/sample/universal/domain.ts @@ -0,0 +1,50 @@ +import { normalizeStorefrontDomain } from "../cart"; + +export type ParsedShopDomain = { ok: true; domain: string } | { ok: false; message: string }; + +const DOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const DOMAIN_SUFFIX = /^[a-z]{2,}$/; + +/** Accept a shop domain or its HTTPS homepage, without silently dropping URL authority. */ +export function parseShopDomain(raw: string): ParsedShopDomain { + const value = raw.trim(); + if (!value) return { ok: false, message: "Enter a storefront domain." }; + if (value.includes("\\") || value.startsWith("//")) { + return { ok: false, message: "Enter a storefront domain or its HTTPS homepage." }; + } + + const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value)?.[0] ?? ""; + const authority = value.slice(scheme.length).split(/[/?#]/, 1)[0] ?? ""; + if (authority.includes("@") || authority.includes(":")) { + return { ok: false, message: "Storefront domains cannot include credentials or ports." }; + } + + let url: URL; + try { + url = new URL(scheme ? value : `https://${value}`); + } catch { + return { ok: false, message: "Enter a valid storefront domain." }; + } + + if (url.protocol !== "https:" || url.username || url.password || url.port) { + return { + ok: false, + message: "Storefront domains must use HTTPS without credentials or ports.", + }; + } + if (url.pathname !== "/" || url.search || url.hash) { + return { ok: false, message: "Enter only the storefront domain, without a path or query." }; + } + + const domain = normalizeStorefrontDomain(url.toString()); + const labels = domain.split("."); + if ( + labels.length < 2 || + !labels.every((label) => DOMAIN_LABEL.test(label)) || + !DOMAIN_SUFFIX.test(labels[labels.length - 1] ?? "") + ) { + return { ok: false, message: "Enter a complete storefront domain." }; + } + + return { ok: true, domain }; +} diff --git a/platforms/web/sample/universal/state.ts b/platforms/web/sample/universal/state.ts new file mode 100644 index 000000000..f4587a4fc --- /dev/null +++ b/platforms/web/sample/universal/state.ts @@ -0,0 +1,175 @@ +import type { LogLevel } from "@shopify/checkout-kit"; + +import { + buildCartPermalink, + cartLineTotalQuantity, + type CartLine, + type ProductVariantOption, +} from "../cart"; + +export type CatalogStatus = "loading" | "ready" | "error"; + +export interface ShopState { + key: string; + domain: string; + catalogStatus: CatalogStatus; + catalogError: string; + variants: ProductVariantOption[]; + cartLines: CartLine[]; + cartRevision: number; +} + +export interface PreparationState { + generation: number; + phase: "editing" | "creatingCarts" | "creatingSession" | "ready" | "error"; + url: string; + readyGeneration: number | null; + error: string; +} + +export interface DisplayState { + target: "popup" | "auto"; + appearance: string; + logLevel: LogLevel; + settingsCollapsed: boolean; + eventsCollapsed: boolean; +} + +export interface UniversalState { + shops: ShopState[]; + addShopError: string; + preparation: PreparationState; + display: DisplayState; + runtime: { notice: string }; +} + +export type StateListener = (state: UniversalState, previous: UniversalState) => void; + +export interface UniversalStore { + getState(): UniversalState; + update(change: (state: UniversalState) => UniversalState): void; + subscribe(listener: StateListener): () => void; +} + +export const DEFAULT_DISPLAY: DisplayState = { + target: "popup", + appearance: "", + logLevel: "warn", + settingsCollapsed: false, + eventsCollapsed: false, +}; + +export function createInitialState(display: DisplayState = DEFAULT_DISPLAY): UniversalState { + return { + shops: [], + addShopError: "", + preparation: { generation: 0, phase: "editing", url: "", readyGeneration: null, error: "" }, + display, + runtime: { notice: "Open a universal checkout to see received events." }, + }; +} + +export function createUniversalStore(initial: UniversalState): UniversalStore { + let state = initial; + const listeners = new Set(); + + return { + getState: () => state, + update(change) { + const next = change(state); + if (next === state) return; + const previous = state; + state = next; + for (const listener of listeners) listener(next, previous); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +/** An actual cart/shop input edit invalidates any prepared continuation URL. */ +export function withInputChange(state: UniversalState, shops: ShopState[]): UniversalState { + return { + ...state, + shops, + preparation: { + generation: state.preparation.generation + 1, + phase: "editing", + url: "", + readyGeneration: null, + error: "", + }, + }; +} + +export function selectShop(state: UniversalState, key: string): ShopState | undefined { + return state.shops.find((shop) => shop.key === key); +} + +export function selectQuantity(shop: ShopState, variantId: string): number { + return shop.cartLines.find((line) => line.variantId === variantId)?.quantity ?? 0; +} + +export function invalidCartLines(shop: ShopState): CartLine[] { + return shop.cartLines.filter( + (line) => !shop.variants.some((variant) => variant.id === line.variantId && variant.available), + ); +} + +export function selectShopCartPreview(shop: ShopState): string { + if ( + shop.catalogStatus !== "ready" || + shop.cartLines.length === 0 || + invalidCartLines(shop).length + ) { + return ""; + } + try { + return buildCartPermalink(shop.domain, shop.cartLines); + } catch { + return ""; + } +} + +export interface CartReadiness { + ready: boolean; + shopCount: number; + itemCount: number; + hint: string; +} + +export function selectCartReadiness(state: UniversalState): CartReadiness { + const shopCount = state.shops.length; + const itemCount = state.shops.reduce( + (total, shop) => total + cartLineTotalQuantity(shop.cartLines), + 0, + ); + const base = { shopCount, itemCount }; + + if (shopCount === 0) { + return { ...base, ready: false, hint: "Add a shop to start building carts." }; + } + + for (const shop of state.shops) { + if (shop.catalogStatus === "loading") { + return { ...base, ready: false, hint: `Wait for products from ${shop.domain} to load.` }; + } + if (shop.catalogStatus === "error") { + return { ...base, ready: false, hint: `Retry product loading for ${shop.domain}.` }; + } + if (shop.cartLines.length === 0) { + return { ...base, ready: false, hint: `Add a product to ${shop.domain}'s cart.` }; + } + if (invalidCartLines(shop).length > 0) { + return { + ...base, + ready: false, + hint: `Remove unavailable products from ${shop.domain}'s cart.`, + }; + } + } + + return { ...base, ready: true, hint: "Every selected shop has a valid cart." }; +} diff --git a/platforms/web/sample/universal/storage.test.ts b/platforms/web/sample/universal/storage.test.ts new file mode 100644 index 000000000..61d846d0d --- /dev/null +++ b/platforms/web/sample/universal/storage.test.ts @@ -0,0 +1,15 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { loadUniversalDisplay, persistUniversalWidth, readUniversalWidth } from "./storage"; + +beforeEach(() => localStorage.clear()); + +describe("Universal sample storage", () => { + it("uses column keys separate from the standard sample", () => { + localStorage.setItem("checkout-kit:web-demo:col-left", "280"); + persistUniversalWidth("left", 320); + expect(readUniversalWidth("left")).toBe(320); + expect(localStorage.getItem("checkout-kit:web-demo:col-left")).toBe("280"); + expect(loadUniversalDisplay().target).toBe("popup"); + }); +}); diff --git a/platforms/web/sample/universal/storage.ts b/platforms/web/sample/universal/storage.ts new file mode 100644 index 000000000..a34f84d25 --- /dev/null +++ b/platforms/web/sample/universal/storage.ts @@ -0,0 +1,48 @@ +import { parseColumnWidth, type ColumnSide } from "../columns"; +import { coerceLogLevel, readStorage, writeStorage } from "../storage"; +import { DEFAULT_DISPLAY, type DisplayState } from "./state"; + +export const UNIVERSAL_STORAGE_KEYS = { + target: "checkout-kit:universal-demo:target", + appearance: "checkout-kit:universal-demo:appearance", + logLevel: "checkout-kit:universal-demo:log-level", + settingsCollapsed: "checkout-kit:universal-demo:settings-collapsed", + eventsCollapsed: "checkout-kit:universal-demo:events-collapsed", + columnLeft: "checkout-kit:universal-demo:col-left", + columnRight: "checkout-kit:universal-demo:col-right", +} as const; + +const APPEARANCES = new Set(["", "app:light", "app:dark", "app:automatic", "storefront"]); + +export function loadUniversalDisplay(): DisplayState { + const appearance = readStorage(UNIVERSAL_STORAGE_KEYS.appearance); + return { + ...DEFAULT_DISPLAY, + target: readStorage(UNIVERSAL_STORAGE_KEYS.target) === "auto" ? "auto" : "popup", + appearance: APPEARANCES.has(appearance) ? appearance : "", + logLevel: coerceLogLevel(readStorage(UNIVERSAL_STORAGE_KEYS.logLevel)), + settingsCollapsed: readStorage(UNIVERSAL_STORAGE_KEYS.settingsCollapsed) === "1", + eventsCollapsed: readStorage(UNIVERSAL_STORAGE_KEYS.eventsCollapsed) === "1", + }; +} + +export function persistUniversalDisplay(display: DisplayState): void { + writeStorage(UNIVERSAL_STORAGE_KEYS.target, display.target); + writeStorage(UNIVERSAL_STORAGE_KEYS.appearance, display.appearance); + writeStorage(UNIVERSAL_STORAGE_KEYS.logLevel, display.logLevel); + writeStorage(UNIVERSAL_STORAGE_KEYS.settingsCollapsed, display.settingsCollapsed ? "1" : ""); + writeStorage(UNIVERSAL_STORAGE_KEYS.eventsCollapsed, display.eventsCollapsed ? "1" : ""); +} + +const WIDTH_KEYS: Record = { + left: UNIVERSAL_STORAGE_KEYS.columnLeft, + right: UNIVERSAL_STORAGE_KEYS.columnRight, +}; + +export function readUniversalWidth(side: ColumnSide): number | null { + return parseColumnWidth(readStorage(WIDTH_KEYS[side])); +} + +export function persistUniversalWidth(side: ColumnSide, width: number | null): void { + writeStorage(WIDTH_KEYS[side], width === null ? "" : String(width)); +} diff --git a/platforms/web/sample/universal/views.test.ts b/platforms/web/sample/universal/views.test.ts new file mode 100644 index 000000000..3af79eeef --- /dev/null +++ b/platforms/web/sample/universal/views.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import page from "../universal.html?raw"; +import type { ProductVariantOption } from "../cart"; +import { createUniversalController } from "./controller"; +import { createInitialState, createUniversalStore, type ShopState } from "./state"; +import { queryUniversalRefs, renderUniversalApp, renderUniversalChange } from "./views"; + +function fixture(): void { + const start = page.indexOf('
      '); + const end = page.indexOf("
      ", start); + document.body.innerHTML = page.slice(start, end + "
      ".length); +} + +function variant(id: string, available = true): ProductVariantOption { + return { + id, + title: `Product ${id}`, + productTitle: `Product ${id}`, + variantTitle: "Default Title", + vendor: "Synthetic shop", + price: "10.00", + available, + }; +} + +function shop(): ShopState { + return { + key: "shop-1", + domain: "shop-one.example.com", + catalogStatus: "ready", + catalogError: "", + variants: [variant("123"), variant("456", false)], + cartLines: [{ variantId: "123", quantity: 2 }], + cartRevision: 1, + }; +} + +beforeEach(fixture); + +describe("Universal sample views", () => { + it("renders independent shop cards, cart controls, and neutral price text", () => { + const refs = queryUniversalRefs(); + renderUniversalApp(refs, { ...createInitialState(), shops: [shop()] }); + + expect(refs.shopList.querySelectorAll(".product-card")).toHaveLength(2); + expect(refs.shopList.querySelectorAll(".cart-line")).toHaveLength(1); + expect(refs.shopList.querySelector(".product-card")?.getAttribute("data-shop-key")).toBe( + "shop-1", + ); + expect(refs.shopList.querySelector(".cart-line")?.getAttribute("data-variant-id")).toBe("123"); + expect(refs.itemCount.textContent).toBe("2 items"); + expect(refs.shopCount.textContent).toBe("1 shop"); + expect(refs.shopList.querySelector(".product-price")?.textContent).toBe( + "10.00 · currency unavailable", + ); + expect( + refs.shopList.querySelector('[data-variant-id="456"] [data-cart-action="add"]'), + ).toBeNull(); + expect(refs.shopList.querySelector(".permalink-link")?.getAttribute("href")).toBe( + "https://shop-one.example.com/cart/123:2", + ); + }); + + it("updates runtime without rebuilding product controls or losing the shop draft focus", () => { + const refs = queryUniversalRefs(); + const store = createUniversalStore({ ...createInitialState(), shops: [shop()] }); + const controller = createUniversalController({ store }); + renderUniversalApp(refs, store.getState()); + const unsubscribe = store.subscribe((state, previous) => + renderUniversalChange(refs, state, previous), + ); + const card = refs.shopList.querySelector(".product-card"); + const quantity = refs.shopList.querySelector(".cart-line-quantity"); + refs.domainInput.value = "partially-typed.example.com"; + refs.domainInput.focus(); + + controller.setRuntimeNotice("Received ec.update"); + + expect(refs.shopList.querySelector(".product-card")).toBe(card); + expect(refs.shopList.querySelector(".cart-line-quantity")).toBe(quantity); + expect(document.activeElement).toBe(refs.domainInput); + expect(refs.domainInput.value).toBe("partially-typed.example.com"); + expect(refs.runtimeNotice.textContent).toBe("Received ec.update"); + unsubscribe(); + controller.dispose(); + }); + + it("preserves an uncommitted quantity draft while another shop changes", () => { + const refs = queryUniversalRefs(); + const loadingShop: ShopState = { + ...shop(), + key: "shop-2", + domain: "shop-two.example.com", + catalogStatus: "loading", + variants: [], + cartLines: [], + }; + const store = createUniversalStore({ ...createInitialState(), shops: [shop(), loadingShop] }); + renderUniversalApp(refs, store.getState()); + const unsubscribe = store.subscribe((state, previous) => + renderUniversalChange(refs, state, previous), + ); + + const productQuantity = refs.shopList.querySelector( + '[data-shop-key="shop-1"] .cart-line-quantity', + ); + expect(productQuantity).not.toBeNull(); + productQuantity!.focus(); + productQuantity!.value = "27"; + + store.update((state) => ({ + ...state, + shops: state.shops.map((entry) => + entry.key === "shop-2" + ? { ...entry, catalogStatus: "ready", variants: [variant("789")] } + : entry, + ), + })); + + expect(refs.shopList.querySelector('[data-shop-key="shop-1"] .cart-line-quantity')).toBe( + productQuantity, + ); + expect(document.activeElement).toBe(productQuantity); + expect(productQuantity!.value).toBe("27"); + expect(refs.shopList.querySelector('[data-shop-key="shop-2"] .product-card')).not.toBeNull(); + + const summaryQuantity = refs.shopList.querySelector( + '[data-shop-key="shop-1"] .cart-line-summary-quantity', + ); + expect(summaryQuantity).not.toBeNull(); + summaryQuantity!.focus(); + summaryQuantity!.value = "14"; + + store.update((state) => ({ + ...state, + shops: state.shops.map((entry) => + entry.key === "shop-2" + ? { ...entry, cartLines: [{ variantId: "789", quantity: 1 }] } + : entry, + ), + })); + + expect( + refs.shopList.querySelector('[data-shop-key="shop-1"] .cart-line-summary-quantity'), + ).toBe(summaryQuantity); + expect(document.activeElement).toBe(summaryQuantity); + expect(summaryQuantity!.value).toBe("14"); + unsubscribe(); + }); + + it("shows stale lines and a blocking correction message after catalog changes", () => { + const refs = queryUniversalRefs(); + const stale = { ...shop(), variants: [variant("replacement")] }; + renderUniversalApp(refs, { ...createInitialState(), shops: [stale] }); + + expect(refs.shopList.querySelector(".cart-line")?.textContent).toContain("Unavailable"); + expect(refs.shopList.querySelector(".permalink-link")?.hasAttribute("href")).toBe(false); + expect(refs.readiness.textContent).toContain("Remove unavailable"); + }); +}); diff --git a/platforms/web/sample/universal/views.ts b/platforms/web/sample/universal/views.ts new file mode 100644 index 000000000..f8e6804de --- /dev/null +++ b/platforms/web/sample/universal/views.ts @@ -0,0 +1,401 @@ +import { cartLineTotalQuantity, type CartLine, type ProductVariantOption } from "../cart"; +import { $, quantityButton } from "../dom"; +import { + invalidCartLines, + selectCartReadiness, + selectQuantity, + selectShopCartPreview, + type ShopState, + type UniversalState, +} from "./state"; + +export interface UniversalRefs { + layout: HTMLElement; + settingsPanel: HTMLElement; + runtimePanel: HTMLElement; + resizeLeft: HTMLElement; + resizeRight: HTMLElement; + form: HTMLFormElement; + domainInput: HTMLInputElement; + addError: HTMLParagraphElement; + settingsToggle: HTMLButtonElement; + eventsToggle: HTMLButtonElement; + targetInput: HTMLSelectElement; + appearanceInput: HTMLSelectElement; + logLevelInput: HTMLSelectElement; + shopList: HTMLDivElement; + shopsEmpty: HTMLDivElement; + shopCount: HTMLSpanElement; + itemCount: HTMLSpanElement; + cartSummary: HTMLParagraphElement; + selectedShops: HTMLUListElement; + readiness: HTMLParagraphElement; + preparationStatus: HTMLParagraphElement; + runtimeNotice: HTMLParagraphElement; +} + +export function queryUniversalRefs(): UniversalRefs { + return { + layout: $("#layout"), + settingsPanel: $(".settings-panel"), + runtimePanel: $(".runtime-panel"), + resizeLeft: $("#resize-left"), + resizeRight: $("#resize-right"), + form: $("#options-form"), + domainInput: $("#uc-domain"), + addError: $("#uc-add-error"), + settingsToggle: $("#toggle-settings"), + eventsToggle: $("#toggle-events"), + targetInput: $("#uc-target"), + appearanceInput: $("#uc-appearance"), + logLevelInput: $("#uc-log-level"), + shopList: $("#uc-shop-list"), + shopsEmpty: $("#uc-shops-empty"), + shopCount: $("#uc-shop-count"), + itemCount: $("#uc-item-count"), + cartSummary: $("#uc-cart-summary"), + selectedShops: $("#uc-selected-shops"), + readiness: $("#uc-readiness"), + preparationStatus: $("#uc-preparation-status"), + runtimeNotice: $("#uc-runtime-notice"), + }; +} + +function appendImage(container: HTMLElement, imageUrl?: string): void { + if (imageUrl) { + try { + const url = new URL(imageUrl); + if (url.protocol === "https:") { + const image = document.createElement("img"); + image.src = url.toString(); + image.alt = ""; + container.append(image); + return; + } + } catch { + // A missing or invalid image uses the same placeholder as the standard sample. + } + } + container.textContent = "📦"; +} + +function priceLabel(variant: ProductVariantOption): string { + return variant.price ? `${variant.price} · currency unavailable` : "Price unavailable"; +} + +function productCard(shop: ShopState, variant: ProductVariantOption): HTMLLIElement { + const quantity = selectQuantity(shop, variant.id); + const item = document.createElement("li"); + item.className = "product-card"; + item.dataset["shopKey"] = shop.key; + item.dataset["variantId"] = variant.id; + + const image = document.createElement("div"); + image.className = "product-image"; + appendImage(image, variant.imageUrl); + + const details = document.createElement("div"); + details.className = "product-info"; + const vendor = document.createElement("p"); + vendor.className = "product-vendor"; + vendor.textContent = variant.vendor || "Storefront product"; + const title = document.createElement("h4"); + title.className = "product-title"; + title.textContent = variant.title; + const meta = document.createElement("p"); + meta.className = "product-meta"; + meta.textContent = `Variant ID: ${variant.id}`; + const price = document.createElement("p"); + price.className = "product-price"; + price.textContent = priceLabel(variant); + 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} at ${shop.domain}`); + controls.append(input, quantityButton("+", "increment", variant.title)); + actions.append(controls); + } else { + const add = document.createElement("button"); + add.type = "button"; + add.className = "secondary-action"; + add.dataset["cartAction"] = "add"; + add.textContent = "Add to cart"; + actions.append(add); + } + + details.append(vendor, title, meta, price, actions); + item.append(image, details); + return item; +} + +function cartLine(shop: ShopState, line: CartLine): HTMLLIElement { + const variant = shop.variants.find((entry) => entry.id === line.variantId); + const item = document.createElement("li"); + item.className = "cart-line"; + item.dataset["shopKey"] = shop.key; + item.dataset["variantId"] = line.variantId; + + const image = document.createElement("div"); + image.className = "cart-line-image"; + appendImage(image, variant?.imageUrl); + const details = document.createElement("div"); + details.className = "cart-line-details"; + const title = document.createElement("strong"); + title.className = "cart-line-title"; + title.textContent = variant?.title ?? `Variant ${line.variantId}`; + const meta = document.createElement("span"); + meta.className = "cart-line-meta"; + meta.textContent = + !variant || !variant.available + ? "Unavailable or no longer in the catalog" + : priceLabel(variant); + details.append(title, meta); + + const controls = document.createElement("div"); + controls.className = "cart-line-controls"; + if (shop.catalogStatus === "ready" && variant?.available) { + controls.append(quantityButton("−", "decrement", title.textContent)); + const quantity = document.createElement("input"); + quantity.type = "number"; + quantity.className = "cart-line-summary-quantity"; + quantity.min = "1"; + quantity.max = "999"; + quantity.value = String(line.quantity); + quantity.setAttribute("aria-label", `Quantity for ${title.textContent} at ${shop.domain}`); + controls.append(quantity, quantityButton("+", "increment", title.textContent)); + } else { + controls.textContent = `${line.quantity} selected`; + } + + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "remove-line-button"; + remove.dataset["cartAction"] = "remove"; + remove.textContent = "×"; + remove.setAttribute("aria-label", `Remove ${title.textContent} from ${shop.domain}`); + item.append(image, details, controls, remove); + return item; +} + +function renderShop(shop: ShopState): HTMLElement { + const section = document.createElement("section"); + section.className = "uc-shop"; + section.dataset["shopKey"] = shop.key; + const headingId = `uc-shop-heading-${shop.key}`; + section.setAttribute("aria-labelledby", headingId); + + const header = document.createElement("div"); + header.className = "uc-shop-header"; + const heading = document.createElement("h3"); + heading.id = headingId; + heading.textContent = shop.domain; + const status = document.createElement("span"); + status.className = "status-pill"; + status.textContent = + shop.catalogStatus === "ready" + ? `${shop.variants.length} variants` + : shop.catalogStatus === "loading" + ? "Loading" + : "Load failed"; + const actions = document.createElement("div"); + actions.className = "uc-shop-actions"; + if (shop.catalogStatus === "error") { + const retry = document.createElement("button"); + retry.type = "button"; + retry.dataset["shopAction"] = "retry"; + retry.textContent = "Retry"; + retry.setAttribute("aria-label", `Retry products from ${shop.domain}`); + actions.append(retry); + } + const remove = document.createElement("button"); + remove.type = "button"; + remove.dataset["shopAction"] = "remove"; + remove.textContent = "Remove shop"; + remove.setAttribute("aria-label", `Remove ${shop.domain}`); + actions.append(remove); + header.append(heading, status, actions); + + const cart = document.createElement("div"); + cart.className = "uc-shop-cart"; + const cartHeading = document.createElement("h4"); + const selectedQuantity = cartLineTotalQuantity(shop.cartLines); + cartHeading.textContent = + selectedQuantity === 1 ? "Cart · 1 item" : `Cart · ${selectedQuantity} items`; + const lines = document.createElement("ol"); + lines.className = "cart-lines"; + for (const line of shop.cartLines) lines.append(cartLine(shop, line)); + const cartHint = document.createElement("p"); + cartHint.className = "muted"; + cartHint.textContent = + invalidCartLines(shop).length > 0 + ? "Remove unavailable lines before creating a checkout URL." + : "Add available products to this shop’s cart."; + cartHint.hidden = shop.cartLines.length > 0 && invalidCartLines(shop).length === 0; + const source = document.createElement("div"); + source.className = "computed-source"; + const sourceLabel = document.createElement("span"); + sourceLabel.textContent = "Individual cart preview"; + const preview = document.createElement("a"); + preview.className = "permalink-link"; + preview.target = "_blank"; + preview.rel = "noopener noreferrer"; + const previewUrl = selectShopCartPreview(shop); + if (previewUrl) { + preview.href = previewUrl; + preview.textContent = "Open this shop’s cart permalink"; + preview.dataset["empty"] = "false"; + } else { + preview.textContent = "Add available products to preview this shop’s cart"; + preview.dataset["empty"] = "true"; + } + source.append(sourceLabel, preview); + cart.append(cartHeading, lines, cartHint, source); + + const catalog = document.createElement("div"); + catalog.className = "uc-shop-catalog"; + if (shop.catalogStatus === "loading" || shop.catalogStatus === "error") { + const notice = document.createElement("p"); + notice.className = "notice"; + notice.dataset["tone"] = shop.catalogStatus === "error" ? "error" : "info"; + notice.setAttribute("role", shop.catalogStatus === "error" ? "alert" : "status"); + notice.textContent = + shop.catalogStatus === "error" ? shop.catalogError : "Loading public products…"; + catalog.append(notice); + } else { + const grid = document.createElement("ul"); + grid.className = "product-grid"; + for (const variant of shop.variants) grid.append(productCard(shop, variant)); + catalog.append(grid); + } + + section.append(header, cart, catalog); + return section; +} + +export function renderShopList( + refs: UniversalRefs, + state: UniversalState, + previous?: UniversalState, +): void { + refs.shopsEmpty.hidden = state.shops.length > 0; + const currentByKey = new Map(); + for (const section of refs.shopList.children) { + if (section instanceof HTMLElement && section.dataset["shopKey"]) { + currentByKey.set(section.dataset["shopKey"], section); + } + } + const previousByKey = new Map((previous?.shops ?? []).map((shop) => [shop.key, shop] as const)); + const selectedKeys = new Set(state.shops.map((shop) => shop.key)); + for (const [key, section] of currentByKey) { + if (!selectedKeys.has(key)) section.remove(); + } + + // Keep an unchanged shop mounted while another shop loads or changes its cart. + const sections = state.shops.map((shop) => { + const current = currentByKey.get(shop.key); + if (current && previousByKey.get(shop.key) === shop) return current; + const section = renderShop(shop); + if (current) current.replaceWith(section); + else refs.shopList.append(section); + return section; + }); + for (const [index, section] of sections.entries()) { + const atIndex = refs.shopList.children.item(index); + if (atIndex !== section) refs.shopList.insertBefore(section, atIndex); + } + while (refs.shopList.children.length > sections.length) refs.shopList.lastElementChild?.remove(); +} + +export function renderSummary(refs: UniversalRefs, state: UniversalState): void { + const summary = selectCartReadiness(state); + refs.shopCount.textContent = summary.shopCount === 1 ? "1 shop" : `${summary.shopCount} shops`; + refs.itemCount.textContent = summary.itemCount === 1 ? "1 item" : `${summary.itemCount} items`; + refs.cartSummary.textContent = + summary.shopCount === 0 + ? "Add shops and choose products to build independent carts." + : `${summary.shopCount} selected ${summary.shopCount === 1 ? "shop" : "shops"} · ${summary.itemCount} selected ${summary.itemCount === 1 ? "item" : "items"}`; + refs.readiness.textContent = summary.hint; + refs.readiness.dataset["tone"] = summary.ready ? "success" : "info"; + refs.selectedShops.replaceChildren(); + for (const shop of state.shops) { + const item = document.createElement("li"); + const count = cartLineTotalQuantity(shop.cartLines); + item.textContent = `${shop.domain}: ${count} ${count === 1 ? "item" : "items"}`; + refs.selectedShops.append(item); + } +} + +export function renderPreparation(refs: UniversalRefs, state: UniversalState): void { + const summary = selectCartReadiness(state); + refs.preparationStatus.textContent = summary.ready + ? "All selected shop carts are ready for URL creation." + : summary.hint; +} + +export function renderRuntime(refs: UniversalRefs, state: UniversalState): void { + refs.runtimeNotice.textContent = state.runtime.notice; +} + +export function renderSettings(refs: UniversalRefs, state: UniversalState): void { + refs.addError.hidden = state.addShopError === ""; + refs.addError.textContent = state.addShopError; + refs.domainInput.setAttribute("aria-invalid", String(state.addShopError !== "")); + refs.layout.classList.toggle("settings-collapsed", state.display.settingsCollapsed); + refs.layout.classList.toggle("events-collapsed", state.display.eventsCollapsed); + refs.settingsToggle.setAttribute("aria-expanded", String(!state.display.settingsCollapsed)); + refs.eventsToggle.setAttribute("aria-expanded", String(!state.display.eventsCollapsed)); + refs.settingsToggle.setAttribute( + "aria-label", + state.display.settingsCollapsed ? "Show settings panel" : "Hide settings panel", + ); + refs.eventsToggle.setAttribute( + "aria-label", + state.display.eventsCollapsed ? "Show events panel" : "Hide events panel", + ); + refs.targetInput.value = state.display.target; + refs.appearanceInput.value = state.display.appearance; + refs.logLevelInput.value = state.display.logLevel; +} + +export function renderUniversalApp(refs: UniversalRefs, state: UniversalState): void { + renderSettings(refs, state); + renderShopList(refs, state); + renderSummary(refs, state); + renderPreparation(refs, state); + renderRuntime(refs, state); +} + +/** Received events only touch Runtime; product inputs and the Add shop draft stay mounted. */ +export function renderUniversalChange( + refs: UniversalRefs, + state: UniversalState, + previous: UniversalState, +): void { + if (state.shops !== previous.shops) { + renderShopList(refs, state, previous); + renderSummary(refs, state); + renderPreparation(refs, state); + } else if (state.preparation !== previous.preparation) { + renderSummary(refs, state); + renderPreparation(refs, state); + } + if (state.display !== previous.display || state.addShopError !== previous.addShopError) { + renderSettings(refs, state); + } + if (state.runtime !== previous.runtime) renderRuntime(refs, state); +} diff --git a/platforms/web/sample/vite.config.ts b/platforms/web/sample/vite.config.ts index f37dd5d58..fda2b1056 100644 --- a/platforms/web/sample/vite.config.ts +++ b/platforms/web/sample/vite.config.ts @@ -14,16 +14,26 @@ export default defineConfig({ // Treat `sample/` as the project root so vite serves `index.html` from here. root: here, resolve: { - alias: { - // Same entry consumers use from npm (`import '@shopify/checkout-kit'`). - "@shopify/checkout-kit": resolve(here, "../src/index.ts"), - }, + alias: [ + { + find: /^@shopify\/checkout-kit\/universal$/, + replacement: resolve(here, "../src/universal.ts"), + }, + // Exact matching keeps the standard import from swallowing `/universal`. + { find: /^@shopify\/checkout-kit$/, replacement: resolve(here, "../src/index.ts") }, + ], }, build: { outDir: resolve(here, "dist"), emptyOutDir: true, target: "es2022", sourcemap: true, + rollupOptions: { + input: { + index: resolve(here, "index.html"), + universal: resolve(here, "universal.html"), + }, + }, }, server: { port: 5173,