Merged in feat/sw-2873-move-selecthotel-to-booking-flow (pull request #2727)
feat(SW-2873): Move select-hotel to booking flow * crude setup of select-hotel in partner-sas * wip * Fix linting * restructure tracking files * Remove dependency on trpc in tracking hooks * Move pageview tracking to common * Fix some lint and import issues * Add AlternativeHotelsPage * Add SelectHotelMapPage * Add AlternativeHotelsMapPage * remove next dependency in tracking store * Remove dependency on react in tracking hooks * move isSameBooking to booking-flow * Inject searchParamsComparator into tracking store * Move useTrackHardNavigation to common * Move useTrackSoftNavigation to common * Add TrackingSDK to partner-sas * call serverclient in layout * Remove unused css * Update types * Move HotelPin type * Fix todos * Merge branch 'master' into feat/sw-2873-move-selecthotel-to-booking-flow * Merge branch 'master' into feat/sw-2873-move-selecthotel-to-booking-flow * Fix component Approved-by: Joakim Jäderberg
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.hotelCards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
243
packages/booking-flow/lib/components/HotelCardListing/index.tsx
Normal file
243
packages/booking-flow/lib/components/HotelCardListing/index.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
alternativeHotelsMap,
|
||||
selectHotelMap,
|
||||
} from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
|
||||
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
|
||||
import { HotelCard } from "@scandic-hotels/design-system/HotelCard"
|
||||
|
||||
import { useIsLoggedIn } from "../../hooks/useIsLoggedIn"
|
||||
import useLang from "../../hooks/useLang"
|
||||
import { mapApiImagesToGalleryImages } from "../../misc/imageGallery"
|
||||
import {
|
||||
BookingCodeFilterEnum,
|
||||
useBookingCodeFilterStore,
|
||||
} from "../../stores/bookingCode-filter"
|
||||
import { useHotelFilterStore } from "../../stores/hotel-filters"
|
||||
import { useHotelsMapStore } from "../../stores/hotels-map"
|
||||
import { HotelDetailsSidePeek } from "../HotelDetailsSidePeek"
|
||||
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
|
||||
import { getSortedHotels } from "./utils"
|
||||
|
||||
import styles from "./hotelCardListing.module.css"
|
||||
|
||||
import type { HotelType } from "@scandic-hotels/common/constants/hotelType"
|
||||
|
||||
import type { HotelResponse } from "../SelectHotel/helpers"
|
||||
|
||||
export enum HotelCardListingTypeEnum {
|
||||
MapListing = "mapListing",
|
||||
PageListing = "pageListing",
|
||||
}
|
||||
|
||||
type HotelCardListingProps = {
|
||||
hotelData: HotelResponse[]
|
||||
unfilteredHotelCount: number
|
||||
type?: HotelCardListingTypeEnum
|
||||
isAlternative?: boolean
|
||||
}
|
||||
|
||||
export default function HotelCardListing({
|
||||
hotelData,
|
||||
unfilteredHotelCount,
|
||||
type = HotelCardListingTypeEnum.PageListing,
|
||||
isAlternative,
|
||||
}: HotelCardListingProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const isUserLoggedIn = useIsLoggedIn()
|
||||
const searchParams = useSearchParams()
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||
const { activeHotel, activate, disengage, engage } = useHotelsMapStore()
|
||||
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
|
||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const sortBy = searchParams.get("sort") ?? DEFAULT_SORT
|
||||
|
||||
const bookingCode = searchParams.get("bookingCode")
|
||||
// Special rates (corporate cheque, voucher) will not show regular rate hotels availability
|
||||
const isSpecialRate = bookingCode
|
||||
? hotelData.find(
|
||||
(hotel) =>
|
||||
hotel.availability.productType?.bonusCheque ||
|
||||
hotel.availability.productType?.voucher
|
||||
)
|
||||
: false
|
||||
const activeCodeFilter = useBookingCodeFilterStore(
|
||||
(state) => state.activeCodeFilter
|
||||
)
|
||||
const isBookingCodeRateAvailable =
|
||||
bookingCode && !isSpecialRate
|
||||
? hotelData.some((hotel) => hotel.availability.bookingCode)
|
||||
: false
|
||||
const showOnlyBookingCodeRates =
|
||||
isBookingCodeRateAvailable &&
|
||||
activeCodeFilter === BookingCodeFilterEnum.Discounted
|
||||
|
||||
const hotels = useMemo(() => {
|
||||
const sortedHotels = getSortedHotels({
|
||||
hotels: hotelData,
|
||||
sortBy,
|
||||
bookingCode: isSpecialRate ? null : bookingCode,
|
||||
})
|
||||
|
||||
const updatedHotelsList = showOnlyBookingCodeRates
|
||||
? sortedHotels.filter((hotel) => hotel.availability.bookingCode)
|
||||
: sortedHotels
|
||||
|
||||
if (!activeFilters.length) {
|
||||
return updatedHotelsList
|
||||
}
|
||||
|
||||
return updatedHotelsList.filter((hotel) =>
|
||||
activeFilters.every((appliedFilterId) =>
|
||||
hotel.hotel.detailedFacilities.some(
|
||||
(facility) => facility.id.toString() === appliedFilterId
|
||||
)
|
||||
)
|
||||
)
|
||||
}, [
|
||||
activeFilters,
|
||||
bookingCode,
|
||||
hotelData,
|
||||
sortBy,
|
||||
showOnlyBookingCodeRates,
|
||||
isSpecialRate,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCardRef.current && type === HotelCardListingTypeEnum.MapListing) {
|
||||
activeCardRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
})
|
||||
}
|
||||
}, [activeHotel, type])
|
||||
|
||||
useEffect(() => {
|
||||
setResultCount(hotels.length, unfilteredHotelCount)
|
||||
}, [hotels, setResultCount, unfilteredHotelCount])
|
||||
|
||||
function isHotelActiveInMapView(hotelName: string): boolean {
|
||||
return (
|
||||
hotelName === activeHotel && type === HotelCardListingTypeEnum.MapListing
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.hotelCards}>
|
||||
{hotels.map((hotel) => (
|
||||
<div
|
||||
key={hotel.hotel.operaId}
|
||||
ref={isHotelActiveInMapView(hotel.hotel.name) ? activeCardRef : null}
|
||||
data-active={
|
||||
isHotelActiveInMapView(hotel.hotel.name) ? "true" : "false"
|
||||
}
|
||||
>
|
||||
<HotelCard
|
||||
hotel={{
|
||||
id: hotel.hotel.operaId,
|
||||
name: hotel.hotel.name,
|
||||
address: hotel.hotel.address,
|
||||
description: hotel.hotel.hotelContent.texts.descriptions?.short,
|
||||
hotelType: hotel.hotel.hotelType as HotelType,
|
||||
detailedFacilities: hotel.hotel.detailedFacilities,
|
||||
ratings: {
|
||||
tripAdvisor: hotel.hotel.ratings?.tripAdvisor.rating,
|
||||
},
|
||||
}}
|
||||
lang={lang}
|
||||
fullPrice={!hotel.availability.bookingCode}
|
||||
prices={
|
||||
hotel.availability.productType && {
|
||||
public: hotel.availability.productType?.public
|
||||
? {
|
||||
...hotel.availability.productType.public,
|
||||
requestedPrice:
|
||||
hotel.availability.productType?.public.requestedPrice ??
|
||||
undefined,
|
||||
}
|
||||
: undefined,
|
||||
member: hotel.availability.productType?.member
|
||||
? {
|
||||
...hotel.availability.productType.member,
|
||||
requestedPrice:
|
||||
hotel.availability.productType?.member.requestedPrice ??
|
||||
undefined,
|
||||
}
|
||||
: undefined,
|
||||
voucher: hotel.availability.productType?.voucher,
|
||||
bonusCheque: hotel.availability.productType?.bonusCheque
|
||||
? {
|
||||
...hotel.availability.productType.bonusCheque,
|
||||
requestedPrice:
|
||||
hotel.availability.productType.bonusCheque
|
||||
.requestedPrice ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
redemptions: hotel.availability.productType?.redemptions?.map(
|
||||
(redemption) => ({
|
||||
...redemption,
|
||||
localPrice: {
|
||||
...redemption.localPrice,
|
||||
currency: redemption.localPrice.currency,
|
||||
},
|
||||
})
|
||||
),
|
||||
}
|
||||
}
|
||||
onHover={() => engage(hotel.hotel.name)}
|
||||
onHoverEnd={() => disengage()}
|
||||
onAddressClick={() => {
|
||||
const mapUrl = isAlternative
|
||||
? alternativeHotelsMap(lang)
|
||||
: selectHotelMap(lang)
|
||||
|
||||
disengage() // Disengage the current hotel to avoid the hover state from being active when clicking on the address
|
||||
activate(hotel.hotel.name)
|
||||
router.push(`${mapUrl}?${searchParams.toString()}`)
|
||||
}}
|
||||
belowInfoSlot={
|
||||
<HotelDetailsSidePeek
|
||||
hotel={{ ...hotel.hotel, url: hotel.url }}
|
||||
restaurants={hotel.restaurants}
|
||||
additionalHotelData={hotel.additionalData}
|
||||
triggerLabel={intl.formatMessage({
|
||||
defaultMessage: "See hotel details",
|
||||
})}
|
||||
buttonVariant="primary"
|
||||
/>
|
||||
}
|
||||
distanceToCityCenter={hotel.hotel.location.distanceToCentre}
|
||||
images={mapApiImagesToGalleryImages(hotel.hotel.galleryImages)}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
state={
|
||||
isHotelActiveInMapView(hotel.hotel.name) ? "active" : "default"
|
||||
}
|
||||
type={type}
|
||||
bookingCode={bookingCode}
|
||||
isAlternative={isAlternative}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{showBackToTop && (
|
||||
<BackToTopButton
|
||||
position="right"
|
||||
onClick={scrollToTop}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Back to top",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { SortOrder } from "../../misc/sortOrder"
|
||||
|
||||
import type { HotelResponse } from "../SelectHotel/helpers"
|
||||
|
||||
function getPricePerNight(hotel: HotelResponse): number {
|
||||
return (
|
||||
hotel.availability.productType?.member?.localPrice?.pricePerNight ??
|
||||
hotel.availability.productType?.public?.localPrice?.pricePerNight ??
|
||||
hotel.availability.productType?.redemptions?.find(
|
||||
(r) => r?.localPrice.pointsPerStay
|
||||
)?.localPrice?.pointsPerStay ??
|
||||
Infinity
|
||||
)
|
||||
}
|
||||
|
||||
export function getSortedHotels({
|
||||
hotels,
|
||||
sortBy,
|
||||
bookingCode,
|
||||
}: {
|
||||
hotels: HotelResponse[]
|
||||
sortBy: string
|
||||
bookingCode: string | null
|
||||
}) {
|
||||
const availableHotels = hotels.filter(
|
||||
(hotel) => !!hotel.availability.productType
|
||||
)
|
||||
const unavailableHotels = hotels.filter(
|
||||
(hotel) => !hotel.availability.productType
|
||||
)
|
||||
|
||||
const sortingStrategies: Record<
|
||||
string,
|
||||
(a: HotelResponse, b: HotelResponse) => number
|
||||
> = {
|
||||
[SortOrder.Name]: (a: HotelResponse, b: HotelResponse) =>
|
||||
a.hotel.name.localeCompare(b.hotel.name),
|
||||
[SortOrder.TripAdvisorRating]: (a: HotelResponse, b: HotelResponse) =>
|
||||
(b.hotel.ratings?.tripAdvisor.rating ?? 0) -
|
||||
(a.hotel.ratings?.tripAdvisor.rating ?? 0),
|
||||
[SortOrder.Price]: (a: HotelResponse, b: HotelResponse) =>
|
||||
getPricePerNight(a) - getPricePerNight(b),
|
||||
[SortOrder.Distance]: (a: HotelResponse, b: HotelResponse) =>
|
||||
a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre,
|
||||
}
|
||||
|
||||
const sortStrategy =
|
||||
sortingStrategies[sortBy] ?? sortingStrategies[SortOrder.Distance]
|
||||
|
||||
if (bookingCode) {
|
||||
const bookingCodeRateHotels = availableHotels.filter(
|
||||
(hotel) => hotel.availability.bookingCode
|
||||
)
|
||||
const regularRateHotels = availableHotels.filter(
|
||||
(hotel) => !hotel.availability.bookingCode
|
||||
)
|
||||
|
||||
return bookingCodeRateHotels
|
||||
.sort(sortStrategy)
|
||||
.concat(regularRateHotels.sort(sortStrategy))
|
||||
.concat(unavailableHotels.sort(sortStrategy))
|
||||
}
|
||||
|
||||
return availableHotels
|
||||
.sort(sortStrategy)
|
||||
.concat(unavailableHotels.sort(sortStrategy))
|
||||
}
|
||||
Reference in New Issue
Block a user