# Events

> Every event the AndOr widgets dispatch and listen for, with payload types, pixel events and listening examples.

Source: https://backtrackhq.com/docs/developers/events/

AndOr widgets dispatch DOM events when they mount, change and move
between tiers. Upsell offers also publish Shopify customer events you can
read in an optional custom pixel.

All DOM events are `CustomEvent`s with `bubbles: true`. There is no
shadow root, so there is no `composed` flag.

> **Note:** The `andor:widget:*` and `andor:display:change` events need
> the **AndOr Discount Display** app embed on. Blocks still show
> without it, but these events don't fire.

## Catalogue

These events are public. Names do not change, and payloads only ever
gain fields. The
three `andor:offer_*` events are Shopify customer events, not DOM
events: you read them in a custom pixel, not with `addEventListener`.
See [Upsell offer pixel events](#upsell-offer-pixel-events-optional).

| Event | Host | Detail type |
|---|---|---|
| `andor:widget:mount` | widget host | `AndorWidgetEventDetail` |
| `andor:widget:unmount` | host, `document` | `AndorWidgetEventDetail` |
| `andor:display:change` | widget host | `AndorDisplayChangeDetail` |
| `andor:tier:change` | picker or table | `AndorTierChangeDetail` |
| `andor:goal:tier-change` | goal bar | `AndorGoalTierDetail` |
| `andor:offer_viewed` | customer events | `AndorOfferPixelDetail` |
| `andor:offer_clicked` | customer events | `AndorOfferPixelDetail` |
| `andor:offer_added` | customer events | `AndorOfferPixelDetail` |

When each one fires:

- `andor:widget:mount`: the first pass that sees the host.
- `andor:widget:unmount`: the first pass after the host left the page.
  It is sent on the element and on `document`, because a removed node
  cannot bubble. A listener in one place sees exactly one.
- `andor:display:change`: a pass finds a state different from the stored
  one. Never on first paint.
- `andor:tier:change`: the volume picker's checked card or the table's
  current row moves to another tier. Not on first paint. A page with a
  table and a picker gets one event from each, told apart by
  `detail.source`.
- `andor:goal:tier-change`: the goal bar's count of reached steps
  changes after a cart change. Not on first paint.
- `andor:offer_viewed`: an upsell offer is first seen, once per offer and
  placement per visit.
- `andor:offer_clicked`: a shopper clicks an upsell offer.
- `andor:offer_added`: an upsell offer item was added to the cart.

### How `andor:display:change` behaves

- **No initial fire.** The first pass sends `andor:widget:mount` and
  stores the state silently.
- **Deduped.** A pass with the same state sends nothing. A ticking
  countdown is silent: its time string is not part of the state.
- **Up to one second late.** The events script runs its own pass every
  second, and on `visibilitychange` and `pageshow`. The page is right at
  the instant a sale ends. The event can arrive up to one second later.
  Treat it as a notification, not as the exact moment.
- **Variant switches** show up as ordinary `andor:display:change` events.
  A switch between two variants with the same deal and price sends
  nothing.

> **Note:** The events script only reports. If it never loads, prices,
> badges and countdowns still work.

## Payloads

```ts
type AndorWidgetType =
  | "price"
  | "badge"
  | "countdown"
  | "offer"
  | "goal"
  | "tiers"
  | "bundle"
  | "bxgy"
  | "gift";

/** andor:widget:mount and andor:widget:unmount */
type AndorWidgetEventDetail = {
  resource: {
    widgetType: AndorWidgetType;
    widgetId: string | null;  // data-andor-widget-id, else null
    productId: number | null; // data-andor-product-id, else null
  };
};

type AndorDisplayState = {
  discountId: string | null; // data-andor-discount-id, or the
                             // price block's data-best-id
  kind: string | null;       // data-andor-kind
  mode: string | null;       // data-andor-mode
  visible: boolean;          // !host.hidden
  endsAt: string | null;     // data-andor-ends, ISO 8601
  priceCents: number | null; // always null today
  originalCents: number | null; // data-price, integer cents
};

/** andor:display:change */
type AndorDisplayChangeDetail = {
  resource: {
    widgetType: AndorWidgetType;
    widgetId: string | null;
    productId: number | null;
    previous: AndorDisplayState | null;
    current: AndorDisplayState;
  };
};

/** A tier: 0 based among the tiers, and its quantity threshold. */
type AndorTier = { index: number; quantity: number };

/**
 * andor:tier:change. A tier is `null` below the first tier (the
 * picker's 1 item card). Sent by the volume picker and the tiers
 * table.
 */
type AndorTierChangeDetail = {
  productId: number | null;
  variantId: number | null;
  previousTier: AndorTier | null;
  currentTier: AndorTier | null;
  /** Which widget sent it. One change, one event per widget. */
  source: "picker" | "table";
  /** The host's `data-andor-widget-id` (block id, or "pasted"). */
  widgetId: string | null;
};

/**
 * andor:goal:tier-change, from the goal bar. A tier is
 * the count of steps reached: 0 none, 1 the first, and so on.
 */
type AndorGoalTierDetail = {
  previousTier: number;
  currentTier: number;
  cartTotalCents: number; // the subtotal the bar measured
};

/** andor:offer_viewed, andor:offer_clicked, andor:offer_added */
type AndorOfferPixelDetail = {
  offerId: string; // digits
  placement: "product_page" | "cart_page" | "cart_drawer";
  /** Adds only: the item's value, 0 for an auto-added gift. */
  valueCents?: number;
  /** A gift the storefront added on the shopper's behalf. */
  auto?: true;
};
```

The widget hosts that send these events, and their `widgetType`:

| Host element | `widgetType` |
|---|---|
| `<andor-price>` | `price` |
| `<andor-badge>` | `badge` |
| `<andor-countdown>` | `countdown` |
| `<li class="andor-offers__item">` | `offer` |
| `<andor-goal>` (the live bar) | `goal` |
| `<andor-tiers>` | `tiers` |
| `<andor-bundle>` (offer picker) | `bundle`, `bxgy` or `gift` |

Only the live goal bar is a host. On the offers block, each row is a
host, not the `<andor-offers>` wrapper.

> **Tip:** New widget types can be added later. Treat an unknown
> `widgetType` as a normal widget, not as an error.

Which fields each widget fills (the goal, tiers and offer picker hosts
read the same host attributes; a field the host does not carry is
`null`):

| Field | price | badge | countdown | offer |
|---|---|---|---|---|
| `widgetId` | block id | block id | block id | `null` |
| `productId` | id or `null` | id or `null` | id or `null` | `null` |
| `discountId` | `data-best-id` | id | id | id |
| `kind` | kind | kind | `null` | kind |
| `mode` | `"price"` | `price` or `badge_only` | `null` | same |
| `endsAt` | `null` | ISO or `null` | ISO | ISO or `null` |
| `originalCents` | cents | `null` | `null` | `null` |

## Listening: examples

React to a sale ending:

```js
document.addEventListener("andor:display:change", (event) => {
  const { widgetType, current, previous } = event.detail.resource;
  if (widgetType === "badge" && previous?.visible && !current.visible) {
    console.log("this deal just ended:", previous.discountId);
  }
});
```

Track widgets across theme editor re-renders:

```js
document.addEventListener("andor:widget:unmount", (event) => {
  const { widgetType, widgetId } = event.detail.resource;
  console.log("gone:", widgetType, widgetId);
});
```

Follow the volume picker and the goal bar:

```js
document.addEventListener("andor:tier:change", (event) => {
  const { currentTier } = event.detail;
  console.log(currentTier ? `tier ${currentTier.quantity}+` : "no tier");
});

document.addEventListener("andor:goal:tier-change", (event) => {
  const { currentTier, cartTotalCents } = event.detail;
  console.log("goal step", currentTier, "at", cartTotalCents);
});
```

## Upsell offer pixel events (optional)

AndOr needs no web pixel and installs none. Its own offer and analytics
counts go through the app proxy, so there is nothing to set up in
**Customer events**. The pixel below is optional. Add it only if you
want the `andor:offer_*` events in your own tools.

The three `andor:offer_*` events are not DOM events. They are published
to Shopify's customer events with `Shopify.analytics.publish`, so a
custom pixel or an analytics app can read them. Subscribe in a custom
pixel:

```js
// Settings > Customer events > Add custom pixel
analytics.subscribe("andor:offer_added", (event) => {
  const { offerId, placement, valueCents } = event.customData;
  console.log("upsell add", offerId, placement, valueCents);
});
```

- A view is published once per offer and placement per visit, the same
  rule the app's own counts use.
- No customer data, cart token or product ids are sent.
- The app's own counting waits for the shopper's analytics consent.
  Shopify's customer events system applies its own consent rules to your
  pixel.

## Events the widgets listen for

These are Shopify theme events. When a theme sends them, the widgets
redraw with no extra code.

- `shopify:cart:lines-update`: the goal bar, offer picker, volume
  picker line, goal fillers and loader re-read the cart.
- `shopify:cart:view`: the loader and the cart drawer goal load and
  draw the drawer widgets.
- `shopify:section:load`: the theme editor re-rendered a section. The
  goal bar, offers list, countdown, volume picker, offer picker and
  loader redraw it.
- `shopify:product:select`: a variant change (Horizon). The sale price
  follows the new variant.

## The copy button in your own markup

One click listener on `document` copies any
`.andor-code__button[data-discount-code]` button. Your own code button
works with no extra JavaScript when it has this shape:

```liquid
<span class="andor-code">
  <button
    type="button"
    class="andor-code__button"
    data-discount-code="{{ code | escape }}"
    data-andor-copied="{{ 'discount_display.code_copied' | t }}"
    aria-describedby="my-code-status"
  >{{ code | escape }}</button>
  <span class="andor-code__status andor-vh"
    id="my-code-status" role="status"></span>
</span>
```

It copies the code and swaps the button text to `data-andor-copied`
(default "Copied"). It fills the status element from
`data-andor-copied-status`, if set. Both are restored after 2 seconds.
The button must be a real `<button>` with the `andor-code__button`
class, holding only the code text.
