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"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useHotelListingDataStore } from "@/stores/hotel-listing-data"
|
||||
|
||||
import CampaignHotelListingSkeleton from "@/components/Blocks/CampaignHotelListing/CampaignHotelListingSkeleton"
|
||||
import HotelFilterAndSort from "@/components/HotelFilterAndSort"
|
||||
|
||||
import HotelListingItem from "./HotelListingItem"
|
||||
|
||||
import styles from "./campaignHotelListing.module.css"
|
||||
|
||||
import type { HotelDataWithUrl } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
interface CampaignHotelListingClientProps {
|
||||
heading: string
|
||||
preamble?: string | null
|
||||
hotels: HotelDataWithUrl[]
|
||||
visibleCountMobile?: 3 | 6
|
||||
visibleCountDesktop?: 3 | 6
|
||||
visibleCountMobile: 3 | 6
|
||||
visibleCountDesktop: 3 | 6
|
||||
isMainBlock: boolean
|
||||
}
|
||||
|
||||
export default function CampaignHotelListingClient({
|
||||
heading,
|
||||
preamble,
|
||||
hotels,
|
||||
visibleCountMobile = 3,
|
||||
visibleCountDesktop = 6,
|
||||
visibleCountMobile,
|
||||
visibleCountDesktop,
|
||||
isMainBlock,
|
||||
}: CampaignHotelListingClientProps) {
|
||||
const intl = useIntl()
|
||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||
const scrollRef = useRef<HTMLElement>(null)
|
||||
const { activeHotels, isLoading } = useHotelListingDataStore((state) => ({
|
||||
activeHotels: state.activeHotels,
|
||||
isLoading: state.isLoading,
|
||||
}))
|
||||
|
||||
const initialCount = isMobile ? visibleCountMobile : visibleCountDesktop // Initial number of hotels to show
|
||||
const initialCount = isMobile ? visibleCountMobile : visibleCountDesktop // Initial number of activeHotels to show
|
||||
const thresholdCount = initialCount + 3 // This is the threshold at which we start showing the "Show More" button
|
||||
const showAllThreshold = initialCount * 3 // This is the threshold at which we show the "Show All" button
|
||||
const incrementCount = initialCount // Number of hotels to increment when the button is clicked
|
||||
const incrementCount = initialCount // Number of activeHotels to increment when the button is clicked
|
||||
|
||||
const [visibleCount, setVisibleCount] = useState(() =>
|
||||
// Set initial visible count based on the number of hotels and the threshold
|
||||
hotels.length <= thresholdCount ? hotels.length : initialCount
|
||||
// Set initial visible count based on the number of activeHotels and the threshold
|
||||
activeHotels.length <= thresholdCount ? activeHotels.length : initialCount
|
||||
)
|
||||
|
||||
// Only show the show more/less button if the length of hotels exceeds the threshold count
|
||||
const showButton = hotels.length > thresholdCount
|
||||
// Only show the show more/less button if the length of activeHotels exceeds the threshold count
|
||||
const showButton = activeHotels.length > thresholdCount
|
||||
|
||||
// Determine if we are at the stage where the user can click to show all hotels
|
||||
// Determine if we are at the stage where the user can click to show all activeHotels
|
||||
const canShowAll =
|
||||
hotels.length > visibleCount &&
|
||||
activeHotels.length > visibleCount &&
|
||||
(visibleCount + incrementCount > showAllThreshold ||
|
||||
visibleCount + incrementCount >= hotels.length)
|
||||
visibleCount + incrementCount >= activeHotels.length)
|
||||
|
||||
function handleButtonClick() {
|
||||
if (visibleCount < hotels.length) {
|
||||
if (visibleCount < activeHotels.length) {
|
||||
if (canShowAll) {
|
||||
setVisibleCount(hotels.length)
|
||||
setVisibleCount(activeHotels.length)
|
||||
} else {
|
||||
setVisibleCount((prev) =>
|
||||
Math.min(prev + incrementCount, hotels.length)
|
||||
Math.min(prev + incrementCount, activeHotels.length)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -78,7 +85,7 @@ export default function CampaignHotelListingClient({
|
||||
})
|
||||
let iconDirection: MaterialIconProps["icon"] = "keyboard_arrow_down"
|
||||
|
||||
if (visibleCount === hotels.length) {
|
||||
if (visibleCount === activeHotels.length) {
|
||||
buttonText = intl.formatMessage({
|
||||
defaultMessage: "Show less",
|
||||
})
|
||||
@@ -89,20 +96,30 @@ export default function CampaignHotelListingClient({
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <CampaignHotelListingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.hotelListingSection} ref={scrollRef}>
|
||||
<section
|
||||
className={cx(styles.hotelListingSection, {
|
||||
[styles.isMainBlock]: isMainBlock,
|
||||
})}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<h3>{heading}</h3>
|
||||
<Typography variant={isMainBlock ? "Title/md" : "Title/Subtitle/lg"}>
|
||||
<h3 className={styles.heading}>{heading}</h3>
|
||||
</Typography>
|
||||
{isMainBlock ? <HotelFilterAndSort /> : null}
|
||||
{preamble ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{preamble}</p>
|
||||
<p className={styles.preamble}>{preamble}</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
</header>
|
||||
<ul className={styles.list}>
|
||||
{hotels.map(({ hotel, url }, index) => (
|
||||
{activeHotels.map(({ hotel, url }, index) => (
|
||||
<li
|
||||
key={hotel.id}
|
||||
className={cx(styles.listItem, {
|
||||
|
||||
@@ -13,11 +13,11 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./hotelListingItem.module.css"
|
||||
|
||||
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
interface HotelListingItemProps {
|
||||
hotel: Hotel
|
||||
url: string
|
||||
hotel: HotelListingHotelData["hotel"]
|
||||
url: string | null
|
||||
}
|
||||
|
||||
export default function HotelListingItem({
|
||||
@@ -25,11 +25,11 @@ export default function HotelListingItem({
|
||||
url,
|
||||
}: HotelListingItemProps) {
|
||||
const intl = useIntl()
|
||||
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
||||
const tripadvisorRating = hotel.ratings?.tripAdvisor.rating
|
||||
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages)
|
||||
const tripadvisorRating = hotel.tripadvisor
|
||||
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
|
||||
const amenities = hotel.detailedFacilities.slice(0, 5)
|
||||
const hotelDescription = hotel.hotelContent.texts.descriptions?.short
|
||||
const hotelDescription = hotel.description
|
||||
|
||||
return (
|
||||
<article className={styles.hotelListingItem}>
|
||||
@@ -57,7 +57,7 @@ export default function HotelListingItem({
|
||||
<div className={styles.content}>
|
||||
<div className={styles.intro}>
|
||||
<HotelLogoIcon
|
||||
hotelId={hotel.operaId}
|
||||
hotelId={hotel.id}
|
||||
hotelType={hotel.hotelType}
|
||||
height={30}
|
||||
/>
|
||||
@@ -111,19 +111,21 @@ export default function HotelListingItem({
|
||||
</ul>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.ctaWrapper}>
|
||||
<ButtonLink
|
||||
href={url}
|
||||
variant="Tertiary"
|
||||
color="Primary"
|
||||
size="Small"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See hotel details",
|
||||
})}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
{url ? (
|
||||
<div className={styles.ctaWrapper}>
|
||||
<ButtonLink
|
||||
href={url}
|
||||
variant="Tertiary"
|
||||
color="Primary"
|
||||
size="Small"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See hotel details",
|
||||
})}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
gap: var(--Space-x3);
|
||||
scroll-margin-top: var(--scroll-margin-top);
|
||||
|
||||
&.isMainBlock .heading {
|
||||
color: var(--Text-Heading);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr max-content;
|
||||
gap: var(--Space-x15);
|
||||
}
|
||||
|
||||
.preamble {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
@@ -28,6 +37,10 @@
|
||||
--scroll-margin-top: calc(
|
||||
var(--booking-widget-tablet-height) + var(--Spacing-x2)
|
||||
);
|
||||
|
||||
&.isMainBlock {
|
||||
gap: var(--Space-x5);
|
||||
}
|
||||
}
|
||||
.list {
|
||||
row-gap: var(--Space-x5);
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import {
|
||||
type HotelSortItem,
|
||||
HotelSortOption,
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
|
||||
|
||||
import { getHotelsByCSFilter } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import HotelListingDataProvider from "@/providers/HotelListingDataProvider"
|
||||
|
||||
import CampaignHotelListingSkeleton from "./CampaignHotelListingSkeleton"
|
||||
import CampaignHotelListingClient from "./Client"
|
||||
|
||||
interface CampaignHotelListingProps {
|
||||
@@ -8,28 +21,56 @@ interface CampaignHotelListingProps {
|
||||
hotelIds: string[]
|
||||
visibleCountMobile?: 3 | 6
|
||||
visibleCountDesktop?: 3 | 6
|
||||
isMainBlock?: boolean
|
||||
}
|
||||
|
||||
export default async function CampaignHotelListing({
|
||||
heading,
|
||||
preamble,
|
||||
hotelIds,
|
||||
visibleCountMobile,
|
||||
visibleCountDesktop,
|
||||
visibleCountMobile = 3,
|
||||
visibleCountDesktop = 6,
|
||||
isMainBlock = false,
|
||||
}: CampaignHotelListingProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = await getLang()
|
||||
const hotels = await getHotelsByCSFilter({ hotelsToInclude: hotelIds })
|
||||
|
||||
if (!hotels.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const allFilters = getFiltersFromHotels(hotels, lang)
|
||||
const sortItems: HotelSortItem[] = [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Name",
|
||||
}),
|
||||
value: HotelSortOption.Name,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "TripAdvisor rating",
|
||||
}),
|
||||
value: HotelSortOption.TripAdvisorRating,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<CampaignHotelListingClient
|
||||
heading={heading}
|
||||
preamble={preamble}
|
||||
hotels={hotels}
|
||||
visibleCountMobile={visibleCountMobile}
|
||||
visibleCountDesktop={visibleCountDesktop}
|
||||
/>
|
||||
<Suspense fallback={<CampaignHotelListingSkeleton />}>
|
||||
<HotelListingDataProvider
|
||||
allHotels={hotels}
|
||||
allFilters={allFilters}
|
||||
sortItems={sortItems}
|
||||
>
|
||||
<CampaignHotelListingClient
|
||||
heading={heading}
|
||||
preamble={preamble}
|
||||
visibleCountMobile={visibleCountMobile}
|
||||
visibleCountDesktop={visibleCountDesktop}
|
||||
isMainBlock={isMainBlock}
|
||||
/>
|
||||
</HotelListingDataProvider>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,32 +7,42 @@ import Image from "@/components/Image"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getSingleDecimal } from "@/utils/numberFormatting"
|
||||
|
||||
import { getTypeSpecificInformation } from "./utils"
|
||||
|
||||
import styles from "./hotelListingItem.module.css"
|
||||
|
||||
import type { HotelListingItemProps } from "@/types/components/contentPage/hotelListingItem"
|
||||
|
||||
export default async function HotelListingItem({
|
||||
hotel,
|
||||
additionalData,
|
||||
hotelData,
|
||||
contentType = "hotel",
|
||||
url,
|
||||
}: HotelListingItemProps) {
|
||||
const intl = await getIntl()
|
||||
const { description, image, cta } = getTypeSpecificInformation(
|
||||
intl,
|
||||
contentType,
|
||||
hotel.hotelContent,
|
||||
additionalData,
|
||||
url
|
||||
)
|
||||
|
||||
const { galleryImages, description, id, name, hotelType, location, address } =
|
||||
hotelData.hotel
|
||||
const image = galleryImages[0]
|
||||
|
||||
const cta =
|
||||
contentType === "meeting" && hotelData.meetingUrl
|
||||
? {
|
||||
url: hotelData.meetingUrl,
|
||||
openInNewTab: true,
|
||||
text: intl.formatMessage({
|
||||
defaultMessage: "Book a meeting",
|
||||
}),
|
||||
}
|
||||
: {
|
||||
url: hotelData.url,
|
||||
openInNewTab: false,
|
||||
text: intl.formatMessage({
|
||||
defaultMessage: "See hotel details",
|
||||
}),
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={styles.container}>
|
||||
<Image
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
src={image.imageSizes.large}
|
||||
alt={image.metaData.altText || image.metaData.altText_En}
|
||||
width={400}
|
||||
height={300}
|
||||
sizes="(min-width: 768px) 400px, 100vw"
|
||||
@@ -40,13 +50,13 @@ export default async function HotelListingItem({
|
||||
/>
|
||||
<section className={styles.content}>
|
||||
<div className={styles.intro}>
|
||||
<HotelLogoIcon hotelId={hotel.operaId} hotelType={hotel.hotelType} />
|
||||
<HotelLogoIcon hotelId={id} hotelType={hotelType} />
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<h4>{hotel.name}</h4>
|
||||
<h4>{name}</h4>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<div className={styles.captions}>
|
||||
<span>{hotel.address.streetAddress}</span>
|
||||
<span>{address.streetAddress}</span>
|
||||
<Divider variant="vertical" />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
@@ -54,9 +64,7 @@ export default async function HotelListingItem({
|
||||
defaultMessage: "{number} km to city center",
|
||||
},
|
||||
{
|
||||
number: getSingleDecimal(
|
||||
hotel.location.distanceToCentre / 1000
|
||||
),
|
||||
number: getSingleDecimal(location.distanceToCentre / 1000),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
@@ -77,7 +85,7 @@ export default async function HotelListingItem({
|
||||
variant="Primary"
|
||||
size="Small"
|
||||
href={cta.url}
|
||||
target={cta.openInNewTab ? "_blank" : "_self"}
|
||||
target={cta.openInNewTab ? "_blank" : undefined}
|
||||
className={styles.button}
|
||||
>
|
||||
{cta.text}
|
||||
|
||||
@@ -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">
|
||||
<h3 className={styles.heading}>{heading}</h3>
|
||||
</Typography>
|
||||
{hotels.map(({ url, hotel, additionalData }) => (
|
||||
{hotels.map((hotelData) => (
|
||||
<HotelListingItem
|
||||
key={hotel.name}
|
||||
hotel={hotel}
|
||||
additionalData={additionalData}
|
||||
key={hotelData.hotel.name}
|
||||
hotelData={hotelData}
|
||||
contentType={contentType}
|
||||
url={url}
|
||||
/>
|
||||
))}
|
||||
</SectionContainer>
|
||||
|
||||
@@ -38,6 +38,7 @@ export default function Blocks({ blocks }: BlocksProps) {
|
||||
<CampaignHotelListing
|
||||
heading={block.hotel_listing.heading}
|
||||
hotelIds={block.hotel_listing.hotelIds}
|
||||
isMainBlock={true}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -12,11 +12,11 @@ import HotelListItem from "../HotelListItem"
|
||||
|
||||
import styles from "./hotelList.module.css"
|
||||
|
||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
interface HotelListContentProps {
|
||||
hotelsCount: number
|
||||
visibleHotels: DestinationPagesHotelData[]
|
||||
visibleHotels: HotelListingHotelData[]
|
||||
}
|
||||
|
||||
export default function HotelListContent({
|
||||
|
||||
@@ -17,15 +17,15 @@ import { getVisibleHotels } from "./utils"
|
||||
|
||||
import styles from "./hotelList.module.css"
|
||||
|
||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
export default function HotelList() {
|
||||
const intl = useIntl()
|
||||
const map = useMap()
|
||||
const coreLib = useMapsLibrary("core")
|
||||
const [visibleHotels, setVisibleHotels] = useState<
|
||||
DestinationPagesHotelData[]
|
||||
>([])
|
||||
const [visibleHotels, setVisibleHotels] = useState<HotelListingHotelData[]>(
|
||||
[]
|
||||
)
|
||||
const { activeHotels, isLoading } = useDestinationDataStore((state) => ({
|
||||
activeHotels: state.activeHotels,
|
||||
isLoading: state.isLoading,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
export function getVisibleHotels(
|
||||
hotels: DestinationPagesHotelData[],
|
||||
hotels: HotelListingHotelData[],
|
||||
map: google.maps.Map | null
|
||||
) {
|
||||
const bounds = map?.getBounds()
|
||||
|
||||
@@ -19,11 +19,15 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./hotelListItem.module.css"
|
||||
|
||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
export default function HotelListItem(data: DestinationPagesHotelData) {
|
||||
interface HotelListItemProps {
|
||||
hotel: HotelListingHotelData["hotel"]
|
||||
url: string | null
|
||||
}
|
||||
|
||||
export default function HotelListItem({ hotel, url }: HotelListItemProps) {
|
||||
const intl = useIntl()
|
||||
const { hotel, url } = data
|
||||
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
||||
const amenities = hotel.detailedFacilities.slice(0, 5)
|
||||
const address = `${hotel.address.streetAddress}, ${hotel.address.city}`
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
|
||||
import { getFiltersFromHotels } from "@scandic-hotels/trpc/routers/contentstack/metadata/helpers"
|
||||
import {
|
||||
type HotelSortItem,
|
||||
HotelSortOption,
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
import {
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
import Breadcrumbs from "@/components/Breadcrumbs"
|
||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import DestinationDataProvider from "@/providers/DestinationDataProvider"
|
||||
import { getPathname } from "@/utils/getPathname"
|
||||
|
||||
@@ -30,8 +34,6 @@ import DestinationCityPageSkeleton from "./DestinationCityPageSkeleton"
|
||||
|
||||
import styles from "./destinationCityPage.module.css"
|
||||
|
||||
import type { SortItem } from "@/types/components/destinationFilterAndSort"
|
||||
|
||||
interface DestinationCityPageProps {
|
||||
isMapView: boolean
|
||||
filterFromUrl?: string
|
||||
@@ -42,6 +44,7 @@ export default async function DestinationCityPage({
|
||||
filterFromUrl,
|
||||
}: DestinationCityPageProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = await getLang()
|
||||
const pathname = await getPathname()
|
||||
const pageData = await getDestinationCityPage()
|
||||
|
||||
@@ -62,26 +65,26 @@ export default async function DestinationCityPage({
|
||||
} = destinationCityPage
|
||||
|
||||
const allHotels = await getHotelsByCityIdentifier(cityIdentifier)
|
||||
const allFilters = getFiltersFromHotels(allHotels)
|
||||
const sortItems: SortItem[] = [
|
||||
const allFilters = getFiltersFromHotels(allHotels, lang)
|
||||
const sortItems: HotelSortItem[] = [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Distance to city center",
|
||||
}),
|
||||
value: SortOption.Distance,
|
||||
value: HotelSortOption.Distance,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Name",
|
||||
}),
|
||||
value: SortOption.Name,
|
||||
value: HotelSortOption.Name,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "TripAdvisor rating",
|
||||
}),
|
||||
value: SortOption.TripAdvisorRating,
|
||||
value: HotelSortOption.TripAdvisorRating,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
|
||||
import { getFiltersFromHotels } from "@scandic-hotels/trpc/routers/contentstack/metadata/helpers"
|
||||
import {
|
||||
type HotelSortItem,
|
||||
HotelSortOption,
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
import {
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
import Breadcrumbs from "@/components/Breadcrumbs"
|
||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import DestinationDataProvider from "@/providers/DestinationDataProvider"
|
||||
import { getPathname } from "@/utils/getPathname"
|
||||
|
||||
@@ -30,8 +34,6 @@ import DestinationCountryPageSkeleton from "./DestinationCountryPageSkeleton"
|
||||
|
||||
import styles from "./destinationCountryPage.module.css"
|
||||
|
||||
import type { SortItem } from "@/types/components/destinationFilterAndSort"
|
||||
|
||||
interface DestinationCountryPageProps {
|
||||
isMapView: boolean
|
||||
filterFromUrl?: string
|
||||
@@ -42,6 +44,7 @@ export default async function DestinationCountryPage({
|
||||
filterFromUrl,
|
||||
}: DestinationCountryPageProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = await getLang()
|
||||
const pathname = await getPathname()
|
||||
const pageData = await getDestinationCountryPage()
|
||||
|
||||
@@ -65,21 +68,21 @@ export default async function DestinationCountryPage({
|
||||
getHotelsByCountry(destination_settings.country),
|
||||
getDestinationCityPagesByCountry(destination_settings.country),
|
||||
])
|
||||
const allFilters = getFiltersFromHotels(allHotels)
|
||||
const allFilters = getFiltersFromHotels(allHotels, lang)
|
||||
|
||||
const sortItems: SortItem[] = [
|
||||
const sortItems: HotelSortItem[] = [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Recommended",
|
||||
}),
|
||||
value: SortOption.Recommended,
|
||||
value: HotelSortOption.Recommended,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Name",
|
||||
}),
|
||||
value: SortOption.Name,
|
||||
value: HotelSortOption.Name,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ import HotelMapCard from "../HotelMapCard"
|
||||
|
||||
import styles from "./hotelCardCarousel.module.css"
|
||||
|
||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
interface MapCardCarouselProps {
|
||||
visibleHotels: DestinationPagesHotelData[]
|
||||
visibleHotels: HotelListingHotelData[]
|
||||
}
|
||||
export default function HotelCardCarousel({
|
||||
visibleHotels,
|
||||
@@ -52,7 +52,7 @@ export default function HotelCardCarousel({
|
||||
tripadvisorRating={hotel.tripadvisor}
|
||||
hotelName={hotel.name}
|
||||
url={url}
|
||||
image={getImage({ hotel, url })}
|
||||
image={getImage({ hotel })}
|
||||
amenities={hotel.detailedFacilities.slice(0, 3)}
|
||||
/>
|
||||
</Carousel.Item>
|
||||
@@ -62,11 +62,11 @@ export default function HotelCardCarousel({
|
||||
)
|
||||
}
|
||||
|
||||
function getImage(hotel: DestinationPagesHotelData) {
|
||||
function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
|
||||
return {
|
||||
src: hotel.hotel.galleryImages?.[0]?.imageSizes.medium,
|
||||
src: hotel.galleryImages?.[0]?.imageSizes.large,
|
||||
alt:
|
||||
hotel.hotel.galleryImages?.[0]?.metaData.altText ||
|
||||
hotel.hotel.galleryImages?.[0]?.metaData.altText_En,
|
||||
hotel.galleryImages?.[0]?.metaData.altText ||
|
||||
hotel.galleryImages?.[0]?.metaData.altText_En,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,19 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./hotelListingItem.module.css"
|
||||
|
||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
export default function HotelListingItem(data: DestinationPagesHotelData) {
|
||||
interface HotelListingItemProps {
|
||||
hotel: HotelListingHotelData["hotel"]
|
||||
url: string | null
|
||||
}
|
||||
|
||||
export default function HotelListingItem({
|
||||
hotel,
|
||||
url,
|
||||
}: HotelListingItemProps) {
|
||||
const intl = useIntl()
|
||||
const params = useParams()
|
||||
const { hotel, url } = data
|
||||
const { setActiveMarker } = useDestinationPageHotelsMapStore()
|
||||
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
|
||||
const amenities = hotel.detailedFacilities.slice(0, 5)
|
||||
@@ -103,9 +110,9 @@ export default function HotelListingItem(data: DestinationPagesHotelData) {
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
{hotel.hotelDescription ? (
|
||||
{hotel.description ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{hotel.hotelDescription}</p>
|
||||
<p>{hotel.description}</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
|
||||
@@ -9,7 +9,7 @@ import styles from "./dialogImage.module.css"
|
||||
interface DialogImageProps {
|
||||
image?: string
|
||||
altText?: string
|
||||
rating?: number
|
||||
rating?: number | null
|
||||
imageError: boolean
|
||||
setImageError: (error: boolean) => void
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ import type { GalleryImage } from "@/types/components/imageGallery"
|
||||
|
||||
interface HotelMapCardProps {
|
||||
amenities: Amenities
|
||||
tripadvisorRating: number | undefined
|
||||
tripadvisorRating: number | null
|
||||
hotelName: string
|
||||
image: GalleryImage | null
|
||||
url: string
|
||||
url: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,12 @@ import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "./utils"
|
||||
|
||||
import styles from "./map.module.css"
|
||||
|
||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
import type { MapLocation } from "@/types/components/mapLocation"
|
||||
|
||||
interface MapProps {
|
||||
hotels: DestinationPagesHotelData[]
|
||||
hotels: HotelListingHotelData[]
|
||||
mapId: string
|
||||
apiKey: string
|
||||
pageType: "city" | "country"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { HotelListingHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
import type {
|
||||
DestinationMarker,
|
||||
@@ -29,7 +29,7 @@ export function mapMarkerDataToGeoJson(markers: DestinationMarker[]) {
|
||||
return geoJson
|
||||
}
|
||||
|
||||
export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) {
|
||||
export function getHotelMapMarkers(hotels: HotelListingHotelData[]) {
|
||||
const markers = hotels
|
||||
.map(({ hotel, url }) => ({
|
||||
id: hotel.id,
|
||||
@@ -41,10 +41,10 @@ export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) {
|
||||
lng: hotel.location.longitude,
|
||||
}
|
||||
: null,
|
||||
url: url,
|
||||
url,
|
||||
tripadvisor: hotel.tripadvisor,
|
||||
amenities: hotel.detailedFacilities.slice(0, 3),
|
||||
image: getImage({ hotel, url }),
|
||||
image: getImage({ hotel }),
|
||||
}))
|
||||
|
||||
.filter((item): item is DestinationMarker => !!item.coordinates)
|
||||
@@ -52,11 +52,11 @@ export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) {
|
||||
return markers
|
||||
}
|
||||
|
||||
function getImage(hotel: DestinationPagesHotelData) {
|
||||
function getImage({ hotel }: Pick<HotelListingHotelData, "hotel">) {
|
||||
return {
|
||||
src: hotel.hotel.galleryImages?.[0]?.imageSizes.medium,
|
||||
src: hotel.galleryImages?.[0]?.imageSizes.large,
|
||||
alt:
|
||||
hotel.hotel.galleryImages?.[0]?.metaData.altText ||
|
||||
hotel.hotel.galleryImages?.[0]?.metaData.altText_En,
|
||||
hotel.galleryImages?.[0]?.metaData.altText ||
|
||||
hotel.galleryImages?.[0]?.metaData.altText_En,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
|
||||
import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { IntlShape } from "react-intl"
|
||||
|
||||
export function getHeadingText(
|
||||
intl: IntlShape,
|
||||
location: string,
|
||||
allFilters: CategorizedFilters,
|
||||
allFilters: CategorizedHotelFilters,
|
||||
filter?: string
|
||||
) {
|
||||
if (filter) {
|
||||
|
||||
@@ -11,10 +11,10 @@ import Checkbox from "./Checkbox"
|
||||
|
||||
import styles from "./filter.module.css"
|
||||
|
||||
import type { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
|
||||
import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
interface FilterProps {
|
||||
filters: CategorizedFilters
|
||||
filters: CategorizedHotelFilters
|
||||
}
|
||||
|
||||
export default function Filter({ filters }: FilterProps) {
|
||||
|
||||
@@ -6,12 +6,13 @@ import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
|
||||
|
||||
import { useDestinationDataStore } from "@/stores/destination-data"
|
||||
|
||||
import type { SortOption } from "@scandic-hotels/trpc/enums/destinationFilterAndSort"
|
||||
|
||||
import type { SortItem } from "@/types/components/destinationFilterAndSort"
|
||||
import type {
|
||||
HotelSortItem,
|
||||
HotelSortOption,
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
interface SortProps {
|
||||
sortItems: SortItem[]
|
||||
sortItems: HotelSortItem[]
|
||||
}
|
||||
|
||||
export default function Sort({ sortItems }: SortProps) {
|
||||
@@ -33,7 +34,7 @@ export default function Sort({ sortItems }: SortProps) {
|
||||
})}
|
||||
name="sort"
|
||||
showRadioButton
|
||||
onSelect={(sort) => setPendingSort(sort as SortOption)}
|
||||
onSelect={(sort) => setPendingSort(sort as HotelSortOption)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
import type { SortItem } from "@/types/components/destinationFilterAndSort"
|
||||
|
||||
const HOTEL_SORTING_STRATEGIES: Partial<
|
||||
Record<
|
||||
SortOption,
|
||||
(a: DestinationPagesHotelData, b: DestinationPagesHotelData) => number
|
||||
HotelSortOption,
|
||||
(a: HotelListingHotelData, b: HotelListingHotelData) => number
|
||||
>
|
||||
> = {
|
||||
[SortOption.Name]: function (a, b) {
|
||||
[HotelSortOption.Name]: function (a, b) {
|
||||
return a.hotel.name.localeCompare(b.hotel.name)
|
||||
},
|
||||
[SortOption.TripAdvisorRating]: function (a, b) {
|
||||
[HotelSortOption.TripAdvisorRating]: function (a, b) {
|
||||
return (b.hotel.tripadvisor ?? 0) - (a.hotel.tripadvisor ?? 0)
|
||||
},
|
||||
[SortOption.Distance]: function (a, b) {
|
||||
[HotelSortOption.Distance]: function (a, b) {
|
||||
return a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre
|
||||
},
|
||||
}
|
||||
|
||||
export function getFilteredHotels(
|
||||
hotels: DestinationPagesHotelData[],
|
||||
hotels: HotelListingHotelData[],
|
||||
filters: string[]
|
||||
) {
|
||||
if (filters.length) {
|
||||
@@ -37,7 +38,7 @@ export function getFilteredHotels(
|
||||
}
|
||||
|
||||
export function getFilteredCities(
|
||||
filteredHotels: DestinationPagesHotelData[],
|
||||
filteredHotels: HotelListingHotelData[],
|
||||
cities: DestinationCityListItem[]
|
||||
) {
|
||||
const filteredCityIdentifiers = filteredHotels.map(
|
||||
@@ -52,8 +53,8 @@ export function getFilteredCities(
|
||||
}
|
||||
|
||||
export function getSortedHotels(
|
||||
hotels: DestinationPagesHotelData[],
|
||||
sortOption: SortOption
|
||||
hotels: HotelListingHotelData[],
|
||||
sortOption: HotelSortOption
|
||||
) {
|
||||
const sortFn = HOTEL_SORTING_STRATEGIES[sortOption]
|
||||
return sortFn ? [...hotels].sort(sortFn) : hotels
|
||||
@@ -61,9 +62,9 @@ export function getSortedHotels(
|
||||
|
||||
export function isValidSortOption(
|
||||
value: string,
|
||||
sortItems: SortItem[]
|
||||
): value is SortOption {
|
||||
return sortItems.map((item) => item.value).includes(value as SortOption)
|
||||
sortItems: HotelSortItem[]
|
||||
): value is HotelSortOption {
|
||||
return sortItems.map((item) => item.value).includes(value as HotelSortOption)
|
||||
}
|
||||
|
||||
export function getBasePathNameWithoutFilters(
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
isValidSortOption,
|
||||
} from "./helper"
|
||||
|
||||
import type { Filter } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
|
||||
import type { HotelFilter } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
import type {
|
||||
DestinationDataState,
|
||||
@@ -36,8 +36,8 @@ export function createDestinationDataStore({
|
||||
}: InitialState) {
|
||||
const defaultSort =
|
||||
sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value
|
||||
const allFilterSlugs = Object.values(allFilters).flatMap((filter: Filter[]) =>
|
||||
filter.map((f) => f.slug)
|
||||
const allFilterSlugs = Object.values(allFilters).flatMap(
|
||||
(filter: HotelFilter[]) => filter.map((f) => f.slug)
|
||||
)
|
||||
|
||||
const activeFilters: string[] = filterFromUrl ? [filterFromUrl] : []
|
||||
|
||||
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"
|
||||
|
||||
export interface HotelListingItemProps {
|
||||
hotel: Hotel
|
||||
additionalData: AdditionalData
|
||||
hotelData: HotelListingHotelData
|
||||
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 { Amenities } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { GalleryImage } from "../imageGallery"
|
||||
|
||||
export interface DestinationMarker {
|
||||
@@ -9,7 +9,7 @@ export interface DestinationMarker {
|
||||
name: string
|
||||
coordinates: google.maps.LatLngLiteral
|
||||
url: string
|
||||
tripadvisor: number | undefined
|
||||
tripadvisor: number | null
|
||||
amenities: Amenities
|
||||
image: GalleryImage
|
||||
}
|
||||
|
||||
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 { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
|
||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
import type { SortItem } from "../components/destinationFilterAndSort"
|
||||
import type {
|
||||
CategorizedHotelFilters,
|
||||
HotelListingHotelData,
|
||||
HotelSortItem,
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
|
||||
export interface DestinationDataProviderProps extends React.PropsWithChildren {
|
||||
allHotels: DestinationPagesHotelData[]
|
||||
allHotels: HotelListingHotelData[]
|
||||
allCities?: DestinationCityListItem[]
|
||||
allFilters: CategorizedFilters
|
||||
allFilters: CategorizedHotelFilters
|
||||
filterFromUrl?: string
|
||||
sortItems: SortItem[]
|
||||
sortItems: HotelSortItem[]
|
||||
pathname: string
|
||||
}
|
||||
|
||||
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 { CategorizedFilters } from "@scandic-hotels/trpc/types/destinationFilterAndSort"
|
||||
import type { DestinationPagesHotelData } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type {
|
||||
CategorizedHotelFilters,
|
||||
HotelListingHotelData,
|
||||
HotelSortItem,
|
||||
HotelSortOption,
|
||||
} from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { ReadonlyURLSearchParams } from "next/navigation"
|
||||
|
||||
import type { SortItem } from "../components/destinationFilterAndSort"
|
||||
|
||||
interface Actions {
|
||||
updateActiveFiltersAndSort: (filters: string[], sort: string | null) => void
|
||||
setPendingSort: (sort: SortOption) => void
|
||||
setPendingSort: (sort: HotelSortOption) => void
|
||||
togglePendingFilter: (filter: string) => void
|
||||
clearPendingFilters: () => void
|
||||
resetPendingValues: () => void
|
||||
@@ -16,8 +17,8 @@ interface Actions {
|
||||
}
|
||||
|
||||
export interface SubmitCallbackData {
|
||||
sort: SortOption
|
||||
defaultSort: SortOption
|
||||
sort: HotelSortOption
|
||||
defaultSort: HotelSortOption
|
||||
filters: string[]
|
||||
basePath: string
|
||||
}
|
||||
@@ -25,19 +26,19 @@ export interface DestinationDataState {
|
||||
actions: Actions
|
||||
allCities: DestinationCityListItem[]
|
||||
activeCities: DestinationCityListItem[]
|
||||
allHotels: DestinationPagesHotelData[]
|
||||
activeHotels: DestinationPagesHotelData[]
|
||||
pendingSort: SortOption
|
||||
activeSort: SortOption
|
||||
defaultSort: SortOption
|
||||
allHotels: HotelListingHotelData[]
|
||||
activeHotels: HotelListingHotelData[]
|
||||
pendingSort: HotelSortOption
|
||||
activeSort: HotelSortOption
|
||||
defaultSort: HotelSortOption
|
||||
pendingFilters: string[]
|
||||
activeFilters: string[]
|
||||
pendingHotelCount: number
|
||||
pendingCityCount: number
|
||||
allFilters: CategorizedFilters
|
||||
allFilters: CategorizedHotelFilters
|
||||
allFilterSlugs: string[]
|
||||
basePathnameWithoutFilters: string
|
||||
sortItems: SortItem[]
|
||||
sortItems: HotelSortItem[]
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user