feat(BOOK-62): Added new InfoCard component and using that on hotel pages

Approved-by: Bianca Widstam
This commit is contained in:
Erik Tiekstra
2025-11-04 07:39:33 +00:00
parent 10bf4d08d9
commit 4491d1de8e
27 changed files with 1119 additions and 663 deletions

View File

@@ -1,8 +1,9 @@
import { cx } from 'class-variance-authority'
import { MaterialIcon } from '../Icons/MaterialIcon'
import styles from './imageFallback.module.css'
interface ImageFallbackProps {
interface ImageFallbackProps extends React.HTMLAttributes<HTMLDivElement> {
width?: string
height?: string
}
@@ -10,9 +11,15 @@ interface ImageFallbackProps {
export default function ImageFallback({
width = '100%',
height = '100%',
className,
...props
}: ImageFallbackProps) {
return (
<div className={styles.imageFallback} style={{ width, height }}>
<div
{...props}
className={cx(styles.imageFallback, className)}
style={{ width, height }}
>
<MaterialIcon
icon="imagesmode"
size={32}

View File

@@ -0,0 +1,262 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Theme } from '@scandic-hotels/common/utils/theme'
import { InfoCard } from './InfoCard.tsx'
import { infoCardConfig } from './variants.ts'
const DEFAULT_ARGS = {
topTitle: "Here's to your health!",
heading: 'Gym & Wellness',
primaryButton: {
href: '#',
text: 'Primary button',
},
secondaryButton: {
href: '#',
text: 'Secondary button',
},
bodyText:
'Our gym is open 24/7 and offers state-of-the-art equipment to help you stay fit during your stay.',
}
const meta: Meta<typeof InfoCard> = {
title: 'Components/InfoCard',
component: InfoCard,
argTypes: {
topTitle: {
control: 'text',
table: {
type: { summary: 'string' },
},
},
topTitleAngled: {
control: 'boolean',
description:
'Whether the top title should be angled. Only applies when `hotelTheme` is set to `Theme.scandic`.',
type: 'boolean',
},
heading: {
control: 'text',
table: {
type: { summary: 'string' },
},
},
bodyText: {
control: 'text',
table: {
type: { summary: 'string' },
},
},
theme: {
control: 'select',
options: Object.keys(infoCardConfig.variants.theme),
table: {
type: {
summary: Object.keys(infoCardConfig.variants.theme).join(' | '),
},
},
},
height: {
control: 'select',
options: Object.keys(infoCardConfig.variants.height),
table: {
type: {
summary: Object.keys(infoCardConfig.variants.height).join(' | '),
},
},
},
hotelTheme: {
control: 'select',
options: Object.keys(infoCardConfig.variants.hotelTheme),
description:
'The hotel theme to adjust button colors for better contrast.',
table: {
type: { summary: 'Theme', detail: Object.values(Theme).join(' | ') },
},
},
backgroundImage: {
control: 'object',
table: {
type: {
summary: 'InfoCardBackgroundImage',
detail:
'{ src: string, alt?: string, focalPoint?: { x: number, y: number }, dimensions?: { width: number, height: number, aspectRatio?: number } }',
},
},
},
primaryButton: {
control: 'object',
table: {
type: {
summary: 'InfoCardButton',
detail:
'{ href: string, text: string, openInNewTab?: boolean, scrollOnClick?: boolean, onClick?: MouseEventHandler }',
},
},
},
secondaryButton: {
control: 'object',
table: {
type: {
summary: 'InfoCardButton',
detail:
'{ href: string, text: string, openInNewTab?: boolean, scrollOnClick?: boolean, onClick?: MouseEventHandler }',
},
},
},
},
args: { ...DEFAULT_ARGS },
decorators: [
(Story, context) => {
if (context.name.toLowerCase().indexOf('all themes') >= 0) {
return (
<div
className={context.args.hotelTheme!}
style={{ display: 'grid', gap: '1rem' }}
>
{Object.keys(infoCardConfig.variants.theme).map((theme) => {
console.log(theme)
const args = {
...context.args,
backgroundImage:
theme === 'Image'
? {
src: './img/img1.jpg',
alt: 'Image alt text',
}
: undefined,
}
return (
<div style={{ display: 'grid', gap: '0.5rem' }}>
<h3>{theme}</h3>
<InfoCard
{...args}
theme={theme as keyof typeof infoCardConfig.variants.theme}
/>
</div>
)
})}
</div>
)
}
return (
<div style={{ display: 'flex' }}>
<Story />
</div>
)
},
],
}
export default meta
type Story = StoryObj<typeof InfoCard>
export const Default: Story = {
args: {
...meta.args,
},
}
export const Primary_1: Story = {
args: {
...meta.args,
theme: 'Primary 1',
},
}
export const Primary_2: Story = {
args: {
...meta.args,
theme: 'Primary 2',
},
}
export const Primary_3: Story = {
args: {
...meta.args,
theme: 'Primary 3',
},
}
export const Accent: Story = {
args: {
...meta.args,
theme: 'Accent',
},
}
export const White: Story = {
args: {
...meta.args,
theme: 'White',
},
}
export const Image: Story = {
args: {
...meta.args,
backgroundImage: {
src: './img/img1.jpg',
alt: 'Image alt text',
},
theme: 'Image',
},
}
export const AllThemesScandic: Story = {
args: {
...meta.args,
hotelTheme: Theme.scandic,
},
}
export const AllThemesDowntownCamper: Story = {
args: {
...meta.args,
hotelTheme: Theme.downtownCamper,
},
}
export const AllThemesHaymarket: Story = {
args: {
...meta.args,
hotelTheme: Theme.haymarket,
},
}
export const AllThemesScandicGo: Story = {
args: {
...meta.args,
hotelTheme: Theme.scandicGo,
},
}
export const AllThemesGrandHotel: Story = {
args: {
...meta.args,
hotelTheme: Theme.grandHotel,
},
}
export const AllThemesHotelNorge: Story = {
args: {
...meta.args,
hotelTheme: Theme.hotelNorge,
},
}
export const AllThemesMarski: Story = {
args: {
...meta.args,
hotelTheme: Theme.marski,
},
}
export const AllThemesTheDock: Story = {
args: {
...meta.args,
hotelTheme: Theme.theDock,
},
}

View File

@@ -0,0 +1,100 @@
import ButtonLink from '../ButtonLink'
import Image from '../Image'
import { Typography } from '../Typography'
import { getButtonProps } from './utils'
import { infoCardVariants } from './variants'
import styles from './infoCard.module.css'
import ImageFallback from '../ImageFallback'
import type { InfoCardProps } from './types'
export function InfoCard({
primaryButton,
secondaryButton,
topTitle,
heading,
bodyText,
className,
theme,
height,
backgroundImage,
topTitleAngled,
hotelTheme,
}: InfoCardProps) {
const classNames = infoCardVariants({
theme,
hotelTheme,
topTitleAngled,
height,
className,
})
const buttonProps = getButtonProps(theme, hotelTheme)
return (
<div className={classNames}>
{theme === 'Image' ? (
<div className={styles.backgroundImageWrapper}>
{backgroundImage ? (
<Image
src={backgroundImage.src}
className={styles.backgroundImage}
alt={backgroundImage.alt ?? ''}
fill
sizes="(min-width: 1367px) 700px, 900px"
focalPoint={backgroundImage.focalPoint}
dimensions={backgroundImage.dimensions}
/>
) : (
<ImageFallback className={styles.backgroundImage} />
)}
</div>
) : null}
<div className={styles.content}>
<div className={styles.titleWrapper}>
{topTitle ? (
<Typography variant="Title/Decorative/md">
<span className={styles.topTitle}>{topTitle}</span>
</Typography>
) : null}
<Typography variant="Title/smLowCase">
<h3 className={styles.heading}>{heading}</h3>
</Typography>
</div>
{bodyText ? (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.bodyText}>{bodyText}</p>
</Typography>
) : null}
<div className={styles.buttonContainer}>
{primaryButton ? (
<ButtonLink
size="Small"
href={primaryButton.href}
typography="Body/Supporting text (caption)/smBold"
onClick={primaryButton.onClick}
scroll={primaryButton.scrollOnClick ?? false}
{...buttonProps.primaryButton}
>
{primaryButton.text}
</ButtonLink>
) : null}
{secondaryButton ? (
<ButtonLink
size="Small"
href={secondaryButton.href}
typography="Body/Supporting text (caption)/smBold"
onClick={secondaryButton.onClick}
scroll={secondaryButton.scrollOnClick ?? false}
{...buttonProps.secondaryButton}
>
{secondaryButton.text}
</ButtonLink>
) : null}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { InfoCard } from './InfoCard'
export type { InfoCardProps } from './types'

View File

@@ -0,0 +1,130 @@
.infoCard {
--background-color: var(--Surface-Brand-Primary-1-Default);
--topTitle-color: var(--Text-Brand-OnPrimary-1-Accent);
--heading-color: var(--Text-Brand-OnPrimary-1-Heading);
--text-color: var(--Text-Brand-OnPrimary-1-Default);
position: relative;
display: grid;
justify-content: center;
align-items: center;
border-radius: var(--Corner-radius-md);
text-align: center;
width: 100%;
text-wrap: balance;
overflow: hidden;
background-color: var(--background-color);
z-index: 0;
}
.height-fixed {
height: 320px;
}
.height-dynamic {
height: 100%;
}
.theme-primary-1 {
--background-color: var(--Surface-Brand-Primary-1-Default);
--topTitle-color: var(--Text-Brand-OnPrimary-1-Accent);
--heading-color: var(--Text-Brand-OnPrimary-1-Heading);
--text-color: var(--Text-Brand-OnPrimary-1-Default);
}
.theme-primary-2 {
--background-color: var(--Surface-Brand-Primary-2-Default);
--topTitle-color: var(--Text-Brand-OnPrimary-2-Accent);
--heading-color: var(--Text-Brand-OnPrimary-2-Heading);
--text-color: var(--Text-Brand-OnPrimary-2-Default);
}
.theme-primary-3 {
--background-color: var(--Surface-Brand-Primary-3-Default);
--topTitle-color: var(--Text-Brand-OnPrimary-3-Accent);
--heading-color: var(--Text-Brand-OnPrimary-3-Heading);
--text-color: var(--Text-Brand-OnPrimary-3-Default);
}
.theme-accent {
--background-color: var(--Surface-Brand-Accent-Default);
--topTitle-color: var(--Text-Brand-OnAccent-Accent);
--heading-color: var(--Text-Brand-OnAccent-Heading);
--text-color: var(--Text-Brand-OnAccent-Default);
}
.theme-white {
--background-color: var(--Surface-Primary-Default);
--topTitle-color: var(--Text-Accent-Primary);
--heading-color: var(--Text-Heading);
--text-color: var(--Text-Default);
}
.theme-image {
--background-color: transparent;
--topTitle-color: var(--Text-Inverted);
--heading-color: var(--Text-Inverted);
--text-color: var(--Text-Inverted);
}
.titleWrapper {
display: grid;
gap: var(--Space-x1);
justify-items: center;
}
.topTitle {
color: var(--topTitle-color);
}
.top-title-angled .topTitle {
transform: rotate(-3deg);
transform-origin: left;
}
.heading {
color: var(--heading-color);
}
.bodyText {
color: var(--text-color);
}
.backgroundImageWrapper {
position: absolute;
inset: 0;
overflow: hidden;
z-index: -1;
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 50%,
rgba(0, 0, 0, 0.75) 100%
);
}
}
.backgroundImage {
object-fit: cover;
width: 100%;
height: min(100%, 320px);
}
.content {
display: grid;
max-width: 800px;
padding: var(--Space-x4) var(--Space-x3);
gap: var(--Space-x2);
}
.buttonContainer {
display: flex;
flex-wrap: wrap;
gap: var(--Space-x1);
align-items: center;
justify-content: center;
.primaryButton,
.secondaryButton {
flex-shrink: 0;
}
}

View File

@@ -0,0 +1,30 @@
import type { VariantProps } from 'class-variance-authority'
import { MouseEventHandler } from 'react'
import type { infoCardVariants } from './variants'
export type InfoCardBackgroundImage = {
src: string
alt?: string
focalPoint?: { x: number; y: number }
dimensions?: { width: number; height: number; aspectRatio?: number }
}
export type InfoCardButton = {
href: string
text: string
openInNewTab?: boolean
scrollOnClick?: boolean
onClick?: MouseEventHandler<HTMLAnchorElement>
}
export interface InfoCardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof infoCardVariants> {
topTitle?: string | null
heading?: string | null
bodyText?: string | null
backgroundImage?: InfoCardBackgroundImage
primaryButton?: InfoCardButton | null
secondaryButton?: InfoCardButton | null
}

View File

@@ -0,0 +1,166 @@
import type { VariantProps } from 'class-variance-authority'
import { Theme } from '@scandic-hotels/common/utils/theme'
import type { variants as buttonVariants } from '../Button/variants'
import type { infoCardVariants } from './variants'
type ButtonVariants = VariantProps<typeof buttonVariants>
type InfoCardButtonProps = {
primaryButton: {
variant: ButtonVariants['variant']
color: ButtonVariants['color']
}
secondaryButton: {
variant: ButtonVariants['variant']
color: ButtonVariants['color']
}
}
const PRIMARY = { variant: 'Primary', color: 'Primary' } as const
const PRIMARY_INVERTED = { variant: 'Primary', color: 'Inverted' } as const
const SECONDARY = { variant: 'Secondary', color: 'Primary' } as const
const SECONDARY_INVERTED = { variant: 'Secondary', color: 'Inverted' } as const
const TERTIARY = { variant: 'Tertiary', color: 'Primary' } as const
// Determine button variant and color based on card theme and hotel theme.
// This is done to avoid low contrast issues and conflicting colors in
// certain combinations and according to design guidelines.
export function getButtonProps(
cardTheme: VariantProps<typeof infoCardVariants>['theme'],
hotelTheme: Theme | null = Theme.scandic
): InfoCardButtonProps {
let buttonProps: InfoCardButtonProps = {
primaryButton: TERTIARY,
secondaryButton: SECONDARY,
}
// Image theme always uses inverted buttons, regardless of hotel theme
if (cardTheme === 'Image') {
return {
primaryButton: PRIMARY_INVERTED,
secondaryButton: SECONDARY_INVERTED,
}
}
switch (hotelTheme) {
case Theme.scandic:
if (cardTheme === 'Primary 2' || cardTheme === 'Primary 3') {
buttonProps = {
primaryButton: PRIMARY_INVERTED,
secondaryButton: SECONDARY_INVERTED,
}
}
break
case Theme.downtownCamper:
if (
cardTheme === 'Primary 1' ||
cardTheme === 'Primary 2' ||
cardTheme === 'Primary 3' ||
cardTheme === 'Accent'
) {
buttonProps = {
primaryButton: PRIMARY_INVERTED,
secondaryButton: SECONDARY_INVERTED,
}
}
break
case Theme.haymarket:
if (cardTheme === 'Primary 1' || cardTheme === 'White') {
buttonProps = {
primaryButton: PRIMARY,
secondaryButton: SECONDARY,
}
} else if (
cardTheme === 'Primary 2' ||
cardTheme === 'Primary 3' ||
cardTheme === 'Accent'
) {
buttonProps = {
primaryButton: PRIMARY_INVERTED,
secondaryButton: SECONDARY_INVERTED,
}
}
break
case Theme.scandicGo:
if (cardTheme === 'Primary 1' || cardTheme === 'Primary 2') {
buttonProps = {
primaryButton: PRIMARY_INVERTED,
secondaryButton: SECONDARY_INVERTED,
}
}
break
case Theme.grandHotel:
if (
cardTheme === 'Primary 2' ||
cardTheme === 'Primary 3' ||
cardTheme === 'Accent' ||
cardTheme === 'White'
) {
buttonProps = {
primaryButton: PRIMARY,
secondaryButton: SECONDARY,
}
} else if (cardTheme === 'Primary 1') {
buttonProps = {
primaryButton: PRIMARY_INVERTED,
secondaryButton: SECONDARY_INVERTED,
}
}
break
case Theme.hotelNorge:
if (
cardTheme === 'Primary 1' ||
cardTheme === 'Primary 2' ||
cardTheme === 'Primary 3'
) {
buttonProps = {
primaryButton: PRIMARY_INVERTED,
secondaryButton: SECONDARY_INVERTED,
}
}
break
case Theme.marski:
if (cardTheme === 'Primary 1') {
buttonProps = {
primaryButton: TERTIARY,
secondaryButton: SECONDARY_INVERTED,
}
} else if (cardTheme === 'White') {
buttonProps = {
primaryButton: PRIMARY,
secondaryButton: SECONDARY,
}
} else if (
cardTheme === 'Primary 2' ||
cardTheme === 'Primary 3' ||
cardTheme === 'Accent'
) {
buttonProps = {
primaryButton: PRIMARY_INVERTED,
secondaryButton: SECONDARY_INVERTED,
}
}
break
case Theme.theDock:
if (
cardTheme === 'Primary 1' ||
cardTheme === 'Accent' ||
cardTheme === 'White'
) {
buttonProps = {
primaryButton: PRIMARY,
secondaryButton: SECONDARY,
}
} else if (cardTheme === 'Primary 2' || cardTheme === 'Primary 3') {
buttonProps = {
primaryButton: PRIMARY_INVERTED,
secondaryButton: SECONDARY_INVERTED,
}
}
break
default:
break
}
return buttonProps
}

View File

@@ -0,0 +1,70 @@
import { cva } from 'class-variance-authority'
import { DEFAULT_THEME, Theme } from '@scandic-hotels/common/utils/theme'
import styles from './infoCard.module.css'
const variantKeys = {
theme: {
'Primary 1': 'Primary 1',
'Primary 2': 'Primary 2',
'Primary 3': 'Primary 3',
Accent: 'Accent',
Image: 'Image',
White: 'White',
},
height: {
fixed: 'fixed',
dynamic: 'dynamic',
},
} as const
export const infoCardConfig = {
variants: {
theme: {
[variantKeys.theme['Primary 1']]: styles['theme-primary-1'],
[variantKeys.theme['Primary 2']]: styles['theme-primary-2'],
[variantKeys.theme['Primary 3']]: styles['theme-primary-3'],
[variantKeys.theme['Accent']]: styles['theme-accent'],
[variantKeys.theme['Image']]: styles['theme-image'],
[variantKeys.theme['White']]: styles['theme-white'],
},
height: {
[variantKeys.height.fixed]: styles['height-fixed'],
[variantKeys.height.dynamic]: styles['height-dynamic'],
},
// Only Theme.scandic can be used with the Angled variant.
// The topTitleAngled variant will be applied using the compoundVariants.
topTitleAngled: {
true: undefined,
false: undefined,
},
// The hotelTheme is not used to apply styles directly,
// but is needed for compound variants and to get the correct button color.
// The class name for the hotelTheme is applied on page level.
hotelTheme: {
[Theme.scandic]: undefined,
[Theme.downtownCamper]: undefined,
[Theme.haymarket]: undefined,
[Theme.scandicGo]: undefined,
[Theme.grandHotel]: undefined,
[Theme.hotelNorge]: undefined,
[Theme.marski]: undefined,
[Theme.theDock]: undefined,
},
},
compoundVariants: [
{
hotelTheme: Theme.scandic,
topTitleAngled: true,
class: styles['top-title-angled'],
},
],
defaultVariants: {
theme: variantKeys.theme['Primary 1'],
height: variantKeys.height.fixed,
topTitleAngled: false,
hotelTheme: DEFAULT_THEME,
},
}
export const infoCardVariants = cva(styles.infoCard, infoCardConfig)

View File

@@ -134,6 +134,7 @@
"./ImageContainer": "./lib/components/ImageContainer/index.tsx",
"./ImageFallback": "./lib/components/ImageFallback/index.tsx",
"./ImageGallery": "./lib/components/ImageGallery/index.tsx",
"./InfoCard": "./lib/components/InfoCard/index.tsx",
"./Input": "./lib/components/Input/index.tsx",
"./JsonToHtml": "./lib/components/JsonToHtml/JsonToHtml.tsx",
"./Label": "./lib/components/Label/index.tsx",