Merged in feat/SW-1549-map-improvements (pull request #1783)

Feat/SW-1549 map improvements

* fix: imported new icon

* refactor: rename component and set map handling to 'greedy'

* fix: show cards for 3s after hover

* refactor: update styles and added HotelPin component

* fix: change from close to back icon

* refactor: update to only use 1 state value for active pin and card

* fix: add click handler when dialog is opened

* fix: performance fixes for the dialog carousel

* fix: added border

* fix: clear timeout on mouseenter

* fix: changed to absolute import

* fix: moved hover state into the store

* fix: renamed store actions


Approved-by: Michael Zetterberg
This commit is contained in:
Tobias Johansson
2025-04-15 13:23:23 +00:00
parent 57cd2f6a7f
commit 9a9789e736
27 changed files with 288 additions and 261 deletions

View File

@@ -2,7 +2,7 @@
import { useParams } from "next/dist/client/components/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { memo, useCallback } from "react"
import { memo } from "react"
import { useIntl } from "react-intl"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
@@ -53,16 +53,7 @@ function HotelCard({
const lang = params.lang as Lang
const intl = useIntl()
const { setActiveHotelPin, setActiveHotelCard } = useHotelsMapStore()
const handleMouseEnter = useCallback(() => {
setActiveHotelPin(hotel.name)
}, [setActiveHotelPin, hotel])
const handleMouseLeave = useCallback(() => {
setActiveHotelPin(null)
setActiveHotelCard(null)
}, [setActiveHotelPin, setActiveHotelCard])
const { activate, engage, disengageAfterDelay } = useHotelsMapStore()
const amenities = hotel.detailedFacilities.slice(0, 5)
const router = useRouter()
@@ -73,8 +64,7 @@ function HotelCard({
const handleAddressClick = (event: React.MouseEvent) => {
event.preventDefault()
setActiveHotelPin(hotel.name)
setActiveHotelCard(hotel.name)
activate(hotel.name)
router.push(`${selectHotelMap(lang)}?${searchParams.toString()}`)
}
@@ -95,8 +85,8 @@ function HotelCard({
return (
<article
className={classNames}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseEnter={() => engage(hotel.name)}
onMouseLeave={() => disengageAfterDelay()}
>
<div>
<div className={styles.imageContainer}>

View File

@@ -62,28 +62,20 @@ export default function ListingHotelCardDialog({
setImageError={setImageError}
position="top"
/>
<div className={styles.detailsContainer}>
<div>
<div className={styles.name}>
<Subtitle type="two">{name}</Subtitle>
</div>
<div className={styles.facilities}>
{amenities.map((facility) => {
const Icon = (
{amenities.map((facility) => (
<div className={styles.facilitiesItem} key={facility.id}>
<FacilityToIcon
id={facility.id}
size={20}
color="Icon/Default"
/>
)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{Icon && Icon}
<Caption color="uiTextMediumContrast">
{facility.name}
</Caption>
</div>
)
})}
))}
</div>
</div>
</div>
@@ -115,7 +107,7 @@ export default function ListingHotelCardDialog({
</>
)}
{memberPrice && (
<Subtitle type="two" color="red" className={styles.memberPrice}>
<Subtitle type="two" color="red">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",

View File

@@ -65,12 +65,10 @@ export default function StandaloneHotelCardDialog({
/>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.detailsContainer}>
<div className={styles.name}>
<Body textTransform="bold">{name}</Body>
</div>
</div>
</div>
<div className={styles.facilities}>
{amenities.slice(0, 3).map((facility) => {
const Icon = (
@@ -126,11 +124,7 @@ export default function StandaloneHotelCardDialog({
</Subtitle>
)}
{memberPrice && (
<Subtitle
type="two"
color="red"
className={styles.memberPrice}
>
<Subtitle type="two" color="red">
{intl.formatMessage(
{
defaultMessage: "{price} {currency}",

View File

@@ -66,7 +66,7 @@
white-space: nowrap;
position: relative;
padding-right: 20px;
gap: 0 var(--Spacing-x1);
gap: 0 var(--Spacing-x-one-and-half);
max-width: 242px;
}
.dialogContainer[data-type="listing"] .facilities::after {

View File

@@ -31,7 +31,7 @@ export default function HotelCardDialog({
<dialog open={isOpen} className={styles.dialog}>
<div className={styles.dialogContainer} data-type={type}>
<div onClick={handleClose}>
<MaterialIcon icon="close" className={styles.closeIcon} size={16} />
<MaterialIcon icon="close" className={styles.closeIcon} size={22} />
</div>
{type === "standalone" ? (

View File

@@ -3,6 +3,23 @@
flex-direction: row;
gap: var(--Spacing-x1);
align-items: flex-end;
overflow-x: scroll;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch; /* Needed to work on iOS Safari */
padding-inline: var(--Spacing-x2);
scroll-padding-inline: var(--Spacing-x2);
overscroll-behavior-inline: contain;
scroll-behavior: smooth;
will-change: transform;
backface-visibility: hidden;
transform: translateZ(0);
}
.hotelCardDialogListing > div {
height: 100%;
scroll-snap-align: center;
}
.hotelCardDialogListing dialog {
@@ -10,11 +27,3 @@
padding: 0;
margin: 0;
}
.hotelCardDialogListing > div:first-child {
margin-left: var(--Spacing-x2);
}
.hotelCardDialogListing > div:last-child {
margin-right: var(--Spacing-x2);
}

View File

@@ -2,12 +2,9 @@
import { useCallback, useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { useHotelsMapStore } from "@/stores/hotels-map"
import useClickOutside from "@/hooks/useClickOutside"
import HotelCardDialog from "../HotelCardDialog"
import { getHotelPins } from "./utils"
@@ -27,74 +24,104 @@ export default function HotelCardDialogListing({
defaultMessage: "Points",
})
: undefined
const hotelsPinData = hotels ? getHotelPins(hotels, currencyValue) : []
const hotelsPinData = getHotelPins(hotels, currencyValue)
const activeCardRef = useRef<HTMLDivElement | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const isMobile = useMediaQuery("(max-width: 767px)")
const { activeHotelCard, setActiveHotelCard, setActiveHotelPin } =
useHotelsMapStore()
function handleClose() {
setActiveHotelCard(null)
setActiveHotelPin(null)
}
useClickOutside(dialogRef, !!activeHotelCard && isMobile, handleClose)
const isScrollingRef = useRef<boolean>(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { activeHotel, activate, deactivate } = useHotelsMapStore()
const handleIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
// skip intersection handling during scrolling
if (isScrollingRef.current) return
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
debounceTimerRef.current = setTimeout(() => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const cardName = entry.target.getAttribute("data-name")
if (cardName) {
setActiveHotelCard(cardName)
if (cardName && cardName !== activeHotel) {
activate(cardName)
}
}
})
}, 100)
},
[setActiveHotelCard]
[activate, activeHotel]
)
useEffect(() => {
observerRef.current = new IntersectionObserver(handleIntersection, {
root: null,
threshold: 0.5,
threshold: [0.3, 0.5, 0.7],
})
const elements = document.querySelectorAll("[data-name]")
elements.forEach((el) => observerRef.current?.observe(el))
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
elements.forEach((el) => observerRef.current?.unobserve(el))
observerRef.current?.disconnect()
observerRef.current = null
}
}, [handleIntersection])
useEffect(() => {
if (activeCardRef.current) {
// Temporarily disconnect the observer
observerRef.current?.disconnect()
isScrollingRef.current = true
activeCardRef.current.scrollIntoView({
requestAnimationFrame(() => {
activeCardRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
})
// Reconnect the observer after scrolling
const elements = document.querySelectorAll("[data-name]")
setTimeout(() => {
elements.forEach((el) => observerRef.current?.observe(el))
}, 1000)
isScrollingRef.current = false
}, 800)
})
}
}, [activeHotelCard])
}, [activeHotel])
useEffect(() => {
const handleMapClick = (e: MouseEvent) => {
// ignore clicks within the dialog
if (dialogRef.current?.contains(e.target as Node)) {
return
}
// ignore clicks on hotel pins
const target = e.target as HTMLElement
if (target.closest("[data-hotelpin]")) {
return
}
deactivate()
}
if (activeHotel) {
document.addEventListener("click", handleMapClick)
}
return () => {
document.removeEventListener("click", handleMapClick)
}
}, [dialogRef, activeHotel, deactivate])
return (
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
{!!hotelsPinData?.length &&
hotelsPinData.map((data) => {
const isActive = data.name === activeHotelCard
{hotelsPinData?.map((data) => {
const isActive = data.name === activeHotel
return (
<div
key={data.name}
@@ -103,8 +130,8 @@ export default function HotelCardDialogListing({
>
<HotelCardDialog
data={data}
isOpen={!!activeHotelCard}
handleClose={handleClose}
isOpen={!!activeHotel}
handleClose={deactivate}
type="listing"
/>
</div>

View File

@@ -37,7 +37,7 @@ export default function HotelCardListing({
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const intl = useIntl()
const { activeHotelCard } = useHotelsMapStore()
const { activeHotel } = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
const activeCardRef = useRef<HTMLDivElement | null>(null)
@@ -108,7 +108,7 @@ export default function HotelCardListing({
inline: "center",
})
}
}, [activeHotelCard, type])
}, [activeHotel, type])
useEffect(() => {
setResultCount(hotels.length)
@@ -116,8 +116,7 @@ export default function HotelCardListing({
function isHotelActiveInMapView(hotelName: string): boolean {
return (
hotelName === activeHotelCard &&
type === HotelCardListingTypeEnum.MapListing
hotelName === activeHotel && type === HotelCardListingTypeEnum.MapListing
)
}

View File

@@ -13,12 +13,11 @@ import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/se
import type { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelListing({ hotels }: HotelListingProps) {
const { activeHotelPin } = useHotelsMapStore()
const { activeHotel } = useHotelsMapStore()
const isMobile = useMediaQuery("(max-width: 767px)")
return (
<>
{isMobile ? (
<div className={styles.hotelListingMobile} data-open={!!activeHotelPin}>
return isMobile ? (
<div className={styles.hotelListingMobile} data-open={!!activeHotel}>
<HotelCardDialogListing hotels={hotels} />
</div>
) : (
@@ -28,7 +27,5 @@ export default function HotelListing({ hotels }: HotelListingProps) {
type={HotelCardListingTypeEnum.MapListing}
/>
</div>
)}
</>
)
}

View File

@@ -53,7 +53,7 @@ export default function SelectHotelContent({
const listingContainerRef = useRef<HTMLDivElement | null>(null)
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const { activeHotelPin } = useHotelsMapStore()
const { activeHotel } = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 490,
@@ -65,8 +65,8 @@ export default function SelectHotelContent({
)
const coordinates = useMemo(() => {
if (activeHotelPin) {
const hotel = hotels.find((hotel) => hotel.hotel.name === activeHotelPin)
if (activeHotel) {
const hotel = hotels.find((hotel) => hotel.hotel.name === activeHotel)
if (hotel && hotel.hotel.location) {
return {
@@ -79,7 +79,7 @@ export default function SelectHotelContent({
return isAboveMobile
? cityCoordinates
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
}, [activeHotelPin, hotels, isAboveMobile, cityCoordinates])
}, [activeHotel, hotels, isAboveMobile, cityCoordinates])
const filteredHotelPins = useMemo(() => {
const updatedHotelsList = bookingCode
@@ -159,14 +159,15 @@ export default function SelectHotelContent({
<div className={styles.filterContainer}>
<Button
intent="text"
size="small"
type="button"
variant="icon"
wrapping
size="small"
className={styles.filterContainerCloseButton}
asChild
>
<Link href={selectHotel(lang)} keepSearchParams>
<MaterialIcon icon="close" color="CurrentColor" />
<MaterialIcon icon="arrow_back" color="CurrentColor" size={20} />
{intl.formatMessage({ id: "Back" })}
</Link>
</Button>
<FilterAndSortModal

View File

@@ -27,6 +27,10 @@
display: none;
}
.filterContainerCloseButton {
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
}
@media (min-width: 768px) {
.container .closeButton {
display: flex;

View File

@@ -0,0 +1,32 @@
.pin {
display: flex;
justify-content: center;
align-items: center;
padding: var(--Spacing-x-half) var(--Spacing-x1) var(--Spacing-x-half)
var(--Spacing-x-half);
border: 2px solid var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Rounded);
background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
gap: var(--Spacing-x1);
width: max-content;
}
.pin.active {
background-color: var(--Primary-Dark-Surface-Normal);
color: var(--Base-Surface-Primary-light-Normal);
}
.pinIcon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--Surface-Brand-Primary-2-Default);
}
.pin.active .pinIcon {
background: var(--Base-Surface-Primary-light-Normal);
}

View File

@@ -0,0 +1,46 @@
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import HotelMarker from "@/components/Maps/Markers/HotelMarker"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./hotelPin.module.css"
interface HotelPinProps {
isActive: boolean
hotelPrice: number | null
currency: string
}
export default function HotelPin({
isActive,
hotelPrice,
currency,
}: HotelPinProps) {
const intl = useIntl()
const isNotAvailable = !hotelPrice
return (
<div
className={`${styles.pin} ${isActive ? styles.active : ""}`}
data-hotelpin
>
<span className={styles.pinIcon}>
{isNotAvailable ? (
<MaterialIcon
icon="calendar_clock"
size={16}
color={isActive ? "Icon/Interactive/Default" : "Icon/Inverted"}
/>
) : (
<HotelMarker width={16} color={isActive ? "burgundy" : "white"} />
)}
</span>
<Typography variant="Body/Paragraph/mdRegular">
<p>{isNotAvailable ? "—" : formatPrice(intl, hotelPrice, currency)}</p>
</Typography>
</div>
)
}

View File

@@ -1,62 +1,11 @@
.advancedMarker {
height: 32px;
min-width: 109px !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
}
.advancedMarker.active {
height: 32px;
min-width: 109px !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
}
.dialogContainer {
display: none;
}
.pin {
position: absolute;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
padding: var(--Spacing-x-half) var(--Spacing-x1) var(--Spacing-x-half)
var(--Spacing-x-half);
border-radius: var(--Corner-radius-Rounded);
background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
gap: var(--Spacing-x1);
width: max-content;
}
.pin.active {
background-color: var(--Primary-Dark-Surface-Normal);
}
.pinLabel {
display: none;
}
.pin.active .pinLabel {
display: flex;
align-items: center;
gap: var(--Spacing-x2);
text-wrap: nowrap;
}
.pinIcon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--Primary-Dark-Surface-Normal);
}
.pin.active .pinIcon {
background: var(--Base-Surface-Primary-light-Normal);
}
.card {
display: none;
position: absolute;

View File

@@ -2,62 +2,39 @@ import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
} from "@vis.gl/react-google-maps"
import { useCallback, useState } from "react"
import { useIntl } from "react-intl"
import { useCallback } from "react"
import { useHotelsMapStore } from "@/stores/hotels-map"
import HotelCardDialog from "@/components/HotelReservation/HotelCardDialog"
import Body from "@/components/TempDesignSystem/Text/Body"
import { formatPrice } from "@/utils/numberFormatting"
import HotelMarker from "../../Markers/HotelMarker"
import HotelPin from "./HotelPin"
import styles from "./hotelListingMapContent.module.css"
import type { HotelListingMapContentProps } from "@/types/components/hotelReservation/selectHotel/map"
function HotelListingMapContent({ hotelPins }: HotelListingMapContentProps) {
const intl = useIntl()
const [hoveredHotelPin, setHoveredHotelPin] = useState<string | null>(null)
const { activeHotelPin, setActiveHotelPin, setActiveHotelCard } =
const { activeHotel, hoveredHotel, activate, deactivate, engage, disengage } =
useHotelsMapStore()
const toggleActiveHotelPin = useCallback(
(pinName: string | null) => {
if (setActiveHotelPin) {
const newActivePin = activeHotelPin === pinName ? null : pinName
setActiveHotelPin(newActivePin)
setActiveHotelCard(newActivePin)
if (newActivePin === null) {
setHoveredHotelPin(null)
setActiveHotelCard(null)
if (activeHotel === pinName || pinName === null) {
deactivate()
return
}
}
},
[activeHotelPin, setActiveHotelPin, setActiveHotelCard]
)
const handleHover = useCallback(
(pinName: string | null) => {
if (pinName !== null && activeHotelPin !== pinName) {
setHoveredHotelPin(pinName)
if (activeHotelPin && setActiveHotelPin) {
setActiveHotelPin(null)
setActiveHotelCard(null)
}
} else if (pinName === null) {
setHoveredHotelPin(null)
}
activate(pinName)
},
[activeHotelPin, setActiveHotelPin, setActiveHotelCard]
[activeHotel, activate, deactivate]
)
return (
<div>
{hotelPins.map((pin) => {
const isActiveOrHovered =
activeHotelPin === pin.name || hoveredHotelPin === pin.name
activeHotel === pin.name || hoveredHotel === pin.name
const hotelPrice =
pin.memberPrice ?? pin.publicPrice ?? pin.redemptionPrice
return (
@@ -67,8 +44,8 @@ function HotelListingMapContent({ hotelPins }: HotelListingMapContentProps) {
position={pin.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
zIndex={isActiveOrHovered ? 2 : 0}
onMouseEnter={() => handleHover(pin.name)}
onMouseLeave={() => handleHover(null)}
onMouseEnter={() => engage(pin.name)}
onMouseLeave={() => disengage()}
onClick={() => toggleActiveHotelPin(pin.name)}
>
<div className={styles.dialogContainer}>
@@ -76,37 +53,17 @@ function HotelListingMapContent({ hotelPins }: HotelListingMapContentProps) {
isOpen={isActiveOrHovered}
handleClose={(event: { stopPropagation: () => void }) => {
event.stopPropagation()
if (activeHotelPin === pin.name) {
toggleActiveHotelPin(null)
}
deactivate()
disengage()
}}
data={pin}
/>
</div>
<span
className={`${styles.pin} ${isActiveOrHovered ? styles.active : ""}`}
>
<span className={styles.pinIcon}>
<HotelMarker
width={16}
color={isActiveOrHovered ? "burgundy" : "white"}
<HotelPin
isActive={isActiveOrHovered}
hotelPrice={hotelPrice}
currency={pin.currency}
/>
</span>
<Body
asChild
color={isActiveOrHovered ? "white" : "baseTextHighContrast"}
>
<span>
{/* TODO: Handle when no price is available */}
{hotelPrice
? formatPrice(intl, hotelPrice, pin.currency)
: intl.formatMessage({
defaultMessage: "N/A",
})}
</span>
</Body>
</span>
</AdvancedMarker>
)
})}

View File

@@ -10,16 +10,16 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
import PoiMarker from "../../Markers/Poi"
import ScandicMarker from "../../Markers/Scandic"
import styles from "./hotelMapContent.module.css"
import styles from "./poiMapMarkers.module.css"
import type { HotelMapContentProps } from "@/types/hotel"
import type { PoiMapMarkersProps } from "@/types/hotel"
export default function HotelMapContent({
export default function PoiMapMarkers({
coordinates,
pointsOfInterest,
onActivePoiChange,
activePoi,
}: HotelMapContentProps) {
}: PoiMapMarkersProps) {
const intl = useIntl()
function toggleActivePoi(poiName: string) {

View File

@@ -9,7 +9,7 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Button from "@/components/TempDesignSystem/Button"
import HotelListingMapContent from "./HotelListingMapContent"
import HotelMapContent from "./HotelMapContent"
import PoiMapMarkers from "./PoiMapMarkers"
import styles from "./interactiveMap.module.css"
@@ -36,6 +36,7 @@ export default function InteractiveMap({
disableDefaultUI: true,
clickableIcons: false,
mapId,
gestureHandling: "greedy",
}
function zoomIn() {
@@ -67,7 +68,7 @@ export default function InteractiveMap({
<Map {...mapOptions} onTilesLoaded={onTilesLoaded}>
{hotelPins && <HotelListingMapContent hotelPins={hotelPins} />}
{pointsOfInterest && (
<HotelMapContent
<PoiMapMarkers
coordinates={coordinates}
pointsOfInterest={pointsOfInterest}
onActivePoiChange={onActivePoiChange}

View File

@@ -35,7 +35,7 @@
.zoomButtons {
display: grid;
gap: var(--Spacing-x1);
gap: var(--Spacing-x2);
}
.closeButton {
@@ -55,8 +55,8 @@
@media screen and (min-width: 768px) {
.ctaButtons {
top: var(--Spacing-x4);
right: var(--Spacing-x4);
bottom: var(--Spacing-x4);
right: var(--Spacing-x5);
bottom: var(--Spacing-x7);
justify-content: space-between;
}

View File

@@ -1,15 +1,43 @@
import { create } from "zustand"
interface HotelsMapState {
activeHotelCard: string | null
activeHotelPin: string | null
setActiveHotelCard: (hotelCard: string | null) => void
setActiveHotelPin: (hotelPin: string | null) => void
activeHotel: string | null
hoveredHotel: string | null
hoverTimeout: number | null
activate: (hotel: string | null) => void
deactivate: () => void
engage: (hotel: string | null) => void
disengage: () => void
disengageAfterDelay: () => void
}
export const useHotelsMapStore = create<HotelsMapState>((set) => ({
activeHotelCard: null,
activeHotelPin: null,
setActiveHotelCard: (hotelCard) => set({ activeHotelCard: hotelCard }),
setActiveHotelPin: (hotelPin) => set({ activeHotelPin: hotelPin }),
export const useHotelsMapStore = create<HotelsMapState>((set, get) => ({
activeHotel: null,
hoveredHotel: null,
hoverTimeout: null,
activate: (hotel) => set({ activeHotel: hotel }),
deactivate: () => set({ activeHotel: null }),
engage: (hotel) => {
const state = get()
if (state.hoverTimeout) {
window.clearTimeout(state.hoverTimeout)
}
if (hotel && state.activeHotel) {
set({ activeHotel: null })
}
set({ hoveredHotel: hotel })
},
disengage: () => {
set({ hoveredHotel: null })
},
disengageAfterDelay: () => {
const timeoutId = window.setTimeout(() => {
set({ hoveredHotel: null, activeHotel: null, hoverTimeout: null })
}, 3000)
set({ hoverTimeout: timeoutId })
},
}))

View File

@@ -63,7 +63,7 @@ export type RestaurantOpeningHoursDay = z.output<
>
export type Room = ReturnType<typeof transformRoomCategories>
export type HotelMapContentProps = {
export type PoiMapMarkersProps = {
activePoi?: string | null
coordinates: { lat: number; lng: number }
onActivePoiChange?: (poiName: string | null) => void

View File

@@ -269,7 +269,7 @@
font-style: normal;
font-weight: 400;
font-display: block;
src: url(/_static/fonts/material-symbols/rounded-ad9423f9.woff2)
src: url(/_static/fonts/material-symbols/rounded-112272ae.woff2)
format('woff2');
}

View File

@@ -45,6 +45,7 @@ const icons = [
'box',
'business_center',
'calendar_add_on',
'calendar_clock',
'calendar_month',
'calendar_today',
'call',