@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.
- package/dist/link-injections.js +25 -10
- package/package.json +1 -1
- package/src/lib/afterArticle.ts +40 -0
- package/src/lib/api/api.ts +1 -1
- package/src/lib/api/titles.ts +13 -1
- package/src/lib/color.ts +19 -0
- package/src/lib/data/countries.json +216 -0
- package/src/lib/data/translations.ts +5 -0
- package/src/lib/disclaimer.ts +27 -0
- package/src/lib/enums/SplitTest.ts +5 -0
- package/src/lib/explore.ts +59 -0
- package/src/lib/fakeData.ts +1 -0
- package/src/lib/images/titles-list.webp +0 -0
- package/src/lib/injection.ts +41 -147
- package/src/lib/modal.ts +38 -7
- package/src/lib/popover.ts +71 -0
- package/src/lib/scss/global.scss +39 -2
- package/src/lib/trailer.ts +22 -0
- package/src/lib/types/api.d.ts +6 -0
- package/src/lib/types/config.d.ts +12 -0
- package/src/lib/types/filter.d.ts +2 -0
- package/src/lib/types/title.d.ts +4 -1
- package/src/routes/+page.svelte +13 -4
- package/src/routes/components/Ads/TopScroll.svelte +4 -18
- package/src/routes/components/Button.svelte +101 -0
- package/src/routes/components/Debugger.svelte +36 -0
- package/src/routes/components/Explore/Explore.svelte +226 -0
- package/src/routes/components/Explore/ExploreCallToAction.svelte +58 -0
- package/src/routes/components/Explore/ExploreModal.svelte +15 -0
- package/src/routes/components/Explore/Filter/Dropdown.svelte +72 -0
- package/src/routes/components/Explore/Filter/Filter.svelte +79 -0
- package/src/routes/components/Explore/Filter/FilterItem.svelte +57 -0
- package/src/routes/components/Explore/Filter/FilterSorting.svelte +70 -0
- package/src/routes/components/Explore/Filter/Search.svelte +56 -0
- package/src/routes/components/Explore/Filter/TogglesWithSearch.svelte +142 -0
- package/src/routes/components/GridTitle.svelte +122 -0
- package/src/routes/components/GridTitleSkeleton.svelte +36 -0
- package/src/routes/components/Icons/IconArrow.svelte +10 -2
- package/src/routes/components/Icons/IconClose.svelte +9 -1
- package/src/routes/components/Icons/IconFilter.svelte +5 -0
- package/src/routes/components/Icons/IconPlay.svelte +3 -0
- package/src/routes/components/Icons/IconSearch.svelte +3 -0
- package/src/routes/components/ListTitle.svelte +10 -68
- package/src/routes/components/ListTitleSkeleton.svelte +42 -0
- package/src/routes/components/Modal.svelte +27 -29
- package/src/routes/components/Participant.svelte +0 -2
- package/src/routes/components/ParticipantModal.svelte +1 -1
- package/src/routes/components/Playlinks/PlaylinkIcon.svelte +1 -1
- package/src/routes/components/Playlinks/PlaylinksCompact.svelte +71 -0
- package/src/routes/components/Share.svelte +5 -23
- package/src/routes/components/Title.svelte +22 -22
- package/src/routes/components/TitleModal.svelte +4 -1
- package/src/routes/components/Trailer.svelte +18 -0
- package/src/routes/components/YouTubeEmbedOverlay.svelte +96 -0
- package/src/routes/elements/+page.svelte +39 -2
- package/src/routes/explore/+page.svelte +60 -0
- package/src/tests/lib/afterArticle.test.js +108 -0
- package/src/tests/lib/api/ads.test.js +0 -1
- package/src/tests/lib/api/titles.test.js +55 -0
- package/src/tests/lib/disclaimer.test.js +90 -0
- package/src/tests/lib/explore.test.js +139 -0
- package/src/tests/lib/injections.test.js +5 -157
- package/src/tests/lib/modal.test.js +64 -1
- package/src/tests/lib/popover.test.js +70 -0
- package/src/tests/lib/trailer.test.js +56 -0
- package/src/tests/routes/components/Button.test.js +28 -0
- package/src/tests/routes/components/Explore/Explore.test.js +133 -0
- package/src/tests/routes/components/Explore/Filter/Dropdown.test.js +16 -0
- package/src/tests/routes/components/Explore/Filter/Filter.test.js +20 -0
- package/src/tests/routes/components/Explore/Filter/FilterItem.test.js +50 -0
- package/src/tests/routes/components/Explore/Filter/FilterSorting.test.js +34 -0
- package/src/tests/routes/components/Explore/Filter/Search.test.js +26 -0
- package/src/tests/routes/components/Explore/Filter/TogglesWithSearch.test.js +53 -0
- package/src/tests/routes/components/GridTitle.test.js +42 -0
- package/src/tests/routes/components/ListTitle.test.js +1 -1
- package/src/tests/routes/components/Playlinks/PlaylinksCompact.test.js +42 -0
- package/src/tests/routes/components/Share.test.js +12 -12
- package/src/tests/routes/components/Title.test.js +13 -0
- package/src/tests/routes/components/Trailer.test.js +20 -0
- package/src/tests/routes/components/YouTubeEmbedOverlay.test.js +31 -0
- package/src/tests/setup.js +2 -0
package/src/lib/injection.ts
CHANGED
|
@@ -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 {
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
421
|
+
window.addEventListener('mousemove', destroyLinkPopoverOnMouseleave)
|
|
414
422
|
window.addEventListener('click', (event) => {
|
|
415
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
541
|
-
if (
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
if (inTextDisclaimerInsertComponent) return
|
|
451
|
+
const linkElement = entry.target.querySelector('a')
|
|
452
|
+
if (!linkElement) return
|
|
552
453
|
|
|
553
|
-
|
|
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
|
-
|
|
559
|
-
insertElement.insertAdjacentElement(insertPosition, target)
|
|
456
|
+
;(entry.target as HTMLElement).style = `--animation-color: ${colorLuminance(linkColor, -0.5)}`
|
|
560
457
|
|
|
561
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|
package/src/lib/scss/global.scss
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
package/src/lib/types/title.d.ts
CHANGED
|
@@ -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:
|
|
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[]
|
package/src/routes/+page.svelte
CHANGED
|
@@ -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
|
|
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 {
|
|
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>
|