@playpilot/tpi 8.9.3 → 8.10.1
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 +2 -0
- package/package.json +1 -1
- package/src/lib/enums/TrackingEvent.ts +2 -0
- package/src/routes/components/Explore/ExploreLayout.svelte +5 -0
- package/src/routes/components/TrackAnyClick.svelte +51 -0
- package/src/routes/components/TrackScrollDistance.svelte +38 -0
- package/src/routes/components/YouTubeEmbed.svelte +1 -1
- package/src/tests/routes/components/TrackAnyClick.test.js +243 -0
package/events.md
CHANGED
|
@@ -122,3 +122,5 @@ Event | Action | Info | Payload
|
|
|
122
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)
|
|
123
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)
|
|
124
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 = {
|
|
@@ -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,51 @@
|
|
|
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 closestButtonOrLink = target.closest('a, button')
|
|
22
|
+
const directTextNode =
|
|
23
|
+
elementHasDirectTextContent(target) ||
|
|
24
|
+
elementHasDirectTextContent(parent) ||
|
|
25
|
+
(closestButtonOrLink && elementHasDirectTextContent(closestButtonOrLink))
|
|
26
|
+
|
|
27
|
+
const textContent = directTextNode ? directTextNode.textContent?.slice(0, 30) : ''
|
|
28
|
+
const label = target.getAttribute('aria-label') || closestButtonOrLink?.getAttribute('aria-label') || parent?.getAttribute('aria-label')
|
|
29
|
+
const placeholder = (target as HTMLInputElement).placeholder
|
|
30
|
+
const alt = (target as HTMLImageElement).alt
|
|
31
|
+
const text = (textContent || label || placeholder || alt || '').trim()
|
|
32
|
+
|
|
33
|
+
track(trackingEvent, null, { selector, text })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getClasslistAsString(element: Element): string {
|
|
37
|
+
if (!element) return ''
|
|
38
|
+
|
|
39
|
+
const classnames = Array.from(element.classList).filter(classname => !classname.startsWith('s-'))
|
|
40
|
+
|
|
41
|
+
if (!classnames.length) return ''
|
|
42
|
+
|
|
43
|
+
return '.' + classnames.join('.')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function elementHasDirectTextContent(element: Element): Node | undefined {
|
|
47
|
+
return Array.from(element.childNodes).find(node => node.nodeName === '#text' && !!node.textContent?.trim())
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<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} />
|
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
|
|
108
|
+
it('Should select text of closest button', async () => {
|
|
109
|
+
document.body.innerHTML = '<button>Some text <span><em></em></span></button>'
|
|
110
|
+
|
|
111
|
+
render(ClickTracking)
|
|
112
|
+
|
|
113
|
+
// @ts-ignore
|
|
114
|
+
await fireEvent.click(document.querySelector('em'))
|
|
115
|
+
|
|
116
|
+
expect(track).toHaveBeenCalledWith(
|
|
117
|
+
TrackingEvent.ExploreAnyClick,
|
|
118
|
+
null,
|
|
119
|
+
expect.objectContaining({
|
|
120
|
+
text: 'Some text',
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('Should select text of closest link', async () => {
|
|
126
|
+
document.body.innerHTML = '<a href="/">Some text <span><em></em></span></a>'
|
|
127
|
+
|
|
128
|
+
render(ClickTracking)
|
|
129
|
+
|
|
130
|
+
// @ts-ignore
|
|
131
|
+
await fireEvent.click(document.querySelector('em'))
|
|
132
|
+
|
|
133
|
+
expect(track).toHaveBeenCalledWith(
|
|
134
|
+
TrackingEvent.ExploreAnyClick,
|
|
135
|
+
null,
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
text: 'Some text',
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('Should use placeholder text', async () => {
|
|
143
|
+
document.body.innerHTML = '<input placeholder="Some placeholder" />'
|
|
144
|
+
|
|
145
|
+
render(ClickTracking)
|
|
146
|
+
|
|
147
|
+
// @ts-ignore
|
|
148
|
+
await fireEvent.click(document.querySelector('input'))
|
|
149
|
+
|
|
150
|
+
expect(track).toHaveBeenCalledWith(
|
|
151
|
+
TrackingEvent.ExploreAnyClick,
|
|
152
|
+
null,
|
|
153
|
+
expect.objectContaining({
|
|
154
|
+
text: 'Some placeholder',
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('Should use alt text', async () => {
|
|
160
|
+
document.body.innerHTML = '<img alt="Some alt text" />'
|
|
161
|
+
|
|
162
|
+
render(ClickTracking)
|
|
163
|
+
|
|
164
|
+
// @ts-ignore
|
|
165
|
+
await fireEvent.click(document.querySelector('img'))
|
|
166
|
+
|
|
167
|
+
expect(track).toHaveBeenCalledWith(
|
|
168
|
+
TrackingEvent.ExploreAnyClick,
|
|
169
|
+
null,
|
|
170
|
+
expect.objectContaining({
|
|
171
|
+
text: 'Some alt text',
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('Should use aria label', async () => {
|
|
177
|
+
document.body.innerHTML = '<button aria-label="Some aria label"></button>'
|
|
178
|
+
|
|
179
|
+
render(ClickTracking)
|
|
180
|
+
|
|
181
|
+
// @ts-ignore
|
|
182
|
+
await fireEvent.click(document.querySelector('button'))
|
|
183
|
+
|
|
184
|
+
expect(track).toHaveBeenCalledWith(
|
|
185
|
+
TrackingEvent.ExploreAnyClick,
|
|
186
|
+
null,
|
|
187
|
+
expect.objectContaining({
|
|
188
|
+
text: 'Some aria label',
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('Should use parent aria label', async () => {
|
|
194
|
+
document.body.innerHTML = '<button aria-label="Some aria label"><img /></button>'
|
|
195
|
+
|
|
196
|
+
render(ClickTracking)
|
|
197
|
+
|
|
198
|
+
// @ts-ignore
|
|
199
|
+
await fireEvent.click(document.querySelector('img'))
|
|
200
|
+
|
|
201
|
+
expect(track).toHaveBeenCalledWith(
|
|
202
|
+
TrackingEvent.ExploreAnyClick,
|
|
203
|
+
null,
|
|
204
|
+
expect.objectContaining({
|
|
205
|
+
text: 'Some aria label',
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('Should prefer aria label over placeholder and alt text', async () => {
|
|
211
|
+
document.body.innerHTML = '<button aria-label="Some aria label"><img placeholder="Some placeholder" alt="Some alt text" /></button>'
|
|
212
|
+
|
|
213
|
+
render(ClickTracking)
|
|
214
|
+
|
|
215
|
+
// @ts-ignore
|
|
216
|
+
await fireEvent.click(document.querySelector('img'))
|
|
217
|
+
|
|
218
|
+
expect(track).toHaveBeenCalledWith(
|
|
219
|
+
TrackingEvent.ExploreAnyClick,
|
|
220
|
+
null,
|
|
221
|
+
expect.objectContaining({
|
|
222
|
+
text: 'Some aria label',
|
|
223
|
+
}),
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('Should prefer text content over aria label, placeholder and alt text', async () => {
|
|
228
|
+
document.body.innerHTML = '<button aria-label="Some aria label">Some text <img placeholder="Some placeholder" alt="Some alt text" /></button>'
|
|
229
|
+
|
|
230
|
+
render(ClickTracking)
|
|
231
|
+
|
|
232
|
+
// @ts-ignore
|
|
233
|
+
await fireEvent.click(document.querySelector('img'))
|
|
234
|
+
|
|
235
|
+
expect(track).toHaveBeenCalledWith(
|
|
236
|
+
TrackingEvent.ExploreAnyClick,
|
|
237
|
+
null,
|
|
238
|
+
expect.objectContaining({
|
|
239
|
+
text: 'Some text',
|
|
240
|
+
}),
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
})
|