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>

View File

@@ -38,6 +38,7 @@ export default function Blocks({ blocks }: BlocksProps) {
<CampaignHotelListing
heading={block.hotel_listing.heading}
hotelIds={block.hotel_listing.hotelIds}
isMainBlock={true}
/>
</Suspense>
)

View File

@@ -12,11 +12,11 @@ import HotelListItem from "../HotelListItem"
import styles from "./hotelList.module.css"
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
interface HotelListContentProps {
hotelsCount: number
visibleHotels: DestinationPagesHotelData[]
visibleHotels: HotelListingHotelData[]
}
export default function HotelListContent({

View File

@@ -17,15 +17,15 @@ import { getVisibleHotels } from "./utils"
import styles from "./hotelList.module.css"
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
export default function HotelList() {
const intl = useIntl()
const map = useMap()
const coreLib = useMapsLibrary("core")
const [visibleHotels, setVisibleHotels] = useState<
DestinationPagesHotelData[]
>([])
const [visibleHotels, setVisibleHotels] = useState<HotelListingHotelData[]>(
[]
)
const { activeHotels, isLoading } = useDestinationDataStore((state) => ({
activeHotels: state.activeHotels,
isLoading: state.isLoading,

View File

@@ -1,7 +1,7 @@
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
export function getVisibleHotels(
hotels: DestinationPagesHotelData[],
hotels: HotelListingHotelData[],
map: google.maps.Map | null
) {
const bounds = map?.getBounds()

View File

@@ -19,11 +19,15 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
import styles from "./hotelListItem.module.css"
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
export default function HotelListItem(data: DestinationPagesHotelData) {
interface HotelListItemProps {
hotel: HotelListingHotelData["hotel"]
url: string | null
}
export default function HotelListItem({ hotel, url }: HotelListItemProps) {
const intl = useIntl()
const { hotel, url } = data
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const amenities = hotel.detailedFacilities.slice(0, 5)
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`

View File

@@ -1,8 +1,11 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
import { getFiltersFromHotels } from "@scandic-hotels/trpc/routers/contentstack/metadata/helpers"
import {
type HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
import { env } from "@/env/server"
import {
@@ -13,6 +16,7 @@ import {
import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import DestinationDataProvider from "@/providers/DestinationDataProvider"
import { getPathname } from "@/utils/getPathname"
@@ -30,8 +34,6 @@ import DestinationCityPageSkeleton from "./DestinationCityPageSkeleton"
import styles from "./destinationCityPage.module.css"
import type { SortItem } from "@/types/components/destinationFilterAndSort"
interface DestinationCityPageProps {
isMapView: boolean
filterFromUrl?: string
@@ -42,6 +44,7 @@ export default async function DestinationCityPage({
filterFromUrl,
}: DestinationCityPageProps) {
const intl = await getIntl()
const lang = await getLang()
const pathname = await getPathname()
const pageData = await getDestinationCityPage()
@@ -62,26 +65,26 @@ export default async function DestinationCityPage({
} = destinationCityPage
const allHotels = await getHotelsByCityIdentifier(cityIdentifier)
const allFilters = getFiltersFromHotels(allHotels)
const sortItems: SortItem[] = [
const allFilters = getFiltersFromHotels(allHotels, lang)
const sortItems: HotelSortItem[] = [
{
label: intl.formatMessage({
defaultMessage: "Distance to city center",
}),
value: SortOption.Distance,
value: HotelSortOption.Distance,
isDefault: true,
},
{
label: intl.formatMessage({
defaultMessage: "Name",
}),
value: SortOption.Name,
value: HotelSortOption.Name,
},
{
label: intl.formatMessage({
defaultMessage: "TripAdvisor rating",
}),
value: SortOption.TripAdvisorRating,
value: HotelSortOption.TripAdvisorRating,
},
]

View File

@@ -1,8 +1,11 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
import { getFiltersFromHotels } from "@scandic-hotels/trpc/routers/contentstack/metadata/helpers"
import {
type HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
import { env } from "@/env/server"
import {
@@ -14,6 +17,7 @@ import {
import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import DestinationDataProvider from "@/providers/DestinationDataProvider"
import { getPathname } from "@/utils/getPathname"
@@ -30,8 +34,6 @@ import DestinationCountryPageSkeleton from "./DestinationCountryPageSkeleton"
import styles from "./destinationCountryPage.module.css"
import type { SortItem } from "@/types/components/destinationFilterAndSort"
interface DestinationCountryPageProps {
isMapView: boolean
filterFromUrl?: string
@@ -42,6 +44,7 @@ export default async function DestinationCountryPage({
filterFromUrl,
}: DestinationCountryPageProps) {
const intl = await getIntl()
const lang = await getLang()
const pathname = await getPathname()
const pageData = await getDestinationCountryPage()
@@ -65,21 +68,21 @@ export default async function DestinationCountryPage({
getHotelsByCountry(destination_settings.country),
getDestinationCityPagesByCountry(destination_settings.country),
])
const allFilters = getFiltersFromHotels(allHotels)
const allFilters = getFiltersFromHotels(allHotels, lang)
const sortItems: SortItem[] = [
const sortItems: HotelSortItem[] = [
{
label: intl.formatMessage({
defaultMessage: "Recommended",
}),
value: SortOption.Recommended,
value: HotelSortOption.Recommended,
isDefault: true,
},
{
label: intl.formatMessage({
defaultMessage: "Name",
}),
value: SortOption.Name,
value: HotelSortOption.Name,
},
]

View File

@@ -11,10 +11,10 @@ import HotelMapCard from "../HotelMapCard"
import styles from "./hotelCardCarousel.module.css"
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
interface MapCardCarouselProps {
visibleHotels: DestinationPagesHotelData[]
visibleHotels: HotelListingHotelData[]
}
export default function HotelCardCarousel({
visibleHotels,
@@ -52,7 +52,7 @@ export default function HotelCardCarousel({
tripadvisorRating={hotel.tripadvisor}
hotelName={hotel.name}
url={url}
image={getImage({ hotel, url })}
image={getImage({ hotel })}
amenities={hotel.detailedFacilities.slice(0, 3)}
/>
</Carousel.Item>
@@ -62,11 +62,11 @@ export default function HotelCardCarousel({
)
}
function getImage(hotel: DestinationPagesHotelData) {
function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
return {
src: hotel.hotel.galleryImages?.[0]?.imageSizes.medium,
src: hotel.galleryImages?.[0]?.imageSizes.large,
alt:
hotel.hotel.galleryImages?.[0]?.metaData.altText ||
hotel.hotel.galleryImages?.[0]?.metaData.altText_En,
hotel.galleryImages?.[0]?.metaData.altText ||
hotel.galleryImages?.[0]?.metaData.altText_En,
}
}

View File

@@ -21,12 +21,19 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
import styles from "./hotelListingItem.module.css"
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
export default function HotelListingItem(data: DestinationPagesHotelData) {
interface HotelListingItemProps {
hotel: HotelListingHotelData["hotel"]
url: string | null
}
export default function HotelListingItem({
hotel,
url,
}: HotelListingItemProps) {
const intl = useIntl()
const params = useParams()
const { hotel, url } = data
const { setActiveMarker } = useDestinationPageHotelsMapStore()
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const amenities = hotel.detailedFacilities.slice(0, 5)
@@ -103,9 +110,9 @@ export default function HotelListingItem(data: DestinationPagesHotelData) {
</div>
</Typography>
</div>
{hotel.hotelDescription ? (
{hotel.description ? (
<Typography variant="Body/Paragraph/mdRegular">
<p>{hotel.hotelDescription}</p>
<p>{hotel.description}</p>
</Typography>
) : null}
<Typography variant="Body/Supporting text (caption)/smRegular">

View File

@@ -9,7 +9,7 @@ import styles from "./dialogImage.module.css"
interface DialogImageProps {
image?: string
altText?: string
rating?: number
rating?: number | null
imageError: boolean
setImageError: (error: boolean) => void
}

View File

@@ -22,10 +22,10 @@ import type { GalleryImage } from "@/types/components/imageGallery"
interface HotelMapCardProps {
amenities: Amenities
tripadvisorRating: number | undefined
tripadvisorRating: number | null
hotelName: string
image: GalleryImage | null
url: string
url: string | null
className?: string
}

View File

@@ -29,12 +29,12 @@ import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "./utils"
import styles from "./map.module.css"
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { MapLocation } from "@/types/components/mapLocation"
interface MapProps {
hotels: DestinationPagesHotelData[]
hotels: HotelListingHotelData[]
mapId: string
apiKey: string
pageType: "city" | "country"

View File

@@ -1,4 +1,4 @@
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
import type {
DestinationMarker,
@@ -29,7 +29,7 @@ export function mapMarkerDataToGeoJson(markers: DestinationMarker[]) {
return geoJson
}
export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) {
export function getHotelMapMarkers(hotels: HotelListingHotelData[]) {
const markers = hotels
.map(({ hotel, url }) => ({
id: hotel.id,
@@ -41,10 +41,10 @@ export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) {
lng: hotel.location.longitude,
}
: null,
url: url,
url,
tripadvisor: hotel.tripadvisor,
amenities: hotel.detailedFacilities.slice(0, 3),
image: getImage({ hotel, url }),
image: getImage({ hotel }),
}))
.filter((item): item is DestinationMarker => !!item.coordinates)
@@ -52,11 +52,11 @@ export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) {
return markers
}
function getImage(hotel: DestinationPagesHotelData) {
function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
return {
src: hotel.hotel.galleryImages?.[0]?.imageSizes.medium,
src: hotel.galleryImages?.[0]?.imageSizes.large,
alt:
hotel.hotel.galleryImages?.[0]?.metaData.altText ||
hotel.hotel.galleryImages?.[0]?.metaData.altText_En,
hotel.galleryImages?.[0]?.metaData.altText ||
hotel.galleryImages?.[0]?.metaData.altText_En,
}
}

View File

@@ -1,10 +1,10 @@
import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel"
import type { IntlShape } from "react-intl"
export function getHeadingText(
intl: IntlShape,
location: string,
allFilters: CategorizedFilters,
allFilters: CategorizedHotelFilters,
filter?: string
) {
if (filter) {

View File

@@ -11,10 +11,10 @@ import Checkbox from "./Checkbox"
import styles from "./filter.module.css"
import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel"
interface FilterProps {
filters: CategorizedFilters
filters: CategorizedHotelFilters
}
export default function Filter({ filters }: FilterProps) {

View File

@@ -6,12 +6,13 @@ import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
import { useDestinationDataStore } from "@/stores/destination-data"
import type { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
import type { SortItem } from "@/types/components/destinationFilterAndSort"
import type {
HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
interface SortProps {
sortItems: SortItem[]
sortItems: HotelSortItem[]
}
export default function Sort({ sortItems }: SortProps) {
@@ -33,7 +34,7 @@ export default function Sort({ sortItems }: SortProps) {
})}
name="sort"
showRadioButton
onSelect={(sort) => setPendingSort(sort as SortOption)}
onSelect={(sort) => setPendingSort(sort as HotelSortOption)}
/>
)
}

View File

@@ -0,0 +1,41 @@
.checkboxWrapper {
display: flex;
align-items: center;
gap: var(--Space-x15);
padding: var(--Space-x1) var(--Space-x15);
cursor: pointer;
border-radius: var(--Corner-radius-md);
transition: background-color 0.3s;
}
.checkboxWrapper:hover {
background-color: var(--UI-Input-Controls-Surface-Hover);
}
.checkbox {
width: 24px;
height: 24px;
min-width: 24px;
border: 1px solid var(--UI-Input-Controls-Border-Normal);
border-radius: var(--Corner-radius-sm);
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--UI-Input-Controls-Surface-Normal);
}
.checkboxWrapper[data-selected] .checkbox {
border-color: var(--UI-Input-Controls-Fill-Selected);
background-color: var(--UI-Input-Controls-Fill-Selected);
}
@media screen and (max-width: 767px) {
.checkboxWrapper:hover {
background-color: transparent;
}
.checkboxWrapper[data-selected] {
background-color: transparent;
}
}

View File

@@ -0,0 +1,41 @@
"use client"
import { Checkbox as AriaCheckbox } from "react-aria-components"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./checkbox.module.css"
interface CheckboxProps {
name: string
value: string
isSelected: boolean
onChange: (filterId: string) => void
}
export default function Checkbox({
isSelected,
name,
value,
onChange,
}: CheckboxProps) {
return (
<AriaCheckbox
className={styles.checkboxWrapper}
isSelected={isSelected}
onChange={() => onChange(value)}
>
{({ isSelected }) => (
<>
<span className={styles.checkbox}>
{isSelected && <MaterialIcon icon="check" color="Icon/Inverted" />}
</span>
<Typography variant="Body/Paragraph/mdRegular">
<span>{name}</span>
</Typography>
</>
)}
</AriaCheckbox>
)
}

View File

@@ -0,0 +1,41 @@
.container {
display: grid;
gap: var(--Space-x2);
}
.form {
display: grid;
gap: var(--Space-x4);
}
.heading {
color: var(--Text-Heading);
}
.fieldset {
border: none;
padding: 0;
display: grid;
}
.fieldset:first-of-type {
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.list {
display: grid;
grid-template-columns: repeat(3, 1fr);
list-style: none;
gap: var(--Space-x1) var(--Space-x2);
margin: var(--Space-x3) 0;
}
@media screen and (max-width: 767px) {
.list {
grid-template-columns: 1fr;
}
.list label {
padding-left: 0;
}
}

View File

@@ -0,0 +1,115 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useHotelListingDataStore } from "@/stores/hotel-listing-data"
import Checkbox from "./Checkbox"
import styles from "./filter.module.css"
import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel"
interface FilterProps {
filters: CategorizedHotelFilters
}
export default function Filter({ filters }: FilterProps) {
const intl = useIntl()
const { facilityFilters, surroundingsFilters, countryFilters } = filters
const { pendingFilters, togglePendingFilter } = useHotelListingDataStore(
(state) => ({
pendingFilters: state.pendingFilters,
togglePendingFilter: state.actions.togglePendingFilter,
})
)
if (
!facilityFilters.length &&
!surroundingsFilters.length &&
!countryFilters.length
) {
return null
}
return (
<div className={styles.container}>
<Typography variant="Title/xs">
<h4 className={styles.heading}>
{intl.formatMessage({
defaultMessage: "Filter by",
})}
</h4>
</Typography>
<form className={styles.form}>
<fieldset className={styles.fieldset}>
<Typography variant="Title/Subtitle/md">
<legend>
{intl.formatMessage({
defaultMessage: "Country",
})}
</legend>
</Typography>
<ul className={styles.list}>
{countryFilters.map((filter) => (
<li key={`filter-${filter.slug}`}>
<Checkbox
name={filter.name}
value={filter.slug}
onChange={() => togglePendingFilter(filter.slug)}
isSelected={!!pendingFilters.find((f) => f === filter.slug)}
/>
</li>
))}
</ul>
</fieldset>
<fieldset className={styles.fieldset}>
<Typography variant="Title/Subtitle/md">
<legend>
{intl.formatMessage({
defaultMessage: "Hotel facilities",
})}
</legend>
</Typography>
<ul className={styles.list}>
{facilityFilters.map((filter) => (
<li key={`filter-${filter.slug}`}>
<Checkbox
name={filter.name}
value={filter.slug}
onChange={() => togglePendingFilter(filter.slug)}
isSelected={!!pendingFilters.find((f) => f === filter.slug)}
/>
</li>
))}
</ul>
</fieldset>
<fieldset className={styles.fieldset}>
<Typography variant="Title/Subtitle/md">
<legend>
{intl.formatMessage({
defaultMessage: "Hotel surroundings",
})}
</legend>
</Typography>
<ul className={styles.list}>
{surroundingsFilters.map((filter) => (
<li key={`filter-${filter.slug}`}>
<Checkbox
name={filter.name}
value={filter.slug}
onChange={() => togglePendingFilter(filter.slug)}
isSelected={!!pendingFilters.find((f) => f === filter.slug)}
/>
</li>
))}
</ul>
</fieldset>
</form>
</div>
)
}

View File

@@ -0,0 +1,40 @@
"use client"
import { useIntl } from "react-intl"
import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
import { useHotelListingDataStore } from "@/stores/hotel-listing-data"
import type {
HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
interface SortProps {
sortItems: HotelSortItem[]
}
export default function Sort({ sortItems }: SortProps) {
const intl = useIntl()
const { pendingSort, setPendingSort } = useHotelListingDataStore((state) => ({
pendingSort: state.pendingSort,
setPendingSort: state.actions.setPendingSort,
}))
return (
<DeprecatedSelect
items={sortItems}
defaultSelectedKey={pendingSort}
label={intl.formatMessage({
defaultMessage: "Sort by",
})}
aria-label={intl.formatMessage({
defaultMessage: "Sort by",
})}
name="sort"
showRadioButton
onSelect={(sort) => setPendingSort(sort as HotelSortOption)}
/>
)
}

View File

@@ -0,0 +1,115 @@
.overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
align-items: center;
z-index: var(--default-modal-overlay-z-index);
}
.dialog {
width: min(80dvw, 960px);
border-radius: var(--Corner-radius-lg);
background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
overflow: hidden;
}
.header {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
padding: var(--Space-x2) var(--Space-x3);
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.heading {
text-align: center;
}
.buttonWrapper {
display: flex;
gap: var(--Space-x1);
align-items: center;
}
.badge {
background-color: var(--Base-Text-Accent);
border-radius: var(--Corner-radius-xl);
width: 20px;
height: 20px;
color: var(--Base-Surface-Primary-light-Normal);
display: flex;
align-items: center;
justify-content: center;
}
.content {
display: grid;
gap: var(--Space-x4);
align-content: start;
padding: var(--Space-x4) var(--Space-x3);
overflow-y: auto;
height: min(calc(80dvh - 180px), 500px);
}
.alertWrapper:not(:empty) {
padding: var(--Space-x2) var(--Space-x4) 0;
border-top: 1px solid var(--Base-Border-Subtle);
}
.alertWrapper:not(:empty) + .footer {
border-top: none;
}
.footer {
display: flex;
justify-content: space-between;
padding: var(--Space-x2) var(--Space-x4);
border-top: 1px solid var(--Base-Border-Subtle);
}
@media screen and (max-width: 767px) {
.overlay {
height: var(--visual-viewport-height);
}
.dialog {
display: flex;
flex-direction: column;
height: 100dvh;
width: 100vw;
border-radius: 0;
}
.header {
display: flex;
justify-content: flex-end;
border-bottom: none;
padding: var(--Space-x3) var(--Space-x2);
}
.title,
.divider {
display: none;
}
.content {
height: 100%;
padding: 0 var(--Space-x2) var(--Space-x3);
overflow-y: scroll;
}
.alertWrapper:not(:empty) {
padding: var(--Space-x3) var(--Space-x2) 0;
}
.footer {
flex-direction: column-reverse;
gap: var(--Space-x3);
padding: var(--Space-x3) var(--Space-x2);
margin-top: auto;
}
}

View File

@@ -0,0 +1,178 @@
"use client"
import { useRouter } from "next/navigation"
import {
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { AlertTypeEnum } from "@scandic-hotels/trpc/types/alertType"
import { useHotelListingDataStore } from "@/stores/hotel-listing-data"
import Alert from "../TempDesignSystem/Alert"
import Filter from "./Filter"
import Sort from "./Sort"
import styles from "./hotelFilterAndSort.module.css"
export default function HotelFilterAndSort() {
const intl = useIntl()
const router = useRouter()
const {
filters,
sortItems,
pendingFilters,
pendingSort,
defaultSort,
pendingCount,
activeFilters,
clearPendingFilters,
resetPendingValues,
setIsLoading,
} = useHotelListingDataStore((state) => ({
filters: state.allFilters,
sortItems: state.sortItems,
pendingFilters: state.pendingFilters,
pendingSort: state.pendingSort,
defaultSort: state.defaultSort,
pendingCount: state.pendingHotelCount,
activeFilters: state.activeFilters,
clearPendingFilters: state.actions.clearPendingFilters,
resetPendingValues: state.actions.resetPendingValues,
setIsLoading: state.actions.setIsLoading,
}))
const alertHeading = intl.formatMessage({
defaultMessage: "No matching hotels found",
})
const alertText = intl.formatMessage({
defaultMessage:
"It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.",
})
function submitAndClose(close: () => void) {
setIsLoading(true)
const sort = pendingSort
const filters = pendingFilters
const parsedUrl = new URL(window.location.href)
const searchParams = parsedUrl.searchParams
if (sort === defaultSort && searchParams.has("sort")) {
searchParams.delete("sort")
} else if (sort !== defaultSort) {
searchParams.set("sort", sort)
}
if (!filters.length && searchParams.has("filter")) {
searchParams.delete("filter")
} else if (filters.length) {
searchParams.set("filter", filters.join(","))
}
router.push(parsedUrl.toString(), { scroll: false })
close()
}
function handleClose(isOpen: boolean) {
if (isOpen) {
resetPendingValues()
}
}
return (
<DialogTrigger onOpenChange={handleClose}>
<div className={styles.buttonWrapper}>
<Button
variant="Text"
wrapping={false}
typography="Body/Paragraph/mdBold"
>
<MaterialIcon icon="filter_alt" color="CurrentColor" />
{intl.formatMessage({
defaultMessage: "Filter and sort",
})}
</Button>
{activeFilters.length > 0 && (
<Typography variant="Label/xsRegular">
<span className={styles.badge}>{activeFilters.length}</span>
</Typography>
)}
</div>
<ModalOverlay isDismissable className={styles.overlay}>
<Modal>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({
defaultMessage: "Filter and sort",
})}
>
{({ close }) => (
<>
<header className={styles.header}>
<Typography variant="Title/Subtitle/md">
<h3 className={styles.heading}>
{intl.formatMessage({
defaultMessage: "Filter and sort",
})}
</h3>
</Typography>
<IconButton onPress={close} theme="Black">
<MaterialIcon icon="close" color="CurrentColor" />
</IconButton>
</header>
<div className={styles.content}>
<Sort sortItems={sortItems} />
<Divider className={styles.divider} />
<Filter filters={filters} />
</div>
{pendingCount === 0 && (
<div className={styles.alertWrapper}>
<Alert
type={AlertTypeEnum.Warning}
heading={alertHeading}
text={alertText}
ariaRole="status"
/>
</div>
)}
<footer className={styles.footer}>
<Button
onClick={clearPendingFilters}
variant="Text"
typography="Body/Paragraph/mdBold"
>
{intl.formatMessage({
defaultMessage: "Clear all filters",
})}
</Button>
<Button
variant="Tertiary"
size="Large"
isDisabled={pendingCount === 0}
onPress={() => submitAndClose(close)}
typography="Body/Paragraph/mdBold"
>
{intl.formatMessage(
{
defaultMessage: "See results ({ count })",
},
{ count: pendingCount }
)}
</Button>
</footer>
</>
)}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
)
}

View File

@@ -0,0 +1,6 @@
import { createContext } from "react"
import type { HotelListingDataStore } from "@/types/contexts/hotel-listing-data"
export const HotelListingDataContext =
createContext<HotelListingDataStore | null>(null)

View File

@@ -0,0 +1,38 @@
"use client"
import { useParams } from "next/navigation"
import { useEffect } from "react"
import { useHotelListingDataStore } from "@/stores/hotel-listing-data"
export default function HotelListingDataProviderContent({
children,
}: React.PropsWithChildren) {
const params = useParams()
const { updateActiveFiltersAndSort, allFilterSlugs } =
useHotelListingDataStore((state) => ({
allFilterSlugs: state.allFilterSlugs,
updateActiveFiltersAndSort: state.actions.updateActiveFiltersAndSort,
}))
useEffect(() => {
const currentUrl = new URL(window.location.href)
const searchParams = currentUrl.searchParams
const sort = searchParams.get("sort")
const filterParam = searchParams.get("filter")
const activeFilters: string[] = []
if (filterParam) {
const filters = filterParam.split(",")
filters.forEach((filter) => {
if (allFilterSlugs.includes(filter)) {
activeFilters.push(filter)
}
})
}
updateActiveFiltersAndSort(activeFilters, sort)
}, [params, updateActiveFiltersAndSort, allFilterSlugs])
return <>{children}</>
}

View File

@@ -0,0 +1,39 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useRef } from "react"
import { createHotelListingDataStore } from "@/stores/hotel-listing-data"
import { HotelListingDataContext } from "@/contexts/HotelListingData"
import HotelListingDataProviderContent from "./Content"
import type { HotelListingDataStore } from "@/types/contexts/hotel-listing-data"
import type { HotelListingDataProviderProps } from "@/types/providers/hotel-listing-data"
export default function HotelListingDataProvider({
allHotels,
allFilters,
sortItems,
children,
}: HotelListingDataProviderProps) {
const storeRef = useRef<HotelListingDataStore>(undefined)
const searchParams = useSearchParams()
if (!storeRef.current) {
storeRef.current = createHotelListingDataStore({
allHotels,
allFilters,
sortItems,
searchParams,
})
}
return (
<HotelListingDataContext.Provider value={storeRef.current}>
<HotelListingDataProviderContent>
{children}
</HotelListingDataProviderContent>
</HotelListingDataContext.Provider>
)
}

View File

@@ -1,29 +1,30 @@
import { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
import {
type HotelListingHotelData,
type HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { SortItem } from "@/types/components/destinationFilterAndSort"
const HOTEL_SORTING_STRATEGIES: Partial<
Record<
SortOption,
(a: DestinationPagesHotelData, b: DestinationPagesHotelData) => number
HotelSortOption,
(a: HotelListingHotelData, b: HotelListingHotelData) => number
>
> = {
[SortOption.Name]: function (a, b) {
[HotelSortOption.Name]: function (a, b) {
return a.hotel.name.localeCompare(b.hotel.name)
},
[SortOption.TripAdvisorRating]: function (a, b) {
[HotelSortOption.TripAdvisorRating]: function (a, b) {
return (b.hotel.tripadvisor ?? 0) - (a.hotel.tripadvisor ?? 0)
},
[SortOption.Distance]: function (a, b) {
[HotelSortOption.Distance]: function (a, b) {
return a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre
},
}
export function getFilteredHotels(
hotels: DestinationPagesHotelData[],
hotels: HotelListingHotelData[],
filters: string[]
) {
if (filters.length) {
@@ -37,7 +38,7 @@ export function getFilteredHotels(
}
export function getFilteredCities(
filteredHotels: DestinationPagesHotelData[],
filteredHotels: HotelListingHotelData[],
cities: DestinationCityListItem[]
) {
const filteredCityIdentifiers = filteredHotels.map(
@@ -52,8 +53,8 @@ export function getFilteredCities(
}
export function getSortedHotels(
hotels: DestinationPagesHotelData[],
sortOption: SortOption
hotels: HotelListingHotelData[],
sortOption: HotelSortOption
) {
const sortFn = HOTEL_SORTING_STRATEGIES[sortOption]
return sortFn ? [...hotels].sort(sortFn) : hotels
@@ -61,9 +62,9 @@ export function getSortedHotels(
export function isValidSortOption(
value: string,
sortItems: SortItem[]
): value is SortOption {
return sortItems.map((item) => item.value).includes(value as SortOption)
sortItems: HotelSortItem[]
): value is HotelSortOption {
return sortItems.map((item) => item.value).includes(value as HotelSortOption)
}
export function getBasePathNameWithoutFilters(

View File

@@ -18,7 +18,7 @@ import {
isValidSortOption,
} from "./helper"
import type { Filter } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
import type { HotelFilter } from "@scandic-hotels/trpc/types/hotel"
import type {
DestinationDataState,
@@ -36,8 +36,8 @@ export function createDestinationDataStore({
}: InitialState) {
const defaultSort =
sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value
const allFilterSlugs = Object.values(allFilters).flatMap((filter: Filter[]) =>
filter.map((f) => f.slug)
const allFilterSlugs = Object.values(allFilters).flatMap(
(filter: HotelFilter[]) => filter.map((f) => f.slug)
)
const activeFilters: string[] = filterFromUrl ? [filterFromUrl] : []

View File

@@ -0,0 +1,64 @@
import {
type HotelListingHotelData,
type HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
const HOTEL_SORTING_STRATEGIES: Partial<
Record<
HotelSortOption,
(a: HotelListingHotelData, b: HotelListingHotelData) => number
>
> = {
[HotelSortOption.Name]: function (a, b) {
return a.hotel.name.localeCompare(b.hotel.name)
},
[HotelSortOption.TripAdvisorRating]: function (a, b) {
return (b.hotel.tripadvisor ?? 0) - (a.hotel.tripadvisor ?? 0)
},
}
export function getFilteredHotels(
hotels: HotelListingHotelData[],
filters: string[]
) {
if (filters.length) {
return hotels.filter(({ hotel }) =>
filters.every((filter) => {
const matchesFacility = hotel.detailedFacilities.some(
(facility) => facility.slug === filter
)
const matchesCountry =
hotel.countryCode.toLowerCase() === filter.toLowerCase()
return matchesFacility || matchesCountry
})
)
}
return hotels
}
export function getSortedHotels(
hotels: HotelListingHotelData[],
sortOption: HotelSortOption
) {
const sortFn = HOTEL_SORTING_STRATEGIES[sortOption]
return sortFn ? [...hotels].sort(sortFn) : hotels
}
export function isValidSortOption(
value: string,
sortItems: HotelSortItem[]
): value is HotelSortOption {
return sortItems.map((item) => item.value).includes(value as HotelSortOption)
}
export function getBasePathNameWithoutFilters(
pathname: string,
filterSlugs: string[]
) {
const pathSegments = pathname.split("/")
const filteredSegments = pathSegments.filter(
(segment) => !filterSlugs.includes(segment)
)
return filteredSegments.join("/")
}

View File

@@ -0,0 +1,175 @@
import { produce } from "immer"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { HotelListingDataContext } from "@/contexts/HotelListingData"
import {
trackFilterChangeEvent,
trackSortingChangeEvent,
} from "@/utils/tracking/destinationPage"
import { getFilteredHotels, getSortedHotels, isValidSortOption } from "./helper"
import type { HotelFilter } from "@scandic-hotels/trpc/types/hotel"
import type {
HotelListingDataState,
InitialState,
} from "@/types/stores/hotel-listing-data"
export function createHotelListingDataStore({
allHotels,
allFilters,
sortItems,
searchParams,
}: InitialState) {
const defaultSort =
sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value
const allFilterSlugs = Object.values(allFilters).flatMap(
(filter: HotelFilter[]) => filter.map((f) => f.slug)
)
const activeFilters: string[] = []
let activeSort = defaultSort
if (searchParams) {
const sortParam = searchParams.get("sort")
const filterParam = searchParams.get("filter")
if (sortParam && isValidSortOption(sortParam, sortItems)) {
activeSort = sortParam
}
if (filterParam) {
const filters = filterParam.split(",")
filters.forEach((filter) => {
if (allFilterSlugs.includes(filter)) {
activeFilters.push(filter)
}
})
}
}
const filteredHotels = getFilteredHotels(allHotels, activeFilters)
const activeHotels = getSortedHotels(filteredHotels, activeSort)
return create<HotelListingDataState>((set) => ({
actions: {
updateActiveFiltersAndSort(filters, sort) {
return set(
produce((state: HotelListingDataState) => {
const newSort =
sort && isValidSortOption(sort, state.sortItems)
? sort
: state.defaultSort
const filteredHotels = getFilteredHotels(state.allHotels, filters)
const sortedHotels = getSortedHotels(filteredHotels, newSort)
// Tracking
if (newSort !== state.activeSort) {
trackSortingChangeEvent(newSort)
}
if (
JSON.stringify(filters) !== JSON.stringify(state.activeFilters)
) {
const facilityFiltersUsed = filters.filter((f) =>
state.allFilters.facilityFilters
.map((ff) => ff.slug)
.includes(f)
)
const surroundingsFiltersUsed = filters.filter((f) =>
state.allFilters.surroundingsFilters
.map((sf) => sf.slug)
.includes(f)
)
trackFilterChangeEvent(
facilityFiltersUsed,
surroundingsFiltersUsed
)
}
state.activeSort = newSort
state.activeFilters = filters
state.activeHotels = sortedHotels
state.pendingFilters = filters
state.pendingSort = newSort
state.pendingHotelCount = filteredHotels.length
state.isLoading = false
})
)
},
setIsLoading(isLoading) {
return set(
produce((state: HotelListingDataState) => {
state.isLoading = isLoading
})
)
},
setPendingSort(sort) {
return set(
produce((state: HotelListingDataState) => {
state.pendingSort = sort
})
)
},
togglePendingFilter(filter) {
return set(
produce((state: HotelListingDataState) => {
const isActive = state.pendingFilters.includes(filter)
const filters = isActive
? state.pendingFilters.filter((f) => f !== filter)
: [...state.pendingFilters, filter]
const pendingHotels = getFilteredHotels(state.allHotels, filters)
state.pendingFilters = filters
state.pendingHotelCount = pendingHotels.length
})
)
},
clearPendingFilters() {
return set(
produce((state: HotelListingDataState) => {
state.pendingFilters = []
state.pendingHotelCount = state.allHotels.length
})
)
},
resetPendingValues() {
return set(
produce((state: HotelListingDataState) => {
state.pendingFilters = state.activeFilters
state.pendingSort = state.activeSort
state.pendingHotelCount = state.activeHotels.length
})
)
},
},
allHotels,
activeHotels: activeHotels,
pendingHotelCount: activeHotels.length,
activeSort,
pendingSort: activeSort,
defaultSort,
activeFilters,
pendingFilters: activeFilters,
allFilters,
allFilterSlugs,
sortItems,
isLoading: false,
}))
}
export function useHotelListingDataStore<T>(
selector: (store: HotelListingDataState) => T
) {
const store = useContext(HotelListingDataContext)
if (!store) {
throw new Error(
"useHotelListingDataStore must be used within HotelListingDataProvider"
)
}
return useStore(store, selector)
}

View File

@@ -1,10 +1,8 @@
import type { AdditionalData, Hotel } from "@scandic-hotels/trpc/types/hotel"
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks"
export interface HotelListingItemProps {
hotel: Hotel
additionalData: AdditionalData
hotelData: HotelListingHotelData
contentType: HotelListing["contentType"]
url: string | null
}

View File

@@ -1,7 +0,0 @@
import type { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
export interface SortItem {
label: string
value: SortOption
isDefault?: boolean
}

View File

@@ -1,6 +1,6 @@
import type { Amenities } from "@scandic-hotels/trpc/types/hotel"
import type { FeatureCollection, Point } from "geojson"
import type { Amenities } from "@scandic-hotels/trpc/types/hotel"
import type { GalleryImage } from "../imageGallery"
export interface DestinationMarker {
@@ -9,7 +9,7 @@ export interface DestinationMarker {
name: string
coordinates: google.maps.LatLngLiteral
url: string
tripadvisor: number | undefined
tripadvisor: number | null
amenities: Amenities
image: GalleryImage
}

View File

@@ -0,0 +1,5 @@
import type { createHotelListingDataStore } from "@/stores/hotel-listing-data"
export type HotelListingDataStore = ReturnType<
typeof createHotelListingDataStore
>

View File

@@ -1,14 +1,15 @@
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
import type { SortItem } from "../components/destinationFilterAndSort"
import type {
CategorizedHotelFilters,
HotelListingHotelData,
HotelSortItem,
} from "@scandic-hotels/trpc/types/hotel"
export interface DestinationDataProviderProps extends React.PropsWithChildren {
allHotels: DestinationPagesHotelData[]
allHotels: HotelListingHotelData[]
allCities?: DestinationCityListItem[]
allFilters: CategorizedFilters
allFilters: CategorizedHotelFilters
filterFromUrl?: string
sortItems: SortItem[]
sortItems: HotelSortItem[]
pathname: string
}

View File

@@ -0,0 +1,11 @@
import type {
CategorizedHotelFilters,
HotelListingHotelData,
HotelSortItem,
} from "@scandic-hotels/trpc/types/hotel"
export interface HotelListingDataProviderProps extends React.PropsWithChildren {
allHotels: HotelListingHotelData[]
allFilters: CategorizedHotelFilters
sortItems: HotelSortItem[]
}

View File

@@ -1,14 +1,15 @@
import type { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
import type {
CategorizedHotelFilters,
HotelListingHotelData,
HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
import type { ReadonlyURLSearchParams } from "next/navigation"
import type { SortItem } from "../components/destinationFilterAndSort"
interface Actions {
updateActiveFiltersAndSort: (filters: string[], sort: string | null) => void
setPendingSort: (sort: SortOption) => void
setPendingSort: (sort: HotelSortOption) => void
togglePendingFilter: (filter: string) => void
clearPendingFilters: () => void
resetPendingValues: () => void
@@ -16,8 +17,8 @@ interface Actions {
}
export interface SubmitCallbackData {
sort: SortOption
defaultSort: SortOption
sort: HotelSortOption
defaultSort: HotelSortOption
filters: string[]
basePath: string
}
@@ -25,19 +26,19 @@ export interface DestinationDataState {
actions: Actions
allCities: DestinationCityListItem[]
activeCities: DestinationCityListItem[]
allHotels: DestinationPagesHotelData[]
activeHotels: DestinationPagesHotelData[]
pendingSort: SortOption
activeSort: SortOption
defaultSort: SortOption
allHotels: HotelListingHotelData[]
activeHotels: HotelListingHotelData[]
pendingSort: HotelSortOption
activeSort: HotelSortOption
defaultSort: HotelSortOption
pendingFilters: string[]
activeFilters: string[]
pendingHotelCount: number
pendingCityCount: number
allFilters: CategorizedFilters
allFilters: CategorizedHotelFilters
allFilterSlugs: string[]
basePathnameWithoutFilters: string
sortItems: SortItem[]
sortItems: HotelSortItem[]
isLoading: boolean
}

View File

@@ -0,0 +1,46 @@
import type {
CategorizedHotelFilters,
HotelListingHotelData,
HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
import type { ReadonlyURLSearchParams } from "next/navigation"
interface Actions {
updateActiveFiltersAndSort: (filters: string[], sort: string | null) => void
setPendingSort: (sort: HotelSortOption) => void
togglePendingFilter: (filter: string) => void
clearPendingFilters: () => void
resetPendingValues: () => void
setIsLoading: (isLoading: boolean) => void
}
export interface SubmitCallbackData {
sort: HotelSortOption
defaultSort: HotelSortOption
filters: string[]
basePath: string
}
export interface HotelListingDataState {
actions: Actions
allHotels: HotelListingHotelData[]
activeHotels: HotelListingHotelData[]
pendingSort: HotelSortOption
activeSort: HotelSortOption
defaultSort: HotelSortOption
pendingFilters: string[]
activeFilters: string[]
pendingHotelCount: number
allFilters: CategorizedHotelFilters
allFilterSlugs: string[]
sortItems: HotelSortItem[]
isLoading: boolean
}
export interface InitialState
extends Pick<
HotelListingDataState,
"allHotels" | "sortItems" | "allFilters"
> {
searchParams: ReadonlyURLSearchParams
}