@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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "8.9.3",
3
+ "version": "8.10.1",
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 = {
@@ -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} />
@@ -38,7 +38,7 @@
38
38
  </iframe>
39
39
 
40
40
  {#if showMuteControls}
41
- <button class="mute" onclick={() => toggleMute()}>
41
+ <button class="mute" onclick={() => toggleMute()} aria-label="Mute">
42
42
  <IconMute muted={isMuted} />
43
43
  </button>
44
44
  {/if}
@@ -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
+ })