@playpilot/tpi 5.32.3 → 5.33.0-beta.explore.10

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.
Files changed (81) hide show
  1. package/dist/link-injections.js +25 -10
  2. package/package.json +1 -1
  3. package/src/lib/afterArticle.ts +40 -0
  4. package/src/lib/api/api.ts +1 -1
  5. package/src/lib/api/titles.ts +13 -1
  6. package/src/lib/color.ts +19 -0
  7. package/src/lib/data/countries.json +216 -0
  8. package/src/lib/data/translations.ts +5 -0
  9. package/src/lib/disclaimer.ts +27 -0
  10. package/src/lib/enums/SplitTest.ts +5 -0
  11. package/src/lib/explore.ts +59 -0
  12. package/src/lib/fakeData.ts +1 -0
  13. package/src/lib/images/titles-list.webp +0 -0
  14. package/src/lib/injection.ts +41 -147
  15. package/src/lib/modal.ts +38 -7
  16. package/src/lib/popover.ts +71 -0
  17. package/src/lib/scss/global.scss +39 -2
  18. package/src/lib/trailer.ts +22 -0
  19. package/src/lib/types/api.d.ts +6 -0
  20. package/src/lib/types/config.d.ts +12 -0
  21. package/src/lib/types/filter.d.ts +2 -0
  22. package/src/lib/types/title.d.ts +4 -1
  23. package/src/routes/+page.svelte +13 -4
  24. package/src/routes/components/Ads/TopScroll.svelte +4 -18
  25. package/src/routes/components/Button.svelte +101 -0
  26. package/src/routes/components/Debugger.svelte +36 -0
  27. package/src/routes/components/Explore/Explore.svelte +226 -0
  28. package/src/routes/components/Explore/ExploreCallToAction.svelte +58 -0
  29. package/src/routes/components/Explore/ExploreModal.svelte +15 -0
  30. package/src/routes/components/Explore/Filter/Dropdown.svelte +72 -0
  31. package/src/routes/components/Explore/Filter/Filter.svelte +79 -0
  32. package/src/routes/components/Explore/Filter/FilterItem.svelte +57 -0
  33. package/src/routes/components/Explore/Filter/FilterSorting.svelte +70 -0
  34. package/src/routes/components/Explore/Filter/Search.svelte +56 -0
  35. package/src/routes/components/Explore/Filter/TogglesWithSearch.svelte +142 -0
  36. package/src/routes/components/GridTitle.svelte +122 -0
  37. package/src/routes/components/GridTitleSkeleton.svelte +36 -0
  38. package/src/routes/components/Icons/IconArrow.svelte +10 -2
  39. package/src/routes/components/Icons/IconClose.svelte +9 -1
  40. package/src/routes/components/Icons/IconFilter.svelte +5 -0
  41. package/src/routes/components/Icons/IconPlay.svelte +3 -0
  42. package/src/routes/components/Icons/IconSearch.svelte +3 -0
  43. package/src/routes/components/ListTitle.svelte +10 -68
  44. package/src/routes/components/ListTitleSkeleton.svelte +42 -0
  45. package/src/routes/components/Modal.svelte +27 -29
  46. package/src/routes/components/Participant.svelte +0 -2
  47. package/src/routes/components/ParticipantModal.svelte +1 -1
  48. package/src/routes/components/Playlinks/PlaylinkIcon.svelte +1 -1
  49. package/src/routes/components/Playlinks/PlaylinksCompact.svelte +71 -0
  50. package/src/routes/components/Share.svelte +5 -23
  51. package/src/routes/components/Title.svelte +22 -22
  52. package/src/routes/components/TitleModal.svelte +4 -1
  53. package/src/routes/components/Trailer.svelte +18 -0
  54. package/src/routes/components/YouTubeEmbedOverlay.svelte +96 -0
  55. package/src/routes/elements/+page.svelte +39 -2
  56. package/src/routes/explore/+page.svelte +60 -0
  57. package/src/tests/lib/afterArticle.test.js +108 -0
  58. package/src/tests/lib/api/ads.test.js +0 -1
  59. package/src/tests/lib/api/titles.test.js +55 -0
  60. package/src/tests/lib/disclaimer.test.js +90 -0
  61. package/src/tests/lib/explore.test.js +139 -0
  62. package/src/tests/lib/injections.test.js +5 -157
  63. package/src/tests/lib/modal.test.js +64 -1
  64. package/src/tests/lib/popover.test.js +70 -0
  65. package/src/tests/lib/trailer.test.js +56 -0
  66. package/src/tests/routes/components/Button.test.js +28 -0
  67. package/src/tests/routes/components/Explore/Explore.test.js +133 -0
  68. package/src/tests/routes/components/Explore/Filter/Dropdown.test.js +16 -0
  69. package/src/tests/routes/components/Explore/Filter/Filter.test.js +20 -0
  70. package/src/tests/routes/components/Explore/Filter/FilterItem.test.js +50 -0
  71. package/src/tests/routes/components/Explore/Filter/FilterSorting.test.js +34 -0
  72. package/src/tests/routes/components/Explore/Filter/Search.test.js +26 -0
  73. package/src/tests/routes/components/Explore/Filter/TogglesWithSearch.test.js +53 -0
  74. package/src/tests/routes/components/GridTitle.test.js +42 -0
  75. package/src/tests/routes/components/ListTitle.test.js +1 -1
  76. package/src/tests/routes/components/Playlinks/PlaylinksCompact.test.js +42 -0
  77. package/src/tests/routes/components/Share.test.js +12 -12
  78. package/src/tests/routes/components/Title.test.js +13 -0
  79. package/src/tests/routes/components/Trailer.test.js +20 -0
  80. package/src/tests/routes/components/YouTubeEmbedOverlay.test.js +31 -0
  81. package/src/tests/setup.js +2 -0
@@ -1,23 +1,18 @@
1
- import { mount, unmount } from 'svelte'
2
- import TitlePopover from '../routes/components/TitlePopover.svelte'
3
- import AfterArticlePlaylinks from '../routes/components/Playlinks/AfterArticlePlaylinks.svelte'
4
1
  import { cleanPhrase, findNumberOfMatchesInString, findShortestMatchBetweenPhrases, findTextNodeContaining, getIndexOfPhraseInElement, getIndexOfPhraseInBoundary, getNumberOfLeadingAndTrailingSpaces, isNodeInLink, replaceBetween, replaceStartingFrom, findSurroundingPhrases } from './text'
5
2
  import type { LinkInjection, LinkInjectionTypes } from './types/injection'
6
- import { isHoldingSpecialKey } from './event'
7
- import { playFallbackViewTransition } from './viewTransition'
8
- import { prefersReducedMotion } from 'svelte/motion'
9
3
  import { getNumberOfOccurrencesInArray } from './array'
10
- import { mobileBreakpoint } from './constants'
11
- import { destroyAllModals, openModal } from './modal'
12
- import InTextDisclaimer from '../routes/components/InTextDisclaimer.svelte'
4
+ import { destroyAllModals, openModalForInjectedLink } from './modal'
5
+ import { getSplitTestVariantName } from './splitTest'
6
+ import { SplitTest } from './enums/SplitTest'
7
+ import { colorLuminance } from './color'
8
+ import { clearCurrentlyHoveredInjection, destroyLinkPopover, destroyLinkPopoverOnMouseleave, isPopoverActive, openPopoverForInjectedLink } from './popover'
9
+ import { clearAfterArticlePlaylinks, insertAfterArticlePlaylinks } from './afterArticle'
10
+ import { clearInTextDisclaimer, insertInTextDisclaimer } from './disclaimer'
13
11
 
14
12
  export const keyDataAttribute = 'data-playpilot-injection-key'
15
13
  export const keySelector = `[${keyDataAttribute}]`
16
14
 
17
- let currentlyHoveredInjection: EventTarget | null = null
18
- let activePopoverInsertedComponent: object | null = null
19
- let afterArticlePlaylinkInsertedComponent: object | null = null
20
- let inTextDisclaimerInsertComponent: object | null = null
15
+ const linksIntersectionObserver = typeof window !== 'undefined' ? new IntersectionObserver(animateLink) : null
21
16
 
22
17
  /**
23
18
  * Return a list of all valid text containing elements that may get injected into.
@@ -306,6 +301,19 @@ function createLinkInjectionElement(injection: LinkInjection): { injectionElemen
306
301
  linkElement.target = '_blank'
307
302
  linkElement.rel = 'noopener nofollow noreferrer'
308
303
 
304
+ // Part of a split test, insert an absolute positioned icon in the top right of links.
305
+ if (getSplitTestVariantName(SplitTest.InTextEngagement) === 'Clapper Icon' || getSplitTestVariantName(SplitTest.InTextEngagement) === 'Play Icon') {
306
+ const iconElement = document.createElement('span')
307
+ iconElement.classList.add('playpilot-injection-info-icon')
308
+ iconElement.dataset.playpilotElement = 'true'
309
+
310
+ const playIcon = '<svg width="8" height="8" viewBox="0 0 8 8" fill="none"><path d="M7.66011 4.53958C8.1133 4.31131 8.1133 3.68869 7.6601 3.46042L0.930122 0.0706345C0.507292 -0.142339 8.99396e-08 0.151949 8.42451e-08 0.610212L0 7.38979C-5.69452e-09 7.84805 0.507291 8.14234 0.930122 7.92937L7.66011 4.53958Z" fill="currentColor"/></svg>'
311
+ const clapperIcon = '<svg width="10" height="12" viewBox="0 0 13 16" fill="none"><rect x="0.950195" y="6.56641" width="12.0498" height="8.80563" rx="1" fill="currentColor" /><rect y="5.92969" width="12" height="2.4" rx="0.5" transform="rotate(-29.6116 0 5.92969)" fill="currentColor" /><path d="M8.82314 11.4484C9.02159 11.3443 9.02159 11.0602 8.82314 10.956L5.87605 9.40918C5.69089 9.312 5.46875 9.44629 5.46875 9.6554L5.46875 12.749C5.46875 12.9582 5.69089 13.0924 5.87605 12.9953L8.82314 11.4484Z" fill="white" /></svg>'
312
+
313
+ iconElement.innerHTML = getSplitTestVariantName(SplitTest.InTextEngagement) === 'Clapper Icon' ? clapperIcon : playIcon
314
+ linkElement.insertAdjacentElement('beforeend', iconElement)
315
+ }
316
+
309
317
  injectionElement.insertAdjacentElement('beforeend', linkElement)
310
318
 
311
319
  return { injectionElement, linkElement }
@@ -410,43 +418,12 @@ function addCSSVariablesToLinks(): void {
410
418
  * Add event listeners to all injected links. These events are for both the popover and the modal.
411
419
  */
412
420
  function addLinkInjectionEventListeners(injections: LinkInjection[]): void {
413
- // Open modal on click
421
+ window.addEventListener('mousemove', destroyLinkPopoverOnMouseleave)
414
422
  window.addEventListener('click', (event) => {
415
- if (isHoldingSpecialKey(event)) return
416
-
417
- let target = event.target as HTMLElement | null
418
- if (!target) return
419
-
420
- if (!target.hasAttribute(keyDataAttribute)) target = target.closest(keySelector)
421
- if (!target) return
422
-
423
- const key = target.getAttribute(keyDataAttribute)
424
- if (!key) return
425
-
426
- const injection = injections.find(injection => key === injection.key)
427
- if (!injection) return
428
-
429
- event.preventDefault()
430
-
431
- playFallbackViewTransition(() => {
432
- destroyLinkPopover(false)
433
- openModal({ event, injection, data: injection.title_details })
434
- }, !prefersReducedMotion.current && window.innerWidth >= mobileBreakpoint && !window.matchMedia('(pointer: coarse)').matches)
423
+ openModalForInjectedLink(event, injections)
435
424
  })
436
425
 
437
- window.addEventListener('mousemove', (event) => {
438
- if (!activePopoverInsertedComponent) return
439
-
440
- const target = event.target as Element
441
-
442
- // Mousemove is inside of popover or link that popover
443
- if (target.hasAttribute('data-playpilot-title-popover') || target.closest('[data-playpilot-title-popover]') ||
444
- target.hasAttribute(keyDataAttribute) || target.closest(keySelector)) return
445
-
446
- destroyLinkPopover()
447
- })
448
-
449
- const createdInjectionElements = Array.from(document.querySelectorAll<HTMLElement>(keySelector))
426
+ const createdInjectionElements = document.querySelectorAll<HTMLElement>(keySelector)
450
427
 
451
428
  // Open and close popover on mouseenter/mouseleave
452
429
  createdInjectionElements.forEach((injectionElement) => {
@@ -456,117 +433,31 @@ function addLinkInjectionEventListeners(injections: LinkInjection[]): void {
456
433
  if (!injection) return
457
434
 
458
435
  injectionElement.addEventListener('mouseenter', (event) => {
459
- if (!activePopoverInsertedComponent) openLinkPopover(event, injection)
436
+ if (!isPopoverActive()) openPopoverForInjectedLink(event, injection)
460
437
  })
461
438
 
462
- injectionElement.addEventListener('mouseleave', () => {
463
- currentlyHoveredInjection = null
464
- })
465
- })
466
- }
467
-
468
-
469
-
470
- /**
471
- * When a link is hovered, it is shown as a popover. The component is mounted when a mouse enters the link,
472
- * and removed when clicked or on mouseleave.
473
- */
474
- function openLinkPopover(event: MouseEvent, injection: LinkInjection): void {
475
- // Skip touch devices
476
- if (window.matchMedia('(pointer: coarse)').matches) return
477
- if (activePopoverInsertedComponent) destroyLinkPopover()
478
-
479
- const target = event.currentTarget as Element
480
- currentlyHoveredInjection = target
481
-
482
- // Only show if the link is hovered for more than 100ms. This is to prevent the popover from showing
483
- // when a user just happens to mouseover as they are moving their mouse about.
484
- setTimeout(() => {
485
- if (currentlyHoveredInjection !== target) return // User is no longer hovering this link
486
-
487
- activePopoverInsertedComponent = mount(TitlePopover, { target: getPlayPilotWrapperElement(), props: { event, title: injection.title_details! } })
488
- }, 100)
489
- }
490
-
491
- /**
492
- * Unmount the popover, removing it from the dom
493
- */
494
- async function destroyLinkPopover(outro: boolean = true) {
495
- if (activePopoverInsertedComponent) {
496
- const promise = unmount(activePopoverInsertedComponent, { outro })
497
-
498
- currentlyHoveredInjection = null
499
- activePopoverInsertedComponent = null
500
-
501
- // Await the unmount promise after setting the variables above to prevent race conditions when
502
- // mounting a new popover. The promise resolves after the element has transitioned fully out.
503
- await promise
504
- }
439
+ injectionElement.addEventListener('mouseleave', clearCurrentlyHoveredInjection)
505
440
 
506
- // In some cases a popover lingers even if it should have been removed. This happens sometimes during
507
- // HMR during development, but I've seen it happen on production too.
508
- // In that case we remove the element straight from the dom.
509
- // Doing this will prevent the outro animation from playing, but this being a fallback, that's ok.
510
- // TODO: Find the actual cause of this bug.
511
- document.querySelectorAll<HTMLElement>('[data-playpilot-title-popover]').forEach(element => element.remove())
512
- }
513
-
514
- /**
515
- * Insert AfterArticlePlaylinks after the last valid element or at the position given in the window config object.
516
- * The config object contains a selector option as well as a position. This way a selector can be given and you can
517
- * choose to insert the after article before or after the given element.
518
- */
519
- export function insertAfterArticlePlaylinks(elements: HTMLElement[], injections: LinkInjection[]): void {
520
- if (afterArticlePlaylinkInsertedComponent) return
521
- if (!injections.length) return
522
-
523
- const target = document.createElement('div')
524
- const afterArticleSelector = window.PlayPilotLinkInjections?.after_article_selector
525
- const insertElement = (afterArticleSelector ? document.querySelector(afterArticleSelector) : null) || elements[elements.length - 1]
526
- const insertPosition = window.PlayPilotLinkInjections?.after_article_insert_position || 'afterend'
527
-
528
- target.dataset.playpilotAfterArticlePlaylinks = 'true'
529
- insertElement.insertAdjacentElement(insertPosition, target)
530
-
531
- afterArticlePlaylinkInsertedComponent = mount(AfterArticlePlaylinks, {
532
- target,
533
- props: {
534
- linkInjections: injections,
535
- onclickmodal: (event, injection) => openModal({ event, injection, data: injection.title_details }),
536
- },
441
+ linksIntersectionObserver!.observe(injectionElement)
537
442
  })
538
443
  }
539
444
 
540
- function clearAfterArticlePlaylinks(): void {
541
- if (!afterArticlePlaylinkInsertedComponent) return
542
-
543
- unmount(afterArticlePlaylinkInsertedComponent)
544
- document.querySelector('[data-playpilot-after-article-playlinks]')?.remove()
545
- afterArticlePlaylinkInsertedComponent = null
546
- }
445
+ export function animateLink(entries: IntersectionObserverEntry[]): void {
446
+ if (getSplitTestVariantName(SplitTest.InTextEngagement) !== 'Animated Link') return
547
447
 
448
+ entries.forEach(entry => {
449
+ if (!entry.isIntersecting) return
548
450
 
549
- export function insertInTextDisclaimer(elements: HTMLElement[]) {
550
- if (!window.PlayPilotLinkInjections?.config?.in_text_disclaimer_enabled) return
551
- if (inTextDisclaimerInsertComponent) return
451
+ const linkElement = entry.target.querySelector('a')
452
+ if (!linkElement) return
552
453
 
553
- const target = document.createElement('div')
554
- const selector = window.PlayPilotLinkInjections?.config?.in_text_disclaimer_selector
555
- const insertElement = (selector ? document.querySelector(selector) : null) || elements[0]
556
- const insertPosition = window.PlayPilotLinkInjections?.config?.in_text_disclaimer_insert_position || 'beforebegin'
454
+ const linkColor = window.getComputedStyle(linkElement).color
557
455
 
558
- target.dataset.playpilotInTextDisclaimer = 'true'
559
- insertElement.insertAdjacentElement(insertPosition, target)
456
+ ;(entry.target as HTMLElement).style = `--animation-color: ${colorLuminance(linkColor, -0.5)}`
560
457
 
561
- inTextDisclaimerInsertComponent = mount(InTextDisclaimer, { target })
562
- }
563
-
564
- function clearInTextDisclaimer(): void {
565
- if (!inTextDisclaimerInsertComponent) return
566
-
567
- unmount(inTextDisclaimerInsertComponent)
568
- document.querySelector('[data-playpilot-in-text-disclaimer]')?.remove()
569
- inTextDisclaimerInsertComponent = null
458
+ entry.target.classList.remove('animate-injection')
459
+ setTimeout(() => entry.target.classList.add('animate-injection'))
460
+ })
570
461
  }
571
462
 
572
463
  /**
@@ -590,6 +481,9 @@ export function clearLinkInjection(key: string): void {
590
481
  const element: HTMLAnchorElement | null = document.querySelector(`[${keyDataAttribute}="${key}"]`)
591
482
  if (!element) return
592
483
 
484
+ const playpilotElements = element.querySelectorAll('[data-playpilot-element]')
485
+ playpilotElements.forEach(element => element.remove())
486
+
593
487
  const linkContent = element.querySelector('a')?.innerHTML
594
488
  element.outerHTML = linkContent || ''
595
489
  }
package/src/lib/modal.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  import { mount, unmount } from "svelte"
2
2
  import { isHoldingSpecialKey } from "./event"
3
- import TitleModal from "../routes/components/TitleModal.svelte"
4
3
  import type { LinkInjection } from "./types/injection"
5
- import ParticipantModal from "../routes/components/ParticipantModal.svelte"
6
4
  import type { TitleData } from "./types/title"
7
5
  import type { ParticipantData } from "./types/participant"
8
6
  import { mobileBreakpoint } from "./constants"
9
- import { getPlayPilotWrapperElement } from "./injection"
7
+ import TitleModal from "../routes/components/TitleModal.svelte"
8
+ import ParticipantModal from "../routes/components/ParticipantModal.svelte"
9
+ import ExploreModal from "../routes/components/Explore/ExploreModal.svelte"
10
+ import { getPlayPilotWrapperElement, keyDataAttribute, keySelector } from "./injection"
11
+ import { playFallbackViewTransition } from "./viewTransition"
12
+ import { destroyLinkPopover } from "./popover"
13
+ import { prefersReducedMotion } from "svelte/motion"
14
+ import { trackSplitTestAction } from "./splitTest"
15
+ import { SplitTest } from "./enums/SplitTest"
10
16
 
11
- type ModalType = 'title' | 'participant'
17
+ type ModalType = 'title' | 'participant' | 'explore'
12
18
 
13
19
  type Modal = {
14
20
  injection?: LinkInjection | null
@@ -46,9 +52,9 @@ export function openModal(
46
52
  }
47
53
 
48
54
  function getModalComponentByType({ type = 'title', target, data, props = {} }: { type: ModalType, target: Element, data: TitleData | ParticipantData | null, props?: Record<string, any> }) {
49
- return type === 'title' ?
50
- mount(TitleModal, { target, props: { title: data as TitleData, ...props } }) :
51
- mount(ParticipantModal, { target, props: { participant: data as ParticipantData, ...props } })
55
+ if (type === 'participant') return mount(ParticipantModal, { target, props: { participant: data as ParticipantData, ...props } })
56
+ if (type === 'explore') return mount(ExploreModal, { target, props: { ...props } })
57
+ return mount(TitleModal, { target, props: { title: data as TitleData, ...props } })
52
58
  }
53
59
 
54
60
  function addModalToList({ type = 'title', injection = null, data, scrollPosition = 0, component }: Modal) {
@@ -108,3 +114,28 @@ function saveCurrentModalScrollPosition(): void {
108
114
 
109
115
  modals[modals.length - 1].scrollPosition = element.scrollTop
110
116
  }
117
+
118
+ export function openModalForInjectedLink(event: MouseEvent, injections: LinkInjection[]): void {
119
+ if (isHoldingSpecialKey(event)) return
120
+
121
+ let target = event.target as HTMLElement | null
122
+ if (!target) return
123
+
124
+ if (!target.hasAttribute(keyDataAttribute)) target = target.closest(keySelector)
125
+ if (!target) return
126
+
127
+ const key = target.getAttribute(keyDataAttribute)
128
+ if (!key) return
129
+
130
+ const injection = injections.find(injection => key === injection.key)
131
+ if (!injection) return
132
+
133
+ event.preventDefault()
134
+
135
+ trackSplitTestAction(SplitTest.InTextEngagement, 'click')
136
+
137
+ playFallbackViewTransition(() => {
138
+ destroyLinkPopover(false)
139
+ openModal({ event, injection, data: injection.title_details })
140
+ }, !prefersReducedMotion.current && window.innerWidth >= mobileBreakpoint && !window.matchMedia('(pointer: coarse)').matches)
141
+ }
@@ -0,0 +1,71 @@
1
+ import { mount, unmount } from 'svelte'
2
+ import TitlePopover from '../routes/components/TitlePopover.svelte'
3
+ import { getPlayPilotWrapperElement, keyDataAttribute, keySelector } from './injection'
4
+ import type { LinkInjection } from './types/injection'
5
+
6
+ export let currentlyHoveredInjection: EventTarget | null = null
7
+ export let activePopoverInsertedComponent: object | null = null
8
+
9
+ /**
10
+ * When a link is hovered it is shown as a popover. The component is mounted when a mouse enters the link,
11
+ * and removed when clicked or on mouseleave.
12
+ */
13
+ export function openPopoverForInjectedLink(event: MouseEvent, injection: LinkInjection): void {
14
+ // Skip touch devices
15
+ if (window.matchMedia('(pointer: coarse)').matches) return
16
+ if (activePopoverInsertedComponent) destroyLinkPopover()
17
+
18
+ const target = event.currentTarget as Element
19
+ currentlyHoveredInjection = target
20
+
21
+ // Only show if the link is hovered for more than 100ms. This is to prevent the popover from showing
22
+ // when a user just happens to mouseover as they are moving their mouse about.
23
+ setTimeout(() => {
24
+ if (currentlyHoveredInjection !== target) return // User is no longer hovering this link
25
+
26
+ activePopoverInsertedComponent = mount(TitlePopover, { target: getPlayPilotWrapperElement(), props: { event, title: injection.title_details! } })
27
+ }, 100)
28
+ }
29
+
30
+ export async function destroyLinkPopover(outro: boolean = true) {
31
+ if (activePopoverInsertedComponent) {
32
+ const promise = unmount(activePopoverInsertedComponent, { outro })
33
+
34
+ currentlyHoveredInjection = null
35
+ activePopoverInsertedComponent = null
36
+
37
+ // Await the unmount promise after setting the variables above to prevent race conditions when
38
+ // mounting a new popover. The promise resolves after the element has transitioned fully out.
39
+ await promise
40
+ }
41
+
42
+ // In some cases a popover lingers even if it should have been removed. This happens sometimes during
43
+ // HMR during development, but I've seen it happen on production too.
44
+ // In that case we remove the element straight from the dom.
45
+ // Doing this will prevent the outro animation from playing, but this being a fallback, that's ok.
46
+ // TODO: Find the actual cause of this bug.
47
+ document.querySelectorAll<HTMLElement>('[data-playpilot-title-popover]').forEach(element => element.remove())
48
+ }
49
+
50
+ /**
51
+ * Destroy active the popover when the mouse leaves any popover element
52
+ */
53
+ export function destroyLinkPopoverOnMouseleave(event: MouseEvent): void {
54
+ if (!activePopoverInsertedComponent) return
55
+
56
+ const target = event.target as Element
57
+
58
+ // Mousemove is inside of popover or link that popover
59
+ if (target.hasAttribute('data-playpilot-title-popover') || target.closest('[data-playpilot-title-popover]') ||
60
+ target.hasAttribute(keyDataAttribute) || target.closest(keySelector)) return
61
+
62
+ destroyLinkPopover()
63
+ }
64
+
65
+ export function clearCurrentlyHoveredInjection() {
66
+ currentlyHoveredInjection = null
67
+ }
68
+
69
+ export function isPopoverActive() {
70
+ return !!activePopoverInsertedComponent
71
+ }
@@ -7,8 +7,47 @@
7
7
  }
8
8
  }
9
9
 
10
+ @keyframes animate-injection {
11
+ 0% {
12
+ background-position: 0% 0%;
13
+ }
14
+
15
+ 100% {
16
+ background-position: -200% 0%;
17
+ }
18
+ }
19
+
10
20
  [data-playpilot-injection-key] {
11
21
  position: relative;
22
+
23
+ &.animate-injection a {
24
+ background: linear-gradient(to right, currentColor 50%, var(--animation-color), currentcolor);
25
+ background-clip: text;
26
+ -webkit-background-clip: text;
27
+ -webkit-text-fill-color: transparent;
28
+ background-size: 200% auto;
29
+ animation: animate-injection 650ms 500ms ease-in-out forwards;
30
+ }
31
+ }
32
+
33
+ .playpilot-injection-info-icon {
34
+ position: relative;
35
+ display: inline-block;
36
+ height: 1em;
37
+
38
+ svg {
39
+ display: inline-flex;
40
+ justify-content: center;
41
+ align-items: center;
42
+ position: absolute;
43
+ top: -0.5em;
44
+ right: -0.25em;
45
+ font-size: 0.65em;
46
+ }
47
+
48
+ h1 &, h2 &, h3 &, h4 &, h5 &, h6 & {
49
+ display: none
50
+ }
12
51
  }
13
52
 
14
53
  .playpilot-injection-highlight {
@@ -21,9 +60,7 @@
21
60
  .playpilot-styled-scrollbar {
22
61
  scrollbar-color: var(--playpilot-content-light) var(--playpilot-lighter);
23
62
  scrollbar-width: thin;
24
- }
25
63
 
26
- .playpilot-styled-scrollbed {
27
64
  &::-webkit-scrollbar {
28
65
  width: margin(0.75);
29
66
  }
@@ -0,0 +1,22 @@
1
+ import { mount, unmount } from "svelte"
2
+ import { getPlayPilotWrapperElement } from "./injection"
3
+ import type { TitleData } from "./types/title"
4
+ import YouTubeEmbedOverlay from "../routes/components/YouTubeEmbedOverlay.svelte"
5
+
6
+ let currentTrailerComponent: object | null = {}
7
+
8
+ export function openTrailerOverlay(title: TitleData) {
9
+ const target = getPlayPilotWrapperElement()
10
+ // !! Temporarily falls back to a placeholder is while embeddable_url is not yet present
11
+ const props = { onclose: closeTrailerOverlay, embeddable_url: title.embeddable_url || 'https://www.youtube.com/watch?v=xGTq0blCPVQ' }
12
+
13
+ currentTrailerComponent = mount(YouTubeEmbedOverlay, { target, props })
14
+ }
15
+
16
+ export function closeTrailerOverlay(): void {
17
+ if (!currentTrailerComponent) return
18
+
19
+ unmount(currentTrailerComponent, { outro: true })
20
+
21
+ currentTrailerComponent = null
22
+ }
@@ -0,0 +1,6 @@
1
+ export type APIPaginatedResult<T> = {
2
+ next: string | null
3
+ previous: string | null
4
+ results: T[]
5
+ count?: number
6
+ }
@@ -39,4 +39,16 @@ export type ConfigResponse = {
39
39
  in_text_disclaimer_text?: string
40
40
  in_text_disclaimer_selector?: string
41
41
  in_text_disclaimer_insert_position?: InsertPosition
42
+
43
+ /**
44
+ * These options are all relevant for the Explore component, which can be inserted as a widget on any page or as a modal.
45
+ * `explore_navigation_selector` is used to select the navigation element that should be copied and inserted _after_.
46
+ * `explore_navigation_label` will end up being the label of the navigation item, defaults to `Streaming Guide`.
47
+ * `explore_navigation_path` is the path that the navigation item will lead to, this will be set up by the third party.
48
+ * `explore_navigation_insert_position` is used to determine if the navigation item should be inserted before or after the given selector.
49
+ */
50
+ explore_navigation_selector?: string
51
+ explore_navigation_label?: string
52
+ explore_navigation_path?: string
53
+ explore_navigation_insert_position?: InsertPosition
42
54
  }
@@ -0,0 +1,2 @@
1
+ // TODO: Add proper filter types
2
+ export type ExploreFilter = Record<string, string>
@@ -1,6 +1,8 @@
1
1
  import type { ParticipantData } from './participant'
2
2
  import type { PlaylinkData } from './playlink'
3
3
 
4
+ export type ContentType = 'movie' | 'series'
5
+
4
6
  export type TitleData = {
5
7
  sid: string
6
8
  slug: string
@@ -9,7 +11,7 @@ export type TitleData = {
9
11
  genres: string[]
10
12
  year: number
11
13
  imdb_score: number
12
- type: 'movie' | 'series'
14
+ type: ContentType
13
15
  providers: PlaylinkData[]
14
16
  description: string | null
15
17
  small_poster: string
@@ -17,6 +19,7 @@ export type TitleData = {
17
19
  standing_poster: string
18
20
  title: string
19
21
  original_title: string
22
+ embeddable_url: string | null
20
23
  length?: number
21
24
  blurb?: string
22
25
  participants?: ParticipantData[]
@@ -8,8 +8,9 @@
8
8
  import { TrackingEvent } from '$lib/enums/TrackingEvent'
9
9
  import { fetchAds } from '$lib/api/ads'
10
10
  import { fetchConfig } from '$lib/api/config'
11
+ import { insertExplore, insertExploreIntoNavigation } from '$lib/explore'
11
12
  import { authorize, getAuthToken, isEditorialModeEnabled, removeAuthCookie, setEditorialParamInUrl } from '$lib/api/auth'
12
- import { getSplitTestVariantName } from '$lib/splitTest'
13
+ import { trackSplitTestView, getSplitTestVariantName } from '$lib/splitTest'
13
14
  import { SplitTest } from '$lib/enums/SplitTest'
14
15
  import type { LinkInjectionResponse, LinkInjection, LinkInjectionTypes } from '$lib/types/injection'
15
16
  import Editor from './components/Editorial/Editor.svelte'
@@ -44,6 +45,8 @@
44
45
  if (isEditorialMode && !loading) rerender()
45
46
  })
46
47
 
48
+ insertExplore()
49
+
47
50
  onDestroy(clearLinkInjections)
48
51
 
49
52
  // This function is called when a user has properly consented via tcfapi or if no consent is required.
@@ -57,7 +60,11 @@
57
60
  fireQueuedTrackingEvents()
58
61
  track(TrackingEvent.ArticlePageView)
59
62
 
60
- if (aiInjections.length || manualInjections.length) window.PlayPilotLinkInjections.ads = await fetchAds()
63
+ if (!aiInjections.length && !manualInjections.length) return
64
+
65
+ window.PlayPilotLinkInjections.ads = await fetchAds()
66
+
67
+ trackSplitTestView(SplitTest.InTextEngagement)
61
68
  }
62
69
 
63
70
  async function initialize(): Promise<void> {
@@ -76,6 +83,7 @@
76
83
  if (isUrlExcluded) return
77
84
 
78
85
  if (config?.custom_style) insertCustomStyle(config.custom_style || '')
86
+ if (config?.explore_navigation_selector) insertExploreIntoNavigation()
79
87
 
80
88
  setElements(config?.html_selector || '', config?.exclude_elements_selector || '')
81
89
  } catch(error) {
@@ -234,7 +242,7 @@
234
242
  </svelte:boundary>
235
243
  {/if}
236
244
 
237
- <Debugger />
245
+ <Debugger onrerender={rerender} />
238
246
  </div>
239
247
 
240
248
  {#if response?.pixels?.length}
@@ -250,7 +258,8 @@
250
258
  <style lang="scss">
251
259
  @import url('$lib/scss/global.scss');
252
260
 
253
- .playpilot-link-injections {
261
+ .playpilot-link-injections,
262
+ :global([data-playpilot-explore]) {
254
263
  :global(*) {
255
264
  box-sizing: border-box;
256
265
  }
@@ -4,7 +4,7 @@
4
4
  import { SplitTest } from '$lib/enums/SplitTest'
5
5
  import { TrackingEvent } from '$lib/enums/TrackingEvent'
6
6
  import { imageFromUUID } from '$lib/image'
7
- import { isInSplitTestVariant, trackSplitTestView } from '$lib/splitTest'
7
+ import { trackSplitTestView } from '$lib/splitTest'
8
8
  import { track } from '$lib/tracking'
9
9
  import type { Campaign } from '$lib/types/campaign'
10
10
  import Disclaimer from './Disclaimer.svelte'
@@ -22,7 +22,6 @@
22
22
  const { format, header, header_logo: logo, image_uuid: backgroundImageUUID } = $derived(content)
23
23
  const { header: buttonLabel, url: href } = $derived(cta)
24
24
 
25
- const inline = isInSplitTestVariant(SplitTest.TopScrollFormat, 1)
26
25
  const simple = $derived(format === 'large')
27
26
 
28
27
  const backgroundImage = $derived(imageFromUUID(backgroundImageUUID, ImageDimensions.TopScrollBackground))
@@ -43,7 +42,6 @@
43
42
  target="_blank"
44
43
  class="top-scroll"
45
44
  class:simple
46
- class:inline
47
45
  tabindex="-1"
48
46
  rel="sponsored"
49
47
  style:--width="{clientWidth}px">
@@ -81,17 +79,13 @@
81
79
  position: relative;
82
80
  display: block;
83
81
  width: 100%;
84
- border-radius: $border-radius-size;
82
+ border-radius: $border-radius-size $border-radius-size 0 0;
85
83
  background: black;
86
84
  color: theme(top-scroll-text-color, white);
87
85
  font-family: theme(top-scroll-font-family, font-family);
88
86
  font-size: theme(top-scroll-font-size, font-size-base);
89
87
  text-decoration: none;
90
88
  line-height: 1.35;
91
-
92
- &.inline {
93
- border-radius: $border-radius-size $border-radius-size 0 0;
94
- }
95
89
  }
96
90
 
97
91
  .content {
@@ -152,7 +146,7 @@
152
146
  right: 0;
153
147
  bottom: 0;
154
148
  left: 0;
155
- border-radius: $border-radius-size;
149
+ border-radius: $border-radius-size $border-radius-size 0 0;
156
150
  background-image: var(--background);
157
151
  background-position: center;
158
152
  background-size: cover;
@@ -161,10 +155,6 @@
161
155
  .top-scroll:hover & {
162
156
  filter: brightness(1.15);
163
157
  }
164
-
165
- .inline & {
166
- border-radius: $border-radius-size $border-radius-size 0 0;
167
- }
168
158
  }
169
159
 
170
160
  .content-image {
@@ -172,14 +162,10 @@
172
162
  max-width: 100%;
173
163
  height: auto;
174
164
  background: black;
175
- border-radius: $border-radius-size;
165
+ border-radius: $border-radius-size $border-radius-size 0 0;
176
166
 
177
167
  .top-scroll:hover & {
178
168
  filter: brightness(1.15);
179
169
  }
180
-
181
- .inline & {
182
- border-radius: $border-radius-size $border-radius-size 0 0;
183
- }
184
170
  }
185
171
  </style>