Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat / Movie screen by composition #674

Merged
merged 5 commits into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/common/src/utils/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,19 @@ export const is12HourClock = () => {
const date = new Date(Date.UTC(2022, 6, 20, 18, 0, 0));
return /am|pm/.test(date.toLocaleTimeString());
};

export const formatItemDate = (date?: string | null, locale?: string) => {
if (!date) return null;

if (date.length <= 4) return date;

try {
const [day, month, year] = date.split('-');
const dateObj = new Date(`${year}-${month}-${day}`);
if (dateObj.toString() === 'Invalid Date') return date; // fallback to original string

return dateObj.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' });
} catch (error: unknown) {
return date; // fallback to original string
}
};
4 changes: 4 additions & 0 deletions packages/common/src/utils/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,7 @@ export const createLiveEventMetadata = (media: PlaylistItem, locale: string, i18

return metaData;
};

export const countListValues = (value: string) => (!value ? 0 : value.split(',').length);

export const hasFormatMetadata = (media: PlaylistItem) => ['subtitleFormat', 'videoFormat', 'audioFormat'].some((property) => media[property]);
65 changes: 65 additions & 0 deletions packages/hooks-react/src/useMovieData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { PlaylistItem } from '@jwp/ott-common/types/playlist';
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { createVideoMetadata } from '@jwp/ott-common/src/utils/metadata';
import { shallow } from '@jwp/ott-common/src/utils/compare';
import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore';
import { mediaURL } from '@jwp/ott-common/src/utils/urlFormatting';
import useMedia from '@jwp/ott-hooks-react/src/useMedia';
import usePlaylist from '@jwp/ott-hooks-react/src/usePlaylist';
import useEntitlement from '@jwp/ott-hooks-react/src/useEntitlement';

export default function useMovieData(data: PlaylistItem, id: string, feedId: string | null) {
const { t } = useTranslation('common');
const [playTrailer, setPlayTrailer] = useState<boolean>(false);

// Config
const { config, accessModel } = useConfigStore(({ config, accessModel }) => ({ config, accessModel }), shallow);
const { features } = config;
const isFavoritesEnabled: boolean = Boolean(features?.favoritesList);

// Media
const { isLoading: isTrailerLoading, data: trailerItem } = useMedia(data?.trailerId || '');
const { isLoading: isPlaylistLoading, data: playlist } = usePlaylist(features?.recommendationsPlaylist || '', { related_media_id: id });
const nextItem = useMemo(() => {
if (!id || !playlist) return;

const index = playlist.playlist.findIndex(({ mediaid }) => mediaid === id);
const nextItem = playlist.playlist[index + 1];

return nextItem;
}, [id, playlist]);

// User, entitlement
const { isEntitled, mediaOffers } = useEntitlement(data);

// UI
const movieURL = mediaURL({ id: data.mediaid, title: data.title, playlistId: feedId, play: false });
const playUrl = mediaURL({ id: data.mediaid, title: data.title, playlistId: feedId, play: true });
const hasTrailer = !!trailerItem || isTrailerLoading;
const primaryMetadata = [
...createVideoMetadata(data, {
hoursAbbreviation: t('common:abbreviation.hours'),
minutesAbbreviation: t('common:abbreviation.minutes'),
}),
...(data?.contentType ? [data.contentType] : []),
];

return {
playlist,
isTrailerLoading,
isPlaylistLoading,
mediaOffers,
movieURL,
playUrl,
nextItem,
trailerItem,
accessModel,
hasTrailer,
isFavoritesEnabled,
isEntitled,
playTrailer,
primaryMetadata,
setPlayTrailer,
};
}
5 changes: 3 additions & 2 deletions packages/ui-react/src/components/Hero/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import styles from './Hero.module.scss';

type Props = PropsWithChildren<{
image?: string;
className?: string;
infoClassName?: string;
}>;

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

return (
<div className={styles.hero}>
<div className={classNames([styles.hero, className])}>
<Image ref={posterRef} className={styles.poster} image={image} width={1280} alt={alt} />
<div className={styles.posterFadeMenu} />
<div className={styles.posterFadeLeft} />
Expand Down
43 changes: 43 additions & 0 deletions packages/ui-react/src/components/MediaHead/MediaHead.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { shallow } from '@jwp/ott-common/src/utils/compare';
import type { PlaylistItem } from '@jwp/ott-common/types/playlist';
import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore';
import { generateMovieJSONLD } from '@jwp/ott-common/src/utils/structuredData';
import env from '@jwp/ott-common/src/env';

const MediaHead = ({ data, canonicalUrl }: { data: PlaylistItem; canonicalUrl: string }) => {
const { config } = useConfigStore(({ config, accessModel }) => ({ config, accessModel }), shallow);
const { siteName } = config;

const pageTitle = `${data.title} - ${siteName}`;

return (
<Helmet>
<title>{pageTitle}</title>
<link rel="canonical" href={canonicalUrl} />
<meta name="description" content={data.description} />
<meta property="og:description" content={data.description} />
<meta property="og:title" content={pageTitle} />
<meta property="og:type" content="video.other" />
{data.image && <meta property="og:image" content={data.image?.replace(/^https:/, 'http:')} />}
{data.image && <meta property="og:image:secure_url" content={data.image?.replace(/^http:/, 'https:')} />}
<meta property="og:image:width" content={data.image ? '720' : ''} />
<meta property="og:image:height" content={data.image ? '406' : ''} />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={data.description} />
<meta name="twitter:image" content={data.image} />
<meta property="og:video" content={canonicalUrl.replace(/^https:/, 'http:')} />
<meta property="og:video:secure_url" content={canonicalUrl.replace(/^http:/, 'https:')} />
<meta property="og:video:type" content="text/html" />
<meta property="og:video:width" content="1280" />
<meta property="og:video:height" content="720" />
{data.tags?.split(',').map((tag) => (
<meta property="og:video:tag" content={tag} key={tag} />
))}
{data ? <script type="application/ld+json">{generateMovieJSONLD(data, env.APP_PUBLIC_URL)}</script> : null}
</Helmet>
);
};

export default MediaHead;
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.infoSmallScreenReorder {
// Re-ordering the elements on small screens
display: flex;
flex-direction: column;

[class^='_title'] {
order: -1;
margin-bottom: 24px;
}

[class^='_buttonBar'] {
order: -1;
}

[class^='_metaContainer'] {
order: 99;
}
}
19 changes: 19 additions & 0 deletions packages/ui-react/src/components/MediaHero/MediaHero.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type PropsWithChildren } from 'react';
import Hero from '@jwp/ott-ui-react/src/components/Hero/Hero';
import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint';

import styles from './MediaHero.module.scss';

const MediaHero = ({ image, children }: PropsWithChildren<{ image?: string }>) => {
const breakpoint = useBreakpoint();

return (
<header id="video-details">
<Hero image={image} infoClassName={breakpoint < Breakpoint.md ? styles.infoSmallScreenReorder : undefined}>
{children}
</Hero>
</header>
);
};

export default MediaHero;
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
@use '@jwp/ott-ui-react/src/styles/variables';
@use '@jwp/ott-ui-react/src/styles/mixins/responsive';

.buttonBar {
display: flex;
align-items: center;
margin-top: 24px;
gap: calc(variables.$base-spacing / 2);

> button {
flex-shrink: 0;
}

@include responsive.mobile-and-small-tablet() {
flex-wrap: wrap;
> button {
flex: 1;
justify-content: center;

&:first-child {
flex-basis: 100%;
margin-bottom: 8px;
}
}
}
}

.roundButton {
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;

> [class^='_startIcon'] {
margin-right: 0;
}

> span {
display: none;
}
}

.rectangleButton {
flex-direction: column;
height: auto;
padding: 16px;
background-color: transparent !important;
border: none !important;

> [class^='_startIcon'] {
margin-right: 0;
margin-bottom: 8px;
}

> span {
font-weight: var(--body-font-weight-bold);
}

&[class*='primary'] {
color: inherit; // Toggle state for Favorite button
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';
import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint';

import styles from './MediaHeroButtons.module.scss';

const MediaHeroButtons = ({
cta,
children,
buttonClassOverride = true,
}: {
cta: React.ReactNode;
children: React.ReactNode;
buttonClassOverride?: boolean;
}) => {
const breakpoint = useBreakpoint();
const buttonClassName = buttonClassOverride ? (breakpoint < Breakpoint.md ? styles.rectangleButton : styles.roundButton) : undefined;

const validChildren = React.Children.toArray(children).filter(React.isValidElement);

return (
<div className={styles.buttonBar}>
{cta}
{validChildren.map((child) => React.cloneElement(child as React.ReactElement, { className: buttonClassName }))}
</div>
);
};

export default MediaHeroButtons;
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
@use '@jwp/ott-ui-react/src/styles/variables';
@use '@jwp/ott-ui-react/src/styles/mixins/typography';
@use '@jwp/ott-ui-react/src/styles/mixins/responsive';

.title {
@include typography.video-title-base() {
margin-bottom: calc(variables.$base-spacing / 2);
}
}

.description {
line-height: variables.$base-line-height;
}

.metaContainer {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 8px;
gap: 16px;
font-size: 1em;
line-height: variables.$base-line-height;
letter-spacing: 0.15px;

> div:last-of-type {
// Enforce lengthy single-line metadata list in VideoMetaData on large screens
white-space: nowrap;
}

@include responsive.mobile-only() {
font-size: 0.8em;

> div:last-of-type {
white-space: initial;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';

import styles from './MediaHeroInfo.module.scss';

const MediaHeroInfo = ({
title,
description,
metadataComponent,
formatComponent,
definitionComponent,
}: {
title: string;
description?: string | React.ReactElement | null;
metadataComponent: React.ReactElement | null;
formatComponent: React.ReactElement | null;
definitionComponent: React.ReactElement | null;
}) => {
return (
<>
<h1 className={styles.title}>{title}</h1>
<div className={styles.metaContainer}>
{formatComponent}
{metadataComponent}
</div>
{description && typeof description === 'string' ? <p className={styles.description}>{description}</p> : description}
{definitionComponent}
</>
);
};

export default MediaHeroInfo;
Loading
Loading