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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
36
components/Blocks/carouselCards.module.css
Normal file
36
components/Blocks/carouselCards.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
60
components/ContentCard/contentCard.module.css
Normal file
60
components/ContentCard/contentCard.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
21
components/ContentCard/contentCard.ts
Normal file
21
components/ContentCard/contentCard.ts
Normal 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
|
||||
}
|
||||
70
components/ContentCard/index.tsx
Normal file
70
components/ContentCard/index.tsx
Normal 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>
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user