Adobe Client Data Layer integration

To bridge page information and user actions to tag managers (Adobe Launch, GTM, and the like), the site uses Adobe Client Data Layer (the official npm package, "ACDL" below) as its data layer.

Initialization

ACDL is initialized inside <head> in src/layouts/Base.astro, before any other script runs.

<script is:inline define:vars={{ pageContext }}>
  window.adobeDataLayer = window.adobeDataLayer || [];
  window.adobeDataLayer.push({ page: pageContext });
</script>
<script>
  import '@adobe/adobe-client-data-layer/dist/adobe-client-data-layer.min.js';
</script>

window.adobeDataLayer is initialized as a plain array first, the page context is pushed onto it, and only then is the ACDL library itself loaded. The library detects the pre-existing array and upgrades it into the real object with push/getState/addEventListener (this is the officially recommended initialization pattern).

window.adobeDataLayer.push({
  page: {
    pageName: string,
    pageType: string,   // "top" | "product" | "cart" | ... same enum as frontmatter's pageType
    locale: string,      // only when i18n is in use
  },
});

The page push itself carries no event key, so it's a plain Data Object merged into state. It's pushed as early as possible (<head>) so tag managers can detect the page context as soon as possible.

Page Load event (page loaded)

The Event Object that follows the official ACDL wiki's "event": "page loaded" convention is pushed separately, at the very end of <body> (after MemberOverlay).

window.adobeDataLayer.push({ event: 'page loaded' });

This is timed separately from the page push (which happens at <head>) so it fires only after the other ACDL contexts on the page — user (member data), view_item, and the like — have already been pushed. If a tag manager instead used the page push itself as the Page View trigger, it would fire before user and friends were in place. Tag managers should trigger Page View measurement off the page loaded event, not the page push.

page loaded carries no payload data, so per ACDL's spec it can't be caught via adobeDataLayer:change — listen on adobeDataLayer:event (or the event name itself) instead. Events that do carry payload data (e.g. add-to-cart's productListItems) can still be caught via change.

Two patterns for pushing events

The deciding question is whether the change is cross-cutting (page, cart, member session) or local to a single Block.

Pattern B: push directly from the Block (try this first)

For interactions that are fully contained within one Block — opening/closing a modal, switching tabs, toggling an accordion — push directly from that Block's <script>.

// src/lib/acdl.ts
export function pushEvent(eventName: string, payload: Record<string, unknown> = {}): void {
  window.adobeDataLayer.push({ event: eventName, ...payload });
}
<!-- example: src/blocks/accordion/index.astro -->
<script>
  import { pushEvent } from '../../lib/acdl';

  document.querySelectorAll('.accordion__item').forEach((item) => {
    item.addEventListener('toggle', () => {
      const label = item.querySelector('.accordion__title')?.textContent ?? '';
      pushEvent('accordion_toggle', { blockName: 'accordion', label, state: item.open ? 'open' : 'closed' });
    });
  });
</script>

The Blocks currently using this pattern are accordion (accordion_toggle), modal (modal_toggle — open on trigger click, closed on the native close event; since close fires uniformly whether the dialog was closed via its close button, a backdrop click, or the Escape key, there's no need for a separate pushEvent per path), and tabs (tabs_switch, fired on every tab switch).

There's no enforced naming format for events, but <blockname>_<action> (e.g. accordion_toggle) is recommended. This keeps changes local to the new Block — no central file needs editing — matching the "just add a directory" philosophy behind Blocks.

Pattern A: the central bridge (cross-cutting state)

Changes that aren't scoped to a single Block — cart state, a member logging in or out, page-arrival lifecycle events (view_item/start-checkout/purchase-complete) — get funneled through one central location instead. The commerce module's acdl-bridge.ts and the member module's acdl-bridge.ts are both examples of this.

cart.ts (state management, vendor-agnostic)
   ↓ window.dispatchEvent(new CustomEvent('cart:change', ...))
acdl-bridge.ts (ACDL-specific mapping layer)
   ↓ window.adobeDataLayer.push({ event: 'add-to-cart', ... })
adobe-client-data-layer

The state-management module (cart.ts, member.ts) has no idea ACDL exists — it just dispatches a generic event (cart:change, member:login, etc.). If the site ever switches marketing tools, only the bridge layer needs to change.

A central-bridge module must be explicitly imported on every page where its triggering event can occur. Because this is a static site, all JS is reloaded on every navigation — nothing gets automatically pulled in by the shared layout. (The member module's acdl-bridge.ts is a notable exception: it's loaded from MemberOverlay.astro, which is itself injected into every page when member.enabled is true, so there's effectively no page where the import can be missed.)

Commerce lifecycle events (the commerce module, following the XDM Commerce event design)

Sites using the commerce module fire these events. Across all of them, what's about the commerce feature itself (fire counts, IDs, cart info, etc.) lives under the commerce key, and the product info at that moment lives in the productListItems array (XDM Commerce's Product List Items format).

  • view_item — on PDP page load; pushed directly by the PDP's init script
  • add-to-cart — on adding to cart / increasing quantity; pushed by commerce's acdl-bridge.ts
  • remove-from-cart — on removing from cart / decreasing quantity; pushed by commerce's acdl-bridge.ts
  • start-checkout — on loading the checkout page (/commerce/cart/checkout); pushed directly by that page's init script
  • purchase-complete — on loading the order-complete page (/commerce/order); pushed directly by that page's init script

Example view_item payload:

window.adobeDataLayer.push({
  event: 'view_item',
  commerce: {
    productViews: { value: 1, id: string },           // id is unique per event
  },
  productListItems: [{ SKU, name, quantity: 1, priceTotal, currencyCode, productImageUrl }],
});

Example add-to-cart payload:

window.adobeDataLayer.push({
  event: 'add-to-cart',
  commerce: {
    productListAdds: { value: 1, id: string },       // id is unique per event
    cart: { cartID: string, cartSource: 'product_detail' },
  },
  productListItems: [
    { SKU, name, quantity, priceTotal, currencyCode, productImageUrl, productAddMethod: 'add_to_cart_button' },
  ],
});

priceTotal is not a unit price — it's the total for that line item (unit price × quantity). What quantity means depends on the event:

  • view_item — always 1 (a view has no notion of quantity)
  • add-to-cart/remove-from-cart — the quantity just added/removed
  • start-checkout — the quantity currently in the cart (all cart items go into productListItems)
  • purchase-complete — the quantity ordered (every item in the order goes into productListItems — this array shape covers single- and multi-item orders alike)

commerce.cart.cartID is the cart's unique identifier (cart.ts issues it with crypto.randomUUID() and reissues a new one whenever the cart empties out, i.e. on order confirmation). cartSource (only on add-to-cart) records where the addition happened; it's always product_detail since only PDP-initiated additions are supported.

Example purchase-complete payload:

window.adobeDataLayer.push({
  event: 'purchase-complete',
  commerce: {
    purchases: { value: 1, id: `purchase-event-${purchaseId}` },  // de-dup key for the sent event
    order: {
      purchaseID: string,       // the order ID issued by the store (business key) — kept separate from commerce.purchases.id
      currencyCode: string,
      priceTotal: number,       // order-level total; should match the sum of line-item priceTotal values
      payments: [{ paymentAmount: number, paymentType: string, currencyCode: string, transactionID: string }],
    },
    cart: { cartID: string },
  },
  productListItems: [{ SKU, name, quantity, priceTotal, currencyCode }],
});

purchase-complete guards against reload-triggered duplicate sends:

  1. It only fires on the order-confirmed page (/commerce/order)
  2. purchaseID is the order-system-issued ID captured at the moment the order was confirmed on /commerce/confirmation (never regenerated on page load)
  3. A per-purchaseID "already sent" flag is kept in sessionStorage, so a reload or back/forward navigation on order.astro won't push the same purchaseID twice

See The commerce module for more.

The user namespace (the member module)

Sites using the member feature (member.enabled in site.config.ts) push the currently logged-in member's info under the user namespace. This is handled by src/modules/member/lib/acdl-bridge.ts, which subscribes to the vendor-agnostic member:login/member:logout custom events fired by member.ts and converts them.

Like page loaded, user pushes carry an event-keyed trigger too. What user (identity) means gets split into two separate concerns, each with its own event name: an operation log and a state-set notice.

  • Login operation (on member:login) — pushes both user_login (operation log) and set_identity (state-set notice) back to back, both carrying the same user object:

    window.adobeDataLayer.push({ event: 'user_login', user: { id, ...attributes, email, emailSha256 } });
    window.adobeDataLayer.push({ event: 'set_identity', user: { id, ...attributes, email, emailSha256 } });
    

    email is a required field at registration, so it's always present; whatever attributes were set on the member issuance page also get spread directly into the user object. emailSha256 is a SHA-256 hex digest of the email after normalizing it (trimmed, lowercased), for downstream integrations that can't accept a raw email address.

  • State restoration on page load (below) — pushes set_identity only. This isn't an actual login operation (it's just reflecting an existing session into the new page's data layer), so user_login isn't fired.

  • Logout operation (on member:logout) — pushes user_logout only (user: null). No identity was set, so set_identity isn't fired:

    window.adobeDataLayer.push({ event: 'user_logout', user: null });
    
  • On page load when nobody is logged in — window.adobeDataLayer.push({ user: null }) (no event key)

In every case, user itself is still merged into state, so tag managers can read "who, if anyone, is currently logged in" as state while also using user_login/user_logout/set_identity as triggers.

Because window.adobeDataLayer is scoped per page (a full navigation clears it), the bridge re-pushes the current user state at load time whenever someone is already logged in — the same reasoning that applies to the page context (this is the "state restoration on page load" case above). Without this, navigating from, say, a quick-login on the member issuance page straight to another page would leave that new page's adobeDataLayer without the user push made on the previous page.

The member module's acdl-bridge.ts is loaded from MemberOverlay.astro, which is injected into every page whenever member.enabled is true. That means login/logout reaches ACDL reliably no matter where it originates — the member issuance page, the login page, or the overlay itself — a case where the usual "don't forget to import the bridge" risk of the central-bridge pattern structurally doesn't apply. See Member features (the login sandbox) for the full picture of the member system.

Injecting marketing tags (the tag manager itself)

The tag manager's own script (Adobe Launch, GTM, etc.) is configured through site.config.ts rather than edited directly into the code. Base.astro emits this configuration after ACDL initialization but before any other script, guaranteeing window.adobeDataLayer already exists by the time the tag manager loads.

Adding a new event

  1. Decide whether it fits pattern A or pattern B.
  2. For pattern B, add a pushEvent() call to the relevant Block's <script>. For pattern A, add a subscriber to the central bridge (and don't forget to wire up the import).
  3. Confirm the push actually happens, e.g. via the browser devtools.

Verifying pushes

When verifying pushes with a tool like Playwright, register a listener before the ACDL library loads, using a "function push" (ACDL recognizes any pushed item where typeof item === 'function' as an FCTN item and invokes it with the data-layer instance once the library initializes). Overwriting window.adobeDataLayer.push with your own function doesn't work — that override is lost when the library initializes.

await page.addInitScript(() => {
  window.adobeDataLayer = window.adobeDataLayer || [];
  window.__acdlEvents = [];
  window.adobeDataLayer.push((dataLayer) => {
    dataLayer.addEventListener('adobeDataLayer:change', (event) => {
      window.__acdlEvents.push(event);
    });
  });
});

Future extensions

CMP consent state can be added the same way, as a new consent namespace pushed alongside the existing ones — no change is needed to the existing page/user/event structure. That schema hasn't been designed yet.