@playpilot/tpi 6.7.0 → 6.9.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/events.md CHANGED
@@ -90,6 +90,9 @@ Event | Action | Info | Payload
90
90
  ### Various
91
91
  Event | Action | Info | Payload
92
92
  --- | --- | --- | ---
93
+ `ali_share_title` | _Fires any time the share button is clicked for a title_ | | `Title`, `method` ('native', 'copy', or 'email')
94
+ `ali_trailer_button_click` | _Fires any time the trailer button is clicked for a title_ | | `Title`
95
+ `ali_expand_title_description` | _Fires any time the "show more" button is clicked for a title description_ | | `Title`
93
96
  `ali_region_request_failed` | _Fires when requests to external service for getting the user region fails_ | | `message` (error message as returned by the request)
94
97
 
95
98
  ### Explore
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "6.7.0",
3
+ "version": "6.9.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -4,4 +4,9 @@ export const SplitTest = {
4
4
  numberOfVariants: 2,
5
5
  variantNames: ['Separated', 'Inline'] as string[],
6
6
  },
7
+ TitleDescriptionPlacement: {
8
+ key: 'title_description_placement',
9
+ numberOfVariants: 2,
10
+ variantNames: ['Top', 'Bottom'] as string[],
11
+ },
7
12
  } as const
@@ -58,6 +58,7 @@ export const TrackingEvent = {
58
58
  SaveTitle: 'ali_save_title',
59
59
  NotifyTitle: 'ali_notify_title',
60
60
  TrailerClick: 'ali_trailer_button_click',
61
+ ExpandTitleDescription: 'ali_expand_title_description',
61
62
  RegionRequestFailed: 'ali_region_request_failed',
62
63
 
63
64
  // Explore
@@ -49,6 +49,11 @@ export type ConfigResponse = {
49
49
  */
50
50
  allow_retargeting_pixels?: boolean
51
51
 
52
+ /**
53
+ * By default affiliate links (playlinks) will render as links even without consent, as a disclaimer is given. Set to true to require explicit consent, rendering as static text without consent.
54
+ */
55
+ require_affiliate_consent?: boolean
56
+
52
57
  /**
53
58
  * The following options are all relevant for in text disclaimers, which renders as a disclaimer text within the article,
54
59
  * rather than only inside of title cards.
@@ -3,21 +3,27 @@
3
3
  text: string
4
4
  blurb?: string
5
5
  limit?: number
6
+ onclick?: () => void
6
7
  }
7
8
 
8
- const { text, blurb = '', limit = 200 }: Props = $props()
9
+ const { text, blurb = '', limit = 200, onclick = () => null }: Props = $props()
9
10
 
10
11
  let expanded = $state(false)
11
12
 
12
13
  const limitedText = $derived(text.slice(0, limit))
14
+
15
+ function expand(): void {
16
+ expanded = true
17
+ onclick()
18
+ }
13
19
  </script>
14
20
 
15
- <div>
21
+ <div class="description">
16
22
  <span class="paragraph" role="paragraph">
17
23
  {expanded ? text : limitedText}{#if !expanded && text.length > limit}...{/if}
18
24
 
19
25
  {#if !expanded && (text.length > limit || blurb)}
20
- <button class="show-more" onclick={() => expanded = true}>Show more</button>
26
+ <button class="show-more" onclick={() => expand()}>Show more</button>
21
27
  {/if}
22
28
  </span>
23
29
 
@@ -27,6 +33,10 @@
27
33
  </div>
28
34
 
29
35
  <style lang="scss">
36
+ .description:first-child {
37
+ margin: margin(-1) 0 margin(1);
38
+ }
39
+
30
40
  .paragraph {
31
41
  display: block;
32
42
  margin: margin(1) 0 0;
@@ -35,7 +35,7 @@
35
35
  <!-- svelte-ignore a11y_no_static_element_interactions -->
36
36
  <svelte:element
37
37
  {onclick}
38
- this={hasConsentedTo('affiliate') ? 'a' : 'span'}
38
+ this={!window.PlayPilotLinkInjections?.config?.require_affiliate_consent || hasConsentedTo('affiliate') ? 'a' : 'span'}
39
39
  {@attach usePixel ? trackViewViaPixel(MetaEvent.ProviderInterest, { provider: playlink.slug }) : null}
40
40
  {@attach usePixel ? trackClickViaPixel(MetaEvent.ProviderClick, { provider: playlink.slug }) : null}
41
41
  href={url}
@@ -9,7 +9,6 @@
9
9
  import { campaignToPlaylink, getFirstAdOfType } from '$lib/api/ads'
10
10
  import { getContext } from 'svelte'
11
11
  import Playlink from './Playlink.svelte'
12
- import Notify from '../Notify.svelte'
13
12
 
14
13
  interface Props {
15
14
  playlinks: PlaylinkData[]
@@ -36,27 +35,31 @@
36
35
  </script>
37
36
 
38
37
 
39
- {#if mergedPlaylinks.length}
40
- <div class="heading" use:heading={3}>{t('Where To Stream Online')}</div>
38
+ <div class="heading" use:heading={3}>{t('Where To Stream Online')}</div>
41
39
 
40
+ {#if mergedPlaylinks.length}
42
41
  <div class="disclaimer" data-testid="commission-disclaimer">
43
42
  {window?.PlayPilotLinkInjections?.config?.playlinks_disclaimer_text || t('Commission Disclaimer')}
44
43
  <a href="https://playpilot.com/" target="_blank" rel="sponsored">PlayPilot.com</a>
45
44
  </div>
45
+ {/if}
46
46
 
47
- <div class="playlinks" class:list bind:clientWidth={outerWidth}>
48
- {#each mergedPlaylinks as playlink, index}
49
- <Playlink {playlink} onclick={() => onclick(playlink.name)} />
47
+ <div class="playlinks" class:list bind:clientWidth={outerWidth}>
48
+ {#each mergedPlaylinks as playlink, index}
49
+ <Playlink {playlink} onclick={() => onclick(playlink.name)} />
50
50
 
51
- <!-- A fake highlighted playlink as part of the display ad, to be shown after the first playlink -->
52
- {#if displayAd && (index === 0)}
53
- <Playlink playlink={campaignToPlaylink(displayAd)} onclick={() => track(TrackingEvent.DisplayedAdPlaylickClick, title, { campaign_name: displayAd.campaign_name })} hideCategory disclaimer={displayAd.disclaimer || ''} />
54
- {/if}
55
- {/each}
56
- </div>
57
- {:else}
58
- <Notify {title} />
59
- {/if}
51
+ <!-- A fake highlighted playlink as part of the display ad, to be shown after the first playlink -->
52
+ {#if displayAd && (index === 0)}
53
+ <Playlink playlink={campaignToPlaylink(displayAd)} onclick={() => track(TrackingEvent.DisplayedAdPlaylickClick, title, { campaign_name: displayAd.campaign_name })} hideCategory disclaimer={displayAd.disclaimer || ''} />
54
+ {/if}
55
+ {/each}
56
+
57
+ {#if !mergedPlaylinks.length}
58
+ <div class="empty" data-testid="playlinks-empty">
59
+ {t('Title Unavailable')}
60
+ </div>
61
+ {/if}
62
+ </div>
60
63
 
61
64
  <style lang="scss">
62
65
  .heading {
@@ -100,4 +103,19 @@
100
103
  }
101
104
  }
102
105
  }
106
+
107
+ .empty {
108
+ grid-column: span 2;
109
+ padding: margin(0.75);
110
+ margin-top: margin(0.5);
111
+ background: theme(playlink-background, lighter);
112
+ box-shadow: theme(playlink-shadow, shadow);
113
+ border-radius: theme(playlink-border-radius, border-radius);
114
+ white-space: initial;
115
+ line-height: 1.35;
116
+
117
+ .list & {
118
+ grid-column: 1;
119
+ }
120
+ }
103
121
  </style>
@@ -49,8 +49,8 @@
49
49
 
50
50
  <ContextMenu>
51
51
  {#snippet button({ toggle })}
52
- <Button onclick={(event) => shareApiOrToggle(event, toggle)} size="wide" label={t('Share')}>
53
- <IconShare />
52
+ <Button onclick={(event) => shareApiOrToggle(event, toggle)}>
53
+ <IconShare /> {t('Share')}
54
54
  </Button>
55
55
  {/snippet}
56
56
 
@@ -6,7 +6,6 @@
6
6
  import ParticipantsRail from './Rails/ParticipantsRail.svelte'
7
7
  import SimilarRail from './Rails/SimilarRail.svelte'
8
8
  import TitlePoster from './TitlePoster.svelte'
9
- import Save from './Save.svelte'
10
9
  import Share from './Share.svelte'
11
10
  import Trailer from './Trailer.svelte'
12
11
  import { t } from '$lib/localization'
@@ -14,7 +13,11 @@
14
13
  import { heading } from '$lib/actions/heading'
15
14
  import { removeImageUrlPrefix } from '$lib/image'
16
15
  import { titleUrl } from '$lib/routes'
17
- import { setContext } from 'svelte'
16
+ import { isInSplitTestVariant, trackSplitTestView } from '$lib/splitTest'
17
+ import { onMount, setContext } from 'svelte'
18
+ import { SplitTest } from '$lib/enums/SplitTest'
19
+ import { track } from '$lib/tracking'
20
+ import { TrackingEvent } from '$lib/enums/TrackingEvent'
18
21
 
19
22
  interface Props {
20
23
  title: TitleData
@@ -25,11 +28,23 @@
25
28
 
26
29
  setContext('title', title)
27
30
 
31
+ const showDescription = $derived(!small && title.description)
32
+
28
33
  let posterLoaded = $state(false)
29
34
  let backgroundLoaded = $state(false)
30
35
  let useBackgroundFallback = $state(false)
36
+
37
+ onMount(() => {
38
+ if (showDescription) trackSplitTestView(SplitTest.TitleDescriptionPlacement)
39
+ })
31
40
  </script>
32
41
 
42
+ {#snippet description()}
43
+ {#if showDescription}
44
+ <Description text={title.description!} blurb={title.blurb} onclick={() => track(TrackingEvent.ExpandTitleDescription, title)} />
45
+ {/if}
46
+ {/snippet}
47
+
33
48
  <div class="content" class:small>
34
49
  <div class="header">
35
50
  <div class="poster" class:loaded={posterLoaded}>
@@ -59,18 +74,19 @@
59
74
  <Trailer {title} />
60
75
  {/if}
61
76
 
62
- <div class="right">
63
- <Save {title} />
64
- <Share title={title.title} url={titleUrl(title)} />
65
- </div>
77
+ <Share title={title.title} url={titleUrl(title)} />
66
78
  </div>
67
79
  </div>
68
80
 
69
81
  <div class="main">
82
+ {#if isInSplitTestVariant(SplitTest.TitleDescriptionPlacement, 0)}
83
+ {@render description()}
84
+ {/if}
85
+
70
86
  <Playlinks playlinks={title.providers} {title} />
71
87
 
72
- {#if !small && title.description}
73
- <Description text={title.description} blurb={title.blurb} />
88
+ {#if isInSplitTestVariant(SplitTest.TitleDescriptionPlacement, 1)}
89
+ {@render description()}
74
90
  {/if}
75
91
 
76
92
  <ParticipantsRail {title} />
@@ -9,6 +9,8 @@
9
9
  import TopScroll from './Ads/TopScroll.svelte'
10
10
  import Display from './Ads/Display.svelte'
11
11
  import ExploreCallToAction from './Explore/ExploreCallToAction.svelte'
12
+ import { getSplitTestVariantName } from '$lib/splitTest'
13
+ import { SplitTest } from '$lib/enums/SplitTest'
12
14
 
13
15
  interface Props {
14
16
  title: TitleData
@@ -28,7 +30,7 @@
28
30
  onMount(() => {
29
31
  const openTimestamp = Date.now()
30
32
 
31
- return () => track(TrackingEvent.TitleModalClose, title, { time_spent: Date.now() - openTimestamp })
33
+ return () => track(TrackingEvent.TitleModalClose, title, { time_spent: Date.now() - openTimestamp, split_test: getSplitTestVariantName(SplitTest.TitleDescriptionPlacement) })
32
34
  })
33
35
 
34
36
  function onscroll(): void {
@@ -8,6 +8,8 @@
8
8
  import Title from './Title.svelte'
9
9
  import TopScroll from './Ads/TopScroll.svelte'
10
10
  import Display from './Ads/Display.svelte'
11
+ import { getSplitTestVariantName } from '$lib/splitTest'
12
+ import { SplitTest } from '$lib/enums/SplitTest'
11
13
 
12
14
  interface Props {
13
15
  event: MouseEvent
@@ -27,7 +29,7 @@
27
29
  setOffset()
28
30
 
29
31
  const openTimestamp = Date.now()
30
- return () => track(TrackingEvent.TitlePopoverClose, title, { time_spent: Date.now() - openTimestamp })
32
+ return () => track(TrackingEvent.TitlePopoverClose, title, { time_spent: Date.now() - openTimestamp, split_test: getSplitTestVariantName(SplitTest.TitleDescriptionPlacement) })
31
33
  })
32
34
 
33
35
  /**
@@ -19,6 +19,6 @@
19
19
  }
20
20
  </script>
21
21
 
22
- <Button {onclick} size="wide" label={t('Watch Trailer')}>
23
- <IconPlay />
22
+ <Button {onclick}>
23
+ <IconPlay /> {t('Watch Trailer')}
24
24
  </Button>
@@ -1,5 +1,5 @@
1
1
  import { fireEvent, render } from '@testing-library/svelte'
2
- import { describe, expect, it } from 'vitest'
2
+ import { describe, expect, it, vi } from 'vitest'
3
3
 
4
4
  import Description from '../../../routes/components/Description.svelte'
5
5
 
@@ -55,4 +55,13 @@ describe('Description.svelte', () => {
55
55
 
56
56
  expect(queryByText('Show more')).not.toBeTruthy()
57
57
  })
58
+
59
+ it('Should fire given onclick function after expanding if given', async () => {
60
+ const onclick = vi.fn()
61
+ const { queryByRole } = render(Description, { text: 'Some test description', limit: 5, onclick })
62
+
63
+ await fireEvent.click(/** @type {Node} **/(queryByRole('button')))
64
+
65
+ expect(onclick).toHaveBeenCalled()
66
+ })
58
67
  })
@@ -1,5 +1,5 @@
1
1
  import { fireEvent, render } from '@testing-library/svelte'
2
- import { describe, expect, it, vi } from 'vitest'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
3
 
4
4
  import Playlink from '../../../../routes/components/Playlinks/Playlink.svelte'
5
5
  import { hasConsentedTo } from '$lib/consent'
@@ -19,6 +19,11 @@ vi.mock('svelte', async (importActual) => ({
19
19
  }))
20
20
 
21
21
  describe('Playlink.svelte', () => {
22
+ beforeEach(() => {
23
+ // @ts-ignore
24
+ window.PlayPilotLinkInjections = { config: {} }
25
+ })
26
+
22
27
  const playlink = { name: 'Some playlink', logo_url: 'logo', url: 'https://playpilot.com/', extra_info: { category: 'SVOD' } }
23
28
 
24
29
  it('Should render category as words', () => {
@@ -109,7 +114,20 @@ describe('Playlink.svelte', () => {
109
114
  expect(getByRole('link')).toBeTruthy()
110
115
  })
111
116
 
112
- it('Should not render as link when user has not consented to affiliate', () => {
117
+ it('Should render as link when user has not consented to affiliate but require_affiliate_consent is false', () => {
118
+ window.PlayPilotLinkInjections.config.require_affiliate_consent = false
119
+
120
+ vi.mocked(hasConsentedTo).mockImplementation(() => false)
121
+
122
+ // @ts-ignore
123
+ const { queryByRole } = render(Playlink, { playlink })
124
+
125
+ expect(queryByRole('link')).toBeTruthy()
126
+ })
127
+
128
+ it('Should render as link when user has not consented to affiliate and require_affiliate_consent is true', () => {
129
+ window.PlayPilotLinkInjections.config.require_affiliate_consent = true
130
+
113
131
  vi.mocked(hasConsentedTo).mockImplementation(() => false)
114
132
 
115
133
  // @ts-ignore
@@ -68,8 +68,7 @@ describe('Playlinks.svelte', () => {
68
68
  const playlinks = []
69
69
  const { queryByTestId, getByText } = render(Playlinks, { playlinks, title })
70
70
 
71
- expect(getByText(`${title.title} is currently not available to stream.`)).toBeTruthy()
72
- expect(getByText('Notify me when available')).toBeTruthy()
71
+ expect(getByText('This title is not currently available to stream.')).toBeTruthy()
73
72
  expect(queryByTestId('commission-disclaimer')).not.toBeTruthy()
74
73
  })
75
74
 
@@ -21,57 +21,57 @@ describe('Share.svelte', () => {
21
21
  })
22
22
 
23
23
  it('Should open context menu on click', async () => {
24
- const { getByLabelText, queryByText } = render(Share, { title: 'Some title', url: 'some-url' })
24
+ const { getByText, queryByText } = render(Share, { title: 'Some title', url: 'some-url' })
25
25
 
26
26
  expect(queryByText('Copy URL')).not.toBeTruthy()
27
27
  expect(queryByText('Email')).not.toBeTruthy()
28
28
 
29
- await fireEvent.click(getByLabelText('Share'))
29
+ await fireEvent.click(getByText('Share'))
30
30
 
31
31
  expect(queryByText('Copy URL')).toBeTruthy()
32
32
  expect(queryByText('Email')).toBeTruthy()
33
33
  })
34
34
 
35
35
  it('Should close context menu on click of items', async () => {
36
- const { getByLabelText, getByText, queryByText } = render(Share, { title: 'Some title', url: 'some-url' })
36
+ const { getByText, queryByText } = render(Share, { title: 'Some title', url: 'some-url' })
37
37
 
38
- await fireEvent.click(getByLabelText('Share'))
38
+ await fireEvent.click(getByText('Share'))
39
39
  await fireEvent.click(getByText('Copy URL'))
40
40
 
41
41
  expect(queryByText('Copy URL')).not.toBeTruthy()
42
42
  })
43
43
 
44
44
  it('Should close context menu on click of body', async () => {
45
- const { getByLabelText, queryByText } = render(Share, { title: 'Some title', url: 'some-url' })
45
+ const { getByText, queryByText } = render(Share, { title: 'Some title', url: 'some-url' })
46
46
 
47
- await fireEvent.click(getByLabelText('Share'))
47
+ await fireEvent.click(getByText('Share'))
48
48
  await fireEvent.click(document.body)
49
49
 
50
50
  expect(queryByText('Copy URL')).not.toBeTruthy()
51
51
  })
52
52
 
53
53
  it('Should fire copyToClipboard on click of button', async () => {
54
- const { getByLabelText, getByText } = render(Share, { title: 'Some title', url: 'some-url' })
54
+ const { getByText } = render(Share, { title: 'Some title', url: 'some-url' })
55
55
 
56
- await fireEvent.click(getByLabelText('Share'))
56
+ await fireEvent.click(getByText('Share'))
57
57
  await fireEvent.click(getByText('Copy URL'))
58
58
 
59
59
  expect(copyToClipboard).toHaveBeenCalledWith('some-url?utm_source=tpi')
60
60
  })
61
61
 
62
62
  it('Should fire track function on click of copy URL button', async () => {
63
- const { getByLabelText, getByText } = render(Share, { title: 'Some title', url: 'some-url' })
63
+ const { getByText } = render(Share, { title: 'Some title', url: 'some-url' })
64
64
 
65
- await fireEvent.click(getByLabelText('Share'))
65
+ await fireEvent.click(getByText('Share'))
66
66
  await fireEvent.click(getByText('Copy URL'))
67
67
 
68
68
  expect(track).toHaveBeenCalledWith(TrackingEvent.ShareTitle, null, { title: 'Some title', url: 'http://localhost:3000/', method: 'copy' })
69
69
  })
70
70
 
71
71
  it('Should fire track function on click of email button', async () => {
72
- const { getByLabelText, getByText } = render(Share, { title: 'Some title', url: 'some-url' })
72
+ const { getByText } = render(Share, { title: 'Some title', url: 'some-url' })
73
73
 
74
- await fireEvent.click(getByLabelText('Share'))
74
+ await fireEvent.click(getByText('Share'))
75
75
  await fireEvent.click(getByText('Email'))
76
76
 
77
77
  expect(track).toHaveBeenCalledWith(TrackingEvent.ShareTitle, null, { title: 'Some title', url: 'http://localhost:3000/', method: 'email' })
@@ -94,14 +94,14 @@ describe('Title.svelte', () => {
94
94
  })
95
95
 
96
96
  it('Should show trailer button when embeddable_url is given', () => {
97
- const { getByLabelText } = render(Title, { title: { ...title, embeddable_url: 'some-url' } })
97
+ const { getByText } = render(Title, { title: { ...title, embeddable_url: 'some-url' } })
98
98
 
99
- expect(getByLabelText('Watch trailer')).toBeTruthy()
99
+ expect(getByText('Watch trailer')).toBeTruthy()
100
100
  })
101
101
 
102
102
  it('Should not show trailer button when embeddable_url is not given', () => {
103
- const { queryByLabelText } = render(Title, { title })
103
+ const { queryByText } = render(Title, { title })
104
104
 
105
- expect(queryByLabelText('Watch trailer')).not.toBeTruthy()
105
+ expect(queryByText('Watch trailer')).not.toBeTruthy()
106
106
  })
107
107
  })
@@ -56,7 +56,7 @@ describe('TitleModal.svelte', () => {
56
56
  vi.advanceTimersByTime(200)
57
57
  unmount()
58
58
 
59
- expect(track).toHaveBeenCalledWith(TrackingEvent.TitleModalClose, title, { time_spent: 200 })
59
+ expect(track).toHaveBeenCalledWith(TrackingEvent.TitleModalClose, title, expect.objectContaining({ time_spent: 200 }))
60
60
  })
61
61
 
62
62
  it('Should render top scroll ad when given', () => {
@@ -50,7 +50,7 @@ describe('TitlePopover.svelte', () => {
50
50
  vi.advanceTimersByTime(200)
51
51
  unmount()
52
52
 
53
- expect(track).toHaveBeenCalledWith(TrackingEvent.TitlePopoverClose, title, { time_spent: 200 })
53
+ expect(track).toHaveBeenCalledWith(TrackingEvent.TitlePopoverClose, title, expect.objectContaining({ time_spent: 200 }))
54
54
  })
55
55
 
56
56
  it('Should render top scroll ad when given', () => {