@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/dist/editorial.mount.js +7 -7
- package/dist/link-injections.js +1 -1
- package/dist/mount.js +5 -5
- package/events.md +13 -7
- package/package.json +1 -1
- package/src/lib/enums/TrackingEvent.ts +2 -0
- package/src/lib/tracking.ts +1 -0
- package/src/lib/user.ts +2 -1
- package/src/routes/components/Explore/ExploreLayout.svelte +5 -0
- package/src/routes/components/TrackAnyClick.svelte +38 -0
- package/src/routes/components/TrackScrollDistance.svelte +38 -0
- package/src/tests/lib/user.test.js +15 -1
- package/src/tests/routes/components/TrackAnyClick.test.js +107 -0
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
|
-
|
|
48
|
-
|
|
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
|
-
`
|
|
113
|
-
`
|
|
114
|
-
`
|
|
115
|
-
`
|
|
116
|
-
`
|
|
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
|
@@ -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 = {
|
package/src/lib/tracking.ts
CHANGED
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
|
+
})
|