@playpilot/tpi 8.9.2 → 8.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/events.md CHANGED
@@ -26,6 +26,10 @@ Events related to titles share an additional set of data (referred to below as `
26
26
 
27
27
  Events may have additional data in their payload.
28
28
 
29
+ If user tracking is allowed the following is added to all events:
30
+ - `user_id`: A unique id per user that is saved in localStorage
31
+ - `region`: Region derived from the users ip
32
+
29
33
  ### General
30
34
  Event | Action | Info | Payload
31
35
  --- | --- | --- | ---
@@ -44,8 +48,8 @@ Event | Action | Info | Payload
44
48
  `ali_title_modal_save_click` | _Currently unused, there is no save functionality._ | | `Title`
45
49
  `ali_participant_modal_view` | _Fires any time a title modal is viewed_ | The title modal opens when viewing a participant modal both on desktop and mobile | `participant` (name of the participant)
46
50
  `ali_participant_modal_close` | _Fires any time a title modal is closed_ | | `participant` (name of the participant) `time_spent` (time between modal_view and modal_close milliseconds)
47
- 'ali_similar_title_click' | _Fires any time a similar titles rail item is clicked_ | Title | `title_source` (original name of the title the rail item was clicked in)
48
- 'ali_participant_click' | _Fires any time a participants rail item is clicked_ | null | `title_source` (original name of the title the rail item was clicked in), `participant` (name of the clicked participant)
51
+ `ali_similar_title_click` | _Fires any time a similar titles rail item is clicked_ | Title | `title_source` (original name of the title the rail item was clicked in)
52
+ `ali_participant_click` | _Fires any time a participants rail item is clicked_ | null | `title_source` (original name of the title the rail item was clicked in), `participant` (name of the clicked participant)
49
53
 
50
54
  ### Popover
51
55
  Event | Action | Info | Payload
@@ -109,12 +113,14 @@ Event | Action | Info | Payload
109
113
  ### Explore
110
114
  Event | Action | Info | Payload
111
115
  --- | --- | --- | ---
112
- `explore_page_view` | _Fires any time explore is loaded_ | This always fires when explore is first shown, regardless of if anything loads | | -
113
- `explore_title_click` | _Fires any time a title is clicked on explore_ | Includes both desktop and mobile titles | `Title`
114
- `explore_show_more` | _Fires when "show more" is clicked on explore_ | | `page` (The current page number) |
115
- `explore_set_filter` | _Fires when any filter is set on the explore page._ | This includes any filter type, including sorting. | `key` (the name of the current filter being set), `value`, `total_filter` (all currently active filter items)
116
- `explore_search` | _Fires any time the user searches for something_ | | `query`
116
+ `venus_page_view` | _Fires any time explore is loaded_ | This always fires when explore is first shown, regardless of if anything loads | | -
117
+ `venus_title_click` | _Fires any time a title is clicked on explore_ | Includes both desktop and mobile titles | `Title`
118
+ `venus_show_more` | _Fires when "show more" is clicked on explore_ | | `page` (The current page number) |
119
+ `venus_set_filter` | _Fires when any filter is set on the explore page._ | This includes any filter type, including sorting. | `key` (the name of the current filter being set), `value`, `total_filter` (all currently active filter items)
120
+ `venus_search` | _Fires any time the user searches for something_ | | `query`
117
121
  `venus_title_rail_modal_view` | _Fires any time a title is clicked in a rail, opening the rail modal_ | | `rail` (heading key of the rail)
118
122
  `venus_title_rail_expand_click` | _Fires any time a title is expanded in a rail directly via a click_ | Does not fire when a title opens automatically on load or navigate | `Title`, `rail` (heading key of the rail)
119
123
  `venus_title_rail_set_index` | _Fires when navigating in the titles rail modal, either via arrows, swipe, or clicking titles_ | | `Title`, `index` (index of the new position of the slider)
120
124
  `venus_navigate` | _Fires when navigating on the explore page_ | Does not fire on initial load | `route` (the key of the given route)
125
+ `venus_any_click` | _Fires on any click anywhere_ | | `selector` (parent > child selector of the clicked element), `text` (direct text content of the clicked element, limited to 30 characters)
126
+ `venus_scroll_distance` | _Fires on increments of scroll distance_ | 25%, 50%, 75%, 100% | `scroll_percentage` (percentage of the explore element the user has seen)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "8.9.2",
3
+ "version": "8.10.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -77,6 +77,8 @@ export const TrackingEvent = {
77
77
  ExploreTitleRailExpandClick: 'venus_title_rail_expand_click',
78
78
  ExploreTitleRailSetIndex: 'venus_title_rail_set_index',
79
79
  ExploreNavigate: 'venus_navigate',
80
+ ExploreAnyClick: 'venus_any_click',
81
+ ExploreScrollDistance: 'venus_scroll_distance',
80
82
  } as const
81
83
 
82
84
  export const MetaEvent = {
@@ -39,6 +39,7 @@ export async function track(event: string, title: TitleData | null = null, paylo
39
39
 
40
40
  if (isUserTrackingAllowed()) {
41
41
  payload.user_id = getUserId()
42
+ payload.region = window.PlayPilotLinkInjections.region
42
43
  }
43
44
 
44
45
  payload.version = __SCRIPT_VERSION__
package/src/lib/user.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { hasConsentedTo } from './consent'
1
2
  import { generateRandomHash } from './hash'
2
3
 
3
4
  export const localStorageUserKey = 'playpilot_user_id'
@@ -17,5 +18,5 @@ export function getUserId(): string | null {
17
18
  }
18
19
 
19
20
  export function isUserTrackingAllowed(): boolean {
20
- return !!window.PlayPilotLinkInjections?.config?.allow_user_id_tracking
21
+ return !!window.PlayPilotLinkInjections?.config?.allow_user_id_tracking && hasConsentedTo('tracking')
21
22
  }
@@ -9,6 +9,8 @@
9
9
  import { onMount, type Snippet } from 'svelte'
10
10
  import Search from './Filter/Search.svelte'
11
11
  import Filter from './Filter/Filter.svelte'
12
+ import TrackAnyClick from '../TrackAnyClick.svelte'
13
+ import TrackScrollDistance from '../TrackScrollDistance.svelte'
12
14
 
13
15
  interface Props {
14
16
  navigate?: (key: string) => void
@@ -70,6 +72,9 @@
70
72
  {@render children()}
71
73
  </div>
72
74
 
75
+ <TrackAnyClick />
76
+ <TrackScrollDistance {element} />
77
+
73
78
  <style lang="scss">
74
79
  .explore {
75
80
  background: theme(explore-background, light);
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import { TrackingEvent } from '$lib/enums/TrackingEvent'
3
+ import { track } from '$lib/tracking'
4
+
5
+ interface Props {
6
+ trackingEvent?: string
7
+ }
8
+
9
+ const { trackingEvent = TrackingEvent.ExploreAnyClick }: Props = $props()
10
+
11
+ function onclick(event: MouseEvent): void {
12
+ const target = event.target as Element
13
+ const parent = target.parentElement as Element
14
+
15
+ if (target === document.body) return
16
+
17
+ const parentSelector = `${parent?.nodeName.toLowerCase()}${getClasslistAsString(parent)}`
18
+ const targetSelector = `${target?.nodeName.toLowerCase()}${getClasslistAsString(target)}`
19
+ const selector = `${parentSelector} > ${targetSelector}`
20
+
21
+ const hasDirectTextContent = Array.from(target.childNodes).some(node => node.nodeName === '#text' && !!node.nodeValue?.trim())
22
+ const text = hasDirectTextContent ? target.textContent.slice(0, 30) : ''
23
+
24
+ track(trackingEvent, null, { selector, text })
25
+ }
26
+
27
+ function getClasslistAsString(element: Element): string {
28
+ if (!element) return ''
29
+
30
+ const classnames = Array.from(element.classList).filter(classname => !classname.startsWith('s-'))
31
+
32
+ if (!classnames.length) return ''
33
+
34
+ return '.' + classnames.join('.')
35
+ }
36
+ </script>
37
+
38
+ <svelte:window {onclick} />
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import { TrackingEvent } from '$lib/enums/TrackingEvent'
3
+ import { track } from '$lib/tracking'
4
+
5
+ interface Props {
6
+ element: Element
7
+ trackingEvent?: string
8
+ }
9
+
10
+ const { element, trackingEvent = TrackingEvent.ExploreScrollDistance }: Props = $props()
11
+
12
+ const visibilityTrackingPercentages: Record<string, boolean> = {
13
+ '25': false,
14
+ '50': false,
15
+ '75': false,
16
+ '100': false,
17
+ }
18
+
19
+ function onscroll(): void {
20
+ const scrollDistance = window.scrollY
21
+ const windowHeight = window.innerHeight
22
+ const elementHeight = element.clientHeight
23
+ const elementBottomDistance = elementHeight + scrollDistance + element.getBoundingClientRect().top
24
+
25
+ const totalVisibilityPercentage = 100 / elementBottomDistance * (scrollDistance + windowHeight)
26
+
27
+ for (const percentage in visibilityTrackingPercentages) {
28
+ if (visibilityTrackingPercentages[percentage]) continue
29
+ if (totalVisibilityPercentage < parseInt(percentage)) continue
30
+
31
+ visibilityTrackingPercentages[percentage] = true
32
+
33
+ track(trackingEvent, null, { scroll_percentage: percentage + '%' })
34
+ }
35
+ }
36
+ </script>
37
+
38
+ <svelte:window {onscroll} />
@@ -1,5 +1,10 @@
1
- import { describe, it, expect, beforeEach } from 'vitest'
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
2
  import { getUserId, isUserTrackingAllowed, localStorageUserKey } from '$lib/user'
3
+ import { hasConsentedTo } from '$lib/consent'
4
+
5
+ vi.mock('$lib/consent', () => ({
6
+ hasConsentedTo: vi.fn(() => true),
7
+ }))
3
8
 
4
9
  describe('user.ts', () => {
5
10
  describe('getUserId', () => {
@@ -10,6 +15,8 @@ describe('user.ts', () => {
10
15
  allow_user_id_tracking: true,
11
16
  },
12
17
  }
18
+
19
+ vi.mocked(hasConsentedTo).mockImplementation(() => true)
13
20
  })
14
21
 
15
22
  it('Should return random hash by default', () => {
@@ -53,5 +60,12 @@ describe('user.ts', () => {
53
60
  it('Should return false if user tracking is not allowed', () => {
54
61
  expect(isUserTrackingAllowed()).toBe(false)
55
62
  })
63
+
64
+ it('Should return false if user tracking is allowed but user has not consented', () => {
65
+ window.PlayPilotLinkInjections.config = { allow_user_id_tracking: true }
66
+ vi.mocked(hasConsentedTo).mockImplementationOnce(() => false)
67
+
68
+ expect(isUserTrackingAllowed()).toBe(false)
69
+ })
56
70
  })
57
71
  })
@@ -0,0 +1,107 @@
1
+ import { render, fireEvent } from '@testing-library/svelte'
2
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
3
+ import { track } from '$lib/tracking'
4
+ import { TrackingEvent } from '$lib/enums/TrackingEvent'
5
+ import ClickTracking from '../../../routes/components/TrackAnyClick.svelte'
6
+
7
+ vi.mock('$lib/tracking', () => ({
8
+ track: vi.fn(),
9
+ }))
10
+
11
+ describe('TrackAnyClick.svelte', () => {
12
+ beforeEach(() => {
13
+ document.body.innerHTML = ''
14
+ vi.resetAllMocks()
15
+ })
16
+
17
+ it('Should call track with the correct selector and text content on click', async () => {
18
+ document.body.innerHTML = '<button class="some-button">Some button</button>'
19
+
20
+ const { getByRole } = render(ClickTracking)
21
+
22
+ await fireEvent.click(getByRole('button'))
23
+
24
+ expect(track).toHaveBeenCalledWith(TrackingEvent.ExploreAnyClick, null, { selector: 'body > button.some-button', text: 'Some button' })
25
+ })
26
+
27
+ it('Should not call track when body is clicked', async () => {
28
+ // @ts-ignore
29
+ await fireEvent.click(document.querySelector('body'))
30
+
31
+ expect(track).not.toHaveBeenCalled()
32
+ })
33
+
34
+ it('Should limit text to 30 characters', async () => {
35
+ document.body.innerHTML = '<button class="some-button">This is a very long text that exceeds thirty characters</button>'
36
+
37
+ const { getByRole } = render(ClickTracking)
38
+
39
+ await fireEvent.click(getByRole('button'))
40
+
41
+ expect(track).toHaveBeenCalledWith(
42
+ TrackingEvent.ExploreAnyClick,
43
+ null,
44
+ expect.objectContaining({
45
+ text: 'This is a very long text that ',
46
+ }),
47
+ )
48
+ })
49
+
50
+ it('Should not include text when element has no direct text node', async () => {
51
+ document.body.innerHTML = '<button><span>Some text</span></button>'
52
+
53
+ const { getByRole } = render(ClickTracking)
54
+
55
+ await fireEvent.click(getByRole('button'))
56
+
57
+ expect(track).toHaveBeenCalledWith(
58
+ TrackingEvent.ExploreAnyClick,
59
+ null,
60
+ expect.objectContaining({
61
+ text: '',
62
+ }),
63
+ )
64
+ })
65
+
66
+ it('Should include class names in the selector', async () => {
67
+ document.body.innerHTML = '<div class="some element"><button class="some button">Some text</button></div>'
68
+
69
+ const { getByRole } = render(ClickTracking)
70
+
71
+ await fireEvent.click(getByRole('button'))
72
+
73
+ expect(track).toHaveBeenCalledWith(
74
+ TrackingEvent.ExploreAnyClick,
75
+ null,
76
+ expect.objectContaining({
77
+ selector: 'div.some.element > button.some.button',
78
+ }),
79
+ )
80
+ })
81
+
82
+ it('Should filter out svelte-generated s- classes from selector', async () => {
83
+ document.body.innerHTML = '<div class="some element s-abc"><button class="some button s-123">Some text</button></div>'
84
+
85
+ const { getByRole } = render(ClickTracking)
86
+
87
+ await fireEvent.click(getByRole('button'))
88
+
89
+ expect(track).toHaveBeenCalledWith(
90
+ TrackingEvent.ExploreAnyClick,
91
+ null,
92
+ expect.objectContaining({
93
+ selector: 'div.some.element > button.some.button',
94
+ }),
95
+ )
96
+ })
97
+
98
+ it('Should build selector without classes when element has none', async () => {
99
+ document.body.innerHTML = '<section><p>Some text</p></section>'
100
+
101
+ const { getByRole } = render(ClickTracking)
102
+
103
+ await fireEvent.click(getByRole('paragraph'))
104
+
105
+ expect(track).toHaveBeenCalledWith(TrackingEvent.ExploreAnyClick, null, expect.objectContaining({ selector: 'section > p' }))
106
+ })
107
+ })