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 SectionContainer from "@/components/Section/Container"
|
||||||
import SectionHeader from "@/components/Section/Header"
|
import SectionHeader from "@/components/Section/Header"
|
||||||
|
|
||||||
|
import styles from "./carouselCards.module.css"
|
||||||
|
|
||||||
import type { CarouselCardsProps } from "@/types/components/blocks/carouselCards"
|
import type { CarouselCardsProps } from "@/types/components/blocks/carouselCards"
|
||||||
|
|
||||||
export default function CarouselCards({ carousel_cards }: CarouselCardsProps) {
|
export default function CarouselCards({ carousel_cards }: CarouselCardsProps) {
|
||||||
@@ -19,17 +22,27 @@ export default function CarouselCards({ carousel_cards }: CarouselCardsProps) {
|
|||||||
{enableFilters && (
|
{enableFilters && (
|
||||||
<details>
|
<details>
|
||||||
<summary>Filter data</summary>
|
<summary>Filter data</summary>
|
||||||
<p>Todo: Add filter component here</p>
|
<div>
|
||||||
<pre>
|
{/* Filter component will go here */}
|
||||||
{JSON.stringify({ filterCategories, defaultFilter }, null, 2)}
|
<pre className={styles.code}>
|
||||||
</pre>
|
{JSON.stringify({ filterCategories, defaultFilter }, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
<details>
|
{/* Carousel functionality will go here */}
|
||||||
<summary>Carousel cards</summary>
|
<div className={styles.cardsContainer}>
|
||||||
<p>Todo: Add carousel cards component here</p>
|
{cards.map((card) => (
|
||||||
<pre>{JSON.stringify({ cards }, null, 2)}</pre>
|
<ContentCard
|
||||||
</details>
|
link={card.link}
|
||||||
|
key={card.heading}
|
||||||
|
heading={card.heading}
|
||||||
|
bodyText={card.bodyText}
|
||||||
|
image={card.image}
|
||||||
|
promoText={card.promoText}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</SectionContainer>
|
</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;
|
display: grid;
|
||||||
gap: var(--Spacing-x6);
|
gap: var(--Spacing-x6);
|
||||||
padding: calc(var(--Spacing-x5) * 2) 0 calc(var(--Spacing-x5) * 4);
|
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) {
|
@media screen and (min-width: 768px) {
|
||||||
|
|||||||
@@ -25,36 +25,9 @@ fragment ContentCardBlock on ContentCard {
|
|||||||
heading
|
heading
|
||||||
image
|
image
|
||||||
body_text
|
body_text
|
||||||
has_primary_button
|
has_card_link
|
||||||
has_secondary_button
|
|
||||||
promo_text
|
promo_text
|
||||||
primary_button {
|
card_link {
|
||||||
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
|
|
||||||
is_contentstack_link
|
is_contentstack_link
|
||||||
open_in_new_tab
|
open_in_new_tab
|
||||||
external_link {
|
external_link {
|
||||||
@@ -85,25 +58,7 @@ fragment ContentCardBlock on ContentCard {
|
|||||||
|
|
||||||
fragment ContentCardBlockRef on ContentCard {
|
fragment ContentCardBlockRef on ContentCard {
|
||||||
__typename
|
__typename
|
||||||
primary_button {
|
card_link {
|
||||||
linkConnection {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
__typename
|
|
||||||
...AccountPageRef
|
|
||||||
...CollectionPageRef
|
|
||||||
...ContentPageRef
|
|
||||||
...DestinationCityPageRef
|
|
||||||
...DestinationCountryPageRef
|
|
||||||
...DestinationOverviewPageRef
|
|
||||||
...HotelPageRef
|
|
||||||
...LoyaltyPageRef
|
|
||||||
...StartPageRef
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
secondary_button {
|
|
||||||
linkConnection {
|
linkConnection {
|
||||||
edges {
|
edges {
|
||||||
node {
|
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,
|
image: tempImageVaultAssetSchema,
|
||||||
body_text: z.string(),
|
body_text: z.string(),
|
||||||
promo_text: z.string().optional(),
|
promo_text: z.string().optional(),
|
||||||
has_primary_button: z.boolean(),
|
has_card_link: z.boolean(),
|
||||||
has_secondary_button: z.boolean(),
|
card_link: buttonSchema,
|
||||||
primary_button: buttonSchema,
|
|
||||||
secondary_button: buttonSchema,
|
|
||||||
system: systemSchema,
|
system: systemSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const contentCardRefSchema = z.object({
|
export const contentCardRefSchema = z.object({
|
||||||
__typename: z.literal(CardsEnum.ContentCard),
|
__typename: z.literal(CardsEnum.ContentCard),
|
||||||
primary_button: linkConnectionRefsSchema,
|
card_link: linkConnectionRefsSchema,
|
||||||
secondary_button: linkConnectionRefsSchema,
|
|
||||||
system: systemSchema,
|
system: systemSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function transformContentCard(card: typeof contentCardSchema._type) {
|
export function transformContentCard(card: typeof contentCardSchema._type) {
|
||||||
|
// Return null if image or image URL is missing
|
||||||
|
if (!card.image?.url) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
__typename: card.__typename,
|
__typename: card.__typename,
|
||||||
title: card.title,
|
title: card.title,
|
||||||
@@ -36,9 +36,12 @@ export function transformContentCard(card: typeof contentCardSchema._type) {
|
|||||||
image: card.image,
|
image: card.image,
|
||||||
bodyText: card.body_text,
|
bodyText: card.body_text,
|
||||||
promoText: card.promo_text,
|
promoText: card.promo_text,
|
||||||
primaryButton: card.has_primary_button ? card.primary_button : undefined,
|
link: card.has_card_link
|
||||||
secondaryButton: card.has_secondary_button
|
? {
|
||||||
? card.secondary_button
|
href: card.card_link.href,
|
||||||
|
openInNewTab: card.card_link.openInNewTab,
|
||||||
|
isExternal: card.card_link.isExternal,
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,11 +69,13 @@ export const carouselCardsSchema = z.object({
|
|||||||
heading: data.heading,
|
heading: data.heading,
|
||||||
enableFilters: false,
|
enableFilters: false,
|
||||||
filterCategories: [],
|
filterCategories: [],
|
||||||
cards: data.card_groups.flatMap((group) =>
|
cards: data.card_groups
|
||||||
group.cardConnection.edges.map((edge) =>
|
.flatMap((group) =>
|
||||||
transformContentCard(edge.node)
|
group.cardConnection.edges.map((edge) =>
|
||||||
|
transformContentCard(edge.node)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
.filter((card): card is NonNullable<typeof card> => card !== null),
|
||||||
defaultFilter: null,
|
defaultFilter: null,
|
||||||
link: data.link
|
link: data.link
|
||||||
? { href: data.link.href, text: data.link.title }
|
? { href: data.link.href, text: data.link.title }
|
||||||
@@ -102,10 +104,13 @@ export const carouselCardsSchema = z.object({
|
|||||||
enableFilters: true,
|
enableFilters: true,
|
||||||
filterCategories,
|
filterCategories,
|
||||||
cards: data.card_groups.flatMap((group) =>
|
cards: data.card_groups.flatMap((group) =>
|
||||||
group.cardConnection.edges.map((edge) => ({
|
group.cardConnection.edges
|
||||||
...transformContentCard(edge.node),
|
.map((edge) => transformContentCard(edge.node))
|
||||||
filterId: group.filter_category.filter_identifier,
|
.filter((card): card is NonNullable<typeof card> => card !== null)
|
||||||
}))
|
.map((card) => ({
|
||||||
|
...card,
|
||||||
|
filterId: group.filter_category.filter_identifier,
|
||||||
|
}))
|
||||||
),
|
),
|
||||||
defaultFilter: data.default_filter,
|
defaultFilter: data.default_filter,
|
||||||
link: data.link
|
link: data.link
|
||||||
|
|||||||
Reference in New Issue
Block a user