Skip to content

Commit

Permalink
feat(videodetail): add tvod entitlement to series screen
Browse files Browse the repository at this point in the history
  • Loading branch information
royschut committed May 19, 2022
1 parent 54f8db8 commit b3df73e
Show file tree
Hide file tree
Showing 9 changed files with 121 additions and 88 deletions.
4 changes: 2 additions & 2 deletions src/components/CardGrid/CardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)}
/>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/Shelf/Shelf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -85,7 +85,7 @@ const Shelf: React.FC<ShelfProps> = ({
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],
Expand Down
6 changes: 3 additions & 3 deletions src/components/Video/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type Props = {
feedId?: string;
trailerItem?: PlaylistItem;
play: boolean;
allowedToWatch: boolean;
isEntitled: boolean;
startWatchingLabel: string;
progress?: number;
onStartWatchingClick: () => void;
Expand All @@ -57,7 +57,7 @@ const Video: React.FC<Props> = ({
feedId,
trailerItem,
play,
allowedToWatch,
isEntitled,
startWatchingLabel,
onStartWatchingClick,
progress,
Expand Down Expand Up @@ -134,7 +134,7 @@ const Video: React.FC<Props> = ({
variant="contained"
size="large"
label={startWatchingLabel}
startIcon={allowedToWatch ? <Play /> : undefined}
startIcon={isEntitled ? <Play /> : undefined}
onClick={onStartWatchingClick}
active={play}
fullWidth={breakpoint < Breakpoint.md}
Expand Down
64 changes: 40 additions & 24 deletions src/hooks/useEntitlement.ts
Original file line number Diff line number Diff line change
@@ -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<UseEntitlementResult>(
(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;
1 change: 1 addition & 0 deletions src/i18n/locales/en_US/video.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
40 changes: 18 additions & 22 deletions src/screens/Movie/Movie.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -62,15 +61,9 @@ const Movie = ({ match, location }: RouteComponentProps<MovieRouteParams>): JSX.
const [hasShared, setHasShared] = useState<boolean>(false);
const [playTrailer, setPlayTrailer] = useState<boolean>(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));
Expand All @@ -96,18 +89,21 @@ const Movie = ({ match, location }: RouteComponentProps<MovieRouteParams>): 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(() => {
Expand All @@ -122,7 +118,7 @@ const Movie = ({ match, location }: RouteComponentProps<MovieRouteParams>): JSX.
}, [id]);

// UI
if (isLoading && !item) return <LoadingOverlay />;
if ((isLoading && !item) || isMediaEntitlementLoading) return <LoadingOverlay />;
if ((!isLoading && error) || !item) return <ErrorPage title={t('video_not_found')} />;

const pageTitle = `${item.title} - ${siteName}`;
Expand Down Expand Up @@ -159,9 +155,9 @@ const Movie = ({ match, location }: RouteComponentProps<MovieRouteParams>): 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}
Expand Down
37 changes: 20 additions & 17 deletions src/screens/Series/Series.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -51,7 +52,6 @@ const Series = ({ match, location }: RouteComponentProps<SeriesRouteParams>): 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);
Expand All @@ -69,9 +69,9 @@ const Series = ({ match, location }: RouteComponentProps<SeriesRouteParams>): JS
const [hasShared, setHasShared] = useState<boolean>(false);
const [playTrailer, setPlayTrailer] = useState<boolean>(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));
Expand All @@ -97,18 +97,21 @@ const Series = ({ match, location }: RouteComponentProps<SeriesRouteParams>): 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(() => {
Expand All @@ -129,7 +132,7 @@ const Series = ({ match, location }: RouteComponentProps<SeriesRouteParams>): JS
}, [history, searchParams, seriesPlaylist]);

// UI
if ((!item && isLoading) || playlistIsLoading || !searchParams.has('e')) return <LoadingOverlay />;
if ((!item && isLoading) || playlistIsLoading || !searchParams.has('e') || isMediaEntitlementLoading) return <LoadingOverlay />;
if ((!isLoading && error) || !item) return <ErrorPage title={t('episode_not_found')} />;
if (playlistError || !seriesPlaylist) return <ErrorPage title={t('series_not_found')} />;

Expand Down Expand Up @@ -168,10 +171,10 @@ const Series = ({ match, location }: RouteComponentProps<SeriesRouteParams>): 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}
Expand Down
51 changes: 34 additions & 17 deletions src/utils/cleeng.ts
Original file line number Diff line number Diff line change
@@ -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<MediaOffer[]>(
(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,
[],
);
Expand Down
Loading

0 comments on commit b3df73e

Please sign in to comment.