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>
|
||||
|
||||
Reference in New Issue
Block a user