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
64 changes: 32 additions & 32 deletions platforms/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ checkout.target = 'popup';
document.body.append(checkout);

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

checkout.open();
Expand Down Expand Up @@ -181,7 +181,7 @@ export function BuyNowButton({checkoutUrl}: {checkoutUrl: string}) {

checkout.addEventListener(
'ec.complete',
(event) => console.log('Order complete', event.detail.order.id),
(event) => console.log('Order complete', event.detail.checkout.order?.id),
{signal},
);
checkout.addEventListener('ec.close', () => console.log('Dismissed'), {
Expand All @@ -200,10 +200,10 @@ export function BuyNowButton({checkoutUrl}: {checkoutUrl: string}) {
}
```

`event` is fully typed inside each listener — `event.detail.order` on
`ec.complete`, and so on — courtesy of the element's overloaded
`addEventListener` signatures. See [Checkout lifecycle](#checkout-lifecycle)
for the full event list.
`event` is fully typed inside each listener. For example, order data for
`ec.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.

TypeScript doesn't know about the `<shopify-checkout>` tag in JSX out of the
box. Declare it once, anywhere in your project's type definitions:
Expand Down Expand Up @@ -418,58 +418,58 @@ 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, order}` | The buyer completed the order successfully. |
| `ec.close` | _(none)_ | The popup was dismissed (by the buyer, by `close()`, or by `focus` loss). |
| `ec.error` | `{error}` | Session-level fatal error — tear down the embedded context. |
| `ec.line_items.change` | `{checkout, lineItems}` | The cart's line items changed (item added/removed/quantity updated). |
| `ec.totals.change` | `{checkout, totals}` | The cart totals changed (subtotal, tax, shipping, discounts, total). |
| `ec.messages.change` | `{checkout, messages}` | Checkout-level warnings/errors/info shown inside the checkout changed. |
| 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 popup was dismissed (by the buyer, by `close()`, or by `focus` loss). |
| `ec.error` | `{error}` | Session-level fatal error — tear down the embedded context. |
| `ec.line_items.change` | `{checkout}` | 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. |

The `checkout` field on every change event is the full UCP `Checkout`
snapshot, included for handlers that want broader context. Most handlers only
need the named slice (e.g. `event.detail.totals`).
`ec.start`, `ec.complete`, and the change events carry the full UCP `Checkout`
snapshot in `event.detail.checkout` for handlers that need broader context.

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

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

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

Reach for `event.detail.checkout` when a handler needs fields beyond the
named slice. It carries the full UCP `Checkout` snapshot at the moment the
event was dispatched. For example, rendering an inline cart summary on
`ec.start` requires line items, totals, and currency together:
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:

```ts
checkout.addEventListener('ec.start', (event) => {
const {checkout: snapshot} = event.detail;
loadingSpinner.hide();
cartSummary.render({
currency: snapshot.currency,
items: snapshot.line_items,
items: snapshot.lineItems,
totals: snapshot.totals,
});
});
```

The full UCP `Checkout` snapshot is also mirrored to the
[`element.checkout`](#) property every time a payload-carrying event arrives,
and the latest error is mirrored to [`element.error`](#) when `ec.error`
fires — useful for handlers that don't have a reference to the originating
event. TypeScript users get fully typed events via overloaded
`addEventListener` signatures — no additional setup required.
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
Expand Down
34 changes: 18 additions & 16 deletions platforms/web/src/checkout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1204,10 +1204,10 @@ describe("<shopify-checkout>", () => {
await wait;

const event = spy.mock.calls[0]![0] as CustomEvent;
expect(event.detail).toEqual({ checkout: decodeCheckout(payload) });
expect(event.detail).toStrictEqual({ checkout: decodeCheckout(payload) });
});

it("ec.complete carries {checkout, order} with order derived from checkout", async () => {
it("ec.complete carries {checkout} with order nested in checkout", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const spy = vi.fn();
const wait = waitForEvent(checkout, "ec.complete", spy);
Expand All @@ -1224,11 +1224,11 @@ describe("<shopify-checkout>", () => {

const event = spy.mock.calls[0]![0] as CustomEvent;
const decoded = decodeCheckout(payload);
expect(event.detail).toEqual({ checkout: decoded, order: decoded.order });
expect(event.detail.order).toEqual(event.detail.checkout.order);
expect(event.detail).toStrictEqual({ checkout: decoded });
expect(event.detail.checkout.order).toEqual(decoded.order);
});

it("ec.complete leaves order undefined when the checkout has no order", async () => {
it("ec.complete keeps an absent order nested in checkout", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const spy = vi.fn();
const wait = waitForEvent(checkout, "ec.complete", spy);
Expand All @@ -1240,7 +1240,9 @@ describe("<shopify-checkout>", () => {
await wait;

const event = spy.mock.calls[0]![0] as CustomEvent;
expect(event.detail.order).toBeUndefined();
const decoded = decodeCheckout(payload);
expect(event.detail).toStrictEqual({ checkout: decoded });
expect(event.detail.checkout.order).toBeUndefined();
});

it("ec.error carries {error}", async () => {
Expand All @@ -1255,10 +1257,10 @@ describe("<shopify-checkout>", () => {
await wait;

const event = spy.mock.calls[0]![0] as CustomEvent;
expect(event.detail).toEqual({ error: decodeError(errorParams) });
expect(event.detail).toStrictEqual({ error: decodeError(errorParams) });
});

it("ec.line_items.change carries {lineItems, checkout}", async () => {
it("ec.line_items.change carries {checkout} with lineItems nested in checkout", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const spy = vi.fn();
const wait = waitForEvent(checkout, "ec.line_items.change", spy);
Expand All @@ -1271,11 +1273,11 @@ describe("<shopify-checkout>", () => {

const event = spy.mock.calls[0]![0] as CustomEvent;
const decoded = decodeCheckout(payload);
expect(event.detail.lineItems).toEqual(decoded.lineItems);
expect(event.detail.checkout).toEqual(decoded);
expect(event.detail).toStrictEqual({ checkout: decoded });
expect(event.detail.checkout.lineItems).toEqual(decoded.lineItems);
});

it("ec.totals.change carries {totals, checkout}", async () => {
it("ec.totals.change carries {checkout} with totals nested in checkout", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const spy = vi.fn();
const wait = waitForEvent(checkout, "ec.totals.change", spy);
Expand All @@ -1288,11 +1290,11 @@ describe("<shopify-checkout>", () => {

const event = spy.mock.calls[0]![0] as CustomEvent;
const decoded = decodeCheckout(payload);
expect(event.detail.totals).toEqual(decoded.totals);
expect(event.detail.checkout).toEqual(decoded);
expect(event.detail).toStrictEqual({ checkout: decoded });
expect(event.detail.checkout.totals).toEqual(decoded.totals);
});

it("ec.messages.change carries {messages, checkout}", async () => {
it("ec.messages.change carries {checkout} with messages nested in checkout", async () => {
const { checkout, mockCheckoutWindow } = openPopupCheckout();
const spy = vi.fn();
const wait = waitForEvent(checkout, "ec.messages.change", spy);
Expand All @@ -1305,8 +1307,8 @@ describe("<shopify-checkout>", () => {

const event = spy.mock.calls[0]![0] as CustomEvent;
const decoded = decodeCheckout(payload);
expect(event.detail.messages).toEqual(decoded.messages ?? []);
expect(event.detail.checkout).toEqual(decoded);
expect(event.detail).toStrictEqual({ checkout: decoded });
expect(event.detail.checkout.messages).toEqual(decoded.messages);
});

it("ec.close carries no detail", () => {
Expand Down
41 changes: 8 additions & 33 deletions platforms/web/src/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ import type {
CheckoutTarget,
TypedEventListener,
Checkout,
LineItem,
Message,
CheckoutAppearance,
CheckoutTotal,
ErrorResponse,
} from "./checkout.types";

Expand Down Expand Up @@ -232,7 +229,7 @@ export class ShopifyCheckout
* @returns The current Checkout, or undefined before the first notification.
* @example
* checkout.addEventListener('ec.start', (event) => {
* const {line_items, totals, buyer} = event.detail.checkout;
* const {lineItems, totals, buyer} = event.detail.checkout;
* });
*/
get checkout(): Checkout | undefined {
Expand Down Expand Up @@ -587,7 +584,7 @@ export class ShopifyCheckout
})
.on(Event.complete, (checkout) => {
this.#checkout = checkout;
this.dispatchEvent(new ShopifyCheckoutCompleteEvent({ checkout, order: checkout.order }));
this.dispatchEvent(new ShopifyCheckoutCompleteEvent({ checkout }));
})
.on(Event.error, (error) => {
this.#error = error;
Expand All @@ -603,30 +600,15 @@ export class ShopifyCheckout
})
.on(Event.lineItemsChange, (checkout) => {
this.#checkout = checkout;
this.dispatchEvent(
new ShopifyCheckoutLineItemsChangeEvent({
checkout,
lineItems: checkout.lineItems,
}),
);
this.dispatchEvent(new ShopifyCheckoutLineItemsChangeEvent({ checkout }));
})
.on(Event.totalsChange, (checkout) => {
this.#checkout = checkout;
this.dispatchEvent(
new ShopifyCheckoutTotalsChangeEvent({
checkout,
totals: checkout.totals,
}),
);
this.dispatchEvent(new ShopifyCheckoutTotalsChangeEvent({ checkout }));
})
.on(Event.messagesChange, (checkout) => {
this.#checkout = checkout;
this.dispatchEvent(
new ShopifyCheckoutMessagesChangeEvent({
checkout,
messages: checkout.messages ?? [],
}),
);
this.dispatchEvent(new ShopifyCheckoutMessagesChangeEvent({ checkout }));
})
.on(Event.windowOpen, (request) => this.#handleWindowOpen(request));
}
Expand Down Expand Up @@ -801,7 +783,6 @@ export interface ShopifyCheckoutStartEventDetail {
export interface ShopifyCheckoutCompleteEventDetail {
/** Final checkout snapshot from the ECP `ec.complete` notification. */
checkout: Checkout;
order?: Checkout["order"];
}

export interface ShopifyCheckoutErrorEventDetail {
Expand All @@ -810,23 +791,17 @@ export interface ShopifyCheckoutErrorEventDetail {
}

export interface ShopifyCheckoutLineItemsChangeEventDetail {
/** Updated cart line items. */
lineItems: readonly LineItem[];
/** Full checkout snapshot for handlers that want broader context. */
/** Checkout snapshot with updated cart line items. */
checkout: Checkout;
}

export interface ShopifyCheckoutTotalsChangeEventDetail {
/** Updated totals. */
totals: readonly CheckoutTotal[];
/** Full checkout snapshot for handlers that want broader context. */
/** Checkout snapshot with updated totals. */
checkout: Checkout;
}

export interface ShopifyCheckoutMessagesChangeEventDetail {
/** Updated checkout-level messages (warnings, errors, info). */
messages: readonly Message[];
/** Full checkout snapshot for handlers that want broader context. */
/** Checkout snapshot with updated warnings, errors, and informational messages. */
checkout: Checkout;
}

Expand Down
Loading