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

@@ -1,3 +1,4 @@
import { TeaserCard } from "@scandic-hotels/design-system/TeaserCard"
import {
CardsGridEnum,
CardsGridLayoutEnum,
@@ -9,7 +10,6 @@ import { SectionHeader } from "@/components/Section/Header"
import Card from "@/components/TempDesignSystem/Card"
import Grids from "@/components/TempDesignSystem/Grids"
import LoyaltyCard from "@/components/TempDesignSystem/LoyaltyCard"
import TeaserCard from "@/components/TempDesignSystem/TeaserCard"
import type { CardsGrid as CardsGridBlock } from "@scandic-hotels/trpc/types/blocks"
import type { VariantProps } from "class-variance-authority"
@@ -89,8 +89,8 @@ export default function CardsGrid({
return (
<TeaserCard
key={card.system.uid}
title={card.heading}
description={card.body_text}
heading={card.heading}
bodyText={card.body_text}
primaryButton={card.primaryButton}
secondaryButton={card.secondaryButton}
sidePeekButton={card.sidePeekButton}

View File

@@ -1,4 +1,5 @@
import { JsonToHtml } from "@scandic-hotels/design-system/JsonToHtml"
import { TeaserCard } from "@scandic-hotels/design-system/TeaserCard"
import { DynamicContentEnum } from "@scandic-hotels/trpc/types/dynamicContent"
import { SidebarEnums } from "@scandic-hotels/trpc/types/sidebar"
@@ -7,7 +8,6 @@ import EmployeeBenefitsAuthCard from "@/components/DigitalTeamMemberCard/Employe
import ShortcutsList from "../Blocks/ShortcutsList"
import Card from "../TempDesignSystem/Card"
import TeaserCard from "../TempDesignSystem/TeaserCard"
import JoinLoyaltyContact from "./JoinLoyalty"
import styles from "./sidebar.module.css"
@@ -71,9 +71,9 @@ export default function Sidebar({ blocks }: SidebarProps) {
return (
<TeaserCard
key={block.teaser_card.system.uid}
title={block.teaser_card.heading}
description={block.teaser_card.body_text}
intent={block.teaser_card.theme}
heading={block.teaser_card.heading}
bodyText={block.teaser_card.body_text}
style={block.teaser_card.theme}
primaryButton={block.teaser_card.primaryButton}
secondaryButton={block.teaser_card.secondaryButton}
sidePeekButton={block.teaser_card.sidePeekButton}

View File

@@ -1,27 +0,0 @@
import type { ImageVaultAsset } from "@scandic-hotels/common/utils/imageVault"
import type { TeaserCard } from "@scandic-hotels/trpc/types/blocks"
import type { VariantProps } from "class-variance-authority"
import type { CardProps } from "@/components/TempDesignSystem/Card/card"
import type { teaserCardVariants } from "@/components/TempDesignSystem/TeaserCard/variants"
interface SidePeekButton {
call_to_action_text: string
}
export interface TeaserCardProps
extends VariantProps<typeof teaserCardVariants> {
title: string
description: string
primaryButton?: CardProps["primaryButton"]
secondaryButton?: CardProps["secondaryButton"]
sidePeekButton?: SidePeekButton
sidePeekContent?: TeaserCard["sidepeek_content"]
image?: ImageVaultAsset
className?: string
}
export interface TeaserCardSidepeekProps {
button: SidePeekButton
sidePeekContent: NonNullable<TeaserCard["sidepeek_content"]>
}

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

@@ -3,15 +3,20 @@
import { useState } from "react"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { JsonToHtml } from "@scandic-hotels/design-system/JsonToHtml"
import SidePeek from "@scandic-hotels/design-system/SidePeek"
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"
import type { TeaserCardSidepeekProps } from "@/types/components/teaserCard"
interface TeaserCardSidepeekProps {
button: {
call_to_action_text: string
}
sidePeekContent: TeaserCardSidepeekContent
}
export default function TeaserCardSidepeek({
button,

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

@@ -1,27 +1,47 @@
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import Image from "@scandic-hotels/design-system/Image"
import { Typography } from "@scandic-hotels/design-system/Typography"
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"
import styles from "./teaserCard.module.css"
interface SidePeekButton {
call_to_action_text: string
}
import type { TeaserCardProps } from "@/types/components/teaserCard"
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 default function TeaserCard({
title,
description,
export function TeaserCard({
heading,
bodyText,
primaryButton,
secondaryButton,
sidePeekButton,
sidePeekContent,
image,
intent,
style,
alwaysStack = false,
className,
}: TeaserCardProps) {
const classNames = teaserCardVariants({ intent, alwaysStack, className })
const classNames = teaserCardVariants({ style, alwaysStack, className })
return (
<article className={classNames}>
@@ -30,7 +50,6 @@ export default function TeaserCard({
<Image
src={image.url}
alt={image.meta?.alt || ""}
className={styles.image}
focalPoint={image.focalPoint}
dimensions={image.dimensions}
fill
@@ -39,10 +58,10 @@ export default function TeaserCard({
)}
<div className={styles.content}>
<Typography variant="Title/Subtitle/md">
<p>{title}</p>
<p>{heading}</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>{description}</p>
<p>{bodyText}</p>
</Typography>
{sidePeekButton && sidePeekContent ? (

View File

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

View File

@@ -1,8 +1,18 @@
.card {
.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 {
@@ -11,36 +21,12 @@
position: relative;
}
.default {
background-color: var(--Base-Surface-Subtle-Normal);
}
.featured {
background-color: var(--Main-Grey-White);
}
.default,
.featured {
border: 1px solid var(--Base-Border-Subtle);
}
.image {
width: 100%;
height: 12.5rem; /* 200px */
}
.content {
display: grid;
gap: var(--Space-x15);
padding: var(--Space-x2) var(--Space-x3);
grid-template-rows: auto 1fr auto;
flex-grow: 1;
color: var(--Main-Grey-100);
}
.description {
color: var(--Base-Text-Medium-contrast);
}
.ctaContainer {
@@ -51,11 +37,11 @@
}
@media (min-width: 1367px) {
.card:not(.alwaysStack) .ctaContainer {
.teaserCard:not(.alwaysStack) .ctaContainer {
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
}
.card:not(.alwaysStack) .ctaContainer:has(:only-child) {
&:has(:only-child) {
grid-template-columns: 1fr;
}
}
}

View File

@@ -2,9 +2,9 @@ import { cva } from "class-variance-authority"
import styles from "./teaserCard.module.css"
export const teaserCardVariants = cva(styles.card, {
export const config = {
variants: {
intent: {
style: {
default: styles.default,
featured: styles.featured,
},
@@ -14,7 +14,9 @@ export const teaserCardVariants = cva(styles.card, {
},
},
defaultVariants: {
intent: "default",
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",