@playpilot/tpi 6.3.0-beta.5 → 6.4.0-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "6.3.0-beta.5",
3
+ "version": "6.4.0-beta.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -4,4 +4,9 @@ export const SplitTest = {
4
4
  numberOfVariants: 2,
5
5
  variantNames: ['Separated', 'Inline'] as string[],
6
6
  },
7
+ PostersScrollReveal: {
8
+ key: 'posters_scroll_reveal',
9
+ numberOfVariants: 2,
10
+ variantNames: ['Default', 'With Posters'] as string[],
11
+ },
7
12
  } as const
@@ -73,12 +73,12 @@
73
73
 
74
74
  window.PlayPilotLinkInjections.config = config
75
75
 
76
- isUrlExcluded = !!(config?.exclude_urls_pattern && url.match(new RegExp(config.exclude_urls_pattern)))
77
- if (isUrlExcluded) return
78
-
79
76
  if (config?.custom_style) insertCustomStyle(config.custom_style || '')
80
77
  if (config?.explore_navigation_selector) insertExploreIntoNavigation()
81
78
 
79
+ isUrlExcluded = !!(config?.exclude_urls_pattern && url.match(new RegExp(config.exclude_urls_pattern)))
80
+ if (isUrlExcluded) return
81
+
82
82
  setElements(config?.html_selector || '', config?.exclude_elements_selector || '')
83
83
  } catch(error) {
84
84
  // We return if the config did not get fetched properly, as we can't determine what should and should
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { heading } from '$lib/actions/heading'
3
+ import { fetchAds } from '$lib/api/ads'
3
4
  import { fetchTitles } from '$lib/api/titles'
4
5
  import { TrackingEvent } from '$lib/enums/TrackingEvent'
5
6
  import { exploreParentSelector } from '$lib/explore'
@@ -46,7 +47,11 @@
46
47
  height = element?.closest<HTMLElement>(exploreParentSelector)?.style.height || null
47
48
  })
48
49
 
49
- onMount(() => track(TrackingEvent.ExplorePageView))
50
+ onMount(async () => {
51
+ track(TrackingEvent.ExplorePageView)
52
+
53
+ if (!window.PlayPilotLinkInjections.ads?.length) window.PlayPilotLinkInjections.ads = await fetchAds()
54
+ })
50
55
 
51
56
  async function getTitlesForFilter(): Promise<APIPaginatedResult<TitleData>> {
52
57
  latestRequestId += 1
@@ -41,12 +41,14 @@
41
41
  border: theme(explore-search-border, 0);
42
42
  border-radius: theme(exlore-search-border-radius, border-radius);
43
43
  background: theme(explore-search-background, content);
44
+ transition: none;
44
45
  color: theme(text-color-alt);
45
- font-size: theme(font-size-base);
46
+ font-size: theme(font-size-large);
46
47
  font-family: theme(font-family);
47
48
 
48
49
  &:focus {
49
- outline: 1px solid white;
50
+ outline: theme(explore-search-focus-outline, 1px solid white);
51
+ outline-offset: 0;
50
52
  }
51
53
 
52
54
  &::placeholder {
@@ -41,9 +41,9 @@
41
41
  let windowWidth = $state(0)
42
42
  let modalElement: HTMLElement | null = $state(null)
43
43
  let dialogElement: HTMLElement | null = $state(null)
44
+ let closeElement: HTMLElement | null = $state(null)
44
45
  let dialogOffset: number = $state(0)
45
46
  let hasPreviousModal = $state(false)
46
- let isTransitioningIn = $state(false)
47
47
 
48
48
  const isMobile = $derived(windowWidth < mobileBreakpoint)
49
49
 
@@ -79,10 +79,10 @@
79
79
  scrollableElement?.scrollTo(0, initialScrollPosition)
80
80
  }
81
81
 
82
- function scaleOrFly(node: Element, options: { y: number } = { y: 0 }): TransitionConfig {
82
+ function scaleOrFly(node: Element): TransitionConfig {
83
83
  if (prefersReducedMotion.current) return fade(node, { duration: 0 })
84
84
 
85
- if (isMobile) return fly(node, { duration: 250, ...options })
85
+ if (isMobile) return fly(node, { duration: 250, y: window.innerHeight, opacity: 1 })
86
86
  return scale(node, { duration: 150, start: 0.85 })
87
87
  }
88
88
 
@@ -103,6 +103,14 @@
103
103
  onhashchange={close}
104
104
  bind:innerWidth={windowWidth} />
105
105
 
106
+ {#snippet closeButton()}
107
+ <div class="close {closeButtonStyle}" transition:scaleOrFly|global bind:this={closeElement}>
108
+ <RoundButton onclick={close} aria-label="Close">
109
+ <IconClose />
110
+ </RoundButton>
111
+ </div>
112
+ {/snippet}
113
+
106
114
  <!-- svelte-ignore a11y_click_events_have_key_events -->
107
115
  <!-- svelte-ignore a11y_no_static_element_interactions -->
108
116
  <div
@@ -114,38 +122,38 @@
114
122
  style:--dialog-offset="{dialogOffset}px"
115
123
  onclick={closeOnBackdropClick}
116
124
  data-testid="modal"
117
- transition:fade|global={{ duration: 150 }}
125
+ in:fade|global={{ duration: 150 }}
126
+ out:fade|global={{ duration: 150, delay: 100 }}
118
127
  bind:this={modalElement}
119
128
  use:focustrap>
120
129
  {#if prepend}
121
- <div class="prepend" transition:scaleOrFly|global={{ y: window.innerHeight }} data-view-transition-new="playpilot-title-extra">
130
+ <div class="prepend" transition:scaleOrFly|global data-view-transition-new="playpilot-title-extra">
122
131
  {@render prepend()}
123
132
  </div>
124
133
  {/if}
125
134
 
126
135
  {#if bubble}
127
- <div class="bubble" transition:scaleOrFly|global={{ y: window.innerHeight }} data-view-transition-new="playpilot-title-extra">
136
+ <div class="bubble" transition:scaleOrFly|global data-view-transition-new="playpilot-title-extra">
128
137
  {@render bubble()}
129
138
  </div>
130
139
  {/if}
131
140
 
132
141
  {#if isMobile}
133
142
  <div class="swipe-handle" transition:scaleOrFly|global>
134
- <SwipeHandle target={dialogElement!} onpassed={close} />
143
+ {#if dialogElement && closeElement}
144
+ <SwipeHandle targets={[dialogElement, closeElement]} onpassed={close} />
145
+ {/if}
135
146
  </div>
136
147
  {/if}
137
148
 
138
149
  <div
139
150
  class="dialog"
140
- class:transitioning={isTransitioningIn}
141
151
  {onscroll}
142
152
  bind:this={dialogElement}
143
153
  role="dialog"
144
154
  aria-labelledby="title"
145
155
  data-view-transition-new="playpilot-title-content"
146
- onintrostart={() => isTransitioningIn = true}
147
- onintroend={() => isTransitioningIn = false}
148
- transition:scaleOrFly|global={{ y: window.innerHeight }}>
156
+ transition:scaleOrFly|global>
149
157
  {#if hasPreviousModal}
150
158
  <div class="close back {closeButtonStyle}">
151
159
  <RoundButton onclick={() => goBackToPreviousModal()} aria-label="Back">
@@ -154,14 +162,16 @@
154
162
  </div>
155
163
  {/if}
156
164
 
157
- <div class="close {closeButtonStyle}">
158
- <RoundButton onclick={close} aria-label="Close">
159
- <IconClose />
160
- </RoundButton>
161
- </div>
162
-
163
165
  {@render children()}
166
+
167
+ {#if !isMobile}
168
+ {@render closeButton()}
169
+ {/if}
164
170
  </div>
171
+
172
+ {#if isMobile}
173
+ {@render closeButton()}
174
+ {/if}
165
175
  </div>
166
176
 
167
177
  <style lang="scss">
@@ -174,7 +184,6 @@
174
184
  flex-direction: column;
175
185
  justify-content: flex-start;
176
186
  align-items: center;
177
- overflow: auto;
178
187
  top: 0;
179
188
  right: 0;
180
189
  bottom: 0;
@@ -182,6 +191,8 @@
182
191
  background: theme(detail-backdrop, rgba(0, 0, 0, 0.65));
183
192
 
184
193
  @include desktop() {
194
+ overflow: auto;
195
+ overscroll-behavior: contain;
185
196
  padding: margin(2) 0;
186
197
  }
187
198
 
@@ -208,9 +219,11 @@
208
219
  margin-top: auto;
209
220
  padding-bottom: env(safe-area-inset-bottom);
210
221
  overflow: auto;
222
+ overscroll-behavior: contain;
211
223
  border-radius: theme(detail-border-radius, margin(1) margin(1) 0 0);
212
224
  background: theme(detail-background, light);
213
- transition: transform 200ms;
225
+ will-change: top;
226
+ transition: top 200ms;
214
227
 
215
228
  @supports (height: 1dvh) {
216
229
  height: 80dvh;
@@ -218,7 +231,7 @@
218
231
 
219
232
  @include desktop() {
220
233
  height: unset;
221
- margin-top: 0;
234
+ margin: auto 0;
222
235
  padding-bottom: 0;
223
236
  overflow: visible;
224
237
  border-radius: theme(detail-border-radius, border-radius-large);
@@ -262,11 +275,14 @@
262
275
  --playpilot-button-text-color: #{theme(modal-close-button-text-color, text-color-alt)};
263
276
  z-index: 5;
264
277
  position: fixed;
265
- top: calc(var(--dialog-offset) + margin(1));
278
+ margin-top: calc(var(--dialog-offset) + margin(1));
279
+ top: 0;
266
280
  right: margin(1);
281
+ transition: top 200ms;
267
282
 
268
283
  @include desktop() {
269
284
  position: absolute;
285
+ margin-top: 0;
270
286
  top: margin(1);
271
287
  }
272
288
 
@@ -291,10 +307,6 @@
291
307
  }
292
308
  }
293
309
  }
294
-
295
- .transitioning & {
296
- top: margin(1);
297
- }
298
310
  }
299
311
 
300
312
  .prepend {
@@ -1,4 +1,7 @@
1
1
  <script lang="ts">
2
+ import { mobileBreakpoint } from '$lib/constants'
3
+ import { SplitTest } from '$lib/enums/SplitTest'
4
+ import { isInSplitTestVariant, trackSplitTestAction, trackSplitTestView } from '$lib/splitTest'
2
5
  import { onMount } from 'svelte'
3
6
 
4
7
  const linksWithPosters = Array.from(document.querySelectorAll<HTMLElement>('[data-playpilot-poster-url]'))
@@ -6,13 +9,37 @@
6
9
  const targetThreshold = 0.6
7
10
  const posterSelector = '[data-playpilot-poster]'
8
11
 
12
+ let windowWidth = $state(0)
9
13
  let scrollTimeout: ReturnType<typeof setTimeout> | null = null
10
14
 
15
+ const isMobile = $derived(windowWidth < mobileBreakpoint)
16
+
11
17
  onMount(() => {
18
+ if (!isMobile) return
19
+
20
+ trackSplitTestView(SplitTest.PostersScrollReveal)
21
+
12
22
  return destroyAllPosters
13
23
  })
14
24
 
25
+ $effect(() => {
26
+ if (!isMobile) return
27
+ if (!isInSplitTestVariant(SplitTest.PostersScrollReveal)) return
28
+ if (!linksWithPosters.length) return
29
+
30
+ const onclick = () => trackSplitTestAction(SplitTest.PostersScrollReveal, 'click')
31
+
32
+ for(const link of linksWithPosters) link.addEventListener('click', onclick)
33
+
34
+ return () => {
35
+ for(const link of linksWithPosters) link.removeEventListener('click', onclick)
36
+ }
37
+ })
38
+
15
39
  function onscroll(): void {
40
+ if (!isMobile) return
41
+ if (!isInSplitTestVariant(SplitTest.PostersScrollReveal)) return
42
+
16
43
  setScrollTimeout()
17
44
 
18
45
  const windowHeight = window.innerHeight
@@ -82,8 +109,8 @@
82
109
  destroyPosterForLink(link)
83
110
  linksWithCurrentlyVisiblePosters.delete(link)
84
111
  }
85
- }, 800)
112
+ }, 500)
86
113
  }
87
114
  </script>
88
115
 
89
- <svelte:window {onscroll} />
116
+ <svelte:window {onscroll} bind:innerWidth={windowWidth} />
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  interface Props {
3
- target?: HTMLElement
3
+ targets: HTMLElement[]
4
4
  threshold?: number
5
5
  isDragging?: boolean
6
6
  passedThreshold?: boolean
@@ -8,7 +8,7 @@
8
8
  }
9
9
 
10
10
  let {
11
- target,
11
+ targets,
12
12
  threshold = 100,
13
13
  isDragging = $bindable(false),
14
14
  passedThreshold = $bindable(false),
@@ -28,28 +28,33 @@
28
28
 
29
29
  export function start(event: TouchEvent): void {
30
30
  if (!event) return
31
- if (!target) return
32
31
 
33
32
  isDragging = true
34
33
  hasBeenDragged = true
35
34
 
36
35
  movementStartY = event.touches[0].pageY
37
- target.style.transitionDuration = '0ms'
36
+
37
+ for (const target of targets) {
38
+ target.style.transitionDuration = '0ms'
39
+ }
38
40
  }
39
41
 
40
42
  export function move(event: TouchEvent): void {
41
43
  if (!event) return
42
- if (!target) return
43
44
  if (!isDragging) return
44
45
 
46
+ event.preventDefault()
47
+
45
48
  movementCurrentY = event.touches[0].pageY
46
49
 
47
- target.style.transform = `translateY(${difference}px)`
48
50
  handleElement!.style.transform = `translateY(${difference}px)`
51
+
52
+ for (const target of targets) {
53
+ target.style.top = `${difference}px`
54
+ }
49
55
  }
50
56
 
51
57
  export function end(): void {
52
- if (!target) return
53
58
  if (!isDragging) return
54
59
 
55
60
  isDragging = false
@@ -57,8 +62,12 @@
57
62
  if (passedThreshold) {
58
63
  onpassed()
59
64
  } else {
60
- target.removeAttribute('style')
61
65
  handleElement!.removeAttribute('style')
66
+
67
+ for (const target of targets) {
68
+ target.style.removeProperty('transition')
69
+ requestAnimationFrame(() => target.style.top = '0px')
70
+ }
62
71
  }
63
72
 
64
73
  movementStartY = 0
@@ -6,6 +6,7 @@ import { fetchTitles } from '$lib/api/titles'
6
6
  import { title } from '$lib/fakeData'
7
7
  import { TrackingEvent } from '$lib/enums/TrackingEvent'
8
8
  import { track } from '$lib/tracking'
9
+ import { fetchAds } from '$lib/api/ads'
9
10
 
10
11
  vi.mock('$lib/api/titles', () => ({
11
12
  fetchTitles: vi.fn(),
@@ -19,8 +20,15 @@ vi.mock('$lib/tracking', () => ({
19
20
  track: vi.fn(),
20
21
  }))
21
22
 
23
+ vi.mock('$lib/api/ads', () => ({
24
+ fetchAds: vi.fn(),
25
+ }))
26
+
22
27
  describe('Explore.svelte', () => {
23
28
  beforeEach(() => {
29
+ // @ts-ignore
30
+ window.PlayPilotLinkInjections = {}
31
+
24
32
  vi.resetAllMocks()
25
33
  })
26
34
 
@@ -30,10 +38,23 @@ describe('Explore.svelte', () => {
30
38
  expect(fetchTitles).toHaveBeenCalledWith({ page_size: 24, page: 1 })
31
39
  })
32
40
 
33
- it('Should call tracking event on mount', () => {
41
+ it('Should call tracking event and fetchAds on mount', () => {
34
42
  render(Explore)
35
43
 
36
44
  expect(track).toHaveBeenCalledWith(TrackingEvent.ExplorePageView)
45
+ expect(fetchAds).toHaveBeenCalled()
46
+ })
47
+
48
+ it('Should not call fetchAds on mount if ads are already present', () => {
49
+ // @ts-ignore
50
+ window.PlayPilotLinkInjections = {
51
+ // @ts-ignore
52
+ ads: ['a'],
53
+ }
54
+
55
+ render(Explore)
56
+
57
+ expect(fetchAds).not.toHaveBeenCalled()
37
58
  })
38
59
 
39
60
  it('Should render all returned titles', async () => {
@@ -1,6 +1,14 @@
1
- import { render } from '@testing-library/svelte'
1
+ import { fireEvent, render } from '@testing-library/svelte'
2
2
  import { describe, it, expect, vi, beforeEach } from 'vitest'
3
3
  import PostersScrollReveal from '../../../routes/components/PostersScrollReveal.svelte'
4
+ import { mobileBreakpoint } from '$lib/constants'
5
+ import { isInSplitTestVariant, trackSplitTestAction } from '$lib/splitTest'
6
+
7
+ vi.mock('$lib/splitTest', () => ({
8
+ trackSplitTestView: vi.fn(),
9
+ trackSplitTestAction: vi.fn(),
10
+ isInSplitTestVariant: vi.fn(() => true),
11
+ }))
4
12
 
5
13
  function createPosterLink(top = 0, url = 'poster.jpg') {
6
14
  const link = document.createElement('a')
@@ -22,8 +30,15 @@ describe('FloatingPoster', () => {
22
30
  writable: true,
23
31
  })
24
32
 
33
+ Object.defineProperty(window, 'innerWidth', {
34
+ value: mobileBreakpoint - 1,
35
+ writable: true,
36
+ })
37
+
25
38
  vi.useFakeTimers()
26
39
  vi.resetAllMocks()
40
+
41
+ vi.mocked(isInSplitTestVariant).mockReturnValue(true)
27
42
  })
28
43
 
29
44
  it('Should create poster when link is in viewport', async () => {
@@ -38,6 +53,21 @@ describe('FloatingPoster', () => {
38
53
  expect(poster?.getAttribute('src')).toBe('poster.jpg')
39
54
  })
40
55
 
56
+ it('Should not create posters on desktop when link is in viewport', async () => {
57
+ createPosterLink(500)
58
+
59
+ Object.defineProperty(window, 'innerWidth', {
60
+ value: mobileBreakpoint + 1,
61
+ writable: true,
62
+ })
63
+
64
+ render(PostersScrollReveal)
65
+
66
+ window.dispatchEvent(new Event('scroll'))
67
+
68
+ expect(document.querySelector('[data-playpilot-poster]')).not.toBeTruthy()
69
+ })
70
+
41
71
  it('Should not create poster when link is below in viewport', async () => {
42
72
  createPosterLink(2000)
43
73
 
@@ -104,4 +134,16 @@ describe('FloatingPoster', () => {
104
134
  window.dispatchEvent(new Event('scroll'))
105
135
  expect(document.querySelector('[data-playpilot-poster]')).toBeTruthy()
106
136
  })
137
+
138
+ it('Should fire split test tracking on click', async () => {
139
+ createPosterLink(500)
140
+
141
+ render(PostersScrollReveal)
142
+
143
+ window.dispatchEvent(new Event('scroll'))
144
+
145
+ await fireEvent.click(/** @type {HTMLElement} */ (document.querySelector('[data-playpilot-poster]')))
146
+
147
+ expect(trackSplitTestAction).toHaveBeenCalled()
148
+ })
107
149
  })