diff --git a/packages/docs/components/Track/index.md b/packages/docs/components/Track/index.md new file mode 100644 index 00000000..a9645dad --- /dev/null +++ b/packages/docs/components/Track/index.md @@ -0,0 +1,49 @@ +--- +badges: [JS] +--- + +# Track + +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 ` + Click me + +``` + +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 +
+ ... +
+``` + +::: 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. +::: diff --git a/packages/docs/components/Track/js-api.md b/packages/docs/components/Track/js-api.md new file mode 100644 index 00000000..b4a1b409 --- /dev/null +++ b/packages/docs/components/Track/js-api.md @@ -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 ` +``` + +## 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` + +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`): the data sent with the event diff --git a/packages/tests/Track/Track.spec.ts b/packages/tests/Track/Track.spec.ts new file mode 100644 index 00000000..becddd1a --- /dev/null +++ b/packages/tests/Track/Track.spec.ts @@ -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 } = {}) { + 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); + }); +}); diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts index 151da8b6..01771274 100644 --- a/packages/tests/index.spec.ts +++ b/packages/tests/index.spec.ts @@ -64,6 +64,7 @@ test('components exports', () => { "Sticky", "Tabs", "Target", + "Track", "Transition", "animationScrollWithEase", "withDeprecation", diff --git a/packages/ui/Track/Track.ts b/packages/ui/Track/Track.ts new file mode 100644 index 00000000..7ad157ec --- /dev/null +++ b/packages/ui/Track/Track.ts @@ -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 extends withIntersectionObserver(Base, { + threshold: 0, +}) { + /** + * 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 `