diff --git a/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.stories.tsx b/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.stories.tsx new file mode 100644 index 00000000000..0bf59c01b7a --- /dev/null +++ b/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { FootballMatchHeader as FootballMatchHeaderComponent } from './FootballMatchHeader'; + +const meta = { + component: FootballMatchHeaderComponent, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Fixture = { + args: { + leagueName: 'Premier League', + match: { + kind: 'Fixture', + kickOff: new Date('2025-11-05T20:30:00Z'), + venue: 'Old Trafford', + homeTeam: { + name: 'Wolverhampton Wanderers', + paID: '44', + }, + awayTeam: { + name: 'Belgium', + paID: '997', + }, + paId: 'matchId', + }, + tabs: { + selected: 'info', + }, + edition: 'UK', + }, +} satisfies Story; + +export const Result = { + args: { + leagueName: 'Premier League', + match: { + ...Fixture.args.match, + kind: 'Result', + homeTeam: { + ...Fixture.args.match.homeTeam, + score: 0, + scorers: [], + }, + awayTeam: { + ...Fixture.args.match.awayTeam, + score: 13, + scorers: [ + 'Carlos Casemiro 12 Pen', + 'Carlos Casemiro 4', + 'Mason Mount 82 O.g.', + ], + }, + comment: undefined, + }, + tabs: { + selected: 'info', + liveURL: new URL( + 'https://www.theguardian.com/football/live/2025/nov/26/arsenal-v-bayern-munich-champions-league-live', + ), + reportURL: new URL( + 'https://www.theguardian.com/football/2025/nov/26/arsenal-bayern-munich-champions-league-match-report', + ), + }, + edition: 'AU', + }, +} satisfies Story; diff --git a/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx b/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx new file mode 100644 index 00000000000..ebec95fc9c2 --- /dev/null +++ b/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx @@ -0,0 +1,355 @@ +import { css } from '@emotion/react'; +import { + from, + headlineBold20Object, + headlineBold24Object, + space, + textSans14Object, + textSans15Object, + textSansBold14Object, + textSansBold17Object, +} from '@guardian/source/foundations'; +import { type ComponentProps, type ReactNode, useMemo } from 'react'; +import type { FootballMatch } from '../../footballMatchV2'; +import { grid } from '../../grid'; +import { + type EditionId, + getLocaleFromEdition, + getTimeZoneFromEdition, +} from '../../lib/edition'; +import { palette } from '../../palette'; +import { BigNumber } from '../BigNumber'; +import { FootballCrest } from '../FootballCrest'; +import { Tabs } from './Tabs'; + +type Props = { + leagueName: string; + match: FootballMatch; + tabs: ComponentProps; + edition: EditionId; +}; + +export const FootballMatchHeader = (props: Props) => ( +
+
+ +
+ +
+ +
+
+); + +const StatusLine = (props: { + leagueName: string; + match: FootballMatch; + edition: EditionId; +}) => ( +

+ {props.leagueName} + {props.match.venue} •{' '} + +

+); + +const LeagueName = (props: { children: ReactNode }) => ( + <> + + {props.children} + + + {' '} + •{' '} + + +); + +const MatchStatus = (props: { match: FootballMatch; edition: EditionId }) => { + const kickOffFormatter = useMemo( + () => kickOffFormatterForEdition(props.edition), + [props.edition], + ); + + switch (props.match.kind) { + case 'Fixture': + return kickOffFormatter.format(props.match.kickOff); + case 'Live': + return props.match.status; + case 'Result': + return 'FT'; + } +}; + +const kickOffFormatterForEdition = (edition: EditionId): Intl.DateTimeFormat => + new Intl.DateTimeFormat(getLocaleFromEdition(edition), { + hour: 'numeric', + minute: 'numeric', + hour12: true, + day: 'numeric', + month: 'short', + timeZoneName: 'short', + timeZone: getTimeZoneFromEdition(edition), + }); + +const Hr = (props: { borderStyle: 'dotted' | 'solid' }) => ( +
+); + +const Teams = (props: { match: FootballMatch }) => ( +
+ + +
+); + +const Team = (props: { + team: 'homeTeam' | 'awayTeam'; + match: FootballMatch; +}) => ( +
+ + + + {props.match.kind !== 'Fixture' ? ( + + ) : null} + + {props.match.kind !== 'Fixture' ? ( + + ) : null} +
+); + +const TeamName = (props: { name: string }) => ( +

+ {props.name} +

+); + +const Crest = (props: { name: string; paID: string }) => ( + + + +); + +/** + * This uses `role="img"` because the score can be made up of multiple inline + * SVGs used in combination. For example, a 1 and a 2 are combined to make 12. + * We can't use the `img` tag because the SVGs are inline, and we can't use an + * SVG `title` tag because that would only work for a single number, not a + * combined one. + * + * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/img_role + */ +const Score = (props: { score: number }) => ( + :nth-of-type(2)': { + marginLeft: -10, + }, + }} + > + + +); + +const circleStyles = { + borderRadius: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 60, + height: 60, +}; + +const ScoreNumber = (props: { score: number }) => { + if (!Number.isInteger(props.score) || props.score < 0) { + return null; + } + + switch (props.score) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + case 8: + case 9: + return ; + default: + return ( + <> + + + + ); + } +}; + +const Scorers = (props: { scorers: string[] }) => + props.scorers.length === 0 ? null : ( +
    + {props.scorers.map((scorer) => ( +
  • {scorer}
  • + ))} +
+ );