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 (
<section className={styles.essentialsSection}>
<header className={styles.header}>
<Typography variant="Title/sm">
<Typography variant="Title/smRegular">
<h3 className={styles.heading}>{title}</h3>
</Typography>
{preamble ? (

View File

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

View File

@@ -1,4 +1,8 @@
import { Suspense } from "react"
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 Essentials from "@/components/Blocks/Essentials"
@@ -27,6 +31,15 @@ export default function Blocks({ blocks }: BlocksProps) {
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:
return null
}

View File

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