Merged in feat/SW-1443-card-gallery-destination-overview (pull request #1362)

feat(SW-1443): added cardGallery block to destination overview page instead of carousel functionality

* feat(SW-1443): added cardGallery block to destination overview page instead of carousel functionality


Approved-by: Fredrik Thorsson
This commit is contained in:
Erik Tiekstra
2025-02-18 14:42:36 +00:00
parent f14eb05262
commit 2781a41110
16 changed files with 337 additions and 102 deletions

View File

@@ -0,0 +1,25 @@
.cardsList {
list-style: none;
display: none;
gap: var(--Spacing-x4) var(--Spacing-x1);
}
.navigationButton {
top: 30%;
}
@media screen and (min-width: 768px) {
.carousel {
display: none;
}
.cardsList {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
}
@media screen and (min-width: 1024px) {
.cardsList {
grid-template-columns: repeat(3, 1fr);
}
}

View File

@@ -0,0 +1,67 @@
"use client"
import { useState } from "react"
import { Carousel } from "@/components/Carousel"
import ContentCard from "@/components/ContentCard"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import TabFilters from "@/components/TabFilters"
import styles from "./cardGallery.module.css"
import type { CardGalleryProps } from "@/types/components/blocks/cardGallery"
export default function CardGallery({ card_gallery }: CardGalleryProps) {
const { heading, defaultFilter, filterCategories, cards, link } = card_gallery
const [activeFilter, setActiveFilter] = useState(defaultFilter)
const filteredCards = cards.filter((card) => card.filterId === activeFilter)
return (
<SectionContainer>
<SectionHeader title={heading} link={link} />
{filterCategories.length > 0 && activeFilter && (
<TabFilters
categories={filterCategories}
selectedFilter={activeFilter}
onFilterSelect={setActiveFilter}
/>
)}
<ul className={styles.cardsList}>
{filteredCards.map((card, index) => (
<li key={`${card.heading}-${index}`}>
<ContentCard
heading={card.heading}
image={card.image}
bodyText={card.bodyText}
promoText={card.promoText}
link={card.link}
/>
</li>
))}
</ul>
<Carousel className={styles.carousel}>
<Carousel.Content>
{filteredCards.map((card, index) => (
<Carousel.Item key={`${card.heading}-${index}`}>
<ContentCard
heading={card.heading}
image={card.image}
bodyText={card.bodyText}
promoText={card.promoText}
link={card.link}
/>
</Carousel.Item>
))}
</Carousel.Content>
<Carousel.Previous className={styles.navigationButton} />
<Carousel.Next className={styles.navigationButton} />
<Carousel.Dots />
</Carousel>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}

View File

@@ -7,8 +7,7 @@ import ContentCard from "@/components/ContentCard"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import Filters from "./Filters"
import TabFilters from "@/components/TabFilters"
import styles from "./carouselCards.module.css"
@@ -43,7 +42,7 @@ export default function CarouselCards({ carousel_cards }: CarouselCardsProps) {
link={link}
/>
{filterCategories.length > 0 && activeFilter && (
<Filters
<TabFilters
categories={filterCategories}
selectedFilter={activeFilter}
onFilterSelect={setActiveFilter}

View File

@@ -9,6 +9,7 @@ import UspGrid from "@/components/Blocks/UspGrid"
import JsonToHtml from "@/components/JsonToHtml"
import AccordionSection from "./Accordion"
import CardGallery from "./CardGallery"
import FullWidthCampaign from "./FullWidthCampaign"
import HotelListing from "./HotelListing"
import JoinScandicFriends from "./JoinScandicFriends"
@@ -61,6 +62,13 @@ export default function Blocks({ blocks }: BlocksProps) {
key={`${block.carousel_cards.heading}-${idx}`}
/>
)
case BlocksEnums.block.CardGallery:
return (
<CardGallery
card_gallery={block.card_gallery}
key={`${block.card_gallery.heading}-${idx}`}
/>
)
case BlocksEnums.block.HotelListing:
const { heading, contentType, locationFilter, hotelsToInclude } =
block.hotel_listing

View File

@@ -1,20 +1,26 @@
"use client"
import styles from "./filters.module.css"
import styles from "./tabFilters.module.css"
import type { CardGalleryFilter } from "@/types/enums/cardGallery"
import type { CarouselCardFilter } from "@/types/enums/carouselCards"
interface FiltersProps {
categories: Array<{ identifier: CarouselCardFilter; label: string }>
selectedFilter: CarouselCardFilter
onFilterSelect: (filter: CarouselCardFilter) => void
interface Filter {
identifier: CarouselCardFilter | CardGalleryFilter
label: string
}
export default function Filters({
interface TabFiltersProps {
categories: Array<Filter>
selectedFilter: Filter["identifier"]
onFilterSelect: (filter: Filter["identifier"]) => void
}
export default function TabFilters({
categories,
selectedFilter,
onFilterSelect,
}: FiltersProps) {
}: TabFiltersProps) {
return (
<div className={styles.container}>
{categories.map((category) => (

View File

@@ -0,0 +1,92 @@
#import "../System.graphql"
#import "./ContentCard.graphql"
#import "../PageLink/AccountPageLink.graphql"
#import "../PageLink/CollectionPageLink.graphql"
#import "../PageLink/ContentPageLink.graphql"
#import "../PageLink/DestinationCityPageLink.graphql"
#import "../PageLink/DestinationCountryPageLink.graphql"
#import "../PageLink/DestinationOverviewPageLink.graphql"
#import "../PageLink/HotelPageLink.graphql"
#import "../PageLink/LoyaltyPageLink.graphql"
#import "../PageLink/StartPageLink.graphql"
#import "../AccountPage/Ref.graphql"
#import "../ContentPage/Ref.graphql"
#import "../HotelPage/Ref.graphql"
#import "../LoyaltyPage/Ref.graphql"
#import "../CollectionPage/Ref.graphql"
#import "../DestinationCityPage/Ref.graphql"
#import "../DestinationCountryPage/Ref.graphql"
#import "../DestinationOverviewPage/Ref.graphql"
fragment CardGallery_DestinationOverviewPage on DestinationOverviewPageBlocksCardGallery {
card_gallery {
heading
card_groups {
filter_identifier
filter_label
cardConnection {
edges {
node {
...ContentCardBlock
}
}
}
}
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
}
}
}
}
}
}
fragment CardGallery_DestinationOverviewPageRefs on DestinationOverviewPageBlocksCardGallery {
card_gallery {
card_groups {
cardConnection {
edges {
node {
...ContentCardBlockRef
}
}
}
}
link {
linkConnection {
edges {
node {
__typename
...AccountPageRef
...CollectionPageRef
...ContentPageRef
...DestinationCityPageRef
...DestinationCountryPageRef
...DestinationOverviewPageRef
...HotelPageRef
...LoyaltyPageRef
}
}
}
}
}
}

View File

@@ -91,76 +91,3 @@ fragment CarouselCards_StartPageRefs on StartPageBlocksCarouselCards {
}
}
}
fragment CarouselCards_DestinationOverviewPage on DestinationOverviewPageBlocksCarouselCards {
carousel_cards {
heading
enable_filters
card_groups {
filter_identifier
filter_label
cardConnection {
edges {
node {
...ContentCardBlock
}
}
}
}
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
}
}
}
}
}
}
fragment CarouselCards_DestinationOverviewPageRefs on DestinationOverviewPageBlocksCarouselCards {
carousel_cards {
card_groups {
cardConnection {
edges {
node {
...ContentCardBlockRef
}
}
}
}
link {
linkConnection {
edges {
node {
__typename
...AccountPageRef
...CollectionPageRef
...ContentPageRef
...DestinationCityPageRef
...DestinationCountryPageRef
...DestinationOverviewPageRef
...HotelPageRef
...LoyaltyPageRef
}
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
#import "../../Fragments/System.graphql"
#import "../../Fragments/Blocks/CarouselCards.graphql"
#import "../../Fragments/Blocks/CardGallery.graphql"
query GetDestinationOverviewPage($locale: String!, $uid: String!) {
destination_overview_page(uid: $uid, locale: $locale) {
@@ -8,7 +8,7 @@ query GetDestinationOverviewPage($locale: String!, $uid: String!) {
url
blocks {
__typename
...CarouselCards_DestinationOverviewPage
...CardGallery_DestinationOverviewPage
}
system {
...System
@@ -25,7 +25,7 @@ query GetDestinationOverviewPageRefs($locale: String!, $uid: String!) {
destination_overview_page(locale: $locale, uid: $uid) {
blocks {
__typename
...CarouselCards_DestinationOverviewPageRefs
...CardGallery_DestinationOverviewPageRefs
}
system {
...System

View File

@@ -5,23 +5,23 @@ import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
import { removeMultipleSlashes } from "@/utils/url"
import {
carouselCardsRefsSchema,
carouselCardsSchema,
} from "../schemas/blocks/carouselCards"
cardGalleryRefsSchema,
cardGallerySchema,
} from "../schemas/blocks/cardGallery"
import { systemSchema } from "../schemas/system"
import { DestinationOverviewPageEnum } from "@/types/enums/destinationOverviewPage"
const destinationOverviewPageCarouselCards = z
const destinationOverviewPageCardGallery = z
.object({
__typename: z.literal(
DestinationOverviewPageEnum.ContentStack.blocks.CarouselCards
DestinationOverviewPageEnum.ContentStack.blocks.CardGallery
),
})
.merge(carouselCardsSchema)
.merge(cardGallerySchema)
export const blocksSchema = z.discriminatedUnion("__typename", [
destinationOverviewPageCarouselCards,
destinationOverviewPageCardGallery,
])
export const destinationOverviewPageSchema = z.object({
@@ -62,16 +62,16 @@ export const countryPageUrlSchema = z
)
/** REFS */
const destinationOverviewPageCarouselCardsRef = z
const destinationOverviewPageCardGalleryRef = z
.object({
__typename: z.literal(
DestinationOverviewPageEnum.ContentStack.blocks.CarouselCards
DestinationOverviewPageEnum.ContentStack.blocks.CardGallery
),
})
.merge(carouselCardsRefsSchema)
.merge(cardGalleryRefsSchema)
const blocksRefsSchema = z.discriminatedUnion("__typename", [
destinationOverviewPageCarouselCardsRef,
destinationOverviewPageCardGalleryRef,
])
export const destinationOverviewPageRefsSchema = z.object({

View File

@@ -0,0 +1,95 @@
import { z } from "zod"
import {
contentCardRefSchema,
contentCardSchema,
transformContentCard,
} from "./cards/contentCard"
import { buttonSchema } from "./utils/buttonLinkSchema"
import { linkConnectionRefsSchema } from "./utils/linkConnection"
import { BlocksEnums } from "@/types/enums/blocks"
import {
type CardGalleryFilter,
CardGalleryFilterEnum,
} from "@/types/enums/cardGallery"
export const cardGallerySchema = z.object({
typename: z
.literal(BlocksEnums.block.CardGallery)
.optional()
.default(BlocksEnums.block.CardGallery),
card_gallery: z
.object({
heading: z.string().optional(),
link: buttonSchema.optional(),
card_groups: z.array(
z.object({
filter_identifier: z.nativeEnum(CardGalleryFilterEnum),
filter_label: z.string(),
cardConnection: z.object({
edges: z.array(z.object({ node: contentCardSchema })),
}),
})
),
})
.transform((data) => {
const filterCategories = data.card_groups.reduce<
Array<{
identifier: CardGalleryFilter
label: string
}>
>((acc, group) => {
const identifier = group.filter_identifier
if (!acc.some((category) => category.identifier === identifier)) {
acc.push({
identifier,
label: group.filter_label,
})
}
return acc
}, [])
return {
heading: data.heading,
filterCategories,
cards: data.card_groups.flatMap((group) =>
group.cardConnection.edges
.map((edge) => transformContentCard(edge.node))
.filter((card): card is NonNullable<typeof card> => card !== null)
.map((card) => ({
...card,
filterId: group.filter_identifier,
}))
),
defaultFilter:
data.card_groups[0]?.filter_identifier ??
filterCategories[0]?.identifier,
link:
data.link?.href && data.link.title
? { href: data.link.href, text: data.link.title }
: undefined,
}
}),
})
export const cardGalleryRefsSchema = z.object({
typename: z
.literal(BlocksEnums.block.CardGallery)
.optional()
.default(BlocksEnums.block.CardGallery),
card_gallery: z.object({
card_groups: z.array(
z.object({
cardConnection: z.object({
edges: z.array(
z.object({
node: contentCardRefSchema,
})
),
}),
})
),
link: linkConnectionRefsSchema.optional(),
}),
})

View File

@@ -0,0 +1,3 @@
import type { CardGallery } from "@/types/trpc/routers/contentstack/blocks"
export interface CardGalleryProps extends Pick<CardGallery, "card_gallery"> {}

View File

@@ -1,17 +1,18 @@
export namespace BlocksEnums {
export const enum block {
Accordion = "Accordion",
CardGallery = "CardGallery",
CardsGrid = "CardsGrid",
CarouselCards = "CarouselCards",
Content = "Content",
DynamicContent = "DynamicContent",
FullWidthCampaign = "FullWidthCampaign",
HotelListing = "HotelListing",
JoinScandicFriends = "JoinScandicFriends",
Shortcuts = "Shortcuts",
Table = "Table",
TextCols = "TextCols",
TextContent = "TextContent",
UspGrid = "UspGrid",
HotelListing = "HotelListing",
FullWidthCampaign = "FullWidthCampaign",
CarouselCards = "CarouselCards",
JoinScandicFriends = "JoinScandicFriends",
}
}

View File

@@ -0,0 +1,10 @@
export const CardGalleryFilterEnum = {
offers: "offers",
popular_destinations: "popular_destinations",
popular_hotels: "popular_hotels",
popular_cities: "popular_cities",
spa_wellness: "spa_wellness",
} as const
export type CardGalleryFilter =
(typeof CardGalleryFilterEnum)[keyof typeof CardGalleryFilterEnum]

View File

@@ -1,7 +1,7 @@
export namespace DestinationOverviewPageEnum {
export namespace ContentStack {
export const enum blocks {
CarouselCards = "DestinationOverviewPageBlocksCarouselCards",
CardGallery = "DestinationOverviewPageBlocksCardGallery",
}
}
}

View File

@@ -1,5 +1,6 @@
import type { z } from "zod"
import type { cardGallerySchema } from "@/server/routers/contentstack/schemas/blocks/cardGallery"
import type { teaserCardBlockSchema } from "@/server/routers/contentstack/schemas/blocks/cards/teaserCard"
import type { cardsGridSchema } from "@/server/routers/contentstack/schemas/blocks/cardsGrid"
import type { carouselCardsSchema } from "@/server/routers/contentstack/schemas/blocks/carouselCards"
@@ -24,3 +25,4 @@ export interface UspGrid extends z.output<typeof uspGridSchema> {}
interface GetHotelListing extends z.output<typeof hotelListingSchema> {}
export type HotelListing = GetHotelListing["hotel_listing"]
export interface CarouselCards extends z.output<typeof carouselCardsSchema> {}
export interface CardGallery extends z.output<typeof cardGallerySchema> {}