Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **TrackShopify:** publish only when Shopify analytics processing is allowed ([#654](https://github.com/studiometa/ui/pull/654))

## [v2.0.0-alpha.0](https://github.com/studiometa/ui/compare/1.11.1..2.0.0-alpha.0) (2026-09-03)

This is the first release of the v2 line. It moves every package onto [`@studiometa/js-toolkit` v4](https://js-toolkit-v4.studiometa.dev/), removes six component families that a newer component already covers, renames three components, merges `LargeText` and `CircularMarquee` into `Marquee`, rewrites `Tabs` on the WAI-ARIA Tabs pattern, redesigns `Cursor` around published CSS hooks, completes the `Carousel` family and gives it an accessibility contract, and changes the shape of every event payload. There is no compatibility layer.
Expand Down
8 changes: 7 additions & 1 deletion packages/docs/.vitepress/reference/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,7 +1105,13 @@ export const referenceCatalog = [
uiSymbol('TrackEvent', '/reference/items/Track/js-api', 'TrackEvent', 'primitive'),
uiSymbol('TrackShopify', '/reference/items/Track/js-api'),
],
capabilities: ['click tracking', 'context', 'custom events', 'impression tracking'],
capabilities: [
'click tracking',
'consent gating',
'context',
'custom events',
'impression tracking',
],
},
{
id: 'transition',
Expand Down
12 changes: 6 additions & 6 deletions packages/docs/reference/items/Track/anatomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ TrackContext data-component="TrackContext" shared, i

## Parts

| Part | Selector | Required | Role |
| ----------------- | ------------------------------- | -------- | ------------------------------------------------------------------------------------------ |
| Tracker | `data-component="Track"` | Yes | Resolves each `data-track:<event>` payload and pushes it to `window.dataLayer`. |
| Tracker (Shopify) | `data-component="TrackShopify"` | Yes\* | Same as `Track`, but publishes through `window.Shopify.analytics.publish`. |
| Context | `data-component="TrackContext"` | Optional | Provides data inherited by every descendant tracker, deep-merged up the ancestor chain. |
| Payload | `data-ref="payload"` | Optional | A `<script>` (or `data-option-payload`) holding data shared by every event on the element. |
| Part | Selector | Required | Role |
| ----------------- | ------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Tracker | `data-component="Track"` | Yes | Resolves each `data-track:<event>` payload and pushes it to `window.dataLayer`. |
| Tracker (Shopify) | `data-component="TrackShopify"` | Yes\* | Same as `Track`, but publishes through `window.Shopify.analytics.publish`, and only when the visitor allows analytics processing. |
| Context | `data-component="TrackContext"` | Optional | Provides data inherited by every descendant tracker, deep-merged up the ancestor chain. |
| Payload | `data-ref="payload"` | Optional | A `<script>` (or `data-option-payload`) holding data shared by every event on the element. |

<small>\* `Track` and `TrackShopify` are alternative providers — pick the one matching your destination. The provider is chosen by the component name, so switching is a one-token change in the markup.</small>

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/reference/items/Track/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ A product page that sets shared context once, tracks a product impression, and p
</section>
```

The `add_to_cart` click publishes:
The `add_to_cart` click publishes, when the visitor [allows analytics processing](./js-api.md#consent):

```js
Shopify.analytics.publish('my_app:add_to_cart', {
Expand Down
10 changes: 8 additions & 2 deletions packages/docs/reference/items/Track/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ badges: [JS]
The `Track` components provide declarative analytics tracking, defined entirely in HTML/Twig/Liquid attributes — no custom JavaScript required. A provider-agnostic core (`AbstractTrack`) is shipped as two ready-to-use variants selected by component name:

- **`Track`** pushes the resolved payload to `window.dataLayer` (GTM / GA4).
- **`TrackShopify`** publishes it through `window.Shopify.analytics.publish`.
- **`TrackShopify`** publishes it through `window.Shopify.analytics.publish`, only when the visitor allows analytics processing.

`TrackContext` lets you factor shared data out of individual events and inherit it from ancestors.

Expand Down Expand Up @@ -168,6 +168,12 @@ The provider is chosen by the component name, so switching destinations is a one

`TrackShopify` uses the payload's `event` value as the published event name. Shopify recommends namespacing custom events (e.g. `my_app:add_to_cart`). To send to another destination, extend `Track` and override its [`dispatch()`](./js-api.md#providers) method.

## Shopify analytics consent

`TrackShopify` is consent-safe by default. Before each publish it calls `window.Shopify.customerPrivacy.analyticsProcessingAllowed()` and publishes only when that call returns `true`. The check runs on every dispatch, so a consent change applies to the next event without a remount. A denied consent and an absent Customer Privacy API both drop the event — nothing is queued, and each drop reports a diagnostic so the missing event is diagnosable.

Shopify's Web Pixels Manager gates App Pixels at load time, but it hands every published event to custom pixels, which are expected to apply their own consent logic. `Track` is not gated the same way: it appends to `window.dataLayer`, which transmits nothing by itself, so consent there is applied by the tag manager or CMP reading that array. See the [JavaScript API](./js-api.md#consent) for the full behaviour and the diagnostic codes.

::: warning
Payloads are serialised into the DOM (attribute or `<script>`), so they are visible in the page source. Never put personal data (emails, names, user IDs) in a tracking payload — resolve sensitive values at runtime via a `CustomEvent` and `$detail.*` instead, and gate `TrackShopify` on the visitor's analytics consent where required.
Payloads are serialised into the DOM (attribute or `<script>`), so they are visible in the page source. Never put personal data (emails, names, user IDs) in a tracking payload — resolve sensitive values at runtime via a `CustomEvent` and `$detail.*` instead. The components add no customer identifier of their own.
:::
37 changes: 31 additions & 6 deletions packages/docs/reference/items/Track/js-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ title: Track JS API

## Components

| Component | Destination |
| -------------- | ---------------------------------------------------------- |
| `Track` | `window.dataLayer.push(payload)` (GTM / GA4) |
| `TrackShopify` | `window.Shopify.analytics.publish(payload.event, payload)` |
| `TrackContext` | Provides context inherited by descendant `Track`s |
| Component | Destination |
| -------------- | ---------------------------------------------------------------------------------------- |
| `Track` | `window.dataLayer.push(payload)` (GTM / GA4) |
| `TrackShopify` | `window.Shopify.analytics.publish(payload.event, payload)`, gated on [consent](#consent) |
| `TrackContext` | Provides context inherited by descendant `Track`s |

`Track` and `TrackShopify` share a common, provider-agnostic base (`AbstractTrack`) and differ only in their [`dispatch()`](#providers) method.

Expand Down Expand Up @@ -136,7 +136,7 @@ Context is resolved by walking up the ancestor chain (`$closest('TrackContext')`
`dispatch(payload, event?)` is the seam that sends the resolved payload to its destination:

- **`Track`** — `window.dataLayer.push(payload)`.
- **`TrackShopify`** — `window.Shopify.analytics.publish(payload.event, payload)`, guarded when the API is unavailable or the payload has no `event` name. Namespace Shopify event names (e.g. `my_app:add_to_cart`).
- **`TrackShopify`** — `window.Shopify.analytics.publish(payload.event, payload)`, guarded when the API is unavailable, when the payload has no `event` name, and when [analytics consent](#consent) is not granted. Namespace Shopify event names (e.g. `my_app:add_to_cart`).

To send to any other destination (Segment, a custom endpoint, …), extend `Track` and override `dispatch()`:

Expand All @@ -151,3 +151,28 @@ export class TrackSegment extends Track {
}
}
```

## Consent

`TrackShopify` is consent-safe by default. Before each publish it calls `window.Shopify.customerPrivacy.analyticsProcessingAllowed()` and publishes only when that call returns `true`.

- The check runs on every dispatch, not once at mount. `window.Shopify` is read fresh each time, so a consent change applies to the next event without a remount.
- A denied consent drops the event. Nothing is queued, and a dropped event is never replayed once consent is granted.
- An absent Customer Privacy API drops the event as well. Shopify loads that API asynchronously through `loadFeatures()`, so it is legitimately undefined early in the page's life.
- Every drop reports a diagnostic, so a missing event is diagnosable instead of silent.

The event name is validated before consent is read. A declaration without an `event` name is wrong for every visitor, so it is reported even when consent is denied.

This gate covers what the platform does not. Shopify's Web Pixels Manager gates App Pixels at load time: it loads a pixel only when the visitor has granted every consent that pixel declares as required. Custom pixels receive every published event and are expected to apply their own consent logic, which is what `TrackShopify` does for you.

`Track` is not gated the same way, on purpose. It appends to `window.dataLayer`, an array in the page that transmits nothing by itself. The tag manager or CMP reading that array decides what leaves the browser, so consent belongs at the tag level there.

## Diagnostics

| Code | Reported when |
| ----------------------------------- | ----------------------------------------------------------------------------- |
| `track.invalid-json` | An attribute value, a `payload` ref or a `context` ref holds malformed JSON |
| `track.missing-event-name` | `TrackShopify` resolved a payload with no string `event` key |
| `track.shopify-unavailable` | `window.Shopify.analytics.publish` is not a function |
| `track.shopify-privacy-unavailable` | `window.Shopify.customerPrivacy.analyticsProcessingAllowed` is not a function |
| `track.shopify-consent-denied` | `analyticsProcessingAllowed()` returned `false` |
55 changes: 2 additions & 53 deletions packages/tests/Track/Track.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getInstance, registerComponents } from '@studiometa/js-toolkit';
import { captureDiagnostics, mount, resetDom, settle, waitFor } from '@studiometa/js-toolkit/test';
import { Track } from '#private/Track/Track.js';
import { TrackContext } from '#private/Track/TrackContext.js';
import { TrackShopify } from '#private/Track/TrackShopify.js';

registerComponents(Track, TrackContext, TrackShopify);
registerComponents(Track, TrackContext);

/**
* Record diagnostics and cancel their default sink.
Expand Down Expand Up @@ -629,53 +628,3 @@ describe('the intersection service under load', () => {
expect(pushes()).toHaveLength(CARDS);
});
});

describe('TrackShopify — the dispatch seam', () => {
it('publishes through window.Shopify.analytics.publish', async () => {
const publish = vi.fn();
window.Shopify = { analytics: { publish } };

const root = await mount(`
<div data-component="TrackContext" data-option-context='{"page_type": "product"}'>
<button data-component="TrackShopify" data-track:click='{"event": "add_to_cart", "id": "1"}'></button>
</div>
`);
root.querySelector('button')?.click();

expect(publish).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledWith('add_to_cart', {
page_type: 'product',
event: 'add_to_cart',
id: '1',
});
expect(pushes()).toHaveLength(0);
delete window.Shopify;
});

it('publishes nothing without a string `event` name', async () => {
const publish = vi.fn();
window.Shopify = { analytics: { publish } };
const log = captureDiagnostics();

const root = await mount(
`<button data-component="TrackShopify" data-track:click='{"id": "1"}'></button>`,
);
root.querySelector('button')?.click();

expect(publish).not.toHaveBeenCalled();
expect(log.codes).toContain('track.missing-event-name');
log.stop();
delete window.Shopify;
});

it('does not throw when the Shopify analytics API is absent', async () => {
const log = captureDiagnostics();
const root = await mount(
`<button data-component="TrackShopify" data-track:click='{"event": "x"}'></button>`,
);

expect(() => root.querySelector('button')?.click()).not.toThrow();
expect(log.codes).toContain('track.shopify-unavailable');
log.stop();
});
});
Loading
Loading