feat(BOOK-757): Moved TeaserCard to design system and added stories

Approved-by: Bianca Widstam
This commit is contained in:
Erik Tiekstra
2026-01-21 07:50:43 +00:00
parent 8a143a2916
commit b91504e7c6
18 changed files with 465 additions and 107 deletions

View File

@@ -9,13 +9,3 @@ export const AlertVisibleOnEnum = {
WEB: "WEB",
APP: "APP",
} as const
export type SidepeekContent = {
heading: string
content: {
json?: any
embedded_itemsConnection: {
edges: any
}
}
}

View File

@@ -30,6 +30,7 @@
"./constants/rateType": "./constants/rateType.ts",
"./constants/routes/*": "./constants/routes/*.ts",
"./constants/sessionKeys": "./constants/sessionKeys.ts",
"./constants/sidepeekContent": "./constants/sidepeekContent.ts",
"./constants/signatureHotels": "./constants/signatureHotels.ts",
"./constants/transactionType": "./constants/transactionType.ts",
"./dataCache": "./dataCache/index.ts",

View File

@@ -42,8 +42,8 @@ export default function AlertSidepeek({
})}
>
<JsonToHtml
nodes={content.json.children}
embeds={content.embedded_itemsConnection.edges}
nodes={content?.json.children}
embeds={content?.embedded_itemsConnection.edges}
/>
</SidePeek>
</div>

View File

@@ -1,6 +1,6 @@
import type { SidepeekContent } from "@scandic-hotels/common/constants/alert"
import { AlertSidepeekContent } from "../../../types/sidepeekContent"
export interface AlertSidepeekProps {
ctaText: string
sidePeekContent: NonNullable<SidepeekContent>
sidePeekContent: NonNullable<AlertSidepeekContent>
}

View File

@@ -1,10 +1,8 @@
import type {
AlertTypeEnum,
SidepeekContent,
} from "@scandic-hotels/common/constants/alert"
import type { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import type { VariantProps } from "class-variance-authority"
import type { AriaRole, ReactNode } from "react"
import { AlertSidepeekContent } from "../../types/sidepeekContent"
import type { alertVariants } from "./variants"
export interface AlertProps extends VariantProps<typeof alertVariants> {
@@ -17,7 +15,7 @@ export interface AlertProps extends VariantProps<typeof alertVariants> {
phoneNumber?: string
footnote?: string | null
} | null
sidepeekContent?: SidepeekContent | null
sidepeekContent?: AlertSidepeekContent | null
sidepeekCtaText?: string | null
link?: {
url: string

View File

@@ -0,0 +1,83 @@
"use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import { TeaserCardSidepeekContent } from "../../../types/sidepeekContent"
import { Button } from "../../Button"
import ButtonLink from "../../ButtonLink"
import { MaterialIcon } from "../../Icons/MaterialIcon"
import { JsonToHtml } from "../../JsonToHtml/JsonToHtml"
import SidePeek from "../../SidePeek"
import styles from "./sidepeek.module.css"
interface TeaserCardSidepeekProps {
button: {
call_to_action_text: string
}
sidePeekContent: TeaserCardSidepeekContent
}
export default function TeaserCardSidepeek({
button,
sidePeekContent,
}: TeaserCardSidepeekProps) {
const intl = useIntl()
const [sidePeekIsOpen, setSidePeekIsOpen] = useState(false)
const { heading, content, primary_button, secondary_button } = sidePeekContent
return (
<div className={styles.teaserCardSidepeek}>
<Button
onPress={() => setSidePeekIsOpen(true)}
variant="Secondary"
color="Primary"
size="sm"
>
{button.call_to_action_text}
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
</Button>
<SidePeek
title={heading}
isOpen={sidePeekIsOpen}
handleClose={() => setSidePeekIsOpen(false)}
openInRoot
closeLabel={intl.formatMessage({
id: "common.close",
defaultMessage: "Close",
})}
>
{content ? (
<JsonToHtml
nodes={content.json.children}
embeds={content.embedded_itemsConnection.edges}
/>
) : null}
<div className={styles.ctaContainer}>
{primary_button && (
<ButtonLink
variant="Primary"
color="Primary"
size="sm"
href={primary_button.href}
target={primary_button.openInNewTab ? "_blank" : undefined}
>
{primary_button.title}
</ButtonLink>
)}
{secondary_button && (
<ButtonLink
variant="Secondary"
color="Primary"
size="sm"
href={secondary_button.href}
target={secondary_button.openInNewTab ? "_blank" : undefined}
>
{secondary_button.title}
</ButtonLink>
)}
</div>
</SidePeek>
</div>
)
}

View File

@@ -0,0 +1,8 @@
.teaserCardSidepeek {
display: grid;
}
.ctaContainer {
display: grid;
gap: var(--Space-x2);
}

View File

@@ -0,0 +1,343 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite"
import { TeaserCard } from "./TeaserCard.tsx"
import { config } from "./variants.ts"
const PRIMARY_BUTTON = {
title: "Primary action",
href: "#",
openInNewTab: false,
}
const SECONDARY_BUTTON = {
...PRIMARY_BUTTON,
title: "Secondary action",
}
const SIDEPEEK_CONTENT = {
heading: "Sidepeek heading",
content: {
json: {
type: "doc",
attrs: {},
uid: "8126b570ffef4090a78f8c863d73c3b8",
children: [
{
type: "p",
attrs: {},
uid: "ed82964e32764cf589e07a251014543b",
children: [
{
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur vitae neque non ipsum efficitur hendrerit at ut nulla. Cras in tellus et ligula posuere ullamcorper. Praesent pulvinar rutrum metus ut gravida.",
},
],
},
{
type: "h3",
attrs: {},
uid: "799903bc123d479d9bcf29cb4ba24b65",
children: [
{
text: "Lorem ipsum",
},
],
},
{
type: "p",
attrs: {},
uid: "00886e5b0a5d4268930a1472b58e9170",
children: [
{
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur vitae neque non ipsum efficitur hendrerit at ut nulla. Cras in tellus et ligula posuere ullamcorper. Praesent pulvinar rutrum metus ut gravida.",
},
],
},
{
type: "p",
attrs: {},
uid: "6bd20356e8bd4612a99e1af6061f861c",
children: [
{
text: "",
},
{
uid: "28e963603b714055b948a038539bfd98",
type: "a",
attrs: {
url: "https://www.scandichotels.com/en",
target: "_blank",
},
children: [
{
text: "Learn more about this",
},
],
},
{
text: "",
},
],
},
{
type: "h3",
attrs: {},
uid: "9db195292c5b49ac970edc8b4be19081",
children: [
{
text: "Dolor sit amet",
},
],
},
{
type: "p",
attrs: {},
uid: "f45861b9146040ff8a6076cc2bc4d218",
children: [
{
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur vitae neque non ipsum efficitur hendrerit at ut nulla. Cras in tellus et ligula posuere ullamcorper. Praesent pulvinar rutrum metus ut gravida.",
},
],
},
{
uid: "a40eb77a4c26401689255e0caa2f564a",
type: "ul",
children: [
{
type: "li",
attrs: {},
uid: "701d76e63827483cb80180056d1b396e",
children: [
{
text: "Lorem ipsum",
id: "",
},
],
},
{
type: "li",
attrs: {},
uid: "f3f28b61c4b54162a7c723995a38a063",
children: [
{
text: "Dolor sit amet consectetur adipiscing elit.",
id: "",
},
],
},
{
type: "li",
attrs: {},
uid: "27f51d6ad42e4dafb5037b5ee790f0b2",
children: [
{
text: "Curabitur vitae neque non ipsum efficitur",
id: "",
},
],
},
],
attrs: {},
},
],
_version: 16,
},
embedded_itemsConnection: {
edges: [],
},
},
primary_button: {
title: "Sidepeek primary action",
href: "#",
openInNewTab: false,
},
}
const DEFAULT_ARGS = {
heading: "Lorem ipsum",
bodyText:
"Dolor sit amet, consectetur adipiscing elit. Curabitur vitae neque non ipsum efficitur hendrerit at ut nulla.",
image: {
id: 1,
url: "./img/img2.jpg",
title: "Placeholder image",
meta: {
alt: "Placeholder image",
caption: "This is a placeholder image",
},
focalPoint: { x: 50, y: 50 },
dimensions: { width: 1920, height: 1189, aspectRatio: 1.61 },
},
primaryButton: PRIMARY_BUTTON,
secondaryButton: SECONDARY_BUTTON,
alwaysStack: false,
}
const meta: Meta<typeof TeaserCard> = {
title: "Core Components/Cards/TeaserCard",
parameters: {
docs: {
description: {
component:
"The card itself does not have a maximum width, but it will adapt to the width of its container. The card is mostly used together with other content cards. It is recommended to use the ContentCard inside a grid or a container with a set maximum width for best results.",
},
},
},
component: TeaserCard,
argTypes: {
heading: {
control: "text",
table: {
type: { summary: "string" },
},
},
bodyText: {
control: "text",
table: {
type: { summary: "string" },
},
},
primaryButton: {
control: "object",
table: {
type: {
summary:
"{ title: string, href: string, openInNewTab?: boolean, onPress?: () => void } | undefined",
},
},
},
secondaryButton: {
control: "object",
table: {
type: {
summary:
"{ title: string, href: string, openInNewTab?: boolean, onPress?: () => void } | undefined",
},
},
},
sidePeekButton: {
control: "object",
table: {
type: {
summary: "{ call_to_action_text: string } | undefined",
},
},
},
sidePeekContent: {
control: "object",
table: {
type: {
summary: "any | undefined",
},
},
},
image: {
control: "object",
table: {
type: {
summary: "ImageVaultAsset | undefined",
detail:
"{ id: number, url: string, meta: {alt?: string | null, caption?: string | null}, focalPoint: { x: number, y: number }, dimensions: { width: number, height: number, aspectRatio: number } }",
},
},
},
style: {
control: "select",
options: Object.keys(config.variants.style),
table: {
type: { summary: Object.keys(config.variants.style).join(" | ") },
},
defaultValue: config.defaultVariants.style,
},
alwaysStack: {
control: "boolean",
table: {
type: { summary: "boolean" },
},
defaultValue: config.defaultVariants.alwaysStack.toString(),
description:
"If true, the buttons will always be stacked vertically, regardless if these would fit next to each other.",
},
},
args: { ...DEFAULT_ARGS },
globals: {
backgrounds: { default: "storybookLight" },
},
decorators: [
(Story, context) => {
const showMultipleStyles = [
"with sidepeek",
"without image",
"always stack buttons",
].some((substring) => context.name.toLowerCase().includes(substring))
if (showMultipleStyles) {
return (
<div
style={{
display: "grid",
gap: "1em",
gridTemplateColumns: `repeat(${Object.keys(config.variants.style).length}, 400px)`,
}}
>
{Object.keys(config.variants.style).map((style, ix) => {
return (
<TeaserCard
key={ix}
{...context.args}
style={style as keyof typeof config.variants.style}
/>
)
})}
</div>
)
}
return (
<div style={{ maxWidth: "400px" }}>
<Story />
</div>
)
},
],
}
export default meta
type Story = StoryObj<typeof TeaserCard>
export const Default: Story = {
args: {
...meta.args,
},
}
export const Featured: Story = {
args: {
...meta.args,
style: "featured",
},
}
export const WithSidepeek: Story = {
args: {
...meta.args,
sidePeekButton: {
call_to_action_text: "Side peek action",
},
sidePeekContent: SIDEPEEK_CONTENT,
style: "featured",
},
}
export const WithoutImage: Story = {
args: {
...meta.args,
image: undefined,
},
}
export const AlwaysStackButtons: Story = {
args: {
...meta.args,
alwaysStack: true,
},
}

View File

@@ -0,0 +1,103 @@
import { ImageVaultAsset } from "@scandic-hotels/common/utils/imageVault"
import { VariantProps } from "class-variance-authority"
import { TeaserCardSidepeekContent } from "../../types/sidepeekContent"
import { type ButtonProps } from "../Button"
import ButtonLink from "../ButtonLink"
import Image from "../Image"
import { Typography } from "../Typography"
import TeaserCardSidepeek from "./Sidepeek"
import styles from "./teaserCard.module.css"
import { teaserCardVariants } from "./variants"
interface SidePeekButton {
call_to_action_text: string
}
interface TeaserCardButton extends Pick<ButtonProps, "onPress"> {
title: string
href: string
openInNewTab?: boolean
}
interface TeaserCardProps extends VariantProps<typeof teaserCardVariants> {
heading: string
bodyText: string
primaryButton?: TeaserCardButton
secondaryButton?: TeaserCardButton
sidePeekButton?: SidePeekButton
sidePeekContent?: TeaserCardSidepeekContent
image?: ImageVaultAsset
className?: string
}
export function TeaserCard({
heading,
bodyText,
primaryButton,
secondaryButton,
sidePeekButton,
sidePeekContent,
image,
style,
alwaysStack = false,
className,
}: TeaserCardProps) {
const classNames = teaserCardVariants({ style, alwaysStack, className })
return (
<article className={classNames}>
{image && (
<div className={styles.imageContainer}>
<Image
src={image.url}
alt={image.meta?.alt || ""}
focalPoint={image.focalPoint}
dimensions={image.dimensions}
fill
/>
</div>
)}
<div className={styles.content}>
<Typography variant="Title/Subtitle/md">
<p>{heading}</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>{bodyText}</p>
</Typography>
{sidePeekButton && sidePeekContent ? (
<TeaserCardSidepeek
button={sidePeekButton}
sidePeekContent={sidePeekContent}
/>
) : (
<div className={styles.ctaContainer}>
{primaryButton && (
<ButtonLink
variant="Tertiary"
color="Primary"
size="sm"
className={styles.ctaButton}
href={primaryButton.href}
target={primaryButton.openInNewTab ? "_blank" : undefined}
>
{primaryButton.title}
</ButtonLink>
)}
{secondaryButton && (
<ButtonLink
variant="Secondary"
color="Primary"
size="sm"
className={styles.ctaButton}
href={secondaryButton.href}
target={secondaryButton.openInNewTab ? "_blank" : undefined}
>
{secondaryButton.title}
</ButtonLink>
)}
</div>
)}
</div>
</article>
)
}

View File

@@ -0,0 +1 @@
export { TeaserCard } from "./TeaserCard"

View File

@@ -0,0 +1,47 @@
.teaserCard {
border-radius: var(--Corner-radius-md);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--Border-Default);
color: var(--Text-Default);
&.default {
background-color: var(--Surface-Secondary-Default);
}
&.featured {
background-color: var(--Surface-Primary-Default);
}
}
.imageContainer {
width: 100%;
height: 200px;
position: relative;
}
.content {
display: grid;
gap: var(--Space-x15);
padding: var(--Space-x2) var(--Space-x3);
grid-template-rows: auto 1fr auto;
flex-grow: 1;
}
.ctaContainer {
display: grid;
grid-template-columns: 1fr;
gap: var(--Space-x1);
width: 100%;
}
@media (min-width: 1367px) {
.teaserCard:not(.alwaysStack) .ctaContainer {
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
&:has(:only-child) {
grid-template-columns: 1fr;
}
}
}

View File

@@ -0,0 +1,22 @@
import { cva } from "class-variance-authority"
import styles from "./teaserCard.module.css"
export const config = {
variants: {
style: {
default: styles.default,
featured: styles.featured,
},
alwaysStack: {
true: styles.alwaysStack,
false: "",
},
},
defaultVariants: {
style: "default",
alwaysStack: false,
},
} as const
export const teaserCardVariants = cva(styles.teaserCard, config)

View File

@@ -24,3 +24,16 @@ ul {
outline-color: var(--Border-Interactive-Focus);
outline-offset: 2px;
}
/* From Tailwind */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

View File

@@ -0,0 +1,26 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
type SidepeekContentBase = {
heading: string
content?: {
json?: any
embedded_itemsConnection: {
edges: any
}
} | null
}
export type AlertSidepeekContent = SidepeekContentBase
export type TeaserCardSidepeekContent = SidepeekContentBase & {
primary_button?: {
href: string
title: string
openInNewTab?: boolean
isExternal?: boolean
}
secondary_button?: {
href: string
title: string
openInNewTab?: boolean
isExternal?: boolean
}
}

View File

@@ -179,6 +179,7 @@
"./Switch": "./lib/components/Switch/index.tsx",
"./Table": "./lib/components/Table/index.tsx",
"./TermModal": "./lib/components/RateCard/TermModal/index.tsx",
"./TeaserCard": "./lib/components/TeaserCard/index.tsx",
"./TextArea": "./lib/components/TextArea/index.tsx",
"./TextLink": "./lib/components/TextLink/index.tsx",
"./TextLinkButton": "./lib/components/TextLinkButton/index.tsx",