diff --git a/platforms/web/README.md b/platforms/web/README.md index b935eeac1..3375cee4b 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -15,9 +15,9 @@ present the world's highest converting, customizable, one-page checkout. The presented experience is a fully-featured checkout that preserves all of the store customizations: Checkout UI extensions, Functions, branding, and more. It also provides web idiomatic defaults such as opening checkout in a popup or -new tab, a transient overlay scrim while the popup is open, and convenient -developer APIs to embed, customize, and follow the lifecycle of the checkout -experience via the +new tab, a transient overlay scrim while the popup is open, and +convenient developer APIs to embed, customize, and follow the lifecycle of the +checkout experience via the [Embedded Checkout Protocol](https://ucp.dev/2026-04-08/specification/embedded-checkout/). Check out our blog to @@ -33,6 +33,8 @@ Check out our blog to - [Usage with the Shopify Storefront API](#usage-with-the-shopify-storefront-api) - [Configuration](#configuration) - [`src`](#src) + - [`presentation`](#presentation) + - [Authentication](#authentication) - [`target`](#target) - [`debug`](#debug) - [Popup dimensions](#popup-dimensions) @@ -97,7 +99,7 @@ checkout: @@ -112,9 +114,9 @@ checkout: ``` -The element has no visible layout of its own beyond a transient `` -scrim that appears over the host page while the popup is open. It can sit -anywhere in your DOM. +For popup and auto presentations, the element has no visible layout of its own +beyond a transient `` scrim that appears over the host page while the +popup is open. See [usage with the Storefront API](#usage-with-the-shopify-storefront-api) below for details on how to obtain a checkout URL. @@ -129,7 +131,7 @@ import type {ShopifyCheckout} from '@shopify/checkout-kit'; const checkout = document.createElement('shopify-checkout') as ShopifyCheckout; checkout.src = 'https://your-store.myshopify.com/checkouts/cn/abc123'; -checkout.target = 'popup'; +checkout.presentation = 'popup'; document.body.append(checkout); checkout.addEventListener('ec.complete', (event) => { @@ -192,7 +194,7 @@ export function BuyNowButton({checkoutUrl}: {checkoutUrl: string}) { return ( <> - + ); @@ -216,7 +218,12 @@ declare module 'react' { 'shopify-checkout': DetailedHTMLProps< HTMLAttributes, ShopifyCheckout - > & {src?: string; target?: string; debug?: boolean}; + > & { + src?: string; + presentation?: string; + target?: string; + debug?: boolean; + }; } } } @@ -323,19 +330,64 @@ checkout: `ec_version` (Embedded Checkout Protocol version), `ec_delegate` (which capabilities the host delegates), and `ck_version` (the Checkout Kit version). +### `presentation` + +How checkout is presented when `open()` is called. Defaults to `"auto"`. + +| Value | Behavior | +| ---------- | --------------------------------------------------------------------- | +| `"auto"` | Opens checkout in a new browser tab or named window based on `target`. | +| `"popup"` | Opens checkout in a popup window sized and centered over the page. | + +For popup integrations, prefer `presentation="popup"`: + +```html + +``` + +```ts +checkout.presentation = 'popup'; +checkout.open(); +``` + +For new-tab integrations, use `presentation="auto"` with `target="_blank"`. +For compatibility, omitting `presentation` and setting `target="popup"` still +opens a popup, but prefer `presentation="popup"` for new code: + +```html + +``` + +### Authentication + +Authentication for embedded checkout is not available in Checkout Kit yet. +[Authenticated checkout](https://ucp.dev/latest/specification/embedded-checkout/#authentication) +via UCP flows will be documented once they are supported. + ### `target` -Where the checkout is presented. Defaults to `"auto"`. +The browser context target used when `presentation="auto"`. Defaults to +`"auto"`. | Value | Behavior | | ---------- | ------------------------------------------------------------------- | | `"auto"` | Opens checkout in a new browser tab (default). | -| `"popup"` | Opens checkout in a popup window sized and centered over the page. | -| `"_blank"` | Synonym for `"auto"` — new tab. | -| _(string)_ | Any other value is treated as a named window target, the same as the [`target` parameter of `window.open()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/open#target). | +| `"popup"` | Legacy alias for `presentation="popup"` only when `presentation` is omitted. | +| `"_blank"` | Opens checkout in a new browser tab/window. | +| _(string)_ | Passed as the [`target` parameter of `window.open()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/open#target). | + +When `presentation="auto"` is set explicitly, `target` is passed to +`window.open()` as the browser context target. ```html - + ``` > [!NOTE] @@ -354,9 +406,9 @@ and event handlers; turn it off in production. ### Popup dimensions -When `target="popup"`, the popup is centered over the host window. Defaults -are `600 × 600`, capped at 90% of the host window. Override via CSS custom -properties: +When `presentation="popup"`, the popup is centered over the host window. +Defaults are `600 × 600`, capped at 90% of the host window. Override via CSS +custom properties: ```css shopify-checkout { @@ -394,7 +446,7 @@ exactly the fields relevant to that moment. | `ec.complete` | `{checkout, order}` | The buyer completed the order successfully. | | `ec.close` | _(none)_ | The popup was dismissed (by the buyer, by `close()`, or by `focus` loss). | | `ec.error` | `{error}` | Session-level fatal error — tear down the embedded context. | -| `ec.line_items.change` | `{checkout, lineItems}` | The cart's line items changed (item added/removed/quantity updated). | +| `ec.line_items.change` | `{checkout, lineItems}` | The cart's line items changed (item added/removed/quantity updated). | | `ec.totals.change` | `{checkout, totals}` | The cart totals changed (subtotal, tax, shipping, discounts, total). | | `ec.messages.change` | `{checkout, messages}` | Checkout-level warnings/errors/info shown inside the checkout changed. | diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index cb6c77823..63c564fc8 100644 --- a/platforms/web/sample/README.md +++ b/platforms/web/sample/README.md @@ -14,16 +14,17 @@ pnpm sample Vite serves at `http://localhost:5173`. The page has three panels: -- **Options** — form for `src`, `target` (`auto` | `popup`), and `debug`, - plus buttons for `open()`, `close()`, and `focus()`. +- **Options** — form for `src`, `presentation`, `target`, 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`. + `checkout.open()`. The button stays disabled until a checkout URL is set. The + collapsible readout shows `checkout`, `error`, `presentation`, `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. +Popup / auto presentations mostly show the overlay scrim while checkout is open +in a separate window or tab. ## Build diff --git a/platforms/web/sample/index.html b/platforms/web/sample/index.html index e28f0a624..63781925d 100644 --- a/platforms/web/sample/index.html +++ b/platforms/web/sample/index.html @@ -34,11 +34,19 @@

Options

/> + + @@ -111,6 +119,8 @@

Studio Plant Pot

+
+
Component state
@@ -118,6 +128,8 @@

Studio Plant Pot

error
+
presentation
+
target
debug
diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts index f0cd48afd..2c054901a 100644 --- a/platforms/web/sample/main.ts +++ b/platforms/web/sample/main.ts @@ -36,10 +36,12 @@ const eventLog = $("#event-log"); const clearLogButton = $("#clear-log"); const buyNowButton = $("#buy-now"); const buyHint = $("#buy-hint"); +const checkoutHost = $("#checkout-host"); const stateNodes = { checkout: $("#state-checkout"), error: $("#state-error"), + presentation: $("#state-presentation"), target: $("#state-target"), debug: $("#state-debug"), }; @@ -47,13 +49,11 @@ const stateNodes = { // ───── Mount the component (off-layout) ─────────────────────────────────── // // In real merchant integrations the element lives wherever -// it makes sense in the page. For popup / auto targets it has no visible UI -// of its own beyond a transient dialog scrim that appears when open() is -// called, so we attach it to and leave the storefront panel free for -// the merchant's product UI. +// it makes sense in the page. Popup / auto presentations have no visible UI of +// their own beyond the transient dialog scrim. const checkout = document.createElement("shopify-checkout") as ShopifyCheckout; -document.body.append(checkout); +checkoutHost.append(checkout); const checkoutEl: HTMLElement = checkout; @@ -70,6 +70,7 @@ function setStringAttribute(el: HTMLElement, name: string, value: FormDataEntryV function syncAttributes(): void { const data = new FormData(form); setStringAttribute(checkout, "src", data.get("src")); + setStringAttribute(checkout, "presentation", data.get("presentation")); setStringAttribute(checkout, "target", data.get("target")); if (data.has("debug")) { checkout.setAttribute("debug", ""); @@ -78,6 +79,7 @@ function syncAttributes(): void { } refreshBuyButton(data.get("src")); + refreshState(); } function refreshBuyButton(src: FormDataEntryValue | null): void { @@ -153,6 +155,7 @@ function snapshotState(): Record { return { checkout: checkout.checkout, error: checkout.error, + presentation: checkout.presentation, target: checkout.target, debug: checkout.debug, }; @@ -161,6 +164,7 @@ function snapshotState(): Record { function refreshState(): void { stateNodes.checkout.textContent = formatValue(checkout.checkout); stateNodes.error.textContent = formatValue(checkout.error); + stateNodes.presentation.textContent = formatValue(checkout.presentation); stateNodes.target.textContent = formatValue(checkout.target); stateNodes.debug.textContent = formatValue(checkout.debug); } diff --git a/platforms/web/sample/styles.css b/platforms/web/sample/styles.css index b847da344..2165e8094 100644 --- a/platforms/web/sample/styles.css +++ b/platforms/web/sample/styles.css @@ -250,6 +250,15 @@ button:disabled { box-shadow: var(--shadow-md); } +.checkout-host { + width: 100%; +} + +.checkout-host shopify-checkout { + display: block; + width: 100%; +} + .product-image { aspect-ratio: 4 / 3; display: grid; diff --git a/platforms/web/src/checkout.styles.ts b/platforms/web/src/checkout.styles.ts index b83490252..88c182c08 100644 --- a/platforms/web/src/checkout.styles.ts +++ b/platforms/web/src/checkout.styles.ts @@ -12,6 +12,14 @@ export const STYLES: SafeMarkup = css` border: none; } + #checkout-frame { + display: block; + inline-size: 100%; + block-size: 100%; + border: 0; + background: hsl(0, 0%, 100%); + } + :host { @media (prefers-reduced-motion: reduce) { --shopify-checkout-overlay-transition-duration: 1ms; @@ -122,4 +130,10 @@ export const STYLES: SafeMarkup = css` fill: currentColor; } } + + :host([presentation="iframe"]) { + display: block; + block-size: var(--shopify-checkout-iframe-height, 640px); + min-block-size: var(--shopify-checkout-iframe-min-height, 640px); + } `; diff --git a/platforms/web/src/checkout.test.ts b/platforms/web/src/checkout.test.ts index f6bc1461e..e87460daf 100644 --- a/platforms/web/src/checkout.test.ts +++ b/platforms/web/src/checkout.test.ts @@ -45,6 +45,30 @@ describe("", () => { expect(checkout.src).toBe(newSrc); }); }); + + describe("presentation", () => { + it("changing the presentation attribute reflects to the presentation property", () => { + const checkout = renderCheckout(); + checkout.setAttribute("presentation", "popup"); + + expect(checkout.presentation).toBe("popup"); + }); + + it('does not derive the presentation property from target="popup"', () => { + const checkout = renderCheckout({ target: "popup" }); + + expect(checkout.presentation).toBe("auto"); + }); + }); + + describe("auth", () => { + it("changing the auth attribute reflects to the auth property", () => { + const checkout = renderCheckout(); + checkout.setAttribute("auth", "test-auth-token"); + + expect(checkout.auth).toBe("test-auth-token"); + }); + }); }); describe("target", () => { @@ -104,6 +128,15 @@ describe("", () => { }); }); + describe("auth", () => { + it("changing the auth property reflects to the auth attribute", () => { + const checkout = renderCheckout(); + checkout.auth = "test-auth-token"; + + expect(checkout.getAttribute("auth")).toBe("test-auth-token"); + }); + }); + describe("target", () => { it("changing the target property reflects to the target attribute", () => { const checkout = renderCheckout(); @@ -113,6 +146,15 @@ describe("", () => { }); }); + describe("presentation", () => { + it("changing the presentation property reflects to the presentation attribute", () => { + const checkout = renderCheckout(); + checkout.presentation = "popup"; + + expect(checkout.getAttribute("presentation")).toBe("popup"); + }); + }); + describe("debug", () => { it("sets the debug attribute when assigned true", () => { const checkout = renderCheckout(); @@ -190,6 +232,21 @@ describe("", () => { expect(url.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); }); + it("passes auth to checkout as ec_auth", () => { + const checkout = renderCheckout({ + auth: "test-auth-token", + 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")).toBe("test-auth-token"); + expect(url.searchParams.get("keep")).toBe("1"); + }); + it("handles invalid src URL gracefully", () => { const checkout = renderCheckout(); checkout.src = "invalid-url"; @@ -251,6 +308,19 @@ describe("", () => { }); }); + describe('when presentation="popup"', () => { + it("opens in a popup window regardless of the window target", () => { + const checkout = renderCheckout({ presentation: "popup", target: "_blank" }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const call = expectWindowOpenArgs(windowOpenSpy); + expect(call[1]).toBe(""); + expect(call[2]).toEqual(expect.stringContaining("scrollbars=yes")); + }); + }); + describe('when target="popup"', () => { it("shows the checkout in a popup window", () => { POPUP_TARGETS.forEach((target) => { @@ -727,26 +797,72 @@ describe("", () => { 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 () => { + it("dispatches a respondable DOM event and posts a successful response", 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); + checkout.addEventListener("ec.ready", onReadySpy); simulateProtocolMessageEvent( checkout, "ec.ready", - { delegate: [] }, + { delegate: ["window.open"], auth: { type: "jwt" } }, { id: "ready-1", source: mockCheckoutWindow }, ); await Promise.resolve(); + expect(onReadySpy).toHaveBeenCalledOnce(); + const event = onReadySpy.mock.calls[0]![0] as CustomEvent<{ + delegate: readonly string[]; + auth?: { type?: string }; + }>; + expect(event.detail).toEqual({ + delegate: ["window.open"], + auth: { type: "jwt" }, + }); + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( + { + jsonrpc: "2.0", + id: "ready-1", + result: { + ucp: { + version: EMBED_PROTOCOL_VERSION, + status: "success", + }, + }, + }, + { targetOrigin: new URL(checkout.src).origin }, + ); + }); + + it("uses respondWith() to include a ready credential", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + + checkout.addEventListener("ec.ready", (event) => { + event.respondWith({ credential: "ready-token" }); + }); + + simulateProtocolMessageEvent( + checkout, + "ec.ready", + { delegate: [], auth: { type: "oauth" } }, + { id: "ready-credential", source: mockCheckoutWindow }, + ); + await Promise.resolve(); + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( - { jsonrpc: "2.0", id: "ready-1", result: {} }, - new URL(checkout.src).origin, + { + jsonrpc: "2.0", + id: "ready-credential", + result: { + ucp: { + version: EMBED_PROTOCOL_VERSION, + status: "success", + }, + credential: "ready-token", + }, + }, + { targetOrigin: new URL(checkout.src).origin }, ); - expect(onReadySpy).not.toHaveBeenCalled(); }); it("does not post a response when id is missing", () => { @@ -763,6 +879,65 @@ describe("", () => { }); }); + describe("ec.auth", () => { + it("uses respondWith() to return a credential", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + + checkout.addEventListener("ec.auth", (event) => { + expect(event.detail.type).toBe("jwt"); + event.respondWith({ credential: "refreshed-token" }); + }); + + simulateProtocolMessageEvent( + checkout, + "ec.auth", + { type: "jwt" }, + { id: "auth-1", source: mockCheckoutWindow }, + ); + await Promise.resolve(); + + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( + { + jsonrpc: "2.0", + id: "auth-1", + result: { + ucp: { + version: EMBED_PROTOCOL_VERSION, + status: "success", + }, + credential: "refreshed-token", + }, + }, + { targetOrigin: new URL(checkout.src).origin }, + ); + }); + + it("posts an error when ec.auth has no credential responder", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + checkout.addEventListener("ec.auth", () => {}); + + simulateProtocolMessageEvent( + checkout, + "ec.auth", + { type: "jwt" }, + { id: "auth-missing", source: mockCheckoutWindow }, + ); + await Promise.resolve(); + + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( + { + jsonrpc: "2.0", + id: "auth-missing", + error: { + code: -32000, + message: "No credential response was provided for ec.auth", + }, + }, + { targetOrigin: new URL(checkout.src).origin }, + ); + }); + }); + describe("unsupported protocol methods", () => { it("posts method-not-found for unsupported requests", () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); @@ -795,7 +970,7 @@ describe("", () => { it("ignores unsupported requests with invalid request ids", () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); - for (const id of [{}, null, true]) { + for (const id of [{}, true]) { simulateRawMessageEvent( checkout, { diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index e8a02f99c..ebdefa62f 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -1,10 +1,14 @@ import { createTemplate, html } from "./utils"; import type { CheckoutAttributes, + CheckoutCredentialResponse, CheckoutMethods, CheckoutProperties, + CheckoutPresentation, CheckoutProtocolMessageMap, + CheckoutReadyResponse, CheckoutTarget, + JsonRpcRequestId, TypedEventListener, CheckoutProtocolMessageData, Checkout, @@ -54,13 +58,18 @@ const SHADOW_TEMPLATE = createTemplate(html` `); /** - * 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()`. + * An element that renders a Shopify Checkout. Checkout opens according to the configured + * `presentation` and `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). + * @attribute presentation - How checkout is presented (auto or popup). + * @attribute auth - Checkout-bound authentication token. + * @attribute target - The browsing context target for non-popup window presentations. * + * @event ec.ready - Dispatched when checkout initiates the ECP handshake + * @event ec.auth - Dispatched when checkout requests an ECP credential * @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 @@ -71,10 +80,11 @@ const SHADOW_TEMPLATE = createTemplate(html` * * @example * ```js - * // Popup target (default) + * // Popup presentation * const cart = await fetchCart(); * const checkout = document.createElement("shopify-checkout"); * checkout.setAttribute("src", cart.checkoutUrl); + * checkout.setAttribute("presentation", "popup"); * document.body.append(checkout); * checkout.open(); * ``` @@ -83,7 +93,7 @@ export class ShopifyCheckout extends HTMLElement implements CheckoutAttributes, CheckoutMethods, CheckoutProperties { - static observedAttributes = ["src", "target"] as const; + static observedAttributes = ["src", "auth", "presentation", "target"] as const; constructor() { super(); @@ -130,9 +140,12 @@ export class ShopifyCheckout } 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. + // Drop ec_auth if present on src (e.g. prepared checkout URLs); the + // explicit auth attribute is the only supported source for this value. url.searchParams.delete("ec_auth"); + if (this.auth) { + url.searchParams.set("ec_auth", this.auth); + } url.searchParams.set("ec_version", EMBED_PROTOCOL_VERSION); if (EMBED_DELEGATIONS.length > 0) { @@ -142,6 +155,14 @@ export class ShopifyCheckout return url; } + get auth(): string { + return this.getAttribute("auth") ?? ""; + } + + set auth(value: string | undefined) { + this.#setAttribute("auth", value); + } + /** * Whether the component should log diagnostic messages to the console. */ @@ -165,6 +186,18 @@ export class ShopifyCheckout } } + get presentation(): CheckoutPresentation { + const presentation = this.getAttribute("presentation"); + if (presentation === "auto" || presentation === "popup" || presentation === "iframe") { + return presentation; + } + return "auto"; + } + + set presentation(value: CheckoutPresentation | undefined) { + this.#setAttribute("presentation", value); + } + get target(): CheckoutTarget | string { return this.getAttribute("target") ?? "auto"; } @@ -183,6 +216,107 @@ export class ShopifyCheckout } } + #resolvedPresentation(): CheckoutPresentation { + const presentation = this.getAttribute("presentation"); + + switch (presentation) { + case "auto": + case "popup": + case "iframe": + return presentation; + case null: + return this.target === "popup" ? "popup" : "auto"; + default: + this.#debugWarn(`presentation="${presentation}" is invalid; falling back to "auto"`); + return "auto"; + } + } + + async #handleReadyRequest(message: CheckoutProtocolMessage): Promise { + if (!this.#isRespondableRequest(message)) return; + + const session = this.#currentOpen; + const body = message.body as CheckoutProtocolMessageMap["ec.ready"] | undefined; + const readyEvent = new ShopifyCheckoutReadyEvent({ + delegate: Array.isArray(body?.delegate) + ? body.delegate.filter((delegation): delegation is string => typeof delegation === "string") + : [], + auth: body?.auth, + }); + this.dispatchEvent(readyEvent); + + let response: CheckoutReadyResponse | undefined; + const responsePromise = getRespondWithResponse(readyEvent); + try { + response = responsePromise ? await responsePromise : undefined; + } catch { + if (this.#isCurrentCheckoutSession(session)) { + postProtocolError(message, -32000, "ec.ready response rejected"); + } + return; + } + + if (!this.#isCurrentCheckoutSession(session)) return; + + postProtocolResult(message, { + ucp: ucpSuccess(), + ...normalizeReadyResponse(response), + }); + } + + async #handleAuthRequest(message: CheckoutProtocolMessage): Promise { + if (!this.#isRespondableRequest(message)) return; + + const session = this.#currentOpen; + const body = message.body; + if (!isObjectRecord(body)) { + postProtocolError(message, -32602, "Invalid params: expected object"); + return; + } + if ("type" in body && body.type != null && typeof body.type !== "string") { + postProtocolError(message, -32602, "Invalid params: type must be a string"); + return; + } + + const auth = body as CheckoutProtocolMessageMap["ec.auth"]; + const authEvent = new ShopifyCheckoutAuthEvent({ + type: typeof auth.type === "string" ? auth.type : undefined, + auth, + }); + this.dispatchEvent(authEvent); + + const responsePromise = getRespondWithResponse(authEvent); + if (!responsePromise) { + if (this.#isCurrentCheckoutSession(session)) { + postProtocolError(message, -32000, "No credential response was provided for ec.auth"); + } + return; + } + + let response: CheckoutCredentialResponse; + try { + response = await responsePromise; + } catch { + if (this.#isCurrentCheckoutSession(session)) { + postProtocolError(message, -32000, "ec.auth response rejected"); + } + return; + } + + if (!this.#isCurrentCheckoutSession(session)) return; + + const credential = normalizeCredentialResponse(response); + if (!credential) { + postProtocolError(message, -32000, "No credential response was provided for ec.auth"); + return; + } + + postProtocolResult(message, { + ucp: ucpSuccess(), + credential, + }); + } + /* ------------------------------------------------------------ * Read-only properties (populated by checkout protocol events) * ------------------------------------------------------------ @@ -233,6 +367,10 @@ export class ShopifyCheckout return this.shadowRoot?.querySelector("#overlay-link") ?? undefined; } + get #iframeElement(): HTMLIFrameElement | undefined { + return this.shadowRoot?.querySelector("#checkout-frame") ?? undefined; + } + get #targetElement(): HTMLDivElement | undefined { return this.shadowRoot?.querySelector(".Shopify-target") ?? undefined; } @@ -243,13 +381,15 @@ export class ShopifyCheckout */ /** - * Reveals checkout in the target. + * Reveals checkout using the configured presentation. */ open(): void { const { target } = this; - const src = this.#srcAsURL()?.href; + const presentation = this.#resolvedPresentation(); + const url = this.#srcAsURL(); + const src = url?.href; - if (!src) { + if (!url || !src) { // eslint-disable-next-line no-console console.warn("``: src property is empty or invalid, cannot open checkout"); return; @@ -260,9 +400,14 @@ export class ShopifyCheckout this.close(); } + if (presentation === "iframe") { + this.#openIframeCheckout(url); + return; + } + let checkoutWindow: WindowProxy | null = null; - switch (target) { + switch (presentation) { case "popup": { const features = this.#getPopupFeatures(); checkoutWindow = window.open(src, "", features); @@ -270,7 +415,6 @@ export class ShopifyCheckout } case "auto": - case "_blank": default: { if (target === "_self" || target === "_parent" || target === "_top") { this.#debugWarn( @@ -421,6 +565,55 @@ export class ShopifyCheckout this.#targetElement?.classList.remove(`Shopify-target--${value}`); } + #openIframeCheckout(url: URL) { + const iframe = this.#iframeElement ?? this.#createIframeElement(); + + const targetElement = this.#targetElement; + if (targetElement && iframe.parentElement !== targetElement) { + targetElement.appendChild(iframe); + } + + iframe.src = url.href; + this.#checkoutWindow = iframe.contentWindow ?? null; + + const abortController = new AbortController(); + abortController.signal.addEventListener("abort", () => { + iframe.remove(); + this.#checkoutWindow = null; + this.#currentOpen = null; + this.dispatchEvent(new ShopifyCheckoutCloseEvent()); + }); + + this.#currentOpen = { controller: abortController }; + } + + #createIframeElement() { + const iframe = document.createElement("iframe"); + iframe.id = "checkout-frame"; + iframe.title = "Checkout"; + iframe.setAttribute("part", "iframe"); + iframe.setAttribute("allow", "publickey-credentials-get *; geolocation"); + iframe.setAttribute( + "sandbox", + "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox", + ); + return iframe; + } + + #updateIframeSrc() { + const iframe = this.#iframeElement; + if (!iframe) return; + + const url = this.#srcAsURL(); + if (!url) { + this.close(); + return; + } + + this.close(); + this.#openIframeCheckout(url); + } + #getPopupFeatures() { const computedStyle = window.getComputedStyle(this); const widthFromCustomProperty = computedStyle.getPropertyValue( @@ -474,8 +667,12 @@ export class ShopifyCheckout */ #isRespondableRequest( message: CheckoutProtocolMessage, - ): message is CheckoutProtocolMessage & { id: string } { - return message.id != null; + ): message is CheckoutProtocolMessage & { id: JsonRpcRequestId } { + return message.id !== undefined; + } + + #isCurrentCheckoutSession(session: { controller: AbortController } | null): boolean { + return session != null && this.#currentOpen === session && !session.controller.signal.aborted; } #validateMessageOrigin(event: MessageEvent) { @@ -536,12 +733,11 @@ export class ShopifyCheckout 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, - ); - } + void this.#handleReadyRequest(message); + break; + } + case "ec.auth": { + void this.#handleAuthRequest(message); break; } case "ec.start": { @@ -699,6 +895,15 @@ export class ShopifyCheckout switch (name) { case "src": + this.#updateOverlayLink(); + this.#updateIframeSrc(); + break; + case "presentation": + if (oldValue !== newValue && this.#currentOpen) { + this.close(); + } + break; + case "auth": this.#updateOverlayLink(); break; case "target": { @@ -719,6 +924,18 @@ export class ShopifyCheckout * ------------------------------------------------------------ */ // we overload these so that the consumer of the component can autocomplete the correct events + override addEventListener( + type: "ec.ready", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "ec.auth", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + override addEventListener( type: "ec.start", listener: TypedEventListener | null, @@ -776,6 +993,20 @@ export class ShopifyCheckout * ------------------------------------------------------------ */ +export interface ShopifyCheckoutReadyEventDetail { + /** Delegation types requested by checkout in `ec.ready.params.delegate`. */ + delegate: readonly string[]; + /** Authorization requested by checkout in `ec.ready.params.auth`, when present. */ + auth?: CheckoutProtocolMessageMap["ec.ready"]["auth"]; +} + +export interface ShopifyCheckoutAuthEventDetail { + /** Authorization type requested by checkout, e.g. `oauth`, `api_key`, or `jwt`. */ + type?: string; + /** Raw `ec.auth` params for integrations that need additional fields. */ + auth: CheckoutProtocolMessageMap["ec.auth"]; +} + export interface ShopifyCheckoutStartEventDetail { /** Initial checkout snapshot from the ECP `ec.start` notification. */ checkout: Checkout; @@ -819,6 +1050,49 @@ export interface ShopifyCheckoutMessagesChangeEventDetail { * ------------------------------------------------------------ */ +const respondWithResponses = new WeakMap>(); + +export class ShopifyCheckoutRespondableEvent extends CustomEvent { + respondWith(response: Promise | ResponsePayload): void { + if (respondWithResponses.has(this)) { + throw new DOMException( + `: respondWith() has already been called for this ${this.type} event`, + "InvalidStateError", + ); + } + + respondWithResponses.set(this, Promise.resolve(response)); + } +} + +function getRespondWithResponse( + event: Event, +): Promise | undefined { + return respondWithResponses.get(event) as Promise | undefined; +} + +export class ShopifyCheckoutReadyEvent extends ShopifyCheckoutRespondableEvent< + ShopifyCheckoutReadyEventDetail, + CheckoutReadyResponse +> { + declare type: "ec.ready"; + + constructor(detail: ShopifyCheckoutReadyEventDetail) { + super("ec.ready", { detail, bubbles: true }); + } +} + +export class ShopifyCheckoutAuthEvent extends ShopifyCheckoutRespondableEvent< + ShopifyCheckoutAuthEventDetail, + CheckoutCredentialResponse +> { + declare type: "ec.auth"; + + constructor(detail: ShopifyCheckoutAuthEventDetail) { + super("ec.auth", { detail, bubbles: true }); + } +} + export class ShopifyCheckoutStartEvent extends CustomEvent { declare type: "ec.start"; @@ -881,6 +1155,7 @@ export class ShopifyCheckoutMessagesChangeEvent extends CustomEvent & { id?: string }, + { method, params, id }: CheckoutProtocolMessageData, { source, origin }: { source: MessageEventSource | null; origin: string }, ) { this.protocol = { version: "2026-04-08" }; @@ -932,6 +1207,65 @@ function isEcErrorParams(params: unknown): params is CheckoutProtocolMessageMap[ return params != null && typeof params === "object" && "error" in params; } +function isObjectRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function postProtocolResult( + message: CheckoutProtocolMessage & { id: JsonRpcRequestId }, + result: unknown, +): void { + message.source?.postMessage( + { + jsonrpc: "2.0" as const, + id: message.id, + result, + }, + { targetOrigin: message.origin }, + ); +} + +function postProtocolError( + message: CheckoutProtocolMessage & { id: JsonRpcRequestId }, + code: number, + errorMessage: string, +): void { + message.source?.postMessage( + { + jsonrpc: "2.0" as const, + id: message.id, + error: { + code, + message: errorMessage, + }, + }, + { targetOrigin: message.origin }, + ); +} + +function ucpSuccess() { + return { + version: EMBED_PROTOCOL_VERSION, + status: "success" as const, + }; +} + +function normalizeReadyResponse(response: CheckoutReadyResponse | undefined): { + readonly credential?: string; +} { + if (typeof response === "string") { + return { credential: response }; + } + if (response == null) { + return {}; + } + return response.credential ? { credential: response.credential } : {}; +} + +function normalizeCredentialResponse(response: CheckoutCredentialResponse): string | undefined { + return typeof response === "string" ? response : response.credential; +} + function respondToUnsupportedProtocolRequest(event: MessageEvent) { const request = parseUnsupportedProtocolRequest(event.data); if (!request) return; @@ -946,7 +1280,7 @@ function respondToUnsupportedProtocolRequest(event: MessageEvent) { ); } -function parseUnsupportedProtocolRequest(data: unknown): { id: string | number } | undefined { +function parseUnsupportedProtocolRequest(data: unknown): { id: JsonRpcRequestId } | undefined { if ( data == null || typeof data !== "object" || @@ -964,7 +1298,8 @@ function parseUnsupportedProtocolRequest(data: unknown): { id: string | number } return { id: data.id }; } -function isJsonRpcRequestId(id: unknown): id is string | number { +function isJsonRpcRequestId(id: unknown): id is JsonRpcRequestId { + if (id === null) return true; if (typeof id === "string") return true; if (typeof id === "number" && Number.isFinite(id)) return true; return false; @@ -977,6 +1312,7 @@ function isCheckoutProtocolMessage(data: unknown): data is CheckoutProtocolMessa "jsonrpc" in data && data.jsonrpc === "2.0" && "method" in data && - CHECKOUT_PROTOCOL_MESSAGES.includes(data.method as keyof CheckoutProtocolMessageMap) + CHECKOUT_PROTOCOL_MESSAGES.includes(data.method as keyof CheckoutProtocolMessageMap) && + (!("id" in data) || isJsonRpcRequestId(data.id)) ); } diff --git a/platforms/web/src/checkout.types.ts b/platforms/web/src/checkout.types.ts index 39b42c763..c390dd63b 100644 --- a/platforms/web/src/checkout.types.ts +++ b/platforms/web/src/checkout.types.ts @@ -5,6 +5,8 @@ import type { Checkout, CheckoutLineItem, CheckoutMessage, + EcAuthParams, + EcAuthRequest, EcReadyParams, OrderConfirmation, Total, @@ -29,24 +31,29 @@ import type { // Documentation-safe types: +export type CheckoutPresentation = "auto" | "popup" | "iframe"; + export type CheckoutTarget = "auto" | "popup" | "_blank"; +export type JsonRpcRequestId = string | number | null; + export interface CheckoutAttributes { src?: string; + presentation?: CheckoutPresentation; + auth?: 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. + * Opens checkout using the configured `presentation`. */ open?: () => void; /** - * Closes the checkout popup. - * Can be used after checkout completion or to cancel the checkout process + * Closes the active checkout presentation. + * Can be used after checkout completion or to cancel the checkout process. */ close?: () => void; } @@ -62,10 +69,31 @@ export interface CheckoutProperties { 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 + * The presentation mode used when checkout is opened. Defaults to `'auto'`. + * - `'auto'`: Opens checkout in a new tab or named window based on `target` + * - `'popup'`: Opens checkout in a popup window sized and centered over the page + * + * This property is automatically reflected to the `presentation` attribute, + * so you can use the `presentation` attribute or this property interchangeably. + */ + presentation?: CheckoutPresentation; + + /** + * Checkout-bound authentication token. + * + * This property is automatically reflected to the `auth` attribute. When set, + * Checkout Kit sends it to checkout as the `ec_auth` query parameter on the + * generated checkout URL. + */ + auth?: string; + + /** + * The browser context target used when `presentation` is `'auto'`. Defaults to `'auto'`. + * - `'auto'`: Opens checkout in a new tab (default) + * - `'popup'`: Legacy alias for `presentation='popup'`; prefer the presentation property + * for new integrations + * - `'_blank'`: Opens checkout in a new tab/window + * - `string`: Passed to `window.open()` as the target * * For more details on window targets, see the [`Window.open()` `target` parameter](https://developer.mozilla.org/en-US/docs/Web/API/Window/open#target) * @@ -90,6 +118,18 @@ export interface CheckoutProperties { // exist so the generated API docs show what's on `event.detail` directly, // without dragging in the full `CustomEvent`/`Event` documentation. export interface CheckoutEvents { + /** + * Dispatched when checkout sends the ECP `ec.ready` handshake. Listeners can + * call `respondWith()` synchronously when checkout requests a credential. + */ + "ec.ready": CheckoutReadyEvent; + + /** + * Dispatched when checkout sends an ECP `ec.auth` credential refresh request. + * Listeners should call `respondWith()` synchronously with a credential. + */ + "ec.auth": CheckoutAuthEvent; + /** * Dispatched when checkout has started. */ @@ -189,6 +229,48 @@ export interface CheckoutMessagesChangeEvent { }; } +export type CheckoutCredentialResponse = string | { readonly credential: string }; + +export type CheckoutReadyResponse = + | string + | { + readonly credential?: string; + }; + +export interface RespondableCheckoutEvent { + type: string; + detail: Detail; + respondWith(response: Promise | ResponsePayload): void; +} + +export interface CheckoutReadyEvent extends RespondableCheckoutEvent< + CheckoutReadyResponse, + CheckoutReadyEventDetail +> { + type: "ec.ready"; +} + +export interface CheckoutReadyEventDetail { + /** Delegation types requested by checkout in `ec.ready.params.delegate`. */ + delegate: readonly string[]; + /** Authorization requested by checkout in `ec.ready.params.auth`, when present. */ + auth?: EcAuthRequest; +} + +export interface CheckoutAuthEvent extends RespondableCheckoutEvent< + CheckoutCredentialResponse, + CheckoutAuthEventDetail +> { + type: "ec.auth"; +} + +export interface CheckoutAuthEventDetail { + /** Authorization type requested by checkout, e.g. `oauth`, `api_key`, or `jwt`. */ + type?: string; + /** Raw `ec.auth` params for integrations that need additional fields. */ + auth: EcAuthParams; +} + export type TypedEventListener = | ((event: Event) => void) | { @@ -209,6 +291,7 @@ export interface CheckoutProtocolMessageData< T extends keyof CheckoutProtocolMessageMap = keyof CheckoutProtocolMessageMap, > { jsonrpc: "2.0"; + id?: JsonRpcRequestId; method: T; params?: CheckoutProtocolMessageMap[T]; } @@ -232,6 +315,7 @@ export interface EcErrorParams { */ export interface CheckoutProtocolMessageMap { "ec.ready": EcReadyParams; + "ec.auth": EcAuthParams; "ec.start": CheckoutPayload; "ec.complete": CheckoutPayload; "ec.error": EcErrorParams; @@ -246,6 +330,8 @@ export type { Checkout, CheckoutLineItem, CheckoutMessage, + EcAuthParams, + EcAuthRequest, EcReadyParams, OrderConfirmation, Total, diff --git a/platforms/web/src/ucp-embed-types.ts b/platforms/web/src/ucp-embed-types.ts index 65d93ae65..e6e904bed 100644 --- a/platforms/web/src/ucp-embed-types.ts +++ b/platforms/web/src/ucp-embed-types.ts @@ -150,12 +150,20 @@ export interface FulfillmentAvailableMethod { readonly [key: string]: unknown; } +export interface EcAuthRequest { + readonly type?: string; + readonly [key: string]: unknown; +} + /** `ec.ready` request params (handshake). */ export interface EcReadyParams { readonly delegate: readonly string[]; + readonly auth?: EcAuthRequest; readonly [key: string]: unknown; } +export interface EcAuthParams extends EcAuthRequest {} + /** Populated on completed checkout (`checkout.order`). */ export interface OrderConfirmation { readonly id: string;