chore(BOOK-754): Moved ContentCard to design system and added stories

Approved-by: Bianca Widstam
Approved-by: Anton Gunnarsson
This commit is contained in:
Erik Tiekstra
2026-01-20 12:37:22 +00:00
parent 510f25a812
commit d7eed5b318
12 changed files with 172 additions and 172 deletions
@@ -0,0 +1,127 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite"
import { ContentCard } from "./ContentCard.tsx"
const 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 },
}
const IMAGE_2 = {
...IMAGE,
id: 2,
url: "./img/img3.jpg",
dimensions: { width: 1920, height: 2880, aspectRatio: 0.67 },
}
const DEFAULT_ARGS = {
heading: "Lorem ipsum",
bodyText:
"Dolor sit amet, consectetur adipiscing elit. Curabitur vitae neque non ipsum efficitur hendrerit at ut nulla.",
link: {
href: "#",
text: "Learn more",
},
image: IMAGE,
}
const meta: Meta<typeof ContentCard> = {
title: "Core Components/Cards/ContentCard",
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: ContentCard,
argTypes: {
heading: {
control: "text",
table: {
type: { summary: "string" },
},
},
bodyText: {
control: "text",
table: {
type: { summary: "string" },
},
},
promoText: {
control: "text",
table: {
type: { summary: "string | undefined" },
},
},
link: {
control: "object",
description:
"The link of where the card should navigate to. The whole card is clickable.",
table: {
type: {
summary:
"{href: string, openInNewTab?: boolean, isExternal?: boolean}",
},
},
},
image: {
control: "object",
table: {
type: {
summary: "ImageVaultAsset",
detail:
"{ id: number, url: string, meta: {alt?: string | null, caption?: string | null}, focalPoint: { x: number, y: number }, dimensions: { width: number, height: number, aspectRatio: number } }",
},
},
},
},
args: { ...DEFAULT_ARGS },
globals: {
backgrounds: { default: "storybookLight" },
},
}
export default meta
type Story = StoryObj<typeof ContentCard>
export const Default: Story = {
args: {
...meta.args,
},
}
export const WithPromoText: Story = {
args: {
...meta.args,
promoText: "Popular choice",
},
}
export const MultipleCards: Story = {
args: {
...meta.args,
},
render: (args) => (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "16px",
width: "min(800px, 100%)",
margin: "0 auto",
}}
>
<ContentCard {...args} promoText="Popular choice" />
<ContentCard {...args} image={IMAGE_2} />
</div>
),
}
@@ -0,0 +1,90 @@
"use client"
import { cx } from "class-variance-authority"
import NextLink from "next/link"
import { useIntl } from "react-intl"
import styles from "./contentCard.module.css"
import type { ImageVaultAsset } from "@scandic-hotels/common/utils/imageVault"
import { ChipStatic } from "../ChipStatic"
import Image from "../Image"
import { Typography } from "../Typography"
interface ContentCardProps {
link?: {
href: string
openInNewTab?: boolean
isExternal?: boolean
}
heading: string
image: ImageVaultAsset
bodyText: string
promoText?: string
className?: string
}
export function ContentCard({
heading,
image,
bodyText,
promoText,
className,
link,
}: ContentCardProps) {
const intl = useIntl()
const card = (
<article className={cx(styles.card, className)}>
<div className={styles.imageContainer}>
<Image
src={image.url}
alt={image.meta.alt || image.meta.caption || ""}
className={styles.image}
fill
sizes="(min-width: 768px) 413px, 100vw"
focalPoint={image.focalPoint}
dimensions={image.dimensions}
/>
{promoText ? (
<ChipStatic
color="Neutral"
size="sm"
lowerCase
className={styles.promoTag}
>
{promoText}
</ChipStatic>
) : null}
</div>
<div className={styles.content}>
<Typography variant="Title/Subtitle/md">
<h4>{heading}</h4>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>{bodyText}</p>
</Typography>
</div>
</article>
)
if (!link) return card
const linkProps = {
className: styles.link,
...(link.openInNewTab && {
target: "_blank",
rel: "noopener noreferrer",
title: intl.formatMessage({
id: "common.linkOpenInNewTab",
defaultMessage: "Opens in a new tab/window",
}),
}),
}
return (
<NextLink href={link.href} {...linkProps}>
{card}
</NextLink>
)
}
@@ -0,0 +1,52 @@
.card {
display: grid;
&:hover {
.imageContainer,
.image {
border-radius: var(--Corner-radius-lg);
}
.image {
transform: scale(1.05);
}
}
}
.imageContainer {
position: relative;
width: 100%;
height: 250px;
border-radius: var(--Corner-radius-md);
overflow: hidden;
transition: border-radius 0.3s ease-in-out;
}
.image {
border-radius: var(--Corner-radius-md);
width: 100%;
height: 100%;
object-fit: cover;
transition:
transform 0.3s ease-in-out,
border-radius 0.3s ease-in-out;
}
.promoTag {
position: absolute;
top: 14px;
left: 14px;
}
.content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--Space-x15);
align-self: stretch;
padding: var(--Space-x2) var(--Space-x2) var(--Space-x2) 0;
}
.link {
text-decoration: none;
color: inherit;
}
@@ -0,0 +1 @@
export { ContentCard } from "./ContentCard"