@playpilot/tpi 8.8.0 → 8.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
@@ -77,6 +77,8 @@ Event | Action | Info | Payload
77
77
  `ali_manual_report` | _Fires only through manual action when reporting issues with an injection via the Editor._ | | `Title`, `report_reason`, `sid` (of injection), `title` (of injection), `sentence`, `failed` (true or false), `failed_message` (reason for failure as given in the editor), `manual` (true or false)
78
78
  `ali_editor_error` | _Fires whenever an error occurs within the Editor._ | | `Title`, `phrase`, `sentence`
79
79
  `ali_injection_error` | _Fires whenever an error occurs during injection_ | This includes fetching the injections as well as actually injecting itself. Does not include fetching of the config object. | `message` (error message as given by the browser)
80
+ `venus_explore_results_error` | _Fires whenever an error occurs when fetching titles on the results page of the streaming guide_ | | `message` (error message as given by the browser), `params`
81
+ `venus_explore_home_rail_error` | _Fires whenever an error when fetching titles for any rail on the streaming guide homepage_ | | `message` (error message as given by the browser), `params`
80
82
 
81
83
  ### Split Testing
82
84
  Event | Action | Info | Payload
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "8.8.0",
3
+ "version": "8.9.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -19,9 +19,7 @@ export async function fetchTitles(params: Record<string, any> = {}): Promise<API
19
19
 
20
20
  if (params.region !== null) params.region = params.region || await getRegionBasedOnIp()
21
21
 
22
- const response = await api<APIPaginatedResult<TitleData>>(`/titles/browse?api-token=${apiToken}&` + paramsToString(params))
23
-
24
- return response
22
+ return await api<APIPaginatedResult<TitleData>>(`/titles/browse?api-token=${apiToken}&` + paramsToString(params))
25
23
  }
26
24
 
27
25
  export async function fetchSimilarTitles(title: TitleData): Promise<TitleData[]> {
@@ -231,6 +231,21 @@ export const translations = {
231
231
  [Language.Swedish]: 'Toppbetyg',
232
232
  [Language.Danish]: 'Højst vurderet',
233
233
  },
234
+ '-popularity': {
235
+ [Language.English]: 'Popularity',
236
+ [Language.Swedish]: 'Popularitet',
237
+ [Language.Danish]: 'Popularitet',
238
+ },
239
+ '-new': {
240
+ [Language.English]: 'New',
241
+ [Language.Swedish]: 'Nytt',
242
+ [Language.Danish]: 'Nyt',
243
+ },
244
+ '-best': {
245
+ [Language.English]: 'Top Rated',
246
+ [Language.Swedish]: 'Toppbetyg',
247
+ [Language.Danish]: 'Højst vurderet',
248
+ },
234
249
  'Search': {
235
250
  [Language.English]: 'Search',
236
251
  [Language.Swedish]: 'Sök',
@@ -40,6 +40,8 @@ export const TrackingEvent = {
40
40
  ManualReport: 'ali_manual_report',
41
41
  EditorError: 'ali_editor_error',
42
42
  InjectionError: 'ali_injection_error',
43
+ ExploreResultsError: 'venus_explore_results_error',
44
+ ExploreHomeRailError: 'venus_explore_home_rail_error',
43
45
 
44
46
  // Ads
45
47
  TopScrollView: 'ali_top_scroll_view',
@@ -0,0 +1,12 @@
1
+ import type { ExploreFilterItem } from './types/filter'
2
+
3
+ export function isFilterItemActive(item: ExploreFilterItem, range?: [number, number] | null): boolean {
4
+ if (!item) return false
5
+
6
+ const { type, value } = item
7
+
8
+ if (type === 'array' && !((value as Array<any>).length)) return false
9
+ if (type === 'range' && JSON.stringify(value) === JSON.stringify(range)) return false
10
+
11
+ return true
12
+ }
@@ -1,2 +1,4 @@
1
1
  export type ExploreFilterType = 'array' | 'range' | 'string'
2
- export type ExploreFilter = Record<string, { type: ExploreFilterType, value: unknown }>
2
+ export type ExploreFilterItem = { type: ExploreFilterType, value: unknown }
3
+ export type ExploreFilter = Record<string, ExploreFilterItem>
4
+ export type ExploreFilterItemArrayValue = { label: string, value: string }
@@ -57,7 +57,13 @@
57
57
 
58
58
  {#if useExploreRouter()}
59
59
  <div class="filter">
60
- <Filter {filter} limit={clientWidth < 500} showSorting={!searchQuery} onchange={() => navigate('results')} />
60
+ <Filter
61
+ {filter}
62
+ {searchQuery}
63
+ limit={clientWidth < 500}
64
+ showSorting={!searchQuery}
65
+ onchange={() => navigate('results')}
66
+ onempty={() => navigate('home')} />
61
67
  </div>
62
68
  {/if}
63
69
 
@@ -0,0 +1,76 @@
1
+ <script lang="ts">
2
+ import { isFilterItemActive } from '$lib/filter'
3
+ import { t } from '$lib/localization'
4
+ import type { ExploreFilter, ExploreFilterItemArrayValue } from '$lib/types/filter'
5
+ import IconClose from '../../Icons/IconClose.svelte'
6
+
7
+ interface Props {
8
+ filter: ExploreFilter
9
+ items: any
10
+ }
11
+
12
+ const { filter, items }: Props = $props()
13
+
14
+ function removeFilterItem(param: string): void {
15
+ delete filter[param]
16
+ }
17
+
18
+ function truncate(text: string): string {
19
+ let truncated = text.slice(0, 30).trim()
20
+ if (text.length > truncated.length) truncated += '...'
21
+
22
+ return truncated
23
+ }
24
+ </script>
25
+
26
+ <div class="active-filter">
27
+ {#each Object.entries(filter) as [param, item]}
28
+ {@const { label, range, data, fetchData } = items.find((i: any) => param === i.param) || {}}
29
+
30
+ {#if isFilterItemActive(item, range)}
31
+ <button class="item" onclick={() => removeFilterItem(param)}>
32
+ {#if item.type === 'array'}
33
+ {#await fetchData?.() || data then data}
34
+ {truncate(data?.filter((d: ExploreFilterItemArrayValue) => (item.value as string[])
35
+ .includes(d.value))
36
+ .map((d: ExploreFilterItemArrayValue) => d.label)
37
+ .join(' + '))}
38
+ {/await}
39
+ {:else if item.type === 'range'}
40
+ {label}: {(item.value as number[]).join(' - ')}
41
+ {:else}
42
+ {t(item.value as string)}
43
+ {/if}
44
+
45
+ <IconClose size={8} />
46
+ </button>
47
+ {/if}
48
+ {/each}
49
+ </div>
50
+
51
+ <style lang="scss">
52
+ .active-filter {
53
+ display: flex;
54
+ gap: margin(0.25);
55
+ }
56
+
57
+ .item {
58
+ appearance: none;
59
+ display: flex;
60
+ align-items: center;
61
+ gap: margin(0.5);
62
+ padding: margin(0.15) margin(0.5);
63
+ border: 1px solid theme(content);
64
+ border-radius: theme(border-radius-large);
65
+ background: theme(lighter);
66
+ color: theme(text-color-alt);
67
+ font-family: inherit;
68
+ font-size: theme(font-size-small);
69
+ cursor: pointer;
70
+
71
+ &:hover {
72
+ border-color: theme(content-light);
73
+ color: theme(text-color);
74
+ }
75
+ }
76
+ </style>
@@ -10,15 +10,26 @@
10
10
  import IconArrow from '../../Icons/IconArrow.svelte'
11
11
  import IconFilter from '../../Icons/IconFilter.svelte'
12
12
  import { fly, scale } from 'svelte/transition'
13
+ import ActiveFilterItems from './ActiveFilterItems.svelte'
14
+ import { isFilterItemActive } from '$lib/filter'
13
15
 
14
16
  interface Props {
15
17
  filter: ExploreFilter
16
- onchange?: () => void
17
18
  limit?: boolean
18
19
  showSorting?: boolean
20
+ searchQuery?: string
21
+ onchange?: () => void
22
+ onempty?: () => void
19
23
  }
20
24
 
21
- const { filter, onchange = () => null, limit = true, showSorting = true }: Props = $props()
25
+ const {
26
+ filter,
27
+ limit = true,
28
+ showSorting = true,
29
+ searchQuery = '',
30
+ onchange = () => null,
31
+ onempty = () => null,
32
+ }: Props = $props()
22
33
 
23
34
  const shownItemsLimit = 3
24
35
  const items = [{
@@ -49,6 +60,22 @@
49
60
  }]
50
61
 
51
62
  let limited = $state(limit)
63
+ let wasPreviouslyActive = false
64
+
65
+ $effect(() => {
66
+ const hasAnyActiveFilter = Object.keys(filter).some((param) => {
67
+ const { range } = items.find((i: any) => param === i.param) || {}
68
+
69
+ return isFilterItemActive(filter[param], range)
70
+ })
71
+
72
+ if (hasAnyActiveFilter || searchQuery) {
73
+ wasPreviouslyActive = true
74
+ } else if (wasPreviouslyActive) {
75
+ wasPreviouslyActive = false
76
+ onempty()
77
+ }
78
+ })
52
79
  </script>
53
80
 
54
81
  <div class="filter" role="navigation" class:limit>
@@ -77,6 +104,8 @@
77
104
  </div>
78
105
  </div>
79
106
 
107
+ <ActiveFilterItems {filter} {items} />
108
+
80
109
  <style lang="scss">
81
110
  .filter {
82
111
  position: relative;
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { ExploreFilter, ExploreFilterType } from '$lib/types/filter'
2
+ import type { ExploreFilter, ExploreFilterItemArrayValue, ExploreFilterType } from '$lib/types/filter'
3
3
  import Button from '../../Button.svelte'
4
4
  import IconArrow from '../../Icons/IconArrow.svelte'
5
5
  import TogglesWithSearch from './TogglesWithSearch.svelte'
@@ -8,16 +8,15 @@
8
8
  import { TrackingEvent } from '$lib/enums/TrackingEvent'
9
9
  import { track } from '$lib/tracking'
10
10
  import { t } from '$lib/localization'
11
-
12
- type Item = { label: string, value: string }
11
+ import { isFilterItemActive } from '$lib/filter'
13
12
 
14
13
  interface Props {
15
14
  filter: ExploreFilter
16
15
  label: string
17
16
  param: string
18
17
  onchange?: () => void
19
- data?: Item[] | null
20
- fetchData?: (() => Promise<Item[]>) | null
18
+ data?: ExploreFilterItemArrayValue[] | null
19
+ fetchData?: (() => Promise<ExploreFilterItemArrayValue[]>) | null
21
20
  range?: [number, number] | null
22
21
  valueAppend?: string
23
22
  }
@@ -33,16 +32,7 @@
33
32
  valueAppend = '',
34
33
  }: Props = $props()
35
34
 
36
- const active = $derived.by(() => {
37
- if (!filter[param]) return false
38
-
39
- const { type, value } = filter[param]
40
-
41
- if (type === 'array' && !((value as Array<any>).length)) return false
42
- if (type === 'range' && JSON.stringify(value) === JSON.stringify(range)) return false
43
-
44
- return true
45
- })
35
+ const active = $derived(isFilterItemActive(filter[param], range))
46
36
 
47
37
  let loading = $state(!!fetchData)
48
38
 
@@ -33,7 +33,12 @@
33
33
  }]
34
34
 
35
35
  async function getListTitles(params: Record<string, any> = {}): Promise<TitleData[]> {
36
- return (await fetchTitles(params))?.results
36
+ try {
37
+ return (await fetchTitles(params))?.results
38
+ } catch (error: any) {
39
+ track(TrackingEvent.ExploreHomeRailError, null, { message: error.message, params })
40
+ throw new Error(error)
41
+ }
37
42
  }
38
43
  </script>
39
44
 
@@ -7,7 +7,6 @@
7
7
  import type { ExploreFilter } from '$lib/types/filter'
8
8
  import type { TitleData } from '$lib/types/title'
9
9
  import { hasConsentedTo } from '$lib/consent'
10
- import { useExploreRouter } from '$lib/explore'
11
10
  import { t } from '$lib/localization'
12
11
  import { trackViaPixel } from '@playpilot/retargeting-tracking'
13
12
  import Button from '../../Button.svelte'
@@ -16,7 +15,6 @@
16
15
  import ListTitle from '../../ListTitle.svelte'
17
16
  import ListTitleSkeleton from '../../ListTitleSkeleton.svelte'
18
17
  import Empty from '../Empty.svelte'
19
- import IconArrow from '../../Icons/IconArrow.svelte'
20
18
 
21
19
  interface Props {
22
20
  searchQuery?: string,
@@ -24,7 +22,7 @@
24
22
  navigate?: (key: string) => void
25
23
  }
26
24
 
27
- const { searchQuery = '', filter = {}, navigate = () => null }: Props = $props()
25
+ const { searchQuery = '', filter = {} }: Props = $props()
28
26
 
29
27
  // svelte-ignore non_reactive_update
30
28
  let page = 1
@@ -74,11 +72,16 @@
74
72
  }
75
73
  })
76
74
 
77
- const response = await fetchTitles(params) || { next: null, previous: null, results: [] }
75
+ try {
76
+ const response = await fetchTitles(params) || { next: null, previous: null, results: [] }
78
77
 
79
- if (requestId === latestRequestId) titles = [...titles, ...response.results]
78
+ if (requestId === latestRequestId) titles = [...titles, ...response.results]
80
79
 
81
- return response
80
+ return response
81
+ } catch (error: any) {
82
+ track(TrackingEvent.ExploreResultsError, null, { message: error.message, params })
83
+ throw new Error(error)
84
+ }
82
85
  }
83
86
 
84
87
  async function search(query: string): Promise<void> {
@@ -121,12 +124,6 @@
121
124
  track(TrackingEvent.ExploreTitleClick, title)
122
125
  }
123
126
 
124
- function clearFilter(): void {
125
- for (const key of Object.keys(filter)) {
126
- delete filter[key]
127
- }
128
- }
129
-
130
127
  function isNewRequest(): boolean {
131
128
  const requestAsString = JSON.stringify(filter) + searchQuery
132
129
  if (requestAsString === lastRequestAsString) return false
@@ -136,13 +133,6 @@
136
133
  }
137
134
  </script>
138
135
 
139
- {#if useExploreRouter()}
140
- <Button variant="link" onclick={() => { navigate('home'); clearFilter() }}>
141
- <IconArrow direction="left" />
142
- {t('Home')}
143
- </Button>
144
- {/if}
145
-
146
136
  <div class="titles" class:grid role="main" style:--grid-columns={gridColumns} bind:clientWidth={width} data-testid="explore-results">
147
137
  {#each titles as title}
148
138
  {#key title.sid}
@@ -255,6 +255,7 @@
255
255
  .media {
256
256
  grid-area: media;
257
257
  width: var(--width);
258
+ max-height: var(--image-height);
258
259
  border-radius: var(--border-radius);
259
260
  transition: width 200ms;
260
261
  transition-delay: 1ms;
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { isFilterItemActive } from '$lib/filter'
3
+
4
+ describe('filter.ts', () => {
5
+ describe('isFilterItemActive', () => {
6
+ it('Should return false if type is array and value is empty', () => {
7
+ expect(isFilterItemActive({ type: 'array', value: [] })).toBe(false)
8
+ })
9
+
10
+ it('Should return true if type is array and value is not empty', () => {
11
+ expect(isFilterItemActive({ type: 'array', value: ['some-value'] })).toBe(true)
12
+ })
13
+
14
+ it('Should return false if type is range and value matches given range', () => {
15
+ expect(isFilterItemActive({ type: 'range', value: [0, 100] }, [0, 100])).toBe(false)
16
+ })
17
+
18
+ it('Should return true if type is range and value does not match given range', () => {
19
+ expect(isFilterItemActive({ type: 'range', value: [0, 100] }, [0, 50])).toBe(true)
20
+ })
21
+
22
+ it('Should return true if type is range and no range is provided', () => {
23
+ expect(isFilterItemActive({ type: 'range', value: [0, 100] })).toBe(true)
24
+ })
25
+
26
+ it('Should return true if type is range and range is null', () => {
27
+ expect(isFilterItemActive({ type: 'range', value: [0, 100] }, null)).toBe(true)
28
+ })
29
+
30
+ it('Should return true for other types regardless of value', () => {
31
+ expect(isFilterItemActive({ type: 'string', value: 'some-value' })).toBe(true)
32
+ expect(isFilterItemActive({ type: 'string', value: '' })).toBe(true)
33
+ })
34
+ })
35
+ })
@@ -0,0 +1,120 @@
1
+ import { fireEvent, render, waitFor } from '@testing-library/svelte'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import ActiveFilter from '../../../../../routes/components/Explore/Filter/ActiveFilterItems.svelte'
4
+
5
+ vi.mock('$lib/localization', () => ({
6
+ t: vi.fn((key) => key),
7
+ }))
8
+
9
+ describe('ActiveFilter.svelte', () => {
10
+ const arrayItem = {
11
+ param: 'genres',
12
+ label: 'Genres',
13
+ data: [
14
+ { label: 'Action', value: 'action' },
15
+ { label: 'Comedy', value: 'comedy' },
16
+ ],
17
+ }
18
+
19
+ const rangeItem = {
20
+ param: 'year',
21
+ label: 'Year',
22
+ range: [1900, 2025],
23
+ }
24
+
25
+ const stringItem = {
26
+ param: 'ordering',
27
+ label: 'Sort by',
28
+ }
29
+
30
+ it('Should not render any buttons if filter is empty', () => {
31
+ const { container } = render(ActiveFilter, { filter: {}, items: [] })
32
+
33
+ expect(container.querySelectorAll('button')).toHaveLength(0)
34
+ })
35
+
36
+ it('Should not render button if filter item is not active', () => {
37
+ const filter = { genres: { type: 'array', value: [] } }
38
+ // @ts-ignore
39
+ const { container } = render(ActiveFilter, { filter, items: [arrayItem] })
40
+
41
+ expect(container.querySelectorAll('button')).toHaveLength(0)
42
+ })
43
+
44
+ it('Should render button for active array filter item', async () => {
45
+ const filter = { genres: { type: 'array', value: ['action'] } }
46
+ // @ts-ignore
47
+ const { findByText } = render(ActiveFilter, { filter, items: [arrayItem] })
48
+
49
+ expect(await findByText('Action')).toBeTruthy()
50
+ })
51
+
52
+ it('Should render joined labels for multiple active array values', async () => {
53
+ const filter = { genres: { type: 'array', value: ['action', 'comedy'] } }
54
+ // @ts-ignore
55
+ const { findByText } = render(ActiveFilter, { filter, items: [arrayItem] })
56
+
57
+ expect(await findByText('Action + Comedy')).toBeTruthy()
58
+ })
59
+
60
+ it('Should render label and range values for active range filter item', () => {
61
+ const filter = { year: { type: 'range', value: [2000, 2020] } }
62
+ // @ts-ignore
63
+ const { getByText } = render(ActiveFilter, { filter, items: [rangeItem] })
64
+
65
+ expect(getByText('Year: 2000 - 2020')).toBeTruthy()
66
+ })
67
+
68
+ it('Should not render button if range value matches the default range', () => {
69
+ const filter = { year: { type: 'range', value: [1900, 2025] } }
70
+ // @ts-ignore
71
+ const { container } = render(ActiveFilter, { filter, items: [rangeItem] })
72
+
73
+ expect(container.querySelectorAll('button')).toHaveLength(0)
74
+ })
75
+
76
+ it('Should remove filter item on button click', async () => {
77
+ const filter = { genres: { type: 'array', value: ['action'] } }
78
+ // @ts-ignore
79
+ const { container } = render(ActiveFilter, { filter, items: [arrayItem] })
80
+
81
+ expect(container.querySelectorAll('button')).toHaveLength(1)
82
+
83
+ await fireEvent.click(/** @type {HTMLButtonElement} */ (container.querySelector('button')))
84
+
85
+ expect(container.querySelectorAll('button')).toHaveLength(0)
86
+ })
87
+
88
+ it('Should render buttons for multiple active filter items', async () => {
89
+ const filter = {
90
+ genres: { type: 'array', value: ['action'] },
91
+ year: { type: 'range', value: [2000, 2020] },
92
+ }
93
+ // @ts-ignore
94
+ const { findByText, getByText } = render(ActiveFilter, { filter, items: [arrayItem, rangeItem] })
95
+
96
+ expect(await findByText('Action')).toBeTruthy()
97
+ expect(getByText('Year: 2000 - 2020')).toBeTruthy()
98
+ })
99
+
100
+ it('Should render translated value for active string filter item', () => {
101
+ const filter = { ordering: { type: 'string', value: '-new' } }
102
+ // @ts-ignore
103
+ const { getByText } = render(ActiveFilter, { filter, items: [stringItem] })
104
+
105
+ expect(getByText('-new')).toBeTruthy()
106
+ })
107
+
108
+ it('Should resolve array data from fetchData if provided', async () => {
109
+ const fetchData = vi.fn().mockResolvedValue([{ label: 'Action', value: 'action' }])
110
+ const itemWithFetch = { param: 'genres', label: 'Genres', fetchData }
111
+ const filter = { genres: { type: 'array', value: ['action'] } }
112
+
113
+ // @ts-ignore
114
+ const { findByText } = render(ActiveFilter, { filter, items: [itemWithFetch] })
115
+
116
+ await waitFor(() => {
117
+ expect(findByText('Action')).toBeTruthy()
118
+ })
119
+ })
120
+ })
@@ -67,4 +67,32 @@ describe('Filter.svelte', () => {
67
67
 
68
68
  expect(container.querySelector('.sorting')).toBeTruthy()
69
69
  })
70
+
71
+ it('Should call onempty after filter is removed', async () => {
72
+ const onempty = vi.fn()
73
+
74
+ const { getByText } = render(Filter, { filter: { genres: { type: 'array', value: ['101'] } }, onempty })
75
+
76
+ await fireEvent.click(getByText('Action'))
77
+
78
+ expect(onempty).toHaveBeenCalled()
79
+ })
80
+
81
+ it('Should not call onempty if filter is empty on mount', () => {
82
+ const onempty = vi.fn()
83
+
84
+ render(Filter, { filter: {}, onempty })
85
+
86
+ expect(onempty).not.toHaveBeenCalled()
87
+ })
88
+
89
+ it('Should not call onempty if filter is emptied but searchQuery is not', async () => {
90
+ const onempty = vi.fn()
91
+
92
+ const { getByText } = render(Filter, { filter: { genres: { type: 'array', value: ['101'] } }, onempty, searchQuery: 'thing' })
93
+
94
+ await fireEvent.click(getByText('Action'))
95
+
96
+ expect(onempty).not.toHaveBeenCalled()
97
+ })
70
98
  })