Files
web/packages/booking-flow/lib/components/SelectHotel/SelectHotelMap/SelectHotelMapContent/index.tsx
Anton Gunnarsson 16fbdb7ae0 Merged in fix/refactor-currency-display (pull request #3434)
fix(SW-3616): Handle EuroBonus point type everywhere

* Add tests to formatPrice

* formatPrice

* More work replacing config with api points type

* More work replacing config with api points type

* More fixing with currency

* maybe actually fixed it

* Fix MyStay

* Clean up

* Fix comments

* Merge branch 'master' into fix/refactor-currency-display

* Fix calculateTotalPrice for EB points + SF points + cash


Approved-by: Joakim Jäderberg
2026-01-15 09:32:17 +00:00

330 lines
10 KiB
TypeScript

"use client"
import { useMap } from "@vis.gl/react-google-maps"
import { useCallback, useEffect, 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 { InteractiveMap } from "@scandic-hotels/design-system/Map/InteractiveMap"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackEvent } from "@scandic-hotels/tracking/base"
import { useIsLoggedIn } from "../../../../hooks/useIsLoggedIn"
import useLang from "../../../../hooks/useLang"
import { mapApiImagesToGalleryImages } from "../../../../misc/imageGallery"
import {
BookingCodeFilterEnum,
useBookingCodeFilterStore,
} from "../../../../stores/bookingCode-filter"
import { useHotelResultCountStore } from "../../../../stores/hotel-result-count"
import { useHotelsMapStore } from "../../../../stores/hotels-map"
import BookingCodeFilter from "../../../BookingCodeFilter"
import { getHotelPins } from "../../../HotelCardDialogListing/utils"
import { RoomCardSkeleton } from "../../../RoomCardSkeleton/RoomCardSkeleton"
import FilterAndSortModal from "../../Filters/FilterAndSortModal"
import { useHotelFilters } from "../../Filters/useHotelFilters"
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 isAboveMobile = useMediaQuery("(min-width: 900px)")
const [visibleHotels, setVisibleHotels] = useState<HotelResponse[]>([])
const [showSkeleton, setShowSkeleton] = useState<boolean>(true)
const listingContainerRef = useRef<HTMLDivElement | null>(null)
const [activeFilters] = useHotelFilters(null)
const setResultCount = useHotelResultCountStore(
(state) => state.setResultCount
)
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="sm"
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({
id: "selectHotel.closeMap",
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 = showOnlyBookingCodeRates
? hotelPins.filter((hotel) => hotel.bookingCode).length
: hotelPins.length
useEffect(() => {
setResultCount(hotels.length, unfilteredHotelCount)
}, [hotels, setResultCount, unfilteredHotelCount])
return (
<div className={styles.container}>
<div className={styles.listingContainer} ref={listingContainerRef}>
<div className={styles.filterContainer}>
<Button
variant="Text"
type="button"
size="sm"
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({
id: "common.back",
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} />
)}
{showBackToTop && (
<BackToTopButton
position="left"
onClick={scrollToTop}
label={intl.formatMessage({
id: "common.backToTop",
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 ?? "",
},
}
})}
activeHotelPin={hotelMapStore.activeHotel}
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
}
trackEvent({
event: "hotelClickMap",
map: {
action: "hotel click - map",
},
hotelInfo: {
hotelId: args.hotelId,
},
})
hotelMapStore.activate(args.hotelName)
}}
onClickHotel={(hotelId) => {
trackEvent({
event: "hotelClickMap",
map: {
action: "hotel click - map",
},
hotelInfo: {
hotelId,
},
})
}}
lang={lang}
isUserLoggedIn={isUserLoggedIn}
/>
</div>
)
}