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
49 changes: 49 additions & 0 deletions packages/docs/components/Track/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
badges: [JS]
---

# Track <Badges :texts="$frontmatter.badges" />

Use the `Track` component to publish [Shopify analytics events](https://shopify.dev/docs/api/web-pixels-api/emitting-data) when a section enters the viewport or is clicked.

## Table of content

- [JS API](./js-api.md)

## Usage

Register the component in your JavaScript app and use it in your templates. The component will publish a viewed event the first time its root element enters the viewport, and a clicked event on every click, with the payload defined in the `<script data-ref="payload">` element.

```js {1,2,4}
import { registerComponent } from '@studiometa/js-toolkit';
import { Track } from '@studiometa/ui';

registerComponent(Track);
```

```liquid
<div data-component="Track">
<script data-ref="payload" type="application/json">
{
"name": "bloc-name",
"template": {{ template.name | json }}
}
</script>
<a href="#">Click me</a>
</div>
```

The published events default to `bento_section_viewed` and `bento_section_clicked` and can be configured with the [`viewedEvent` and `clickedEvent` options](./js-api.md#options):

```liquid
<div
data-component="Track"
data-option-viewed-event="product_card_viewed"
data-option-clicked-event="product_card_clicked">
...
</div>
```

::: warning Shopify only
This component relies on the `Shopify.analytics.publish` API which is only available on Shopify storefronts. A warning will be displayed in debug mode when the API is not available.
:::
72 changes: 72 additions & 0 deletions packages/docs/components/Track/js-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
title: Track JS API
outline: deep
---

# JS API

The `Track` class extends the [`Base` class](https://js-toolkit.studiometa.dev/api/) with the [`withIntersectionObserver` decorator](https://js-toolkit.studiometa.dev/api/decorators/withIntersectionObserver.html).

## Refs

### `payload`

A `<script type="application/json">` element whose content is parsed and sent as the payload of the published events. If the JSON is invalid, an empty payload is used and a warning is displayed in debug mode.

```html
<script data-ref="payload" type="application/json">
{ "name": "bloc-name" }
</script>
```

## Options

### `viewedEvent`

- Type: `string`
- Default: `'bento_section_viewed'`

The name of the event published the first time the root element enters the viewport.

### `clickedEvent`

- Type: `string`
- Default: `'bento_section_clicked'`

The name of the event published when the root element is clicked. In addition to the payload, the published data contains:

- `url`: the current page URL
- `target`: the lowercase tag name of the clicked element
- `target_content`: the text content of the clicked element, trimmed and truncated to 100 characters

### `intersectionObserver`

- Type: `IntersectionObserverInit`
- Default: `{ threshold: 0 }`

Options for the `IntersectionObserver` instance used to detect the visibility of the root element.

## Properties

### `payload`

- Type: `Record<string, unknown>`

The payload parsed from the [`payload` ref](#payload).

### `hasBeenViewed`

- Type: `boolean`

Whether the viewed event has already been published.

## Methods

### `publish`

Publish an event with the `Shopify.analytics.publish` API. A warning is displayed in debug mode when the API is not available.

**Parameters**

- `name` (`string`): the name of the event
- `payload` (`Record<string, unknown>`): the data sent with the event
125 changes: 125 additions & 0 deletions packages/tests/Track/Track.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
import { Track } from '@studiometa/ui';
import {
h,
destroy,
intersectionObserverAfterEachCallback,
intersectionObserverBeforeAllCallback,
mockIsIntersecting,
mount,
} from '#test-utils';

beforeAll(() => {
intersectionObserverBeforeAllCallback();
});

const publish = vi.fn();

beforeEach(() => {
// @ts-expect-error the `Shopify` global is only available on Shopify storefronts.
window.Shopify = { analytics: { publish } };
});

afterEach(() => {
intersectionObserverAfterEachCallback();
// @ts-expect-error the `Shopify` global is only available on Shopify storefronts.
delete window.Shopify;
publish.mockClear();
});

async function getContext({
payload = JSON.stringify({ name: 'bloc-name', template: 'index' }),
attributes = {},
}: { payload?: string; attributes?: Record<string, string> } = {}) {
const script = h('script', { dataRef: 'payload', type: 'application/json' }, [payload]);
const link = h('a', { href: '#' }, ['Click me']);
const root = h('div', attributes, [script, link]);
const track = new Track(root);
await mount(track);

return { root, script, link, track };
}

describe('The Track component', () => {
it('should publish the viewed event with the payload when intersecting for the first time only', async () => {
const { root, track } = await getContext();

await mockIsIntersecting(root, false);
expect(publish).not.toHaveBeenCalled();

await mockIsIntersecting(root, true);
expect(publish).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledWith('bento_section_viewed', {
name: 'bloc-name',
template: 'index',
});

await mockIsIntersecting(root, false);
await mockIsIntersecting(root, true);
expect(publish).toHaveBeenCalledTimes(1);

await destroy(track);
});

it('should publish the clicked event with the payload and target infos on click', async () => {
const { link, track } = await getContext();

link.click();

expect(publish).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledWith('bento_section_clicked', {
name: 'bloc-name',
template: 'index',
url: window.location.href,
target: 'a',
target_content: 'Click me',
});

await destroy(track);
});

it('should use the configured event names', async () => {
const { root, link, track } = await getContext({
attributes: {
dataOptionViewedEvent: 'custom_viewed',
dataOptionClickedEvent: 'custom_clicked',
},
});

await mockIsIntersecting(root, true);
link.click();

expect(publish).toHaveBeenNthCalledWith(1, 'custom_viewed', expect.any(Object));
expect(publish).toHaveBeenNthCalledWith(2, 'custom_clicked', expect.any(Object));

await destroy(track);
});

it('should warn and publish an empty payload when the payload JSON is invalid', async () => {
const { root, track } = await getContext({ payload: '{ invalid json' });
const warn = vi.fn();
vi.spyOn(track, '$warn', 'get').mockReturnValue(warn);

await mockIsIntersecting(root, true);

expect(warn).toHaveBeenCalledWith('Invalid payload JSON');
expect(publish).toHaveBeenCalledWith('bento_section_viewed', {});

await destroy(track);
});

it('should warn and not fail when the Shopify analytics API is not available', async () => {
const { root, track } = await getContext();
const warn = vi.fn();
vi.spyOn(track, '$warn', 'get').mockReturnValue(warn);
// @ts-expect-error the `Shopify` global is only available on Shopify storefronts.
delete window.Shopify;

await mockIsIntersecting(root, true);

expect(publish).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith('The `Shopify.analytics.publish` API is not available');

await destroy(track);
});
});
1 change: 1 addition & 0 deletions packages/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ test('components exports', () => {
"Sticky",
"Tabs",
"Target",
"Track",
"Transition",
"animationScrollWithEase",
"withDeprecation",
Expand Down
99 changes: 99 additions & 0 deletions packages/ui/Track/Track.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Base, withIntersectionObserver } from '@studiometa/js-toolkit';
import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit';

export interface TrackProps extends BaseProps {
$refs: {
payload: HTMLScriptElement;
};
$options: {
viewedEvent: string;
clickedEvent: string;
};
}

/**
* Track class.
*
* Publish Shopify analytics events when the root element enters the viewport
* for the first time or when it is clicked.
* @link https://ui.studiometa.dev/components/Track/
*/
export class Track<T extends BaseProps = BaseProps> extends withIntersectionObserver<Base>(Base, {
threshold: 0,
})<T & TrackProps> {
/**
* Config.
*/
static config: BaseConfig = {
name: 'Track',
refs: ['payload'],
options: {
viewedEvent: {
type: String,
default: 'bento_section_viewed',
},
clickedEvent: {
type: String,
default: 'bento_section_clicked',
},
},
};

/**
* Whether the viewed event has already been published.
*/
hasBeenViewed = false;

/**
* Payload parsed from the `<script data-ref="payload">` element.
*/
get payload(): Record<string, unknown> {
try {
return JSON.parse(this.$refs.payload.textContent);
} catch {
this.$warn('Invalid payload JSON');
return {};
}
}

/**
* Publish an event with the Shopify analytics API.
*/
publish(name: string, payload: Record<string, unknown>) {
// @ts-expect-error the `Shopify` global is only available on Shopify storefronts.
const shopify = window.Shopify;

if (typeof shopify?.analytics?.publish !== 'function') {
this.$warn('The `Shopify.analytics.publish` API is not available');
return;
}

shopify.analytics.publish(name, payload);
}

/**
* Publish the viewed event the first time the root element enters the viewport.
*/
intersected([entry]: IntersectionObserverEntry[]) {
if (!entry.isIntersecting || this.hasBeenViewed) {
return;
}

this.hasBeenViewed = true;
this.publish(this.$options.viewedEvent, { ...this.payload });
}

/**
* Publish the clicked event with information on the click target.
*/
onClick({ event }: { event: MouseEvent }) {
const target = event.target as HTMLElement;

this.publish(this.$options.clickedEvent, {
...this.payload,
url: window.location.href,
target: target.tagName.toLowerCase(),
target_content: (target.textContent ?? '').trim().slice(0, 100),
});
}
}
1 change: 1 addition & 0 deletions packages/ui/Track/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Track.js';
1 change: 1 addition & 0 deletions packages/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ export * from './Sentinel/index.js';
export * from './Slider/index.js';
export * from './Sticky/index.js';
export * from './Tabs/index.js';
export * from './Track/index.js';
export * from './Transition/index.js';
Loading