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,314 @@
|
||||
"use client"
|
||||
|
||||
import { useMap } from "@vis.gl/react-google-maps"
|
||||
import { useCallback, useMemo, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import {
|
||||
alternativeHotels,
|
||||
selectHotel,
|
||||
} from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop"
|
||||
import { debounce } from "@scandic-hotels/common/utils/debounce"
|
||||
import { BackToTopButton } from "@scandic-hotels/design-system/BackToTopButton"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Link from "@scandic-hotels/design-system/Link"
|
||||
import { InteractiveMap } from "@scandic-hotels/design-system/Map/InteractiveMap"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
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 { useTrackingContext } from "../../../../trackingContext"
|
||||
import BookingCodeFilter from "../../../BookingCodeFilter"
|
||||
import { getHotelPins } from "../../../HotelCardDialogListing/utils"
|
||||
import { RoomCardSkeleton } from "../../../RoomCardSkeleton/RoomCardSkeleton"
|
||||
import FilterAndSortModal from "../../Filters/FilterAndSortModal"
|
||||
import { type HotelResponse } from "../../helpers"
|
||||
import HotelListing from "../HotelListing"
|
||||
import { getVisibleHotels } from "./utils"
|
||||
|
||||
import styles from "./selectHotelMapContent.module.css"
|
||||
|
||||
import type { CategorizedHotelFilters } from "../../../../types"
|
||||
|
||||
const SKELETON_LOAD_DELAY = 750
|
||||
|
||||
interface SelectHotelMapContentProps {
|
||||
mapId: string
|
||||
hotels: HotelResponse[]
|
||||
cityCoordinates: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
bookingCode: string | undefined
|
||||
isBookingCodeRateAvailable?: boolean
|
||||
isAlternativeHotels?: boolean
|
||||
filterList: CategorizedHotelFilters
|
||||
}
|
||||
|
||||
export function SelectHotelMapContent({
|
||||
cityCoordinates,
|
||||
mapId,
|
||||
hotels,
|
||||
bookingCode,
|
||||
isBookingCodeRateAvailable,
|
||||
isAlternativeHotels,
|
||||
filterList,
|
||||
}: SelectHotelMapContentProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const map = useMap()
|
||||
const isUserLoggedIn = useIsLoggedIn()
|
||||
const tracking = useTrackingContext()
|
||||
|
||||
const isAboveMobile = useMediaQuery("(min-width: 900px)")
|
||||
const [visibleHotels, setVisibleHotels] = useState<HotelResponse[]>([])
|
||||
const [showSkeleton, setShowSkeleton] = useState<boolean>(true)
|
||||
const listingContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const hotelMapStore = useHotelsMapStore()
|
||||
|
||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||
threshold: 490,
|
||||
elementRef: listingContainerRef,
|
||||
refScrollable: true,
|
||||
})
|
||||
const activeCodeFilter = useBookingCodeFilterStore(
|
||||
(state) => state.activeCodeFilter
|
||||
)
|
||||
|
||||
const hotelPins = getHotelPins(hotels)
|
||||
|
||||
const coordinates = useMemo(() => {
|
||||
if (hotelMapStore.activeHotel) {
|
||||
const hotel = hotels.find(
|
||||
(hotel) => hotel.hotel.name === hotelMapStore.activeHotel
|
||||
)
|
||||
|
||||
if (hotel && hotel.hotel.location) {
|
||||
return isAboveMobile
|
||||
? {
|
||||
lat: hotel.hotel.location.latitude,
|
||||
lng: hotel.hotel.location.longitude,
|
||||
}
|
||||
: {
|
||||
lat: hotel.hotel.location.latitude - 0.003,
|
||||
lng: hotel.hotel.location.longitude,
|
||||
}
|
||||
}
|
||||
}
|
||||
return isAboveMobile
|
||||
? cityCoordinates
|
||||
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
|
||||
}, [hotelMapStore.activeHotel, hotels, isAboveMobile, cityCoordinates])
|
||||
|
||||
const showOnlyBookingCodeRates =
|
||||
bookingCode &&
|
||||
isBookingCodeRateAvailable &&
|
||||
activeCodeFilter === BookingCodeFilterEnum.Discounted
|
||||
|
||||
const filteredHotelPins = useMemo(() => {
|
||||
const updatedHotelsList = showOnlyBookingCodeRates
|
||||
? hotelPins.filter((hotel) => hotel.bookingCode)
|
||||
: hotelPins
|
||||
return updatedHotelsList.filter((hotel) =>
|
||||
activeFilters.every((filterId) =>
|
||||
hotel.facilityIds.includes(Number(filterId))
|
||||
)
|
||||
)
|
||||
}, [activeFilters, hotelPins, showOnlyBookingCodeRates])
|
||||
|
||||
const getHotelCards = useCallback(() => {
|
||||
const visibleHotels = getVisibleHotels(hotels, filteredHotelPins, map)
|
||||
setVisibleHotels(visibleHotels)
|
||||
setTimeout(() => {
|
||||
setShowSkeleton(false)
|
||||
}, SKELETON_LOAD_DELAY)
|
||||
}, [hotels, filteredHotelPins, map])
|
||||
|
||||
/**
|
||||
* Updates visible hotels when map viewport changes (zoom/pan)
|
||||
* - Debounces updates to prevent excessive re-renders during map interaction
|
||||
* - Shows loading skeleton while map tiles load
|
||||
* - Triggers on: initial load, zoom, pan, and tile loading completion
|
||||
*/
|
||||
const debouncedUpdateHotelCards = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
if (!map) return
|
||||
if (isAboveMobile) {
|
||||
setShowSkeleton(true)
|
||||
}
|
||||
getHotelCards()
|
||||
}, 100),
|
||||
[map, getHotelCards, isAboveMobile]
|
||||
)
|
||||
|
||||
const closeMapUrl = isAlternativeHotels
|
||||
? alternativeHotels(lang)
|
||||
: selectHotel(lang)
|
||||
const closeButton = (
|
||||
<Button
|
||||
variant="Primary"
|
||||
color="Inverted"
|
||||
wrapping
|
||||
size="Small"
|
||||
className={styles.closeButton}
|
||||
>
|
||||
<Link
|
||||
href={closeMapUrl}
|
||||
keepSearchParams
|
||||
prefetch
|
||||
className={styles.link}
|
||||
>
|
||||
<MaterialIcon icon="close" size={20} color="CurrentColor" />
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Close the map",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
|
||||
const isSpecialRate = bookingCode
|
||||
? hotels.some(
|
||||
(hotel) =>
|
||||
hotel.availability.productType?.bonusCheque ||
|
||||
hotel.availability.productType?.voucher
|
||||
)
|
||||
: false
|
||||
|
||||
const showBookingCodeFilter =
|
||||
bookingCode && isBookingCodeRateAvailable && !isSpecialRate
|
||||
|
||||
const unfilteredHotelCount = hotelPins.length
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.listingContainer} ref={listingContainerRef}>
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
variant="Text"
|
||||
type="button"
|
||||
size="Small"
|
||||
className={styles.filterContainerCloseButton}
|
||||
>
|
||||
<Link href={closeMapUrl} keepSearchParams className={styles.link}>
|
||||
<MaterialIcon
|
||||
icon="arrow_back_ios"
|
||||
color="CurrentColor"
|
||||
size={20}
|
||||
/>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p>{intl.formatMessage({ defaultMessage: "Back" })}</p>
|
||||
</Typography>
|
||||
</Link>
|
||||
</Button>
|
||||
<FilterAndSortModal
|
||||
filters={filterList}
|
||||
setShowSkeleton={setShowSkeleton}
|
||||
/>
|
||||
{showBookingCodeFilter ? (
|
||||
<div className={styles.bookingCodeFilter}>
|
||||
<BookingCodeFilter />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showSkeleton ? (
|
||||
<div className={styles.skeletonContainer}>
|
||||
<RoomCardSkeleton />
|
||||
<RoomCardSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<HotelListing
|
||||
hotels={visibleHotels}
|
||||
unfilteredHotelCount={unfilteredHotelCount}
|
||||
/>
|
||||
)}
|
||||
{showBackToTop && (
|
||||
<BackToTopButton
|
||||
position="left"
|
||||
onClick={scrollToTop}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Back to top",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
hotelPins={filteredHotelPins.map((pin) => {
|
||||
const galleryImage = mapApiImagesToGalleryImages(pin.images).at(0)
|
||||
return {
|
||||
...pin,
|
||||
ratings: {
|
||||
tripAdvisor: pin.ratings ?? null,
|
||||
},
|
||||
image: {
|
||||
alt: galleryImage?.alt ?? "",
|
||||
url: galleryImage?.src ?? "",
|
||||
},
|
||||
}
|
||||
})}
|
||||
mapId={mapId}
|
||||
onTilesLoaded={debouncedUpdateHotelCards}
|
||||
fitBounds={isAboveMobile || !hotelMapStore.activeHotel}
|
||||
onHoverHotelPin={(args) => {
|
||||
if (!args) {
|
||||
hotelMapStore.disengageAfterDelay()
|
||||
return
|
||||
}
|
||||
|
||||
hotelMapStore.engage(args.hotelName)
|
||||
}}
|
||||
hoveredHotelPin={hotelMapStore.hoveredHotel}
|
||||
onSetActiveHotelPin={(args) => {
|
||||
if (!args || args.hotelName === hotelMapStore.activeHotel) {
|
||||
hotelMapStore.deactivate()
|
||||
return
|
||||
}
|
||||
|
||||
tracking.trackGenericEvent({
|
||||
event: "hotelClickMap",
|
||||
map: {
|
||||
action: "hotel click - map",
|
||||
},
|
||||
hotelInfo: {
|
||||
hotelId: args.hotelId,
|
||||
},
|
||||
})
|
||||
|
||||
hotelMapStore.activate(args.hotelName)
|
||||
}}
|
||||
onClickHotel={(hotelId) => {
|
||||
tracking.trackGenericEvent({
|
||||
event: "hotelClickMap",
|
||||
map: {
|
||||
action: "hotel click - map",
|
||||
},
|
||||
hotelInfo: {
|
||||
hotelId,
|
||||
},
|
||||
})
|
||||
}}
|
||||
lang={lang}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.container .closeButton {
|
||||
pointer-events: initial;
|
||||
box-shadow: var(--button-box-shadow);
|
||||
gap: var(--Space-x05);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.filterContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
padding: var(--Space-x025) var(--Space-x2);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.container .listingContainer .filterContainer > button {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skeletonContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.bookingCodeFilter {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.container .closeButton {
|
||||
display: flex;
|
||||
}
|
||||
.container .listingContainer .filterContainer .filterContainerCloseButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.listingContainer {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
padding: var(--Space-x3) var(--Space-x4) var(--Space-x3)
|
||||
var(--Layout-Tablet-Margin-Margin-min);
|
||||
overflow-y: auto;
|
||||
min-width: 420px;
|
||||
width: 420px;
|
||||
position: relative;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.filterContainer {
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 var(--Space-x1);
|
||||
position: static;
|
||||
}
|
||||
|
||||
.skeletonContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.listingContainer {
|
||||
padding: var(--Space-x3) var(--Space-x4) var(--Space-x3)
|
||||
var(--Layout-Desktop-Margin-Margin-min);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { HotelPin } from "../../../HotelCardDialogListing/utils"
|
||||
import type { HotelResponse } from "../../helpers"
|
||||
|
||||
export function getVisibleHotelPins(
|
||||
map: google.maps.Map | null,
|
||||
filteredHotelPins: HotelPin[]
|
||||
) {
|
||||
if (!map || !filteredHotelPins) return []
|
||||
|
||||
const bounds = map.getBounds()
|
||||
if (!bounds) return []
|
||||
|
||||
return filteredHotelPins.filter((pin) => {
|
||||
const { lat, lng } = pin.coordinates
|
||||
return bounds.contains({ lat, lng })
|
||||
})
|
||||
}
|
||||
|
||||
export function getVisibleHotels(
|
||||
hotels: HotelResponse[],
|
||||
filteredHotelPins: HotelPin[],
|
||||
map: google.maps.Map | null
|
||||
) {
|
||||
const visibleHotelPins = getVisibleHotelPins(map, filteredHotelPins)
|
||||
const visibleHotels = hotels.filter((hotel) =>
|
||||
visibleHotelPins.some((pin) => pin.operaId === hotel.hotel.operaId)
|
||||
)
|
||||
return visibleHotels
|
||||
}
|
||||
Reference in New Issue
Block a user