Feat/SW-2271 hotel list filtering

* feat(SW-2271): Changes to hotel data types in preperation for filtering
* feat(SW-2271): Added filter and sort functionality

Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-07-04 09:27:20 +00:00
parent 82e21af0d4
commit fa7214cb58
58 changed files with 1572 additions and 450 deletions

View File

@@ -12,57 +12,64 @@ import {
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useHotelListingDataStore } from "@/stores/hotel-listing-data"
import CampaignHotelListingSkeleton from "@/components/Blocks/CampaignHotelListing/CampaignHotelListingSkeleton"
import HotelFilterAndSort from "@/components/HotelFilterAndSort"
import HotelListingItem from "./HotelListingItem"
import styles from "./campaignHotelListing.module.css"
import type { HotelDataWithUrl } from "@scandic-hotels/trpc/types/hotel"
interface CampaignHotelListingClientProps {
heading: string
preamble?: string | null
hotels: HotelDataWithUrl[]
visibleCountMobile?: 3 | 6
visibleCountDesktop?: 3 | 6
visibleCountMobile: 3 | 6
visibleCountDesktop: 3 | 6
isMainBlock: boolean
}
export default function CampaignHotelListingClient({
heading,
preamble,
hotels,
visibleCountMobile = 3,
visibleCountDesktop = 6,
visibleCountMobile,
visibleCountDesktop,
isMainBlock,
}: CampaignHotelListingClientProps) {
const intl = useIntl()
const isMobile = useMediaQuery("(max-width: 767px)")
const scrollRef = useRef<HTMLElement>(null)
const { activeHotels, isLoading } = useHotelListingDataStore((state) => ({
activeHotels: state.activeHotels,
isLoading: state.isLoading,
}))
const initialCount = isMobile ? visibleCountMobile : visibleCountDesktop // Initial number of hotels to show
const initialCount = isMobile ? visibleCountMobile : visibleCountDesktop // Initial number of activeHotels to show
const thresholdCount = initialCount + 3 // This is the threshold at which we start showing the "Show More" button
const showAllThreshold = initialCount * 3 // This is the threshold at which we show the "Show All" button
const incrementCount = initialCount // Number of hotels to increment when the button is clicked
const incrementCount = initialCount // Number of activeHotels 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
// Set initial visible count based on the number of activeHotels and the threshold
activeHotels.length <= thresholdCount ? activeHotels.length : initialCount
)
// Only show the show more/less button if the length of hotels exceeds the threshold count
const showButton = hotels.length > thresholdCount
// Only show the show more/less button if the length of activeHotels exceeds the threshold count
const showButton = activeHotels.length > thresholdCount
// Determine if we are at the stage where the user can click to show all hotels
// Determine if we are at the stage where the user can click to show all activeHotels
const canShowAll =
hotels.length > visibleCount &&
activeHotels.length > visibleCount &&
(visibleCount + incrementCount > showAllThreshold ||
visibleCount + incrementCount >= hotels.length)
visibleCount + incrementCount >= activeHotels.length)
function handleButtonClick() {
if (visibleCount < hotels.length) {
if (visibleCount < activeHotels.length) {
if (canShowAll) {
setVisibleCount(hotels.length)
setVisibleCount(activeHotels.length)
} else {
setVisibleCount((prev) =>
Math.min(prev + incrementCount, hotels.length)
Math.min(prev + incrementCount, activeHotels.length)
)
}
} else {
@@ -78,7 +85,7 @@ export default function CampaignHotelListingClient({
})
let iconDirection: MaterialIconProps["icon"] = "keyboard_arrow_down"
if (visibleCount === hotels.length) {
if (visibleCount === activeHotels.length) {
buttonText = intl.formatMessage({
defaultMessage: "Show less",
})
@@ -89,20 +96,30 @@ export default function CampaignHotelListingClient({
})
}
if (isLoading) {
return <CampaignHotelListingSkeleton />
}
return (
<section className={styles.hotelListingSection} ref={scrollRef}>
<section
className={cx(styles.hotelListingSection, {
[styles.isMainBlock]: isMainBlock,
})}
ref={scrollRef}
>
<header className={styles.header}>
<Typography variant="Title/Subtitle/lg">
<h3>{heading}</h3>
<Typography variant={isMainBlock ? "Title/md" : "Title/Subtitle/lg"}>
<h3 className={styles.heading}>{heading}</h3>
</Typography>
{isMainBlock ? <HotelFilterAndSort /> : null}
{preamble ? (
<Typography variant="Body/Paragraph/mdRegular">
<p>{preamble}</p>
<p className={styles.preamble}>{preamble}</p>
</Typography>
) : null}
</header>
<ul className={styles.list}>
{hotels.map(({ hotel, url }, index) => (
{activeHotels.map(({ hotel, url }, index) => (
<li
key={hotel.id}
className={cx(styles.listItem, {

View File

@@ -13,11 +13,11 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
import styles from "./hotelListingItem.module.css"
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
interface HotelListingItemProps {
hotel: Hotel
url: string
hotel: HotelListingHotelData["hotel"]
url: string | null
}
export default function HotelListingItem({
@@ -25,11 +25,11 @@ export default function HotelListingItem({
url,
}: HotelListingItemProps) {
const intl = useIntl()
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const tripadvisorRating = hotel.ratings?.tripAdvisor.rating
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages)
const tripadvisorRating = hotel.tripadvisor
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
const amenities = hotel.detailedFacilities.slice(0, 5)
const hotelDescription = hotel.hotelContent.texts.descriptions?.short
const hotelDescription = hotel.description
return (
<article className={styles.hotelListingItem}>
@@ -57,7 +57,7 @@ export default function HotelListingItem({
<div className={styles.content}>
<div className={styles.intro}>
<HotelLogoIcon
hotelId={hotel.operaId}
hotelId={hotel.id}
hotelType={hotel.hotelType}
height={30}
/>
@@ -111,19 +111,21 @@ export default function HotelListingItem({
</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>
{url ? (
<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>
) : null}
</article>
)
}

View File

@@ -0,0 +1,41 @@
"use client"
import { useParams } from "next/navigation"
import { useEffect } from "react"
import { useDestinationDataStore } from "@/stores/destination-data"
export default function DestinationDataProviderContent({
children,
}: React.PropsWithChildren) {
const params = useParams()
const { basePath, updateActiveFiltersAndSort } = useDestinationDataStore(
(state) => ({
basePath: state.basePathnameWithoutFilters,
updateActiveFiltersAndSort: state.actions.updateActiveFiltersAndSort,
})
)
useEffect(() => {
const currentUrl = new URL(window.location.href)
const searchParams = currentUrl.searchParams
const currentPathname = currentUrl.pathname
const currentHash = currentUrl.hash
const sort = searchParams.get("sort")
const filters = []
const pathParts = currentPathname.split("/")
const lastPathPart = pathParts[pathParts.length - 1]
if (basePath !== currentPathname) {
filters.push(lastPathPart)
}
if (currentHash) {
const hashValue = currentHash.substring(1)
filters.push(...hashValue.split("&"))
}
updateActiveFiltersAndSort(filters, sort)
}, [params, updateActiveFiltersAndSort, basePath])
return <>{children}</>
}

View File

@@ -0,0 +1,45 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useRef } from "react"
import { createDestinationDataStore } from "@/stores/destination-data"
import { DestinationDataContext } from "@/contexts/DestinationData"
import DestinationDataProviderContent from "./Content"
import type { DestinationDataStore } from "@/types/contexts/destination-data"
import type { DestinationDataProviderProps } from "@/types/providers/destination-data"
export default function DestinationDataProvider({
allCities = [],
allHotels,
allFilters,
filterFromUrl,
sortItems,
pathname,
children,
}: DestinationDataProviderProps) {
const storeRef = useRef<DestinationDataStore>(undefined)
const searchParams = useSearchParams()
if (!storeRef.current) {
storeRef.current = createDestinationDataStore({
allCities,
allHotels,
allFilters,
filterFromUrl,
pathname,
sortItems,
searchParams,
})
}
return (
<DestinationDataContext.Provider value={storeRef.current}>
<DestinationDataProviderContent>
{children}
</DestinationDataProviderContent>
</DestinationDataContext.Provider>
)
}

View File

@@ -6,13 +6,22 @@
display: grid;
gap: var(--Space-x3);
scroll-margin-top: var(--scroll-margin-top);
&.isMainBlock .heading {
color: var(--Text-Heading);
}
}
.header {
display: grid;
grid-template-columns: 1fr max-content;
gap: var(--Space-x15);
}
.preamble {
grid-column: span 2;
}
.list {
list-style: none;
display: grid;
@@ -28,6 +37,10 @@
--scroll-margin-top: calc(
var(--booking-widget-tablet-height) + var(--Spacing-x2)
);
&.isMainBlock {
gap: var(--Space-x5);
}
}
.list {
row-gap: var(--Space-x5);

View File

@@ -1,5 +1,18 @@
import { Suspense } from "react"
import {
type HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
import { getHotelsByCSFilter } from "@/lib/trpc/memoizedRequests"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import HotelListingDataProvider from "@/providers/HotelListingDataProvider"
import CampaignHotelListingSkeleton from "./CampaignHotelListingSkeleton"
import CampaignHotelListingClient from "./Client"
interface CampaignHotelListingProps {
@@ -8,28 +21,56 @@ interface CampaignHotelListingProps {
hotelIds: string[]
visibleCountMobile?: 3 | 6
visibleCountDesktop?: 3 | 6
isMainBlock?: boolean
}
export default async function CampaignHotelListing({
heading,
preamble,
hotelIds,
visibleCountMobile,
visibleCountDesktop,
visibleCountMobile = 3,
visibleCountDesktop = 6,
isMainBlock = false,
}: CampaignHotelListingProps) {
const intl = await getIntl()
const lang = await getLang()
const hotels = await getHotelsByCSFilter({ hotelsToInclude: hotelIds })
if (!hotels.length) {
return null
}
const allFilters = getFiltersFromHotels(hotels, lang)
const sortItems: HotelSortItem[] = [
{
label: intl.formatMessage({
defaultMessage: "Name",
}),
value: HotelSortOption.Name,
},
{
label: intl.formatMessage({
defaultMessage: "TripAdvisor rating",
}),
value: HotelSortOption.TripAdvisorRating,
},
]
return (
<CampaignHotelListingClient
heading={heading}
preamble={preamble}
hotels={hotels}
visibleCountMobile={visibleCountMobile}
visibleCountDesktop={visibleCountDesktop}
/>
<Suspense fallback={<CampaignHotelListingSkeleton />}>
<HotelListingDataProvider
allHotels={hotels}
allFilters={allFilters}
sortItems={sortItems}
>
<CampaignHotelListingClient
heading={heading}
preamble={preamble}
visibleCountMobile={visibleCountMobile}
visibleCountDesktop={visibleCountDesktop}
isMainBlock={isMainBlock}
/>
</HotelListingDataProvider>
</Suspense>
)
}

View File

@@ -7,32 +7,42 @@ import Image from "@/components/Image"
import { getIntl } from "@/i18n"
import { getSingleDecimal } from "@/utils/numberFormatting"
import { getTypeSpecificInformation } from "./utils"
import styles from "./hotelListingItem.module.css"
import type { HotelListingItemProps } from "@/types/components/contentPage/hotelListingItem"
export default async function HotelListingItem({
hotel,
additionalData,
hotelData,
contentType = "hotel",
url,
}: HotelListingItemProps) {
const intl = await getIntl()
const { description, image, cta } = getTypeSpecificInformation(
intl,
contentType,
hotel.hotelContent,
additionalData,
url
)
const { galleryImages, description, id, name, hotelType, location, address } =
hotelData.hotel
const image = galleryImages[0]
const cta =
contentType === "meeting" && hotelData.meetingUrl
? {
url: hotelData.meetingUrl,
openInNewTab: true,
text: intl.formatMessage({
defaultMessage: "Book a meeting",
}),
}
: {
url: hotelData.url,
openInNewTab: false,
text: intl.formatMessage({
defaultMessage: "See hotel details",
}),
}
return (
<article className={styles.container}>
<Image
src={image.src}
alt={image.alt}
src={image.imageSizes.large}
alt={image.metaData.altText || image.metaData.altText_En}
width={400}
height={300}
sizes="(min-width: 768px) 400px, 100vw"
@@ -40,13 +50,13 @@ export default async function HotelListingItem({
/>
<section className={styles.content}>
<div className={styles.intro}>
<HotelLogoIcon hotelId={hotel.operaId} hotelType={hotel.hotelType} />
<HotelLogoIcon hotelId={id} hotelType={hotelType} />
<Typography variant="Title/Subtitle/lg">
<h4>{hotel.name}</h4>
<h4>{name}</h4>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.captions}>
<span>{hotel.address.streetAddress}</span>
<span>{address.streetAddress}</span>
<Divider variant="vertical" />
<span>
{intl.formatMessage(
@@ -54,9 +64,7 @@ export default async function HotelListingItem({
defaultMessage: "{number} km to city center",
},
{
number: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
number: getSingleDecimal(location.distanceToCentre / 1000),
}
)}
</span>
@@ -77,7 +85,7 @@ export default async function HotelListingItem({
variant="Primary"
size="Small"
href={cta.url}
target={cta.openInNewTab ? "_blank" : "_self"}
target={cta.openInNewTab ? "_blank" : undefined}
className={styles.button}
>
{cta.text}

View File

@@ -1,71 +0,0 @@
import type { AdditionalData, Hotel } from "@scandic-hotels/trpc/types/hotel"
import type { IntlShape } from "react-intl"
import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks"
export function getTypeSpecificInformation(
intl: IntlShape,
contentType: HotelListing["contentType"],
hotelContent: Hotel["hotelContent"],
additionalData: AdditionalData,
url: string | null
) {
const { images, texts } = hotelContent
const { descriptions, meetingDescription } = texts
const { conferencesAndMeetings, restaurantsOverviewPage, restaurantImages } =
additionalData
const data = {
description: descriptions?.short,
image: {
src: images.imageSizes.small,
alt: images.metaData.altText,
},
cta: {
text: intl.formatMessage({
defaultMessage: "See hotel details",
}),
url,
openInNewTab: false,
},
}
switch (contentType) {
case "meeting":
const meetingImage = conferencesAndMeetings?.heroImages[0]
const meetingUrl = additionalData.meetingRooms.meetingOnlineLink
if (meetingDescription?.short) {
data.description = meetingDescription.short
}
if (meetingImage) {
data.image = {
src: meetingImage.imageSizes.small,
alt: meetingImage.metaData.altText,
}
}
if (meetingUrl) {
data.cta = {
text: intl.formatMessage({
defaultMessage: "Book a meeting",
}),
url: meetingUrl,
openInNewTab: true,
}
}
return data
case "restaurant":
const restaurantImage = restaurantImages?.heroImages[0]
if (restaurantsOverviewPage.restaurantsContentDescriptionShort) {
data.description =
restaurantsOverviewPage.restaurantsContentDescriptionShort
}
if (restaurantImage) {
data.image = {
src: restaurantImage.imageSizes.small,
alt: restaurantImage.metaData.altText,
}
}
return data
case "hotel":
default:
return data
}
}

View File

@@ -30,13 +30,11 @@ export default async function HotelListing({
<Typography variant="Title/sm">
<h3 className={styles.heading}>{heading}</h3>
</Typography>
{hotels.map(({ url, hotel, additionalData }) => (
{hotels.map((hotelData) => (
<HotelListingItem
key={hotel.name}
hotel={hotel}
additionalData={additionalData}
key={hotelData.hotel.name}
hotelData={hotelData}
contentType={contentType}
url={url}
/>
))}
</SectionContainer>