Skip to content

Commit 62882da

Browse files
committed
feat: movie screen composition
1 parent 8cff147 commit 62882da

29 files changed

+981
-185
lines changed

packages/common/src/utils/datetime.ts

+16
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,19 @@ export const is12HourClock = () => {
2828
const date = new Date(Date.UTC(2022, 6, 20, 18, 0, 0));
2929
return /am|pm/.test(date.toLocaleTimeString());
3030
};
31+
32+
export const formatItemDate = (date?: string | null, locale?: string) => {
33+
if (!date) return null;
34+
35+
if (date.length <= 4) return date;
36+
37+
try {
38+
const [day, month, year] = date.split('-');
39+
const dateObj = new Date(`${year}-${month}-${day}`);
40+
if (dateObj.toString() === 'Invalid Date') return date; // fallback to original string
41+
42+
return dateObj.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' });
43+
} catch (error: unknown) {
44+
return date; // fallback to original string
45+
}
46+
};

packages/common/src/utils/metadata.ts

+4
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,7 @@ export const createLiveEventMetadata = (media: PlaylistItem, locale: string, i18
3636

3737
return metaData;
3838
};
39+
40+
export const countListValues = (value: string) => (!value ? 0 : value.split(',').length);
41+
42+
export const hasFormatMetadata = (media: PlaylistItem) => ['subtitleFormat', 'videoFormat', 'audioFormat'].some((property) => media[property]);
+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { PlaylistItem } from '@jwp/ott-common/types/playlist';
2+
import { useState, useMemo } from 'react';
3+
import { useTranslation } from 'react-i18next';
4+
import { createVideoMetadata } from '@jwp/ott-common/src/utils/metadata';
5+
import { shallow } from '@jwp/ott-common/src/utils/compare';
6+
import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore';
7+
import { mediaURL } from '@jwp/ott-common/src/utils/urlFormatting';
8+
import useMedia from '@jwp/ott-hooks-react/src/useMedia';
9+
import usePlaylist from '@jwp/ott-hooks-react/src/usePlaylist';
10+
import useEntitlement from '@jwp/ott-hooks-react/src/useEntitlement';
11+
12+
export default function useMovieData(data: PlaylistItem, id: string, feedId: string | null) {
13+
const { t } = useTranslation('common');
14+
const [playTrailer, setPlayTrailer] = useState<boolean>(false);
15+
16+
// Config
17+
const { config, accessModel } = useConfigStore(({ config, accessModel }) => ({ config, accessModel }), shallow);
18+
const { features } = config;
19+
const isFavoritesEnabled: boolean = Boolean(features?.favoritesList);
20+
21+
// Media
22+
const { isLoading: isTrailerLoading, data: trailerItem } = useMedia(data?.trailerId || '');
23+
const { isLoading: isPlaylistLoading, data: playlist } = usePlaylist(features?.recommendationsPlaylist || '', { related_media_id: id });
24+
const nextItem = useMemo(() => {
25+
if (!id || !playlist) return;
26+
27+
const index = playlist.playlist.findIndex(({ mediaid }) => mediaid === id);
28+
const nextItem = playlist.playlist[index + 1];
29+
30+
return nextItem;
31+
}, [id, playlist]);
32+
33+
// User, entitlement
34+
const { isEntitled, mediaOffers } = useEntitlement(data);
35+
36+
// UI
37+
const movieURL = mediaURL({ id: data.mediaid, title: data.title, playlistId: feedId, play: false });
38+
const playUrl = mediaURL({ id: data.mediaid, title: data.title, playlistId: feedId, play: true });
39+
const hasTrailer = !!trailerItem || isTrailerLoading;
40+
const primaryMetadata = [
41+
...createVideoMetadata(data, {
42+
hoursAbbreviation: t('common:abbreviation.hours'),
43+
minutesAbbreviation: t('common:abbreviation.minutes'),
44+
}),
45+
...(data?.contentType ? [data.contentType] : []),
46+
];
47+
48+
return {
49+
playlist,
50+
isTrailerLoading,
51+
isPlaylistLoading,
52+
mediaOffers,
53+
movieURL,
54+
playUrl,
55+
nextItem,
56+
trailerItem,
57+
accessModel,
58+
hasTrailer,
59+
isFavoritesEnabled,
60+
isEntitled,
61+
playTrailer,
62+
primaryMetadata,
63+
setPlayTrailer,
64+
};
65+
}

packages/ui-react/src/components/Hero/Hero.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import styles from './Hero.module.scss';
99

1010
type Props = PropsWithChildren<{
1111
image?: string;
12+
className?: string;
1213
infoClassName?: string;
1314
}>;
1415

15-
const Hero = ({ image, children, infoClassName }: Props) => {
16+
const Hero = ({ image, children, className, infoClassName }: Props) => {
1617
const alt = ''; // intentionally empty for a11y, because adjacent text alternative
1718
const posterRef = useRef<HTMLImageElement>(null);
1819
const breakpoint = useBreakpoint();
@@ -23,7 +24,7 @@ const Hero = ({ image, children, infoClassName }: Props) => {
2324
});
2425

2526
return (
26-
<div className={styles.hero}>
27+
<div className={classNames([styles.hero, className])}>
2728
<Image ref={posterRef} className={styles.poster} image={image} width={1280} alt={alt} />
2829
<div className={styles.posterFadeMenu} />
2930
<div className={styles.posterFadeLeft} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React from 'react';
2+
import { Helmet } from 'react-helmet';
3+
import { shallow } from '@jwp/ott-common/src/utils/compare';
4+
import type { PlaylistItem } from '@jwp/ott-common/types/playlist';
5+
import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore';
6+
import { generateMovieJSONLD } from '@jwp/ott-common/src/utils/structuredData';
7+
import env from '@jwp/ott-common/src/env';
8+
9+
const MediaHead = ({ data, canonicalUrl }: { data: PlaylistItem; canonicalUrl: string }) => {
10+
const { config } = useConfigStore(({ config, accessModel }) => ({ config, accessModel }), shallow);
11+
const { siteName } = config;
12+
13+
const pageTitle = `${data.title} - ${siteName}`;
14+
15+
return (
16+
<Helmet>
17+
<title>{pageTitle}</title>
18+
<link rel="canonical" href={canonicalUrl} />
19+
<meta name="description" content={data.description} />
20+
<meta property="og:description" content={data.description} />
21+
<meta property="og:title" content={pageTitle} />
22+
<meta property="og:type" content="video.other" />
23+
{data.image && <meta property="og:image" content={data.image?.replace(/^https:/, 'http:')} />}
24+
{data.image && <meta property="og:image:secure_url" content={data.image?.replace(/^http:/, 'https:')} />}
25+
<meta property="og:image:width" content={data.image ? '720' : ''} />
26+
<meta property="og:image:height" content={data.image ? '406' : ''} />
27+
<meta name="twitter:title" content={pageTitle} />
28+
<meta name="twitter:description" content={data.description} />
29+
<meta name="twitter:image" content={data.image} />
30+
<meta property="og:video" content={canonicalUrl.replace(/^https:/, 'http:')} />
31+
<meta property="og:video:secure_url" content={canonicalUrl.replace(/^http:/, 'https:')} />
32+
<meta property="og:video:type" content="text/html" />
33+
<meta property="og:video:width" content="1280" />
34+
<meta property="og:video:height" content="720" />
35+
{data.tags?.split(',').map((tag) => (
36+
<meta property="og:video:tag" content={tag} key={tag} />
37+
))}
38+
{data ? <script type="application/ld+json">{generateMovieJSONLD(data, env.APP_PUBLIC_URL)}</script> : null}
39+
</Helmet>
40+
);
41+
};
42+
43+
export default MediaHead;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.infoSmallScreenReorder {
2+
// Re-ordering the elements on small screens
3+
display: flex;
4+
flex-direction: column;
5+
6+
[class^='_title'] {
7+
order: -1;
8+
margin-bottom: 24px;
9+
}
10+
11+
[class^='_buttonBar'] {
12+
order: -1;
13+
}
14+
15+
[class^='_metaContainer'] {
16+
order: 99;
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { type PropsWithChildren } from 'react';
2+
import Hero from '@jwp/ott-ui-react/src/components/Hero/Hero';
3+
import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint';
4+
5+
import styles from './MediaHero.module.scss';
6+
7+
const MediaHero = ({ image, children }: PropsWithChildren<{ image?: string }>) => {
8+
const breakpoint = useBreakpoint();
9+
10+
return (
11+
<header id="video-details">
12+
<Hero image={image} infoClassName={breakpoint < Breakpoint.md ? styles.infoSmallScreenReorder : undefined}>
13+
{children}
14+
</Hero>
15+
</header>
16+
);
17+
};
18+
19+
export default MediaHero;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
@use '@jwp/ott-ui-react/src/styles/variables';
2+
@use '@jwp/ott-ui-react/src/styles/mixins/responsive';
3+
4+
.buttonBar {
5+
display: flex;
6+
align-items: center;
7+
margin-top: 24px;
8+
gap: calc(variables.$base-spacing / 2);
9+
10+
> button {
11+
flex-shrink: 0;
12+
}
13+
14+
@include responsive.mobile-and-small-tablet() {
15+
flex-wrap: wrap;
16+
> button {
17+
flex: 1;
18+
justify-content: center;
19+
20+
&:first-child {
21+
flex-basis: 100%;
22+
margin-bottom: 8px;
23+
}
24+
}
25+
}
26+
}
27+
28+
.roundButton {
29+
justify-content: center;
30+
width: 38px;
31+
height: 38px;
32+
border-radius: 50%;
33+
34+
> [class^='_startIcon'] {
35+
margin-right: 0;
36+
}
37+
38+
> span {
39+
display: none;
40+
}
41+
}
42+
43+
.rectangleButton {
44+
flex-direction: column;
45+
height: auto;
46+
padding: 16px;
47+
background-color: transparent !important;
48+
border: none !important;
49+
50+
> [class^='_startIcon'] {
51+
margin-right: 0;
52+
margin-bottom: 8px;
53+
}
54+
55+
> span {
56+
font-weight: var(--body-font-weight-bold);
57+
}
58+
59+
&[class*='primary'] {
60+
color: inherit; // Toggle state for Favorite button
61+
}
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from 'react';
2+
import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint';
3+
4+
import styles from './MediaHeroButtons.module.scss';
5+
6+
const MediaHeroButtons = ({
7+
cta,
8+
children,
9+
buttonClassOverride = true,
10+
}: {
11+
cta: React.ReactNode;
12+
children: React.ReactNode[];
13+
buttonClassOverride?: boolean;
14+
}) => {
15+
const breakpoint = useBreakpoint();
16+
const buttonClassName = buttonClassOverride ? (breakpoint < Breakpoint.md ? styles.rectangleButton : styles.roundButton) : undefined;
17+
18+
const validChildren = React.Children.toArray(children).filter(React.isValidElement);
19+
20+
return (
21+
<div className={styles.buttonBar}>
22+
{cta}
23+
{validChildren.map((child) => React.cloneElement(child as React.ReactElement, { className: buttonClassName }))}
24+
</div>
25+
);
26+
};
27+
28+
export default MediaHeroButtons;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
@use '@jwp/ott-ui-react/src/styles/variables';
2+
@use '@jwp/ott-ui-react/src/styles/mixins/typography';
3+
@use '@jwp/ott-ui-react/src/styles/mixins/responsive';
4+
5+
.title {
6+
@include typography.video-title-base() {
7+
margin-bottom: calc(variables.$base-spacing / 2);
8+
}
9+
}
10+
11+
.description {
12+
line-height: variables.$base-line-height;
13+
}
14+
15+
.metaContainer {
16+
display: flex;
17+
flex-wrap: wrap;
18+
align-items: center;
19+
margin-bottom: 8px;
20+
gap: 16px;
21+
font-size: 1em;
22+
line-height: variables.$base-line-height;
23+
letter-spacing: 0.15px;
24+
25+
> div:last-of-type {
26+
// Enforce lengthy single-line metadata list in VideoMetaData on large screens
27+
white-space: nowrap;
28+
}
29+
30+
@include responsive.mobile-only() {
31+
font-size: 0.8em;
32+
33+
> div:last-of-type {
34+
white-space: initial;
35+
}
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React from 'react';
2+
3+
import styles from './MediaHeroInfo.module.scss';
4+
5+
const MediaHeroInfo = ({
6+
title,
7+
description,
8+
metadataComponent,
9+
formatComponent,
10+
definitionComponent,
11+
}: {
12+
title: string;
13+
description?: string | React.ReactElement | null;
14+
metadataComponent: React.ReactElement | null;
15+
formatComponent: React.ReactElement | null;
16+
definitionComponent: React.ReactElement | null;
17+
}) => {
18+
return (
19+
<>
20+
<h1 className={styles.title}>{title}</h1>
21+
<div className={styles.metaContainer}>
22+
{formatComponent}
23+
{metadataComponent}
24+
</div>
25+
{description && typeof description === 'string' ? <p className={styles.description}>{description}</p> : description}
26+
{definitionComponent}
27+
</>
28+
);
29+
};
30+
31+
export default MediaHeroInfo;

0 commit comments

Comments
 (0)