Merged in feat/SW-1383-content-card-start-page (pull request #1252)

feat(SW-1383): Implement ContentCard for the Start Page

* feat(SW-1383): Implement ContentCard

- Add ContentCard component
- Use within CarouselCards component

* fix(SW-1383): adjust carousel and content card styling

* refactor(SW-1383): optimize ContentCard component styling and props

* feat(SW-1383): move ContentCard image check out of component

* feat(SW-1383): Add optional link prop to ContentCard component

* refactor(SW-1383): Make ContentCard component linkable


Approved-by: Christian Andolf
Approved-by: Erik Tiekstra
This commit is contained in:
Chuma Mcphoy (We Ahead)
2025-02-05 11:29:53 +00:00
parent a389fba8ce
commit f3e6318d49
10 changed files with 238 additions and 126 deletions

View File

@@ -1,6 +1,9 @@
import ContentCard from "@/components/ContentCard"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import styles from "./carouselCards.module.css"
import type { CarouselCardsProps } from "@/types/components/blocks/carouselCards"
export default function CarouselCards({ carousel_cards }: CarouselCardsProps) {
@@ -19,17 +22,27 @@ export default function CarouselCards({ carousel_cards }: CarouselCardsProps) {
{enableFilters && (
<details>
<summary>Filter data</summary>
<p>Todo: Add filter component here</p>
<pre>
{JSON.stringify({ filterCategories, defaultFilter }, null, 2)}
</pre>
<div>
{/* Filter component will go here */}
<pre className={styles.code}>
{JSON.stringify({ filterCategories, defaultFilter }, null, 2)}
</pre>
</div>
</details>
)}
<details>
<summary>Carousel cards</summary>
<p>Todo: Add carousel cards component here</p>
<pre>{JSON.stringify({ cards }, null, 2)}</pre>
</details>
{/* Carousel functionality will go here */}
<div className={styles.cardsContainer}>
{cards.map((card) => (
<ContentCard
link={card.link}
key={card.heading}
heading={card.heading}
bodyText={card.bodyText}
image={card.image}
promoText={card.promoText}
/>
))}
</div>
</SectionContainer>
)
}

View File

@@ -0,0 +1,36 @@
.code {
padding: var(--Spacing-x2);
background: var(--Base-Surface-Secondary-light-Normal);
}
/*
Mock styles for the carousel cards. Will be removed/replaced
when the carousel functionality is implemented (SW-1542).
*/
.cardsContainer {
display: grid;
gap: var(--Spacing-x3);
grid-auto-flow: column;
grid-auto-columns: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
}
.cardsContainer > * {
scroll-snap-align: start;
}
/* Show 2 cards on tablet */
@media (min-width: 768px) {
.cardsContainer {
grid-auto-columns: calc((100% - var(--Spacing-x3)) / 2);
}
}
/* Show 3 cards on desktop */
@media (min-width: 1024px) {
.cardsContainer {
grid-auto-columns: calc((100% - var(--Spacing-x3) * 2) / 3);
}
}

View File

@@ -0,0 +1,60 @@
.card {
display: grid;
}
.imageContainer {
position: relative;
aspect-ratio: 16/9;
border-radius: var(--Corner-radius-Medium);
overflow: hidden;
transition: border-radius 0.3s ease-in-out;
}
.image {
border-radius: var(--Corner-radius-Medium);
transition:
transform 0.3s ease-in-out,
border-radius 0.3s ease-in-out;
}
.card:hover,
.card:hover .imageContainer,
.card:hover .image {
border-radius: var(--Corner-radius-Large);
}
.card:hover .image {
transform: scale(1.05);
}
.promoTag {
position: absolute;
top: 14px;
left: 14px;
text-transform: uppercase;
}
.content {
display: flex;
padding: var(--Spacing-x-one-and-half);
flex-direction: column;
align-items: flex-start;
gap: var(--Spacing-x-one-and-half);
align-self: stretch;
}
@media (min-width: 768px) {
.card {
max-width: 413px;
}
.content {
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x2) 0;
}
}
@media (max-width: 767px) {
.card {
min-width: 300px;
}
}

View File

@@ -0,0 +1,21 @@
import type { ImageVaultAsset } from "@/types/components/imageVault"
export interface ContentCardProps {
link?: {
href: string
openInNewTab?: boolean
isExternal?: boolean
}
heading: string
image: ImageVaultAsset
bodyText: string
promoText?: string
className?: string
}
export interface ContentCardLinkProps {
href: string
openInNewTab?: boolean
isExternal?: boolean
children: React.ReactNode
}

View File

@@ -0,0 +1,70 @@
import Image from "@/components/Image"
import Chip from "@/components/TempDesignSystem/Chip"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./contentCard.module.css"
import type { ContentCardLinkProps,ContentCardProps } from "./contentCard"
export default function ContentCard({
heading,
image,
bodyText,
promoText,
className = "",
link,
}: ContentCardProps) {
const card = (
<article className={`${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}
/>
{promoText ? (
<Chip className={styles.promoTag}>{promoText}</Chip>
) : null}
</div>
<div className={styles.content}>
<Subtitle type="two">{heading}</Subtitle>
<Body>{bodyText}</Body>
</div>
</article>
)
if (!link) return card
return (
<ContentCardLink
href={link.href}
openInNewTab={link.openInNewTab}
isExternal={link.isExternal}
>
{card}
</ContentCardLink>
)
}
function ContentCardLink({
children,
href,
openInNewTab,
isExternal,
}: ContentCardLinkProps) {
const Component = isExternal ? "a" : Link
const linkProps = {
href,
...(openInNewTab && {
target: "_blank",
rel: "noopener noreferrer",
}),
}
return <Component {...linkProps}>{children}</Component>
}

View File

@@ -42,6 +42,7 @@
display: grid;
gap: var(--Spacing-x6);
padding: calc(var(--Spacing-x5) * 2) 0 calc(var(--Spacing-x5) * 4);
background-color: var(--Base-Surface-Primary-light-Normal);
}
@media screen and (min-width: 768px) {

View File

@@ -25,36 +25,9 @@ fragment ContentCardBlock on ContentCard {
heading
image
body_text
has_primary_button
has_secondary_button
has_card_link
promo_text
primary_button {
cta_text
is_contentstack_link
open_in_new_tab
external_link {
href
title
}
linkConnection {
edges {
node {
__typename
...AccountPageLink
...CollectionPageLink
...ContentPageLink
...DestinationCityPageLink
...DestinationCountryPageLink
...DestinationOverviewPageLink
...HotelPageLink
...LoyaltyPageLink
...StartPageLink
}
}
}
}
secondary_button {
cta_text
card_link {
is_contentstack_link
open_in_new_tab
external_link {
@@ -85,25 +58,7 @@ fragment ContentCardBlock on ContentCard {
fragment ContentCardBlockRef on ContentCard {
__typename
primary_button {
linkConnection {
edges {
node {
__typename
...AccountPageRef
...CollectionPageRef
...ContentPageRef
...DestinationCityPageRef
...DestinationCountryPageRef
...DestinationOverviewPageRef
...HotelPageRef
...LoyaltyPageRef
...StartPageRef
}
}
}
}
secondary_button {
card_link {
linkConnection {
edges {
node {

View File

@@ -1,52 +0,0 @@
#import "../../System.graphql"
#import "../../AccountPage/Ref.graphql"
#import "../../CollectionPage/Ref.graphql"
#import "../../ContentPage/Ref.graphql"
#import "../../DestinationCityPage/Ref.graphql"
#import "../../DestinationCountryPage/Ref.graphql"
#import "../../DestinationOverviewPage/Ref.graphql"
#import "../../HotelPage/Ref.graphql"
#import "../../LoyaltyPage/Ref.graphql"
#import "../../StartPage/Ref.graphql"
fragment ContentCardBlockRef on ContentCard {
secondary_button {
linkConnection {
edges {
node {
__typename
...AccountPageRef
...CollectionPageRef
...ContentPageRef
...DestinationCityPageRef
...DestinationCountryPageRef
...DestinationOverviewPageRef
...HotelPageRef
...LoyaltyPageRef
...StartPageRef
}
}
}
}
primary_button {
linkConnection {
edges {
node {
__typename
...AccountPageRef
...CollectionPageRef
...ContentPageRef
...DestinationCityPageRef
...DestinationCountryPageRef
...DestinationOverviewPageRef
...HotelPageRef
...LoyaltyPageRef
...StartPageRef
}
}
}
}
system {
...System
}
}

View File

@@ -14,21 +14,21 @@ export const contentCardSchema = z.object({
image: tempImageVaultAssetSchema,
body_text: z.string(),
promo_text: z.string().optional(),
has_primary_button: z.boolean(),
has_secondary_button: z.boolean(),
primary_button: buttonSchema,
secondary_button: buttonSchema,
has_card_link: z.boolean(),
card_link: buttonSchema,
system: systemSchema,
})
export const contentCardRefSchema = z.object({
__typename: z.literal(CardsEnum.ContentCard),
primary_button: linkConnectionRefsSchema,
secondary_button: linkConnectionRefsSchema,
card_link: linkConnectionRefsSchema,
system: systemSchema,
})
export function transformContentCard(card: typeof contentCardSchema._type) {
// Return null if image or image URL is missing
if (!card.image?.url) return null
return {
__typename: card.__typename,
title: card.title,
@@ -36,9 +36,12 @@ export function transformContentCard(card: typeof contentCardSchema._type) {
image: card.image,
bodyText: card.body_text,
promoText: card.promo_text,
primaryButton: card.has_primary_button ? card.primary_button : undefined,
secondaryButton: card.has_secondary_button
? card.secondary_button
link: card.has_card_link
? {
href: card.card_link.href,
openInNewTab: card.card_link.openInNewTab,
isExternal: card.card_link.isExternal,
}
: undefined,
}
}

View File

@@ -69,11 +69,13 @@ export const carouselCardsSchema = z.object({
heading: data.heading,
enableFilters: false,
filterCategories: [],
cards: data.card_groups.flatMap((group) =>
group.cardConnection.edges.map((edge) =>
transformContentCard(edge.node)
cards: data.card_groups
.flatMap((group) =>
group.cardConnection.edges.map((edge) =>
transformContentCard(edge.node)
)
)
),
.filter((card): card is NonNullable<typeof card> => card !== null),
defaultFilter: null,
link: data.link
? { href: data.link.href, text: data.link.title }
@@ -102,10 +104,13 @@ export const carouselCardsSchema = z.object({
enableFilters: true,
filterCategories,
cards: data.card_groups.flatMap((group) =>
group.cardConnection.edges.map((edge) => ({
...transformContentCard(edge.node),
filterId: group.filter_category.filter_identifier,
}))
group.cardConnection.edges
.map((edge) => transformContentCard(edge.node))
.filter((card): card is NonNullable<typeof card> => card !== null)
.map((card) => ({
...card,
filterId: group.filter_category.filter_identifier,
}))
),
defaultFilter: data.default_filter,
link: data.link