@playpilot/tpi 5.3.0 → 5.4.0-beta.topscroll-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": "5.3.0",
3
+ "version": "5.4.0-beta.topscroll-1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -25,4 +25,6 @@ export const TrackingEvent = Object.freeze({
25
25
  ManualReport: 'ali_manual_report',
26
26
  EditorError: 'ali_editor_error',
27
27
  InjectionError: 'ali_injection_error',
28
+
29
+ TopScrollClick: 'ali_top_scroll_click',
28
30
  })
@@ -0,0 +1,251 @@
1
+ <script lang="ts">
2
+ import { TrackingEvent } from '$lib/enums/TrackingEvent'
3
+ import { track } from '$lib/tracking'
4
+ import { fade } from 'svelte/transition'
5
+
6
+ // Placeholder data, of course
7
+ const { campaign = {
8
+ campaign_format: 'top_scroll',
9
+ campaign_type: 'video',
10
+ campaign_platforms: ['web', 'android_app', 'ios_app'],
11
+ campaign_name: 'some_campaign_NL',
12
+ campaign_start: '2025-04-24T00:00:00Z',
13
+ campaign_end: '2025-04-30T23:59:00Z',
14
+ campaign_region: 'nl',
15
+ content: {
16
+ header: 'Some top scroll header',
17
+ header_logo: 'https://example.com/',
18
+ header_logo_uuid: '4fb7d6ea152111f08dd20a58a9feac02',
19
+ subheader: null,
20
+ image: 'https://example.com/',
21
+ image_uuid: '4fb7d6ea152111f08dd20a58a9feac02',
22
+ video: null,
23
+ format: null,
24
+ },
25
+ cta: {
26
+ header: 'Button',
27
+ subheader: null,
28
+ image: null,
29
+ image_uuid: null,
30
+ url: 'https://google.com/',
31
+ },
32
+ content_playlist: null,
33
+ provider: null,
34
+ impression_trackers: [],
35
+ target_title_uuid: null,
36
+ target_title_sid: null,
37
+ backfill_providers: [],
38
+ autogenerated: false,
39
+ enabled: true,
40
+ hide_imdb_score: false,
41
+ hide_sponsored_message: false,
42
+ disclaimer: 'Some disclaimer',
43
+ } } = $props()
44
+
45
+ const { disclaimer, content, cta } = $derived(campaign)
46
+ const { format, header, header_logo: logo, image_uuid: backgroundImageUUID } = $derived(content)
47
+ const { header: buttonLabel, url: href } = $derived(cta)
48
+
49
+ const simple = $derived(format === 'large')
50
+
51
+ let clientWidth = $state(0)
52
+ let tooltipVisible = $state(false)
53
+
54
+ function trackClick(): void {
55
+ track(TrackingEvent.TopScrollClick)
56
+ }
57
+
58
+ function toggleTooltip(event: MouseEvent): void {
59
+ event.preventDefault()
60
+ event.stopPropagation()
61
+
62
+ tooltipVisible = !tooltipVisible
63
+ }
64
+ </script>
65
+
66
+ <a
67
+ {href}
68
+ target="_blank"
69
+ class="top-scroll"
70
+ class:simple
71
+ tabindex="-1"
72
+ onclick={trackClick}
73
+ rel="sponsored"
74
+ style:--width="{clientWidth}px"
75
+ bind:clientWidth>
76
+ <div class="content">
77
+ {#if simple}
78
+ <img class="content-image" src="https://picsum.photos/seed/movie/728/90" alt={header} width="728" height="90" />
79
+ {:else}
80
+ {#if logo}
81
+ <img class="logo" src="https://picsum.photos/seed/disney/50/50" alt="" width="50" height="50" />
82
+ {/if}
83
+
84
+ <p class="tagline">{header}</p>
85
+
86
+ {#if buttonLabel}
87
+ <button class="button">{buttonLabel}</button>
88
+ {/if}
89
+ {/if}
90
+ </div>
91
+
92
+ {#if !simple}
93
+ {#if backgroundImageUUID}
94
+ <div class="background" style:--background="url('https://picsum.photos/seed/movie/728/90')"></div>
95
+ {/if}
96
+
97
+ {#if disclaimer}
98
+ <button class="tooltip" onclick={toggleTooltip}>
99
+ <div class="disclaimer">i</div>
100
+
101
+ {#if tooltipVisible}
102
+ <div class="tooltip-content" transition:fade={{ duration: 50 }}>
103
+ {disclaimer}
104
+ </div>
105
+ {/if}
106
+ </button>
107
+ {/if}
108
+ {/if}
109
+ </a>
110
+
111
+ <style lang="scss">
112
+ .top-scroll {
113
+ position: relative;
114
+ display: block;
115
+ width: 100%;
116
+ border-radius: var(--playpilot-top-scroll-border-radius, var(--playpilot-popover-border-radius, margin(1)));
117
+ background: black;
118
+ color: var(--playpilot-top-scroll-text-color, white);
119
+ font-family: var(--playpilot-top-scroll-font-family, var(--playpilot-detail-font-family, var(--playpilot-font-family)));
120
+ font-size: var(--playpilot-top-scroll-font-size, var(--playpilot-detail-font-size, 14px));
121
+ text-decoration: none;
122
+ line-height: 1.35;
123
+ }
124
+
125
+ .content {
126
+ display: flex;
127
+ z-index: 1;
128
+ position: relative;
129
+ align-items: center;
130
+ height: 100%;
131
+ min-height: margin(4.5);
132
+ padding: margin(0.25) margin(0.5);
133
+ gap: margin(0.5);
134
+ color: var(--playpilot-top-scroll-text-color, white);
135
+
136
+ .simple & {
137
+ justify-content: center;
138
+ min-height: 0;
139
+ padding: 0;
140
+ }
141
+ }
142
+
143
+ .logo {
144
+ border-radius: var(--playpilot-top-scroll-logo-border-radius, margin(0.5));
145
+ background: black;
146
+ }
147
+
148
+ .tagline {
149
+ margin: 0;
150
+ text-shadow: var(--playpilot-shadow);
151
+ }
152
+
153
+ .button {
154
+ margin-left: auto;
155
+ margin-right: margin(1);
156
+ padding: margin(0.25) margin(0.5);
157
+ background: var(--playpilot-top-scroll-button-background, white);
158
+ border: 0;
159
+ border-radius: var(--playpilot-top-scroll-button-border-radius, margin(0.25));
160
+ box-shadow: var(--playpilot-shadow);
161
+ color: var(--playpilot-top-scroll-button-text-color, black);
162
+ font-family: inherit;
163
+ font-size: inherit;
164
+ line-height: inherit;
165
+
166
+ &:hover {
167
+ outline: margin(0.25) solid var(--playpilot-top-scroll-button-background, rgba(white, 0.35));
168
+ background: var(--playpilot-top-scroll-button-background, white);
169
+ }
170
+
171
+ &:active {
172
+ background: var(--playpilot-top-scroll-button-background, white);
173
+ }
174
+ }
175
+
176
+ .background {
177
+ z-index: 0;
178
+ position: absolute;
179
+ top: 0;
180
+ right: 0;
181
+ bottom: 0;
182
+ left: 0;
183
+ border-radius: var(--playpilot-top-scroll-border-radius, var(--playpilot-popover-border-radius, margin(1)));
184
+ background-image: var(--background);
185
+ background-position: center;
186
+ background-size: cover;
187
+ opacity: 0.4;
188
+
189
+ .top-scroll:hover & {
190
+ filter: brightness(1.15);
191
+ }
192
+ }
193
+
194
+ .content-image {
195
+ display: block;
196
+ max-width: 100%;
197
+ height: auto;
198
+ background: black;
199
+ border-radius: var(--playpilot-top-scroll-border-radius, var(--playpilot-popover-border-radius, margin(1)));
200
+
201
+ .top-scroll:hover & {
202
+ filter: brightness(1.15);
203
+ }
204
+ }
205
+
206
+ .disclaimer {
207
+ display: flex;
208
+ align-items: center;
209
+ justify-content: center;
210
+ border: var(--playpilot-top-scroll-disclaimer-border, 2px solid rgba(255, 255, 255, 0.5));
211
+ padding: margin(0.25);
212
+ height: margin(1.25);
213
+ width: margin(1.25);
214
+ border-radius: 99px;
215
+ color: var(--playpilot-top-scroll-disclaimer-text-color, rgba(255, 255, 255, 0.75));
216
+ font-weight: bold;
217
+ line-height: 1;
218
+
219
+ &:hover {
220
+ border-color: var(--playpilot-top-scroll-disclaimer-hover-border-color, white);
221
+ color: var(--playpilot-top-scroll-disclaimer-hover-text-color, white);
222
+ }
223
+ }
224
+
225
+ .tooltip {
226
+ z-index: 2;
227
+ position: absolute;
228
+ right: 0;
229
+ bottom: 0;
230
+ padding: margin(0.25);
231
+ background: transparent;
232
+ border: 0;
233
+ color: var(--playpilot-top-scroll-text-color, white);
234
+ cursor: pointer;
235
+ }
236
+
237
+ .tooltip-content {
238
+ display: block;
239
+ z-index: 20;
240
+ position: absolute;
241
+ right: 100%;
242
+ bottom: margin(0.25);
243
+ width: calc(var(--width) * 0.8);
244
+ padding: margin(0.5);
245
+ border-radius: margin(1);
246
+ background: var(--playpilot-content);
247
+ box-shadow: var(--playpilot-shadow);
248
+ line-height: 1.1;
249
+ text-align: right;
250
+ }
251
+ </style>
@@ -7,11 +7,12 @@
7
7
 
8
8
  interface Props {
9
9
  children: Snippet
10
+ bubble?: Snippet
10
11
  onclose?: () => void
11
12
  onscroll?: () => void
12
13
  }
13
14
 
14
- const { children, onclose = () => null, onscroll = () => null }: Props = $props()
15
+ const { children, bubble, onclose = () => null, onscroll = () => null }: Props = $props()
15
16
 
16
17
  setContext('scope', 'modal')
17
18
 
@@ -22,12 +23,12 @@
22
23
  return () => document.body.style.overflowY = baseOverflowStyle || ''
23
24
  })
24
25
 
25
- function scaleOrFly(node: Element): TransitionConfig {
26
+ function scaleOrFly(node: Element, options: { y: number } = { y: 0 }): TransitionConfig {
26
27
  if (prefersReducedMotion.current) return fade(node, { duration: 0 })
27
28
 
28
29
  const shouldFly = window.innerWidth < 600
29
30
 
30
- if (shouldFly) return fly(node, { duration: 250, y: window.innerHeight })
31
+ if (shouldFly) return fly(node, { duration: 250, ...options })
31
32
  return scale(node, { duration: 150, start: 0.85 })
32
33
  }
33
34
  </script>
@@ -35,7 +36,13 @@
35
36
  <svelte:window on:keydown={({ key }) => { if (key === 'Escape') onclose() }} />
36
37
 
37
38
  <div class="modal" transition:fade|global={{ duration: 150 }}>
38
- <div class="dialog" {onscroll} role="dialog" aria-labelledby="title" transition:scaleOrFly|global data-view-transition-new>
39
+ {#if bubble}
40
+ <div class="bubble" transition:scaleOrFly|global={{ y: -10 }}>
41
+ {@render bubble()}
42
+ </div>
43
+ {/if}
44
+
45
+ <div class="dialog" {onscroll} role="dialog" aria-labelledby="title" transition:scaleOrFly|global={{ y: window.innerHeight }} data-view-transition-new>
39
46
  <div class="close">
40
47
  <RoundButton onclick={() => onclose()}>
41
48
  <IconClose />
@@ -51,44 +58,46 @@
51
58
  </div>
52
59
 
53
60
  <style lang="scss">
61
+ $max-width: 600px;
62
+
54
63
  .modal {
55
64
  z-index: 2147483647; // As high as she goes
56
65
  box-sizing: border-box;
57
66
  position: fixed;
58
67
  display: flex;
59
- justify-content: center;
60
- align-items: flex-start;
68
+ flex-direction: column;
69
+ justify-content: flex-start;
70
+ align-items: center;
61
71
  top: 0;
62
72
  left: 0;
63
73
  width: 100%;
64
74
  height: 100%;
65
75
  background: var(--playpilot-detail-backdrop, rgba(0, 0, 0, 0.65));
66
76
 
67
- @media (min-width: 600px) {
77
+ @media (min-width: $max-width) {
68
78
  padding: margin(2);
79
+ gap: margin(1);
69
80
  }
70
81
  }
71
82
 
72
-
73
83
  .dialog {
74
84
  z-index: 1;
75
85
  position: relative;
76
86
  width: 100%;
77
- max-width: 600px;
87
+ max-width: $max-width;
78
88
  max-height: 80vh;
79
89
  overflow: auto;
80
90
  margin-top: auto;
81
91
  border-radius: var(--playpilot-detail-border-radius, margin(1) margin(1) 0 0);
82
92
  background: var(--playpilot-detail-background, var(--playpilot-light));
83
93
 
84
- @media (min-width: 600px) {
94
+ @media (min-width: $max-width) {
85
95
  margin-top: 0;
86
96
  border-radius: var(--playpilot-detail-border-radius, margin(1));
87
97
  max-height: 100%;
88
98
  }
89
99
  }
90
100
 
91
-
92
101
  .backdrop {
93
102
  z-index: 0;
94
103
  position: absolute;
@@ -112,4 +121,17 @@
112
121
  filter: brightness(1.1);
113
122
  }
114
123
  }
124
+
125
+ .bubble {
126
+ z-index: 1;
127
+ position: relative;
128
+ width: calc(100% - margin(1));
129
+ max-width: $max-width;
130
+ margin: margin(0.5);
131
+
132
+ @media (min-width: $max-width) {
133
+ width: 100%;
134
+ margin: 0;
135
+ }
136
+ }
115
137
  </style>
@@ -5,10 +5,11 @@
5
5
 
6
6
  interface Props {
7
7
  children: Snippet
8
+ bubble?: Snippet
8
9
  maxHeight?: number
9
10
  }
10
11
 
11
- let { children, maxHeight = $bindable() }: Props = $props()
12
+ let { children, bubble, maxHeight = $bindable() }: Props = $props()
12
13
 
13
14
  setContext('scope', 'popover')
14
15
 
@@ -63,11 +64,20 @@
63
64
  <div class="dialog" transition:fly|global={{ duration: prefersReducedMotion.current ? 0 : 100, y: 10 }} data-view-transition-old>
64
65
  {@render children()}
65
66
  </div>
67
+
68
+ {#if bubble}
69
+ <div transition:fly|global={{ duration: prefersReducedMotion.current ? 0 : 100, y: 10 }}>
70
+ {@render bubble()}
71
+ </div>
72
+ {/if}
66
73
  </div>
67
74
 
68
75
  <style lang="scss">
69
76
  .popover {
70
77
  --offset: #{margin(0.5)};
78
+ display: flex;
79
+ gap: margin(0.5);
80
+ flex-direction: column-reverse;
71
81
  position: absolute;
72
82
  top: calc((var(--offset) - 1px) * -1); /* Add 1 pixel to account for rounding errors */
73
83
  left: 0;
@@ -78,6 +88,7 @@
78
88
  z-index: 2147483647; // As high as she goes
79
89
 
80
90
  &.flip {
91
+ flex-direction: column;
81
92
  top: auto;
82
93
  bottom: calc(var(--offset) + 1px); /* Add 1 pixel to account for rounding errors */
83
94
  transform: translateY(calc(100% + var(--offset)));
@@ -5,6 +5,7 @@
5
5
  import { onMount } from 'svelte'
6
6
  import Modal from './Modal.svelte'
7
7
  import Title from './Title.svelte'
8
+ import TopScroll from './Ads/TopScroll.svelte'
8
9
 
9
10
  interface Props {
10
11
  onclose: () => void,
@@ -32,4 +33,8 @@
32
33
 
33
34
  <Modal {onclose} {onscroll}>
34
35
  <Title {title} />
36
+
37
+ {#snippet bubble()}
38
+ <TopScroll />
39
+ {/snippet}
35
40
  </Modal>
@@ -5,6 +5,7 @@
5
5
  import { onMount } from 'svelte'
6
6
  import Popover from './Popover.svelte'
7
7
  import Title from './Title.svelte'
8
+ import TopScroll from './Ads/TopScroll.svelte'
8
9
 
9
10
  interface Props {
10
11
  event: MouseEvent
@@ -50,6 +51,10 @@
50
51
  <div class="title-popover" bind:this={element} data-playpilot-title-popover role="region" aria-labelledby="title">
51
52
  <Popover bind:maxHeight>
52
53
  <Title {title} small compact={!!maxHeight && maxHeight < 250} />
54
+
55
+ {#snippet bubble()}
56
+ <TopScroll />
57
+ {/snippet}
53
58
  </Popover>
54
59
  </div>
55
60
 
@@ -0,0 +1,99 @@
1
+ import { fireEvent, render, waitFor } from '@testing-library/svelte'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import TopScroll from '../../../../routes/components/Ads/TopScroll.svelte'
5
+ import { track } from '$lib/tracking'
6
+ import { TrackingEvent } from '$lib/enums/TrackingEvent'
7
+
8
+ vi.mock('$lib/tracking', () => ({
9
+ track: vi.fn(),
10
+ }))
11
+
12
+ export const campaign = {
13
+ campaign_format: 'top_scroll',
14
+ campaign_type: 'video',
15
+ campaign_platforms: ['web', 'android_app', 'ios_app'],
16
+ campaign_name: 'some_campaign_NL',
17
+ campaign_start: '2025-04-24T00:00:00Z',
18
+ campaign_end: '2025-04-30T23:59:00Z',
19
+ campaign_region: 'nl',
20
+ content: {
21
+ header: 'Some top scroll header',
22
+ header_logo: 'https://example.com/',
23
+ header_logo_uuid: '4fb7d6ea152111f08dd20a58a9feac02',
24
+ subheader: null,
25
+ image: 'https://example.com/',
26
+ image_uuid: '4fb7d6ea152111f08dd20a58a9feac02',
27
+ video: null,
28
+ format: null,
29
+ },
30
+ cta: {
31
+ header: 'Button Label',
32
+ subheader: null,
33
+ image: null,
34
+ image_uuid: null,
35
+ url: 'https://google.com/',
36
+ },
37
+ content_playlist: null,
38
+ provider: null,
39
+ impression_trackers: [],
40
+ target_title_uuid: null,
41
+ target_title_sid: null,
42
+ backfill_providers: [],
43
+ autogenerated: false,
44
+ enabled: true,
45
+ hide_imdb_score: false,
46
+ hide_sponsored_message: false,
47
+ disclaimer: 'Some disclaimer',
48
+ }
49
+
50
+ describe('TopScroll.svelte', () => {
51
+ it('Should render the given content', () => {
52
+ const { getByText, container } = render(TopScroll, { campaign })
53
+
54
+ expect(getByText(campaign.cta.header)).toBeTruthy()
55
+ expect(getByText(campaign.content.header)).toBeTruthy()
56
+ expect(container.querySelector('.tooltip')).toBeTruthy()
57
+ })
58
+
59
+ it('Should not show disclaimer when not given', async () => {
60
+ const { getByText, container } = render(TopScroll, {
61
+ campaign: { ...campaign, disclaimer: null },
62
+ })
63
+
64
+ await waitFor(() => getByText(campaign.cta.header))
65
+
66
+ expect(container.querySelector('.tooltip')).not.toBeTruthy()
67
+ })
68
+
69
+ it('Should toggle disclaimer on click', async () => {
70
+ const { getByText, container, queryByText } = render(TopScroll, { campaign })
71
+
72
+ await waitFor(() => getByText(campaign.cta.header))
73
+
74
+ await fireEvent.click(/** @type {HTMLElement} */ (container.querySelector('.tooltip')))
75
+ expect(getByText(campaign.disclaimer)).toBeTruthy()
76
+
77
+ await fireEvent.click(/** @type {HTMLElement} */ (container.querySelector('.tooltip')))
78
+ expect(queryByText(campaign.disclaimer)).not.toBeTruthy()
79
+ })
80
+
81
+ it('Should fire track even on click', async () => {
82
+ const { getByRole } = render(TopScroll, { campaign })
83
+
84
+ await fireEvent.click(getByRole('link'))
85
+
86
+ expect(track).toHaveBeenCalledWith(TrackingEvent.TopScrollClick)
87
+ })
88
+
89
+ it('Should render as simple variant when content format is large', async () => {
90
+ const simpleCampaign = { ...campaign, content: { ...campaign.content, format: 'large' } }
91
+ const { queryByText, container } = render(TopScroll, { campaign: simpleCampaign })
92
+
93
+ expect(queryByText(simpleCampaign.content.header)).not.toBeTruthy()
94
+ expect(queryByText(simpleCampaign.cta.header)).not.toBeTruthy()
95
+ expect(container.querySelector('.background')).not.toBeTruthy()
96
+ expect(container.querySelector('.disclaimer')).not.toBeTruthy()
97
+ expect(container.querySelector('.content-image')).toBeTruthy()
98
+ })
99
+ })