@playpilot/tpi 5.14.0 → 5.16.0
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 +10 -9
- package/package.json +2 -1
- package/src/lib/ads.ts +5 -0
- package/src/lib/consent.ts +13 -0
- package/src/lib/enums/SplitTest.ts +4 -0
- package/src/lib/fakeData.ts +70 -0
- package/src/lib/linkInjection.ts +11 -29
- package/src/lib/modal.ts +97 -0
- package/src/lib/playlink.ts +4 -1
- package/src/lib/splitTest.ts +5 -0
- package/src/lib/tracking.ts +3 -0
- package/src/lib/types/consent.d.ts +9 -0
- package/src/lib/types/participant.d.ts +14 -0
- package/src/lib/types/script.d.ts +3 -0
- package/src/lib/types/title.d.ts +2 -0
- package/src/main.ts +21 -1
- package/src/routes/+layout.svelte +20 -3
- package/src/routes/+page.svelte +14 -10
- package/src/routes/components/Consent.svelte +72 -0
- package/src/routes/components/Icons/IconArrow.svelte +22 -0
- package/src/routes/components/Icons/IconClose.svelte +1 -1
- package/src/routes/components/Icons/IconIMDb.svelte +9 -1
- package/src/routes/components/ListTitle.svelte +204 -0
- package/src/routes/components/Modal.svelte +63 -13
- package/src/routes/components/Participant.svelte +92 -0
- package/src/routes/components/ParticipantModal.svelte +31 -0
- package/src/routes/components/Playlink.svelte +16 -4
- package/src/routes/components/PlaylinkIcon.svelte +41 -0
- package/src/routes/components/PlaylinkLabel.svelte +37 -0
- package/src/routes/components/Playlinks.svelte +1 -3
- package/src/routes/components/Rails/ParticipantsRail.svelte +56 -0
- package/src/routes/components/Rails/Rail.svelte +91 -0
- package/src/routes/components/Rails/SimilarRail.svelte +16 -0
- package/src/routes/components/Rails/TitlesRail.svelte +95 -0
- package/src/routes/components/Tabs.svelte +47 -0
- package/src/routes/components/Title.svelte +19 -16
- package/src/routes/components/TitleModal.svelte +3 -3
- package/src/routes/components/TitlePoster.svelte +30 -0
- package/src/routes/components/TrackingPixels.svelte +7 -3
- package/src/tests/lib/ads.test.js +24 -1
- package/src/tests/lib/consent.test.js +50 -0
- package/src/tests/lib/linkInjection.test.js +10 -22
- package/src/tests/lib/modal.test.js +148 -0
- package/src/tests/lib/playlink.test.js +25 -10
- package/src/tests/lib/splitTest.test.js +30 -6
- package/src/tests/lib/tracking.test.js +18 -3
- package/src/tests/routes/components/Consent.test.js +69 -0
- package/src/tests/routes/components/ListTitle.test.js +84 -0
- package/src/tests/routes/components/Modal.test.js +51 -19
- package/src/tests/routes/components/Playlink.test.js +22 -1
- package/src/tests/routes/components/PlaylinkIcon.test.js +27 -0
- package/src/tests/routes/components/PlaylinkLabel.test.js +19 -0
- package/src/tests/routes/components/Rails/ParticipantsRail.test.js +41 -0
- package/src/tests/routes/components/Rails/TitleRail.test.js +38 -0
- package/src/tests/routes/components/TitleModal.test.js +6 -0
- package/src/tests/routes/components/TitlePopover.test.js +6 -0
- package/src/tests/routes/components/TitlePoster.test.js +20 -0
- package/src/tests/routes/components/TrackingPixels.test.js +15 -1
- package/src/tests/setup.js +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playpilot/tpi",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "vite dev",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"svelte": "^5.0.0",
|
|
33
33
|
"svelte-check": "^4.0.0",
|
|
34
34
|
"svelte-preprocess": "^6.0.3",
|
|
35
|
+
"svelte-tiny-slider": "^2.3.0",
|
|
35
36
|
"typescript": "^5.0.0",
|
|
36
37
|
"typescript-eslint": "^8.32.1",
|
|
37
38
|
"vite": "^5.0.3",
|
package/src/lib/ads.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getApiToken } from "./api"
|
|
2
|
+
import { hasConsentedTo } from "./consent"
|
|
2
3
|
import { apiBaseUrl } from "./constants"
|
|
3
4
|
import { TrackingEvent } from "./enums/TrackingEvent"
|
|
4
5
|
import { track } from "./tracking"
|
|
@@ -6,6 +7,8 @@ import type { Campaign, CampaignFormat } from "./types/campaign"
|
|
|
6
7
|
import type { PlaylinkData } from "./types/playlink"
|
|
7
8
|
|
|
8
9
|
export async function fetchAds() {
|
|
10
|
+
if (!hasConsentedTo('ads')) return
|
|
11
|
+
|
|
9
12
|
const headers = new Headers({ 'Content-Type': 'application/json' })
|
|
10
13
|
const apiToken = getApiToken()
|
|
11
14
|
|
|
@@ -29,6 +32,8 @@ export async function fetchAds() {
|
|
|
29
32
|
* ad of the given format, which will likely always be the most recently added (or only) ad of that format.
|
|
30
33
|
*/
|
|
31
34
|
export function getFirstAdOfType(format: CampaignFormat): Campaign | null {
|
|
35
|
+
if (!hasConsentedTo('ads')) return null
|
|
36
|
+
|
|
32
37
|
return (window.PlayPilotLinkInjections?.ads || []).find(i => i.campaign_format === format) || null
|
|
33
38
|
}
|
|
34
39
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ConsentKeys, ConsentOptions } from "./types/consent"
|
|
2
|
+
|
|
3
|
+
export function setConsent(options: ConsentOptions): void {
|
|
4
|
+
if (!window.PlayPilotLinkInjections) return
|
|
5
|
+
|
|
6
|
+
window.PlayPilotLinkInjections.consents = options
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function hasConsentedTo(key: ConsentKeys): boolean {
|
|
10
|
+
if (window.PlayPilotLinkInjections.require_consent === false) return true
|
|
11
|
+
|
|
12
|
+
return !!window.PlayPilotLinkInjections.consents?.[key]
|
|
13
|
+
}
|
package/src/lib/fakeData.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Campaign } from "./types/campaign"
|
|
2
2
|
import type { LinkInjection } from "./types/injection"
|
|
3
|
+
import type { ParticipantData } from "./types/participant"
|
|
3
4
|
import type { TitleData } from "./types/title"
|
|
4
5
|
|
|
5
6
|
export const title: TitleData = {
|
|
@@ -71,6 +72,75 @@ export const linkInjections: LinkInjection[] = [{
|
|
|
71
72
|
key: 'some-key-4',
|
|
72
73
|
}]
|
|
73
74
|
|
|
75
|
+
export const participants: ParticipantData[] = [
|
|
76
|
+
{
|
|
77
|
+
sid: 'pr5C5W',
|
|
78
|
+
name: 'James Franco',
|
|
79
|
+
birth_date: '1978-04-19',
|
|
80
|
+
death_date: null,
|
|
81
|
+
jobs: ['actor'],
|
|
82
|
+
image: null,
|
|
83
|
+
image_uuid: null,
|
|
84
|
+
gender: 'Male',
|
|
85
|
+
character: 'Will Rodman (archive footage) (uncredited)',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
sid: 'pr8bZm',
|
|
89
|
+
name: 'Thomas Rosales Jr.',
|
|
90
|
+
birth_date: '1948-02-03',
|
|
91
|
+
death_date: null,
|
|
92
|
+
jobs: ['actor'],
|
|
93
|
+
image: null,
|
|
94
|
+
image_uuid: null,
|
|
95
|
+
gender: 'Male',
|
|
96
|
+
character: 'Old Man',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
sid: 'pr45Dp',
|
|
100
|
+
name: 'Barack Obama',
|
|
101
|
+
birth_date: '1961-08-04',
|
|
102
|
+
death_date: null,
|
|
103
|
+
jobs: ['actor'],
|
|
104
|
+
image: null,
|
|
105
|
+
image_uuid: null,
|
|
106
|
+
gender: 'Male',
|
|
107
|
+
character: 'Self (archive footage) (uncredited)',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
sid: 'pr6DnN',
|
|
111
|
+
name: 'Gary Oldman',
|
|
112
|
+
birth_date: '1958-03-21',
|
|
113
|
+
death_date: null,
|
|
114
|
+
jobs: ['actor'],
|
|
115
|
+
image: null,
|
|
116
|
+
image_uuid: null,
|
|
117
|
+
gender: 'Male',
|
|
118
|
+
character: 'Dreyfus',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
sid: 'pr7GK8',
|
|
122
|
+
name: 'Michael Papajohn',
|
|
123
|
+
birth_date: '1964-11-07',
|
|
124
|
+
death_date: null,
|
|
125
|
+
jobs: ['actor'],
|
|
126
|
+
image: null,
|
|
127
|
+
image_uuid: null,
|
|
128
|
+
gender: 'Male',
|
|
129
|
+
character: 'Cannon-Gunner',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
sid: 'pr88KG',
|
|
133
|
+
name: 'Judy Greer',
|
|
134
|
+
birth_date: '1975-07-20',
|
|
135
|
+
death_date: null,
|
|
136
|
+
jobs: ['actor'],
|
|
137
|
+
image: null,
|
|
138
|
+
image_uuid: null,
|
|
139
|
+
gender: 'Female',
|
|
140
|
+
character: 'Cornelia',
|
|
141
|
+
},
|
|
142
|
+
]
|
|
143
|
+
|
|
74
144
|
export const campaign: Campaign = {
|
|
75
145
|
campaign_format: 'card',
|
|
76
146
|
campaign_type: 'image',
|
package/src/lib/linkInjection.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { mount, unmount } from 'svelte'
|
|
2
|
-
import TitleModal from '../routes/components/TitleModal.svelte'
|
|
3
2
|
import TitlePopover from '../routes/components/TitlePopover.svelte'
|
|
4
3
|
import AfterArticlePlaylinks from '../routes/components/AfterArticlePlaylinks.svelte'
|
|
5
4
|
import { cleanPhrase, findNumberOfMatchesInString, findShortestMatchBetweenPhrases, findTextNodeContaining, getIndexOfPhraseInElement, getNumberOfLeadingAndTrailingSpaces, isNodeInLink, replaceBetween, replaceStartingFrom } from './text'
|
|
@@ -9,6 +8,7 @@ import { playFallbackViewTransition } from './viewTransition'
|
|
|
9
8
|
import { prefersReducedMotion } from 'svelte/motion'
|
|
10
9
|
import { getNumberOfOccurrencesInArray } from './array'
|
|
11
10
|
import { mobileBreakpoint } from './constants'
|
|
11
|
+
import { destroyAllModals, openModal } from './modal'
|
|
12
12
|
import { isEditorialModeEnabled } from './auth'
|
|
13
13
|
import { track } from './tracking'
|
|
14
14
|
import { TrackingEvent } from './enums/TrackingEvent'
|
|
@@ -21,7 +21,6 @@ const linksIntersectionObserver = typeof window !== 'undefined' ? new Intersecti
|
|
|
21
21
|
let currentlyHoveredInjection: EventTarget | null = null
|
|
22
22
|
let activePopoverInsertedComponent: object | null = null
|
|
23
23
|
let afterArticlePlaylinkInsertedComponent: object | null = null
|
|
24
|
-
let activeModalInsertedComponent: object | null = null
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
26
|
* Return a list of all valid text containing elements that may get injected into.
|
|
@@ -353,7 +352,7 @@ function addLinkInjectionEventListeners(injections: LinkInjection[]): void {
|
|
|
353
352
|
|
|
354
353
|
playFallbackViewTransition(() => {
|
|
355
354
|
destroyLinkPopover(false)
|
|
356
|
-
|
|
355
|
+
openModal({ event, injection, data: injection.title_details })
|
|
357
356
|
}, !prefersReducedMotion.current && window.innerWidth >= mobileBreakpoint && !window.matchMedia("(pointer: coarse)").matches)
|
|
358
357
|
})
|
|
359
358
|
|
|
@@ -403,29 +402,6 @@ export function trackLinkIntersection(entries: IntersectionObserverEntry[]): voi
|
|
|
403
402
|
})
|
|
404
403
|
}
|
|
405
404
|
|
|
406
|
-
/**
|
|
407
|
-
* Open modal for the corresponding injection by mounting the component and saving it to a variable.
|
|
408
|
-
* Ignore clicks that used modifier keys or that were not left click.
|
|
409
|
-
*/
|
|
410
|
-
function openLinkModal(event: MouseEvent, injection: LinkInjection): void {
|
|
411
|
-
if (isHoldingSpecialKey(event)) return
|
|
412
|
-
if (activeModalInsertedComponent) return
|
|
413
|
-
|
|
414
|
-
event.preventDefault()
|
|
415
|
-
|
|
416
|
-
activeModalInsertedComponent = mount(TitleModal, { target: getPlayPilotWrapperElement(), props: { title: injection.title_details!, onclose: destroyLinkModal } })
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Unmount the modal, removing it from the dom
|
|
421
|
-
*/
|
|
422
|
-
function destroyLinkModal(outro: boolean = true): void {
|
|
423
|
-
if (!activeModalInsertedComponent) return
|
|
424
|
-
|
|
425
|
-
unmount(activeModalInsertedComponent, { outro })
|
|
426
|
-
activeModalInsertedComponent = null
|
|
427
|
-
}
|
|
428
|
-
|
|
429
405
|
/**
|
|
430
406
|
* When a link is hovered, it is shown as a popover. The component is mounted when a mouse enters the link,
|
|
431
407
|
* and removed when clicked or on mouseleave.
|
|
@@ -483,7 +459,13 @@ export function insertAfterArticlePlaylinks(elements: HTMLElement[], injections:
|
|
|
483
459
|
target.dataset.playpilotAfterArticlePlaylinks = 'true'
|
|
484
460
|
insertElement.insertAdjacentElement(insertPosition, target)
|
|
485
461
|
|
|
486
|
-
afterArticlePlaylinkInsertedComponent = mount(AfterArticlePlaylinks, {
|
|
462
|
+
afterArticlePlaylinkInsertedComponent = mount(AfterArticlePlaylinks, {
|
|
463
|
+
target,
|
|
464
|
+
props: {
|
|
465
|
+
linkInjections: injections,
|
|
466
|
+
onclickmodal: (event, injection) => openModal({ event, injection, data: injection.title_details })
|
|
467
|
+
}
|
|
468
|
+
})
|
|
487
469
|
}
|
|
488
470
|
|
|
489
471
|
function clearAfterArticlePlaylinks(): void {
|
|
@@ -502,7 +484,7 @@ export function clearLinkInjections(): void {
|
|
|
502
484
|
elements.forEach((element) => clearLinkInjection(element.getAttribute(keyDataAttribute) || ''))
|
|
503
485
|
|
|
504
486
|
clearAfterArticlePlaylinks()
|
|
505
|
-
|
|
487
|
+
destroyAllModals(false)
|
|
506
488
|
destroyLinkPopover(false)
|
|
507
489
|
|
|
508
490
|
linksIntersectionObserver?.disconnect()
|
|
@@ -611,6 +593,6 @@ export function isEquivalentInjection(injection1: LinkInjection, injection2: Lin
|
|
|
611
593
|
return injection1.title === injection2.title && cleanPhrase(injection1.sentence) === cleanPhrase(injection2.sentence)
|
|
612
594
|
}
|
|
613
595
|
|
|
614
|
-
function getPlayPilotWrapperElement(): Element {
|
|
596
|
+
export function getPlayPilotWrapperElement(): Element {
|
|
615
597
|
return document.querySelector('[data-playpilot-link-injections]') || document.body
|
|
616
598
|
}
|
package/src/lib/modal.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mount, unmount } from "svelte"
|
|
2
|
+
import { isHoldingSpecialKey } from "./event"
|
|
3
|
+
import TitleModal from "../routes/components/TitleModal.svelte"
|
|
4
|
+
import type { LinkInjection } from "./types/injection"
|
|
5
|
+
import { getPlayPilotWrapperElement } from "./linkInjection"
|
|
6
|
+
import ParticipantModal from "../routes/components/ParticipantModal.svelte"
|
|
7
|
+
import type { TitleData } from "./types/title"
|
|
8
|
+
import type { ParticipantData } from "./types/participant"
|
|
9
|
+
import { mobileBreakpoint } from "./constants"
|
|
10
|
+
|
|
11
|
+
type ModalType = 'title' | 'participant'
|
|
12
|
+
|
|
13
|
+
type Modal = {
|
|
14
|
+
injection?: LinkInjection | null
|
|
15
|
+
component: object
|
|
16
|
+
type: ModalType
|
|
17
|
+
data: TitleData | ParticipantData | null
|
|
18
|
+
scrollPosition?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const modals: Modal[] = []
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Open modal for the corresponding injection by mounting the component and saving it to a variable.
|
|
25
|
+
* Ignore clicks that used modifier keys or that were not left click.
|
|
26
|
+
*/
|
|
27
|
+
export function openModal(
|
|
28
|
+
{ type = 'title', event = null, injection = null, data = null, scrollPosition = 0 }:
|
|
29
|
+
{ type?: ModalType, event?: MouseEvent | null, injection?: LinkInjection | null, data?: TitleData | ParticipantData | null, scrollPosition?: number } = {}): void {
|
|
30
|
+
if (event && isHoldingSpecialKey(event)) return
|
|
31
|
+
|
|
32
|
+
event?.preventDefault()
|
|
33
|
+
|
|
34
|
+
if (modals?.length) closeCurrentModal()
|
|
35
|
+
|
|
36
|
+
const target = getPlayPilotWrapperElement()
|
|
37
|
+
const sharedProps = { initialScrollPosition: scrollPosition }
|
|
38
|
+
const component = type === 'title' ?
|
|
39
|
+
mount(TitleModal, { target, props: { title: data as TitleData, ...sharedProps } }) :
|
|
40
|
+
mount(ParticipantModal, { target, props: { participant: data as ParticipantData, ...sharedProps } })
|
|
41
|
+
|
|
42
|
+
modals.push({ type, injection, data, scrollPosition, component })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Unmount the last modal is the list of modals but keep it in the list of active modals */
|
|
46
|
+
export function closeCurrentModal(outro: boolean = true): void {
|
|
47
|
+
if (!modals.length) return
|
|
48
|
+
|
|
49
|
+
saveCurrentModalScrollPosition()
|
|
50
|
+
|
|
51
|
+
unmount(modals[modals.length - 1].component, { outro })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Unmount the last modal is the list of modals and remove it from the list */
|
|
55
|
+
export function destroyCurrentModal(outro: boolean = true): void {
|
|
56
|
+
closeCurrentModal(outro)
|
|
57
|
+
|
|
58
|
+
modals.pop()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function destroyAllModals(outro: boolean = true): void {
|
|
62
|
+
closeCurrentModal(outro)
|
|
63
|
+
|
|
64
|
+
while (modals.length > 0) modals.pop()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @returns The modal before the currently active modal, if any */
|
|
68
|
+
export function getPreviousModal(): Modal | null {
|
|
69
|
+
return modals[modals.length - 2] || null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function goBackToPreviousModal(): void {
|
|
73
|
+
if (modals.length < 2) return
|
|
74
|
+
|
|
75
|
+
destroyCurrentModal()
|
|
76
|
+
|
|
77
|
+
// Get the previous modal from the array by removing it, we're re-adding it when calling openModal
|
|
78
|
+
const previousModal = modals.pop()
|
|
79
|
+
if (!previousModal) return
|
|
80
|
+
|
|
81
|
+
openModal({ ...previousModal })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getAllModals(): Modal[] {
|
|
85
|
+
return modals
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function saveCurrentModalScrollPosition(): void {
|
|
89
|
+
// The scrollable element differs on mobile and desktop.
|
|
90
|
+
// On desktop you scroll the entire parent but on mobile you scroll the dialog itself.
|
|
91
|
+
const selector = `[data-playpilot-link-injections] ${window.innerWidth > mobileBreakpoint ? '.modal' : '.dialog'}`
|
|
92
|
+
const element = document.querySelector(selector)
|
|
93
|
+
|
|
94
|
+
if (!element?.scrollTop) return
|
|
95
|
+
|
|
96
|
+
modals[modals.length - 1].scrollPosition = element.scrollTop
|
|
97
|
+
}
|
package/src/lib/playlink.ts
CHANGED
|
@@ -2,11 +2,14 @@ import type { PlaylinkData } from "./types/playlink"
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Merge playlinks of the same provider of BUY and RENT categories into a shared TVOD category.
|
|
5
|
+
* Also remove playlinks without logos, as these are likely sub providers.
|
|
5
6
|
*/
|
|
6
7
|
export function mergePlaylinks(playlinks: PlaylinkData[]): PlaylinkData[] {
|
|
8
|
+
const filtered = playlinks.filter(playlink => !!playlink.logo_url)
|
|
9
|
+
|
|
7
10
|
let merged: PlaylinkData[] = []
|
|
8
11
|
|
|
9
|
-
for (const playlink of
|
|
12
|
+
for (const playlink of filtered) {
|
|
10
13
|
let newPlaylink = playlink
|
|
11
14
|
const existingPlaylink = merged.find(p => p.name === newPlaylink.name)
|
|
12
15
|
|
package/src/lib/splitTest.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasConsentedTo } from "./consent"
|
|
1
2
|
import { TrackingEvent } from "./enums/TrackingEvent"
|
|
2
3
|
import { track } from "./tracking"
|
|
3
4
|
|
|
@@ -12,6 +13,8 @@ type SplitTest = {
|
|
|
12
13
|
* The identifier is saved on the window object so that it is consistent across this session.
|
|
13
14
|
*/
|
|
14
15
|
export function getSplitTestIdentifier({ key }: SplitTest): number {
|
|
16
|
+
if (!hasConsentedTo('tracking')) return 0
|
|
17
|
+
|
|
15
18
|
const windowIdentifier = window.PlayPilotLinkInjections?.split_test_identifiers?.[key]
|
|
16
19
|
if (windowIdentifier) return windowIdentifier
|
|
17
20
|
|
|
@@ -39,6 +42,8 @@ export function getSplitTestVariantName(test: SplitTest): string {
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export function isInSplitTestVariant(test: SplitTest, variant = 1): boolean {
|
|
45
|
+
if (!hasConsentedTo('tracking')) return false
|
|
46
|
+
|
|
42
47
|
return getSplitTestVariantIndex(test) === variant
|
|
43
48
|
}
|
|
44
49
|
|
package/src/lib/tracking.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasConsentedTo } from "./consent"
|
|
1
2
|
import { mobileBreakpoint } from "./constants"
|
|
2
3
|
import type { TitleData } from "./types/title"
|
|
3
4
|
import { getFullUrlPath } from "./url"
|
|
@@ -12,6 +13,8 @@ const baseUrl = 'https://insights.playpilot.net'
|
|
|
12
13
|
* @param [payload] Any data that will be included with the event
|
|
13
14
|
*/
|
|
14
15
|
export async function track(event: string, title: TitleData | null = null, payload: Record<string, any> = {}): Promise<void> {
|
|
16
|
+
if (!hasConsentedTo('tracking')) return
|
|
17
|
+
|
|
15
18
|
const headers = new Headers({ 'Content-Type': 'application/json' })
|
|
16
19
|
|
|
17
20
|
if (title) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type ParticipantData = {
|
|
2
|
+
sid: string
|
|
3
|
+
name: string
|
|
4
|
+
birth_date: string
|
|
5
|
+
death_date: string | null
|
|
6
|
+
jobs: Job[]
|
|
7
|
+
image: string | null
|
|
8
|
+
image_uuid: string | null
|
|
9
|
+
gender: string
|
|
10
|
+
character?: string | null
|
|
11
|
+
bio?: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type Job = 'actor' | 'writer' | 'director'
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Campaign } from "./campaign"
|
|
2
|
+
import type { ConsentOptions } from "./consent"
|
|
2
3
|
import type { LinkInjection } from "./injection"
|
|
3
4
|
|
|
4
5
|
export type ScriptConfig = {
|
|
@@ -14,5 +15,7 @@ export type ScriptConfig = {
|
|
|
14
15
|
tracked_events?: { event: string, payload: Record<string, any> }[]
|
|
15
16
|
split_test_identifiers?: Record<string, number>
|
|
16
17
|
evaluated_link_injections?: LinkInjection[]
|
|
18
|
+
require_consent?: boolean
|
|
19
|
+
consents?: ConsentOptions
|
|
17
20
|
ads?: Campaign[]
|
|
18
21
|
}
|
package/src/lib/types/title.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ParticipantData } from "./participant"
|
|
1
2
|
import type { PlaylinkData } from "./playlink"
|
|
2
3
|
|
|
3
4
|
export type TitleData = {
|
|
@@ -18,4 +19,5 @@ export type TitleData = {
|
|
|
18
19
|
original_title: string
|
|
19
20
|
length?: number
|
|
20
21
|
blurb?: string
|
|
22
|
+
participants?: ParticipantData[]
|
|
21
23
|
}
|
package/src/main.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mount } from 'svelte'
|
|
|
2
2
|
import App from './routes/+page.svelte'
|
|
3
3
|
import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, getPageText } from '$lib/linkInjection'
|
|
4
4
|
import { getPageMetaData } from '$lib/meta'
|
|
5
|
+
import { setConsent } from '$lib/consent'
|
|
5
6
|
import type { Campaign } from '$lib/types/campaign'
|
|
6
7
|
|
|
7
8
|
window.PlayPilotLinkInjections = {
|
|
@@ -18,9 +19,17 @@ window.PlayPilotLinkInjections = {
|
|
|
18
19
|
split_test_identifiers: {},
|
|
19
20
|
evaluated_link_injections: [],
|
|
20
21
|
ads: [],
|
|
22
|
+
require_consent: true,
|
|
23
|
+
consents: {
|
|
24
|
+
ads: false,
|
|
25
|
+
pixels: false,
|
|
26
|
+
tracking: false,
|
|
27
|
+
split_tests: false,
|
|
28
|
+
affiliate: false,
|
|
29
|
+
},
|
|
21
30
|
app: null,
|
|
22
31
|
|
|
23
|
-
initialize(config = { token: '', selector: '', after_article_selector: '', after_article_insert_position: '', language: null, organization_sid: null, domain_sid: null, editorial_token: '' }): void {
|
|
32
|
+
initialize(config = { token: '', selector: '', after_article_selector: '', after_article_insert_position: '', language: null, organization_sid: null, domain_sid: null, editorial_token: '', require_consent: true }): void {
|
|
24
33
|
if (!config.token) {
|
|
25
34
|
console.error('An API token is required.')
|
|
26
35
|
return
|
|
@@ -34,6 +43,17 @@ window.PlayPilotLinkInjections = {
|
|
|
34
43
|
this.language = config.language
|
|
35
44
|
this.organization_sid = config.organization_sid
|
|
36
45
|
this.domain_sid = config.domain_sid
|
|
46
|
+
this.require_consent = config.require_consent
|
|
47
|
+
|
|
48
|
+
if (!this.require_consent) {
|
|
49
|
+
setConsent({
|
|
50
|
+
ads: true,
|
|
51
|
+
pixels: true,
|
|
52
|
+
tracking: true,
|
|
53
|
+
split_tests: true,
|
|
54
|
+
affiliate: true,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
37
57
|
|
|
38
58
|
if (this.app) this.destroy()
|
|
39
59
|
|
|
@@ -8,9 +8,26 @@
|
|
|
8
8
|
|
|
9
9
|
const { children } = $props()
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
if (browser) {
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
// This is normally given through window.PlayPilotLinkInjections.initialize({ token: 'some-token' })
|
|
14
|
+
window.PlayPilotLinkInjections = { token: 'ZoAL14yqzevMyQiwckbvyetOkeIUeEDN', selector: 'article', require_consent: true }
|
|
15
|
+
|
|
16
|
+
// @ts-ignore
|
|
17
|
+
// Fake tcfapi consent to always be true in development. This way we can write the script assuming consent
|
|
18
|
+
// windows are standard and we don't have to make exceptions. If we do want actual exceptions they can be
|
|
19
|
+
// set via the config object using `require_consent`
|
|
20
|
+
window.__tcfapi = (command, _version, callback) => {
|
|
21
|
+
if (command !== 'addEventListener') return
|
|
22
|
+
|
|
23
|
+
setTimeout(() => callback({
|
|
24
|
+
purpose: { consents: { 1: true, 2: true, 7: true, 8: true, 9: true, 10: true } },
|
|
25
|
+
eventStatus: 'tcloaded',
|
|
26
|
+
}, true), 0)
|
|
27
|
+
|
|
28
|
+
return 1
|
|
29
|
+
}
|
|
30
|
+
}
|
|
14
31
|
</script>
|
|
15
32
|
|
|
16
33
|
<title>PlayPilot Link Injections</title>
|
package/src/routes/+page.svelte
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { onDestroy } from 'svelte'
|
|
3
3
|
import { fetchConfig, pollLinkInjections } from '$lib/api'
|
|
4
4
|
import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, getPageText, injectLinksInDocument, separateLinkInjectionTypes } from '$lib/linkInjection'
|
|
5
5
|
import { setTrackingSids, track } from '$lib/tracking'
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
import EditorTrigger from './components/Editorial/EditorTrigger.svelte'
|
|
13
13
|
import Alert from './components/Editorial/Alert.svelte'
|
|
14
14
|
import TrackingPixels from './components/TrackingPixels.svelte'
|
|
15
|
+
import Consent from './components/Consent.svelte'
|
|
15
16
|
import Debugger from './components/Debugger.svelte'
|
|
16
17
|
import { fetchAds } from '$lib/ads'
|
|
18
|
+
import ParticipantModal from './components/ParticipantModal.svelte';
|
|
17
19
|
|
|
18
20
|
let parentElement: HTMLElement | null = $state(null)
|
|
19
21
|
let elements: HTMLElement[] = $state([])
|
|
@@ -36,18 +38,18 @@
|
|
|
36
38
|
if (isEditorialMode && !loading) rerender()
|
|
37
39
|
})
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
if (isCrawler()) return
|
|
41
|
+
onDestroy(clearLinkInjections)
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// This function is called when a user has properly consented via tcfapi or if no consent is required.
|
|
44
|
+
// Both of these options go through the Consent component.
|
|
45
|
+
async function start() {
|
|
46
|
+
if (isCrawler()) return
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
await initialize()
|
|
49
|
+
track(TrackingEvent.ArticlePageView)
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
+
if (aiInjections.length || manualInjections.length) window.PlayPilotLinkInjections.ads = await fetchAds()
|
|
52
|
+
}
|
|
51
53
|
|
|
52
54
|
async function initialize(): Promise<void> {
|
|
53
55
|
loading = true
|
|
@@ -205,6 +207,8 @@
|
|
|
205
207
|
<TrackingPixels pixels={response.pixels} />
|
|
206
208
|
{/if}
|
|
207
209
|
|
|
210
|
+
<Consent onchange={start} />
|
|
211
|
+
|
|
208
212
|
<style lang="scss">
|
|
209
213
|
@import url('$lib/scss/variables.scss');
|
|
210
214
|
@import url('$lib/scss/global.scss');
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { setConsent } from '$lib/consent'
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
onchange: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { onchange }: Props = $props()
|
|
9
|
+
|
|
10
|
+
const maxTries = 100
|
|
11
|
+
|
|
12
|
+
let currentTry = 1
|
|
13
|
+
|
|
14
|
+
listenForConsent()
|
|
15
|
+
|
|
16
|
+
function listenForConsent(): void {
|
|
17
|
+
// If require_consent has been explicitely turned off we return right away and call `onchange`.
|
|
18
|
+
// We don't need to set consent values as require_consent=false will mean all consent is true.
|
|
19
|
+
if (window.PlayPilotLinkInjections.require_consent === false) {
|
|
20
|
+
onchange()
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// @ts-ignore
|
|
25
|
+
if (!window.__tcfapi) {
|
|
26
|
+
// It could be that the consent script loads after our script. It's not terribly likely since our script is deferred,
|
|
27
|
+
// but still, it's possible. We retry for a little while to see if the script loads. If it doesn't load after x tries
|
|
28
|
+
// we assume there is no consent window at all.
|
|
29
|
+
if (currentTry < maxTries) setTimeout(listenForConsent, 100)
|
|
30
|
+
currentTry++
|
|
31
|
+
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// @ts-ignore
|
|
36
|
+
// '2' here is the version of tcfapi
|
|
37
|
+
window.__tcfapi('addEventListener', 2, (tcData, success) => {
|
|
38
|
+
if (!success) return
|
|
39
|
+
|
|
40
|
+
const { purpose, eventStatus } = tcData
|
|
41
|
+
|
|
42
|
+
if (eventStatus !== 'tcloaded' && eventStatus !== 'useractioncomplete') return
|
|
43
|
+
|
|
44
|
+
// Consent is given as a list of ids, each id giving consent for a certain type of data.
|
|
45
|
+
// We don't use most of these, but for future reference:
|
|
46
|
+
// 1 Store and/or access information on a device
|
|
47
|
+
// 2 Basic ads
|
|
48
|
+
// 3 Create a personalised ads profile
|
|
49
|
+
// 4 Select personalised ads
|
|
50
|
+
// 5 Create a personalised content profile
|
|
51
|
+
// 6 Select personalised content
|
|
52
|
+
// 7 Measure ad performance (impressions, clicks, conversions)
|
|
53
|
+
// 8 Measure content performance (engagement, viewability)
|
|
54
|
+
// 9 Apply market research to generate audience insights
|
|
55
|
+
// 10 Develop and improve products (Split tests, feature measurement)
|
|
56
|
+
// 11 Ensure security, prevent fraud, and debug (bot detection, fraud prevention)
|
|
57
|
+
// When there's overlap or ambiguity we select all possibly relevant categories
|
|
58
|
+
|
|
59
|
+
const consent = (id: number) => purpose.consents[id] === true
|
|
60
|
+
|
|
61
|
+
setConsent({
|
|
62
|
+
ads: consent(1),
|
|
63
|
+
pixels: consent(1) && consent(7),
|
|
64
|
+
split_tests: consent(9) && consent(10),
|
|
65
|
+
tracking: consent(7) && consent(8),
|
|
66
|
+
affiliate: consent(1) && consent(7),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
onchange()
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
direction?: 'left' | 'right'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { direction = 'right' }: Props = $props()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<svg class="{direction}" height="16px" viewBox="0 -960 960 960" width="16px">
|
|
10
|
+
<path d="m288-96-68-68 316-316-316-316 68-68 384 384L288-96Z" fill="currentColor" />
|
|
11
|
+
</svg>
|
|
12
|
+
|
|
13
|
+
<style lang="scss">
|
|
14
|
+
.right {
|
|
15
|
+
margin-left: margin(0.125);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.left {
|
|
19
|
+
margin-right: margin(0.125);
|
|
20
|
+
transform: rotate(180deg);
|
|
21
|
+
}
|
|
22
|
+
</style>
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
2
|
-
<path d="M14 2L2 14M2 2L14 14" stroke="currentColor" stroke-width="
|
|
2
|
+
<path d="M14 2L2 14M2 2L14 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
3
3
|
</svg>
|