fix(BOOK-138): Fixed several issues after country map functionality was added

* fix(BOOK-138): Fixed issue when hovering markers and info windows for both city cluster marker as city markers.

Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-10-07 11:37:54 +00:00
parent d88cd3f418
commit 36aa5089ea
17 changed files with 347 additions and 197 deletions

View File

@@ -1,9 +1,7 @@
.wrapper {
background: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-md);
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
.cityMapCard {
display: flex;
position: relative;
background-color: var(--Base-Surface-Primary-light-Normal);
}
.name {
@@ -28,8 +26,13 @@
gap: var(--Space-x1);
}
.link {
justify-content: center;
.exploreLink {
justify-self: center;
color: var(--Text-Interactive-Secondary);
&:hover {
color: var(--Text-Interactive-Secondary-Hover);
}
}
.links {
@@ -37,6 +40,13 @@
gap: var(--Space-x1);
}
@media screen and (max-width: 949px) {
.cityMapCard {
border-radius: var(--Corner-radius-md);
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
}
}
@media screen and (min-width: 950px) {
.content {
min-width: 220px;
@@ -44,7 +54,7 @@
flex-direction: column;
}
.link {
.exploreLink {
display: none;
}
}

View File

@@ -1,4 +1,5 @@
"use client"
import { cx } from "class-variance-authority"
import { useState } from "react"
import { useIntl } from "react-intl"
@@ -33,92 +34,75 @@ export default function CityMapCard({
}: CityMapCardProps) {
const intl = useIntl()
const [imageError, setImageError] = useState(false)
const { setActiveCityMarker, setHoveredCityMarker } =
const { setActiveCityMarker, resetActiveAndHoveredState } =
useDestinationPageCitiesMapStore()
function handleClose() {
setActiveCityMarker(null)
setHoveredCityMarker(null)
resetActiveAndHoveredState()
}
const cityMapUrl = setMapUrlFromCountryPage(url)
return (
<article className={className}>
<div className={styles.wrapper}>
<IconButton
theme="Black"
style="Muted"
className={styles.closeButton}
onPress={handleClose}
aria-label={intl.formatMessage({
defaultMessage: "Close",
})}
>
<MaterialIcon
icon="close"
size={16}
className={styles.closeIcon}
color="CurrentColor"
/>
</IconButton>
{image ? (
<DialogImage
image={image.src}
altText={image.alt}
imageError={imageError}
setImageError={setImageError}
/>
) : (
<ImageFallback />
)}
<article className={cx(styles.cityMapCard, className)}>
<IconButton
theme="Black"
style="Muted"
className={styles.closeButton}
onPress={handleClose}
aria-label={intl.formatMessage({
defaultMessage: "Close",
})}
>
<MaterialIcon
icon="close"
size={16}
className={styles.closeIcon}
color="CurrentColor"
/>
</IconButton>
{image ? (
<DialogImage
image={image.src}
altText={image.alt}
imageError={imageError}
setImageError={setImageError}
/>
) : (
<ImageFallback />
)}
<div className={styles.content}>
<div className={styles.name}>
<Typography variant="Body/Paragraph/mdBold">
<h4>{cityName}</h4>
</Typography>
</div>
{url && cityMapUrl && (
<span className={styles.links}>
<ButtonLink
href={cityMapUrl}
variant="Secondary"
color="Primary"
size="Small"
onClick={() => setActiveCityMarker(null)}
>
<Typography variant="Body/Supporting text (caption)/smBold">
<p>
{intl.formatMessage({
defaultMessage: "See hotels on map",
})}
</p>
</Typography>
</ButtonLink>
<Link
href={url}
color="Text/Interactive/Secondary"
variant="icon"
className={styles.link}
>
<Typography variant="Link/sm">
<span>
{intl.formatMessage({
defaultMessage: "Explore city",
})}
</span>
</Typography>
<MaterialIcon
icon="open_in_new"
size={20}
color="CurrentColor"
/>
</Link>
</span>
)}
<div className={styles.content}>
<div className={styles.name}>
<Typography variant="Body/Paragraph/mdBold">
<h4>{cityName}</h4>
</Typography>
</div>
{url && cityMapUrl && (
<span className={styles.links}>
<ButtonLink
href={cityMapUrl}
variant="Secondary"
color="Primary"
size="Small"
onClick={() => setActiveCityMarker(null)}
>
<Typography variant="Body/Supporting text (caption)/smBold">
<p>
{intl.formatMessage({
defaultMessage: "See hotels on map",
})}
</p>
</Typography>
</ButtonLink>
<Link href={url} className={styles.exploreLink} size="small">
{intl.formatMessage({
defaultMessage: "Explore city",
})}
</Link>
</span>
)}
</div>
</article>
)

View File

@@ -48,6 +48,14 @@
background-color: transparent;
}
.exploreLink {
color: var(--Text-Interactive-Secondary);
&:hover {
color: var(--Text-Interactive-Secondary-Hover);
}
}
@media (min-width: 950px) {
.content {
min-width: 220px;

View File

@@ -104,15 +104,10 @@ export function CityListItem({
<h3>{cityName}</h3>
</Typography>
<div>
<Link href={url} color="Text/Interactive/Secondary" variant="icon">
<Typography variant="Link/sm">
<span>
{intl.formatMessage({
defaultMessage: "Explore city",
})}
</span>
</Typography>
<MaterialIcon icon="open_in_new" size={20} color="CurrentColor" />
<Link href={url} className={styles.exploreLink} size="small">
{intl.formatMessage({
defaultMessage: "Explore city",
})}
</Link>
</div>
</div>

View File

@@ -1,7 +1,15 @@
.content {
background-color: var(--Base-Surface-Primary-light-Normal);
padding: var(--Space-x15);
display: grid;
gap: var(--Space-x1);
list-style: none;
white-space: nowrap;
}
@media screen and (max-width: 949px) {
.content {
border-radius: var(--Corner-radius-md);
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
}
}

View File

@@ -68,11 +68,18 @@
/* Overriding Google maps infoWindow styles */
.mapWrapper :global(.gm-style .gm-style-iw-c) {
background-color: transparent !important;
padding: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.mapWrapper :global(.gm-style .gm-style-iw-d) {
padding: 0 !important;
overflow: hidden !important;
}
.mapWrapper :global(.gm-style .gm-style-iw-tc) {
display: none !important;
}
}

View File

@@ -1,4 +1,5 @@
.cityMarker {
display: block;
width: 28px !important;
height: 28px !important;
background-color: var(--Base-Text-High-contrast);
@@ -7,13 +8,12 @@
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s;
}
.cityMarker:hover,
.hoveredChild {
background:
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
var(--Surface-Brand-Primary-2-Default);
width: var(--Space-x4) !important;
height: var(--Space-x4) !important;
&.active {
background:
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
var(--Surface-Brand-Primary-2-Default);
width: 32px !important;
height: 32px !important;
}
}

View File

@@ -3,11 +3,14 @@
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
InfoWindow,
} from "@vis.gl/react-google-maps"
import { useCallback, useState } from "react"
import { cx } from "class-variance-authority"
import { useCallback } from "react"
import { useMediaQuery } from "usehooks-ts"
import { useMarkerHover } from "@scandic-hotels/common/hooks/map/useMarkerHover"
import { InfoWindow } from "@scandic-hotels/design-system/Map/InfoWindow"
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
import { trackMapClick } from "@/utils/tracking/destinationPage"
@@ -31,63 +34,55 @@ export default function CityMarker({ position, properties }: CityMarkerProps) {
setActiveCityMarker,
} = useDestinationPageCitiesMapStore()
const [infoWindowHovered, setInfoWindowHovered] = useState<boolean>(false)
const isDesktop = useMediaQuery("(min-width: 950px)")
const { handleMouseEnter, handleMouseLeave } = useMarkerHover((isHovered) => {
if (isHovered) {
if (activeCityMarker?.cityId !== properties.id) {
setActiveCityMarker(null)
}
setHoveredCityMarker(properties.id)
} else {
setHoveredCityMarker(null)
}
})
const handleClick = useCallback(() => {
setActiveCityMarker({ cityId: properties.id, location: position })
trackMapClick(`city with id: ${properties.id}`)
}, [position, properties.id, setActiveCityMarker])
function handleMouseEnter() {
if (activeCityMarker?.cityId !== hoveredCityMarker) {
setActiveCityMarker(null)
}
setHoveredCityMarker(properties.id)
}
function handleMouseLeave() {
setTimeout(() => {
if (!infoWindowHovered) {
setHoveredCityMarker(null)
}
}, 100)
}
const isHovered = hoveredCityMarker === properties.id || infoWindowHovered
const isHovered = hoveredCityMarker === properties.id
const isActive = activeCityMarker?.cityId === properties.id
return (
<AdvancedMarker
position={position}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`${styles.cityMarker} ${isActive || isHovered ? styles.hoveredChild : ""}`}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
>
<span
className={cx(styles.cityMarker, {
[styles.active]: isActive || isHovered,
})}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
{isDesktop && (isActive || isHovered) ? (
<span
onMouseEnter={() => setInfoWindowHovered(true)}
onMouseLeave={() => setInfoWindowHovered(false)}
<InfoWindow
position={position}
headerDisabled={true}
minWidth={450}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<InfoWindow
position={position}
pixelOffset={[0, -24]}
headerDisabled={true}
minWidth={450}
>
<CityMapCard
cityName={properties.name}
image={properties.image}
url={properties.url}
/>
</InfoWindow>
</span>
) : (
<span />
)}
<CityMapCard
cityName={properties.name}
image={properties.image}
url={properties.url}
/>
</InfoWindow>
) : null}
</AdvancedMarker>
)
}

View File

@@ -3,11 +3,14 @@
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
InfoWindow,
} from "@vis.gl/react-google-maps"
import { useCallback, useState } from "react"
import { cx } from "class-variance-authority"
import { useCallback } from "react"
import { useMediaQuery } from "usehooks-ts"
import { useMarkerHover } from "@scandic-hotels/common/hooks/map/useMarkerHover"
import { InfoWindow } from "@scandic-hotels/design-system/Map/InfoWindow"
import { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
import { trackMapClick } from "@/utils/tracking/destinationPage"
@@ -33,18 +36,25 @@ export default function CityClusterMarker({
onMarkerClick,
cities,
}: ClusterMarkerProps) {
const { hoveredCityMarker, activeCityMarker, setActiveCityMarker } =
useDestinationPageCitiesMapStore()
const {
hoveredCityMarker,
setHoveredCityMarker,
activeCityMarker,
setActiveCityMarker,
} = useDestinationPageCitiesMapStore()
const isDesktop = useMediaQuery("(min-width: 950px)")
const cityIdsAsString = cities.map((city) => city.id).join(",")
const [isHoveredOnMap, setIsHoveredOnMap] = useState(false)
const [infoWindowHovered, setInfoWindowHovered] = useState<boolean>(false)
const isActive =
cities.find(
(city) =>
city.id === hoveredCityMarker || city.id === activeCityMarker?.cityId
) || infoWindowHovered
const { handleMouseEnter, handleMouseLeave } = useMarkerHover((isHovered) => {
if (isHovered) {
if (activeCityMarker?.cityId !== cityIdsAsString) {
setActiveCityMarker(null)
}
setHoveredCityMarker(cityIdsAsString)
} else {
setHoveredCityMarker(null)
}
})
const handleClick = useCallback(() => {
if (onMarkerClick) {
@@ -55,20 +65,7 @@ export default function CityClusterMarker({
)
}, [onMarkerClick, position, cities])
function handleMouseEnter() {
if (activeCityMarker?.cityId !== hoveredCityMarker) {
setActiveCityMarker(null)
}
setIsHoveredOnMap(true)
}
function handleMouseLeave() {
setTimeout(() => {
if (!infoWindowHovered) {
setIsHoveredOnMap(false)
}
}, 100)
}
const isHovered = hoveredCityMarker === cityIdsAsString
return (
<AdvancedMarker
@@ -77,23 +74,22 @@ export default function CityClusterMarker({
onMouseLeave={handleMouseLeave}
zIndex={size}
onClick={handleClick}
className={`${styles.clusterMarker} ${isActive ? styles.hoveredChild : ""}`}
className={cx(styles.clusterMarker, {
[styles.active]: isHovered,
})}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
>
<span className={styles.count}>{sizeAsText}</span>
{isDesktop && (isHoveredOnMap || infoWindowHovered) ? (
<span
onMouseEnter={() => setInfoWindowHovered(true)}
onMouseLeave={() => setInfoWindowHovered(false)}
{isDesktop && isHovered ? (
<InfoWindow
position={position}
headerDisabled={true}
pixelOffsetY={-20}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<InfoWindow
position={position}
pixelOffset={[0, -24]}
headerDisabled={true}
>
<LocationsList locations={cities} />
</InfoWindow>
</span>
<LocationsList locations={cities} />
</InfoWindow>
) : null}
</AdvancedMarker>
)

View File

@@ -11,15 +11,14 @@
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s;
}
.clusterMarker:hover,
.hoveredChild {
background:
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
var(--Surface-Brand-Primary-2-Default);
width: 46px !important;
height: 46px !important;
&.active {
background:
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
var(--Surface-Brand-Primary-2-Default);
width: 46px !important;
height: 46px !important;
}
}
.count {

View File

@@ -3,11 +3,11 @@
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
InfoWindow,
useAdvancedMarkerRef,
} from "@vis.gl/react-google-maps"
import { useMediaQuery } from "usehooks-ts"
import { InfoWindow } from "@scandic-hotels/design-system/Map/InfoWindow"
import { HotelMarkerByType } from "@scandic-hotels/design-system/Map/Markers/HotelMarkerByType"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
@@ -69,12 +69,7 @@ export default function HotelMarker({
/>
{isActive && isDesktop && (
<InfoWindow
position={position}
pixelOffset={[0, -24]}
headerDisabled={true}
minWidth={450}
>
<InfoWindow position={position} headerDisabled={true} minWidth={450}>
<HotelMapCard
amenities={properties.amenities.slice(0, 3)}
tripadvisorRating={properties.tripadvisor}

View File

@@ -7,16 +7,28 @@ export type SelectedMarker = {
interface DestinationPageCitiesMapState {
hoveredCityMarker: string | null
hoveredInfoWindow: string | null
activeCityMarker: SelectedMarker
setHoveredInfoWindow: (hovered: string | null) => void
setHoveredCityMarker: (cityId: string | null) => void
setActiveCityMarker: (marker: SelectedMarker) => void
resetActiveAndHoveredState: () => void
}
export const useDestinationPageCitiesMapStore =
create<DestinationPageCitiesMapState>((set) => ({
hoveredCityMarker: null,
hoveredInfoWindow: null,
activeCityMarker: null,
setHoveredInfoWindow: (hovered) =>
set(() => ({ hoveredInfoWindow: hovered })),
setHoveredCityMarker: (cityId) => set({ hoveredCityMarker: cityId }),
setActiveCityMarker: (selectedMarker) =>
set({ activeCityMarker: selectedMarker }),
resetActiveAndHoveredState: () =>
set({
activeCityMarker: null,
hoveredCityMarker: null,
hoveredInfoWindow: null,
}),
}))

View File

@@ -0,0 +1,49 @@
import { useRef } from "react"
/**
* Custom hook to manage hover state across marker and InfoWindow elements.
* The Google Maps InfoWindow component does not natively support hover events.
* This hook provides a way to track hover state across both elements simultaneously.
*
* This hook solves the problem where moving from a marker to its InfoWindow
* causes the InfoWindow to close prematurely. It uses a counter to track
* how many elements (marker, InfoWindow) are currently being hovered over.
*
* When moving from marker to InfoWindow, the InfoWindow's onMouseEnter fires
* before the marker's onMouseLeave, causing the counter to correctly remain > 0.
*
* @param onHoverChange - Callback function that receives true when hovering starts, false when it ends
* @returns Object with handleMouseEnter and handleMouseLeave functions
*/
export function useMarkerHover(onHoverChange: (isHovering: boolean) => void) {
const hoverCountRef = useRef(0)
function handleMouseEnter() {
hoverCountRef.current += 1
if (hoverCountRef.current >= 1) {
onHoverChange(true)
// In case of rapid mouse movements, or close markers/info windows,
// The counter can increase beyond 2, which is unnecessary. We reset
// it to 1 which works to track hover state correctly.
if (hoverCountRef.current > 2) {
hoverCountRef.current = 1
}
}
}
function handleMouseLeave() {
hoverCountRef.current -= 1
if (hoverCountRef.current <= 0) {
onHoverChange(false)
hoverCountRef.current = 0
}
}
return {
handleMouseEnter,
handleMouseLeave,
}
}

View File

@@ -0,0 +1,51 @@
import {
InfoWindow as GoogleMapsInfoWindow,
type InfoWindowProps as GoogleMapsInfoWindowProps,
} from '@vis.gl/react-google-maps'
import styles from './infoWindow.module.css'
import type { MouseEventHandler } from 'react'
interface InfoWindowProps
extends React.PropsWithChildren<
Omit<GoogleMapsInfoWindowProps, 'pixelOffset'>
> {
pixelOffsetY?: number
onMouseEnter?: MouseEventHandler<HTMLDivElement>
onMouseLeave?: MouseEventHandler<HTMLDivElement>
}
export function InfoWindow({
children,
pixelOffsetY = -12,
onMouseEnter,
onMouseLeave,
...options
}: InfoWindowProps) {
function onMouseEnterHandler(e: React.MouseEvent<HTMLDivElement>) {
if (onMouseEnter) {
onMouseEnter(e)
}
}
function onMouseLeaveHandler(e: React.MouseEvent<HTMLDivElement>) {
if (onMouseLeave) {
onMouseLeave(e)
}
}
return (
<GoogleMapsInfoWindow {...options}>
<div
className={styles.infoWindow}
onMouseEnter={onMouseEnterHandler}
onMouseLeave={onMouseLeaveHandler}
style={{ paddingBottom: pixelOffsetY ? `${pixelOffsetY * -1}px` : 0 }}
>
<div className={styles.content}>{children}</div>
<span className={styles.arrow} />
</div>
</GoogleMapsInfoWindow>
)
}

View File

@@ -0,0 +1,33 @@
.infoWindow {
position: relative;
display: grid;
padding: 4px 4px 12px 4px;
margin-bottom: -4px;
}
.content {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-md);
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
}
.arrow {
position: relative;
height: 12px;
width: 25px;
filter: drop-shadow(0 4px 2px rgba(0, 0, 0, 0.1));
justify-self: center;
&::after {
content: '';
background-color: var(--Base-Surface-Primary-light-Normal);
clip-path: polygon(0 0, 50% 100%, 100% 0);
height: 12px;
left: 0;
position: absolute;
top: -1px;
width: 25px;
}
}

View File

@@ -141,6 +141,7 @@
"./Link": "./lib/components/Link/index.tsx",
"./LoadingSpinner": "./lib/components/LoadingSpinner/index.tsx",
"./LoginButton": "./lib/components/LoginButton/index.tsx",
"./Map/InfoWindow": "./lib/components/Map/InfoWindow/index.tsx",
"./Map/InteractiveMap": "./lib/components/Map/InteractiveMap/index.tsx",
"./Map/mapConstants": "./lib/components/Map/mapConstants.ts",
"./Map/Markers/HotelMarkerByType": "./lib/components/Map/Markers/HotelMarkerByType.tsx",

View File

@@ -128,8 +128,15 @@ export async function getCityPages(
const publishedCities = cities[apiCountry].filter((city) => city.isPublished)
// It happens we receive duplicate cities with the same city identifier.
// Remove duplicate cities based on city identifier
const uniquePublishedCities = publishedCities.filter(
(city, index, self) =>
index === self.findIndex((c) => c.cityIdentifier === city.cityIdentifier)
)
const cityPages = await Promise.all(
publishedCities.map(async (city) => {
uniquePublishedCities.map(async (city) => {
if (!city.cityIdentifier) {
return null
}