diff --git a/platforms/web/.oxlintrc.json b/platforms/web/.oxlintrc.json index 437d8386..7bf618d7 100644 --- a/platforms/web/.oxlintrc.json +++ b/platforms/web/.oxlintrc.json @@ -26,5 +26,15 @@ "dist/**", "coverage/**", "node_modules/**" + ], + "overrides": [ + { + "files": ["**/*.test.ts"], + "rules": { + "typescript/no-non-null-assertion": "off", + "vitest/require-mock-type-parameters": "off", + "eslint/no-underscore-dangle": "off" + } + } ] } diff --git a/platforms/web/package.json b/platforms/web/package.json index 6d73e554..ef0b72d1 100644 --- a/platforms/web/package.json +++ b/platforms/web/package.json @@ -32,7 +32,11 @@ "./custom-elements.json": "./dist/custom-elements.json", "./package.json": "./package.json" }, - "sideEffects": false, + "sideEffects": [ + "./src/index.ts", + "./src/checkout-web-component.ts", + "./dist/index.js" + ], "files": [ "LICENSE", "README.md", diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index e85b4952..e0033b31 100644 --- a/platforms/web/sample/README.md +++ b/platforms/web/sample/README.md @@ -1,8 +1,9 @@ # Web Component Playground -A development harness for the `` web component. Renders the -component with adjustable options and logs all dispatched `checkout:*` events -in real time. +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:*` and +`checkout:close` events. ## Run locally @@ -13,34 +14,16 @@ pnpm sample Vite serves at `http://localhost:5173`. The page has three panels: -- **Options** — form for setting the component's attributes (`src`, - `target`) plus a small panel of manual method - buttons (`open()`, `close()`, `focus()`) for ad-hoc debugging. -- **Demo Storefront** — a mocked merchant product card with a **Buy now** - button that calls `checkout.open()`. The button is disabled until you - enter a checkout URL in the Options panel. Below the card, a collapsible - readout shows the component's read-only state (`cart`, `locale`, - `orderConfirmation`, `error`, `sessionId`). -- **Events** — a chronological log of every `checkout:*` event the component - dispatches, with a snapshot of component state at the moment the event - fired. Respondable events are tagged with a badge. - -The `` element is appended to `` rather than placed -inside the storefront panel — for `popup` and `auto` targets, the element -has no visible footprint of its own; only its internal dialog scrim appears -when `open()` is called. `target="inline"` is intentionally not supported -in the v1 of the component. - -## Status - -The `` component implementation has not yet landed in -`../src`. Until it does, the element renders as an unknown HTML element and -dispatches no events — the playground is wired up against the component's -eventual API surface but is **non-functional at runtime**. - -The forward-looking API surface is declared in [`./types.d.ts`](./types.d.ts). -Delete that file once `@shopify/checkout-kit` exports the real `ShopifyCheckout` -types from `../src`. +- **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. + +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. ## Build @@ -48,8 +31,5 @@ types from `../src`. pnpm sample:build # outputs to sample/dist/ ``` -CI runs this on every PR (see `.github/workflows/web.yml`) so the sample stays -buildable as the package evolves. - -The sample is **not** published to npm — it's excluded by the `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/index.html b/platforms/web/sample/index.html index b10767c0..215ee02e 100644 --- a/platforms/web/sample/index.html +++ b/platforms/web/sample/index.html @@ -13,8 +13,9 @@

Checkout Kit

Web Component Playground

- Component not yet implemented in ../src — clicking - Buy now is a no-op until the element is registered. + Loads the real <shopify-checkout> from + @shopify/checkout-kit (dev alias). Use a valid checkout URL to try + Buy now.

@@ -36,11 +37,16 @@

Options

+ +
Methods
@@ -108,16 +114,14 @@

Studio Plant Pot

Component state
-
cart
-
-
locale
-
-
orderConfirmation
-
+
checkout
+
error
-
sessionId
-
+
target
+
+
debug
+
@@ -130,8 +134,8 @@

Events

    - Click Buy now to open checkout. Events the component dispatches will - appear here. + Open checkout and interact; ec:* and checkout:close events from + the component appear here.

    diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts index f5918b5f..dcacda44 100644 --- a/platforms/web/sample/main.ts +++ b/platforms/web/sample/main.ts @@ -1,8 +1,6 @@ -// Importing the package registers the custom element -// as a side effect — once the component implementation lands. Today the -// package only exports `VERSION`, so the element below renders as an -// unknown HTML element and the playground produces no events at runtime. -import "../src"; +// Registers `` (same entry as published `@shopify/checkout-kit`). +import "@shopify/checkout-kit"; +import type { ShopifyCheckout } from "@shopify/checkout-kit"; import "./styles.css"; @@ -40,11 +38,10 @@ const buyNowButton = $("#buy-now"); const buyHint = $("#buy-hint"); const stateNodes = { - cart: $("#state-cart"), - locale: $("#state-locale"), - orderConfirmation: $("#state-order-confirmation"), + checkout: $("#state-checkout"), error: $("#state-error"), - sessionId: $("#state-session-id"), + target: $("#state-target"), + debug: $("#state-debug"), }; // ───── Mount the component (off-layout) ─────────────────────────────────── @@ -55,9 +52,11 @@ const stateNodes = { // called, so we attach it to and leave the storefront panel free for // the merchant's product UI. -const checkout = document.createElement("shopify-checkout"); +const checkout = document.createElement("shopify-checkout") as ShopifyCheckout; document.body.append(checkout); +const checkoutEl: HTMLElement = checkout; + // ───── Form ↔ attributes ────────────────────────────────────────────────── function setStringAttribute(el: HTMLElement, name: string, value: FormDataEntryValue | null): void { @@ -72,6 +71,11 @@ function syncAttributes(): void { const data = new FormData(form); setStringAttribute(checkout, "src", data.get("src")); setStringAttribute(checkout, "target", data.get("target")); + if (data.has("debug")) { + checkout.setAttribute("debug", ""); + } else { + checkout.removeAttribute("debug"); + } refreshBuyButton(data.get("src")); } @@ -123,24 +127,22 @@ for (const swatch of swatches) { // ───── Event log ────────────────────────────────────────────────────────── +/** Dispatched by `ShopifyCheckout` (see `src/checkout.ts`). */ const EVENT_TYPES = [ - "checkout:start", - "checkout:complete", + "ec:start", + "ec:complete", "checkout:close", - "checkout:error", - "checkout:addressChangeStart", - "checkout:paymentMethodChangeStart", - "checkout:submitStart", + "ec:error", + "ec:lineItemsChange", + "ec:buyerChange", + "ec:totalsChange", + "ec:messagesChange", ] as const; -const RESPONDABLE_EVENTS = new Set([ - "checkout:addressChangeStart", - "checkout:paymentMethodChangeStart", - "checkout:submitStart", -]); +const RESPONDABLE_EVENTS = new Set([]); for (const type of EVENT_TYPES) { - checkout.addEventListener(type, () => { + checkoutEl.addEventListener(type, () => { appendLog(type); refreshState(); }); @@ -152,20 +154,18 @@ clearLogButton.addEventListener("click", () => { function snapshotState(): Record { return { - cart: checkout.cart, - locale: checkout.locale, - orderConfirmation: checkout.orderConfirmation, + checkout: checkout.checkout, error: checkout.error, - sessionId: checkout.sessionId, + target: checkout.target, + debug: checkout.debug, }; } function refreshState(): void { - stateNodes.cart.textContent = formatValue(checkout.cart); - stateNodes.locale.textContent = formatValue(checkout.locale); - stateNodes.orderConfirmation.textContent = formatValue(checkout.orderConfirmation); + stateNodes.checkout.textContent = formatValue(checkout.checkout); stateNodes.error.textContent = formatValue(checkout.error); - stateNodes.sessionId.textContent = formatValue(checkout.sessionId); + stateNodes.target.textContent = formatValue(checkout.target); + stateNodes.debug.textContent = formatValue(checkout.debug); } function appendLog(type: string): void { diff --git a/platforms/web/sample/tsconfig.json b/platforms/web/sample/tsconfig.json index 92a0e93f..a7fc11df 100644 --- a/platforms/web/sample/tsconfig.json +++ b/platforms/web/sample/tsconfig.json @@ -1,14 +1,13 @@ { "extends": "../tsconfig.json", "compilerOptions": { - // The sample uses vanilla DOM patterns and a forward-looking shim - // (types.d.ts) for the not-yet-implemented component. Don't enforce the - // library-grade isolatedDeclarations rule here. "isolatedDeclarations": false, "declaration": false, "noEmit": true, - // vite/client provides the `*.css` module declaration so `import "./styles.css"` typechecks. - "types": ["vite/client"] + "types": ["vite/client"], + "paths": { + "@shopify/checkout-kit": ["../src/index.ts"] + } }, "include": ["./**/*.ts", "./**/*.d.ts"] } diff --git a/platforms/web/sample/types.d.ts b/platforms/web/sample/types.d.ts deleted file mode 100644 index ad569659..00000000 --- a/platforms/web/sample/types.d.ts +++ /dev/null @@ -1,51 +0,0 @@ -// TODO: delete this file once `@shopify/checkout-kit` exports the real -// `ShopifyCheckout` types from `../src`. -// -// Forward-looking type declarations describing the API surface that the -// `` component will expose once its implementation lands -// in `../src`. Lets the playground compile and read as if the component -// were already in place. At runtime, the element is not yet registered -// and renders as an unknown HTML element. - -// `inline` is intentionally omitted from the initial release — only popup -// (window.open with explicit features) and auto (window.open new tab) are -// supported in v1. Add `"inline"` back here when iframe rendering lands. -type CheckoutTarget = "auto" | "popup"; - -interface ShopifyCheckoutCart { - [key: string]: unknown; -} - -interface ShopifyCheckoutOrderConfirmation { - [key: string]: unknown; -} - -interface ShopifyCheckoutError { - code: string; - message: string; -} - -interface ShopifyCheckoutElement extends HTMLElement { - // ── Read/write attributes (reflected) ── - src: string; - target: CheckoutTarget | string; - - // ── Read-only state populated by checkout protocol events ── - readonly cart?: ShopifyCheckoutCart; - readonly locale?: string; - readonly orderConfirmation?: ShopifyCheckoutOrderConfirmation; - readonly error?: ShopifyCheckoutError; - readonly sessionId?: string; - - // ── Methods ── - open(): void; - close(): void; - focus(): void; -} - -// In an ambient .d.ts (no top-level imports/exports), interface declarations -// are global, so `HTMLElementTagNameMap` is augmented directly without -// needing a `declare global` wrapper. -interface HTMLElementTagNameMap { - "shopify-checkout": ShopifyCheckoutElement; -} diff --git a/platforms/web/sample/vite.config.ts b/platforms/web/sample/vite.config.ts index 70bc45a1..7343d6c2 100644 --- a/platforms/web/sample/vite.config.ts +++ b/platforms/web/sample/vite.config.ts @@ -8,6 +8,12 @@ const here = dirname(fileURLToPath(import.meta.url)); 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"), + }, + }, build: { outDir: resolve(here, "dist"), emptyOutDir: true, diff --git a/platforms/web/src/checkout-web-component.ts b/platforms/web/src/checkout-web-component.ts new file mode 100644 index 00000000..5248b182 --- /dev/null +++ b/platforms/web/src/checkout-web-component.ts @@ -0,0 +1,34 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +/* eslint ssr-friendly/no-dom-globals-in-module-scope: off */ + +import { ShopifyCheckout } from "./checkout"; + +declare global { + interface HTMLElementTagNameMap { + "shopify-checkout": ShopifyCheckout; + } +} + +customElements.define("shopify-checkout", ShopifyCheckout); diff --git a/platforms/web/src/checkout.styles.ts b/platforms/web/src/checkout.styles.ts new file mode 100644 index 00000000..a7d727c1 --- /dev/null +++ b/platforms/web/src/checkout.styles.ts @@ -0,0 +1,148 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import { css, type SafeMarkup } from "./utils"; + +export const STYLES: SafeMarkup = css` + * { + box-sizing: border-box; + } + + #shopify-element-wrapper, + .Shopify-target { + inline-size: 100%; + block-size: 100%; + border: none; + } + + :host { + @media (prefers-reduced-motion: reduce) { + --shopify-checkout-overlay-transition-duration: 1ms; + } + + /* + * Reset any inheritable styles to ensure the text and links are visible by default no matter + * what the embedding website's styles are. An embedder may override these values + * using CSS parts. + */ + color: hsl(0, 0%, 10%); + font-family: system-ui, sans-serif; + font-size: initial; + line-height: 1.5; + letter-spacing: initial; + font-weight: initial; + font-style: initial; + text-align: initial; + word-spacing: initial; + text-transform: initial; + text-decoration: initial; + text-indent: initial; + /* checkout applies subpixel-antialiased */ + -webkit-font-smoothing: subpixel-antialiased; + -moz-osx-font-smoothing: initial; + text-rendering: initial; + + a, + a:hover, + a:visited, + a:focus, + a:active { + color: inherit; + } + } + + .overlay { + padding: 0; + transition: + display var(--shopify-checkout-dialog-transition-duration, 150ms) allow-discrete, + overlay var(--shopify-checkout-dialog-transition-duration, 150ms) allow-discrete; + } + + .overlay-background { + opacity: 0; + position: fixed; + place-items: center; + inset: 0; + background-color: hsla(0, 0%, 0%, 0.8); + transition: + opacity var(--shopify-checkout-overlay-transition-duration, 150ms) ease-out, + backdrop-filter var(--shopify-checkout-overlay-transition-duration, 150ms) ease-out, + display var(--shopify-checkout-overlay-transition-duration, 150ms) allow-discrete, + overlay var(--shopify-checkout-overlay-transition-duration, 150ms) allow-discrete; + color: hsl(0, 0%, 100%); + font-size: 1.125em; + text-align: center; + overflow: auto; + } + + .overlay[open] .overlay-background { + display: grid; + opacity: 1; + backdrop-filter: blur(6px); + } + + @starting-style { + .overlay[open] .overlay-background { + opacity: 0; + } + } + + .overlay-content-wrapper { + display: grid; + grid-template-rows: 1fr 20%; + place-items: center; + inline-size: 100%; + block-size: 100%; + } + + .overlay-content { + padding: 0.75em; + max-inline-size: 21em; + } + + .overlay-close-button { + display: inline-flex; + align-items: center; + gap: 0.5em; + background: transparent; + border: none; + padding: 0.625em; + cursor: pointer; + font-family: inherit; + color: inherit; + opacity: 0.8; + text-decoration: underline; + line-height: 0; + + &:hover { + opacity: 1; + } + + svg { + inline-size: 1em; + block-size: 1em; + line-height: 0; + fill: currentColor; + } + } +`; diff --git a/platforms/web/src/checkout.test.ts b/platforms/web/src/checkout.test.ts new file mode 100644 index 00000000..57651aa6 --- /dev/null +++ b/platforms/web/src/checkout.test.ts @@ -0,0 +1,1125 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Checkout, CheckoutProtocolMessageMap, UcpErrorResponse } from "./checkout.types"; +import "./checkout-web-component"; +import { DEFAULT_POPUP_WIDTH, DEFAULT_POPUP_HEIGHT, EMBED_PROTOCOL_VERSION } from "./checkout"; +import type { ShopifyCheckout } from "./checkout"; + +const POPUP_TARGETS = ["popup"] as const; +const NEW_TAB_TARGETS = ["_blank", "auto", "", undefined] as const; + +function expectWindowOpenArgs(spy: { + mock: { calls: ReadonlyArray> }; +}): ReadonlyArray { + expect(spy).toHaveBeenCalled(); + const args = spy.mock.calls[0]; + if (args === undefined) { + throw new Error("expected window.open to have been called"); + } + return args; +} + +describe("", () => { + afterEach(() => { + vi.restoreAllMocks(); + // Clear any elements left over from prior tests so + // their global `message` listeners stop responding to events dispatched + // by later tests. Without this, e.g. the new ec.window.open_request SDK + // fallback would call window.open once per leaked instance. + document.body.innerHTML = ""; + }); + + describe("attributes", () => { + describe("src", () => { + it("changing the src attribute reflects to the src property", () => { + const checkout = renderCheckout(); + const newSrc = "https://example.com/checkout/456"; + checkout.setAttribute("src", newSrc); + + expect(checkout.src).toBe(newSrc); + }); + }); + }); + + describe("target", () => { + it("changing the target attribute reflects to the target property", () => { + const checkout = renderCheckout(); + const newTarget = "_blank"; + checkout.setAttribute("target", newTarget); + + expect(checkout.target).toBe(newTarget); + }); + + it("handles HTML metacharacters in the target attribute value", () => { + const target = '">'; + const checkout = renderCheckout({ target }); + + expect(checkout.shadowRoot!.querySelector("script")).toBeNull(); + expect((window as unknown as { __xssed?: boolean }).__xssed).toBeUndefined(); + }); + }); + + describe("properties", () => { + describe("src", () => { + it("changing the src property reflects to the src attribute", () => { + const checkout = renderCheckout(); + const newSrc = "https://example.com/checkout/456"; + checkout.src = newSrc; + + expect(checkout.getAttribute("src")).toBe(newSrc); + }); + }); + + describe("target", () => { + it("changing the target property reflects to the target attribute", () => { + const checkout = renderCheckout(); + const newTarget = "_blank"; + checkout.target = newTarget; + expect(checkout.getAttribute("target")).toBe(newTarget); + }); + }); + }); + + describe("URL generation", () => { + it("preserves existing query parameters when adding ec_* parameters", () => { + const originalSrc = "https://example.com/checkout?existing=param&another=value"; + const checkout = renderCheckout({ src: originalSrc }); + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const firstCall = expectWindowOpenArgs(windowOpenSpy); + const calledUrl = firstCall[0] as string; + const url = new URL(calledUrl); + + expect(url.searchParams.get("existing")).toBe("param"); + expect(url.searchParams.get("another")).toBe("value"); + expect(url.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); + }); + + it("strips ec_auth from src when the incoming checkout URL includes it", () => { + const checkout = renderCheckout({ + src: "https://example.com/checkout?ec_auth=should-not-propagate&keep=1", + }); + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const url = new URL(expectWindowOpenArgs(windowOpenSpy)[0] as string); + expect(url.searchParams.get("ec_auth")).toBeNull(); + expect(url.searchParams.get("keep")).toBe("1"); + expect(url.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); + }); + + it("handles invalid src URL gracefully", () => { + const checkout = renderCheckout(); + checkout.src = "invalid-url"; + + const windowOpenSpy = vi.spyOn(window, "open"); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + checkout.open(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + "``: src property is empty or invalid, cannot open checkout", + ); + expect(windowOpenSpy).not.toHaveBeenCalled(); + }); + + it.each([ + "javascript:alert(1)", + "data:text/html,", + "http://shop.example.com/checkout", + "blob:https://shop.example.com/abc", + "file:///etc/passwd", + "not a url", + ])("leaves overlay link without href when src is %s", (badSrc) => { + const checkout = renderCheckout({ src: badSrc }); + + expect(checkout.shadowRoot!.querySelector("iframe")).toBeNull(); + + const overlayLink = checkout.shadowRoot!.querySelector("#overlay-link"); + expect(overlayLink!.hasAttribute("href")).toBe(false); + }); + + it("does not interpret HTML metacharacters in src as markup", () => { + const src = 'https://shop.example.com/">'; + // The URL constructor accepts this (the `"` becomes part of the + // pathname); the value is rendered via DOM APIs rather than string + // interpolation, so no markup is parsed into the shadow root. + const checkout = renderCheckout({ src }); + + expect(checkout.shadowRoot!.querySelector("script")).toBeNull(); + expect((window as unknown as { __xssed?: boolean }).__xssed).toBeUndefined(); + }); + }); + + describe("methods", () => { + describe("open", () => { + describe("when target is not specified", () => { + it("defaults to auto target (new tab)", () => { + [undefined, ""].forEach((target) => { + const checkout = renderCheckout({ target }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + expect(windowOpenSpy).toHaveBeenCalledWith( + expect.stringContaining(checkout.src), + target ?? "auto", + ); + }); + }); + }); + + describe('when target="popup"', () => { + it("shows the checkout in a popup window", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const call = expectWindowOpenArgs(windowOpenSpy); + const calledUrl = new URL(call[0] as string); + expect(calledUrl.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); + + const features = call[2] as string; + expect(features).toContain("scrollbars=yes"); + expect(features).toContain("status=no"); + expect(features).toContain("toolbar=no"); + expect(features).toContain("resizable=yes"); + }); + }); + + it("does not open the overlay backdrop when a developer uses css to set `::part(overlay)` to `display: none`", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + const dialogShowModalSpy = vi + .spyOn(HTMLDialogElement.prototype, "showModal") + .mockImplementation(() => {}); + + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + getPropertyValue: (prop: string) => { + if (prop === "display") return "none"; + return ""; + }, + } as CSSStyleDeclaration); + + checkout.open(); + + expect(windowOpenSpy).toHaveBeenCalled(); + expect(dialogShowModalSpy).not.toHaveBeenCalled(); + }); + }); + + it("does not open the overlay backdrop when `` is set to `display: none`", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + const dialogShowModalSpy = vi + .spyOn(HTMLDialogElement.prototype, "showModal") + .mockImplementation(() => {}); + + vi.spyOn(window, "getComputedStyle").mockImplementation((el) => { + return { + getPropertyValue: (prop: string) => { + if (el === checkout && prop === "display") return "none"; + return ""; + }, + } as CSSStyleDeclaration; + }); + + checkout.open(); + + expect(windowOpenSpy).toHaveBeenCalled(); + expect(dialogShowModalSpy).not.toHaveBeenCalled(); + }); + }); + + it("returns early when src is empty and shows a console warning for the developer", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + checkout.src = ""; + const windowOpenSpy = vi.spyOn(window, "open"); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + checkout.open(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + "``: src property is empty or invalid, cannot open checkout", + ); + expect(windowOpenSpy).not.toHaveBeenCalled(); + }); + }); + + it("calculates popup window size correctly", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + mockWindowSize(1200, 800); + + checkout.open(); + + const call = expectWindowOpenArgs(windowOpenSpy); + const features = call[2] as string; + expect(features).toContain(`width=${DEFAULT_POPUP_WIDTH}`); + expect(features).toContain(`height=${DEFAULT_POPUP_HEIGHT}`); + }); + }); + + it("calculates popup window position correctly", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + mockWindowSize(1200, 800); + + checkout.open(); + + const call = expectWindowOpenArgs(windowOpenSpy); + const features = call[2] as string; + expect(features).toContain(`left=${(1200 - DEFAULT_POPUP_WIDTH) / 2}`); + expect(features).toContain(`top=${(800 - DEFAULT_POPUP_HEIGHT) / 2}`); + }); + }); + + it("respects custom width and height CSS properties", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + mockWindowSize(1200, 800); + + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + getPropertyValue: (prop: string) => { + if (prop === "--shopify-checkout-dialog-width") return "800"; + if (prop === "--shopify-checkout-dialog-height") return "700"; + return ""; + }, + } as CSSStyleDeclaration); + + checkout.open(); + + const call = expectWindowOpenArgs(windowOpenSpy); + const features = call[2] as string; + expect(features).toContain("width=800"); + expect(features).toContain("height=700"); + }); + }); + + it("handles popup blocked scenario gracefully", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(null); + + checkout.open(); + + expect(windowOpenSpy).toHaveBeenCalled(); + // Should not throw error when popup is blocked + }); + }); + + it("enforces maximum window size constraints", () => { + POPUP_TARGETS.forEach((target) => { + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + mockWindowSize(200, 100); + + const checkout = renderCheckout({ target }); + checkout.open(); + + const call = expectWindowOpenArgs(windowOpenSpy); + const features = call[2] as string; + // Should be constrained to 90% of screen size + expect(features).toContain("width=180"); + expect(features).toContain("height=90"); + }); + }); + + it("closes the checkout when the dialog is closed", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const mockPopup = createMockWindow(); + vi.spyOn(window, "open").mockReturnValue(mockPopup); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + getPropertyValue: (prop: string) => { + if (prop === "display") return "block"; + return ""; + }, + } as CSSStyleDeclaration); + + const closeEventSpy = vi.fn(); + checkout.addEventListener("checkout:close", closeEventSpy); + + checkout.open(); + + const dialog = checkout.shadowRoot!.querySelector("dialog") as HTMLDialogElement; + dialog.dispatchEvent(new Event("close")); + + expect(mockPopup.close).toHaveBeenCalled(); + expect(closeEventSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('when target="_blank", "auto", or undefined', () => { + NEW_TAB_TARGETS.forEach((target) => { + it("opens in a new window", () => { + const checkout = renderCheckout({ target }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const firstCall = expectWindowOpenArgs(windowOpenSpy); + const calledUrl = new URL(firstCall[0] as string); + expect(calledUrl.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); + expect(firstCall[1]).toBe(target ?? "auto"); + }); + }); + }); + + describe("when target is a non keyword string", () => { + it("opens in a named window", () => { + const checkout = renderCheckout({ target: "my-named-window" }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const firstCall = expectWindowOpenArgs(windowOpenSpy); + const calledUrl = new URL(firstCall[0] as string); + expect(calledUrl.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); + expect(firstCall[1]).toBe("my-named-window"); + }); + }); + }); + + describe("focus", () => { + it("focuses the checkout window", () => { + [...POPUP_TARGETS, "_blank"].forEach((target) => { + const checkout = renderCheckout({ target }); + const mockPopup = { + ...createMockWindow(), + focus: vi.fn(), + }; + vi.spyOn(window, "open").mockReturnValue(mockPopup); + + checkout.open(); + checkout.focus(); + + expect(mockPopup.focus).toHaveBeenCalled(); + }); + }); + }); + + describe("close", () => { + describe('when target="popup", "auto", or undefined', () => { + it("dispatches close event when popup is closed", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + + const closeEventSpy = vi.fn(); + const mockWindow = createMockWindow(); + mockWindow.close = closeEventSpy; + + vi.spyOn(window, "open").mockReturnValue(mockWindow); + + checkout.addEventListener("checkout:close", closeEventSpy); + checkout.open(); + checkout.close(); + + expect(closeEventSpy).toHaveBeenCalled(); + }); + }); + + it("closes the checkout scrim dialog", async () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const mockPopup = createMockWindow(); + vi.spyOn(window, "open").mockReturnValue(mockPopup); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + getPropertyValue: (prop: string) => { + if (prop === "display") return "block"; + return ""; + }, + } as CSSStyleDeclaration); + + const dialogCloseSpy = vi + .spyOn(HTMLDialogElement.prototype, "close") + .mockImplementation(() => {}); + + checkout.open(); + + checkout.close(); + + // Should also close the dialog scrim + expect(dialogCloseSpy).toHaveBeenCalled(); + }); + }); + }); + }); + }); + + describe("it subscribes to checkout-protocol events", () => { + describe("ec.ready handshake", () => { + it("auto-responds with an empty result and does not dispatch a DOM event", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onReadySpy = vi.fn(); + // ec:ready is no longer a public event; cast through `never` to verify + // that the component does not dispatch one. + checkout.addEventListener("ec:ready" as never, onReadySpy as EventListener); + + simulateProtocolMessageEvent( + checkout, + "ec.ready", + { delegate: [] }, + { id: "ready-1", source: mockCheckoutWindow }, + ); + await Promise.resolve(); + + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( + { jsonrpc: "2.0", id: "ready-1", result: {} }, + new URL(checkout.src).origin, + ); + expect(onReadySpy).not.toHaveBeenCalled(); + }); + + it("does not post a response when id is missing", () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + + simulateProtocolMessageEvent( + checkout, + "ec.ready", + { delegate: [] }, + { source: mockCheckoutWindow }, + ); + + expect(mockCheckoutWindow.postMessage).not.toHaveBeenCalled(); + }); + }); + + describe("ec:start", () => { + it("updates the checkout property and dispatches an ec:start event", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onStartSpy = vi.fn(); + const listenForEvent = waitForEvent(checkout, "ec:start", onStartSpy); + + const payload = makeCheckoutPayload(); + simulateProtocolMessageEvent(checkout, "ec.start", payload, { source: mockCheckoutWindow }); + await listenForEvent; + + expect(checkout.checkout).toBe(payload.checkout); + expect(onStartSpy).toHaveBeenCalledOnce(); + }); + }); + + describe("ec:complete", () => { + it("updates the checkout property and dispatches an ec:complete event", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onCompleteSpy = vi.fn(); + const listenForEvent = waitForEvent(checkout, "ec:complete", onCompleteSpy); + + const payload = makeCheckoutPayload(); + simulateProtocolMessageEvent(checkout, "ec.complete", payload, { + source: mockCheckoutWindow, + }); + await listenForEvent; + + expect(checkout.checkout).toBe(payload.checkout); + expect(onCompleteSpy).toHaveBeenCalledOnce(); + }); + }); + + describe("ec:error", () => { + it("updates the error property and dispatches an ec:error event", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onErrorSpy = vi.fn(); + const listenForEvent = waitForEvent(checkout, "ec:error", onErrorSpy); + + const errorPayload = makeErrorPayload(); + simulateProtocolMessageEvent(checkout, "ec.error", errorPayload, { + source: mockCheckoutWindow, + }); + await listenForEvent; + + expect(checkout.error).toStrictEqual(errorPayload); + expect(onErrorSpy).toHaveBeenCalledOnce(); + }); + }); + + describe("ec:lineItemsChange", () => { + it("updates the checkout property and dispatches an ec:lineItemsChange event", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onLineItemsChangeSpy = vi.fn(); + const listenForEvent = waitForEvent(checkout, "ec:lineItemsChange", onLineItemsChangeSpy); + + const payload = makeCheckoutPayload(); + simulateProtocolMessageEvent(checkout, "ec.line_items.change", payload, { + source: mockCheckoutWindow, + }); + await listenForEvent; + + expect(checkout.checkout).toBe(payload.checkout); + expect(onLineItemsChangeSpy).toHaveBeenCalledOnce(); + }); + }); + + describe("ec:buyerChange", () => { + it("updates the checkout property and dispatches an ec:buyerChange event", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onBuyerChangeSpy = vi.fn(); + const listenForEvent = waitForEvent(checkout, "ec:buyerChange", onBuyerChangeSpy); + + const payload = makeCheckoutPayload(); + simulateProtocolMessageEvent(checkout, "ec.buyer.change", payload, { + source: mockCheckoutWindow, + }); + await listenForEvent; + + expect(checkout.checkout).toBe(payload.checkout); + expect(onBuyerChangeSpy).toHaveBeenCalledOnce(); + }); + }); + + describe("ec:totalsChange", () => { + it("updates the checkout property and dispatches an ec:totalsChange event", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onTotalsChangeSpy = vi.fn(); + const listenForEvent = waitForEvent(checkout, "ec:totalsChange", onTotalsChangeSpy); + + const payload = makeCheckoutPayload(); + simulateProtocolMessageEvent(checkout, "ec.totals.change", payload, { + source: mockCheckoutWindow, + }); + await listenForEvent; + + expect(checkout.checkout).toBe(payload.checkout); + expect(onTotalsChangeSpy).toHaveBeenCalledOnce(); + }); + }); + + describe("ec:messagesChange", () => { + it("updates the checkout property and dispatches an ec:messagesChange event", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onMessagesChangeSpy = vi.fn(); + const listenForEvent = waitForEvent(checkout, "ec:messagesChange", onMessagesChangeSpy); + + const payload = makeCheckoutPayload(); + simulateProtocolMessageEvent(checkout, "ec.messages.change", payload, { + source: mockCheckoutWindow, + }); + await listenForEvent; + + expect(checkout.checkout).toBe(payload.checkout); + expect(onMessagesChangeSpy).toHaveBeenCalledOnce(); + }); + }); + + describe("ec.window.open_request", () => { + it("opens the requested url in a new tab with noopener when an id is present", () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const windowOpenSpy = vi.spyOn(window, "open"); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/return" }, + { id: "open-1", source: mockCheckoutWindow }, + ); + + expect(windowOpenSpy).toHaveBeenLastCalledWith( + "https://example.com/return", + "_blank", + "noopener", + ); + }); + + it("posts a JSON-RPC response back to the source", () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + vi.spyOn(window, "open").mockReturnValue(null); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/return" }, + { id: "open-resp", source: mockCheckoutWindow }, + ); + + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( + { jsonrpc: "2.0", id: "open-resp", result: {} }, + { targetOrigin: new URL(checkout.src).origin }, + ); + }); + + it("does not open an auxiliary window when the request has no id", () => { + const checkout = renderCheckout({ target: "popup" }); + const mockCheckoutWindow = createMockWindow(); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(mockCheckoutWindow); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); + checkout.open(); + expect(windowOpenSpy).toHaveBeenCalledOnce(); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/return" }, + { source: mockCheckoutWindow }, + ); + + expect(windowOpenSpy).toHaveBeenCalledOnce(); + }); + + it("posts JSON-RPC errors when params are missing or malformed", () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + {} as CheckoutProtocolMessageMap["ec.window.open_request"], + { id: "open-missing", source: mockCheckoutWindow }, + ); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { + url: 42, + } as unknown as CheckoutProtocolMessageMap["ec.window.open_request"], + { id: "open-malformed", source: mockCheckoutWindow }, + ); + + const targetOrigin = new URL(checkout.src).origin; + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("ec.window.open_request received without a valid url"), + expect.objectContaining({ name: "ec.window.open_request" }), + ); + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( + { + jsonrpc: "2.0", + id: "open-missing", + error: { + code: -32602, + message: "Invalid params: expected {url: string}", + }, + }, + { targetOrigin }, + ); + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( + { + jsonrpc: "2.0", + id: "open-malformed", + error: { + code: -32602, + message: "Invalid params: expected {url: string}", + }, + }, + { targetOrigin }, + ); + }); + }); + + describe("message routing", () => { + it("drops protocol messages from an unexpected origin", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onStartSpy = vi.fn(); + checkout.addEventListener("ec:start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://other.example.com", + }); + await Promise.resolve(); + + expect(onStartSpy).not.toHaveBeenCalled(); + expect(checkout.checkout).toBeUndefined(); + }); + + it("drops protocol messages when the source is not the checkout window", async () => { + const { checkout } = openPopupCheckout(); + const otherWindow = createMockWindow(); + const onStartSpy = vi.fn(); + checkout.addEventListener("ec:start", onStartSpy); + + simulateProtocolMessageEvent( + checkout, + "ec.start", + makeCheckoutPayload(), + // Right origin, wrong window. + { source: otherWindow }, + ); + await Promise.resolve(); + + expect(onStartSpy).not.toHaveBeenCalled(); + expect(checkout.checkout).toBeUndefined(); + }); + + it("drops protocol messages when src is unset (no expected origin)", async () => { + // Set up a popup-style checkout WITHOUT a src so #expectedOrigin + // returns undefined and every inbound message is dropped. + const checkout = document.createElement("shopify-checkout"); + document.body.appendChild(checkout); + const mockCheckoutWindow = createMockWindow(); + vi.spyOn(window, "open").mockReturnValue(mockCheckoutWindow); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); + // open() with no src is a no-op (logs a warning), so we can't use + // it to set #checkoutWindow. Instead, set src first, open, then + // clear src so #expectedOrigin becomes undefined while + // #checkoutWindow remains the popup mock. + checkout.src = "https://shop.example.com/checkout"; + checkout.open(); + checkout.removeAttribute("src"); + + const onStartSpy = vi.fn(); + checkout.addEventListener("ec:start", onStartSpy); + + const event = new MessageEvent("message", { + data: { + jsonrpc: "2.0", + method: "ec.start", + params: makeCheckoutPayload(), + }, + origin: "https://shop.example.com", + source: mockCheckoutWindow, + }); + window.dispatchEvent(event); + await Promise.resolve(); + + expect(onStartSpy).not.toHaveBeenCalled(); + }); + + it("drops protocol messages when src uses a non-https scheme", async () => { + // Open with valid https src so #checkoutWindow is set, then swap + // src to a non-https value. #expectedOrigin now returns + // undefined, and inbound messages should be dropped even though + // the source still matches #checkoutWindow. + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + checkout.src = "http://shop.example.com/checkout"; + + const onStartSpy = vi.fn(); + checkout.addEventListener("ec:start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "http://shop.example.com", + }); + await Promise.resolve(); + + expect(onStartSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe("ec_delegate parameter", () => { + it("declares window.open delegation in popup URL", () => { + const checkout = renderCheckout({ target: "popup" }); + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const url = new URL(expectWindowOpenArgs(windowOpenSpy)[0] as string); + expect(url.searchParams.get("ec_delegate")).toBe("window.open"); + }); + }); + + describe("debug attribute", () => { + it("logs a console warning for dropped messages when the debug attribute is set", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + checkout.setAttribute("debug", ""); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://other.example.com", + }); + await Promise.resolve(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Dropped message from unexpected origin"), + ); + }); + + it("does not log warnings for dropped messages when debug is not set", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + origin: "https://other.example.com", + }); + await Promise.resolve(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + describe("overlay link", () => { + it('has rel="noopener noreferrer" and target="_blank"', () => { + const checkout = renderCheckout(); + const link = checkout.shadowRoot!.querySelector("#overlay-link"); + expect(link!.getAttribute("rel")).toBe("noopener noreferrer"); + expect(link!.getAttribute("target")).toBe("_blank"); + }); + + it("points to the parametrised checkout URL (matching what the popup would open)", () => { + const checkout = renderCheckout({ + src: "https://shop.example.com/checkout", + }); + const link = checkout.shadowRoot!.querySelector("#overlay-link"); + const href = link!.getAttribute("href") ?? ""; + const url = new URL(href); + expect(url.origin).toBe("https://shop.example.com"); + expect(url.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); + }); + }); + + describe("lifecycle", () => { + it("preserves the shadow tree across element moves", () => { + const checkout = renderCheckout(); + const wrapper = checkout.shadowRoot!.querySelector("#shopify-element-wrapper"); + + const newParent = document.createElement("div"); + document.body.appendChild(newParent); + newParent.appendChild(checkout); + + expect(checkout.shadowRoot!.querySelector("#shopify-element-wrapper")).toBe(wrapper); + expect(checkout.shadowRoot!.querySelector("iframe")).toBeNull(); + }); + + it("drops protocol messages while the element is disconnected", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onStartSpy = vi.fn(); + checkout.addEventListener("ec:start", onStartSpy); + + checkout.remove(); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + }); + await Promise.resolve(); + + expect(onStartSpy).not.toHaveBeenCalled(); + }); + + it("re-attaches the message listener on reconnect without duplicating it", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onStartSpy = vi.fn(); + checkout.addEventListener("ec:start", onStartSpy); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + }); + await Promise.resolve(); + expect(onStartSpy).toHaveBeenCalledOnce(); + + const newParent = document.createElement("div"); + document.body.appendChild(newParent); + newParent.appendChild(checkout); + checkout.open(); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + }); + await Promise.resolve(); + + expect(onStartSpy).toHaveBeenCalledTimes(2); + }); + + it("routes messages independently when multiple instances coexist on the same page", async () => { + const first = openPopupCheckout(); + const second = openPopupCheckout(); + + const firstSpy = vi.fn(); + const secondSpy = vi.fn(); + first.checkout.addEventListener("ec:start", firstSpy); + second.checkout.addEventListener("ec:start", secondSpy); + + const firstPayload = makeCheckoutPayload(); + simulateProtocolMessageEvent(first.checkout, "ec.start", firstPayload, { + source: first.mockCheckoutWindow, + }); + await Promise.resolve(); + + expect(firstSpy).toHaveBeenCalledOnce(); + expect(secondSpy).not.toHaveBeenCalled(); + expect(first.checkout.checkout).toBe(firstPayload.checkout); + expect(second.checkout.checkout).toBeUndefined(); + + const secondPayload = makeCheckoutPayload(); + simulateProtocolMessageEvent(second.checkout, "ec.start", secondPayload, { + source: second.mockCheckoutWindow, + }); + await Promise.resolve(); + + expect(firstSpy).toHaveBeenCalledOnce(); + expect(secondSpy).toHaveBeenCalledOnce(); + expect(first.checkout.checkout).toBe(firstPayload.checkout); + expect(second.checkout.checkout).toBe(secondPayload.checkout); + }); + }); +}); + +// Test utilities + +/** + * Dispatches a synthetic checkout-protocol MessageEvent at `window` so + * the component's listener processes it. By default both `source` and + * `origin` are derived from `checkout` so that the component's strict + * source-and-origin validation passes: + * + * - `source`: pass the checkout browsing context (the mock window returned + * from `window.open` after `open()`, or another `MessageEventSource` to + * test drops). When omitted, defaults to `null` (messages are dropped). + * - `origin`: the origin of `checkout.src`. Override `origin` to test + * that messages from foreign origins are dropped. + */ +function simulateProtocolMessageEvent( + checkout: ShopifyCheckout, + name: Message, + body: CheckoutProtocolMessageMap[Message], + options?: { + id?: string; + source?: MessageEventSource | null; + origin?: string; + }, +) { + const source = options?.source !== undefined ? options.source : null; + + let origin = options?.origin; + if (origin === undefined) { + try { + origin = new URL(checkout.src).origin; + } catch { + origin = ""; + } + } + + const event = new MessageEvent("message", { + data: { + jsonrpc: "2.0", + method: name, + params: body, + ...(options?.id && { id: options.id }), + }, + origin, + source, + }); + window.dispatchEvent(event); +} + +function waitForEvent(element: HTMLElement, eventName: string, spyFn?: (event: Event) => unknown) { + return new Promise((resolve) => { + const handler = (event: Event) => { + spyFn?.(event); + element.removeEventListener(eventName, handler); + resolve(); + }; + element.addEventListener(eventName, handler); + }); +} + +function renderCheckout(attributes: Record = {}) { + const defaultSrc = "https://demostore.mock.shop/cart/43696905224214:1"; + const checkout = document.createElement("shopify-checkout"); + + if (!attributes.src) { + checkout.setAttribute("src", defaultSrc); + } + + for (const [key, value] of Object.entries(attributes)) { + if (value != null) { + checkout.setAttribute(key, value); + } + } + document.body.appendChild(checkout); + return checkout; +} + +function mockWindowSize(width = 1200, height = 800) { + Object.defineProperty(window, "outerWidth", { value: width, writable: true }); + Object.defineProperty(window, "outerHeight", { value: height, writable: true }); + Object.defineProperty(window, "screenLeft", { value: 0, writable: true }); + Object.defineProperty(window, "screenTop", { value: 0, writable: true }); + Object.defineProperty(document.documentElement, "clientWidth", { + value: width, + writable: true, + }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: height, + writable: true, + }); + Object.defineProperty(screen, "width", { value: width, writable: true }); + Object.defineProperty(screen, "height", { value: height, writable: true }); +} + +function createMockWindow() { + return { + addEventListener: vi.fn(), + close: vi.fn(), + closed: false, + focus: vi.fn(), + postMessage: vi.fn(), + } as unknown as Window; +} + +/** + * Sets up a popup-target checkout whose `#checkoutWindow` is a controllable + * mock window. Tests that exercise the strict source-and-origin validation + * in `#handleMessage` should use this helper and pass `mockCheckoutWindow` + * as `source` in `simulateProtocolMessageEvent`. + * + * Callers receive the checkout, the mock window (use as both `source` for + * `simulateProtocolMessageEvent` and the spy target for response + * `postMessage` calls), and the `window.open` spy already set up. + */ +function openPopupCheckout(): { + checkout: ShopifyCheckout; + mockCheckoutWindow: Window; +} { + const checkout = renderCheckout({ target: "popup" }); + const mockCheckoutWindow = createMockWindow(); + vi.spyOn(window, "open").mockReturnValue(mockCheckoutWindow); + // showModal/close throw in jsdom unless the dialog is in the DOM and + // the test environment supports the modal lifecycle. Stub both for + // tests that just need #checkoutWindow to be set. + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); + checkout.open(); + return { checkout, mockCheckoutWindow }; +} + +function makeCheckoutPayload(overrides: Partial = {}): { + checkout: Checkout; +} { + return { + checkout: { + ucp: { version: "2026-04-08" } as Checkout["ucp"], + id: "gid://shopify/Checkout/test", + currency: "USD", + line_items: [], + totals: [], + payment: {} as Checkout["payment"], + status: "incomplete", + links: [], + ...overrides, + } as Checkout, + }; +} + +function makeErrorPayload(): UcpErrorResponse { + return { + ucp: { version: "2026-04-08", status: "error" }, + messages: [ + { + type: "error", + code: "session_failed", + content: "Session failed", + severity: "unrecoverable", + }, + ], + }; +} diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts new file mode 100644 index 00000000..5d730b28 --- /dev/null +++ b/platforms/web/src/checkout.ts @@ -0,0 +1,890 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import { createTemplate, html } from "./utils"; +import type { + CheckoutAttributes, + CheckoutMethods, + CheckoutProperties, + CheckoutProtocolMessageMap, + CheckoutTarget, + TypedEventListener, + CheckoutProtocolMessageData, + Checkout, + UcpErrorResponse, +} from "./checkout.types"; +import { STYLES } from "./checkout.styles"; + +export const DEFAULT_POPUP_WIDTH = 600; +export const DEFAULT_POPUP_HEIGHT = 600; +export const EMBED_PROTOCOL_VERSION = "2026-04-08"; +const EMBED_DELEGATIONS: readonly string[] = ["window.open"]; + +const SHADOW_TEMPLATE = createTemplate(html` +
    + + +
    + +
    + +
    +
    + Continue your purchase in the
    + checkout window +
    + +
    +
    +
    +
    +
    +
    +`); + +/** + * An element that renders a Shopify Checkout. Checkout opens in a popup or browser tab/window + * (see `target`). To use, create a `shopify-checkout` element, set the `src` attribute to the + * checkout URL (typically retrieved from the `cart.checkoutUrl` field), and then call `open()`. + * + * @attribute src - The URL of the checkout to load. + * @attribute target - Where the checkout is presented (auto, popup, new tab, or a named window). + * + * @event ec:start - Dispatched when the checkout has started + * @event ec:complete - Dispatched when the checkout was successfully completed + * @event ec:error - Dispatched on a session-level fatal error + * @event ec:lineItemsChange - Dispatched when cart line items change + * @event ec:buyerChange - Dispatched when buyer information changes + * @event ec:totalsChange - Dispatched when totals change + * @event ec:messagesChange - Dispatched when checkout messages change + * @event checkout:close - Dispatched when the checkout overlay is closed (synthetic, not part of ECP) + * + * @example + * ```js + * // Popup target (default) + * const cart = await fetchCart(); + * const checkout = document.createElement("shopify-checkout"); + * checkout.setAttribute("src", cart.checkoutUrl); + * document.body.append(checkout); + * checkout.open(); + * ``` + */ +export class ShopifyCheckout + extends HTMLElement + implements CheckoutAttributes, CheckoutMethods, CheckoutProperties +{ + static observedAttributes = ["src", "target"] as const; + + constructor() { + super(); + + this.attachShadow({ mode: "open" }).appendChild(SHADOW_TEMPLATE.content.cloneNode(true)); + } + + #checkout?: Checkout; + #error?: UcpErrorResponse; + + #checkoutWindow: WindowProxy | null = null; + + // Manages the listeners for the popup window, new tabs, and scrim dialog + #currentOpen: { controller: AbortController } | null = null; + // Manages the global message event listener for checkout protocol communication + #checkoutProtocolController: { controller: AbortController } | null = null; + + /* ------------------------------------------------------------ + * Read/write properties (reflected with attributes) + * ------------------------------------------------------------ + */ + + get src(): string { + return this.getAttribute("src") ?? ""; + } + + set src(value: string | undefined) { + this.#setAttribute("src", value); + // see also attributeChangedCallback + } + + /** + * Parses `src` as a URL, validates the scheme, and appends the `ec_*` + * query parameters used for embedded checkout protocol negotiation. + * Returns `undefined` if `src` is unset, malformed, or uses a non- + * `https:` scheme. + */ + #srcAsURL() { + let url: URL; + try { + url = new URL(this.src); + } catch { + return undefined; + } + if (url.protocol !== "https:") return undefined; + + // Drop ec_auth if present on src (e.g. prepared checkout URLs); this build + // does not support passing auth via query string. + url.searchParams.delete("ec_auth"); + + url.searchParams.set("ec_version", EMBED_PROTOCOL_VERSION); + if (EMBED_DELEGATIONS.length > 0) { + url.searchParams.set("ec_delegate", EMBED_DELEGATIONS.join(",")); + } + return url; + } + + /** + * The origin we expect to receive postMessage events from. This is the + * origin of `src` after URL parsing and scheme validation. Returns + * `undefined` if `src` is unset or invalid, in which case all inbound + * messages are dropped. + */ + #expectedOrigin(): string | undefined { + return this.#srcAsURL()?.origin; + } + + /** + * Whether the component should log diagnostic messages to the console. + */ + get debug(): boolean { + return this.getAttribute("debug") !== null; + } + + set debug(value: boolean | string | undefined) { + this.#setAttribute("debug", value); + } + + /** + * Logs a warning to the console only when `debug` is enabled. Use this + * for messages that are useful while integrating but noise once a + * partner has shipped (e.g., dropped messages, invalid `src`). + */ + #debugWarn(message: string, ...args: unknown[]) { + if (this.debug) { + // eslint-disable-next-line no-console + console.warn(`: ${message}`, ...args); + } + } + + get target(): CheckoutTarget | string { + return this.getAttribute("target") ?? "auto"; + } + + set target(value: CheckoutTarget | string | undefined) { + this.#setAttribute("target", value); + } + + #setAttribute(name: string, value: string | boolean | undefined) { + if (value === true) { + this.setAttribute(name, ""); + } else if (value != null && value !== false) { + this.setAttribute(name, value); + } else { + this.removeAttribute(name); + } + } + + /* ------------------------------------------------------------ + * Read-only properties (populated by checkout protocol events) + * ------------------------------------------------------------ + */ + + /** + * The latest UCP `Checkout` object received from the embedded checkout. + * Populated and updated whenever a notification carrying a `checkout` field + * is received (e.g., `ec.start`, `ec.complete`, every `ec.*.change`). + * + * @returns The current Checkout, or undefined before the first notification. + * @example + * checkout.addEventListener('ec:start', () => { + * const {line_items, totals, buyer} = checkout.checkout; + * }); + */ + get checkout(): Checkout | undefined { + return this.#checkout; + } + + /** + * Session-level fatal error received via `ec.error`. + * + * @returns The UCP error response, or undefined. + * @example + * checkout.addEventListener('ec:error', () => { + * const {messages} = checkout.error; + * console.error(messages[0]?.code, messages[0]?.content); + * }); + */ + get error(): UcpErrorResponse | undefined { + return this.#error; + } + + get #dialogElement(): HTMLDialogElement | undefined { + return this.shadowRoot?.querySelector("#overlay") ?? undefined; + } + + get #dialogBackgroundElement(): HTMLDivElement | undefined { + return this.shadowRoot?.querySelector("#overlay-background") ?? undefined; + } + + get #dialogCloseButtonElement(): HTMLButtonElement | undefined { + return this.shadowRoot?.querySelector("#overlay-close-button") ?? undefined; + } + + get #dialogLinkElement(): HTMLAnchorElement | undefined { + return this.shadowRoot?.querySelector("#overlay-link") ?? undefined; + } + + get #targetElement(): HTMLDivElement | undefined { + return this.shadowRoot?.querySelector(".Shopify-target") ?? undefined; + } + + /* ------------------------------------------------------------ + * Methods + * ------------------------------------------------------------ + */ + + /** + * Reveals checkout in the target. + */ + open(): void { + const { target } = this; + const src = this.#srcAsURL()?.href; + + if (!src) { + // eslint-disable-next-line no-console + console.warn("``: src property is empty or invalid, cannot open checkout"); + return; + } + + // Close any existing sessions before opening a new one + if (this.#currentOpen) { + this.close(); + } + + let checkoutWindow: WindowProxy | null = null; + + switch (target) { + case "popup": { + const features = this.#getPopupFeatures(); + checkoutWindow = window.open(src, "", features); + break; + } + + case "auto": + case "_blank": + default: { + if (target === "_self" || target === "_parent" || target === "_top") { + this.#debugWarn( + `target="${target}" would navigate the current page; falling back to "auto"`, + ); + checkoutWindow = window.open(src, "auto"); + } else { + checkoutWindow = window.open(src, target); + } + break; + } + } + + const abortController = new AbortController(); + + // Opens a dialog element to act as a scrim over the current window while the popup is open. + // The dialog can be closed by the user, or will close itself when the popup is closed. + const dialog = this.#dialogElement; + const dialogBackground = this.#dialogBackgroundElement; + const dialogCloseButton = this.#dialogCloseButtonElement; + const dialogLink = this.#dialogLinkElement; + + if (dialog && dialogBackground) { + // By default we show the scrim. + // If a consumer wants to hide it, they can either: + // 1. Set `display: none` on the `` element itself + // 2. Set `display: none` on the overlay using CSS parts, e.g., + // ``` + // shopify-checkout::part(overlay) { + // display: none; + // } + // ``` + // It's important not to call `dialog.showModal()` if the dialog is not visible because it traps focus and + // hides the rest of the page from the accessibility tree. + const isElementHidden = window.getComputedStyle(this).getPropertyValue("display") === "none"; + const isOverlayHidden = + window.getComputedStyle(dialogBackground).getPropertyValue("display") === "none"; + const showDialog = !isElementHidden && !isOverlayHidden; + + if (showDialog) { + dialog.showModal(); + + dialogCloseButton?.addEventListener( + "click", + () => { + dialog.close(); + }, + { + signal: abortController.signal, + }, + ); + + dialog.addEventListener( + "close", + () => { + abortController.abort(); + }, + { + signal: abortController.signal, + }, + ); + + dialogLink?.addEventListener( + "click", + (event: MouseEvent) => { + event.preventDefault(); + this.#checkoutWindow?.focus(); + }, + { + signal: abortController.signal, + }, + ); + + abortController.signal.addEventListener("abort", () => { + dialog.close(); + }); + } + } + + abortController.signal.addEventListener("abort", () => { + checkoutWindow?.close(); + this.#checkoutWindow = null; + this.#currentOpen = null; + this.dispatchEvent(new ShopifyCheckoutCloseEvent()); + }); + + // Handles cases where the user closed the window and returned to the page. + window.addEventListener( + "focus", + () => { + // Small delay to allow browser to update the closed property + setTimeout(() => { + if (checkoutWindow?.closed) { + this.#currentOpen?.controller.abort(); + } + }, 50); + }, + { + signal: abortController.signal, + }, + ); + + this.#currentOpen = { controller: abortController }; + this.#checkoutWindow = checkoutWindow; + } + + close(): void { + if (this.#currentOpen) { + this.#currentOpen.controller.abort(); + } + } + + override focus(): void { + this.#checkoutWindow?.focus(); + } + + /** + * Sets the overlay link href to the validated, parametrised checkout + * URL (matching what the popup would open) + */ + #updateOverlayLink() { + const link = this.#dialogLinkElement; + if (!link) return; + const url = this.#srcAsURL(); + if (url) { + link.setAttribute("href", url.href); + } else { + link.removeAttribute("href"); + } + } + + /** + * Adds the `Shopify-target--` modifier class to the rendered + * target element + */ + #applyTargetClass() { + const value = this.target; + if (!value || /\s/.test(value)) return; + this.#targetElement?.classList.add(`Shopify-target--${value}`); + } + + /** Mirror of `#applyTargetClass` for removing a previous modifier. */ + #removeTargetClass(value: string | null) { + if (!value || /\s/.test(value)) return; + this.#targetElement?.classList.remove(`Shopify-target--${value}`); + } + + #getPopupFeatures() { + const computedStyle = window.getComputedStyle(this); + const widthFromCustomProperty = computedStyle.getPropertyValue( + "--shopify-checkout-dialog-width", + ); + const desiredWidth = widthFromCustomProperty + ? Number.parseInt(widthFromCustomProperty, 10) + : DEFAULT_POPUP_WIDTH; + const screenLeft = window.screenLeft ?? window.screenX; + const windowWidth = window.outerWidth ?? document.documentElement.clientWidth ?? screen.width; + const maxWidth = Math.floor(windowWidth * 0.9); + const width = Math.min(desiredWidth, maxWidth); + + const heightFromCustomProperty = computedStyle.getPropertyValue( + "--shopify-checkout-dialog-height", + ); + const desiredHeight = heightFromCustomProperty + ? Number.parseInt(heightFromCustomProperty, 10) + : DEFAULT_POPUP_HEIGHT; + + const screenTop = window.screenTop ?? window.screenY; + const windowHeight = + window.outerHeight ?? document.documentElement.clientHeight ?? screen.height; + const maxHeight = Math.floor(windowHeight * 0.9); + const height = Math.min(desiredHeight, maxHeight); + + const left = Math.floor((windowWidth - width) / 2) + screenLeft; + const top = Math.floor((windowHeight - height) / 2) + screenTop; + + const features = [ + `width=${width}`, + `height=${height}`, + `left=${left}`, + `top=${top}`, + `scrollbars=yes`, + `status=no`, + `toolbar=no`, + `resizable=yes`, + ].join(","); + + return features; + } + + /* ------------------------------------------------------------ + * Events + * ------------------------------------------------------------ + */ + + /** + * JSON-RPC request messages carry an `id`; notifications do not. + */ + #isRespondableRequest( + message: CheckoutProtocolMessage, + ): message is CheckoutProtocolMessage & { id: string } { + return message.id != null; + } + + #initCheckoutProtocol() { + // Clean up any existing checkout protocol controller to prevent memory leaks + // Necessary because connectedCallback() can be called multiple times + // if the element is moved within the DOM, but disconnectedCallback() is not called + // during DOM moves, leading to potentially accumulated event listeners. + this.#checkoutProtocolController?.controller.abort(); + + this.#checkoutProtocolController = { controller: new AbortController() }; + window.addEventListener("message", this.#handleMessage, { + signal: this.#checkoutProtocolController.controller.signal, + }); + } + + #handleMessage = (event: MessageEvent) => { + // Source check: messages must come from the embedded checkout window + // we opened. Unrelated postMessage traffic on the host page (other + // SDKs, browser extensions, etc.) is dropped silently. + if (event.source !== this.#checkoutWindow) return; + + const expected = this.#expectedOrigin(); + if (!expected || event.origin !== expected) { + this.#debugWarn( + `Dropped message from unexpected origin "${event.origin}" (expected "${expected ?? "none — src is invalid or unset"}")`, + ); + return; + } + + const message = CheckoutProtocolMessage.parse(event); + if (!message) return; + + // @see https://ucp.dev/2026-04-08/specification/embedded-checkout/ + + // Every notification that carries a checkout payload updates the cached value. + if (message.body != null && typeof message.body === "object" && "checkout" in message.body) { + this.#checkout = (message.body as { checkout: Checkout }).checkout; + } + + switch (message.name) { + case "ec.ready": { + if (this.#isRespondableRequest(message) && message.source) { + (message.source as WindowProxy).postMessage( + { jsonrpc: "2.0" as const, id: message.id, result: {} }, + message.origin, + ); + } + break; + } + case "ec.start": { + this.dispatchEvent(new ShopifyEcStartEvent()); + break; + } + case "ec.complete": { + this.dispatchEvent(new ShopifyEcCompleteEvent()); + break; + } + case "ec.error": { + this.#error = message.body as CheckoutProtocolMessageMap["ec.error"]; + this.dispatchEvent(new ShopifyEcErrorEvent()); + break; + } + case "ec.line_items.change": { + this.dispatchEvent(new ShopifyEcLineItemsChangeEvent()); + break; + } + case "ec.buyer.change": { + this.dispatchEvent(new ShopifyEcBuyerChangeEvent()); + break; + } + case "ec.totals.change": { + this.dispatchEvent(new ShopifyEcTotalsChangeEvent()); + break; + } + case "ec.messages.change": { + this.dispatchEvent(new ShopifyEcMessagesChangeEvent()); + break; + } + case "ec.window.open_request": { + if (!this.#isRespondableRequest(message)) break; + const body = message.body as + | CheckoutProtocolMessageMap["ec.window.open_request"] + | undefined; + if (!body || typeof body.url !== "string") { + // eslint-disable-next-line no-console + console.warn( + ": ec.window.open_request received without a valid url", + message, + ); + message.source?.postMessage( + { + jsonrpc: "2.0" as const, + id: message.id, + error: { + code: -32602, + message: "Invalid params: expected {url: string}", + }, + }, + { targetOrigin: message.origin }, + ); + break; + } + let targetUrl: URL; + try { + targetUrl = new URL(body.url); + } catch { + message.source?.postMessage( + { + jsonrpc: "2.0" as const, + id: message.id, + error: { + code: -32602, + message: "Invalid params: url is not a valid URL", + }, + }, + { targetOrigin: message.origin }, + ); + break; + } + if (targetUrl.protocol !== "https:") { + message.source?.postMessage( + { + jsonrpc: "2.0" as const, + id: message.id, + error: { + code: -32602, + message: "Invalid params: url must use https scheme", + }, + }, + { targetOrigin: message.origin }, + ); + break; + } + window.open(targetUrl.href, "_blank", "noopener"); + message.source?.postMessage( + { jsonrpc: "2.0" as const, id: message.id, result: {} }, + { targetOrigin: message.origin }, + ); + break; + } + default: { + // eslint-disable-next-line no-console + console.warn( + `: Unknown checkout protocol message received: ${message.name}`, + message, + ); + break; + } + } + }; + + /* ------------------------------------------------------------ + * Lifecycle + * ------------------------------------------------------------ + */ + + connectedCallback(): void { + this.#applyTargetClass(); + this.#updateOverlayLink(); + + this.#initCheckoutProtocol(); + } + + disconnectedCallback(): void { + this.#checkoutProtocolController?.controller.abort(); + this.#checkoutProtocolController = null; + this.close(); + } + + attributeChangedCallback( + name: (typeof ShopifyCheckout.observedAttributes)[number], + oldValue: string, + newValue: string, + ): void { + if (oldValue === newValue) return; + + switch (name) { + case "src": + this.#updateOverlayLink(); + break; + case "target": { + if (oldValue !== newValue && this.#currentOpen) { + this.close(); + } + + this.#removeTargetClass(oldValue); + this.#applyTargetClass(); + + break; + } + } + } + + /* ------------------------------------------------------------ + * Custom Events + * ------------------------------------------------------------ + */ + // we overload these so that the consumer of the component can autocomplete the correct events + override addEventListener( + type: "ec:start", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "checkout:close", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "ec:complete", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "ec:error", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "ec:lineItemsChange", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "ec:buyerChange", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "ec:totalsChange", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "ec:messagesChange", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void { + if (listener === null) return; + super.addEventListener(type, listener, options); + } +} + +// An abstract class here lets us force the type of the target and currentTarget properties, +// without introducing a real class in the prototype chain. +abstract class ShopifyCheckoutEvent extends Event { + // Convenience getter for accessing the checkout related to this event + get checkout() { + return this.target as ShopifyCheckout; + } +} + +export class ShopifyEcStartEvent extends ShopifyCheckoutEvent { + declare type: "ec:start"; + + constructor() { + super("ec:start", { bubbles: true }); + } +} + +export class ShopifyEcCompleteEvent extends ShopifyCheckoutEvent { + declare type: "ec:complete"; + + constructor() { + super("ec:complete", { bubbles: true }); + } +} + +export class ShopifyCheckoutCloseEvent extends ShopifyCheckoutEvent { + declare type: "checkout:close"; + + constructor() { + super("checkout:close", { bubbles: true }); + } +} + +export class ShopifyEcErrorEvent extends ShopifyCheckoutEvent { + declare type: "ec:error"; + + constructor() { + super("ec:error", { bubbles: true }); + } +} + +export class ShopifyEcLineItemsChangeEvent extends ShopifyCheckoutEvent { + declare type: "ec:lineItemsChange"; + + constructor() { + super("ec:lineItemsChange", { bubbles: true }); + } +} + +export class ShopifyEcBuyerChangeEvent extends ShopifyCheckoutEvent { + declare type: "ec:buyerChange"; + + constructor() { + super("ec:buyerChange", { bubbles: true }); + } +} + +export class ShopifyEcTotalsChangeEvent extends ShopifyCheckoutEvent { + declare type: "ec:totalsChange"; + + constructor() { + super("ec:totalsChange", { bubbles: true }); + } +} + +export class ShopifyEcMessagesChangeEvent extends ShopifyCheckoutEvent { + declare type: "ec:messagesChange"; + + constructor() { + super("ec:messagesChange", { bubbles: true }); + } +} + +/* ------------------------------------------------------------ + * Checkout protocol + * ------------------------------------------------------------ + */ +const CHECKOUT_PROTOCOL_MESSAGES: (keyof CheckoutProtocolMessageMap)[] = [ + "ec.ready", + "ec.start", + "ec.complete", + "ec.error", + "ec.line_items.change", + "ec.buyer.change", + "ec.totals.change", + "ec.messages.change", + "ec.window.open_request", +]; + +class CheckoutProtocolMessage< + MessageType extends keyof CheckoutProtocolMessageMap = keyof CheckoutProtocolMessageMap, +> { + static parse(event: MessageEvent): CheckoutProtocolMessage | undefined { + const { data, source, origin } = event; + if (!isCheckoutProtocolMessage(data)) return; + return new CheckoutProtocolMessage(data, { source, origin }); + } + + readonly protocol: { readonly version: string }; + readonly name: MessageType; + readonly body: CheckoutProtocolMessageMap[MessageType]; + /** The JSON-RPC message ID (undefined for notifications) */ + readonly id?: string; + /** The source window to post responses to */ + readonly source: MessageEventSource | null; + /** The origin to use when posting responses */ + readonly origin: string; + + constructor( + { method, params, id }: CheckoutProtocolMessageData & { id?: string }, + { source, origin }: { source: MessageEventSource | null; origin: string }, + ) { + this.protocol = { version: "2026-04-08" }; + this.name = method; + this.body = params as CheckoutProtocolMessageMap[MessageType]; + this.id = id; + this.source = source; + this.origin = origin; + } +} + +function isCheckoutProtocolMessage(data: unknown): data is CheckoutProtocolMessageData { + return ( + data != null && + typeof data === "object" && + "jsonrpc" in data && + data.jsonrpc === "2.0" && + "method" in data && + CHECKOUT_PROTOCOL_MESSAGES.includes(data.method as keyof CheckoutProtocolMessageMap) + ); +} diff --git a/platforms/web/src/checkout.types.ts b/platforms/web/src/checkout.types.ts new file mode 100644 index 00000000..363842f6 --- /dev/null +++ b/platforms/web/src/checkout.types.ts @@ -0,0 +1,243 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +// Types for this component are derived from the 2026-04-08 UCP embedded +// checkout protocol. Embed payload shapes live in `./ucp-embed-types.ts`. + +import type { Checkout, EcReadyParams, ShopCash, UcpErrorResponse } from "./ucp-embed-types"; + +// This component should follow the custom element conventions set out here: +// https://github.com/Shopify/ui-api-design/tree/main/codex. In particular, +// take note of the following: +// +// - Follow the web platform's convention for naming wherever possible +// (e.g.: casing of attriute, property, and event names) +// - Follow the web platform's conventions on reflecting attributes to properties, +// and on the default values of properties +// - Follow the web platform's convention for preferring properties on an +// element over properties on events +// - For events that require more complex handling, follow patterns established +// in more modern web APIs, like `FetchEvent.respondWith()` and +// `ExtendableEvent.waitUntil()`. +// - For imperative methods, try to take inspiration from other element +// methods, like `HTMLDialogElement.showModal()` and `HTMLDialogElement.close()` + +// Documentation-safe types: + +export type CheckoutTarget = "auto" | "popup" | "_blank"; + +export interface CheckoutAttributes { + src?: string; + target?: CheckoutTarget | string; + debug?: boolean | string; +} + +export interface CheckoutMethods { + /** + * Opens the checkout in a popup window by default, but can be configured + * to open in a new tab or named window using the `target` property. + */ + open?: () => void; + + /** + * Closes the checkout popup. + * Can be used after checkout completion or to cancel the checkout process + */ + close?: () => void; +} + +export interface CheckoutProperties { + /** + * The URL of the checkout to load. This will typically come from the `cart.checkoutUrl` field in + * Shopify’s Storefront API, but could also be a cart permalink or other valid checkout URL. + * + * This property is automatically reflected to the `src` attribute, so you can use the `src` attribute + * or this property interchangeably. + */ + src?: string; + + /** + * The mode in which to display the checkout when opened. Defaults to `'auto'`. + * - `'popup'`: Opens checkout in a popup window + * - `'_blank' | `'auto'`: Opens checkout in a new tab (default) + * - `string`: Opens checkout in a new named window + * + * For more details on window targets, see the [`Window.open()` `target` parameter](https://developer.mozilla.org/en-US/docs/Web/API/Window/open#target) + * + * This property is automatically reflected to the `target` attribute, so you can use the `target` attribute + * or this property interchangeably. + */ + target?: CheckoutTarget | string; + + /** + * Whether the component should log diagnostic warnings to the console. + * + * @example + * ```html + * + * ``` + */ + debug?: boolean | string; +} + +// If I just used the raw Event class types here, the docs would output the entire documentation for `Event` +// on every event type. This is kind of neat, but makes the pages huge, and doesn’t make it clear what fields +// are actually important to the user. To get nice docs output, I instead created "*Docs" types that declare +// what we actually want to show on the docs, and the implementation implements those interfaces in its types. +export interface CheckoutEvents { + /** + * Dispatched when checkout has started. + */ + "ec:start": EcStartEvent; + + /** + * Dispatched when the checkout was successfully completed. + */ + "ec:complete": EcCompleteEvent; + + /** + * Dispatched when the checkout overlay is closed, either due to user action or + * from calling the `close()` method. Synthetic — not part of the ECP wire protocol. + */ + "checkout:close": CheckoutCloseEvent; + + /** + * Dispatched on a session-level fatal error. The host should tear down the + * embedded context. + */ + "ec:error": EcErrorEvent; + + /** + * Dispatched when the cart line items change. + */ + "ec:lineItemsChange": EcLineItemsChangeEvent; + + /** + * Dispatched when the buyer information changes. + */ + "ec:buyerChange": EcBuyerChangeEvent; + + /** + * Dispatched when the totals change. + */ + "ec:totalsChange": EcTotalsChangeEvent; + + /** + * Dispatched when checkout messages (warnings, errors, info) change. + */ + "ec:messagesChange": EcMessagesChangeEvent; +} + +export interface CheckoutEvent { + target?: CheckoutElement; +} + +export interface EcStartEvent extends CheckoutEvent { + type: "ec:start"; +} + +export interface EcCompleteEvent extends CheckoutEvent { + type: "ec:complete"; +} + +export interface CheckoutCloseEvent extends CheckoutEvent { + type: "checkout:close"; +} + +export interface EcErrorEvent extends CheckoutEvent { + type: "ec:error"; +} + +export interface EcLineItemsChangeEvent extends CheckoutEvent { + type: "ec:lineItemsChange"; +} + +export interface EcBuyerChangeEvent extends CheckoutEvent { + type: "ec:buyerChange"; +} + +export interface EcTotalsChangeEvent extends CheckoutEvent { + type: "ec:totalsChange"; +} + +export interface EcMessagesChangeEvent extends CheckoutEvent { + type: "ec:messagesChange"; +} + +export type TypedEventListener = + | ((event: Event) => void) + | { + handleEvent(event: Event): void; + }; + +export type CheckoutElement = CheckoutMethods & CheckoutProperties & CheckoutEvents; + +/* ------------------------------------------------------------ + * Checkout Protocol + * ------------------------------------------------------------ + */ + +/** + * A checkout protocol message as it is communicated via postMessage (JSON-RPC 2.0 format) + */ +export interface CheckoutProtocolMessageData< + T extends keyof CheckoutProtocolMessageMap = keyof CheckoutProtocolMessageMap, +> { + jsonrpc: "2.0"; + method: T; + params?: CheckoutProtocolMessageMap[T]; +} + +/** Common payload shape for messages that carry the full Checkout object. */ +interface CheckoutPayload { + checkout: Checkout; + shop_cash?: ShopCash; +} + +/** + * Mapping of the 2026-04-08 ECP messages this component handles to their + * wire-format payloads. Delegation methods (fulfillment.address_change_request, + * payment.instruments_change_request, payment.credential_request) and the + * embedder→embedded `ec.submit` are intentionally omitted — this component + * does not implement payment delegations. + */ +export interface CheckoutProtocolMessageMap { + "ec.ready": EcReadyParams; + "ec.start": CheckoutPayload; + "ec.complete": CheckoutPayload; + "ec.error": UcpErrorResponse; + "ec.line_items.change": CheckoutPayload; + "ec.buyer.change": CheckoutPayload; + "ec.totals.change": CheckoutPayload; + "ec.messages.change": CheckoutPayload; + "ec.window.open_request": { url: string }; +} + +export type { + Checkout, + CheckoutMessage, + EcReadyParams, + OrderConfirmation, + ShopCash, + UcpErrorResponse, +} from "./ucp-embed-types"; diff --git a/platforms/web/src/index.ts b/platforms/web/src/index.ts index c739e1ea..ba84fc00 100644 --- a/platforms/web/src/index.ts +++ b/platforms/web/src/index.ts @@ -21,8 +21,10 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +// Registers `` (side effect). +import "./checkout-web-component"; + // Public API for `@shopify/checkout-kit`. -// -// Add the web component implementation here. As a starting point, the package -// version string is exposed so consumers can verify what they imported. export const VERSION = "0.0.1"; + +export { ShopifyCheckout } from "./checkout"; diff --git a/platforms/web/src/ucp-embed-types.ts b/platforms/web/src/ucp-embed-types.ts new file mode 100644 index 00000000..f8c7ee2c --- /dev/null +++ b/platforms/web/src/ucp-embed-types.ts @@ -0,0 +1,373 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +/** + * UCP embedded-checkout wire payloads (`ec.*`, `checkout` resource, `ec.error`). + * Consolidated from checkout-web embed mapper / proposal-style shapes (2026-04-08). + */ + +// --- shared.ts (subset used by 2026-04-08 Checkout + EcReadyParams + messages) --- + +export type CheckoutStatus = + | "incomplete" + | "requires_escalation" + | "ready_for_complete" + | "complete_in_progress" + | "completed" + | "canceled"; + +export type TotalType = + | "items_discount" + | "subtotal" + | "discount" + | "fulfillment" + | "tax" + | "fee" + | "total"; + +export interface Total { + readonly type: TotalType; + readonly display_text?: string; + readonly amount: number; +} + +export interface CheckoutLineItemItem { + readonly id: string; + readonly title: string; + readonly price: number; + readonly image_url?: string; + readonly [key: string]: unknown; +} + +export interface CheckoutLineItem { + readonly id: string; + readonly item: CheckoutLineItemItem; + readonly quantity: number; + readonly totals: readonly Total[]; + readonly parent_id?: string; + readonly [key: string]: unknown; +} + +export type MessageContentType = "plain" | "markdown"; + +export type CheckoutMessageSeverity = + | "recoverable" + | "requires_buyer_input" + | "requires_buyer_review" + | "unrecoverable"; + +export interface CheckoutMessageError { + readonly type: "error"; + readonly code: string; + readonly path?: string; + readonly content: string; + readonly content_type?: MessageContentType; + readonly severity: CheckoutMessageSeverity; + readonly [key: string]: unknown; +} + +export interface CheckoutMessageWarning { + readonly type: "warning"; + readonly code: string; + readonly path?: string; + readonly content: string; + readonly content_type?: MessageContentType; + readonly [key: string]: unknown; +} + +export interface CheckoutMessageInfo { + readonly type: "info"; + readonly path?: string; + readonly code?: string; + readonly content: string; + readonly content_type?: MessageContentType; + readonly [key: string]: unknown; +} + +/** Resource-level messages on checkout (not the same as session `ec.error`). */ +export type CheckoutMessage = CheckoutMessageError | CheckoutMessageWarning | CheckoutMessageInfo; + +export type CheckoutLinkType = + | "privacy_policy" + | "terms_of_service" + | "refund_policy" + | "shipping_policy" + | "faq" + | (string & {}); + +export interface CheckoutLink { + readonly type: CheckoutLinkType; + readonly url: string; + readonly title?: string; + readonly [key: string]: unknown; +} + +export interface DiscountAllocation { + readonly path: string; + readonly amount: number; +} + +export interface AppliedDiscount { + readonly title: string; + readonly amount: number; + readonly code?: string; + readonly automatic?: boolean; + readonly method?: "each" | "across"; + readonly priority?: number; + readonly provisional?: boolean; + readonly eligibility?: string; + readonly allocations?: readonly DiscountAllocation[]; +} + +export interface CheckoutDiscounts { + readonly codes?: readonly string[]; + readonly applied?: readonly AppliedDiscount[]; +} + +export type FulfillmentMethodType = "shipping" | "pickup"; + +export interface FulfillmentOption { + readonly id: string; + readonly title: string; + readonly description?: string; + readonly carrier?: string; + readonly earliest_fulfillment_time?: string; + readonly latest_fulfillment_time?: string; + readonly totals: readonly Total[]; + readonly [key: string]: unknown; +} + +export interface FulfillmentGroup { + readonly id: string; + readonly line_item_ids: readonly string[]; + readonly options?: readonly FulfillmentOption[]; + readonly selected_option_id?: string; + readonly [key: string]: unknown; +} + +export interface FulfillmentAvailableMethod { + readonly type: FulfillmentMethodType; + readonly line_item_ids: readonly string[]; + readonly fulfillable_on: string | null; + readonly description?: string; + readonly [key: string]: unknown; +} + +/** `ec.ready` request params (handshake). */ +export interface EcReadyParams { + readonly delegate: readonly string[]; + readonly [key: string]: unknown; +} + +/** Populated on completed checkout (`checkout.order`). */ +export interface OrderConfirmation { + readonly id: string; + readonly permalink_url: string; + readonly [key: string]: unknown; +} + +// --- embed.ts (UcpPaymentRedemption only — rest is from mapper files) --- + +export interface UcpPaymentRedemption { + readonly source: string; + readonly amount: number; + readonly [key: string]: unknown; +} + +// --- proposal/types.ts (minimal payment + address for Payment + destinations) --- + +export interface UcpPostalAddress { + readonly extended_address?: string; + readonly street_address?: string; + readonly address_locality?: string; + readonly address_region?: string; + readonly address_country?: string; + readonly postal_code?: string; + readonly first_name?: string; + readonly last_name?: string; + readonly phone_number?: string; + readonly [key: string]: unknown; +} + +export interface UcpPaymentInstrumentDisplay { + readonly brand?: string; + readonly last_digits?: string; + readonly description?: string; + readonly card_art?: string; + readonly [key: string]: unknown; +} + +export type PaymentInstrument = UcpPaymentInstrument; + +export interface UcpPaymentInstrument { + readonly id: string; + readonly handler_id: string; + readonly type: string; + readonly selected?: boolean; + readonly display?: UcpPaymentInstrumentDisplay; + readonly cardholder_name?: string; + readonly expiry_month?: number; + readonly expiry_year?: number; + readonly credential?: Readonly>; + readonly billing_address?: UcpPostalAddress; + readonly [key: string]: unknown; +} + +// --- shared.ts: UcpMetadata base + service/capability (for checkout.ucp) --- + +export interface UcpPaymentHandler { + readonly id: string; + readonly name: string; + readonly version: string; + readonly spec?: string; + readonly schema?: string; + readonly config?: Readonly>; + readonly [key: string]: unknown; +} + +export interface UcpService { + readonly version: string; + readonly transport: "embedded"; + readonly schema?: string; + readonly endpoint?: string; + readonly config?: Readonly>; + readonly [key: string]: unknown; +} + +export interface UcpCapability { + readonly version: string; + readonly extends?: string | readonly string[]; + readonly spec?: string; + readonly schema?: string; + readonly config?: Readonly>; + readonly [key: string]: unknown; +} + +/** Base metadata (2026-01-23 legacy + 2026-04-08 services). */ +export interface UcpMetadataBase { + readonly version: string; + readonly transports?: { + readonly embedded: { + readonly schema: string; + readonly delegations: readonly string[]; + }; + }; + readonly services?: Readonly>; + readonly payment_handlers?: Readonly>; + readonly [key: string]: unknown; +} + +/** 2026-04-08: adds required `capabilities` registry. */ +export interface UcpMetadata extends UcpMetadataBase { + readonly capabilities: Readonly>; +} + +// --- 2026-04-08.ts (Checkout, ShopCash, UcpErrorResponse) --- + +export interface Buyer { + readonly email?: string; + readonly phone_number?: string; + readonly first_name?: string; + readonly last_name?: string; + readonly [key: string]: unknown; +} + +export interface Payment { + readonly instruments?: readonly PaymentInstrument[]; + readonly [key: string]: unknown; +} + +export interface ShippingDestination extends UcpPostalAddress { + readonly id: string; +} + +export interface RetailLocation { + readonly id: string; + readonly name: string; + readonly address?: UcpPostalAddress; + readonly [key: string]: unknown; +} + +export type FulfillmentDestination = ShippingDestination | RetailLocation; + +export interface FulfillmentMethod { + readonly id: string; + readonly type: FulfillmentMethodType; + readonly line_item_ids: readonly string[]; + readonly selected_destination_id?: string; + readonly destinations?: readonly FulfillmentDestination[]; + readonly groups?: readonly FulfillmentGroup[]; + readonly [key: string]: unknown; +} + +export interface Fulfillment { + readonly methods: readonly FulfillmentMethod[]; + readonly available_methods?: readonly FulfillmentAvailableMethod[]; + readonly [key: string]: unknown; +} + +/** Wire `checkout` object in `ec.*` notifications carrying full resource. */ +export interface Checkout { + readonly ucp: UcpMetadata; + readonly id: string; + readonly currency: string; + readonly line_items: readonly CheckoutLineItem[]; + readonly totals: readonly Total[]; + readonly buyer?: Buyer; + readonly payment?: Payment; + readonly status: CheckoutStatus; + readonly messages?: readonly CheckoutMessage[]; + readonly links: readonly CheckoutLink[]; + readonly continue_url?: string; + readonly expires_at?: string; + readonly fulfillment?: Fulfillment; + readonly order?: OrderConfirmation; + readonly redemptions?: readonly UcpPaymentRedemption[]; + readonly discounts?: CheckoutDiscounts; + readonly [key: string]: unknown; +} + +export interface ShopCash { + readonly balance: { + readonly status: "unavailable" | "pending" | "filled"; + readonly available_balance?: number; + }; + readonly expected_earnings?: number; +} + +export interface UcpEnvelope { + readonly version: string; + readonly status: "success" | "error"; + readonly [key: string]: unknown; +} + +export type UcpSuccessEnvelope = UcpEnvelope & { readonly status: "success" }; +export type UcpErrorEnvelope = UcpEnvelope & { readonly status: "error" }; + +/** Session-level fatal `ec.error` params / delegation error branch. */ +export interface UcpErrorResponse { + readonly ucp: UcpErrorEnvelope; + readonly messages: readonly CheckoutMessageError[]; + readonly continue_url?: string; + readonly [key: string]: unknown; +} diff --git a/platforms/web/src/utils.ts b/platforms/web/src/utils.ts new file mode 100644 index 00000000..19848693 --- /dev/null +++ b/platforms/web/src/utils.ts @@ -0,0 +1,68 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +/** + * Branded type marking a string as safe to interpolate into an `html` + * or `css` tagged template. The brand can only be added via `safe()`, + * `html\`\``, or `css\`\``, so any direct interpolation of a raw + * `string` is a compile-time error. + */ +declare const SafeBrand: unique symbol; +export type SafeMarkup = string & { readonly [SafeBrand]: true }; + +/** + * Use this to drop a plain string into an `html` or `css` template. + * Reach for it sparingly: anything that came from an attribute, + * property, or message payload belongs outside the template, applied + * through DOM APIs after render. + */ +export const safe = (value: string): SafeMarkup => value as SafeMarkup; + +/** + * Tagged template literal that concatenates strings and `SafeMarkup` + * values into a `SafeMarkup` result. The `html` name is purely for + * editor syntax highlighting — it does NOT escape or sanitize. The + * `SafeMarkup` interpolation constraint at the type level prevents + * accidentally embedding a raw string without going through `safe()`. + */ +export const html = (strings: TemplateStringsArray, ...values: SafeMarkup[]): SafeMarkup => { + let raw = strings[0] ?? ""; + for (let i = 0; i < values.length; i++) { + raw += values[i] + (strings[i + 1] ?? ""); + } + return raw as SafeMarkup; +}; + +/** Same semantics as `html`; separate name for CSS editor highlighting. */ +export const css: typeof html = html; + +/** + * Parses a `SafeMarkup` string into an inert `