Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 53 additions & 44 deletions platforms/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ store customizations: Checkout UI extensions, Functions, branding, and more. It
also provides web idiomatic defaults such as opening checkout in a popup or
new tab, a transient overlay scrim while the popup is open, and convenient
developer APIs to embed, customize, and follow the lifecycle of the checkout
experience via the
[Embedded Checkout Protocol](https://ucp.dev/2026-04-08/specification/embedded-checkout/).
experience through typed Checkout Kit events.

Check out our blog to
[learn how and why we built the Shopify Checkout Kit](https://www.shopify.com/partners/blog/mobile-checkout-sdks-for-ios-and-android).
Expand Down Expand Up @@ -134,7 +133,7 @@ checkout.src = 'https://your-store.myshopify.com/checkouts/cn/abc123';
checkout.target = 'popup';
document.body.append(checkout);

checkout.addEventListener('ec.complete', (event) => {
checkout.addEventListener('complete', (event) => {
console.log('Order complete', event.detail.checkout.order?.id);
});

Expand Down Expand Up @@ -163,7 +162,7 @@ React 19+ has first-class support for custom elements — it renders
`<shopify-checkout>` and forwards props to it as properties with no extra
configuration. Reach for a `ref` for the two things that aren't expressible as
JSX props: calling imperative methods (`open()`, `close()`, `focus()`) and
subscribing to the `ec.*` events.
subscribing to Checkout Kit events.

```tsx
import {useEffect, useRef} from 'react';
Expand All @@ -182,11 +181,11 @@ export function BuyNowButton({checkoutUrl}: {checkoutUrl: string}) {
const {signal} = controller;

checkout.addEventListener(
'ec.complete',
'complete',
(event) => console.log('Order complete', event.detail.checkout.order?.id),
{signal},
);
checkout.addEventListener('ec.close', () => console.log('Dismissed'), {
checkout.addEventListener('close', () => console.log('Dismissed'), {
signal,
});

Expand All @@ -203,7 +202,7 @@ export function BuyNowButton({checkoutUrl}: {checkoutUrl: string}) {
```

`event` is fully typed inside each listener. For example, order data for
`ec.complete` is available at `event.detail.checkout.order`. The element's
`complete` is available at `event.detail.checkout.order`. The element's
overloaded `addEventListener` signatures provide these types. See
[Checkout lifecycle](#checkout-lifecycle) for the full event list.

Expand Down Expand Up @@ -483,49 +482,66 @@ shopify-checkout::part(overlay) {

## Checkout lifecycle

The element dispatches `ec.*` `CustomEvent`s at every meaningful moment
of the checkout session. All events bubble, so you can listen anywhere in your
DOM — including a single delegated listener at `document` if you have many
elements on the page. Each event carries a typed `event.detail` payload with
exactly the fields relevant to that moment.

| Event | `event.detail` | When it fires |
| ---------------------- | -------------- | -------------------------------------------------------------------------- |
| `ec.start` | `{checkout}` | Checkout has loaded and is interactive. |
| `ec.complete` | `{checkout}` | The buyer completed the order successfully. |
| `ec.close` | _(none)_ | The open session ended through `close()`, overlay dismissal, or detection of a popup the buyer closed. |
| `ec.error` | `{error}` | Checkout reported an error. The component closes automatically only when a message has `unrecoverable` severity. |
| `ec.fulfillment.change` | `{checkout}` | The checkout's fulfillment details changed. |
| `ec.line_items.change` | `{checkout}` | The cart's line items changed (item added/removed/quantity updated). |
| `ec.totals.change` | `{checkout}` | The cart totals changed (subtotal, tax, shipping, discounts, total). |
| `ec.messages.change` | `{checkout}` | Checkout-level warnings/errors/info shown inside the checkout changed. |

`ec.start`, `ec.complete`, and the change events carry the full UCP `Checkout`
snapshot in `event.detail.checkout` for handlers that need broader context.
The element dispatches typed `CustomEvent`s at every meaningful moment of the
checkout session. The `start`, `update`, `complete`, and `close` events bubble,
so you can listen anywhere in your DOM, including a single delegated listener
at `document` if you have many elements on the page. The `error` event does not
bubble; attach its listener directly to the checkout element. Event payloads
are available in `event.detail`.

| Event | `event.detail` | When it fires |
| ---------- | -------------- | ------------- |
| `start` | `{checkout}` | Checkout has loaded and is interactive. |
| `update` | `{checkout}` | A change to line items, fulfillment, totals, or checkout messages produces a different checkout snapshot. |
| `complete` | `{checkout}` | The buyer completed the order successfully. |
| `error` | `{error}` | Checkout reported a terminal error, exposed as `{code, message}`. The component closes automatically after this event. |
| `close` | _(none)_ | The open session ended through `close()`, overlay dismissal, or detection of a popup the buyer closed. |

`start`, `update`, and `complete` carry a Checkout Kit `Checkout` snapshot in
`event.detail.checkout`. It preserves checkout data, including unknown
extension fields, and omits the protocol's top-level `ucp` metadata. Known
fields use camelCase names such as `lineItems` and `fulfillment`.
Unknown extension properties remain inline and keep their original names.

All four supported change notifications feed the same `update` event. Repeated
identical snapshots are deduplicated, including when separate notifications
describe the same checkout state. Read the fields you need from the full
snapshot; there is no list of changed fields. Buyer and payment updates are
not currently supported.
Start and complete events are always delivered. Opening checkout starts a
fresh snapshot history and clears the previous checkout and error properties.

```ts
checkout.addEventListener('ec.complete', (event) => {
checkout.addEventListener('complete', (event) => {
const {order} = event.detail.checkout;
if (order) {
analytics.track('checkout_complete', {orderId: order.id});
}
});

checkout.addEventListener('ec.totals.change', (event) => {
checkout.addEventListener('update', (event) => {
miniCart.updateTotals(event.detail.checkout.totals);
});

checkout.addEventListener('ec.close', () => {
checkout.addEventListener('error', (event) => {
const {code, message} = event.detail.error;
console.error('Checkout error', code, message);
});

checkout.addEventListener('close', () => {
router.back();
});
```

Protocol errors are terminal for the checkout session regardless of message
severity. The component emits `error` before closing and emitting `close`.

Because these events carry the full snapshot, one handler can combine fields.
For example, rendering an inline cart summary on `ec.start` requires line
items, totals, and currency together:
For example, rendering an inline cart summary on `start` requires line items,
totals, and currency together:

```ts
checkout.addEventListener('ec.start', (event) => {
checkout.addEventListener('start', (event) => {
const {checkout: snapshot} = event.detail;
loadingSpinner.hide();
cartSummary.render({
Expand All @@ -536,18 +552,11 @@ checkout.addEventListener('ec.start', (event) => {
});
```

The latest full UCP `Checkout` snapshot is also mirrored to `element.checkout`
whenever an event with `{checkout}` arrives. The latest error is mirrored to
`element.error` when `ec.error` fires. These properties are useful for handlers
that don't have a reference to the originating event. TypeScript users get
fully typed events through overloaded `addEventListener` signatures with no
additional setup.

> [!NOTE]
> Most public `ec.*` DOM event names mirror the underlying
> [Embedded Checkout Protocol](https://ucp.dev/2026-04-08/specification/embedded-checkout/)
> JSON-RPC method names. `ec.close` is component-only and synthetic; it is not
> part of the ECP wire protocol.
The latest snapshot is also mirrored to `element.checkout`. The latest
`{code, message}` error is mirrored to `element.error` when `error` fires.
These properties are useful for handlers that don't have a reference to the
originating event. TypeScript users get fully typed events through overloaded
`addEventListener` signatures with no additional setup.

## Explore the sample app

Expand Down
11 changes: 9 additions & 2 deletions platforms/web/sample/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Web Component Playground

A development harness for the `<shopify-checkout>` web component. It imports the same entry as published consumers (`@shopify/checkout-kit`, aliased to `../src/index.ts` in dev), registers the custom element, and logs `ec.*` events.
A development harness for the `<shopify-checkout>` web component. It imports the same entry as published consumers (`@shopify/checkout-kit`, aliased to `../src/index.ts` in dev), registers the custom element, and logs Checkout Kit lifecycle events.

## Run locally

Expand Down Expand Up @@ -34,10 +34,17 @@ You can also choose **Use existing checkout source** in Settings. In that mode,

- **Settings** — persisted storefront domain, flow, target (`popup` | `auto`), appearance (default `storefront` | `app:light` | `app:dark` | `app:automatic` | `storefront`), and log-level (`debug` | `warn` | `error` | `none`) settings. The storefront domain appears first because the cart builder cannot load products without it.
- **Center workspace** — build mode shows a storefront-style product grid plus sticky cart banner; manual mode shows a focused checkout URL/cart permalink input.
- **Runtime** — shows component state above the `ec.*` event log, with a JSON snapshot of component state at fire time.
- **Runtime** — shows component state above the `start`, `update`, `complete`, `error`, and `close` event log. Each entry includes the event detail and a JSON snapshot of component state at fire time.

The element is mounted on `<body>`. For `popup` / `auto`, the visible UI is mostly the overlay scrim while checkout is open in a separate window or tab.

The `start`, `update`, and `complete` events expose the latest Checkout Kit
snapshot at `event.detail.checkout`, without protocol metadata. Changes to line
items, fulfillment, totals, or messages feed one `update` event; repeated identical
snapshots do not produce another update. The `error` event exposes a
`{code, message}` error. Checkout stays open for recoverable errors and closes
automatically only for errors with `unrecoverable` severity.

## Troubleshooting product loading

The demo relies on the public `/products.json` endpoint. If product loading fails:
Expand Down
2 changes: 1 addition & 1 deletion platforms/web/sample/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ <h2 id="events-heading">Events</h2>
</div>
<ul id="event-log"></ul>
<p id="event-empty" class="muted">
Open checkout and interact; <code>ec.*</code> events from the component appear here.
Open checkout and interact; lifecycle events appear here.
</p>
</div>

Expand Down
21 changes: 5 additions & 16 deletions platforms/web/sample/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,7 @@ import {
} from "./storage";
import "./styles.css";

const EVENT_TYPES = [
"ec.start",
"ec.complete",
"ec.close",
"ec.error",
"ec.fulfillment.change",
"ec.line_items.change",
"ec.totals.change",
"ec.messages.change",
] as const;
const EVENT_TYPES = ["start", "update", "complete", "close", "error"] as const;

const refs = queryRefs();

Expand Down Expand Up @@ -129,10 +120,11 @@ function openCheckout(): void {
checkout.open();
}

function recordEvent(type: string): void {
function recordEvent(event: Event): void {
const snapshot: ComponentSnapshot = { checkout: checkout.checkout, error: checkout.error };
const json = JSON.stringify(
{
detail: event instanceof CustomEvent ? (event.detail as unknown) : undefined,
checkout: checkout.checkout,
error: checkout.error,
target: checkout.target,
Expand All @@ -144,7 +136,7 @@ function recordEvent(type: string): void {
);
store.setState({
component: snapshot,
log: [{ type, time: timestamp(), snapshot: json }, ...store.getState().log],
log: [{ type: event.type, time: timestamp(), snapshot: json }, ...store.getState().log],
});
}

Expand Down Expand Up @@ -281,10 +273,7 @@ function attachListeners(): void {
store.setState({ log: [] });
});

const checkoutEl: HTMLElement = checkout;
for (const type of EVENT_TYPES) {
checkoutEl.addEventListener(type, () => {
recordEvent(type);
});
checkout.addEventListener(type, recordEvent);
}
}
6 changes: 3 additions & 3 deletions platforms/web/sample/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,15 +193,15 @@ describe("renderLog", () => {
refs,
state({
log: [
{ type: "ec.close", time: "00:00:02.000", snapshot: "{}" },
{ type: "ec.start", time: "00:00:01.000", snapshot: "{}" },
{ type: "close", time: "00:00:02.000", snapshot: "{}" },
{ type: "start", time: "00:00:01.000", snapshot: "{}" },
],
}),
);
const names = [...refs.eventLog.querySelectorAll(".event-entry-name")].map(
(el) => el.textContent,
);
expect(names).toEqual(["ec.close", "ec.start"]);
expect(names).toEqual(["close", "start"]);
});

it("collapses the events panel", () => {
Expand Down
67 changes: 67 additions & 0 deletions platforms/web/src/checkout-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Checkout } from "./models/checkout";
import type { CheckoutError } from "./models/error";

export interface ShopifyCheckoutStartEventDetail {
checkout: Checkout;
}

export interface ShopifyCheckoutUpdateEventDetail {
checkout: Checkout;
}

export interface ShopifyCheckoutCompleteEventDetail {
checkout: Checkout;
}

export interface ShopifyCheckoutErrorEventDetail {
error: CheckoutError;
}

export class ShopifyCheckoutStartEvent extends CustomEvent<ShopifyCheckoutStartEventDetail> {
declare type: "start";

constructor(detail: ShopifyCheckoutStartEventDetail) {
super("start", { detail, bubbles: true });
}
}

export class ShopifyCheckoutUpdateEvent extends CustomEvent<ShopifyCheckoutUpdateEventDetail> {
declare type: "update";

constructor(detail: ShopifyCheckoutUpdateEventDetail) {
super("update", { detail, bubbles: true });
}
}

export class ShopifyCheckoutCompleteEvent extends CustomEvent<ShopifyCheckoutCompleteEventDetail> {
declare type: "complete";

constructor(detail: ShopifyCheckoutCompleteEventDetail) {
super("complete", { detail, bubbles: true });
}
}

export class ShopifyCheckoutCloseEvent extends CustomEvent<undefined> {
declare type: "close";

constructor() {
super("close", { bubbles: true });
}
}

export class ShopifyCheckoutErrorEvent extends CustomEvent<ShopifyCheckoutErrorEventDetail> {
declare type: "error";

constructor(detail: ShopifyCheckoutErrorEventDetail) {
// Keep checkout failures out of window.onerror.
super("error", { detail, bubbles: false });
}
}

export interface ShopifyCheckoutEventMap {
start: ShopifyCheckoutStartEvent;
update: ShopifyCheckoutUpdateEvent;
complete: ShopifyCheckoutCompleteEvent;
error: ShopifyCheckoutErrorEvent;
close: ShopifyCheckoutCloseEvent;
}
Loading
Loading