"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 BookingCodeFilter from "@scandic-hotels/booking-flow/BookingCodeFilter" import { BookingCodeFilterEnum, useBookingCodeFilterStore, } from "@scandic-hotels/booking-flow/stores/bookingCode-filter" import { alternativeHotels, selectHotel, } from "@scandic-hotels/common/constants/routes/hotelReservation" import { useScrollToTop } from "@scandic-hotels/common/hooks/useScrollToTop" import { trackEvent } from "@scandic-hotels/common/tracking/base" 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 { useHotelFilterStore } from "@/stores/hotel-filters" import { useHotelsMapStore } from "@/stores/hotels-map" import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton" import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn" import useLang from "@/hooks/useLang" import { mapApiImagesToGalleryImages } from "@/utils/imageGallery" import FilterAndSortModal from "../../Filters/FilterAndSortModal" import HotelListing from "../HotelListing" import { getVisibleHotels } from "./utils" import styles from "./selectHotelMapContent.module.css" import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map" import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers" const SKELETON_LOAD_DELAY = 750 export function SelectHotelMapContent({ hotelPins, cityCoordinates, mapId, hotels, filterList, bookingCode, isBookingCodeRateAvailable, isAlternativeHotels, }: Omit) { const lang = useLang() const intl = useIntl() const map = useMap() const isUserLoggedIn = useIsUserLoggedIn() const isAboveMobile = useMediaQuery("(min-width: 900px)") const [visibleHotels, setVisibleHotels] = useState([]) const [showSkeleton, setShowSkeleton] = useState(true) const listingContainerRef = useRef(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 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 = ( ) 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 (
{showBookingCodeFilter ? (
) : null}
{showSkeleton ? (
) : ( )} {showBackToTop && ( )}
{ 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.disengage() 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} />
) }