From b3df73e4fb8acf95e346091be263774437c22b1f Mon Sep 17 00:00:00 2001 From: Roy Schut Date: Thu, 19 May 2022 13:56:45 +0200 Subject: [PATCH] feat(videodetail): add tvod entitlement to series screen --- src/components/CardGrid/CardGrid.tsx | 4 +- src/components/Shelf/Shelf.tsx | 4 +- src/components/Video/Video.tsx | 6 +-- src/hooks/useEntitlement.ts | 64 +++++++++++++++++----------- src/i18n/locales/en_US/video.json | 1 + src/screens/Movie/Movie.tsx | 40 ++++++++--------- src/screens/Series/Series.tsx | 37 ++++++++-------- src/utils/cleeng.ts | 51 ++++++++++++++-------- types/media.d.ts | 2 +- 9 files changed, 121 insertions(+), 88 deletions(-) diff --git a/src/components/CardGrid/CardGrid.tsx b/src/components/CardGrid/CardGrid.tsx index 512d1dd09..3f7c699b6 100644 --- a/src/components/CardGrid/CardGrid.tsx +++ b/src/components/CardGrid/CardGrid.tsx @@ -7,7 +7,7 @@ import Card from '../Card/Card'; import useBreakpoint, { Breakpoint, Breakpoints } from '../../hooks/useBreakpoint'; import { chunk, findPlaylistImageForWidth } from '../../utils/collection'; import type { AccessModel } from '../../../types/Config'; -import { isAllowedToWatch } from '../../utils/cleeng'; +import { showLock } from '../../utils/cleeng'; import styles from './CardGrid.module.scss'; @@ -77,7 +77,7 @@ function CardGrid({ loading={isLoading} isCurrent={currentCardItem && currentCardItem.mediaid === mediaid} currentLabel={currentCardLabel} - isLocked={!isAllowedToWatch(accessModel, isLoggedIn, playlistItem.requiresSubscription !== 'false', hasSubscription)} + isLocked={showLock(accessModel, isLoggedIn, hasSubscription, playlistItem)} /> diff --git a/src/components/Shelf/Shelf.tsx b/src/components/Shelf/Shelf.tsx index a8260428b..d663f4bd5 100644 --- a/src/components/Shelf/Shelf.tsx +++ b/src/components/Shelf/Shelf.tsx @@ -9,7 +9,7 @@ import ChevronLeft from '../../icons/ChevronLeft'; import ChevronRight from '../../icons/ChevronRight'; import { findPlaylistImageForWidth } from '../../utils/collection'; import type { AccessModel } from '../../../types/Config'; -import { isAllowedToWatch } from '../../utils/cleeng'; +import { showLock } from '../../utils/cleeng'; import styles from './Shelf.module.scss'; @@ -85,7 +85,7 @@ const Shelf: React.FC = ({ featured={featured} disabled={!isInView} loading={loading} - isLocked={!isAllowedToWatch(accessModel, isLoggedIn, item.requiresSubscription !== 'false', hasSubscription)} + isLocked={showLock(accessModel, isLoggedIn, hasSubscription, item)} /> ), [enableCardTitles, featured, imageSourceWidth, loading, onCardClick, onCardHover, playlist.feedid, watchHistory, accessModel, isLoggedIn, hasSubscription], diff --git a/src/components/Video/Video.tsx b/src/components/Video/Video.tsx index 034db7eb8..deb915122 100644 --- a/src/components/Video/Video.tsx +++ b/src/components/Video/Video.tsx @@ -31,7 +31,7 @@ type Props = { feedId?: string; trailerItem?: PlaylistItem; play: boolean; - allowedToWatch: boolean; + isEntitled: boolean; startWatchingLabel: string; progress?: number; onStartWatchingClick: () => void; @@ -57,7 +57,7 @@ const Video: React.FC = ({ feedId, trailerItem, play, - allowedToWatch, + isEntitled, startWatchingLabel, onStartWatchingClick, progress, @@ -134,7 +134,7 @@ const Video: React.FC = ({ variant="contained" size="large" label={startWatchingLabel} - startIcon={allowedToWatch ? : undefined} + startIcon={isEntitled ? : undefined} onClick={onStartWatchingClick} active={play} fullWidth={breakpoint < Breakpoint.md} diff --git a/src/hooks/useEntitlement.ts b/src/hooks/useEntitlement.ts index d13a03f65..31ebdabc9 100644 --- a/src/hooks/useEntitlement.ts +++ b/src/hooks/useEntitlement.ts @@ -1,51 +1,67 @@ import { useQueries } from 'react-query'; import { useMemo } from 'react'; +import shallow from 'zustand/shallow'; import type { GetEntitlementsResponse } from '../../types/checkout'; -import type { MediaOffer } from '../../types/media'; +import { filterCleengMediaOffers } from '../utils/cleeng'; +import type { PlaylistItem } from '../../types/playlist'; import { useConfigStore } from '#src/stores/ConfigStore'; import { useAccountStore } from '#src/stores/AccountStore'; import { getEntitlements } from '#src/services/checkout.service'; +export type QueriesResult = { + isMediaEntitled: boolean; + isMediaEntitlementLoading: boolean; +}; + export type UseEntitlementResult = { isEntitled: boolean; - isLoading: boolean; - error: unknown | null; + isMediaEntitlementLoading: boolean; + hasMediaOffers: boolean; + hasPremierOffer: boolean; }; -export type UseEntitlement = (mediaOffers?: MediaOffer[], enabled?: boolean) => UseEntitlementResult; +export type UseEntitlement = (playlistItem?: PlaylistItem) => UseEntitlementResult; type QueryResult = { responseData?: GetEntitlementsResponse; }; -const useEntitlement: UseEntitlement = (mediaOffers = [], enabled = true) => { - const sandbox = useConfigStore(({ config }) => config?.cleengSandbox); - const jwt = useAccountStore(({ auth }) => auth?.jwt); +const useEntitlement: UseEntitlement = (playlistItem) => { + const { sandbox, accessModel } = useConfigStore(({ config, accessModel }) => ({ sandbox: config?.cleengSandbox, accessModel }), shallow); + const { user, subscription, jwt } = useAccountStore(({ user, subscription, auth }) => ({ user, subscription, jwt: auth?.jwt }), shallow); - const entitlementQueries = useQueries( + const isItemFree = playlistItem?.requiresSubscription === 'false' || !!playlistItem?.free; + const mediaOffers = useMemo(() => filterCleengMediaOffers(playlistItem?.productIds) || [], [playlistItem]); + const hasPremierOffer = mediaOffers?.some((offer) => offer.premier); + const skipMediaEntitlement = isItemFree || (subscription && !hasPremierOffer); + + const mediaEntitlementQueries = useQueries( mediaOffers.map(({ offerId }) => ({ queryKey: ['mediaOffer', offerId], - queryFn: () => getEntitlements({ offerId: offerId || '' }, sandbox, jwt || ''), - enabled: enabled && !!jwt && !!offerId, + queryFn: () => getEntitlements({ offerId }, sandbox, jwt || ''), + enabled: !!playlistItem && !!jwt && !!offerId && !skipMediaEntitlement, })), ); - const evaluateEntitlement = (queryResult: QueryResult) => !!queryResult?.responseData?.accessGranted; - - return useMemo( - () => - entitlementQueries.reduce( - (prev, cur) => ({ - isLoading: prev.isLoading || cur.isLoading, - error: prev.error || cur.error, - isEntitled: prev.isEntitled || (cur.isSuccess && evaluateEntitlement(cur as QueryResult)), - }), - { isEntitled: false, isLoading: false, error: null }, - ), - [entitlementQueries], - ); + const { isMediaEntitled, isMediaEntitlementLoading } = useMemo(() => { + const isEntitled = mediaEntitlementQueries.some((item) => item.isSuccess && (item as QueryResult)?.responseData?.accessGranted); + + return { isMediaEntitled: isEntitled, isMediaEntitlementLoading: !isEntitled && mediaEntitlementQueries.some((item) => item.isLoading) }; + }, [mediaEntitlementQueries]); + + const isEntitled = useMemo(() => { + if (isItemFree) return true; + if (accessModel === 'AVOD' && !mediaOffers) return true; + if (accessModel === 'AUTHVOD' && !!user && !mediaOffers) return true; + if (accessModel === 'SVOD' && !!subscription && !hasPremierOffer) return true; + if (accessModel === 'SVOD' && isMediaEntitled) return true; + + return false; + }, [accessModel, user, subscription]); + + return { isEntitled, isMediaEntitlementLoading, hasMediaOffers: mediaOffers?.length > 0, hasPremierOffer }; }; export default useEntitlement; diff --git a/src/i18n/locales/en_US/video.json b/src/i18n/locales/en_US/video.json index 6cb1aee91..e54b78035 100644 --- a/src/i18n/locales/en_US/video.json +++ b/src/i18n/locales/en_US/video.json @@ -1,6 +1,7 @@ { "add_to_favorites": "Add to favorites", "all_seasons": "All seasons", + "buy": "Buy", "complete_your_subscription": "Complete your subscription", "continue_watching": "Continue watching", "copied_url": "Copied url", diff --git a/src/screens/Movie/Movie.tsx b/src/screens/Movie/Movie.tsx index 036a47871..ea8fd38a6 100644 --- a/src/screens/Movie/Movie.tsx +++ b/src/screens/Movie/Movie.tsx @@ -23,7 +23,6 @@ import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore'; import { useConfigStore } from '#src/stores/ConfigStore'; import { useAccountStore } from '#src/stores/AccountStore'; import { addQueryParam } from '#src/utils/history'; -import { filterCleengMediaOffers, isAllowedToWatch } from '#src/utils/cleeng'; import { addConfigParamToUrl } from '#src/utils/configOverride'; import { removeItem, saveItem } from '#src/stores/FavoritesController'; import useEntitlement from '#src/hooks/useEntitlement'; @@ -62,15 +61,9 @@ const Movie = ({ match, location }: RouteComponentProps): JSX. const [hasShared, setHasShared] = useState(false); const [playTrailer, setPlayTrailer] = useState(false); - // User, accessModel, entitlement + // User, entitlement const { user, subscription } = useAccountStore(({ user, subscription }) => ({ user, subscription }), shallow); - const isItemFree = item?.requiresSubscription === 'false' || !!item?.free; - - const mediaOffers = useMemo(() => filterCleengMediaOffers(item?.productIds), [item]); - const hasForcedOffer = mediaOffers?.some((offer) => offer.forced); - const skipEntitlement = isItemFree || (subscription && !hasForcedOffer); - const { isEntitled } = useEntitlement(mediaOffers, !skipEntitlement); - const allowedToWatch = isAllowedToWatch(hasForcedOffer ? 'TVOD' : accessModel, !!user, isItemFree, !!subscription, isEntitled); + const { isEntitled, isMediaEntitlementLoading, hasPremierOffer } = useEntitlement(item); // Handlers const goBack = () => item && history.push(videoUrl(item, searchParams.get('r'), false)); @@ -96,18 +89,21 @@ const Movie = ({ match, location }: RouteComponentProps): JSX. return nextItem && history.push(videoUrl(nextItem, searchParams.get('r'), true)); }, [history, id, playlist, searchParams]); - const formatStartWatchingLabel = (): string => { - if (!allowedToWatch && !user) return t('sign_up_to_start_watching'); - if (!allowedToWatch && !subscription) return t('complete_your_subscription'); - return typeof progress === 'number' ? t('continue_watching') : t('start_watching'); - }; + const startWatchingLabel = useMemo((): string => { + if (isEntitled) return typeof progress === 'number' ? t('continue_watching') : t('start_watching'); + if (!user) return t('sign_up_to_start_watching'); + if (!subscription && !hasPremierOffer) return t('complete_your_subscription'); + + return t('buy'); + }, [isEntitled, user, subscription, hasPremierOffer, progress, t]); const handleStartWatchingClick = useCallback(() => { - if (!allowedToWatch && !user) return history.push(addQueryParam(history, 'u', 'create-account')); - if (!allowedToWatch && !subscription) return history.push('/u/payments'); + if (isEntitled) return item && history.push(videoUrl(item, searchParams.get('r'), true)); + if (!user) return history.push(addQueryParam(history, 'u', 'create-account')); + if (!subscription && !hasPremierOffer) return history.push('/u/payments'); - return item && history.push(videoUrl(item, searchParams.get('r'), true)); - }, [allowedToWatch, user, subscription, history, item, searchParams]); + return history.push(addQueryParam(history, 'u', 'choose-offer')); + }, [isEntitled, user, subscription, history, item, searchParams, hasPremierOffer]); // Effects useEffect(() => { @@ -122,7 +118,7 @@ const Movie = ({ match, location }: RouteComponentProps): JSX. }, [id]); // UI - if (isLoading && !item) return ; + if ((isLoading && !item) || isMediaEntitlementLoading) return ; if ((!isLoading && error) || !item) return ; const pageTitle = `${item.title} - ${siteName}`; @@ -159,9 +155,9 @@ const Movie = ({ match, location }: RouteComponentProps): JSX. item={item} feedId={feedId ?? undefined} trailerItem={trailerItem} - play={play && allowedToWatch} - allowedToWatch={allowedToWatch} - startWatchingLabel={formatStartWatchingLabel()} + play={play && isEntitled} + isEntitled={isEntitled} + startWatchingLabel={startWatchingLabel} onStartWatchingClick={handleStartWatchingClick} goBack={goBack} onComplete={handleComplete} diff --git a/src/screens/Series/Series.tsx b/src/screens/Series/Series.tsx index eac7dcfbb..d386fbf3e 100644 --- a/src/screens/Series/Series.tsx +++ b/src/screens/Series/Series.tsx @@ -5,6 +5,8 @@ import { Helmet } from 'react-helmet'; import { useTranslation } from 'react-i18next'; import shallow from 'zustand/shallow'; +import useEntitlement from '../../hooks/useEntitlement'; + import styles from './Series.module.scss'; import CardGrid from '#src/components/CardGrid/CardGrid'; @@ -22,7 +24,6 @@ import { filterSeries, getFiltersFromSeries } from '#src/utils/collection'; import LoadingOverlay from '#src/components/LoadingOverlay/LoadingOverlay'; import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore'; import { useConfigStore } from '#src/stores/ConfigStore'; -import { isAllowedToWatch } from '#src/utils/cleeng'; import { useAccountStore } from '#src/stores/AccountStore'; import { addQueryParam } from '#src/utils/history'; import { useFavoritesStore } from '#src/stores/FavoritesStore'; @@ -51,7 +52,6 @@ const Series = ({ match, location }: RouteComponentProps): JS // Media const { isLoading, error, data: item } = useMedia(episodeId); - const itemRequiresSubscription = item?.requiresSubscription !== 'false'; useBlurImageUpdater(item); const { data: trailerItem } = useMedia(item?.trailerId || ''); const { isLoading: playlistIsLoading, error: playlistError, data: seriesPlaylist = { title: '', playlist: [] } } = usePlaylist(id, undefined, true, false); @@ -69,9 +69,9 @@ const Series = ({ match, location }: RouteComponentProps): JS const [hasShared, setHasShared] = useState(false); const [playTrailer, setPlayTrailer] = useState(false); - // User + // User, entitlement const { user, subscription } = useAccountStore(({ user, subscription }) => ({ user, subscription }), shallow); - const allowedToWatch = isAllowedToWatch(accessModel, !!user, itemRequiresSubscription, !!subscription); + const { isEntitled, isMediaEntitlementLoading, hasPremierOffer } = useEntitlement(item); // Handlers const goBack = () => item && seriesPlaylist && history.push(episodeURL(seriesPlaylist, item.mediaid, false)); @@ -97,18 +97,21 @@ const Series = ({ match, location }: RouteComponentProps): JS return nextItem && history.push(episodeURL(seriesPlaylist, nextItem.mediaid, true)); }, [history, item, seriesPlaylist]); - const formatStartWatchingLabel = (): string => { - if (!allowedToWatch && !user) return t('sign_up_to_start_watching'); - if (!allowedToWatch && !subscription) return t('complete_your_subscription'); - return typeof progress === 'number' ? t('continue_watching') : t('start_watching'); - }; + const startWatchingLabel = useMemo((): string => { + if (isEntitled) return typeof progress === 'number' ? t('continue_watching') : t('start_watching'); + if (!user) return t('sign_up_to_start_watching'); + if (!subscription && !hasPremierOffer) return t('complete_your_subscription'); + + return t('buy'); + }, [isEntitled, progress, user, subscription, hasPremierOffer, t]); const handleStartWatchingClick = useCallback(() => { - if (!allowedToWatch && !user) return history.push(addQueryParam(history, 'u', 'create-account')); - if (!allowedToWatch && !subscription) return history.push('/u/payments'); + if (isEntitled) return history.push(episodeURL(seriesPlaylist, item?.mediaid, true)); + if (!user) return history.push(addQueryParam(history, 'u', 'create-account')); + if (!subscription && !hasPremierOffer) return history.push('/u/payments'); - return history.push(episodeURL(seriesPlaylist, item?.mediaid, true)); - }, [allowedToWatch, user, history, subscription, seriesPlaylist, item?.mediaid]); + return history.push(addQueryParam(history, 'u', 'choose-offer')); + }, [isEntitled, user, history, subscription, seriesPlaylist, item?.mediaid, hasPremierOffer]); // Effects useEffect(() => { @@ -129,7 +132,7 @@ const Series = ({ match, location }: RouteComponentProps): JS }, [history, searchParams, seriesPlaylist]); // UI - if ((!item && isLoading) || playlistIsLoading || !searchParams.has('e')) return ; + if ((!item && isLoading) || playlistIsLoading || !searchParams.has('e') || isMediaEntitlementLoading) return ; if ((!isLoading && error) || !item) return ; if (playlistError || !seriesPlaylist) return ; @@ -168,10 +171,10 @@ const Series = ({ match, location }: RouteComponentProps): JS item={item} feedId={feedId ?? undefined} trailerItem={trailerItem} - play={play && allowedToWatch} - allowedToWatch={allowedToWatch} + play={play && isEntitled} + isEntitled={isEntitled} progress={progress} - startWatchingLabel={formatStartWatchingLabel()} + startWatchingLabel={startWatchingLabel} onStartWatchingClick={handleStartWatchingClick} goBack={goBack} onComplete={handleComplete} diff --git a/src/utils/cleeng.ts b/src/utils/cleeng.ts index fad90dadd..024d085b6 100644 --- a/src/utils/cleeng.ts +++ b/src/utils/cleeng.ts @@ -1,31 +1,48 @@ import type { AccessModel } from '../../types/Config'; import type { MediaOffer } from '../../types/media'; +import type { PlaylistItem } from '../../types/playlist'; -export const isAllowedToWatch = ( - accessModel: AccessModel, - isLoggedIn: boolean, - isItemFree: boolean, - hasSubscription: boolean, - isEntitled?: boolean, -): boolean => { - if (accessModel === 'AVOD') return true; - if (isItemFree) return true; - if (accessModel === 'AUTHVOD' && isLoggedIn) return true; - if (accessModel === 'SVOD' && hasSubscription) return true; - if (accessModel === 'SVOD' && isEntitled) return true; - if (accessModel === 'TVOD' && isEntitled) return true; +/** + * The appearance of the lock icon, depending on the access model + * + * @param accessModel Platform AccessModel, excluding TVOD, which can only be applied per item + * @param isLoggedIn + * @param hasSubscription + * @param playlistItem Used to define if the items is 'free' or has mediaOffers + * @returns + */ - return false; +export const showLock = (accessModel: AccessModel, isLoggedIn: boolean, hasSubscription: boolean, playlistItem: PlaylistItem): boolean => { + const isItemFree = playlistItem?.requiresSubscription === 'false' || !!playlistItem?.free; + const mediaOffers = filterCleengMediaOffers(playlistItem?.productIds); + + if (isItemFree) return false; + if (accessModel === 'AVOD' && !mediaOffers) return false; + if (accessModel === 'AUTHVOD' && isLoggedIn && !mediaOffers) return false; + if (accessModel === 'SVOD' && hasSubscription && !mediaOffers?.some((offer) => offer.premier)) return false; + + return true; }; -export const filterCleengMediaOffers = (offerIds: string = ''): MediaOffer[] => { +/** + * Filters Cleeng MediaOffers from offers string + * + * @param offerIds String of comma separated key/value pairs, i.e. "cleeng:S916977979_NL, !cleeng:S91633379_NL, other_vendor:xyz123" + * Key is vendor, value is the offerId. + * Vendor keys starting with an exclamation mark represent a 'Premier Access' offer (TVOD only) + * + * @returns An array of MediaOffer { offerId, premier } + */ +export const filterCleengMediaOffers = (offerIds?: string): MediaOffer[] | null => { + if (!offerIds) return null; + return offerIds .replace(/\s/g, '') .split(',') .reduce( (offers, offerId) => - offerId.indexOf('cleeng:') === 0 || offerId?.indexOf('!cleeng:') === 0 - ? [...offers, { offerId: offerId.slice(offerId.indexOf(':') + 1), forced: offerId[0] === '!' }] + offerId.indexOf('cleeng:') === 0 || offerId.indexOf('!cleeng:') === 0 + ? [...offers, { offerId: offerId.slice(offerId.indexOf(':') + 1), premier: offerId[0] === '!' }] : offers, [], ); diff --git a/types/media.d.ts b/types/media.d.ts index 3f117eaf9..1ebfce513 100644 --- a/types/media.d.ts +++ b/types/media.d.ts @@ -8,5 +8,5 @@ export type Media = { export type MediaOffer = { offerId: string; - forced: boolean; + premier: boolean; };