@playpilot/tpi 5.31.3 → 5.32.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": "5.31.3",
3
+ "version": "5.32.0-beta.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -61,6 +61,11 @@ export const translations = {
61
61
  [Language.Swedish]: 'minuter',
62
62
  [Language.Danish]: 'minutter',
63
63
  },
64
+ 'Minutes Short': {
65
+ [Language.English]: 'min.',
66
+ [Language.Swedish]: 'min.',
67
+ [Language.Danish]: 'min.',
68
+ },
64
69
  'Type: movie': {
65
70
  [Language.English]: 'Movie',
66
71
  [Language.Swedish]: 'Film',
@@ -9,4 +9,9 @@ export const SplitTest = {
9
9
  numberOfVariants: 2,
10
10
  variantNames: ['Image', 'Label'] as string[],
11
11
  },
12
+ InTextEngagement: {
13
+ key: 'in-text-engagement',
14
+ numberOfVariants: 4,
15
+ variantNames: ['Default', '(i) Icon', 'Providers', 'Highlight'] as string[]
16
+ }
12
17
  } as const
@@ -13,6 +13,9 @@ import { destroyAllModals, openModal } from './modal'
13
13
  import { track } from './tracking'
14
14
  import { TrackingEvent } from './enums/TrackingEvent'
15
15
  import InTextDisclaimer from '../routes/components/InTextDisclaimer.svelte'
16
+ import { getSplitTestVariantName, trackSplitTestAction } from './splitTest'
17
+ import { SplitTest } from './enums/SplitTest'
18
+ import { mergePlaylinks } from './playlink'
16
19
 
17
20
  const keyDataAttribute = 'data-playpilot-injection-key'
18
21
  const keySelector = `[${keyDataAttribute}]`
@@ -307,6 +310,33 @@ function createLinkInjectionElement(injection: LinkInjection): { injectionElemen
307
310
  linkElement.target = '_blank'
308
311
  linkElement.rel = 'noopener nofollow noreferrer'
309
312
 
313
+ // Part of a split test, insert an absolute positioned (i) icon in the top right of links.
314
+ if (getSplitTestVariantName(SplitTest.InTextEngagement) === '(i) Icon') {
315
+ const iconElement = document.createElement('span')
316
+ iconElement.classList.add('playpilot-injection-info-icon')
317
+ iconElement.dataset.playpilotElement = 'true'
318
+
319
+ linkElement.insertAdjacentElement('beforeend', iconElement)
320
+ }
321
+
322
+ // Part of a split test, insert a list of max 3 provider icons after the link, these are inline and cause page shift.
323
+ if (getSplitTestVariantName(SplitTest.InTextEngagement) === 'Providers') {
324
+ const size = parseFloat(getComputedStyle(document.documentElement).fontSize) * 0.875 // 0.875 rem, 14 pixels if 1rem is 16 pixels
325
+
326
+ for (const provider of mergePlaylinks(injection.title_details!.providers).slice(0, 3)) {
327
+ const imageElement = document.createElement('img')
328
+ imageElement.loading = 'lazy'
329
+ imageElement.src = provider.logo_url
330
+ imageElement.alt = '' // Intentionally ignore the image or TTS would make a mess of the sentence
331
+ imageElement.width = size
332
+ imageElement.height = size
333
+ imageElement.dataset.playpilotElement = 'true'
334
+ imageElement.classList.add('playpilot-injection-provider-icon')
335
+
336
+ linkElement.insertAdjacentElement('beforeend', imageElement)
337
+ }
338
+ }
339
+
310
340
  injectionElement.insertAdjacentElement('beforeend', linkElement)
311
341
 
312
342
  return { injectionElement, linkElement }
@@ -422,6 +452,8 @@ function addLinkInjectionEventListeners(injections: LinkInjection[]): void {
422
452
 
423
453
  event.preventDefault()
424
454
 
455
+ trackSplitTestAction(SplitTest.InTextEngagement, 'click')
456
+
425
457
  playFallbackViewTransition(() => {
426
458
  destroyLinkPopover(false)
427
459
  openModal({ event, injection, data: injection.title_details })
@@ -602,6 +634,9 @@ export function clearLinkInjection(key: string): void {
602
634
  const element: HTMLAnchorElement | null = document.querySelector(`[${keyDataAttribute}="${key}"]`)
603
635
  if (!element) return
604
636
 
637
+ const playpilotElements = element.querySelectorAll('[data-playpilot-element]')
638
+ playpilotElements.forEach(element => element.remove())
639
+
605
640
  const linkContent = element.querySelector('a')?.innerHTML
606
641
  element.outerHTML = linkContent || ''
607
642
  }
@@ -11,6 +11,45 @@
11
11
  position: relative;
12
12
  }
13
13
 
14
+ .playpilot-injection-info-icon {
15
+ position: relative;
16
+ display: inline-block;
17
+ height: 1em;
18
+
19
+ &::before {
20
+ content: "i";
21
+ display: inline-flex;
22
+ justify-content: center;
23
+ align-items: center;
24
+ position: absolute;
25
+ top: -0.25em;
26
+ right: -1em;
27
+ width: 0.75em;
28
+ height: 0.75em;
29
+ border-radius: 50%;
30
+ box-shadow: 0 0 0 1px currentColor;
31
+ line-height: 1;
32
+ font-size: 0.65em;
33
+ text-align: center;
34
+ }
35
+
36
+ h1 &, h2 &, h3 &, h4 &, h5 &, h6 & {
37
+ display: none
38
+ }
39
+ }
40
+
41
+ .playpilot-injection-provider-icon {
42
+ display: inline-block;
43
+ margin-left: margin(0.25);
44
+ border-radius: 2px;
45
+ box-shadow: 0 0 1px rgba(0, 0, 0, 0.25);
46
+ overflow: hidden;
47
+
48
+ h1 &, h2 &, h3 &, h4 &, h5 &, h6 & {
49
+ display: none
50
+ }
51
+ }
52
+
14
53
  .playpilot-injection-highlight {
15
54
  outline: margin(0.25) solid var(--playpilot-primary) !important;
16
55
  outline-offset: margin(0.5) !important;
@@ -9,6 +9,8 @@
9
9
  import { fetchAds } from '$lib/api/ads'
10
10
  import { fetchConfig } from '$lib/api/config'
11
11
  import { authorize, getAuthToken, isEditorialModeEnabled, removeAuthCookie, setEditorialParamInUrl } from '$lib/api/auth'
12
+ import { getSplitTestVariantName, trackSplitTestView } from '$lib/splitTest'
13
+ import { SplitTest } from '$lib/enums/SplitTest'
12
14
  import type { LinkInjectionResponse, LinkInjection, LinkInjectionTypes } from '$lib/types/injection'
13
15
  import Editor from './components/Editorial/Editor.svelte'
14
16
  import EditorTrigger from './components/Editorial/EditorTrigger.svelte'
@@ -16,6 +18,7 @@
16
18
  import TrackingPixels from './components/TrackingPixels.svelte'
17
19
  import Consent from './components/Consent.svelte'
18
20
  import Debugger from './components/Debugger.svelte'
21
+ import HighlightedInjection from './components/HighlightedInjection.svelte'
19
22
 
20
23
  let parentElement: HTMLElement | null = $state(null)
21
24
  let elements: HTMLElement[] = $state([])
@@ -54,7 +57,11 @@
54
57
  fireQueuedTrackingEvents()
55
58
  track(TrackingEvent.ArticlePageView)
56
59
 
57
- if (aiInjections.length || manualInjections.length) window.PlayPilotLinkInjections.ads = await fetchAds()
60
+ if (!aiInjections.length && !manualInjections.length) return
61
+
62
+ window.PlayPilotLinkInjections.ads = await fetchAds()
63
+
64
+ trackSplitTestView(SplitTest.InTextEngagement)
58
65
  }
59
66
 
60
67
  async function initialize(): Promise<void> {
@@ -225,7 +232,11 @@
225
232
  </svelte:boundary>
226
233
  {/if}
227
234
 
228
- <Debugger />
235
+ {#if linkInjections.length && getSplitTestVariantName(SplitTest.InTextEngagement) === 'Highlight'}
236
+ <HighlightedInjection {linkInjections} />
237
+ {/if}
238
+
239
+ <Debugger onrerender={rerender} />
229
240
  </div>
230
241
 
231
242
  {#if response?.pixels?.length}
@@ -3,6 +3,12 @@
3
3
  import { getFullUrlPath } from '$lib/url'
4
4
  import { onDestroy } from 'svelte'
5
5
 
6
+ interface Props {
7
+ onrerender: () => void
8
+ }
9
+
10
+ const { onrerender }: Props = $props()
11
+
6
12
  const secrets = ['tpidebug', 'debugtpi']
7
13
  const lastInputs: string[] = []
8
14
  const isUsingBetaScript = !!document.querySelector('script[src*="scripts.playpilot.com/link-injection@next"]')
@@ -92,8 +98,6 @@
92
98
  newScriptTag.setAttribute(attribute.name, attribute.value)
93
99
  }
94
100
 
95
- console.log(newScriptTag, newScriptTag.getAttribute('onload'))
96
-
97
101
  // If no onload attribute is given we add our own and load it straight up. This can happen when the third party loads their
98
102
  // script through other means. That's fine, this beta script is meant to be an approximation and how they load the script
99
103
  // exactly does not matter in this case.
@@ -153,6 +157,8 @@
153
157
 
154
158
  <hr />
155
159
 
160
+ <button onclick={onrerender}>Re-inject</button>
161
+
156
162
  {#if isUsingBetaScript}
157
163
  <small>You are using the beta version of TPI</small>
158
164
  {:else}
@@ -0,0 +1,222 @@
1
+ <script lang="ts">
2
+ import { filterRemovedAndInactiveInjections, sortInjections } from '$lib/injection'
3
+ import { t } from '$lib/localization'
4
+ import { titleUrl } from '$lib/routes'
5
+ import { cleanPhrase } from '$lib/text'
6
+ import type { LinkInjection } from '$lib/types/injection'
7
+ import { openModal } from '$lib/modal'
8
+ import { trackSplitTestAction } from '$lib/splitTest'
9
+ import { SplitTest } from '$lib/enums/SplitTest'
10
+ import { scale } from 'svelte/transition'
11
+ import IconIMDb from './Icons/IconIMDb.svelte'
12
+ import TitlePoster from './TitlePoster.svelte'
13
+ import RoundButton from './RoundButton.svelte'
14
+ import IconClose from './Icons/IconClose.svelte'
15
+
16
+ interface Props {
17
+ linkInjections: LinkInjection[]
18
+ }
19
+
20
+ const { linkInjections }: Props = $props()
21
+
22
+ const scrollThreshold = Math.min(window.innerHeight, 800)
23
+
24
+ const filteredInjections = $derived(filterRemovedAndInactiveInjections(linkInjections))
25
+ const sortedInjections: LinkInjection[] = $derived(sortInjections(filteredInjections))
26
+
27
+ // Get title injection that might relate the most the overall article.
28
+ // If any is found it is used as the primary display, highlighting that injection over others.
29
+ // If none are found we simply use the first active injection and hope for the best.
30
+ const primaryInjection = $derived.by(() => {
31
+ const pageTitle = cleanPhrase(document.querySelector('h1')?.innerText || document.title || '')
32
+ return sortedInjections.find(injection => pageTitle.includes(cleanPhrase(injection.title))) || filteredInjections[0]
33
+ })
34
+
35
+ let shown = $state(false)
36
+ let closed = $state(false)
37
+ let offsetBottom = $state(0)
38
+
39
+ // Only show the element once the user has scroll past a threshold. From here we check the offset from the bottom of
40
+ // the page to make sure we don't overlay the element on top of other elements on the page, such as ads or menus
41
+ function onscroll(): void {
42
+ if (shown || closed) return
43
+ if (window.scrollY < scrollThreshold) return
44
+
45
+ const bottomFixedElementOffsets = getBottomFixedElementOffsets()
46
+ const largestOffset = Math.max(...bottomFixedElementOffsets, 0)
47
+
48
+ offsetBottom = largestOffset + parseFloat(getComputedStyle(document.documentElement).fontSize) // 1 rem
49
+ shown = true
50
+
51
+ trackSplitTestAction(SplitTest.InTextEngagement, 'shown')
52
+ }
53
+
54
+ // Get all elements that are seemingly fixed to the bottom. This is used to determine the position of the element to try
55
+ // and make sure it does not overlap ads or other elements at the bottom of the screen.
56
+ // This is determined by them being fixed (duh) and being close to the bottom of the screen. This might turn out to be unreliable.
57
+ // It does not account for elements that are added after this function is called, which is often the case for ads.
58
+ function getBottomFixedElementOffsets(): number[] {
59
+ const fixedElements = Array.from(document.querySelectorAll('*')).filter((element) => {
60
+ const style = getComputedStyle(element)
61
+ return style.position === 'fixed' && window.innerHeight - element.getBoundingClientRect().bottom < 50
62
+ })
63
+
64
+ return fixedElements.map((element) => element.clientHeight + (window.innerHeight - element.getBoundingClientRect().bottom))
65
+ }
66
+
67
+ function onclick(event: MouseEvent): void {
68
+ event.preventDefault()
69
+
70
+ closed = true
71
+
72
+ openModal({ event, injection: primaryInjection, data: primaryInjection.title_details })
73
+ trackSplitTestAction(SplitTest.InTextEngagement, 'click-highlight')
74
+ }
75
+
76
+ function close(event: MouseEvent): void {
77
+ event.preventDefault()
78
+
79
+ closed = true
80
+
81
+ trackSplitTestAction(SplitTest.InTextEngagement, 'close')
82
+ }
83
+ </script>
84
+
85
+ <svelte:window {onscroll} />
86
+
87
+ {#if primaryInjection && shown && !closed}
88
+ {@const title = primaryInjection.title_details!}
89
+
90
+ <div class="highlighted-injection" style:bottom="{offsetBottom}px" in:scale={{ start: 0.85, duration: 100 }}>
91
+ <a {onclick} class="link" href={titleUrl(title)} target="_blank" aria-label="{title.title} (opens in a new tab)">
92
+ <div class="poster">
93
+ <TitlePoster {title} width={30} height={43} />
94
+ </div>
95
+
96
+ <div class="content">
97
+ <div class="details">
98
+ <div class="heading">{title.title}</div>
99
+
100
+ <div class="meta">
101
+ <div class="imdb">
102
+ <IconIMDb />
103
+ {title.imdb_score || '-'}
104
+ </div>
105
+
106
+ <div>{title.year}</div>
107
+
108
+ <div class="capitalize">{t(`Type: ${title.type}`)}</div>
109
+
110
+ {#if title.length}
111
+ <div>{title.length} {t('Minutes Short')}</div>
112
+ {/if}
113
+ </div>
114
+ </div>
115
+
116
+ <div class="action">
117
+ {t('Watch')}
118
+ </div>
119
+ </div>
120
+ </a>
121
+
122
+ <div class="close">
123
+ <RoundButton onclick={close} aria-label="Close">
124
+ <IconClose />
125
+ </RoundButton>
126
+ </div>
127
+ </div>
128
+ {/if}
129
+
130
+ <style lang="scss">
131
+ .highlighted-injection {
132
+ position: fixed;
133
+ bottom: margin(1);
134
+ left: margin(1);
135
+ width: calc(100% - margin(2));
136
+
137
+ @include desktop {
138
+ max-width: 400px;
139
+ }
140
+ }
141
+
142
+ .link {
143
+ display: flex;
144
+ gap: margin(1);
145
+ align-items: flex-start;
146
+ border-radius: theme(border-radius);
147
+ padding: margin(0.5) margin(1) margin(0.5) margin(0.5);
148
+ background: theme(dark);
149
+ box-shadow: theme(shadow-large);
150
+ text-decoration: none;
151
+
152
+ &:hover,
153
+ &:active {
154
+ transform: scale(1.025);
155
+ transition: transform 50ms;
156
+ filter: brightness(theme(hover-filter-brightness));
157
+ }
158
+
159
+ &:active {
160
+ transform: scale(0.975);
161
+ }
162
+ }
163
+
164
+ .poster {
165
+ flex: 0 0 margin(2);
166
+ width: margin(2);
167
+ box-shadow: 0 0 2px 1px theme(content);
168
+ border-radius: theme(border-radius-small);
169
+ background: theme(content);
170
+ overflow: hidden;
171
+ }
172
+
173
+ .content {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: margin(0.5);
177
+ margin: auto 0;
178
+ color: theme(text-color-alt);
179
+ font-family: theme(font-family);
180
+ font-size: 12px;
181
+ line-height: 1.5;
182
+ font-style: normal;
183
+ }
184
+
185
+ .heading {
186
+ margin-bottom: margin(0.25);
187
+ font-size: theme(font-size-base);
188
+ font-weight: theme(font-bold);
189
+ color: theme(text-color);
190
+ }
191
+
192
+ .meta {
193
+ display: flex;
194
+ flex-wrap: wrap;
195
+ gap: 0 margin(0.5);
196
+ white-space: nowrap;
197
+ color: theme(text-color-alt);
198
+ }
199
+
200
+ .imdb {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: margin(0.25);
204
+ }
205
+
206
+ .action {
207
+ grid-area: action;
208
+ margin-left: auto;
209
+ padding: margin(0.5);
210
+ border: theme(playlinks-action-border, 1px solid currentColor);
211
+ border-radius: theme(playlinks-action-border-radius, margin(2));
212
+ font-weight: theme(playlinks-action-font-weight, 500);
213
+ color: theme(playlinks-action-text-color, text-color);
214
+ line-height: 1;
215
+ }
216
+
217
+ .close {
218
+ position: absolute;
219
+ top: margin(-0.75);
220
+ right: margin(-0.75);
221
+ }
222
+ </style>
@@ -2,10 +2,11 @@
2
2
  import type { Snippet } from 'svelte'
3
3
 
4
4
  interface Props {
5
- size?: string;
6
- children?: Snippet;
7
- onclick?: () => void;
8
- [key: string]: any;
5
+ size?: string
6
+ children?: Snippet
7
+ // eslint-disable-next-line no-unused-vars
8
+ onclick?: (event: MouseEvent) => void
9
+ [key: string]: any
9
10
  }
10
11
 
11
12
  const { children, size = '32px', onclick = () => null, ...rest }: Props = $props()
@@ -13,6 +13,7 @@ import { getFullUrlPath } from '$lib/url'
13
13
  import { fetchAds } from '$lib/api/ads'
14
14
  import { fetchConfig } from '$lib/api/config'
15
15
  import { hasConsentedTo } from '$lib/consent'
16
+ import { getSplitTestVariantName } from '$lib/splitTest'
16
17
 
17
18
  vi.mock('$lib/api/externalPages', () => ({
18
19
  fetchLinkInjections: vi.fn(() => {}),
@@ -66,6 +67,7 @@ vi.mock('$lib/url', () => ({
66
67
 
67
68
  vi.mock('$lib/splitTest', () => ({
68
69
  trackSplitTestView: vi.fn(),
70
+ getSplitTestVariantName: vi.fn(),
69
71
  }))
70
72
 
71
73
  vi.mock('$lib/api/ads', () => ({
@@ -0,0 +1,98 @@
1
+ import { render, fireEvent, waitFor } from '@testing-library/svelte'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ import HighlightedInjection from '../../../routes/components/HighlightedInjection.svelte'
5
+ import { generateInjection } from '../../helpers'
6
+ import { title } from '$lib/fakeData'
7
+ import { openModal } from '$lib/modal'
8
+
9
+ vi.mock('$lib/modal', () => ({
10
+ openModal: vi.fn(),
11
+ }))
12
+
13
+ /**
14
+ * @param {number} value
15
+ */
16
+ async function mockScroll(value) {
17
+ Object.defineProperty(window, 'scrollY', { value, configurable: true })
18
+ window.dispatchEvent(new Event('scroll'))
19
+
20
+ return new Promise(res => setTimeout(res))
21
+ }
22
+
23
+ describe('HighlightedInjection.svelte', () => {
24
+ beforeEach(() => {
25
+ vi.resetAllMocks()
26
+ })
27
+
28
+ const linkInjections = [
29
+ generateInjection('Some sentence', 'Some title'),
30
+ { ...generateInjection('Some sentence', 'Some title'), title_details: { ...title, title: 'Some second injection' } },
31
+ ]
32
+
33
+ it('Should show after scrolling the required distance', async () => {
34
+ const { queryByRole } = render(HighlightedInjection, { linkInjections })
35
+
36
+ await mockScroll(0)
37
+ expect(queryByRole('link')).not.toBeTruthy()
38
+
39
+ await mockScroll(200)
40
+ expect(queryByRole('link')).not.toBeTruthy()
41
+
42
+ await mockScroll(1000)
43
+ expect(queryByRole('link')).toBeTruthy()
44
+ })
45
+
46
+ it('Should not hide after being shown by scrolling', async () => {
47
+ const { queryByRole } = render(HighlightedInjection, { linkInjections })
48
+
49
+ await mockScroll(1000)
50
+ expect(queryByRole('link')).toBeTruthy()
51
+
52
+ await mockScroll(0)
53
+ expect(queryByRole('link')).toBeTruthy()
54
+ })
55
+
56
+ it('Should render the first injection in the list', async () => {
57
+ const { getByText } = render(HighlightedInjection, { linkInjections })
58
+
59
+ await mockScroll(1000)
60
+
61
+ // @ts-ignore
62
+ expect(getByText(linkInjections[0].title_details.title)).toBeTruthy()
63
+ })
64
+
65
+ it('Should render the injection most relevant to the document title', async () => {
66
+ // @ts-ignore
67
+ document.body.innerHTML = `<h1>${linkInjections[1].title_details.title}</h1>`
68
+
69
+ const { getByText } = render(HighlightedInjection, { linkInjections })
70
+
71
+ await mockScroll(1000)
72
+
73
+ // @ts-ignore
74
+ expect(getByText(linkInjections[1].title_details.title)).toBeTruthy()
75
+ })
76
+
77
+ it('Should open modal on click', async () => {
78
+ const { getByRole } = render(HighlightedInjection, { linkInjections })
79
+
80
+ await mockScroll(1000)
81
+ await fireEvent.click(getByRole('link'))
82
+
83
+ expect(openModal).toHaveBeenCalled()
84
+ })
85
+
86
+ it('Should close elements when close button is closed', async () => {
87
+ const { getByLabelText, queryByRole } = render(HighlightedInjection, { linkInjections })
88
+
89
+ await mockScroll(1000)
90
+ await fireEvent.click(getByLabelText('Close'))
91
+
92
+ await waitFor(() => {
93
+ expect(queryByRole('link')).not.toBeTruthy()
94
+ })
95
+
96
+ expect(openModal).not.toHaveBeenCalled()
97
+ })
98
+ })