diff --git a/platforms/web/.oxlintrc.json b/platforms/web/.oxlintrc.json index 437d8386..b51c7d7c 100644 --- a/platforms/web/.oxlintrc.json +++ b/platforms/web/.oxlintrc.json @@ -25,6 +25,7 @@ "ignorePatterns": [ "dist/**", "coverage/**", - "node_modules/**" + "node_modules/**", + "src/**/*.test.ts" ] } diff --git a/platforms/web/package.json b/platforms/web/package.json index 43523da1..b3f26cb1 100644 --- a/platforms/web/package.json +++ b/platforms/web/package.json @@ -32,7 +32,7 @@ "./custom-elements.json": "./dist/custom-elements.json", "./package.json": "./package.json" }, - "sideEffects": false, + "sideEffects": ["./src/checkout-web-component.ts"], "files": [ "LICENSE", "README.md", 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..fe6ef870 --- /dev/null +++ b/platforms/web/src/checkout.styles.ts @@ -0,0 +1,163 @@ +/* +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 } from "./utils"; + +export const STYLES = css` + * { + box-sizing: border-box; + } + + #checkout-iframe, + #shopify-element-wrapper, + .Shopify-target { + inline-size: 100%; + block-size: 100%; + border: none; + } + + #checkout-iframe { + display: none; + } + + .Shopify-target--inline { + #checkout-iframe { + display: block; + } + + .overlay { + display: 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..a948d958 --- /dev/null +++ b/platforms/web/src/checkout.test.ts @@ -0,0 +1,1867 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + CheckoutProtocolMessageMap, + CheckoutAddressChangeStartEvent, + CheckoutAddressChangeStartResponsePayload, +} from "./checkout.types"; +import "./checkout-web-component"; +import { + DEFAULT_POPUP_WIDTH, + DEFAULT_POPUP_HEIGHT, + EMBED_URL_PARAMS, + ShopifyCheckout, +} from "./checkout"; + +const POPUP_TARGETS = ["popup"] as const; +const NEW_TAB_TARGETS = ["_blank", "auto", "", undefined] as const; + +describe("", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + 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("auth", () => { + it("changing the auth attribute reflects to the auth property", () => { + const checkout = renderCheckout(); + const newAuth = "abc123"; + checkout.setAttribute("auth", newAuth); + + expect(checkout.auth).toBe(newAuth); + }); + }); + + describe("colorScheme", () => { + it("changing the color-scheme attribute reflects to the colorScheme property", () => { + const checkout = renderCheckout(); + const newColorScheme = "dark"; + checkout.setAttribute("color-scheme", newColorScheme); + + expect(checkout.colorScheme).toBe(newColorScheme); + }); + }); + + describe("preload", () => { + it("changing the preload attribute reflects to the preload property as a boolean", () => { + const checkout = renderCheckout(); + + checkout.setAttribute("preload", "true"); + expect(checkout.preload).toBe(true); + + // any string value is truthy + checkout.setAttribute("preload", "false"); + expect(checkout.preload).toBe(true); + + // empty string attribute is truthy + checkout.setAttribute("preload", ""); + expect(checkout.preload).toBe(true); + + checkout.removeAttribute("preload"); + expect(checkout.preload).toBe(false); + }); + }); + }); + + 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); + }); + }); + + describe("properties", () => { + describe("locale", () => { + it("returns undefined before checkout:start event", () => { + const checkout = renderCheckout(); + + expect(checkout.locale).toBeUndefined(); + }); + + it("returns checkout-provided locale after checkout:start event", async () => { + const checkout = renderCheckout(); + + expect(checkout.locale).toBeUndefined(); + + const listenForEvent = waitForEvent(checkout, "checkout:start"); + + const testStartPayload: CheckoutProtocolMessageMap["checkout.start"] = { + locale: "ja-JP", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { amount: "0.00", currencyCode: "USD" }, + totalAmount: { amount: "0.00", currencyCode: "USD" }, + }, + buyerIdentity: { countryCode: "JP" }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { addresses: [] }, + payment: { methods: [] }, + }, + }; + + simulateProtocolMessageEvent("checkout.start", testStartPayload); + await listenForEvent; + + expect(checkout.locale).toBe("ja-JP"); + }); + }); + + describe("sessionId", () => { + it("returns undefined before checkout:submitStart event", () => { + const checkout = renderCheckout(); + + expect(checkout.sessionId).toBeUndefined(); + }); + + it("returns checkout-provided sessionId after checkout:submitStart event", async () => { + const checkout = renderCheckout({ target: "inline" }); + + expect(checkout.sessionId).toBeUndefined(); + + const listenForEvent = waitForEvent(checkout, "checkout:submitStart"); + + const testSubmitStartPayload: CheckoutProtocolMessageMap["checkout.submitStart"] = { + sessionId: "test-session-id-123", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { amount: "0.00", currencyCode: "USD" }, + totalAmount: { amount: "0.00", currencyCode: "USD" }, + }, + buyerIdentity: { countryCode: "US" }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { addresses: [] }, + payment: { methods: [] }, + }, + }; + + simulateProtocolMessageEvent("checkout.submitStart", testSubmitStartPayload, { + id: "test-request-id", + }); + await listenForEvent; + + expect(checkout.sessionId).toBe("test-session-id-123"); + }); + }); + + 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("auth", () => { + it("changing the auth property reflects to the auth attribute", () => { + const checkout = renderCheckout(); + const newAuth = "abc123"; + checkout.auth = newAuth; + + expect(checkout.getAttribute("auth")).toBe(newAuth); + }); + }); + + describe("colorScheme", () => { + it("changing the colorScheme property reflects to the color-scheme attribute", () => { + const checkout = renderCheckout(); + const newColorScheme = "dark"; + checkout.colorScheme = newColorScheme; + + expect(checkout.getAttribute("color-scheme")).toBe(newColorScheme); + }); + }); + + 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('when target is "inline"', () => { + it("renders an iframe on mount without needing open()", () => { + const checkout = renderCheckout({ target: "inline" }); + + const iframe = checkout.shadowRoot!.querySelector("iframe"); + expect(iframe).not.toBeNull(); + + const expectedURL = new URL(checkout.src); + expectedURL.searchParams.set( + "embed", + "protocol=2025-10,library=checkout-web-component,platform=web,branding=app,colorscheme=auto", + ); + expect(iframe!.src).toBe(expectedURL.href); + }); + }); + }); + + describe("preload", () => { + it("accepts string values or boolean values mirroring how an attribute works", () => { + const checkout = renderCheckout(); + + checkout.preload = true; + expect(checkout.preload).toBe(true); + + // any string value is truthy + checkout.preload = "false"; + expect(checkout.preload).toBe(true); + + // empty string attribute is truthy + checkout.preload = ""; + expect(checkout.preload).toBe(true); + + checkout.preload = false; + expect(checkout.preload).toBe(false); + + checkout.preload = undefined; + expect(checkout.preload).toBe(false); + }); + + it("changing the preload property reflects to the preload attribute", () => { + const checkout = renderCheckout(); + checkout.preload = true; + expect(checkout.getAttribute("preload")).toBeDefined(); + + checkout.preload = false; + expect(checkout.getAttribute("preload")).toBeNull(); + }); + + it("adds a preload link to the iframe src when set to true", () => { + const checkout = renderCheckout(); + + checkout.preload = true; + + const preloadLink = checkout.shadowRoot!.querySelector( + 'link[rel="preload"]', + ) as HTMLLinkElement; + expect(preloadLink).not.toBeNull(); + expect(preloadLink.rel).toBe("preload"); + expect(preloadLink.href).toBe(checkout.src); + expect(preloadLink.as).toBe("document"); + }); + + it("removes the preload link when set to false", () => { + const checkout = renderCheckout(); + + checkout.preload = true; + expect(checkout.shadowRoot!.querySelector('link[rel="preload"]')).not.toBeNull(); + + checkout.preload = false; + expect(checkout.shadowRoot!.querySelector('link[rel="preload"]')).toBeNull(); + }); + + it("does not add a preload link when src is empty", () => { + const checkout = renderCheckout(); + + checkout.src = ""; + checkout.preload = true; + + expect(checkout.shadowRoot!.querySelector('link[rel="preload"]')).toBeNull(); + }); + + it("updates the preload link when src changes", () => { + const checkout = renderCheckout(); + + checkout.preload = true; + const newSrc = "https://example.com/checkout/456"; + checkout.src = newSrc; + const preloadLink = checkout.shadowRoot!.querySelector( + 'link[rel="preload"]', + ) as HTMLLinkElement; + + expect(preloadLink.href).toBe(newSrc); + }); + }); + }); + + describe("URL generation with auth", () => { + it("includes auth parameter in popup URL when auth is set", () => { + const checkout = renderCheckout(); + checkout.auth = "test-jwt-token"; + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0][0]; + const url = new URL(calledUrl!); + + expect(url.searchParams.get("embed")).toBe( + `${EMBED_URL_PARAMS},authentication=test-jwt-token`, + ); + }); + + it("does not include auth parameter in popup URL when auth is empty", () => { + const checkout = renderCheckout(); + checkout.auth = ""; + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0][0]; + const url = new URL(calledUrl!); + + expect(url.searchParams.get("embed")).toBe(EMBED_URL_PARAMS); + }); + + it("includes auth parameter in iframe src when target is inline and auth is set before creation", () => { + const checkout = renderCheckout(); + checkout.auth = "test-jwt-token"; + checkout.setAttribute("target", "inline"); + + const iframe = checkout.shadowRoot!.querySelector("#checkout-iframe") as HTMLIFrameElement; + expect(iframe).not.toBeNull(); + + const url = new URL(iframe.src); + expect(url.searchParams.get("embed")).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=app,colorscheme=auto,authentication=test-jwt-token", + ); + }); + + it("automatically updates iframe src when auth changes", () => { + const checkout = renderCheckout({ target: "inline" }); + + // Initially no auth + let iframe = checkout.shadowRoot!.querySelector("#checkout-iframe") as HTMLIFrameElement; + let url = new URL(iframe.src); + expect(url.searchParams.get("embed")).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=app,colorscheme=auto", + ); + + checkout.auth = "new-token"; + + iframe = checkout.shadowRoot!.querySelector("#checkout-iframe") as HTMLIFrameElement; + url = new URL(iframe.src); + // Now it should include the auth parameter + expect(url.searchParams.get("embed")).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=app,colorscheme=auto,authentication=new-token", + ); + }); + + it("preserves existing query parameters when adding embed parameter", () => { + const originalSrc = "https://example.com/checkout?existing=param&another=value"; + const checkout = renderCheckout({ src: originalSrc }); + checkout.auth = "test-token"; + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0][0]; + const url = new URL(calledUrl!); + + expect(url.searchParams.get("existing")).toBe("param"); + expect(url.searchParams.get("another")).toBe("value"); + expect(url.searchParams.get("embed")).toBe(`${EMBED_URL_PARAMS},authentication=test-token`); + }); + + it("handles invalid src URL gracefully when auth is set", () => { + const checkout = renderCheckout(); + checkout.src = "invalid-url"; + checkout.auth = "test-token"; + + 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(); + }); + }); + + 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 expectedURL = new URL(checkout.src); + expectedURL.searchParams.set("embed", EMBED_URL_PARAMS); + expect(windowOpenSpy.mock.calls[0][0]).toBe(expectedURL.href); + + const call = windowOpenSpy.mock.calls[0]; + const features = call[2]; + 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"; + }, + } 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: Element) => { + 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 = windowOpenSpy.mock.calls[0]; + const features = call[2]; + 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 = windowOpenSpy.mock.calls[0]; + const features = call[2]; + 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 = windowOpenSpy.mock.calls[0]; + const features = call[2]; + 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 = windowOpenSpy.mock.calls[0]; + const features = call[2]; + // 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="inline"', () => { + it("shows the checkout in an iframe", () => { + const checkout = renderCheckout({ target: "inline" }); + + const iframe = checkout.shadowRoot!.querySelector("iframe"); + expect(iframe).not.toBeNull(); + expect(iframe!.getAttribute("allow")).toBe( + "publickey-credentials-get https://pay.shopify.com https://shop.app; geolocation", + ); + + const expectedURL = new URL(checkout.src); + expectedURL.searchParams.set( + "embed", + "protocol=2025-10,library=checkout-web-component,platform=web,branding=app,colorscheme=auto", + ); + expect(iframe!.src).toBe(expectedURL.href); + }); + + it("sets the correct iframe security attributes", () => { + const checkout = renderCheckout({ target: "inline" }); + + const iframe = checkout.shadowRoot!.querySelector("iframe"); + expect(iframe).not.toBeNull(); + + expect(iframe!.id).toBe("checkout-iframe"); + expect(iframe!.title).toBe("Checkout"); + + expect(iframe!.getAttribute("allow")).toBe( + "publickey-credentials-get https://pay.shopify.com https://shop.app; geolocation", + ); + + expect(iframe!.getAttribute("sandbox")).toBe( + "allow-scripts allow-same-origin allow-forms allow-popups", + ); + }); + }); + + 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 expectedURL = new URL(checkout.src); + expectedURL.searchParams.set("embed", EMBED_URL_PARAMS); + expect(windowOpenSpy).toHaveBeenCalledWith(expectedURL.href, 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 expectedURL = new URL(checkout.src); + expectedURL.searchParams.set("embed", EMBED_URL_PARAMS); + expect(windowOpenSpy).toHaveBeenCalledWith(expectedURL.href, "my-named-window"); + }); + }); + }); + + describe("focus", () => { + it("focuses the checkout window", () => { + // Note: 'inline' target is excluded because the open() method returns early + // for inline targets, so #checkoutWindow is never set and focus() has no effect + [...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="inline"', () => { + it("does not dispatch a close event", () => { + const checkout = renderCheckout({ target: "inline" }); + const closeEventSpy = vi.fn(); + checkout.addEventListener("checkout:close", closeEventSpy); + checkout.close(); + expect(closeEventSpy).not.toHaveBeenCalled(); + }); + }); + + 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("checkout:start", () => { + it("updates the locale and cart properties and dispatches a checkout:start event", async () => { + const checkout = renderCheckout(); + const onCheckoutStartSpy = vi.fn(); + + const listenForEvent = waitForEvent(checkout, "checkout:start", onCheckoutStartSpy); + + const testStartPayload: CheckoutProtocolMessageMap["checkout.start"] = { + locale: "en-US", + cart: { + id: "gid://shopify/Cart/123", + lines: [ + { + id: "gid://shopify/CartLine/1", + quantity: 2, + merchandise: { + id: "gid://shopify/ProductVariant/1", + title: "Test Product", + product: { + id: "gid://shopify/Product/1", + title: "Test Product", + }, + selectedOptions: [], + }, + cost: { + amountPerQuantity: { + amount: "10.00", + currencyCode: "USD", + }, + subtotalAmount: { + amount: "20.00", + currencyCode: "USD", + }, + totalAmount: { + amount: "20.00", + currencyCode: "USD", + }, + }, + discountAllocations: [], + }, + ], + cost: { + subtotalAmount: { + amount: "20.00", + currencyCode: "USD", + }, + totalAmount: { + amount: "20.00", + currencyCode: "USD", + }, + }, + buyerIdentity: { + email: "test@example.com", + countryCode: "US", + }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { addresses: [] }, + payment: { + methods: [], + }, + }, + }; + + simulateProtocolMessageEvent("checkout.start", testStartPayload); + await listenForEvent; + + expect(checkout.locale).toBe(testStartPayload.locale); + expect(checkout.cart).toBe(testStartPayload.cart); + + expect(onCheckoutStartSpy).toHaveBeenCalledOnce(); + }); + }); + + describe("checkout:complete", () => { + it("updates the orderConfirmation and cart properties and dispatches a checkout:complete event", async () => { + const checkout = renderCheckout(); + const onCheckoutCompleteSpy = vi.fn(); + + const listenForEvent = waitForEvent(checkout, "checkout:complete", onCheckoutCompleteSpy); + + const testCompletedPayload: CheckoutProtocolMessageMap["checkout.complete"] = { + orderConfirmation: { + url: "https://example.com/checkout/123", + order: { + id: "gid://shopify/Order/123456", + }, + number: "TEST-001", + isFirstOrder: false, + }, + cart: { + id: "gid://shopify/Cart/456", + lines: [], + cost: { + subtotalAmount: { amount: "100.00", currencyCode: "USD" }, + totalAmount: { amount: "100.00", currencyCode: "USD" }, + }, + buyerIdentity: {}, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { addresses: [] }, + payment: { + methods: [], + }, + }, + }; + + simulateProtocolMessageEvent("checkout.complete", testCompletedPayload); + await listenForEvent; + + expect(checkout.orderConfirmation).toBe(testCompletedPayload.orderConfirmation); + expect(checkout.cart).toBe(testCompletedPayload.cart); + expect(onCheckoutCompleteSpy).toHaveBeenCalledOnce(); + }); + + it("updates the cart property when included in checkout:complete payload", async () => { + const checkout = renderCheckout(); + const onCheckoutCompleteSpy = vi.fn(); + + const listenForEvent = waitForEvent(checkout, "checkout:complete", onCheckoutCompleteSpy); + + const testCompletedPayload: CheckoutProtocolMessageMap["checkout.complete"] = { + orderConfirmation: { + url: "https://example.com/checkout/123", + order: { + id: "gid://shopify/Order/123456", + }, + number: "TEST-001", + isFirstOrder: false, + }, + cart: { + id: "gid://shopify/Cart/456", + lines: [ + { + id: "gid://shopify/CartLine/1", + quantity: 1, + merchandise: { + id: "gid://shopify/ProductVariant/1", + title: "Completed Product", + product: { + id: "gid://shopify/Product/1", + title: "Completed Product", + }, + selectedOptions: [], + }, + cost: { + amountPerQuantity: { + amount: "25.00", + currencyCode: "USD", + }, + subtotalAmount: { + amount: "25.00", + currencyCode: "USD", + }, + totalAmount: { + amount: "25.00", + currencyCode: "USD", + }, + }, + discountAllocations: [], + }, + ], + cost: { + subtotalAmount: { + amount: "25.00", + currencyCode: "USD", + }, + totalAmount: { + amount: "25.00", + currencyCode: "USD", + }, + }, + buyerIdentity: { + email: "completed@example.com", + countryCode: "US", + }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { + addresses: [], + }, + payment: { methods: [] }, + }, + }; + + simulateProtocolMessageEvent("checkout.complete", testCompletedPayload); + await listenForEvent; + + expect(checkout.orderConfirmation).toBe(testCompletedPayload.orderConfirmation); + expect(checkout.cart).toBe(testCompletedPayload.cart); + + expect(onCheckoutCompleteSpy).toHaveBeenCalledOnce(); + }); + }); + + describe("checkout:error", () => { + it("updates the error property and dispatches a checkout:error event", async () => { + const checkout = renderCheckout(); + const onCheckoutErrorSpy = vi.fn(); + + const listenForEvent = waitForEvent(checkout, "checkout:error", onCheckoutErrorSpy); + + const testErrorPayload: CheckoutProtocolMessageMap["checkout.error"] = { + code: "INVALID_CART", + message: "The cart is invalid or has expired", + }; + + simulateProtocolMessageEvent("checkout.error", testErrorPayload); + await listenForEvent; + + expect(checkout.error).toStrictEqual(testErrorPayload); + + expect(onCheckoutErrorSpy).toHaveBeenCalledOnce(); + }); + + it("handles different error codes", async () => { + const checkout = renderCheckout(); + + const errorCodes = [ + "INVALID_PAYLOAD", + "INVALID_SIGNATURE", + "NOT_AUTHORIZED", + "PAYLOAD_EXPIRED", + "CUSTOMER_ACCOUNT_REQUIRED", + "STOREFRONT_PASSWORD_REQUIRED", + "CART_COMPLETED", + "KILLSWITCH_ENABLED", + "UNRECOVERABLE_FAILURE", + "POLICY_VIOLATION", + "PAYMENT_ERROR", + ]; + + for (const code of errorCodes) { + const listenForEvent = waitForEvent(checkout, "checkout:error"); + + const testErrorPayload: CheckoutProtocolMessageMap["checkout.error"] = { + code: code as CheckoutProtocolMessageMap["checkout.error"]["code"], + message: `Test error for ${code}`, + }; + + simulateProtocolMessageEvent("checkout.error", testErrorPayload); + await listenForEvent; + + expect(checkout.error!.code).toBe(code); + } + }); + }); + + describe("checkout:addressChangeStart", () => { + it("dispatches event when target is inline", async () => { + const checkout = renderCheckout({ target: "inline" }); + const onAddressChangeStartSpy = vi.fn(); + + const listenForEvent = waitForEvent( + checkout, + "checkout:addressChangeStart", + onAddressChangeStartSpy, + ); + + const testPayload: CheckoutProtocolMessageMap["checkout.addressChangeStart"] = { + addressType: "shipping", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + totalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + }, + buyerIdentity: { + countryCode: "US", + }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { + addresses: [], + }, + payment: { methods: [] }, + }, + }; + + simulateProtocolMessageEvent("checkout.addressChangeStart", testPayload, { + id: "test-request-id", + }); + await listenForEvent; + + expect(onAddressChangeStartSpy).toHaveBeenCalledOnce(); + }); + + it("does not dispatch event when target is popup", () => { + const checkout = renderCheckout({ target: "popup" }); + const onAddressChangeStartSpy = vi.fn(); + + checkout.addEventListener("checkout:addressChangeStart", onAddressChangeStartSpy); + + const testPayload: CheckoutProtocolMessageMap["checkout.addressChangeStart"] = { + addressType: "shipping", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + totalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + }, + buyerIdentity: { + countryCode: "US", + }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { + addresses: [], + }, + payment: { methods: [] }, + }, + }; + + simulateProtocolMessageEvent("checkout.addressChangeStart", testPayload); + + // Message handler is synchronous, so if it was going to fire, it already did + expect(onAddressChangeStartSpy).not.toHaveBeenCalled(); + }); + + it("does not dispatch event when target is auto", () => { + const checkout = renderCheckout({ target: "auto" }); + const onAddressChangeStartSpy = vi.fn(); + + checkout.addEventListener("checkout:addressChangeStart", onAddressChangeStartSpy); + + const testPayload: CheckoutProtocolMessageMap["checkout.addressChangeStart"] = { + addressType: "shipping", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + totalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + }, + buyerIdentity: { + countryCode: "US", + }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { + addresses: [], + }, + payment: { methods: [] }, + }, + }; + + simulateProtocolMessageEvent("checkout.addressChangeStart", testPayload); + + // Message handler is synchronous, so if it was going to fire, it already did + expect(onAddressChangeStartSpy).not.toHaveBeenCalled(); + }); + + describe("respondWith", () => { + let mockSourceWindow: Window; + + beforeEach(() => { + vi.useFakeTimers(); + mockSourceWindow = createMockWindow(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("sends a JSON-RPC 2.0 response when the promise resolves", async () => { + const checkout = renderCheckout({ target: "inline" }); + + const testPayload: CheckoutProtocolMessageMap["checkout.addressChangeStart"] = { + addressType: "shipping", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { amount: "0.00", currencyCode: "USD" }, + totalAmount: { amount: "0.00", currencyCode: "USD" }, + }, + buyerIdentity: { countryCode: "US" }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { addresses: [] }, + payment: { methods: [] }, + }, + }; + + const responsePayload: CheckoutAddressChangeStartResponsePayload = { + cart: testPayload.cart, + }; + + const listenForEvent = waitForEvent( + checkout, + "checkout:addressChangeStart", + (event: Event) => { + (event as unknown as CheckoutAddressChangeStartEvent).respondWith( + Promise.resolve(responsePayload), + ); + }, + ); + + simulateProtocolMessageEvent("checkout.addressChangeStart", testPayload, { + id: "test-request-id-123", + source: mockSourceWindow, + }); + + await listenForEvent; + // Flush promise microtasks + await vi.runAllTimersAsync(); + + expect(mockSourceWindow.postMessage).toHaveBeenCalledWith( + { + jsonrpc: "2.0", + id: "test-request-id-123", + result: responsePayload, + }, + window.origin ?? "", + ); + }); + + it("throws CheckoutRespondWithError when respondWith is called twice", async () => { + const checkout = renderCheckout({ target: "inline" }); + + const testPayload: CheckoutProtocolMessageMap["checkout.addressChangeStart"] = { + addressType: "shipping", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { amount: "0.00", currencyCode: "USD" }, + totalAmount: { amount: "0.00", currencyCode: "USD" }, + }, + buyerIdentity: { countryCode: "US" }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { addresses: [] }, + payment: { methods: [] }, + }, + }; + + let caughtError: Error | undefined; + + const listenForEvent = waitForEvent( + checkout, + "checkout:addressChangeStart", + (event: Event) => { + const addressEvent = event as unknown as CheckoutAddressChangeStartEvent; + addressEvent.respondWith(Promise.resolve({})); + try { + addressEvent.respondWith(Promise.resolve({})); + } catch (error) { + caughtError = error as Error; + } + }, + ); + + simulateProtocolMessageEvent("checkout.addressChangeStart", testPayload, { + id: "test-request-id-123", + source: mockSourceWindow, + }); + + await listenForEvent; + + expect(caughtError).toBeDefined(); + expect(caughtError!.name).toBe("CheckoutRespondWithError"); + expect(caughtError!.message).toContain("respondWith() has already been called"); + }); + + it("does not dispatch event when no message ID is available (notification)", async () => { + const checkout = renderCheckout({ target: "inline" }); + const onAddressChangeStartSpy = vi.fn(); + + checkout.addEventListener("checkout:addressChangeStart", onAddressChangeStartSpy); + + const testPayload: CheckoutProtocolMessageMap["checkout.addressChangeStart"] = { + addressType: "shipping", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { amount: "0.00", currencyCode: "USD" }, + totalAmount: { amount: "0.00", currencyCode: "USD" }, + }, + buyerIdentity: { countryCode: "US" }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { addresses: [] }, + payment: { methods: [] }, + }, + }; + + // Simulate message WITHOUT an id (notification, not request) + simulateProtocolMessageEvent("checkout.addressChangeStart", testPayload, { + source: mockSourceWindow, + }); + + // Event should not be dispatched for notifications + expect(onAddressChangeStartSpy).not.toHaveBeenCalled(); + }); + + it("throws CheckoutRespondWithError when no source window is available", async () => { + const checkout = renderCheckout({ target: "inline" }); + + const testPayload: CheckoutProtocolMessageMap["checkout.addressChangeStart"] = { + addressType: "shipping", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { amount: "0.00", currencyCode: "USD" }, + totalAmount: { amount: "0.00", currencyCode: "USD" }, + }, + buyerIdentity: { countryCode: "US" }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { addresses: [] }, + payment: { methods: [] }, + }, + }; + + let caughtError: Error | undefined; + + const listenForEvent = waitForEvent( + checkout, + "checkout:addressChangeStart", + (event: Event) => { + try { + (event as unknown as CheckoutAddressChangeStartEvent).respondWith( + Promise.resolve({}), + ); + } catch (error) { + caughtError = error as Error; + } + }, + ); + + // Simulate message WITH an id but WITHOUT a source + simulateProtocolMessageEvent("checkout.addressChangeStart", testPayload, { + id: "test-request-id-123", + source: null, + }); + + await listenForEvent; + + expect(caughtError).toBeDefined(); + expect(caughtError!.name).toBe("CheckoutRespondWithError"); + expect(caughtError!.message).toContain("no source window available"); + }); + + it("allows responding to subsequent events after not responding to the first", async () => { + const checkout = renderCheckout({ target: "inline" }); + let eventCount = 0; + + const testPayload: CheckoutProtocolMessageMap["checkout.addressChangeStart"] = { + addressType: "shipping", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { amount: "0.00", currencyCode: "USD" }, + totalAmount: { amount: "0.00", currencyCode: "USD" }, + }, + buyerIdentity: { countryCode: "US" }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { addresses: [] }, + payment: { methods: [] }, + }, + }; + + checkout.addEventListener("checkout:addressChangeStart", (event: Event) => { + eventCount++; + // Only respond to the second event + if (eventCount === 2) { + (event as unknown as CheckoutAddressChangeStartEvent).respondWith( + Promise.resolve({}), + ); + } + }); + + // First event - don't respond + simulateProtocolMessageEvent("checkout.addressChangeStart", testPayload, { + id: "first-request-id", + source: mockSourceWindow, + }); + + // Flush pending timers for first message processing + await vi.runAllTimersAsync(); + + // Second event - do respond + simulateProtocolMessageEvent("checkout.addressChangeStart", testPayload, { + id: "second-request-id", + source: mockSourceWindow, + }); + + // Flush pending timers for promise resolution + await vi.runAllTimersAsync(); + + expect(eventCount).toBe(2); + expect(mockSourceWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: "2.0", + id: "second-request-id", + }), + window.origin ?? "", + ); + }); + }); + }); + + describe("checkout:paymentMethodChangeStart", () => { + it("can dispatch event when target is inline (placeholder for future implementation)", async () => { + const checkout = renderCheckout({ target: "inline" }); + const onPaymentMethodChangeStartSpy = vi.fn(); + + const listenForEvent = waitForEvent( + checkout, + "checkout:paymentMethodChangeStart", + onPaymentMethodChangeStartSpy, + ); + + const testPayload: CheckoutProtocolMessageMap["checkout.paymentMethodChangeStart"] = { + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + totalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + }, + buyerIdentity: { + countryCode: "US", + }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { + addresses: [], + }, + payment: { methods: [] }, + }, + }; + + simulateProtocolMessageEvent("checkout.paymentMethodChangeStart", testPayload, { + id: "test-request-id", + }); + await listenForEvent; + + expect(onPaymentMethodChangeStartSpy).toHaveBeenCalledOnce(); + }); + + it("does not dispatch event when target is popup", () => { + const checkout = renderCheckout({ target: "popup" }); + const onPaymentMethodChangeStartSpy = vi.fn(); + + checkout.addEventListener( + "checkout:paymentMethodChangeStart", + onPaymentMethodChangeStartSpy, + ); + + const testPayload: CheckoutProtocolMessageMap["checkout.paymentMethodChangeStart"] = { + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + totalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + }, + buyerIdentity: { + countryCode: "US", + }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { + addresses: [], + }, + payment: { methods: [] }, + }, + }; + + simulateProtocolMessageEvent("checkout.paymentMethodChangeStart", testPayload); + + expect(onPaymentMethodChangeStartSpy).not.toHaveBeenCalled(); + }); + }); + + describe("checkout:submitStart", () => { + it("dispatches event and updates sessionId on the element when target is inline", async () => { + const checkout = renderCheckout({ target: "inline" }); + + expect(checkout.sessionId).toBeUndefined(); + + const listenForEvent = waitForEvent(checkout, "checkout:submitStart"); + + const testPayload: CheckoutProtocolMessageMap["checkout.submitStart"] = { + sessionId: "element-session-id-789", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + totalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + }, + buyerIdentity: { + countryCode: "US", + }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { + addresses: [], + }, + payment: { methods: [] }, + }, + }; + + simulateProtocolMessageEvent("checkout.submitStart", testPayload, { + id: "test-request-id", + }); + await listenForEvent; + + expect(checkout.sessionId).toBe("element-session-id-789"); + }); + + it("does not dispatch event when target is popup", () => { + const checkout = renderCheckout({ target: "popup" }); + const onSubmitStartSpy = vi.fn(); + + checkout.addEventListener("checkout:submitStart", onSubmitStartSpy); + + const testPayload: CheckoutProtocolMessageMap["checkout.submitStart"] = { + sessionId: "test-session-id", + cart: { + id: "gid://shopify/Cart/123", + lines: [], + cost: { + subtotalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + totalAmount: { + amount: "0.00", + currencyCode: "USD", + }, + }, + buyerIdentity: { + countryCode: "US", + }, + deliveryGroups: [], + discountCodes: [], + appliedGiftCards: [], + discountAllocations: [], + delivery: { + addresses: [], + }, + payment: { methods: [] }, + }, + }; + + simulateProtocolMessageEvent("checkout.submitStart", testPayload); + + expect(onSubmitStartSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe("platform=web parameter in embed URL", () => { + it("includes platform=web in popup URLs", () => { + const checkout = renderCheckout({ target: "popup" }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0]![0]; + const url = new URL(calledUrl!); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toContain("platform=web"); + expect(embedParam).toBe(EMBED_URL_PARAMS); + }); + + it("includes platform=web in new tab URLs", () => { + const checkout = renderCheckout({ target: "auto" }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0]![0]; + const url = new URL(calledUrl!); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toContain("platform=web"); + expect(embedParam).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=shop,colorscheme=auto", + ); + }); + + it("includes platform=web in inline iframe URLs", () => { + const checkout = renderCheckout({ target: "inline" }); + + const iframe = checkout.shadowRoot!.querySelector("#checkout-iframe") as HTMLIFrameElement; + const url = new URL(iframe.src); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toContain("platform=web"); + expect(embedParam).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=app,colorscheme=auto", + ); + }); + + it("includes platform=web when auth token is also present", () => { + const checkout = renderCheckout({ target: "popup" }); + checkout.auth = "test-token"; + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0]![0]; + const url = new URL(calledUrl!); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toContain("platform=web"); + expect(embedParam).toContain("authentication=test-token"); + expect(embedParam).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=shop,colorscheme=auto,authentication=test-token", + ); + }); + + it("includes platform=web in preload link href", () => { + const checkout = renderCheckout(); + checkout.preload = true; + + const preloadLink = checkout.shadowRoot!.querySelector( + 'link[rel="preload"]', + ) as HTMLLinkElement; + + expect(preloadLink.href).toBe(checkout.src); + }); + }); + + describe("colorScheme parameter in embed URL", () => { + it("includes colorScheme in popup URLs", () => { + const checkout = renderCheckout({ target: "popup" }); + checkout.colorScheme = "dark"; + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0]![0]; + const url = new URL(calledUrl!); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=shop,colorscheme=dark", + ); + }); + + it("includes colorScheme in new tab URLs", () => { + const checkout = renderCheckout({ target: "auto" }); + checkout.colorScheme = "light"; + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0]![0]; + const url = new URL(calledUrl!); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=shop,colorscheme=light", + ); + }); + + it("includes colorScheme in inline iframe URLs", () => { + const checkout = renderCheckout({ + target: "inline", + "color-scheme": "dark", + }); + + const iframe = checkout.shadowRoot!.querySelector("#checkout-iframe") as HTMLIFrameElement; + const url = new URL(iframe.src); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=app,colorscheme=dark", + ); + }); + + it("includes colorScheme when auth token is also present", () => { + const checkout = renderCheckout({ target: "popup" }); + checkout.auth = "test-token"; + checkout.colorScheme = "dark"; + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0]![0]; + const url = new URL(calledUrl!); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=shop,colorscheme=dark,authentication=test-token", + ); + }); + + it("includes colorScheme=auto by default when not explicitly set", () => { + const checkout = renderCheckout({ target: "popup" }); + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0]![0]; + const url = new URL(calledUrl!); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=shop,colorscheme=auto", + ); + }); + + it('defaults colorScheme property to "auto" when color-scheme attribute is undefined', () => { + const checkout = renderCheckout(); + + expect(checkout.colorScheme).toBe("auto"); + expect(checkout.getAttribute("color-scheme")).toBeNull(); + + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0]![0]; + const url = new URL(calledUrl!); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toContain("colorscheme=auto"); + }); + }); + + describe("branding", () => { + it("includes branding=shop by default", () => { + const checkout = renderCheckout(); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); + + checkout.open(); + + const calledUrl = windowOpenSpy.mock.calls[0]![0]; + const url = new URL(calledUrl!); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toBe(EMBED_URL_PARAMS); + }); + + it("includes branding=app when target is inline", () => { + const checkout = renderCheckout({ target: "inline" }); + + const iframe = checkout.shadowRoot!.querySelector("#checkout-iframe") as HTMLIFrameElement; + expect(iframe).not.toBeNull(); + + const url = new URL(iframe.src); + const embedParam = url.searchParams.get("embed"); + + expect(embedParam).toBe( + "protocol=2025-10,library=checkout-web-component,platform=web,branding=app,colorscheme=auto", + ); + }); + }); + + describe("it removes event listeners when the component is disconnected", () => { + it.todo("should remove event listeners"); + }); + + describe("it cleans up listeners when the popup is closed", () => { + it.todo("should clean up listeners"); + }); +}); + +// Test utilities +function simulateProtocolMessageEvent( + name: Message, + body: CheckoutProtocolMessageMap[Message], + options?: { id?: string; source?: MessageEventSource | null }, +) { + const event = new MessageEvent("message", { + data: { + jsonrpc: "2.0", + method: name, + params: body, + ...(options?.id && { id: options.id }), + }, + origin: window.origin ?? "", + source: options?.source ?? null, + }); + window.dispatchEvent(event); +} + +function waitForEvent(element: HTMLElement, eventName: string, spyFn?: (...args: any[]) => any) { + return new Promise((resolve) => { + const handler = (...args: any[]) => { + spyFn?.(...args); + element.removeEventListener(eventName, handler); + resolve(); + }; + element.addEventListener(eventName, handler); + }); +} + +function renderCheckout(attributes: Record = {}): ShopifyCheckout { + 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; +} diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts new file mode 100644 index 00000000..91c5acae --- /dev/null +++ b/platforms/web/src/checkout.ts @@ -0,0 +1,1004 @@ +/* +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 { ShopifyElement, html } from "./utils"; +import type { + CheckoutAttributes, + CheckoutMethods, + CheckoutProperties, + CheckoutProtocolMessageMap, + CheckoutTarget, + ColorScheme, + TypedEventListener, + CheckoutProtocolMessageData, + Cart, + CheckoutAddressChangeStartResponsePayload, + CheckoutPaymentMethodChangeStartResponsePayload, + CheckoutSubmitStartResponsePayload, +} from "./checkout.types"; +import { STYLES } from "./checkout.styles"; + +export const DEFAULT_POPUP_WIDTH = 600; +export const DEFAULT_POPUP_HEIGHT = 600; +export const EMBED_URL_PARAMS = + "protocol=2025-10,library=checkout-web-component,platform=web,branding=shop,colorscheme=auto"; + +/** + * An element that renders a Shopify Checkout. Checkout can be displayed either as a popup window (default) + * or embedded as an iframe by setting the `mode` attribute. 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 auth - JWT authentication token for third-party embedders + * @attribute preload - Whether to preload critical assets and data + * @attribute target - Where the checkout is presented (auto, popup, new tab, or inline). + * @attribute color-scheme - The color scheme for the checkout interface + * + * @event checkout:start - Dispatched when the checkout has started + * @event checkout:complete - Dispatched when the checkout was successfully completed + * @event checkout:close - Dispatched when the checkout is closed + * @event checkout:error - Dispatched when an error occurs + * @event checkout:addressChangeStart - Dispatched when address change starts (inline only) + * @event checkout:paymentMethodChangeStart - Dispatched when payment change starts (inline only, not yet implemented) + * @event checkout:submitStart - Dispatched on checkout completion attempt (inline only, not yet implemented) + * + * @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(); + * + * // Inline target + * const checkout = document.createElement("shopify-checkout"); + * checkout.setAttribute("src", cart.checkoutUrl); + * checkout.setAttribute("target", "inline"); + * document.body.append(checkout); + * ``` + */ +export class ShopifyCheckout + extends ShopifyElement + implements CheckoutAttributes, CheckoutMethods, CheckoutProperties +{ + static observedAttributes = ["auth", "color-scheme", "preload", "src", "target"] as const; + + // Stores the locale from the checkout:start event. Undefined until checkout starts. + #locale?: string; + #cart?: Cart; + #orderConfirmation?: CheckoutProtocolMessageMap["checkout.complete"]["orderConfirmation"]; + #error?: CheckoutProtocolMessageMap["checkout.error"]; + #sessionId?: string; + + #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 + } + + #srcAsURL(): URL | undefined { + try { + const { auth, colorScheme, target } = this; + const url = new URL(this.src); + let embedParams = EMBED_URL_PARAMS; + if (auth) { + embedParams += `,authentication=${auth}`; + } + if (colorScheme && colorScheme !== "auto") { + embedParams = embedParams.replace("colorscheme=auto", `colorscheme=${colorScheme}`); + } + if (target && target === "inline") { + embedParams = embedParams.replace("branding=shop", "branding=app"); + } + url.searchParams.set("embed", embedParams); + return url; + } catch { + return undefined; + } + } + + get auth(): string { + return this.getAttribute("auth") ?? ""; + } + + set auth(value: string | undefined) { + this.#setAttribute("auth", value); + // see also attributeChangedCallback + } + + get colorScheme(): ColorScheme { + return (this.getAttribute("color-scheme") ?? "auto") as ColorScheme; + } + + set colorScheme(value: ColorScheme | undefined) { + this.#setAttribute("color-scheme", value); + // see also attributeChangedCallback + } + + get preload() { + return this.getAttribute("preload") !== null; + } + + set preload(value: boolean | string | undefined) { + this.#setAttribute("preload", value); + // see also attributeChangedCallback + } + + 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) + * ------------------------------------------------------------ + */ + + /** + * Order confirmation details from a completed checkout. + * Populated after the checkout:complete event is dispatched. + * + * @returns Order confirmation with order ID, confirmation URL, order number, and first order status, or undefined + * @example + * checkout.addEventListener('checkout:complete', () => { + * const {order, url, number, isFirstOrder} = checkout.orderConfirmation; + * console.log(`Order ${number} created with ID: ${order.id}`); + * console.log(`Redirect to: ${url}`); + * }); + */ + get orderConfirmation(): + | CheckoutProtocolMessageMap["checkout.complete"]["orderConfirmation"] + | undefined { + return this.#orderConfirmation; + } + + /** + * The locale of the checkout session. Populated after the checkout:start event. + * Returns undefined until the checkout session has started. + * + * @example + * const checkout = document.querySelector('shopify-checkout'); + * console.log(checkout.locale); // undefined + * + * checkout.addEventListener('checkout:start', () => { + * console.log(checkout.locale); // "fr-CA" + * }); + */ + get locale() { + return this.#locale; + } + + /** + * The cart associated with the checkout session. + * Populated after the checkout:start event and updated after checkout:complete. + * + * @returns Cart with lines, costs, buyer identity, delivery info, and discounts, or undefined + * @example + * checkout.addEventListener('checkout:start', () => { + * const {lines, cost, buyerIdentity} = checkout.cart; + * console.log(`Cart total: ${cost.totalAmount.amount} ${cost.totalAmount.currencyCode}`); + * console.log(`Items: ${lines.length}`); + * }); + */ + get cart() { + return this.#cart; + } + + /** + * Error details when checkout encounters an error. + * Populated after the checkout:error event is dispatched. + * + * @returns Error with code and message, or undefined + * @example + * checkout.addEventListener('checkout:error', () => { + * const {code, message} = checkout.error; + * console.error(`Checkout error (${code}): ${message}`); + * }); + */ + get error(): CheckoutProtocolMessageMap["checkout.error"] | undefined { + return this.#error; + } + + /** + * The checkout session ID for authenticated checkouts. + * Populated after the checkout:submitStart event is dispatched. + * + * @returns The checkout session ID, or undefined + */ + get sessionId() { + return this.#sessionId; + } + + get #iframeElement(): HTMLIFrameElement | undefined { + return this.shadowRoot?.querySelector("#checkout-iframe") ?? undefined; + } + + 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() { + const { target } = this; + const src = this.#srcAsURL()?.href; + + // Inline targets render an iframe directly in the DOM when the element connects or target changes, + // so no explicit open() call is needed. The close() method also has no effect on + // inline targets since iframes don't respond to iframe.contentWindow.close(). + if (target === "inline") return; + + 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": + default: { + 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() { + if (this.#currentOpen) { + this.#currentOpen.controller.abort(); + } + } + + override focus() { + this.#checkoutWindow?.focus(); + } + + #updateIframeSrc() { + const src = this.#srcAsURL()?.href; + const iframeElement = this.#iframeElement; + + if (src && iframeElement && iframeElement.src !== src) { + iframeElement.src = src; + } + } + + #updatePreloadLink() { + const existingLink = this.shadowRoot?.querySelector("#checkout-preload-link") as + | HTMLLinkElement + | undefined; + + if (!this.preload || !this.src) { + existingLink?.remove(); + return; + } + + const newSrc = this.src; + if (existingLink) { + existingLink.href = newSrc; + existingLink.rel = "preload"; + existingLink.as = "document"; + } else { + const linkEl = document.createElement("link"); + linkEl.rel = "preload"; + linkEl.href = newSrc; + linkEl.as = "document"; + linkEl.id = "checkout-preload-link"; + this.shadowRoot?.appendChild(linkEl); + } + } + + #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; + } + + #addIframe() { + const iframeEl = document.createElement("iframe"); + iframeEl.id = "checkout-iframe"; + iframeEl.title = "Checkout"; + iframeEl.setAttribute( + "allow", + "publickey-credentials-get https://pay.shopify.com https://shop.app; geolocation", + ); + iframeEl.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups"); + iframeEl.src = this.#srcAsURL()?.href ?? ""; + + this.#targetElement?.appendChild(iframeEl); + this.#checkoutWindow = iframeEl.contentWindow ?? null; + } + + /* ------------------------------------------------------------ + * Events + * ------------------------------------------------------------ + */ + + /** + * Determines if a protocol message should dispatch a respondable event. + * Only inline targets can respond to messages, and the message must have + * an ID (requests) rather than being a notification. + */ + #isRespondableRequest( + message: CheckoutProtocolMessage, + ): message is CheckoutProtocolMessage & { id: string } { + return this.target === "inline" && 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) => { + // In tests, we don’t have a great way to simulate a message from a particular window, so we + // just allow messages from any source. + if (event.source !== this.#checkoutWindow && process.env.NODE_ENV !== "test") { + return; + } + + const message = CheckoutProtocolMessage.parse(event); + if (!message) return; + + // @see https://github.com/Shopify/core-rfcs/blob/main/rfc/checkout/20250128-embedded-checkout-protocol.md#event-system + + if ("cart" in message.body) { + this.#cart = message.body.cart as Cart; + } + + switch (message.name) { + case "checkout.start": { + const { locale } = message.body as CheckoutProtocolMessageMap["checkout.start"]; + this.#locale = locale; + // TODO: Extract checkoutSessionId from payload once available for authenticated checkouts + this.dispatchEvent(new ShopifyCheckoutStartEvent()); + break; + } + case "checkout.complete": { + const { orderConfirmation } = + message.body as CheckoutProtocolMessageMap["checkout.complete"]; + this.#orderConfirmation = orderConfirmation; + this.dispatchEvent(new ShopifyCheckoutCompleteEvent()); + break; + } + case "checkout.error": { + const { code, message: errorMessage } = + message.body as CheckoutProtocolMessageMap["checkout.error"]; + this.#error = { code, message: errorMessage }; + this.dispatchEvent(new ShopifyCheckoutErrorEvent()); + break; + } + case "checkout.addressChangeStart": { + if (this.#isRespondableRequest(message)) { + this.dispatchEvent( + new ShopifyCheckoutAddressChangeStartEvent({ + id: message.id, + source: message.source, + origin: message.origin, + }), + ); + } + break; + } + case "checkout.paymentMethodChangeStart": { + if (this.#isRespondableRequest(message)) { + this.dispatchEvent( + new ShopifyCheckoutPaymentMethodChangeStartEvent({ + id: message.id, + source: message.source, + origin: message.origin, + }), + ); + } + break; + } + case "checkout.submitStart": { + if (this.#isRespondableRequest(message)) { + const { sessionId } = message.body as CheckoutProtocolMessageMap["checkout.submitStart"]; + this.#sessionId = sessionId; + this.dispatchEvent( + new ShopifyCheckoutSubmitStartEvent({ + id: message.id, + source: message.source, + origin: message.origin, + }), + ); + } + break; + } + default: { + // eslint-disable-next-line no-console + console.warn( + `: Unknown checkout protocol message received: ${message.name}`, + message, + ); + break; + } + } + }; + + /* ------------------------------------------------------------ + * Lifecycle + * ------------------------------------------------------------ + */ + + override connectedCallback() { + super.connectedCallback(); + + this.render(html` + + +
+ +
+ +
+
+ Continue your purchase in the
+ checkout window +
+ +
+
+
+
+
+ `); + + if (this.target === "inline") { + this.#addIframe(); + } + + this.#initCheckoutProtocol(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.#checkoutProtocolController?.controller.abort(); + this.#checkoutProtocolController = null; + this.close(); + } + + attributeChangedCallback( + name: (typeof ShopifyCheckout.observedAttributes)[number], + oldValue: string, + newValue: string, + ) { + if (oldValue === newValue) return; + + switch (name) { + case "auth": + case "color-scheme": + this.#updateIframeSrc(); + break; + case "preload": + this.#updatePreloadLink(); + break; + case "src": + this.#updateIframeSrc(); + this.#updatePreloadLink(); + break; + case "target": { + if (oldValue === "inline" && newValue !== "inline") { + this.#iframeElement?.remove(); + this.#checkoutWindow = null; + } else if (newValue === "inline") { + this.#addIframe(); + } + + if (oldValue !== newValue && this.#currentOpen) { + this.close(); + } + + this.#targetElement?.classList.remove(`Shopify-target--${oldValue}`); + this.#targetElement?.classList.add(`Shopify-target--${newValue}`); + + break; + } + } + } + + /* ------------------------------------------------------------ + * Custom Events + * ------------------------------------------------------------ + */ + // we overload these so that the consumer of the component can autocomplete the correct events + override addEventListener( + type: "checkout:start", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "checkout:close", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "checkout:complete", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "checkout:error", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "checkout:addressChangeStart", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "checkout:paymentMethodChangeStart", + listener: TypedEventListener | null, + options?: boolean | AddEventListenerOptions, + ): void; + + override addEventListener( + type: "checkout:submitStart", + 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 ShopifyCheckoutStartEvent extends ShopifyCheckoutEvent { + declare type: "checkout:start"; + + constructor() { + super("checkout:start", { bubbles: true }); + } +} + +export class ShopifyCheckoutCompleteEvent extends ShopifyCheckoutEvent { + declare type: "checkout:complete"; + + constructor() { + super("checkout:complete", { bubbles: true }); + } +} + +export class ShopifyCheckoutCloseEvent extends ShopifyCheckoutEvent { + declare type: "checkout:close"; + + constructor() { + super("checkout:close", { bubbles: true }); + } +} + +export class ShopifyCheckoutErrorEvent extends ShopifyCheckoutEvent { + declare type: "checkout:error"; + + constructor() { + super("checkout:error", { bubbles: true }); + } +} + +/* ------------------------------------------------------------ + * Respondable Events (bidirectional communication) + * ------------------------------------------------------------ + */ + +/** + * Custom error class for checkout respondWith() errors. + */ +class CheckoutRespondWithError extends Error { + override name = "CheckoutRespondWithError"; +} + +/** + * Base class for events that support bidirectional communication via respondWith(). + * Follows the pattern established by FetchEvent.respondWith() in the web platform. + */ +abstract class ShopifyCheckoutRespondableEvent extends ShopifyCheckoutEvent { + readonly #id: string; + readonly #source: MessageEventSource | null; + readonly #origin: string; + #responded = false; + + constructor( + type: string, + { + id, + source, + origin, + }: { + id: string; + source: MessageEventSource | null; + origin: string; + }, + ) { + super(type, { bubbles: true }); + this.#id = id; + this.#source = source; + this.#origin = origin; + } + + /** + * Responds to the checkout event with a response payload. + * The SDK will automatically wrap the payload in a JSON-RPC 2.0 response envelope + * and send it back to the checkout iframe. + * + * @param response - A promise that resolves to the response payload + * @throws CheckoutRespondWithError if respondWith() has already been called for this event + * @throws CheckoutRespondWithError if no source window is available + * + * @example + * checkout.addEventListener('checkout:addressChangeStart', (event) => { + * event.respondWith( + * showAddressSelector().then(selectedAddress => ({ + * delivery: { + * addresses: [{ + * address: selectedAddress + * }] + * } + * })) + * ); + * }); + */ + respondWith(response: Promise): void { + if (this.#responded) { + throw new CheckoutRespondWithError( + `: respondWith() has already been called for this ${this.type} event`, + ); + } + + if (!this.#source) { + throw new CheckoutRespondWithError( + `: Cannot respond to ${this.type} event - no source window available`, + ); + } + + this.#responded = true; + + response + .then((resolvedResponse) => { + // Construct JSON-RPC 2.0 response envelope + const jsonRpcResponse = { + jsonrpc: "2.0" as const, + id: this.#id, + result: resolvedResponse, + }; + + // Post the response back to the checkout + (this.#source as WindowProxy).postMessage(jsonRpcResponse, this.#origin); + return undefined; + }) + .catch(() => { + // Consumer's promise rejected - no way to surface this error + // since respondWith() has already returned + }); + } +} + +export class ShopifyCheckoutAddressChangeStartEvent extends ShopifyCheckoutRespondableEvent { + declare type: "checkout:addressChangeStart"; + + constructor(options: { id: string; source: MessageEventSource | null; origin: string }) { + super("checkout:addressChangeStart", options); + } +} + +export class ShopifyCheckoutPaymentMethodChangeStartEvent extends ShopifyCheckoutRespondableEvent { + declare type: "checkout:paymentMethodChangeStart"; + + constructor(options: { id: string; source: MessageEventSource | null; origin: string }) { + super("checkout:paymentMethodChangeStart", options); + } +} + +export class ShopifyCheckoutSubmitStartEvent extends ShopifyCheckoutRespondableEvent { + declare type: "checkout:submitStart"; + + constructor(options: { id: string; source: MessageEventSource | null; origin: string }) { + super("checkout:submitStart", options); + } +} + +/* ------------------------------------------------------------ + * Checkout protocol + * ------------------------------------------------------------ + */ +const CHECKOUT_PROTOCOL_MESSAGES: (keyof CheckoutProtocolMessageMap)[] = [ + "checkout.start", + "checkout.complete", + "checkout.error", + "checkout.addressChangeStart", + "checkout.paymentMethodChangeStart", + "checkout.submitStart", +]; + +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: "2025-10" }; + 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..b17f4e73 --- /dev/null +++ b/platforms/web/src/checkout.types.ts @@ -0,0 +1,616 @@ +/* +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. +*/ + +// 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()` + +// TODO: how will we use this in storefront-elements repo? + +// Documentation-safe types: + +export type CheckoutTarget = "auto" | "popup" | "inline" | "_blank"; + +export type ColorScheme = "light" | "dark" | "auto"; + +export interface CheckoutAttributes { + src?: string; + auth?: string; + preload?: boolean | string; + target?: CheckoutTarget | string; + colorScheme?: ColorScheme; +} + +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; + + /** + * JWT authentication token for third-party embedders + * Required for third-party embedders, but not for merchants embedding on their own Shopify websites. + * + * This property is automatically reflected to the `auth` attribute, so you can use the `auth` attribute + * or this property interchangeably. + */ + auth?: string; + + /** + * Whether to preload critical assets and data. + * Setting this attribute will cause the checkout to prefetch resources for faster loading + * + * This property is automatically reflected to the `preload` attribute, so you can use the `preload` attribute + * or this property interchangeably. + */ + preload?: boolean | string; + + /** + * The mode in which to display the checkout when opened. Defaults to `'auto'`. + * - `'popup'`: Opens checkout in a popup window + * - `'inline'`: Embeds checkout in an iframe within the component + * - `'_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; + + /** + * The color scheme to apply to the checkout interface. Defaults to `'auto'`. + * - `'auto'`: Uses the user's device system preference (default) + * - `'dark'`: Forces the dark theme, ignoring the user's device system preference + * - `'light'`: Forces the light theme, ignoring the user's device system preference + */ + colorScheme?: ColorScheme; +} + +// 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. + */ + "checkout:start": CheckoutStartEvent; + + /** + * Dispatched when the checkout was successfully completed. + */ + "checkout:complete": CheckoutCompleteEvent; + + /** + * Dispatched when the checkout overlay is closed, either due to user action or + * from calling the `close()` method. + */ + "checkout:close": CheckoutCloseEvent; + + /** + * Dispatched when an error occurs during the checkout process. + */ + "checkout:error": CheckoutErrorEvent; + + /** + * Dispatched when the buyer starts to change their address. + * Only dispatched for inline target mode. + * Requires authentication. + */ + "checkout:addressChangeStart": CheckoutAddressChangeStartEvent; + + /** + * Dispatched when the buyer starts to change their payment method. + * Only dispatched for inline target mode. + * Requires authentication. + */ + "checkout:paymentMethodChangeStart": CheckoutPaymentMethodChangeStartEvent; + + /** + * Dispatched when the buyer attempts to complete the checkout (for PAN exchange). + * Only dispatched for inline target mode. + * Requires authentication. + */ + "checkout:submitStart": CheckoutSubmitStartEvent; +} + +export interface CheckoutEvent { + target?: CheckoutElement; +} + +export interface CheckoutStartEvent extends CheckoutEvent { + type: "checkout:start"; +} + +export interface CheckoutCloseEvent extends CheckoutEvent { + type: "checkout:close"; +} + +export interface CheckoutCompleteEvent extends CheckoutEvent { + type: "checkout:complete"; +} + +export interface CheckoutErrorEvent extends CheckoutEvent { + type: "checkout:error"; +} + +/** + * Interface for events that support bidirectional communication via respondWith(). + * Follows the pattern established by FetchEvent.respondWith() in the web platform. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/respondWith + */ +export interface RespondableCheckoutEvent extends CheckoutEvent { + /** + * Responds to the checkout event with a response payload. + * The SDK will automatically wrap the payload in a JSON-RPC 2.0 response envelope + * and send it back to the checkout iframe. + * + * @param response - A promise that resolves to the response payload + * + * @example + * checkout.addEventListener('checkout:addressChangeStart', (event) => { + * event.respondWith( + * showAddressSelector().then(selectedAddress => ({ + * delivery: { + * addresses: [{ + * address: selectedAddress + * }] + * } + * })) + * ); + * }); + */ + respondWith(response: Promise): void; +} + +export interface CheckoutAddressChangeStartEvent extends RespondableCheckoutEvent { + type: "checkout:addressChangeStart"; +} + +export interface CheckoutPaymentMethodChangeStartEvent extends RespondableCheckoutEvent { + type: "checkout:paymentMethodChangeStart"; +} + +export interface CheckoutSubmitStartEvent extends RespondableCheckoutEvent { + type: "checkout:submitStart"; +} + +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]; +} + +/** + * Complete mapping of all checkout protocol message types to their payloads + */ +export interface CheckoutProtocolMessageMap { + "checkout.start": CheckoutStartPayload; + "checkout.complete": CheckoutCompletePayload; + "checkout.error": CheckoutErrorPayload; + "checkout.addressChangeStart": CheckoutAddressChangeStartPayload; + "checkout.paymentMethodChangeStart": CheckoutPaymentMethodChangeStartPayload; + "checkout.submitStart": CheckoutSubmitStartPayload; +} + +export type { + Cart, + CheckoutAddressChangeStartResponsePayload, + CheckoutPaymentMethodChangeStartResponsePayload, + CheckoutSubmitStartResponsePayload, +}; + +// From app/shared/embed/2025-10/types.ts (payloads + Cart support) + +interface Money { + amount: string; + currencyCode: string; +} + +interface CheckoutPolicies { + termsOfService?: string; + privacyPolicy?: string; +} + +interface MailingAddress { + address1?: string; + address2?: string; + city?: string; + province?: string; + country?: string; + countryCodeV2?: string; + zip?: string; + firstName?: string; + lastName?: string; + phone?: string; + company?: string; +} + +interface CartDeliveryAddress { + address1?: string; + address2?: string; + city?: string; + company?: string; + countryCode?: string; + firstName?: string; + lastName?: string; + phone?: string; + provinceCode?: string; + zip?: string; +} + +interface CartSelectableAddress { + address: CartDeliveryAddress; + oneTimeUse: boolean; + selected: boolean; +} + +interface Customer { + id: string; + firstName?: string; + lastName?: string; + email?: string; + phone?: string; +} + +interface CartBuyerIdentity { + email?: string; + phone?: string; + customer?: Customer; + countryCode?: string; +} + +interface MerchandiseImage { + url: string; + altText?: string; +} + +interface SelectedOption { + name: string; + value: string; +} + +interface CartLineMerchandise { + id: string; + title: string; + product: { + id: string; + title: string; + }; + image?: MerchandiseImage; + selectedOptions: SelectedOption[]; +} + +interface PricingPercentageValue { + percentage: number; +} + +type DiscountValue = Money | PricingPercentageValue; + +type DiscountApplicationTargetType = "LINE_ITEM" | "SHIPPING_LINE"; + +interface DiscountApplication { + allocationMethod: "ACROSS" | "EACH"; + targetSelection: "ALL" | "ENTITLED" | "EXPLICIT"; + targetType: "LINE_ITEM" | "SHIPPING_LINE"; + value: DiscountValue; +} + +interface CartDiscountAllocation { + discountedAmount: Money; + discountApplication: DiscountApplication; + targetType: DiscountApplicationTargetType; +} + +interface CartDiscountCode { + code: string; + applicable: boolean; +} + +type CartDeliveryMethodType = "SHIPPING" | "PICKUP" | "PICKUP_POINT" | "LOCAL" | "NONE"; + +type CartDeliveryGroupType = "SUBSCRIPTION" | "ONE_TIME_PURCHASE"; + +interface CartDeliveryOption { + code?: string; + title?: string; + description?: string; + handle: string; + estimatedCost: Money; + deliveryMethodType: CartDeliveryMethodType; +} + +interface CartDeliveryGroup { + deliveryAddress: MailingAddress; + deliveryOptions: CartDeliveryOption[]; + selectedDeliveryOption?: CartDeliveryOption; + groupType: CartDeliveryGroupType; +} + +interface CartDelivery { + addresses: CartSelectableAddress[]; +} + +interface CartLineCost { + amountPerQuantity: Money; + subtotalAmount: Money; + totalAmount: Money; +} + +interface CartLine { + id: string; + quantity: number; + merchandise: CartLineMerchandise; + cost: CartLineCost; + discountAllocations: CartDiscountAllocation[]; +} + +interface CartCost { + subtotalAmount: Money; + totalAmount: Money; +} + +interface AppliedGiftCard { + amountUsed: Money; + balance: Money; + lastCharacters: string; + presentmentAmountUsed: Money; +} + +interface Cart { + id: string; + lines: CartLine[]; + cost: CartCost; + buyerIdentity: CartBuyerIdentity; + deliveryGroups: CartDeliveryGroup[]; + discountCodes: CartDiscountCode[]; + appliedGiftCards: AppliedGiftCard[]; + discountAllocations: CartDiscountAllocation[]; + delivery: CartDelivery; + payment: CartPayment; +} + +interface RemoteTokenPaymentCredential { + type: "remoteToken"; + token: string; + tokenType: string; + tokenHandler: string; +} + +type PaymentCredential = RemoteTokenPaymentCredential; + +type CardBrand = + | "VISA" + | "MASTERCARD" + | "AMERICAN_EXPRESS" + | "DISCOVER" + | "DINERS_CLUB" + | "JCB" + | "MAESTRO" + | "UNKNOWN"; + +interface CreditCardPaymentInstrument { + externalReferenceId: string; + cardHolderName?: string; + lastDigits?: string; + month?: number; + year?: number; + brand?: CardBrand; + billingAddress?: MailingAddress; + credentials?: PaymentCredential[]; + handlerId?: string; + richTextDescription?: string; + richCardArt?: string; +} + +interface CreditCardPaymentMethod { + type: "creditCard"; + instruments: CreditCardPaymentInstrument[]; +} + +type CartPaymentMethod = CreditCardPaymentMethod; + +interface CartPaymentHandlers { + id: string; + name: string; + config: Record; +} + +interface CartExpressCheckout { + wallet: string; +} + +interface CartPayment { + methods: CartPaymentMethod[]; + handlers?: CartPaymentHandlers[]; + expressCheckout?: CartExpressCheckout; +} + +interface CartUpdate { + delivery?: { + addresses?: CartDelivery["addresses"]; + }; + payment?: { + methods?: CartPayment["methods"]; + }; +} + +interface OrderConfirmation { + url?: string; + order: { + id: string; + }; + number?: string; + isFirstOrder: boolean; +} + +interface CheckoutStartPayload { + locale: string; + cart: Cart; + policies?: CheckoutPolicies; +} + +interface CheckoutAddressChangeStartPayload { + addressType: "shipping"; + cart: Cart; +} + +interface CheckoutPaymentMethodChangeStartPayload { + cart: Cart; +} + +interface CheckoutSubmitStartPayload { + cart: Cart; + sessionId: string; +} + +interface CheckoutCompletePayload { + orderConfirmation: OrderConfirmation; + cart: Cart; +} + +interface CheckoutErrorPayload { + code: string; + message: string; +} + +// From app/shared/embed.ts + +type ResponseErrorCode = + | "VALIDATION_ERROR" + | "SHIPPING_UNAVAILABLE" + | "DISCOUNT_INVALID" + | "INVENTORY_INSUFFICIENT" + | "PAYMENT_METHOD_UNSUPPORTED" + | "GENERAL_ERROR"; + +interface ResponseError { + code: ResponseErrorCode; + message: string; + fieldTarget?: string; +} + +// From app/utilities/proposal/types.ts (subset for response payloads) + +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; +} + +interface UcpPaymentInstrumentDisplay { + readonly brand?: string; + readonly last_digits?: string; + readonly description?: string; + readonly card_art?: string; + readonly [key: string]: unknown; +} + +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; +} + +interface CheckoutAddressChangeStartResponsePayload { + cart?: Pick; + errors?: ResponseError[]; +} + +interface CheckoutPaymentMethodChangeStartResponsePayload { + cart?: Pick; + ucpPaymentInstrument?: UcpPaymentInstrument; + errors?: ResponseError[]; +} + +interface CheckoutSubmitStartResponsePayload { + cart?: Pick; + ucpPaymentInstrument?: UcpPaymentInstrument; + errors?: ResponseError[]; +} diff --git a/platforms/web/src/globals.d.ts b/platforms/web/src/globals.d.ts new file mode 100644 index 00000000..43b93a6c --- /dev/null +++ b/platforms/web/src/globals.d.ts @@ -0,0 +1,2 @@ +/** Minimal typing for `process.env` used in source (no @types/node in this package). */ +declare const process: { env: { NODE_ENV?: string } }; diff --git a/platforms/web/src/index.ts b/platforms/web/src/index.ts index c739e1ea..9351bb26 100644 --- a/platforms/web/src/index.ts +++ b/platforms/web/src/index.ts @@ -21,8 +21,7 @@ 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 "./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"; diff --git a/platforms/web/src/utils.ts b/platforms/web/src/utils.ts new file mode 100644 index 00000000..7f3ab11a --- /dev/null +++ b/platforms/web/src/utils.ts @@ -0,0 +1,118 @@ +/* +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. +*/ + +/** + * A handful of utils copied from storefront-components repo + * https://github.com/Shopify/storefront-components/tree/preview/src/utilities + */ + +export class ShopifyElement extends HTMLElement { + #shadow?: ShadowRoot; + disconnected = false; + events: Record = {}; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: "open" }); + } + + render(content: string) { + if (this.disconnected) { + return; + } + if (!this.#shadow) throw new ShopifyElementError("No shadow root found"); + + let wrapper = this.#shadow.querySelector("#shopify-element-wrapper"); + + if (!wrapper) { + wrapper = document.createElement("div"); + wrapper.id = "shopify-element-wrapper"; + this.#shadow.appendChild(wrapper); + } + + const range = document.createRange(); + range.selectNodeContents(wrapper); + const fragment = range.createContextualFragment(content); + + wrapper.textContent = ""; + wrapper.appendChild(fragment); + } + + styles(...styles: string[]) { + // @todo - Use constructible stylesheets when we drop support for Safari 16. + const sheet = document.createElement("style"); + sheet.textContent = styles.join("\n"); + this.#shadow?.appendChild(sheet); + } + + connectedCallback() { + Object.entries(this.events).forEach(([eventKey, listener]) => { + const parts = eventKey.split(" "); + const type = parts[0] ?? ""; + const selector = parts[1]; + + // Event delegation. Events should automatically be cleared when the element is disconnected. + (selector === "this" ? this : this.shadowRoot)?.addEventListener(type, (event) => { + const target = event.target as Element | null; + const selected = + selector === undefined + ? undefined + : selector === "this" + ? target + : target?.matches(selector) + ? target + : (target?.closest(selector) ?? null); + + if ((selector && selected) || selector === "this") + listener({ + ...event, + target: (selector === "this" ? this : selected) as Element, + }); + }); + }); + } + + disconnectedCallback() { + this.disconnected = true; + } +} + +export class ShopifyElementError extends Error { + constructor(message: string) { + super(message); + this.name = "ShopifyElementError"; + } +} + +function concatTemplateStrings(strings: TemplateStringsArray, ...values: unknown[]) { + const processedStrings = strings.map((str) => + str.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\r/g, "\r").replace(/\\\\/g, "\\"), + ); + let result = processedStrings[0] ?? ""; + for (let i = 0; i < values.length; i++) { + result += String(values[i]) + (processedStrings[i + 1] ?? ""); + } + return result; +} +export const html = concatTemplateStrings; +export const css = concatTemplateStrings; diff --git a/platforms/web/tsconfig.json b/platforms/web/tsconfig.json index cf546523..c5e9f104 100644 --- a/platforms/web/tsconfig.json +++ b/platforms/web/tsconfig.json @@ -24,5 +24,6 @@ "noEmit": true, "types": ["vitest/globals"] }, - "include": ["src/**/*", "vite.config.ts", "custom-elements-manifest.config.mjs"] + "include": ["src/**/*", "vite.config.ts", "custom-elements-manifest.config.mjs"], + "exclude": ["src/**/*.test.ts"] }