feat(SW-2278): Added hotel listing to campaign page

Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-06-19 09:36:28 +00:00
parent 105c4d9cf3
commit af92f7183c
31 changed files with 703 additions and 57 deletions

View File

@@ -0,0 +1,28 @@
"use client"
import { Typography } from "@scandic-hotels/design-system/Typography"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import HotelListingItemSkeleton from "./HotelListingItem/HotelListingItemSkeleton"
import styles from "./campaignHotelListing.module.css"
export default function CampaignHotelListingSkeleton() {
return (
<section className={styles.hotelListingSection}>
<header className={styles.header}>
<Typography variant="Title/md">
<SkeletonShimmer width="40ch" />
</Typography>
</header>
<ul className={styles.list}>
{Array.from({ length: 3 }).map((_, index) => (
<li key={index} className={styles.listItem}>
<HotelListingItemSkeleton />
</li>
))}
</ul>
</section>
)
}

View File

@@ -0,0 +1,120 @@
"use client"
import { cx } from "class-variance-authority"
import { useRef, useState } from "react"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { Button } from "@scandic-hotels/design-system/Button"
import {
MaterialIcon,
type MaterialIconProps,
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import HotelListingItem from "./HotelListingItem"
import styles from "./campaignHotelListing.module.css"
import type { HotelDataWithUrl } from "@/types/hotel"
interface CampaignHotelListingClientProps {
heading: string
hotels: HotelDataWithUrl[]
}
export default function CampaignHotelListingClient({
heading,
hotels,
}: CampaignHotelListingClientProps) {
const intl = useIntl()
const isMobile = useMediaQuery("(max-width: 767px)")
const scrollRef = useRef<HTMLElement>(null)
const initialCount = isMobile ? 3 : 6 // Initial number of hotels to show
const thresholdCount = isMobile ? 6 : 9 // This is the threshold at which we start showing the "Show More" button
const showAllThreshold = isMobile ? 9 : 18 // This is the threshold at which we show the "Show All" button
const incrementCount = isMobile ? 3 : 6 // Number of hotels to increment when the button is clicked
const [visibleCount, setVisibleCount] = useState(() =>
// Set initial visible count based on the number of hotels and the threshold
hotels.length <= thresholdCount ? hotels.length : initialCount
)
// Only show the show more/less button if the length of hotels exceeds the threshold count
const showButton = hotels.length >= thresholdCount
// Determine if we are at the stage where the user can click to show all hotels
const canShowAll =
hotels.length > visibleCount &&
(visibleCount + incrementCount > showAllThreshold ||
visibleCount + incrementCount >= hotels.length)
function handleButtonClick() {
if (visibleCount < hotels.length) {
if (canShowAll) {
setVisibleCount(hotels.length)
} else {
setVisibleCount((prev) =>
Math.min(prev + incrementCount, hotels.length)
)
}
} else {
setVisibleCount(initialCount)
if (scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
}
}
let buttonText = intl.formatMessage({
defaultMessage: "Show more",
})
let iconDirection: MaterialIconProps["icon"] = "keyboard_arrow_down"
if (visibleCount === hotels.length) {
buttonText = intl.formatMessage({
defaultMessage: "Show less",
})
iconDirection = "keyboard_arrow_up"
} else if (canShowAll) {
buttonText = intl.formatMessage({
defaultMessage: "Show all",
})
}
return (
<section className={styles.hotelListingSection} ref={scrollRef}>
<header className={styles.header}>
<Typography variant="Title/md">
<h2 className={styles.heading}>{heading}</h2>
</Typography>
</header>
<ul className={styles.list}>
{hotels.map(({ hotel, url }, index) => (
<li
key={hotel.id}
className={cx(styles.listItem, {
[styles.hidden]: index >= visibleCount,
})}
>
<HotelListingItem hotel={hotel} url={url} />
</li>
))}
</ul>
{showButton ? (
<Button
variant="Text"
color="Primary"
size="Medium"
typography="Body/Paragraph/mdBold"
onPress={handleButtonClick}
>
<MaterialIcon icon={iconDirection} color="CurrentColor" />
{buttonText}
</Button>
) : null}
</section>
)
}

View File

@@ -0,0 +1,59 @@
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./hotelListingItem.module.css"
export default function HotelListingItemSkeleton() {
return (
<article className={styles.hotelListingItem}>
<div className={styles.imageWrapper}>
<SkeletonShimmer width="100%" height="220px" />
</div>
<div className={styles.content}>
<div className={styles.intro}>
<SkeletonShimmer width="20ch" height="30px" />
<Typography variant="Title/Subtitle/md">
<SkeletonShimmer width="25ch" />
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.captions}>
<SkeletonShimmer width="10ch" />
<span>
<Divider
className={styles.divider}
variant="vertical"
color="Border/Divider/Default"
/>
</span>
<SkeletonShimmer width="20ch" />
</div>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p>
<SkeletonShimmer width="100%" />
<SkeletonShimmer width="70%" />
<SkeletonShimmer width="35%" />
</p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<ul className={styles.amenityList}>
{Array.from({ length: 5 }).map((_, index) => {
return (
<li className={styles.amenityItem} key={index}>
<SkeletonShimmer width="20px" height="20px" />
<SkeletonShimmer width="15ch" />
</li>
)
})}
</ul>
</Typography>
</div>
<div className={styles.ctaWrapper}>
<SkeletonShimmer width="100%" height="40px" />
</div>
</article>
)
}

View File

@@ -0,0 +1,64 @@
.hotelListingItem {
border-radius: var(--Corner-radius-md);
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr auto;
gap: var(--Space-x2);
height: 100%;
padding-bottom: var(--Space-x2);
}
.imageWrapper {
height: 220px;
width: 100%;
position: relative;
}
.tripAdvisor {
position: absolute;
top: var(--Space-x2);
left: var(--Space-x2);
display: flex;
align-items: center;
gap: var(--Space-x05);
background-color: var(--Surface-Primary-Default);
padding: var(--Space-x025) var(--Space-x1);
border-radius: var(--Corner-radius-sm);
color: var(--Text-Interactive-Default);
}
.content {
padding: 0 var(--Space-x2);
display: grid;
gap: var(--Space-x15);
align-content: start;
}
.intro {
display: grid;
gap: var(--Space-x05);
}
.captions {
display: flex;
column-gap: var(--Space-x1);
flex-wrap: wrap;
color: var(--Text-Tertiary);
}
.amenityList {
display: flex;
gap: var(--Space-x025) var(--Space-x1);
flex-wrap: wrap;
color: var(--Text-Secondary);
}
.amenityItem {
display: flex;
gap: var(--Space-x05);
align-items: center;
}
.ctaWrapper {
padding: 0 var(--Space-x2);
}

View File

@@ -0,0 +1,129 @@
import { useIntl } from "react-intl"
import { Divider } from "@scandic-hotels/design-system/Divider"
import HotelLogoIcon from "@scandic-hotels/design-system/Icons/HotelLogoIcon"
import TripadvisorIcon from "@scandic-hotels/design-system/Icons/TripadvisorIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import ButtonLink from "@/components/ButtonLink"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import ImageGallery from "@/components/ImageGallery"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { getSingleDecimal } from "@/utils/numberFormatting"
import styles from "./hotelListingItem.module.css"
import type { Hotel } from "@/types/hotel"
interface HotelListingItemProps {
hotel: Hotel
url: string
}
export default function HotelListingItem({
hotel,
url,
}: HotelListingItemProps) {
const intl = useIntl()
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const tripadvisorRating = hotel.ratings?.tripAdvisor.rating
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
const amenities = hotel.detailedFacilities.slice(0, 5)
const hotelDescription = hotel.hotelContent.texts.descriptions?.short
return (
<article className={styles.hotelListingItem}>
<div className={styles.imageWrapper}>
<ImageGallery
images={galleryImages}
fill
sizes="(min-width: 768px) 450px, 100vw"
title={intl.formatMessage(
{
defaultMessage: "{title} - Image gallery",
},
{ title: hotel.name }
)}
/>
{tripadvisorRating ? (
<Typography variant="Title/Overline/sm">
<div className={styles.tripAdvisor}>
<TripadvisorIcon color="CurrentColor" />
<span>{tripadvisorRating}</span>
</div>
</Typography>
) : null}
</div>
<div className={styles.content}>
<div className={styles.intro}>
<HotelLogoIcon
hotelId={hotel.operaId}
hotelType={hotel.hotelType}
height={30}
/>
<Typography variant="Title/Subtitle/md">
<h3>{hotel.name}</h3>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.captions}>
<span>{address}</span>
<span>
<Divider
className={styles.divider}
variant="vertical"
color="Border/Divider/Default"
/>
</span>
<span>
{intl.formatMessage(
{
defaultMessage: "{number} km to city center",
},
{
number: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)}
</span>
</div>
</Typography>
</div>
{hotelDescription ? (
<Typography variant="Body/Paragraph/mdRegular">
<p>{hotelDescription}</p>
</Typography>
) : null}
<Typography variant="Body/Supporting text (caption)/smRegular">
<ul className={styles.amenityList}>
{amenities.map((amenity) => {
return (
<li className={styles.amenityItem} key={amenity.id}>
<FacilityToIcon
id={amenity.id}
color="CurrentColor"
size={20}
/>
{amenity.name}
</li>
)
})}
</ul>
</Typography>
</div>
<div className={styles.ctaWrapper}>
<ButtonLink
href={url}
variant="Tertiary"
color="Primary"
size="Small"
typography="Body/Supporting text (caption)/smBold"
>
{intl.formatMessage({
defaultMessage: "See hotel details",
})}
</ButtonLink>
</div>
</article>
)
}

View File

@@ -0,0 +1,56 @@
.hotelListingSection {
--scroll-margin-top: calc(
var(--booking-widget-mobile-height) + var(--Spacing-x2)
);
display: grid;
gap: var(--Space-x3);
scroll-margin-top: var(--scroll-margin-top);
}
.heading {
color: var(--Text-Heading);
}
.list {
list-style: none;
display: grid;
gap: var(--Space-x4);
}
.listItem.hidden {
display: none;
}
@media screen and (min-width: 768px) {
.hotelListingSection {
--scroll-margin-top: calc(
var(--booking-widget-tablet-height) + var(--Spacing-x2)
);
gap: var(--Space-x5);
}
.list {
row-gap: var(--Space-x5);
column-gap: var(--Space-x2);
}
}
@media screen and (min-width: 768px) and (max-width: 949px) {
.list {
grid-template-columns: repeat(2, 1fr);
}
}
@media screen and (min-width: 950px) {
.list {
grid-template-columns: repeat(3, 1fr);
}
}
@media screen and (min-width: 1367px) {
.hotelListingSection {
--scroll-margin-top: calc(
var(--booking-widget-desktop-height) + var(--Spacing-x2)
);
}
}

View File

@@ -0,0 +1,21 @@
import { getHotelsByCSFilter } from "@/lib/trpc/memoizedRequests"
import CampaignHotelListingClient from "./Client"
interface CampaignHotelListingProps {
heading: string
hotelIds: string[]
}
export default async function CampaignHotelListing({
heading,
hotelIds,
}: CampaignHotelListingProps) {
const hotels = await getHotelsByCSFilter({ hotelsToInclude: hotelIds })
if (!hotels.length) {
return null
}
return <CampaignHotelListingClient heading={heading} hotels={hotels} />
}

View File

@@ -18,7 +18,7 @@ export default async function Essentials({ content }: EssentialsProps) {
return ( return (
<section className={styles.essentialsSection}> <section className={styles.essentialsSection}>
<header className={styles.header}> <header className={styles.header}>
<Typography variant="Title/sm"> <Typography variant="Title/smRegular">
<h3 className={styles.heading}>{title}</h3> <h3 className={styles.heading}>{title}</h3>
</Typography> </Typography>
{preamble ? ( {preamble ? (

View File

@@ -66,7 +66,7 @@ export default function Blocks({ blocks }: BlocksProps) {
key={`${block.card_gallery.heading}-${idx}`} key={`${block.card_gallery.heading}-${idx}`}
/> />
) )
case BlocksEnums.block.HotelListing: case BlocksEnums.block.ContentPageHotelListing:
const { heading, contentType, locationFilter, hotelsToInclude } = const { heading, contentType, locationFilter, hotelsToInclude } =
block.hotel_listing block.hotel_listing
if (!locationFilter && !hotelsToInclude.length) { if (!locationFilter && !hotelsToInclude.length) {

View File

@@ -1,4 +1,8 @@
import { Suspense } from "react"
import AccordionSection from "@/components/Blocks/Accordion" import AccordionSection from "@/components/Blocks/Accordion"
import CampaignHotelListing from "@/components/Blocks/CampaignHotelListing"
import CampaignHotelListingSkeleton from "@/components/Blocks/CampaignHotelListing/CampaignHotelListingSkeleton"
import CarouselCards from "@/components/Blocks/CarouselCards" import CarouselCards from "@/components/Blocks/CarouselCards"
import Essentials from "@/components/Blocks/Essentials" import Essentials from "@/components/Blocks/Essentials"
@@ -27,6 +31,15 @@ export default function Blocks({ blocks }: BlocksProps) {
key={block.accordion.title} key={block.accordion.title}
/> />
) )
case BlocksEnums.block.CampaignPageHotelListing:
return (
<Suspense fallback={<CampaignHotelListingSkeleton />}>
<CampaignHotelListing
heading={block.hotel_listing.heading}
hotelIds={block.hotel_listing.hotelIds}
/>
</Suspense>
)
default: default:
return null return null
} }

View File

@@ -27,8 +27,8 @@
.tripAdvisor { .tripAdvisor {
position: absolute; position: absolute;
top: 16px; top: var(--Space-x2);
left: 16px; left: var(--Space-x2);
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--Space-x05); gap: var(--Space-x05);

View File

@@ -22,3 +22,10 @@ fragment HotelListing_ContentPage on ContentPageBlocksHotelListing {
...HotelListing ...HotelListing
} }
} }
fragment HotelListing_CampaignPage on CampaignPageBlocksHotelListing {
hotel_listing {
heading
}
}

View File

@@ -0,0 +1,45 @@
fragment CampaignPageIncludedHotels on CampaignPageIncludedHotels {
list_1Connection {
edges {
node {
... on HotelPage {
hotel_page_id
}
}
}
}
list_2Connection {
edges {
node {
... on HotelPage {
hotel_page_id
}
}
}
}
}
fragment CampaignPageIncludedHotelsRef on CampaignPageIncludedHotels {
list_1Connection {
edges {
node {
... on HotelPage {
system {
...System
}
}
}
}
}
list_2Connection {
edges {
node {
... on HotelPage {
system {
...System
}
}
}
}
}
}

View File

@@ -1,9 +1,12 @@
#import "../../Fragments/System.graphql" #import "../../Fragments/System.graphql"
#import "../../Fragments/CampaignPage/IncludedHotels.graphql"
#import "../../Fragments/CampaignPage/Hero.graphql"
#import "../../Fragments/Blocks/Accordion.graphql" #import "../../Fragments/Blocks/Accordion.graphql"
#import "../../Fragments/Blocks/Essentials.graphql" #import "../../Fragments/Blocks/Essentials.graphql"
#import "../../Fragments/Blocks/CarouselCards.graphql" #import "../../Fragments/Blocks/CarouselCards.graphql"
#import "../../Fragments/CampaignPage/Hero.graphql" #import "../../Fragments/Blocks/HotelListing.graphql"
query GetCampaignPage($locale: String!, $uid: String!) { query GetCampaignPage($locale: String!, $uid: String!) {
campaign_page(uid: $uid, locale: $locale) { campaign_page(uid: $uid, locale: $locale) {
@@ -16,11 +19,15 @@ query GetCampaignPage($locale: String!, $uid: String!) {
first_column first_column
second_column second_column
} }
included_hotels {
...CampaignPageIncludedHotels
}
blocks { blocks {
__typename __typename
...Essentials_CampaignPage ...Essentials_CampaignPage
...CarouselCards_CampaignPage ...CarouselCards_CampaignPage
...Accordion_CampaignPage ...Accordion_CampaignPage
...HotelListing_CampaignPage
} }
system { system {
...System ...System
@@ -36,6 +43,9 @@ query GetCampaignPage($locale: String!, $uid: String!) {
query GetCampaignPageRefs($locale: String!, $uid: String!) { query GetCampaignPageRefs($locale: String!, $uid: String!) {
campaign_page(locale: $locale, uid: $uid) { campaign_page(locale: $locale, uid: $uid) {
included_hotels {
...CampaignPageIncludedHotelsRef
}
blocks { blocks {
__typename __typename
...CarouselCards_CampaignPageRefs ...CarouselCards_CampaignPageRefs

View File

@@ -11,6 +11,7 @@ import {
carouselCardsSchema, carouselCardsSchema,
} from "../schemas/blocks/carouselCards" } from "../schemas/blocks/carouselCards"
import { essentialsBlockSchema } from "../schemas/blocks/essentials" import { essentialsBlockSchema } from "../schemas/blocks/essentials"
import { campaignPageHotelListingSchema } from "../schemas/blocks/hotelListing"
import { tempImageVaultAssetSchema } from "../schemas/imageVault" import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import { import {
linkConnectionRefs, linkConnectionRefs,
@@ -38,10 +39,17 @@ export const campaignPageAccordion = z
}) })
.merge(accordionSchema) .merge(accordionSchema)
export const campaignPageHotelListing = z
.object({
__typename: z.literal(CampaignPageEnum.ContentStack.blocks.HotelListing),
})
.merge(campaignPageHotelListingSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [ export const blocksSchema = z.discriminatedUnion("__typename", [
campaignPageEssentials, campaignPageEssentials,
campaignPageCarouselCards, campaignPageCarouselCards,
campaignPageAccordion, campaignPageAccordion,
campaignPageHotelListing,
]) ])
export const heroSchema = z.object({ export const heroSchema = z.object({
@@ -58,30 +66,88 @@ export const heroSchema = z.object({
button: z.intersection(z.object({ cta: z.string() }), linkConnectionSchema), button: z.intersection(z.object({ cta: z.string() }), linkConnectionSchema),
}) })
export const campaignPageSchema = z.object({ const includedHotelsSchema = z
campaign_page: z.object({ .object({
title: z.string(), list_1Connection: z.object({
campaign_identifier: z.string().nullish(), edges: z.array(
hero: heroSchema, z.object({
heading: z.string(), node: z.object({
subheading: z.string().nullish(), hotel_page_id: z.string(),
preamble: z.object({ }),
is_two_columns: z.boolean().default(false), })
first_column: z.string(), ),
second_column: z.string(),
}), }),
blocks: discriminatedUnionArray(blocksSchema.options), list_2Connection: z.object({
system: systemSchema.merge( edges: z.array(
z.object({ z.object({
created_at: z.string(), node: z.object({
updated_at: z.string(), hotel_page_id: z.string(),
}) }),
), })
}), ),
trackingProps: z.object({ }),
url: z.string(), })
}), .transform((data) => {
}) const list1HotelIds = data.list_1Connection.edges
.map((edge) => edge.node.hotel_page_id)
.filter(Boolean)
const list2HotelIds = data.list_2Connection.edges
.map((edge) => edge.node.hotel_page_id)
.filter(Boolean)
return [...new Set([...list1HotelIds, ...list2HotelIds])]
})
export const campaignPageSchema = z
.object({
campaign_page: z.object({
title: z.string(),
campaign_identifier: z.string().nullish(),
hero: heroSchema,
heading: z.string(),
subheading: z.string().nullish(),
included_hotels: includedHotelsSchema,
preamble: z.object({
is_two_columns: z.boolean().default(false),
first_column: z.string(),
second_column: z.string(),
}),
blocks: discriminatedUnionArray(blocksSchema.options),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
}),
trackingProps: z.object({
url: z.string(),
}),
})
.transform((data) => {
const blocks = data.campaign_page.blocks.map((block) => {
if (
block.__typename === CampaignPageEnum.ContentStack.blocks.HotelListing
) {
return {
...block,
hotel_listing: {
...block.hotel_listing,
hotelIds: data.campaign_page.included_hotels,
},
}
}
return block
})
return {
...data,
campaign_page: {
...data.campaign_page,
blocks: [...blocks],
},
}
})
/** REFS */ /** REFS */
const campaignPageCarouselCardsRef = z const campaignPageCarouselCardsRef = z

View File

@@ -18,7 +18,7 @@ import {
dynamicContentRefsSchema, dynamicContentRefsSchema,
dynamicContentSchema as blockDynamicContentSchema, dynamicContentSchema as blockDynamicContentSchema,
} from "../schemas/blocks/dynamicContent" } from "../schemas/blocks/dynamicContent"
import { hotelListingSchema } from "../schemas/blocks/hotelListing" import { contentPageHotelListingSchema } from "../schemas/blocks/hotelListing"
import { import {
shortcutsRefsSchema, shortcutsRefsSchema,
shortcutsSchema, shortcutsSchema,
@@ -113,7 +113,7 @@ export const contentPageHotelListing = z
.object({ .object({
__typename: z.literal(ContentPageEnum.ContentStack.blocks.HotelListing), __typename: z.literal(ContentPageEnum.ContentStack.blocks.HotelListing),
}) })
.merge(hotelListingSchema) .merge(contentPageHotelListingSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [ export const blocksSchema = z.discriminatedUnion("__typename", [
contentPageAccordion, contentPageAccordion,

View File

@@ -36,10 +36,10 @@ export const locationFilterSchema = z
} }
}) })
export const hotelListingSchema = z.object({ export const contentPageHotelListingSchema = z.object({
typename: z typename: z
.literal(BlocksEnums.block.HotelListing) .literal(BlocksEnums.block.ContentPageHotelListing)
.default(BlocksEnums.block.HotelListing), .default(BlocksEnums.block.ContentPageHotelListing),
hotel_listing: z hotel_listing: z
.object({ .object({
heading: z.string().optional(), heading: z.string().optional(),
@@ -60,3 +60,12 @@ export const hotelListingSchema = z.object({
} }
}), }),
}) })
export const campaignPageHotelListingSchema = z.object({
typename: z
.literal(BlocksEnums.block.CampaignPageHotelListing)
.default(BlocksEnums.block.CampaignPageHotelListing),
hotel_listing: z.object({
heading: z.string(),
}),
})

View File

@@ -237,7 +237,7 @@ export const getHotelsByCSFilterInput = z.object({
country: z.nativeEnum(Country).nullable(), country: z.nativeEnum(Country).nullable(),
excluded: z.array(z.string()), excluded: z.array(z.string()),
}) })
.nullable(), .nullish(),
hotelsToInclude: z.array(z.string()), hotelsToInclude: z.array(z.string()),
}) })
export interface GetHotelsByCSFilterInput export interface GetHotelsByCSFilterInput

View File

@@ -7,7 +7,8 @@ export namespace BlocksEnums {
Content = "Content", Content = "Content",
DynamicContent = "DynamicContent", DynamicContent = "DynamicContent",
FullWidthCampaign = "FullWidthCampaign", FullWidthCampaign = "FullWidthCampaign",
HotelListing = "HotelListing", CampaignPageHotelListing = "CampaignPageHotelListing",
ContentPageHotelListing = "ContentPageHotelListing",
JoinScandicFriends = "JoinScandicFriends", JoinScandicFriends = "JoinScandicFriends",
Shortcuts = "Shortcuts", Shortcuts = "Shortcuts",
Table = "Table", Table = "Table",

View File

@@ -4,6 +4,7 @@ export namespace CampaignPageEnum {
Essentials = "CampaignPageBlocksEssentials", Essentials = "CampaignPageBlocksEssentials",
CarouselCards = "CampaignPageBlocksCarouselCards", CarouselCards = "CampaignPageBlocksCarouselCards",
Accordion = "CampaignPageBlocksAccordion", Accordion = "CampaignPageBlocksAccordion",
HotelListing = "CampaignPageBlocksHotelListing",
} }
} }
} }

View File

@@ -6,7 +6,7 @@ import type { cardsGridSchema } from "@/server/routers/contentstack/schemas/bloc
import type { carouselCardsSchema } from "@/server/routers/contentstack/schemas/blocks/carouselCards" import type { carouselCardsSchema } from "@/server/routers/contentstack/schemas/blocks/carouselCards"
import type { contentSchema } from "@/server/routers/contentstack/schemas/blocks/content" import type { contentSchema } from "@/server/routers/contentstack/schemas/blocks/content"
import type { dynamicContentSchema } from "@/server/routers/contentstack/schemas/blocks/dynamicContent" import type { dynamicContentSchema } from "@/server/routers/contentstack/schemas/blocks/dynamicContent"
import type { hotelListingSchema } from "@/server/routers/contentstack/schemas/blocks/hotelListing" import type { contentPageHotelListingSchema } from "@/server/routers/contentstack/schemas/blocks/hotelListing"
import type { shortcutsSchema } from "@/server/routers/contentstack/schemas/blocks/shortcuts" import type { shortcutsSchema } from "@/server/routers/contentstack/schemas/blocks/shortcuts"
import type { tableSchema } from "@/server/routers/contentstack/schemas/blocks/table" import type { tableSchema } from "@/server/routers/contentstack/schemas/blocks/table"
import type { textColsSchema } from "@/server/routers/contentstack/schemas/blocks/textCols" import type { textColsSchema } from "@/server/routers/contentstack/schemas/blocks/textCols"
@@ -22,7 +22,8 @@ export interface TableBlock extends z.output<typeof tableSchema> {}
export type TableData = TableBlock["table"] export type TableData = TableBlock["table"]
export interface TextCols extends z.output<typeof textColsSchema> {} export interface TextCols extends z.output<typeof textColsSchema> {}
export interface UspGrid extends z.output<typeof uspGridSchema> {} export interface UspGrid extends z.output<typeof uspGridSchema> {}
interface GetHotelListing extends z.output<typeof hotelListingSchema> {} interface GetHotelListing
extends z.output<typeof contentPageHotelListingSchema> {}
export type HotelListing = GetHotelListing["hotel_listing"] export type HotelListing = GetHotelListing["hotel_listing"]
export interface CarouselCards extends z.output<typeof carouselCardsSchema> {} export interface CarouselCards extends z.output<typeof carouselCardsSchema> {}
export interface CardGallery extends z.output<typeof cardGallerySchema> {} export interface CardGallery extends z.output<typeof cardGallerySchema> {}

View File

@@ -1,7 +1,6 @@
import type { z } from "zod" import type { z } from "zod"
import type { import type {
blocksSchema,
campaignPageRefsSchema, campaignPageRefsSchema,
campaignPageSchema, campaignPageSchema,
heroSchema, heroSchema,
@@ -19,7 +18,7 @@ export interface GetCampaignPageRefsData
export interface CampaignPageRefs export interface CampaignPageRefs
extends z.output<typeof campaignPageRefsSchema> {} extends z.output<typeof campaignPageRefsSchema> {}
export type Block = z.output<typeof blocksSchema> export type Block = CampaignPageData["blocks"][number]
export type EssentialsBlock = z.output<typeof essentialsSchema>["essentials"] export type EssentialsBlock = z.output<typeof essentialsSchema>["essentials"]

View File

@@ -4,6 +4,7 @@ import { iconVariants } from '../variants'
export default function DowntownCamperIcon({ export default function DowntownCamperIcon({
className, className,
color, color,
height = 30,
...props ...props
}: LogoProps) { }: LogoProps) {
const classNames = iconVariants({ className, color }) const classNames = iconVariants({ className, color })
@@ -11,7 +12,7 @@ export default function DowntownCamperIcon({
<svg <svg
className={classNames} className={classNames}
width="123" width="123"
height="30" height={height}
viewBox="0 0 123 30" viewBox="0 0 123 30"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -5,6 +5,7 @@ import type { LogoProps } from '../icon'
export default function GrandHotelOsloLogoIcon({ export default function GrandHotelOsloLogoIcon({
className, className,
color, color,
height = 30,
...props ...props
}: LogoProps) { }: LogoProps) {
const classNames = iconVariants({ className, color }) const classNames = iconVariants({ className, color })
@@ -12,7 +13,7 @@ export default function GrandHotelOsloLogoIcon({
<svg <svg
className={classNames} className={classNames}
width="69" width="69"
height="30" height={height}
viewBox="0 0 69 30" viewBox="0 0 69 30"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -5,6 +5,7 @@ import type { LogoProps } from '../icon'
export default function HaymarketIcon({ export default function HaymarketIcon({
className, className,
color, color,
height = 26,
...props ...props
}: LogoProps) { }: LogoProps) {
const classNames = iconVariants({ className, color }) const classNames = iconVariants({ className, color })
@@ -12,7 +13,7 @@ export default function HaymarketIcon({
<svg <svg
className={classNames} className={classNames}
width="100" width="100"
height="26" height={height}
viewBox="0 0 100 26" viewBox="0 0 100 26"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -5,6 +5,7 @@ import type { LogoProps } from '../icon'
export default function HotelNorgeIcon({ export default function HotelNorgeIcon({
className, className,
color, color,
height = 24,
...props ...props
}: LogoProps) { }: LogoProps) {
const classNames = iconVariants({ className, color }) const classNames = iconVariants({ className, color })
@@ -12,7 +13,7 @@ export default function HotelNorgeIcon({
<svg <svg
className={classNames} className={classNames}
width="117" width="117"
height="24" height={height}
viewBox="0 0 117 24" viewBox="0 0 117 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -5,6 +5,7 @@ import type { LogoProps } from '../icon'
export default function MarskiLogoIcon({ export default function MarskiLogoIcon({
className, className,
color, color,
height = 30,
...props ...props
}: LogoProps) { }: LogoProps) {
const classNames = iconVariants({ className, color }) const classNames = iconVariants({ className, color })
@@ -12,7 +13,7 @@ export default function MarskiLogoIcon({
<svg <svg
className={classNames} className={classNames}
width="92" width="92"
height="30" height={height}
viewBox="0 0 92 30" viewBox="0 0 92 30"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -5,6 +5,7 @@ import type { LogoProps } from '../icon'
export default function ScandicGoLogoIcon({ export default function ScandicGoLogoIcon({
className, className,
color, color,
height = 18,
...props ...props
}: LogoProps) { }: LogoProps) {
const classNames = iconVariants({ className, color }) const classNames = iconVariants({ className, color })
@@ -12,7 +13,7 @@ export default function ScandicGoLogoIcon({
<svg <svg
className={classNames} className={classNames}
width="90" width="90"
height="18" height={height}
viewBox="0 0 90 18" viewBox="0 0 90 18"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -5,6 +5,7 @@ import type { LogoProps } from '../icon'
export default function ScandicLogoIcon({ export default function ScandicLogoIcon({
className, className,
color, color,
height = 14,
...props ...props
}: LogoProps) { }: LogoProps) {
const classNames = iconVariants({ className, color }) const classNames = iconVariants({ className, color })
@@ -12,7 +13,7 @@ export default function ScandicLogoIcon({
<svg <svg
className={classNames} className={classNames}
width="58" width="58"
height="14" height={height}
viewBox="0 0 58 14" viewBox="0 0 58 14"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,14 +1,19 @@
import type { LogoProps } from '../icon' import type { LogoProps } from '../icon'
import { iconVariants } from '../variants' import { iconVariants } from '../variants'
export default function TheDockIcon({ className, color, ...props }: LogoProps) { export default function TheDockIcon({
className,
color,
height = 30,
...props
}: LogoProps) {
const classNames = iconVariants({ className, color }) const classNames = iconVariants({ className, color })
return ( return (
<svg <svg
className={classNames} className={classNames}
width="128" width="128"
height="30" height={height}
viewBox="0 0 128 30" viewBox="0 0 128 30"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -10,6 +10,7 @@ import TheDockIcon from './TheDock'
type HotelLogoProps = { type HotelLogoProps = {
hotelId: string hotelId: string
hotelType: string hotelType: string
height?: number
} }
enum HotelTypeEnum { enum HotelTypeEnum {
@@ -27,25 +28,29 @@ enum SignatureHotelEnum {
TheDock = '796', TheDock = '796',
} }
export default function HotelLogoIcon({ hotelId, hotelType }: HotelLogoProps) { export default function HotelLogoIcon({
hotelId,
hotelType,
height,
}: HotelLogoProps) {
if (hotelType === HotelTypeEnum.ScandicGo) { if (hotelType === HotelTypeEnum.ScandicGo) {
return <ScandicGoLogoIcon /> return <ScandicGoLogoIcon height={height} />
} }
switch (hotelId) { switch (hotelId) {
case SignatureHotelEnum.Haymarket: case SignatureHotelEnum.Haymarket:
return <HaymarketIcon /> return <HaymarketIcon height={height} />
case SignatureHotelEnum.HotelNorge: case SignatureHotelEnum.HotelNorge:
return <HotelNorgeIcon /> return <HotelNorgeIcon height={height} />
case SignatureHotelEnum.DowntownCamper: case SignatureHotelEnum.DowntownCamper:
return <DowntownCamperIcon /> return <DowntownCamperIcon height={height} />
case SignatureHotelEnum.GrandHotelOslo: case SignatureHotelEnum.GrandHotelOslo:
return <GrandHotelOsloLogoIcon /> return <GrandHotelOsloLogoIcon height={height} />
case SignatureHotelEnum.Marski: case SignatureHotelEnum.Marski:
return <MarskiLogoIcon /> return <MarskiLogoIcon height={height} />
case SignatureHotelEnum.TheDock: case SignatureHotelEnum.TheDock:
return <TheDockIcon /> return <TheDockIcon height={height} />
default: default:
return <ScandicLogoIcon color="Icon/Interactive/Accent" /> return <ScandicLogoIcon height={height} color="Icon/Interactive/Accent" />
} }
} }