From 0d879ba17d99cc8f69f92fa11d798d2675ab9c52 Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Mon, 28 Sep 2026 17:04:10 +0200 Subject: [PATCH] Configure and prepare Universal Checkout sample sessions --- .env.example | 14 + platforms/web/sample/README.md | 139 ++++- platforms/web/sample/styles.css | 17 + platforms/web/sample/universal-main.ts | 28 + platforms/web/sample/universal.html | 35 +- .../sample/universal/browser-policy.test.ts | 25 + .../web/sample/universal/browser-policy.ts | 15 + platforms/web/sample/universal/catalog.ts | 20 +- .../sample/universal/configuration.test.ts | 55 ++ .../web/sample/universal/configuration.ts | 40 ++ .../web/sample/universal/controller.test.ts | 28 + .../web/sample/universal/environment.test.ts | 65 +++ platforms/web/sample/universal/environment.ts | 44 ++ platforms/web/sample/universal/policy.test.ts | 91 ++++ platforms/web/sample/universal/policy.ts | 132 +++++ .../web/sample/universal/preparation.test.ts | 387 ++++++++++++++ platforms/web/sample/universal/preparation.ts | 313 +++++++++++ .../universal/server.middleware.test.ts | 103 ++++ platforms/web/sample/universal/server.test.ts | 390 ++++++++++++++ platforms/web/sample/universal/server.ts | 495 ++++++++++++++++++ platforms/web/sample/universal/state.ts | 36 +- platforms/web/sample/universal/transport.ts | 141 +++++ platforms/web/sample/universal/views.test.ts | 58 +- platforms/web/sample/universal/views.ts | 67 ++- platforms/web/sample/vite.config.ts | 9 + 25 files changed, 2717 insertions(+), 30 deletions(-) create mode 100644 platforms/web/sample/universal/browser-policy.test.ts create mode 100644 platforms/web/sample/universal/browser-policy.ts create mode 100644 platforms/web/sample/universal/configuration.test.ts create mode 100644 platforms/web/sample/universal/configuration.ts create mode 100644 platforms/web/sample/universal/environment.test.ts create mode 100644 platforms/web/sample/universal/environment.ts create mode 100644 platforms/web/sample/universal/policy.test.ts create mode 100644 platforms/web/sample/universal/policy.ts create mode 100644 platforms/web/sample/universal/preparation.test.ts create mode 100644 platforms/web/sample/universal/preparation.ts create mode 100644 platforms/web/sample/universal/server.middleware.test.ts create mode 100644 platforms/web/sample/universal/server.test.ts create mode 100644 platforms/web/sample/universal/server.ts create mode 100644 platforms/web/sample/universal/transport.ts diff --git a/.env.example b/.env.example index 68c603dcc..42afba579 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,20 @@ # Storefront details STOREFRONT_DOMAIN=your-store.myshopify.com STOREFRONT_ACCESS_TOKEN=your-public-storefront-access-token + +# Universal Checkout web sample (optional). A comma-separated list preselects +# shops on page load; leave the key unset to use STOREFRONT_DOMAIN instead. +# Set it to an empty string to start without selected shops. +# CHECKOUT_KIT_UC_SHOP_DOMAINS=store-one.myshopify.com,store-two.myshopify.com +# Additional exact domains approved for manually added shops. +CHECKOUT_KIT_UC_ALLOWED_SHOP_DOMAINS= +# Trusted session service URLs are server-only. Configure the environments you use. +# The production endpoint must use the shop.app host. +CHECKOUT_KIT_UC_SESSION_CREATE_URL= +CHECKOUT_KIT_UC_DEVELOPMENT_SESSION_CREATE_URL= +# The exact development continuation hostname is sent at runtime to the local page. +VITE_CHECKOUT_KIT_UC_DEVELOPMENT_CONTINUATION_HOST= + # Optional Apple Pay merchant identifiers used by accelerated checkout flows. # Will be read in sample apps as APPLE_PAY_MERCHANT_IDENTIFIER REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER= diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index 3712ebb7f..2843150ab 100644 --- a/platforms/web/sample/README.md +++ b/platforms/web/sample/README.md @@ -1,25 +1,28 @@ # 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 Checkout Kit lifecycle events. +This two-page Vite playground exercises the single-checkout +`` and experimental Universal Checkout +`` web components. Each page imports its package +entry point, aliased to local sources by Vite. The Universal API and sample +are an experimental preview and have not been released as a supported API. ## Run locally -```bash -cd platforms/web +From `platforms/web`, install the workspace dependencies and start Vite: + +```sh +pnpm install pnpm sample ``` -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. +Open the single-checkout or Universal Checkout URL printed by Vite. The +navigation at the top of either page switches between them. For the Universal +page, configure the local session environment as described below before +starting Vite. -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. +## Single checkout -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 +### What the demo shows The default flow highlights a multi-item cart use case: @@ -38,7 +41,7 @@ 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 +### Panels - **Settings** — persisted storefront domain, flow, target (`popup` | `auto`), appearance (default `storefront` | `app:light` | `app:dark` | `app:automatic` | `storefront`), and log-level (`debug` | `warn` | `error` | `none`) 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. @@ -53,7 +56,7 @@ snapshots do not produce another update. The `error` event exposes a `{code, message}` error. Checkout stays open for recoverable errors and closes automatically only for errors with `unrecoverable` severity. -## Troubleshooting product loading +### Troubleshooting product loading The demo relies on the public `/products.json` endpoint. If product loading fails: @@ -63,12 +66,112 @@ 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. -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. +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. + +## Universal Checkout + +> [!IMPORTANT] +> This is an experimental preview. Its API and end-to-end buyer flow remain +> subject to contract and live validation. + +The Universal page creates an independent Storefront cart for each selected shop, creates a Universal Checkout session from the resulting cart IDs, and opens its keyed continuation URL with Checkout Kit. Each shop's cart permalink on this page is only a preview; cart IDs are the session inputs. + +1. Configure the sample shops and session service in the repository root `.env` or `.env.local` as described below, then start Vite. Open the Universal page. In **Checkout session**, choose **Production** or **Local development** and a **Buyer country** supported by all test shops. +2. The configured shops appear in **Shops** and load products automatically. Add or remove storefront domains as needed. Products load from each shop's public `/products.json` endpoint. Choose available products and quantities for every selected shop. At most 15 shops can be in one session, with at most 50 distinct variants per shop cart. Every selected shop needs a nonempty cart. +3. Click **Create Universal Checkout URL**. The local Vite adapter calls Storefront API `2026-07` to create one cart per shop, checks that all cart currencies match, then sends their cart IDs to the Universal Checkout session endpoint. The keyed URL is held in page state and assigned to the component's `src` for opening; it is not visibly displayed or persisted. Changing shops, products, country, or environment invalidates it; create it again after an edit. +4. Leave **Use the URL created from selected carts** selected, then click **Open Universal Checkout**. This click calls the component's `open()` method directly, so the browser can open the checkout window. Sign in to the corresponding Shop Pay environment to continue as a buyer. +5. Watch **Resource state** and **Event timeline** in the checkout overlay. The **Events** panel on the page mirrors the received events. Expand a timeline entry for its redacted status, revision, and aggregate summary. **Focus checkout**, **Close checkout**, and **Clear events** operate on the current presentation; closing the window is a separate event from checkout completion. + +The event view uses the preview Checkout Kit `start`, `update`, `complete`, `error`, and `close` events. It shows only allowlisted fields and labels resources as `Shop 1`, `Shop 2`, and so on. The timeline does not copy raw checkout payloads, shop IDs, cart IDs, order details, or the keyed URL. Opening a new presentation starts a fresh timeline; **Clear events** clears the timeline while keeping the current resource state. + +### Environment configuration + +The Vite development and preview servers read the same repository root `.env` +and `.env.local` used by the other sample apps. `.env.local` overrides `.env`; +exported shell values are a fallback for keys missing from both files. Restart +the server after changing either file. No shell export or sample rebuild is +needed for a file change. + +Set `CHECKOUT_KIT_UC_SHOP_DOMAINS` to a comma-separated list of up to 15 exact +shop domains. When the key is absent, the sample uses `STOREFRONT_DOMAIN`. +Set the key to an empty value to start without selected shops. Configured shops +are selected on each page load; adding or removing shops through the UI affects +only the current page. Cart contents are never initialized or persisted. + +These are synthetic examples for the root `.env.local`: + +```dotenv +CHECKOUT_KIT_UC_SHOP_DOMAINS=store-one.myshopify.com,store-two.myshopify.com +CHECKOUT_KIT_UC_ALLOWED_SHOP_DOMAINS=another-store.example.test +``` + +The local adapter permits configured shops, additional exact domains listed in +`CHECKOUT_KIT_UC_ALLOWED_SHOP_DOMAINS`, and production domains shaped like +`.myshopify.com`. Local development shops must be explicitly configured. +Use shops that support public products, an eligible cart creation flow, and one +common cart currency. Buyer country affects cart currency, but a shared country +does not guarantee matching currencies. + +Configure `CHECKOUT_KIT_UC_SESSION_CREATE_URL` with your trusted production +session endpoint on `https://shop.app`. For **Local development**, configure both +`CHECKOUT_KIT_UC_DEVELOPMENT_SESSION_CREATE_URL` and +`VITE_CHECKOUT_KIT_UC_DEVELOPMENT_CONTINUATION_HOST`. For example: + +```dotenv +CHECKOUT_KIT_UC_DEVELOPMENT_SESSION_CREATE_URL=https://sessions.example.test/sessions +VITE_CHECKOUT_KIT_UC_DEVELOPMENT_CONTINUATION_HOST=checkout.example.test +``` + +The URLs must use HTTPS and cannot include credentials, query parameters, or +fragments. Session creation URLs stay on the local server. Only the selected +shop domains and the development continuation hostname are returned to the +local browser at runtime. Despite the legacy `VITE_` variable name, this host is +not compiled into the sample. Storefront tokens and other shared environment +values are neither returned to the page nor embedded in build output. + +The generated and pasted continuation URL must use HTTPS, the selected +environment's trusted host, a `/checkouts/uc/` path, and a nonempty +`key` query value. Keep keyed cart and checkout URLs out of logs, screenshots, +and issue reports. + +The **Advanced: paste an existing Universal Checkout URL** option is for +testing an already created session. The pasted value is not persisted, is +cleared when switching URL source modes, and must match the selected +environment. Like a generated URL, it is assigned to Checkout Kit's `src` +when selected. It does not create carts or a new session. + +### Troubleshooting + +- If products do not load, check that the shop domain is allowed for the + selected environment, its storefront is reachable, and it has products + published to the Online Store. Use **Retry** after fixing the shop. +- If cart creation fails, check that the selected variants are available and + the shop permits Storefront API cart creation. If currencies differ, select + a buyer country supported by all shops and create the URL again. +- If session creation fails, check that the selected carts are still valid + and that the local adapter can reach the configured session service. +- If the checkout window does not open, use **Open Universal Checkout** directly + from a click and check the browser's popup permission. Sign in to Shop Pay + in the selected environment before continuing checkout. +- If the window opens but the timeline stays empty, verify that the checkout + implementation is emitting Universal events and inspect Checkout Kit's + `warn` diagnostics for dropped malformed events. An empty timeline does not + establish a purchase outcome. + +The cart, catalog, and session routes exist in the local Vite development and +preview servers. Opening the built HTML file directly or serving it from an +unrelated static host will not provide those routes. The complete two-shop +buyer flow and real producer error events still require a recorded browser +run; unit tests and source inspection alone do not establish that validation. ## Build -```bash -pnpm sample:build # outputs index.html and universal.html to sample/dist/ +From `platforms/web`, run: + +```sh +pnpm sample:build ``` -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`). +The build outputs to `platforms/web/sample/dist/`. CI runs it on every PR. +The sample is not published to npm (`files` allowlist in +`platforms/web/package.json`). diff --git a/platforms/web/sample/styles.css b/platforms/web/sample/styles.css index 542531ad7..b5c4255aa 100644 --- a/platforms/web/sample/styles.css +++ b/platforms/web/sample/styles.css @@ -1171,6 +1171,15 @@ button:disabled { display: none; } +.universal-page .uc-preparation-actions { + display: flex; + justify-content: flex-end; +} + +.universal-page .uc-preparation-actions button { + width: 100%; +} + .universal-page .uc-shop-list { display: flex; flex-direction: column; @@ -1243,6 +1252,14 @@ button:disabled { line-height: 1.45; } +.universal-page #uc-preparation-status[data-tone="error"] { + color: var(--error); +} + +.universal-page #uc-preparation-status[data-tone="success"] { + color: var(--success); +} + .universal-page .events-collapsed #uc-runtime-notice { display: none; } diff --git a/platforms/web/sample/universal-main.ts b/platforms/web/sample/universal-main.ts index caee8537d..cb19e75bb 100644 --- a/platforms/web/sample/universal-main.ts +++ b/platforms/web/sample/universal-main.ts @@ -1,6 +1,10 @@ import { normalizeQuantity } from "./cart"; import { createColumnResizer } from "./column-resizer"; +import { setDevelopmentContinuationHost } from "./universal/browser-policy"; +import { loadSampleConfiguration } from "./universal/configuration"; import { createUniversalController } from "./universal/controller"; +import { createSessionPreparationController } from "./universal/preparation"; +import { isBuyerCountry, isCheckoutEnvironment } from "./universal/policy"; import { createInitialState, createUniversalStore, @@ -20,6 +24,7 @@ import "./styles.css"; const refs = queryUniversalRefs(); const store = createUniversalStore(createInitialState(loadUniversalDisplay())); const controller = createUniversalController({ store }); +const preparation = createSessionPreparationController({ store }); const resizer = createColumnResizer({ layout: refs.layout, leftPanel: refs.settingsPanel, @@ -56,6 +61,10 @@ refs.form.addEventListener("change", (event) => { if (!(target instanceof HTMLSelectElement)) return; if (target === refs.targetInput) { updateDisplay({ target: target.value === "auto" ? "auto" : "popup" }); + } else if (target === refs.environmentInput) { + if (isCheckoutEnvironment(target.value)) preparation.setEnvironment(target.value); + } else if (target === refs.buyerCountryInput) { + if (isBuyerCountry(target.value)) preparation.setBuyerCountry(target.value); } else if (target === refs.appearanceInput) { updateDisplay({ appearance: target.value }); } else if (target === refs.logLevelInput) { @@ -71,6 +80,10 @@ refs.form.addEventListener("change", (event) => { } }); +refs.prepareButton.addEventListener("click", () => { + void preparation.prepare(); +}); + refs.settingsToggle.addEventListener("click", () => { updateDisplay({ settingsCollapsed: !store.getState().display.settingsCollapsed }); }); @@ -138,10 +151,25 @@ refs.shopList.addEventListener("change", (event) => { renderUniversalApp(refs, store.getState()); resizer.applyWidths(); +void loadSampleConfiguration() + .then((configuration) => { + setDevelopmentContinuationHost(configuration.developmentContinuationHost); + for (const domain of configuration.shopDomains) { + if (!store.getState().shops.some((shop) => shop.domain === domain)) + controller.addShop(domain); + } + return undefined; + }) + .catch(() => { + controller.setRuntimeNotice( + "Could not load sample configuration. Start the local sample server, then reload the page.", + ); + }); window.addEventListener("resize", resizer.reposition); window.addEventListener("pagehide", (event) => { if (event.persisted) return; unsubscribe(); controller.dispose(); + preparation.dispose(); window.removeEventListener("resize", resizer.reposition); }); diff --git a/platforms/web/sample/universal.html b/platforms/web/sample/universal.html index 6b9b64ccd..fa8a3d64d 100644 --- a/platforms/web/sample/universal.html +++ b/platforms/web/sample/universal.html @@ -45,6 +45,32 @@

Settings

+
+ Checkout session + + +

+ Each shop gets a separate Storefront cart. All carts must use the same currency. +

+
+
Presentation