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:
@@ -12,57 +12,64 @@ import {
|
|||||||
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
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 HotelListingItem from "./HotelListingItem"
|
||||||
|
|
||||||
import styles from "./campaignHotelListing.module.css"
|
import styles from "./campaignHotelListing.module.css"
|
||||||
|
|
||||||
import type { HotelDataWithUrl } from "@scandic-hotels/trpc/types/hotel"
|
|
||||||
|
|
||||||
interface CampaignHotelListingClientProps {
|
interface CampaignHotelListingClientProps {
|
||||||
heading: string
|
heading: string
|
||||||
preamble?: string | null
|
preamble?: string | null
|
||||||
hotels: HotelDataWithUrl[]
|
visibleCountMobile: 3 | 6
|
||||||
visibleCountMobile?: 3 | 6
|
visibleCountDesktop: 3 | 6
|
||||||
visibleCountDesktop?: 3 | 6
|
isMainBlock: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CampaignHotelListingClient({
|
export default function CampaignHotelListingClient({
|
||||||
heading,
|
heading,
|
||||||
preamble,
|
preamble,
|
||||||
hotels,
|
visibleCountMobile,
|
||||||
visibleCountMobile = 3,
|
visibleCountDesktop,
|
||||||
visibleCountDesktop = 6,
|
isMainBlock,
|
||||||
}: CampaignHotelListingClientProps) {
|
}: CampaignHotelListingClientProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||||
const scrollRef = useRef<HTMLElement>(null)
|
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 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 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(() =>
|
const [visibleCount, setVisibleCount] = useState(() =>
|
||||||
// Set initial visible count based on the number of hotels and the threshold
|
// Set initial visible count based on the number of activeHotels and the threshold
|
||||||
hotels.length <= thresholdCount ? hotels.length : initialCount
|
activeHotels.length <= thresholdCount ? activeHotels.length : initialCount
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only show the show more/less button if the length of hotels exceeds the threshold count
|
// Only show the show more/less button if the length of activeHotels exceeds the threshold count
|
||||||
const showButton = hotels.length > thresholdCount
|
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 =
|
const canShowAll =
|
||||||
hotels.length > visibleCount &&
|
activeHotels.length > visibleCount &&
|
||||||
(visibleCount + incrementCount > showAllThreshold ||
|
(visibleCount + incrementCount > showAllThreshold ||
|
||||||
visibleCount + incrementCount >= hotels.length)
|
visibleCount + incrementCount >= activeHotels.length)
|
||||||
|
|
||||||
function handleButtonClick() {
|
function handleButtonClick() {
|
||||||
if (visibleCount < hotels.length) {
|
if (visibleCount < activeHotels.length) {
|
||||||
if (canShowAll) {
|
if (canShowAll) {
|
||||||
setVisibleCount(hotels.length)
|
setVisibleCount(activeHotels.length)
|
||||||
} else {
|
} else {
|
||||||
setVisibleCount((prev) =>
|
setVisibleCount((prev) =>
|
||||||
Math.min(prev + incrementCount, hotels.length)
|
Math.min(prev + incrementCount, activeHotels.length)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -78,7 +85,7 @@ export default function CampaignHotelListingClient({
|
|||||||
})
|
})
|
||||||
let iconDirection: MaterialIconProps["icon"] = "keyboard_arrow_down"
|
let iconDirection: MaterialIconProps["icon"] = "keyboard_arrow_down"
|
||||||
|
|
||||||
if (visibleCount === hotels.length) {
|
if (visibleCount === activeHotels.length) {
|
||||||
buttonText = intl.formatMessage({
|
buttonText = intl.formatMessage({
|
||||||
defaultMessage: "Show less",
|
defaultMessage: "Show less",
|
||||||
})
|
})
|
||||||
@@ -89,20 +96,30 @@ export default function CampaignHotelListingClient({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <CampaignHotelListingSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.hotelListingSection} ref={scrollRef}>
|
<section
|
||||||
|
className={cx(styles.hotelListingSection, {
|
||||||
|
[styles.isMainBlock]: isMainBlock,
|
||||||
|
})}
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<Typography variant="Title/Subtitle/lg">
|
<Typography variant={isMainBlock ? "Title/md" : "Title/Subtitle/lg"}>
|
||||||
<h3>{heading}</h3>
|
<h3 className={styles.heading}>{heading}</h3>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{isMainBlock ? <HotelFilterAndSort /> : null}
|
||||||
{preamble ? (
|
{preamble ? (
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
<p>{preamble}</p>
|
<p className={styles.preamble}>{preamble}</p>
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
{hotels.map(({ hotel, url }, index) => (
|
{activeHotels.map(({ hotel, url }, index) => (
|
||||||
<li
|
<li
|
||||||
key={hotel.id}
|
key={hotel.id}
|
||||||
className={cx(styles.listItem, {
|
className={cx(styles.listItem, {
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
|
|||||||
|
|
||||||
import styles from "./hotelListingItem.module.css"
|
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 {
|
interface HotelListingItemProps {
|
||||||
hotel: Hotel
|
hotel: HotelListingHotelData["hotel"]
|
||||||
url: string
|
url: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HotelListingItem({
|
export default function HotelListingItem({
|
||||||
@@ -25,11 +25,11 @@ export default function HotelListingItem({
|
|||||||
url,
|
url,
|
||||||
}: HotelListingItemProps) {
|
}: HotelListingItemProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages)
|
||||||
const tripadvisorRating = hotel.ratings?.tripAdvisor.rating
|
const tripadvisorRating = hotel.tripadvisor
|
||||||
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
|
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
|
||||||
const amenities = hotel.detailedFacilities.slice(0, 5)
|
const amenities = hotel.detailedFacilities.slice(0, 5)
|
||||||
const hotelDescription = hotel.hotelContent.texts.descriptions?.short
|
const hotelDescription = hotel.description
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={styles.hotelListingItem}>
|
<article className={styles.hotelListingItem}>
|
||||||
@@ -57,7 +57,7 @@ export default function HotelListingItem({
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.intro}>
|
<div className={styles.intro}>
|
||||||
<HotelLogoIcon
|
<HotelLogoIcon
|
||||||
hotelId={hotel.operaId}
|
hotelId={hotel.id}
|
||||||
hotelType={hotel.hotelType}
|
hotelType={hotel.hotelType}
|
||||||
height={30}
|
height={30}
|
||||||
/>
|
/>
|
||||||
@@ -111,6 +111,7 @@ export default function HotelListingItem({
|
|||||||
</ul>
|
</ul>
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
{url ? (
|
||||||
<div className={styles.ctaWrapper}>
|
<div className={styles.ctaWrapper}>
|
||||||
<ButtonLink
|
<ButtonLink
|
||||||
href={url}
|
href={url}
|
||||||
@@ -124,6 +125,7 @@ export default function HotelListingItem({
|
|||||||
})}
|
})}
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}</>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,13 +6,22 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Space-x3);
|
gap: var(--Space-x3);
|
||||||
scroll-margin-top: var(--scroll-margin-top);
|
scroll-margin-top: var(--scroll-margin-top);
|
||||||
|
|
||||||
|
&.isMainBlock .heading {
|
||||||
|
color: var(--Text-Heading);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr max-content;
|
||||||
gap: var(--Space-x15);
|
gap: var(--Space-x15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preamble {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -28,6 +37,10 @@
|
|||||||
--scroll-margin-top: calc(
|
--scroll-margin-top: calc(
|
||||||
var(--booking-widget-tablet-height) + var(--Spacing-x2)
|
var(--booking-widget-tablet-height) + var(--Spacing-x2)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
&.isMainBlock {
|
||||||
|
gap: var(--Space-x5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
row-gap: var(--Space-x5);
|
row-gap: var(--Space-x5);
|
||||||
|
|||||||
@@ -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 { 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"
|
import CampaignHotelListingClient from "./Client"
|
||||||
|
|
||||||
interface CampaignHotelListingProps {
|
interface CampaignHotelListingProps {
|
||||||
@@ -8,28 +21,56 @@ interface CampaignHotelListingProps {
|
|||||||
hotelIds: string[]
|
hotelIds: string[]
|
||||||
visibleCountMobile?: 3 | 6
|
visibleCountMobile?: 3 | 6
|
||||||
visibleCountDesktop?: 3 | 6
|
visibleCountDesktop?: 3 | 6
|
||||||
|
isMainBlock?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function CampaignHotelListing({
|
export default async function CampaignHotelListing({
|
||||||
heading,
|
heading,
|
||||||
preamble,
|
preamble,
|
||||||
hotelIds,
|
hotelIds,
|
||||||
visibleCountMobile,
|
visibleCountMobile = 3,
|
||||||
visibleCountDesktop,
|
visibleCountDesktop = 6,
|
||||||
|
isMainBlock = false,
|
||||||
}: CampaignHotelListingProps) {
|
}: CampaignHotelListingProps) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
const lang = await getLang()
|
||||||
const hotels = await getHotelsByCSFilter({ hotelsToInclude: hotelIds })
|
const hotels = await getHotelsByCSFilter({ hotelsToInclude: hotelIds })
|
||||||
|
|
||||||
if (!hotels.length) {
|
if (!hotels.length) {
|
||||||
return null
|
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 (
|
return (
|
||||||
|
<Suspense fallback={<CampaignHotelListingSkeleton />}>
|
||||||
|
<HotelListingDataProvider
|
||||||
|
allHotels={hotels}
|
||||||
|
allFilters={allFilters}
|
||||||
|
sortItems={sortItems}
|
||||||
|
>
|
||||||
<CampaignHotelListingClient
|
<CampaignHotelListingClient
|
||||||
heading={heading}
|
heading={heading}
|
||||||
preamble={preamble}
|
preamble={preamble}
|
||||||
hotels={hotels}
|
|
||||||
visibleCountMobile={visibleCountMobile}
|
visibleCountMobile={visibleCountMobile}
|
||||||
visibleCountDesktop={visibleCountDesktop}
|
visibleCountDesktop={visibleCountDesktop}
|
||||||
|
isMainBlock={isMainBlock}
|
||||||
/>
|
/>
|
||||||
|
</HotelListingDataProvider>
|
||||||
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,32 +7,42 @@ import Image from "@/components/Image"
|
|||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
import { getSingleDecimal } from "@/utils/numberFormatting"
|
import { getSingleDecimal } from "@/utils/numberFormatting"
|
||||||
|
|
||||||
import { getTypeSpecificInformation } from "./utils"
|
|
||||||
|
|
||||||
import styles from "./hotelListingItem.module.css"
|
import styles from "./hotelListingItem.module.css"
|
||||||
|
|
||||||
import type { HotelListingItemProps } from "@/types/components/contentPage/hotelListingItem"
|
import type { HotelListingItemProps } from "@/types/components/contentPage/hotelListingItem"
|
||||||
|
|
||||||
export default async function HotelListingItem({
|
export default async function HotelListingItem({
|
||||||
hotel,
|
hotelData,
|
||||||
additionalData,
|
|
||||||
contentType = "hotel",
|
contentType = "hotel",
|
||||||
url,
|
|
||||||
}: HotelListingItemProps) {
|
}: HotelListingItemProps) {
|
||||||
const intl = await getIntl()
|
const intl = await getIntl()
|
||||||
const { description, image, cta } = getTypeSpecificInformation(
|
|
||||||
intl,
|
const { galleryImages, description, id, name, hotelType, location, address } =
|
||||||
contentType,
|
hotelData.hotel
|
||||||
hotel.hotelContent,
|
const image = galleryImages[0]
|
||||||
additionalData,
|
|
||||||
url
|
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 (
|
return (
|
||||||
<article className={styles.container}>
|
<article className={styles.container}>
|
||||||
<Image
|
<Image
|
||||||
src={image.src}
|
src={image.imageSizes.large}
|
||||||
alt={image.alt}
|
alt={image.metaData.altText || image.metaData.altText_En}
|
||||||
width={400}
|
width={400}
|
||||||
height={300}
|
height={300}
|
||||||
sizes="(min-width: 768px) 400px, 100vw"
|
sizes="(min-width: 768px) 400px, 100vw"
|
||||||
@@ -40,13 +50,13 @@ export default async function HotelListingItem({
|
|||||||
/>
|
/>
|
||||||
<section className={styles.content}>
|
<section className={styles.content}>
|
||||||
<div className={styles.intro}>
|
<div className={styles.intro}>
|
||||||
<HotelLogoIcon hotelId={hotel.operaId} hotelType={hotel.hotelType} />
|
<HotelLogoIcon hotelId={id} hotelType={hotelType} />
|
||||||
<Typography variant="Title/Subtitle/lg">
|
<Typography variant="Title/Subtitle/lg">
|
||||||
<h4>{hotel.name}</h4>
|
<h4>{name}</h4>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
<div className={styles.captions}>
|
<div className={styles.captions}>
|
||||||
<span>{hotel.address.streetAddress}</span>
|
<span>{address.streetAddress}</span>
|
||||||
<Divider variant="vertical" />
|
<Divider variant="vertical" />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
@@ -54,9 +64,7 @@ export default async function HotelListingItem({
|
|||||||
defaultMessage: "{number} km to city center",
|
defaultMessage: "{number} km to city center",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
number: getSingleDecimal(
|
number: getSingleDecimal(location.distanceToCentre / 1000),
|
||||||
hotel.location.distanceToCentre / 1000
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -77,7 +85,7 @@ export default async function HotelListingItem({
|
|||||||
variant="Primary"
|
variant="Primary"
|
||||||
size="Small"
|
size="Small"
|
||||||
href={cta.url}
|
href={cta.url}
|
||||||
target={cta.openInNewTab ? "_blank" : "_self"}
|
target={cta.openInNewTab ? "_blank" : undefined}
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
>
|
>
|
||||||
{cta.text}
|
{cta.text}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,13 +30,11 @@ export default async function HotelListing({
|
|||||||
<Typography variant="Title/sm">
|
<Typography variant="Title/sm">
|
||||||
<h3 className={styles.heading}>{heading}</h3>
|
<h3 className={styles.heading}>{heading}</h3>
|
||||||
</Typography>
|
</Typography>
|
||||||
{hotels.map(({ url, hotel, additionalData }) => (
|
{hotels.map((hotelData) => (
|
||||||
<HotelListingItem
|
<HotelListingItem
|
||||||
key={hotel.name}
|
key={hotelData.hotel.name}
|
||||||
hotel={hotel}
|
hotelData={hotelData}
|
||||||
additionalData={additionalData}
|
|
||||||
contentType={contentType}
|
contentType={contentType}
|
||||||
url={url}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SectionContainer>
|
</SectionContainer>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default function Blocks({ blocks }: BlocksProps) {
|
|||||||
<CampaignHotelListing
|
<CampaignHotelListing
|
||||||
heading={block.hotel_listing.heading}
|
heading={block.hotel_listing.heading}
|
||||||
hotelIds={block.hotel_listing.hotelIds}
|
hotelIds={block.hotel_listing.hotelIds}
|
||||||
|
isMainBlock={true}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import HotelListItem from "../HotelListItem"
|
|||||||
|
|
||||||
import styles from "./hotelList.module.css"
|
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 {
|
interface HotelListContentProps {
|
||||||
hotelsCount: number
|
hotelsCount: number
|
||||||
visibleHotels: DestinationPagesHotelData[]
|
visibleHotels: HotelListingHotelData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HotelListContent({
|
export default function HotelListContent({
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ import { getVisibleHotels } from "./utils"
|
|||||||
|
|
||||||
import styles from "./hotelList.module.css"
|
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() {
|
export default function HotelList() {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
const coreLib = useMapsLibrary("core")
|
const coreLib = useMapsLibrary("core")
|
||||||
const [visibleHotels, setVisibleHotels] = useState<
|
const [visibleHotels, setVisibleHotels] = useState<HotelListingHotelData[]>(
|
||||||
DestinationPagesHotelData[]
|
[]
|
||||||
>([])
|
)
|
||||||
const { activeHotels, isLoading } = useDestinationDataStore((state) => ({
|
const { activeHotels, isLoading } = useDestinationDataStore((state) => ({
|
||||||
activeHotels: state.activeHotels,
|
activeHotels: state.activeHotels,
|
||||||
isLoading: state.isLoading,
|
isLoading: state.isLoading,
|
||||||
|
|||||||
@@ -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(
|
export function getVisibleHotels(
|
||||||
hotels: DestinationPagesHotelData[],
|
hotels: HotelListingHotelData[],
|
||||||
map: google.maps.Map | null
|
map: google.maps.Map | null
|
||||||
) {
|
) {
|
||||||
const bounds = map?.getBounds()
|
const bounds = map?.getBounds()
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
|
|||||||
|
|
||||||
import styles from "./hotelListItem.module.css"
|
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 intl = useIntl()
|
||||||
const { hotel, url } = data
|
|
||||||
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
||||||
const amenities = hotel.detailedFacilities.slice(0, 5)
|
const amenities = hotel.detailedFacilities.slice(0, 5)
|
||||||
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
|
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
import { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
|
import {
|
||||||
import { getFiltersFromHotels } from "@scandic-hotels/trpc/routers/contentstack/metadata/helpers"
|
type HotelSortItem,
|
||||||
|
HotelSortOption,
|
||||||
|
} from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
|
||||||
|
|
||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +16,7 @@ import {
|
|||||||
import Breadcrumbs from "@/components/Breadcrumbs"
|
import Breadcrumbs from "@/components/Breadcrumbs"
|
||||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
import { getLang } from "@/i18n/serverContext"
|
||||||
import DestinationDataProvider from "@/providers/DestinationDataProvider"
|
import DestinationDataProvider from "@/providers/DestinationDataProvider"
|
||||||
import { getPathname } from "@/utils/getPathname"
|
import { getPathname } from "@/utils/getPathname"
|
||||||
|
|
||||||
@@ -30,8 +34,6 @@ import DestinationCityPageSkeleton from "./DestinationCityPageSkeleton"
|
|||||||
|
|
||||||
import styles from "./destinationCityPage.module.css"
|
import styles from "./destinationCityPage.module.css"
|
||||||
|
|
||||||
import type { SortItem } from "@/types/components/destinationFilterAndSort"
|
|
||||||
|
|
||||||
interface DestinationCityPageProps {
|
interface DestinationCityPageProps {
|
||||||
isMapView: boolean
|
isMapView: boolean
|
||||||
filterFromUrl?: string
|
filterFromUrl?: string
|
||||||
@@ -42,6 +44,7 @@ export default async function DestinationCityPage({
|
|||||||
filterFromUrl,
|
filterFromUrl,
|
||||||
}: DestinationCityPageProps) {
|
}: DestinationCityPageProps) {
|
||||||
const intl = await getIntl()
|
const intl = await getIntl()
|
||||||
|
const lang = await getLang()
|
||||||
const pathname = await getPathname()
|
const pathname = await getPathname()
|
||||||
const pageData = await getDestinationCityPage()
|
const pageData = await getDestinationCityPage()
|
||||||
|
|
||||||
@@ -62,26 +65,26 @@ export default async function DestinationCityPage({
|
|||||||
} = destinationCityPage
|
} = destinationCityPage
|
||||||
|
|
||||||
const allHotels = await getHotelsByCityIdentifier(cityIdentifier)
|
const allHotels = await getHotelsByCityIdentifier(cityIdentifier)
|
||||||
const allFilters = getFiltersFromHotels(allHotels)
|
const allFilters = getFiltersFromHotels(allHotels, lang)
|
||||||
const sortItems: SortItem[] = [
|
const sortItems: HotelSortItem[] = [
|
||||||
{
|
{
|
||||||
label: intl.formatMessage({
|
label: intl.formatMessage({
|
||||||
defaultMessage: "Distance to city center",
|
defaultMessage: "Distance to city center",
|
||||||
}),
|
}),
|
||||||
value: SortOption.Distance,
|
value: HotelSortOption.Distance,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: intl.formatMessage({
|
label: intl.formatMessage({
|
||||||
defaultMessage: "Name",
|
defaultMessage: "Name",
|
||||||
}),
|
}),
|
||||||
value: SortOption.Name,
|
value: HotelSortOption.Name,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: intl.formatMessage({
|
label: intl.formatMessage({
|
||||||
defaultMessage: "TripAdvisor rating",
|
defaultMessage: "TripAdvisor rating",
|
||||||
}),
|
}),
|
||||||
value: SortOption.TripAdvisorRating,
|
value: HotelSortOption.TripAdvisorRating,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
import { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
|
import {
|
||||||
import { getFiltersFromHotels } from "@scandic-hotels/trpc/routers/contentstack/metadata/helpers"
|
type HotelSortItem,
|
||||||
|
HotelSortOption,
|
||||||
|
} from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
|
||||||
|
|
||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +17,7 @@ import {
|
|||||||
import Breadcrumbs from "@/components/Breadcrumbs"
|
import Breadcrumbs from "@/components/Breadcrumbs"
|
||||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
import { getLang } from "@/i18n/serverContext"
|
||||||
import DestinationDataProvider from "@/providers/DestinationDataProvider"
|
import DestinationDataProvider from "@/providers/DestinationDataProvider"
|
||||||
import { getPathname } from "@/utils/getPathname"
|
import { getPathname } from "@/utils/getPathname"
|
||||||
|
|
||||||
@@ -30,8 +34,6 @@ import DestinationCountryPageSkeleton from "./DestinationCountryPageSkeleton"
|
|||||||
|
|
||||||
import styles from "./destinationCountryPage.module.css"
|
import styles from "./destinationCountryPage.module.css"
|
||||||
|
|
||||||
import type { SortItem } from "@/types/components/destinationFilterAndSort"
|
|
||||||
|
|
||||||
interface DestinationCountryPageProps {
|
interface DestinationCountryPageProps {
|
||||||
isMapView: boolean
|
isMapView: boolean
|
||||||
filterFromUrl?: string
|
filterFromUrl?: string
|
||||||
@@ -42,6 +44,7 @@ export default async function DestinationCountryPage({
|
|||||||
filterFromUrl,
|
filterFromUrl,
|
||||||
}: DestinationCountryPageProps) {
|
}: DestinationCountryPageProps) {
|
||||||
const intl = await getIntl()
|
const intl = await getIntl()
|
||||||
|
const lang = await getLang()
|
||||||
const pathname = await getPathname()
|
const pathname = await getPathname()
|
||||||
const pageData = await getDestinationCountryPage()
|
const pageData = await getDestinationCountryPage()
|
||||||
|
|
||||||
@@ -65,21 +68,21 @@ export default async function DestinationCountryPage({
|
|||||||
getHotelsByCountry(destination_settings.country),
|
getHotelsByCountry(destination_settings.country),
|
||||||
getDestinationCityPagesByCountry(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({
|
label: intl.formatMessage({
|
||||||
defaultMessage: "Recommended",
|
defaultMessage: "Recommended",
|
||||||
}),
|
}),
|
||||||
value: SortOption.Recommended,
|
value: HotelSortOption.Recommended,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: intl.formatMessage({
|
label: intl.formatMessage({
|
||||||
defaultMessage: "Name",
|
defaultMessage: "Name",
|
||||||
}),
|
}),
|
||||||
value: SortOption.Name,
|
value: HotelSortOption.Name,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import HotelMapCard from "../HotelMapCard"
|
|||||||
|
|
||||||
import styles from "./hotelCardCarousel.module.css"
|
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 {
|
interface MapCardCarouselProps {
|
||||||
visibleHotels: DestinationPagesHotelData[]
|
visibleHotels: HotelListingHotelData[]
|
||||||
}
|
}
|
||||||
export default function HotelCardCarousel({
|
export default function HotelCardCarousel({
|
||||||
visibleHotels,
|
visibleHotels,
|
||||||
@@ -52,7 +52,7 @@ export default function HotelCardCarousel({
|
|||||||
tripadvisorRating={hotel.tripadvisor}
|
tripadvisorRating={hotel.tripadvisor}
|
||||||
hotelName={hotel.name}
|
hotelName={hotel.name}
|
||||||
url={url}
|
url={url}
|
||||||
image={getImage({ hotel, url })}
|
image={getImage({ hotel })}
|
||||||
amenities={hotel.detailedFacilities.slice(0, 3)}
|
amenities={hotel.detailedFacilities.slice(0, 3)}
|
||||||
/>
|
/>
|
||||||
</Carousel.Item>
|
</Carousel.Item>
|
||||||
@@ -62,11 +62,11 @@ export default function HotelCardCarousel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImage(hotel: DestinationPagesHotelData) {
|
function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
|
||||||
return {
|
return {
|
||||||
src: hotel.hotel.galleryImages?.[0]?.imageSizes.medium,
|
src: hotel.galleryImages?.[0]?.imageSizes.large,
|
||||||
alt:
|
alt:
|
||||||
hotel.hotel.galleryImages?.[0]?.metaData.altText ||
|
hotel.galleryImages?.[0]?.metaData.altText ||
|
||||||
hotel.hotel.galleryImages?.[0]?.metaData.altText_En,
|
hotel.galleryImages?.[0]?.metaData.altText_En,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,19 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
|
|||||||
|
|
||||||
import styles from "./hotelListingItem.module.css"
|
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 intl = useIntl()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const { hotel, url } = data
|
|
||||||
const { setActiveMarker } = useDestinationPageHotelsMapStore()
|
const { setActiveMarker } = useDestinationPageHotelsMapStore()
|
||||||
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
||||||
const amenities = hotel.detailedFacilities.slice(0, 5)
|
const amenities = hotel.detailedFacilities.slice(0, 5)
|
||||||
@@ -103,9 +110,9 @@ export default function HotelListingItem(data: DestinationPagesHotelData) {
|
|||||||
</div>
|
</div>
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
{hotel.hotelDescription ? (
|
{hotel.description ? (
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
<p>{hotel.hotelDescription}</p>
|
<p>{hotel.description}</p>
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import styles from "./dialogImage.module.css"
|
|||||||
interface DialogImageProps {
|
interface DialogImageProps {
|
||||||
image?: string
|
image?: string
|
||||||
altText?: string
|
altText?: string
|
||||||
rating?: number
|
rating?: number | null
|
||||||
imageError: boolean
|
imageError: boolean
|
||||||
setImageError: (error: boolean) => void
|
setImageError: (error: boolean) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ import type { GalleryImage } from "@/types/components/imageGallery"
|
|||||||
|
|
||||||
interface HotelMapCardProps {
|
interface HotelMapCardProps {
|
||||||
amenities: Amenities
|
amenities: Amenities
|
||||||
tripadvisorRating: number | undefined
|
tripadvisorRating: number | null
|
||||||
hotelName: string
|
hotelName: string
|
||||||
image: GalleryImage | null
|
image: GalleryImage | null
|
||||||
url: string
|
url: string | null
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "./utils"
|
|||||||
|
|
||||||
import styles from "./map.module.css"
|
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"
|
import type { MapLocation } from "@/types/components/mapLocation"
|
||||||
|
|
||||||
interface MapProps {
|
interface MapProps {
|
||||||
hotels: DestinationPagesHotelData[]
|
hotels: HotelListingHotelData[]
|
||||||
mapId: string
|
mapId: string
|
||||||
apiKey: string
|
apiKey: string
|
||||||
pageType: "city" | "country"
|
pageType: "city" | "country"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DestinationMarker,
|
DestinationMarker,
|
||||||
@@ -29,7 +29,7 @@ export function mapMarkerDataToGeoJson(markers: DestinationMarker[]) {
|
|||||||
return geoJson
|
return geoJson
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) {
|
export function getHotelMapMarkers(hotels: HotelListingHotelData[]) {
|
||||||
const markers = hotels
|
const markers = hotels
|
||||||
.map(({ hotel, url }) => ({
|
.map(({ hotel, url }) => ({
|
||||||
id: hotel.id,
|
id: hotel.id,
|
||||||
@@ -41,10 +41,10 @@ export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) {
|
|||||||
lng: hotel.location.longitude,
|
lng: hotel.location.longitude,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
url: url,
|
url,
|
||||||
tripadvisor: hotel.tripadvisor,
|
tripadvisor: hotel.tripadvisor,
|
||||||
amenities: hotel.detailedFacilities.slice(0, 3),
|
amenities: hotel.detailedFacilities.slice(0, 3),
|
||||||
image: getImage({ hotel, url }),
|
image: getImage({ hotel }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
.filter((item): item is DestinationMarker => !!item.coordinates)
|
.filter((item): item is DestinationMarker => !!item.coordinates)
|
||||||
@@ -52,11 +52,11 @@ export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) {
|
|||||||
return markers
|
return markers
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImage(hotel: DestinationPagesHotelData) {
|
function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
|
||||||
return {
|
return {
|
||||||
src: hotel.hotel.galleryImages?.[0]?.imageSizes.medium,
|
src: hotel.galleryImages?.[0]?.imageSizes.large,
|
||||||
alt:
|
alt:
|
||||||
hotel.hotel.galleryImages?.[0]?.metaData.altText ||
|
hotel.galleryImages?.[0]?.metaData.altText ||
|
||||||
hotel.hotel.galleryImages?.[0]?.metaData.altText_En,
|
hotel.galleryImages?.[0]?.metaData.altText_En,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
import type { IntlShape } from "react-intl"
|
||||||
|
|
||||||
export function getHeadingText(
|
export function getHeadingText(
|
||||||
intl: IntlShape,
|
intl: IntlShape,
|
||||||
location: string,
|
location: string,
|
||||||
allFilters: CategorizedFilters,
|
allFilters: CategorizedHotelFilters,
|
||||||
filter?: string
|
filter?: string
|
||||||
) {
|
) {
|
||||||
if (filter) {
|
if (filter) {
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import Checkbox from "./Checkbox"
|
|||||||
|
|
||||||
import styles from "./filter.module.css"
|
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 {
|
interface FilterProps {
|
||||||
filters: CategorizedFilters
|
filters: CategorizedHotelFilters
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Filter({ filters }: FilterProps) {
|
export default function Filter({ filters }: FilterProps) {
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
|
|||||||
|
|
||||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||||
|
|
||||||
import type { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
|
import type {
|
||||||
|
HotelSortItem,
|
||||||
import type { SortItem } from "@/types/components/destinationFilterAndSort"
|
HotelSortOption,
|
||||||
|
} from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
|
||||||
interface SortProps {
|
interface SortProps {
|
||||||
sortItems: SortItem[]
|
sortItems: HotelSortItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sort({ sortItems }: SortProps) {
|
export default function Sort({ sortItems }: SortProps) {
|
||||||
@@ -33,7 +34,7 @@ export default function Sort({ sortItems }: SortProps) {
|
|||||||
})}
|
})}
|
||||||
name="sort"
|
name="sort"
|
||||||
showRadioButton
|
showRadioButton
|
||||||
onSelect={(sort) => setPendingSort(sort as SortOption)}
|
onSelect={(sort) => setPendingSort(sort as HotelSortOption)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
apps/scandic-web/components/HotelFilterAndSort/Filter/index.tsx
Normal file
115
apps/scandic-web/components/HotelFilterAndSort/Filter/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
178
apps/scandic-web/components/HotelFilterAndSort/index.tsx
Normal file
178
apps/scandic-web/components/HotelFilterAndSort/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
6
apps/scandic-web/contexts/HotelListingData.ts
Normal file
6
apps/scandic-web/contexts/HotelListingData.ts
Normal 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)
|
||||||
@@ -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}</>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { 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<
|
const HOTEL_SORTING_STRATEGIES: Partial<
|
||||||
Record<
|
Record<
|
||||||
SortOption,
|
HotelSortOption,
|
||||||
(a: DestinationPagesHotelData, b: DestinationPagesHotelData) => number
|
(a: HotelListingHotelData, b: HotelListingHotelData) => number
|
||||||
>
|
>
|
||||||
> = {
|
> = {
|
||||||
[SortOption.Name]: function (a, b) {
|
[HotelSortOption.Name]: function (a, b) {
|
||||||
return a.hotel.name.localeCompare(b.hotel.name)
|
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)
|
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
|
return a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFilteredHotels(
|
export function getFilteredHotels(
|
||||||
hotels: DestinationPagesHotelData[],
|
hotels: HotelListingHotelData[],
|
||||||
filters: string[]
|
filters: string[]
|
||||||
) {
|
) {
|
||||||
if (filters.length) {
|
if (filters.length) {
|
||||||
@@ -37,7 +38,7 @@ export function getFilteredHotels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFilteredCities(
|
export function getFilteredCities(
|
||||||
filteredHotels: DestinationPagesHotelData[],
|
filteredHotels: HotelListingHotelData[],
|
||||||
cities: DestinationCityListItem[]
|
cities: DestinationCityListItem[]
|
||||||
) {
|
) {
|
||||||
const filteredCityIdentifiers = filteredHotels.map(
|
const filteredCityIdentifiers = filteredHotels.map(
|
||||||
@@ -52,8 +53,8 @@ export function getFilteredCities(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSortedHotels(
|
export function getSortedHotels(
|
||||||
hotels: DestinationPagesHotelData[],
|
hotels: HotelListingHotelData[],
|
||||||
sortOption: SortOption
|
sortOption: HotelSortOption
|
||||||
) {
|
) {
|
||||||
const sortFn = HOTEL_SORTING_STRATEGIES[sortOption]
|
const sortFn = HOTEL_SORTING_STRATEGIES[sortOption]
|
||||||
return sortFn ? [...hotels].sort(sortFn) : hotels
|
return sortFn ? [...hotels].sort(sortFn) : hotels
|
||||||
@@ -61,9 +62,9 @@ export function getSortedHotels(
|
|||||||
|
|
||||||
export function isValidSortOption(
|
export function isValidSortOption(
|
||||||
value: string,
|
value: string,
|
||||||
sortItems: SortItem[]
|
sortItems: HotelSortItem[]
|
||||||
): value is SortOption {
|
): value is HotelSortOption {
|
||||||
return sortItems.map((item) => item.value).includes(value as SortOption)
|
return sortItems.map((item) => item.value).includes(value as HotelSortOption)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBasePathNameWithoutFilters(
|
export function getBasePathNameWithoutFilters(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
isValidSortOption,
|
isValidSortOption,
|
||||||
} from "./helper"
|
} from "./helper"
|
||||||
|
|
||||||
import type { Filter } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
|
import type { HotelFilter } from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DestinationDataState,
|
DestinationDataState,
|
||||||
@@ -36,8 +36,8 @@ export function createDestinationDataStore({
|
|||||||
}: InitialState) {
|
}: InitialState) {
|
||||||
const defaultSort =
|
const defaultSort =
|
||||||
sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value
|
sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value
|
||||||
const allFilterSlugs = Object.values(allFilters).flatMap((filter: Filter[]) =>
|
const allFilterSlugs = Object.values(allFilters).flatMap(
|
||||||
filter.map((f) => f.slug)
|
(filter: HotelFilter[]) => filter.map((f) => f.slug)
|
||||||
)
|
)
|
||||||
|
|
||||||
const activeFilters: string[] = filterFromUrl ? [filterFromUrl] : []
|
const activeFilters: string[] = filterFromUrl ? [filterFromUrl] : []
|
||||||
|
|||||||
64
apps/scandic-web/stores/hotel-listing-data/helper.ts
Normal file
64
apps/scandic-web/stores/hotel-listing-data/helper.ts
Normal 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("/")
|
||||||
|
}
|
||||||
175
apps/scandic-web/stores/hotel-listing-data/index.ts
Normal file
175
apps/scandic-web/stores/hotel-listing-data/index.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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"
|
import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks"
|
||||||
|
|
||||||
export interface HotelListingItemProps {
|
export interface HotelListingItemProps {
|
||||||
hotel: Hotel
|
hotelData: HotelListingHotelData
|
||||||
additionalData: AdditionalData
|
|
||||||
contentType: HotelListing["contentType"]
|
contentType: HotelListing["contentType"]
|
||||||
url: string | null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
|
|
||||||
|
|
||||||
export interface SortItem {
|
|
||||||
label: string
|
|
||||||
value: SortOption
|
|
||||||
isDefault?: boolean
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import type { Amenities } from "@scandic-hotels/trpc/types/hotel"
|
||||||
import type { FeatureCollection, Point } from "geojson"
|
import type { FeatureCollection, Point } from "geojson"
|
||||||
|
|
||||||
import type { Amenities } from "@scandic-hotels/trpc/types/hotel"
|
|
||||||
import type { GalleryImage } from "../imageGallery"
|
import type { GalleryImage } from "../imageGallery"
|
||||||
|
|
||||||
export interface DestinationMarker {
|
export interface DestinationMarker {
|
||||||
@@ -9,7 +9,7 @@ export interface DestinationMarker {
|
|||||||
name: string
|
name: string
|
||||||
coordinates: google.maps.LatLngLiteral
|
coordinates: google.maps.LatLngLiteral
|
||||||
url: string
|
url: string
|
||||||
tripadvisor: number | undefined
|
tripadvisor: number | null
|
||||||
amenities: Amenities
|
amenities: Amenities
|
||||||
image: GalleryImage
|
image: GalleryImage
|
||||||
}
|
}
|
||||||
|
|||||||
5
apps/scandic-web/types/contexts/hotel-listing-data.ts
Normal file
5
apps/scandic-web/types/contexts/hotel-listing-data.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { createHotelListingDataStore } from "@/stores/hotel-listing-data"
|
||||||
|
|
||||||
|
export type HotelListingDataStore = ReturnType<
|
||||||
|
typeof createHotelListingDataStore
|
||||||
|
>
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
|
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
|
||||||
import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
|
import type {
|
||||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
CategorizedHotelFilters,
|
||||||
|
HotelListingHotelData,
|
||||||
import type { SortItem } from "../components/destinationFilterAndSort"
|
HotelSortItem,
|
||||||
|
} from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
|
||||||
export interface DestinationDataProviderProps extends React.PropsWithChildren {
|
export interface DestinationDataProviderProps extends React.PropsWithChildren {
|
||||||
allHotels: DestinationPagesHotelData[]
|
allHotels: HotelListingHotelData[]
|
||||||
allCities?: DestinationCityListItem[]
|
allCities?: DestinationCityListItem[]
|
||||||
allFilters: CategorizedFilters
|
allFilters: CategorizedHotelFilters
|
||||||
filterFromUrl?: string
|
filterFromUrl?: string
|
||||||
sortItems: SortItem[]
|
sortItems: HotelSortItem[]
|
||||||
pathname: string
|
pathname: string
|
||||||
}
|
}
|
||||||
|
|||||||
11
apps/scandic-web/types/providers/hotel-listing-data.ts
Normal file
11
apps/scandic-web/types/providers/hotel-listing-data.ts
Normal 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[]
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
|
|
||||||
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
|
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
|
||||||
import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
|
import type {
|
||||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
CategorizedHotelFilters,
|
||||||
|
HotelListingHotelData,
|
||||||
|
HotelSortItem,
|
||||||
|
HotelSortOption,
|
||||||
|
} from "@scandic-hotels/trpc/types/hotel"
|
||||||
import type { ReadonlyURLSearchParams } from "next/navigation"
|
import type { ReadonlyURLSearchParams } from "next/navigation"
|
||||||
|
|
||||||
import type { SortItem } from "../components/destinationFilterAndSort"
|
|
||||||
|
|
||||||
interface Actions {
|
interface Actions {
|
||||||
updateActiveFiltersAndSort: (filters: string[], sort: string | null) => void
|
updateActiveFiltersAndSort: (filters: string[], sort: string | null) => void
|
||||||
setPendingSort: (sort: SortOption) => void
|
setPendingSort: (sort: HotelSortOption) => void
|
||||||
togglePendingFilter: (filter: string) => void
|
togglePendingFilter: (filter: string) => void
|
||||||
clearPendingFilters: () => void
|
clearPendingFilters: () => void
|
||||||
resetPendingValues: () => void
|
resetPendingValues: () => void
|
||||||
@@ -16,8 +17,8 @@ interface Actions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitCallbackData {
|
export interface SubmitCallbackData {
|
||||||
sort: SortOption
|
sort: HotelSortOption
|
||||||
defaultSort: SortOption
|
defaultSort: HotelSortOption
|
||||||
filters: string[]
|
filters: string[]
|
||||||
basePath: string
|
basePath: string
|
||||||
}
|
}
|
||||||
@@ -25,19 +26,19 @@ export interface DestinationDataState {
|
|||||||
actions: Actions
|
actions: Actions
|
||||||
allCities: DestinationCityListItem[]
|
allCities: DestinationCityListItem[]
|
||||||
activeCities: DestinationCityListItem[]
|
activeCities: DestinationCityListItem[]
|
||||||
allHotels: DestinationPagesHotelData[]
|
allHotels: HotelListingHotelData[]
|
||||||
activeHotels: DestinationPagesHotelData[]
|
activeHotels: HotelListingHotelData[]
|
||||||
pendingSort: SortOption
|
pendingSort: HotelSortOption
|
||||||
activeSort: SortOption
|
activeSort: HotelSortOption
|
||||||
defaultSort: SortOption
|
defaultSort: HotelSortOption
|
||||||
pendingFilters: string[]
|
pendingFilters: string[]
|
||||||
activeFilters: string[]
|
activeFilters: string[]
|
||||||
pendingHotelCount: number
|
pendingHotelCount: number
|
||||||
pendingCityCount: number
|
pendingCityCount: number
|
||||||
allFilters: CategorizedFilters
|
allFilters: CategorizedHotelFilters
|
||||||
allFilterSlugs: string[]
|
allFilterSlugs: string[]
|
||||||
basePathnameWithoutFilters: string
|
basePathnameWithoutFilters: string
|
||||||
sortItems: SortItem[]
|
sortItems: HotelSortItem[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
46
apps/scandic-web/types/stores/hotel-listing-data.ts
Normal file
46
apps/scandic-web/types/stores/hotel-listing-data.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export enum SortOption {
|
|
||||||
Recommended = "recommended",
|
|
||||||
Distance = "distance",
|
|
||||||
Name = "name",
|
|
||||||
TripAdvisorRating = "tripadvisor",
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import type {
|
|
||||||
CategorizedFilters,
|
|
||||||
Filter,
|
|
||||||
} from "../../../types/destinationFilterAndSort"
|
|
||||||
import type { DestinationPagesHotelData } from "../../../types/hotel"
|
|
||||||
|
|
||||||
const HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES = [
|
|
||||||
"Hotel surroundings",
|
|
||||||
"Hotel omgivelser",
|
|
||||||
"Hotelumgebung",
|
|
||||||
"Hotellia lähellä",
|
|
||||||
"Hotellomgivelser",
|
|
||||||
"Omgivningar",
|
|
||||||
]
|
|
||||||
|
|
||||||
const HOTEL_FACILITIES_FILTER_TYPE_NAMES = [
|
|
||||||
"Hotel facilities",
|
|
||||||
"Hotellfaciliteter",
|
|
||||||
"Hotelfaciliteter",
|
|
||||||
"Hotel faciliteter",
|
|
||||||
"Hotel-Infos",
|
|
||||||
"Hotellin palvelut",
|
|
||||||
]
|
|
||||||
|
|
||||||
export function getFiltersFromHotels(
|
|
||||||
hotels: DestinationPagesHotelData[]
|
|
||||||
): CategorizedFilters {
|
|
||||||
if (hotels.length === 0) {
|
|
||||||
return { facilityFilters: [], surroundingsFilters: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters = hotels.flatMap(({ hotel }) => hotel.detailedFacilities)
|
|
||||||
const uniqueFilterNames = [...new Set(filters.map((filter) => filter.name))]
|
|
||||||
const filterList = uniqueFilterNames
|
|
||||||
.map((filterName) => {
|
|
||||||
const filter = filters.find((filter) => filter.name === filterName)
|
|
||||||
return filter
|
|
||||||
? {
|
|
||||||
name: filter.name,
|
|
||||||
slug: filter.slug,
|
|
||||||
filterType: filter.filter,
|
|
||||||
sortOrder: filter.sortOrder,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
})
|
|
||||||
.filter((filter): filter is Filter => !!filter)
|
|
||||||
|
|
||||||
const facilityFilters = filterList.filter((filter) =>
|
|
||||||
HOTEL_FACILITIES_FILTER_TYPE_NAMES.includes(filter.filterType)
|
|
||||||
)
|
|
||||||
const surroundingsFilters = filterList.filter((filter) =>
|
|
||||||
HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES.includes(filter.filterType)
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
facilityFilters: sortFilters(facilityFilters),
|
|
||||||
surroundingsFilters: sortFilters(surroundingsFilters),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortFilters(filters: Filter[]): Filter[] {
|
|
||||||
return [...filters].sort((a, b) => {
|
|
||||||
// First sort by sortOrder
|
|
||||||
const orderDiff = a.sortOrder - b.sortOrder
|
|
||||||
// If sortOrder is the same, sort by name as secondary criterion
|
|
||||||
return orderDiff === 0 ? a.name.localeCompare(b.name) : orderDiff
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SortOption } from "../../../enums/destinationFilterAndSort"
|
|
||||||
import { ApiCountry } from "../../../types/country"
|
import { ApiCountry } from "../../../types/country"
|
||||||
|
import { HotelSortOption } from "../../../types/hotel"
|
||||||
|
import { getFiltersFromHotels } from "../../../utils/getFiltersFromHotels"
|
||||||
import { getSortedCities } from "../../../utils/getSortedCities"
|
import { getSortedCities } from "../../../utils/getSortedCities"
|
||||||
import {
|
import {
|
||||||
getCityByCityIdentifier,
|
getCityByCityIdentifier,
|
||||||
@@ -8,7 +9,6 @@ import {
|
|||||||
getHotelsByHotelIds,
|
getHotelsByHotelIds,
|
||||||
} from "../../hotels/utils"
|
} from "../../hotels/utils"
|
||||||
import { getCityPages } from "../destinationCountryPage/utils"
|
import { getCityPages } from "../destinationCountryPage/utils"
|
||||||
import { getFiltersFromHotels } from "./helpers"
|
|
||||||
|
|
||||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export async function getCityData(
|
|||||||
|
|
||||||
let filterType
|
let filterType
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const allFilters = getFiltersFromHotels(hotels)
|
const allFilters = getFiltersFromHotels(hotels, lang)
|
||||||
const facilityFilter = allFilters.facilityFilters.find(
|
const facilityFilter = allFilters.facilityFilters.find(
|
||||||
(f) => f.slug === filter
|
(f) => f.slug === filter
|
||||||
)
|
)
|
||||||
@@ -101,7 +101,7 @@ export async function getCountryData(
|
|||||||
let filterType
|
let filterType
|
||||||
|
|
||||||
const cities = await getCityPages(lang, serviceToken, country)
|
const cities = await getCityPages(lang, serviceToken, country)
|
||||||
const sortedCities = getSortedCities(cities, SortOption.Recommended)
|
const sortedCities = getSortedCities(cities, HotelSortOption.Recommended)
|
||||||
const hotelIds = await getHotelIdsByCountry({
|
const hotelIds = await getHotelIdsByCountry({
|
||||||
country,
|
country,
|
||||||
serviceToken,
|
serviceToken,
|
||||||
@@ -110,7 +110,7 @@ export async function getCountryData(
|
|||||||
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
|
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const allFilters = getFiltersFromHotels(hotels)
|
const allFilters = getFiltersFromHotels(hotels, lang)
|
||||||
const facilityFilter = allFilters.facilityFilters.find(
|
const facilityFilter = allFilters.facilityFilters.find(
|
||||||
(f) => f.slug === filter
|
(f) => f.slug === filter
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -239,9 +239,13 @@ export const getHotelsByCSFilterInput = z.object({
|
|||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
hotelsToInclude: z.array(z.string()),
|
hotelsToInclude: z.array(z.string()),
|
||||||
|
contentType: z
|
||||||
|
.enum(["hotel", "restaurant", "meeting"])
|
||||||
|
.optional()
|
||||||
|
.default("hotel"),
|
||||||
})
|
})
|
||||||
export interface GetHotelsByCSFilterInput
|
export interface GetHotelsByCSFilterInput
|
||||||
extends z.infer<typeof getHotelsByCSFilterInput> {}
|
extends z.input<typeof getHotelsByCSFilterInput> {}
|
||||||
|
|
||||||
export const nearbyHotelIdsInput = z.object({
|
export const nearbyHotelIdsInput = z.object({
|
||||||
hotelId: z.string(),
|
hotelId: z.string(),
|
||||||
|
|||||||
@@ -616,14 +616,14 @@ export const roomFeaturesSchema = z
|
|||||||
return data.data.attributes.roomFeatures
|
return data.data.attributes.roomFeatures
|
||||||
})
|
})
|
||||||
|
|
||||||
export const destinationPagesHotelDataSchema = z
|
export const hotelListingHotelDataSchema = z.object({
|
||||||
.object({
|
hotel: z.object({
|
||||||
data: z.object({
|
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
countryCode: z.string(),
|
||||||
location: locationSchema,
|
location: locationSchema,
|
||||||
cityIdentifier: z.string().optional(),
|
cityIdentifier: z.string().nullable(),
|
||||||
tripadvisor: z.number().optional(),
|
tripadvisor: z.number().nullable(),
|
||||||
detailedFacilities: detailedFacilitiesSchema,
|
detailedFacilities: detailedFacilitiesSchema,
|
||||||
galleryImages: z
|
galleryImages: z
|
||||||
.array(imageSchema)
|
.array(imageSchema)
|
||||||
@@ -632,24 +632,8 @@ export const destinationPagesHotelDataSchema = z
|
|||||||
address: addressSchema,
|
address: addressSchema,
|
||||||
hotelType: z.string(),
|
hotelType: z.string(),
|
||||||
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
|
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
|
||||||
url: z.string().optional(),
|
description: z.string().nullable(),
|
||||||
hotelContent: z
|
|
||||||
.object({
|
|
||||||
texts: z.object({
|
|
||||||
descriptions: z.object({
|
|
||||||
short: z.string().optional(),
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
})
|
url: z.string().nullable(),
|
||||||
.optional(),
|
meetingUrl: z.string().nullable(),
|
||||||
}),
|
|
||||||
})
|
|
||||||
.transform(({ data: { hotelContent, ...data } }) => {
|
|
||||||
return {
|
|
||||||
hotel: {
|
|
||||||
...data,
|
|
||||||
hotelDescription: hotelContent?.texts.descriptions?.short,
|
|
||||||
},
|
|
||||||
url: data.url ?? "",
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,19 +53,17 @@ import { getVerifiedUser } from "../user/utils"
|
|||||||
import { additionalDataSchema } from "./schemas/hotel/include/additionalData"
|
import { additionalDataSchema } from "./schemas/hotel/include/additionalData"
|
||||||
import { meetingRoomsSchema } from "./schemas/meetingRoom"
|
import { meetingRoomsSchema } from "./schemas/meetingRoom"
|
||||||
import {
|
import {
|
||||||
|
getBedTypes,
|
||||||
getCitiesByCountry,
|
getCitiesByCountry,
|
||||||
getCountries,
|
getCountries,
|
||||||
getHotel,
|
getHotel,
|
||||||
getHotelIdsByCityId,
|
getHotelIdsByCityId,
|
||||||
getHotelIdsByCityIdentifier,
|
getHotelIdsByCityIdentifier,
|
||||||
getHotelIdsByCountry,
|
getHotelIdsByCountry,
|
||||||
getHotelsByHotelIds,
|
|
||||||
getLocations,
|
|
||||||
} from "./utils"
|
|
||||||
import {
|
|
||||||
getBedTypes,
|
|
||||||
getHotelsAvailabilityByCity,
|
getHotelsAvailabilityByCity,
|
||||||
getHotelsAvailabilityByHotelIds,
|
getHotelsAvailabilityByHotelIds,
|
||||||
|
getHotelsByHotelIds,
|
||||||
|
getLocations,
|
||||||
getPackages,
|
getPackages,
|
||||||
getRoomsAvailability,
|
getRoomsAvailability,
|
||||||
getSelectedRoomAvailability,
|
getSelectedRoomAvailability,
|
||||||
@@ -73,10 +71,7 @@ import {
|
|||||||
selectRateRedirectURL,
|
selectRateRedirectURL,
|
||||||
} from "./utils"
|
} from "./utils"
|
||||||
|
|
||||||
import type {
|
import type { HotelListingHotelData } from "../../types/hotel"
|
||||||
DestinationPagesHotelData,
|
|
||||||
HotelDataWithUrl,
|
|
||||||
} from "../../types/hotel"
|
|
||||||
import type { CityLocation } from "../../types/locations"
|
import type { CityLocation } from "../../types/locations"
|
||||||
import type { Room } from "../../types/room"
|
import type { Room } from "../../types/room"
|
||||||
|
|
||||||
@@ -570,7 +565,7 @@ export const hotelQueryRouter = router({
|
|||||||
get: contentStackBaseWithServiceProcedure
|
get: contentStackBaseWithServiceProcedure
|
||||||
.input(getHotelsByCSFilterInput)
|
.input(getHotelsByCSFilterInput)
|
||||||
.query(async function ({ ctx, input }) {
|
.query(async function ({ ctx, input }) {
|
||||||
const { locationFilter, hotelsToInclude } = input
|
const { locationFilter, hotelsToInclude, contentType } = input
|
||||||
|
|
||||||
const language = ctx.lang
|
const language = ctx.lang
|
||||||
let hotelsToFetch: string[] = []
|
let hotelsToFetch: string[] = []
|
||||||
@@ -669,29 +664,16 @@ export const hotelQueryRouter = router({
|
|||||||
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const hotelPages = await getHotelPageUrls(language)
|
const hotels = await getHotelsByHotelIds({
|
||||||
const hotels = await Promise.all(
|
hotelIds: hotelsToFetch,
|
||||||
hotelsToFetch.map(async (hotelId) => {
|
lang: language,
|
||||||
const hotelData = await getHotel(
|
serviceToken: ctx.serviceToken,
|
||||||
{ hotelId, isCardOnlyPayment: false, language },
|
contentType,
|
||||||
ctx.serviceToken
|
|
||||||
)
|
|
||||||
const hotelPage = hotelPages.find(
|
|
||||||
(page) => page.hotelId === hotelId
|
|
||||||
)
|
|
||||||
|
|
||||||
return hotelData
|
|
||||||
? {
|
|
||||||
...hotelData,
|
|
||||||
url: hotelPage?.url ?? null,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
})
|
})
|
||||||
)
|
|
||||||
|
|
||||||
metricsGetHotelsByCSFilter.success()
|
metricsGetHotelsByCSFilter.success()
|
||||||
|
|
||||||
return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel)
|
return hotels
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
getDestinationsMapData: serviceProcedure
|
getDestinationsMapData: serviceProcedure
|
||||||
@@ -713,7 +695,7 @@ export const hotelQueryRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const countryNames = countries.data.map((country) => country.name)
|
const countryNames = countries.data.map((country) => country.name)
|
||||||
const hotelData: DestinationPagesHotelData[] = (
|
const hotelData: HotelListingHotelData[] = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
countryNames.map(async (country) => {
|
countryNames.map(async (country) => {
|
||||||
const hotelIds = await getHotelIdsByCountry({
|
const hotelIds = await getHotelIdsByCountry({
|
||||||
|
|||||||
@@ -48,8 +48,11 @@ import type {
|
|||||||
RoomsAvailabilityOutputSchema,
|
RoomsAvailabilityOutputSchema,
|
||||||
} from "../../types/availability"
|
} from "../../types/availability"
|
||||||
import type { BedTypeSelection } from "../../types/bedTypeSelection"
|
import type { BedTypeSelection } from "../../types/bedTypeSelection"
|
||||||
import type { Room as RoomCategory } from "../../types/hotel"
|
import type {
|
||||||
import type { DestinationPagesHotelData, HotelInput } from "../../types/hotel"
|
HotelInput,
|
||||||
|
HotelListingHotelData,
|
||||||
|
Room as RoomCategory,
|
||||||
|
} from "../../types/hotel"
|
||||||
import type {
|
import type {
|
||||||
CitiesGroupedByCountry,
|
CitiesGroupedByCountry,
|
||||||
CityLocation,
|
CityLocation,
|
||||||
@@ -348,13 +351,15 @@ export async function getHotelsByHotelIds({
|
|||||||
hotelIds,
|
hotelIds,
|
||||||
lang,
|
lang,
|
||||||
serviceToken,
|
serviceToken,
|
||||||
|
contentType = "hotel",
|
||||||
}: {
|
}: {
|
||||||
hotelIds: string[]
|
hotelIds: string[]
|
||||||
lang: Lang
|
lang: Lang
|
||||||
serviceToken: string
|
serviceToken: string
|
||||||
|
contentType?: "hotel" | "restaurant" | "meeting"
|
||||||
}) {
|
}) {
|
||||||
const cacheClient = await getCacheClient()
|
const cacheClient = await getCacheClient()
|
||||||
const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${hotelIds.sort().join(",")}`
|
const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${contentType}:${hotelIds.sort().join(",")}`
|
||||||
|
|
||||||
return await cacheClient.cacheOrGet(
|
return await cacheClient.cacheOrGet(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
@@ -362,7 +367,7 @@ export async function getHotelsByHotelIds({
|
|||||||
const hotelPages = await getHotelPageUrls(lang)
|
const hotelPages = await getHotelPageUrls(lang)
|
||||||
const chunkedHotelIds = chunk(hotelIds, 10)
|
const chunkedHotelIds = chunk(hotelIds, 10)
|
||||||
|
|
||||||
const hotels: DestinationPagesHotelData[] = []
|
const hotels: HotelListingHotelData[] = []
|
||||||
for (const hotelIdChunk of chunkedHotelIds) {
|
for (const hotelIdChunk of chunkedHotelIds) {
|
||||||
const chunkedHotels = await Promise.all(
|
const chunkedHotels = await Promise.all(
|
||||||
hotelIdChunk.map(async (hotelId) => {
|
hotelIdChunk.map(async (hotelId) => {
|
||||||
@@ -378,22 +383,59 @@ export async function getHotelsByHotelIds({
|
|||||||
const hotelPage = hotelPages.find(
|
const hotelPage = hotelPages.find(
|
||||||
(page) => page.hotelId === hotelId
|
(page) => page.hotelId === hotelId
|
||||||
)
|
)
|
||||||
const { hotel, cities } = hotelResponse
|
const { hotel, cities, additionalData } = hotelResponse
|
||||||
const data: DestinationPagesHotelData = {
|
|
||||||
|
const content = {
|
||||||
|
description: hotel.hotelContent?.texts.descriptions?.short,
|
||||||
|
galleryImages: hotel.galleryImages,
|
||||||
|
url: hotelPage?.url ?? "",
|
||||||
|
openInNewTab: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType === "restaurant") {
|
||||||
|
const restaurantDescription =
|
||||||
|
additionalData?.restaurantsOverviewPage
|
||||||
|
.restaurantsContentDescriptionShort
|
||||||
|
const restaurantImages =
|
||||||
|
additionalData.restaurantImages?.heroImages
|
||||||
|
if (restaurantDescription) {
|
||||||
|
content.description = restaurantDescription
|
||||||
|
}
|
||||||
|
if (restaurantImages && restaurantImages.length > 0) {
|
||||||
|
content.galleryImages = restaurantImages
|
||||||
|
}
|
||||||
|
} else if (contentType === "meeting") {
|
||||||
|
const meetingDescription =
|
||||||
|
hotel.hotelContent.texts.meetingDescription?.short
|
||||||
|
const meetingImages =
|
||||||
|
additionalData?.conferencesAndMeetings?.heroImages
|
||||||
|
if (meetingDescription) {
|
||||||
|
content.description = meetingDescription
|
||||||
|
}
|
||||||
|
if (meetingImages && meetingImages.length > 0) {
|
||||||
|
content.galleryImages = meetingImages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: HotelListingHotelData = {
|
||||||
hotel: {
|
hotel: {
|
||||||
id: hotel.id,
|
id: hotel.id,
|
||||||
galleryImages: hotel.galleryImages,
|
countryCode: hotel.countryCode,
|
||||||
|
galleryImages: content.galleryImages,
|
||||||
name: hotel.name,
|
name: hotel.name,
|
||||||
tripadvisor: hotel.ratings?.tripAdvisor?.rating,
|
tripadvisor: hotel.ratings?.tripAdvisor?.rating || null,
|
||||||
detailedFacilities: hotel.detailedFacilities || [],
|
detailedFacilities: hotel.detailedFacilities.sort(
|
||||||
|
(a, b) => b.sortOrder - a.sortOrder
|
||||||
|
),
|
||||||
location: hotel.location,
|
location: hotel.location,
|
||||||
hotelType: hotel.hotelType,
|
hotelType: hotel.hotelType,
|
||||||
type: hotel.type,
|
type: hotel.type,
|
||||||
address: hotel.address,
|
address: hotel.address,
|
||||||
cityIdentifier: cities?.[0]?.cityIdentifier,
|
cityIdentifier: cities[0]?.cityIdentifier || null,
|
||||||
hotelDescription: hotel.hotelContent?.texts.descriptions?.short,
|
description: content.description || null,
|
||||||
},
|
},
|
||||||
url: hotelPage?.url ?? "",
|
url: content.url,
|
||||||
|
meetingUrl: additionalData.meetingRooms.meetingOnlineLink || null,
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
@@ -402,9 +444,7 @@ export async function getHotelsByHotelIds({
|
|||||||
|
|
||||||
hotels.push(...chunkedHotels)
|
hotels.push(...chunkedHotels)
|
||||||
}
|
}
|
||||||
return hotels.filter(
|
return hotels.filter((hotel): hotel is HotelListingHotelData => !!hotel)
|
||||||
(hotel): hotel is DestinationPagesHotelData => !!hotel
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
"1d"
|
"1d"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
export interface Filter {
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
filterType: string
|
|
||||||
sortOrder: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CategorizedFilters {
|
|
||||||
facilityFilters: Filter[]
|
|
||||||
surroundingsFilters: Filter[]
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
hotelInputSchema,
|
hotelInputSchema,
|
||||||
} from "../routers/hotels/input"
|
} from "../routers/hotels/input"
|
||||||
import type {
|
import type {
|
||||||
destinationPagesHotelDataSchema,
|
hotelListingHotelDataSchema,
|
||||||
hotelSchema,
|
hotelSchema,
|
||||||
} from "../routers/hotels/output"
|
} from "../routers/hotels/output"
|
||||||
import type { citySchema } from "../routers/hotels/schemas/city"
|
import type { citySchema } from "../routers/hotels/schemas/city"
|
||||||
@@ -79,11 +79,35 @@ export type ExtraPageSchema = z.output<typeof extraPageSchema>
|
|||||||
|
|
||||||
export type HotelDataWithUrl = HotelData & { url: string }
|
export type HotelDataWithUrl = HotelData & { url: string }
|
||||||
|
|
||||||
export type DestinationPagesHotelData = z.output<
|
export type HotelListingHotelData = z.output<typeof hotelListingHotelDataSchema>
|
||||||
typeof destinationPagesHotelDataSchema
|
|
||||||
>
|
|
||||||
|
|
||||||
export type CityCoordinatesInput = z.input<typeof cityCoordinatesInputSchema>
|
export type CityCoordinatesInput = z.input<typeof cityCoordinatesInputSchema>
|
||||||
export type HotelInput = z.input<typeof hotelInputSchema>
|
export type HotelInput = z.input<typeof hotelInputSchema>
|
||||||
|
|
||||||
export type RoomType = Pick<Room, "roomTypes" | "name">
|
export type RoomType = Pick<Room, "roomTypes" | "name">
|
||||||
|
|
||||||
|
export interface HotelFilter {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
filterType: string
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategorizedHotelFilters {
|
||||||
|
facilityFilters: HotelFilter[]
|
||||||
|
surroundingsFilters: HotelFilter[]
|
||||||
|
countryFilters: HotelFilter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum HotelSortOption {
|
||||||
|
Recommended = "recommended",
|
||||||
|
Distance = "distance",
|
||||||
|
Name = "name",
|
||||||
|
TripAdvisorRating = "tripadvisor",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotelSortItem {
|
||||||
|
label: string
|
||||||
|
value: HotelSortOption
|
||||||
|
isDefault?: boolean
|
||||||
|
}
|
||||||
|
|||||||
110
packages/trpc/lib/utils/getFiltersFromHotels.ts
Normal file
110
packages/trpc/lib/utils/getFiltersFromHotels.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CategorizedHotelFilters,
|
||||||
|
HotelFilter,
|
||||||
|
HotelListingHotelData,
|
||||||
|
} from "../types/hotel"
|
||||||
|
|
||||||
|
const HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES = [
|
||||||
|
"Hotel surroundings",
|
||||||
|
"Hotel omgivelser",
|
||||||
|
"Hotelumgebung",
|
||||||
|
"Hotellia lähellä",
|
||||||
|
"Hotellomgivelser",
|
||||||
|
"Omgivningar",
|
||||||
|
]
|
||||||
|
|
||||||
|
const HOTEL_FACILITIES_FILTER_TYPE_NAMES = [
|
||||||
|
"Hotel facilities",
|
||||||
|
"Hotellfaciliteter",
|
||||||
|
"Hotelfaciliteter",
|
||||||
|
"Hotel faciliteter",
|
||||||
|
"Hotel-Infos",
|
||||||
|
"Hotellin palvelut",
|
||||||
|
]
|
||||||
|
|
||||||
|
function sortFilters(filters: HotelFilter[]): HotelFilter[] {
|
||||||
|
return [...filters].sort((a, b) => {
|
||||||
|
// First sort by sortOrder
|
||||||
|
const orderDiff = a.sortOrder - b.sortOrder
|
||||||
|
// If sortOrder is the same, sort by name as secondary criterion
|
||||||
|
return orderDiff === 0 ? a.name.localeCompare(b.name) : orderDiff
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFiltersFromHotels(
|
||||||
|
hotels: HotelListingHotelData[],
|
||||||
|
lang: Lang
|
||||||
|
): CategorizedHotelFilters {
|
||||||
|
if (hotels.length === 0) {
|
||||||
|
return { facilityFilters: [], surroundingsFilters: [], countryFilters: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueCountries = new Set(
|
||||||
|
hotels.flatMap(({ hotel }) => hotel.countryCode)
|
||||||
|
)
|
||||||
|
const countryFilters = [...uniqueCountries]
|
||||||
|
.map((countryCode) => {
|
||||||
|
const localizedCountry = getLocalizedCountryByCountryCode(
|
||||||
|
countryCode,
|
||||||
|
lang
|
||||||
|
)
|
||||||
|
return localizedCountry
|
||||||
|
? {
|
||||||
|
name: localizedCountry,
|
||||||
|
slug: countryCode.toLowerCase(),
|
||||||
|
filterType: "Country",
|
||||||
|
sortOrder: 0,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
.filter((filter): filter is HotelFilter => !!filter)
|
||||||
|
const flattenedFacilityFilters = hotels.flatMap(
|
||||||
|
({ hotel }) => hotel.detailedFacilities
|
||||||
|
)
|
||||||
|
const uniqueFacilityFilterNames = [
|
||||||
|
...new Set(flattenedFacilityFilters.map((filter) => filter.name)),
|
||||||
|
]
|
||||||
|
const facilityFilterList = uniqueFacilityFilterNames
|
||||||
|
.map((filterName) => {
|
||||||
|
const filter = flattenedFacilityFilters.find(
|
||||||
|
(filter) => filter.name === filterName
|
||||||
|
)
|
||||||
|
return filter
|
||||||
|
? {
|
||||||
|
name: filter.name,
|
||||||
|
slug: filter.slug,
|
||||||
|
filterType: filter.filter,
|
||||||
|
sortOrder: filter.sortOrder,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
.filter((filter): filter is HotelFilter => !!filter)
|
||||||
|
|
||||||
|
const facilityFilters = facilityFilterList.filter((filter) =>
|
||||||
|
HOTEL_FACILITIES_FILTER_TYPE_NAMES.includes(filter.filterType)
|
||||||
|
)
|
||||||
|
const surroundingsFilters = facilityFilterList.filter((filter) =>
|
||||||
|
HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES.includes(filter.filterType)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
facilityFilters: sortFilters(facilityFilters),
|
||||||
|
surroundingsFilters: sortFilters(surroundingsFilters),
|
||||||
|
countryFilters: sortFilters(countryFilters),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalizedCountryByCountryCode(
|
||||||
|
countryCode: string,
|
||||||
|
lang: Lang
|
||||||
|
): string | null {
|
||||||
|
const country = new Intl.DisplayNames([lang], { type: "region" })
|
||||||
|
const localizedCountry = country.of(countryCode)
|
||||||
|
if (!localizedCountry) {
|
||||||
|
console.error(`Could not map ${countryCode} to localized country.`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return localizedCountry
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { SortOption } from "../enums/destinationFilterAndSort"
|
import { HotelSortOption } from "../types/hotel"
|
||||||
|
|
||||||
import type { DestinationCityListItem } from "../types/destinationCityPage"
|
import type { DestinationCityListItem } from "../types/destinationCityPage"
|
||||||
|
|
||||||
const CITY_SORTING_STRATEGIES: Partial<
|
const CITY_SORTING_STRATEGIES: Partial<
|
||||||
Record<
|
Record<
|
||||||
SortOption,
|
HotelSortOption,
|
||||||
(a: DestinationCityListItem, b: DestinationCityListItem) => number
|
(a: DestinationCityListItem, b: DestinationCityListItem) => number
|
||||||
>
|
>
|
||||||
> = {
|
> = {
|
||||||
[SortOption.Name]: function (a, b) {
|
[HotelSortOption.Name]: function (a, b) {
|
||||||
return a.cityName.localeCompare(b.cityName)
|
return a.cityName.localeCompare(b.cityName)
|
||||||
},
|
},
|
||||||
[SortOption.Recommended]: function (a, b) {
|
[HotelSortOption.Recommended]: function (a, b) {
|
||||||
if (a.sort_order === null && b.sort_order === null) {
|
if (a.sort_order === null && b.sort_order === null) {
|
||||||
return a.cityName.localeCompare(b.cityName)
|
return a.cityName.localeCompare(b.cityName)
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ const CITY_SORTING_STRATEGIES: Partial<
|
|||||||
|
|
||||||
export function getSortedCities(
|
export function getSortedCities(
|
||||||
cities: DestinationCityListItem[],
|
cities: DestinationCityListItem[],
|
||||||
sortOption: SortOption
|
sortOption: HotelSortOption
|
||||||
) {
|
) {
|
||||||
const sortFn = CITY_SORTING_STRATEGIES[sortOption]
|
const sortFn = CITY_SORTING_STRATEGIES[sortOption]
|
||||||
return sortFn ? cities.sort(sortFn) : cities
|
return sortFn ? cities.sort(sortFn) : cities
|
||||||
|
|||||||
Reference in New Issue
Block a user