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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}))
|
||||
|
||||
49
packages/common/hooks/map/useMarkerHover.ts
Normal file
49
packages/common/hooks/map/useMarkerHover.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user