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 {
|
.cityMapCard {
|
||||||
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);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
@@ -28,8 +26,13 @@
|
|||||||
gap: var(--Space-x1);
|
gap: var(--Space-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.exploreLink {
|
||||||
justify-content: center;
|
justify-self: center;
|
||||||
|
color: var(--Text-Interactive-Secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--Text-Interactive-Secondary-Hover);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.links {
|
.links {
|
||||||
@@ -37,6 +40,13 @@
|
|||||||
gap: var(--Space-x1);
|
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) {
|
@media screen and (min-width: 950px) {
|
||||||
.content {
|
.content {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
@@ -44,7 +54,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.exploreLink {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { cx } from "class-variance-authority"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
@@ -33,92 +34,75 @@ export default function CityMapCard({
|
|||||||
}: CityMapCardProps) {
|
}: CityMapCardProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const [imageError, setImageError] = useState(false)
|
const [imageError, setImageError] = useState(false)
|
||||||
const { setActiveCityMarker, setHoveredCityMarker } =
|
const { setActiveCityMarker, resetActiveAndHoveredState } =
|
||||||
useDestinationPageCitiesMapStore()
|
useDestinationPageCitiesMapStore()
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
setActiveCityMarker(null)
|
resetActiveAndHoveredState()
|
||||||
setHoveredCityMarker(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cityMapUrl = setMapUrlFromCountryPage(url)
|
const cityMapUrl = setMapUrlFromCountryPage(url)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={className}>
|
<article className={cx(styles.cityMapCard, className)}>
|
||||||
<div className={styles.wrapper}>
|
<IconButton
|
||||||
<IconButton
|
theme="Black"
|
||||||
theme="Black"
|
style="Muted"
|
||||||
style="Muted"
|
className={styles.closeButton}
|
||||||
className={styles.closeButton}
|
onPress={handleClose}
|
||||||
onPress={handleClose}
|
aria-label={intl.formatMessage({
|
||||||
aria-label={intl.formatMessage({
|
defaultMessage: "Close",
|
||||||
defaultMessage: "Close",
|
})}
|
||||||
})}
|
>
|
||||||
>
|
<MaterialIcon
|
||||||
<MaterialIcon
|
icon="close"
|
||||||
icon="close"
|
size={16}
|
||||||
size={16}
|
className={styles.closeIcon}
|
||||||
className={styles.closeIcon}
|
color="CurrentColor"
|
||||||
color="CurrentColor"
|
/>
|
||||||
/>
|
</IconButton>
|
||||||
</IconButton>
|
{image ? (
|
||||||
{image ? (
|
<DialogImage
|
||||||
<DialogImage
|
image={image.src}
|
||||||
image={image.src}
|
altText={image.alt}
|
||||||
altText={image.alt}
|
imageError={imageError}
|
||||||
imageError={imageError}
|
setImageError={setImageError}
|
||||||
setImageError={setImageError}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<ImageFallback />
|
||||||
<ImageFallback />
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.name}>
|
<div className={styles.name}>
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
<h4>{cityName}</h4>
|
<h4>{cityName}</h4>
|
||||||
</Typography>
|
</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>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,6 +48,14 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exploreLink {
|
||||||
|
color: var(--Text-Interactive-Secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--Text-Interactive-Secondary-Hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 950px) {
|
@media (min-width: 950px) {
|
||||||
.content {
|
.content {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
|
|||||||
@@ -104,15 +104,10 @@ export function CityListItem({
|
|||||||
<h3>{cityName}</h3>
|
<h3>{cityName}</h3>
|
||||||
</Typography>
|
</Typography>
|
||||||
<div>
|
<div>
|
||||||
<Link href={url} color="Text/Interactive/Secondary" variant="icon">
|
<Link href={url} className={styles.exploreLink} size="small">
|
||||||
<Typography variant="Link/sm">
|
{intl.formatMessage({
|
||||||
<span>
|
defaultMessage: "Explore city",
|
||||||
{intl.formatMessage({
|
})}
|
||||||
defaultMessage: "Explore city",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
<MaterialIcon icon="open_in_new" size={20} color="CurrentColor" />
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
.content {
|
.content {
|
||||||
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
padding: var(--Space-x15);
|
padding: var(--Space-x15);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Space-x1);
|
gap: var(--Space-x1);
|
||||||
list-style: none;
|
list-style: none;
|
||||||
white-space: nowrap;
|
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 */
|
/* Overriding Google maps infoWindow styles */
|
||||||
.mapWrapper :global(.gm-style .gm-style-iw-c) {
|
.mapWrapper :global(.gm-style .gm-style-iw-c) {
|
||||||
|
background-color: transparent !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapWrapper :global(.gm-style .gm-style-iw-d) {
|
.mapWrapper :global(.gm-style .gm-style-iw-d) {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapWrapper :global(.gm-style .gm-style-iw-tc) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.cityMarker {
|
.cityMarker {
|
||||||
|
display: block;
|
||||||
width: 28px !important;
|
width: 28px !important;
|
||||||
height: 28px !important;
|
height: 28px !important;
|
||||||
background-color: var(--Base-Text-High-contrast);
|
background-color: var(--Base-Text-High-contrast);
|
||||||
@@ -7,13 +8,12 @@
|
|||||||
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
}
|
|
||||||
|
|
||||||
.cityMarker:hover,
|
&.active {
|
||||||
.hoveredChild {
|
background:
|
||||||
background:
|
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
|
||||||
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
|
var(--Surface-Brand-Primary-2-Default);
|
||||||
var(--Surface-Brand-Primary-2-Default);
|
width: 32px !important;
|
||||||
width: var(--Space-x4) !important;
|
height: 32px !important;
|
||||||
height: var(--Space-x4) !important;
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
import {
|
import {
|
||||||
AdvancedMarker,
|
AdvancedMarker,
|
||||||
AdvancedMarkerAnchorPoint,
|
AdvancedMarkerAnchorPoint,
|
||||||
InfoWindow,
|
|
||||||
} from "@vis.gl/react-google-maps"
|
} 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 { 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 { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
|
||||||
|
|
||||||
import { trackMapClick } from "@/utils/tracking/destinationPage"
|
import { trackMapClick } from "@/utils/tracking/destinationPage"
|
||||||
@@ -31,63 +34,55 @@ export default function CityMarker({ position, properties }: CityMarkerProps) {
|
|||||||
setActiveCityMarker,
|
setActiveCityMarker,
|
||||||
} = useDestinationPageCitiesMapStore()
|
} = useDestinationPageCitiesMapStore()
|
||||||
|
|
||||||
const [infoWindowHovered, setInfoWindowHovered] = useState<boolean>(false)
|
|
||||||
|
|
||||||
const isDesktop = useMediaQuery("(min-width: 950px)")
|
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(() => {
|
const handleClick = useCallback(() => {
|
||||||
setActiveCityMarker({ cityId: properties.id, location: position })
|
setActiveCityMarker({ cityId: properties.id, location: position })
|
||||||
trackMapClick(`city with id: ${properties.id}`)
|
trackMapClick(`city with id: ${properties.id}`)
|
||||||
}, [position, properties.id, setActiveCityMarker])
|
}, [position, properties.id, setActiveCityMarker])
|
||||||
|
|
||||||
function handleMouseEnter() {
|
const isHovered = hoveredCityMarker === properties.id
|
||||||
if (activeCityMarker?.cityId !== hoveredCityMarker) {
|
|
||||||
setActiveCityMarker(null)
|
|
||||||
}
|
|
||||||
setHoveredCityMarker(properties.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseLeave() {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!infoWindowHovered) {
|
|
||||||
setHoveredCityMarker(null)
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHovered = hoveredCityMarker === properties.id || infoWindowHovered
|
|
||||||
const isActive = activeCityMarker?.cityId === properties.id
|
const isActive = activeCityMarker?.cityId === properties.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdvancedMarker
|
<AdvancedMarker
|
||||||
position={position}
|
position={position}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
className={`${styles.cityMarker} ${isActive || isHovered ? styles.hoveredChild : ""}`}
|
|
||||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className={cx(styles.cityMarker, {
|
||||||
|
[styles.active]: isActive || isHovered,
|
||||||
|
})}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
/>
|
||||||
{isDesktop && (isActive || isHovered) ? (
|
{isDesktop && (isActive || isHovered) ? (
|
||||||
<span
|
<InfoWindow
|
||||||
onMouseEnter={() => setInfoWindowHovered(true)}
|
position={position}
|
||||||
onMouseLeave={() => setInfoWindowHovered(false)}
|
headerDisabled={true}
|
||||||
|
minWidth={450}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<InfoWindow
|
<CityMapCard
|
||||||
position={position}
|
cityName={properties.name}
|
||||||
pixelOffset={[0, -24]}
|
image={properties.image}
|
||||||
headerDisabled={true}
|
url={properties.url}
|
||||||
minWidth={450}
|
/>
|
||||||
>
|
</InfoWindow>
|
||||||
<CityMapCard
|
) : null}
|
||||||
cityName={properties.name}
|
|
||||||
image={properties.image}
|
|
||||||
url={properties.url}
|
|
||||||
/>
|
|
||||||
</InfoWindow>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
</AdvancedMarker>
|
</AdvancedMarker>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
import {
|
import {
|
||||||
AdvancedMarker,
|
AdvancedMarker,
|
||||||
AdvancedMarkerAnchorPoint,
|
AdvancedMarkerAnchorPoint,
|
||||||
InfoWindow,
|
|
||||||
} from "@vis.gl/react-google-maps"
|
} 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 { 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 { useDestinationPageCitiesMapStore } from "@/stores/destination-page-cities-map"
|
||||||
|
|
||||||
import { trackMapClick } from "@/utils/tracking/destinationPage"
|
import { trackMapClick } from "@/utils/tracking/destinationPage"
|
||||||
@@ -33,18 +36,25 @@ export default function CityClusterMarker({
|
|||||||
onMarkerClick,
|
onMarkerClick,
|
||||||
cities,
|
cities,
|
||||||
}: ClusterMarkerProps) {
|
}: ClusterMarkerProps) {
|
||||||
const { hoveredCityMarker, activeCityMarker, setActiveCityMarker } =
|
const {
|
||||||
useDestinationPageCitiesMapStore()
|
hoveredCityMarker,
|
||||||
|
setHoveredCityMarker,
|
||||||
|
activeCityMarker,
|
||||||
|
setActiveCityMarker,
|
||||||
|
} = useDestinationPageCitiesMapStore()
|
||||||
const isDesktop = useMediaQuery("(min-width: 950px)")
|
const isDesktop = useMediaQuery("(min-width: 950px)")
|
||||||
|
const cityIdsAsString = cities.map((city) => city.id).join(",")
|
||||||
|
|
||||||
const [isHoveredOnMap, setIsHoveredOnMap] = useState(false)
|
const { handleMouseEnter, handleMouseLeave } = useMarkerHover((isHovered) => {
|
||||||
const [infoWindowHovered, setInfoWindowHovered] = useState<boolean>(false)
|
if (isHovered) {
|
||||||
|
if (activeCityMarker?.cityId !== cityIdsAsString) {
|
||||||
const isActive =
|
setActiveCityMarker(null)
|
||||||
cities.find(
|
}
|
||||||
(city) =>
|
setHoveredCityMarker(cityIdsAsString)
|
||||||
city.id === hoveredCityMarker || city.id === activeCityMarker?.cityId
|
} else {
|
||||||
) || infoWindowHovered
|
setHoveredCityMarker(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
if (onMarkerClick) {
|
if (onMarkerClick) {
|
||||||
@@ -55,20 +65,7 @@ export default function CityClusterMarker({
|
|||||||
)
|
)
|
||||||
}, [onMarkerClick, position, cities])
|
}, [onMarkerClick, position, cities])
|
||||||
|
|
||||||
function handleMouseEnter() {
|
const isHovered = hoveredCityMarker === cityIdsAsString
|
||||||
if (activeCityMarker?.cityId !== hoveredCityMarker) {
|
|
||||||
setActiveCityMarker(null)
|
|
||||||
}
|
|
||||||
setIsHoveredOnMap(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseLeave() {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!infoWindowHovered) {
|
|
||||||
setIsHoveredOnMap(false)
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdvancedMarker
|
<AdvancedMarker
|
||||||
@@ -77,23 +74,22 @@ export default function CityClusterMarker({
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
zIndex={size}
|
zIndex={size}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={`${styles.clusterMarker} ${isActive ? styles.hoveredChild : ""}`}
|
className={cx(styles.clusterMarker, {
|
||||||
|
[styles.active]: isHovered,
|
||||||
|
})}
|
||||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||||
>
|
>
|
||||||
<span className={styles.count}>{sizeAsText}</span>
|
<span className={styles.count}>{sizeAsText}</span>
|
||||||
{isDesktop && (isHoveredOnMap || infoWindowHovered) ? (
|
{isDesktop && isHovered ? (
|
||||||
<span
|
<InfoWindow
|
||||||
onMouseEnter={() => setInfoWindowHovered(true)}
|
position={position}
|
||||||
onMouseLeave={() => setInfoWindowHovered(false)}
|
headerDisabled={true}
|
||||||
|
pixelOffsetY={-20}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<InfoWindow
|
<LocationsList locations={cities} />
|
||||||
position={position}
|
</InfoWindow>
|
||||||
pixelOffset={[0, -24]}
|
|
||||||
headerDisabled={true}
|
|
||||||
>
|
|
||||||
<LocationsList locations={cities} />
|
|
||||||
</InfoWindow>
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
</AdvancedMarker>
|
</AdvancedMarker>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,15 +11,14 @@
|
|||||||
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
}
|
|
||||||
|
|
||||||
.clusterMarker:hover,
|
&.active {
|
||||||
.hoveredChild {
|
background:
|
||||||
background:
|
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
|
||||||
linear-gradient(rgba(31, 28, 27, 0.3), rgba(31, 28, 27, 0.3)),
|
var(--Surface-Brand-Primary-2-Default);
|
||||||
var(--Surface-Brand-Primary-2-Default);
|
width: 46px !important;
|
||||||
width: 46px !important;
|
height: 46px !important;
|
||||||
height: 46px !important;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import {
|
import {
|
||||||
AdvancedMarker,
|
AdvancedMarker,
|
||||||
AdvancedMarkerAnchorPoint,
|
AdvancedMarkerAnchorPoint,
|
||||||
InfoWindow,
|
|
||||||
useAdvancedMarkerRef,
|
useAdvancedMarkerRef,
|
||||||
} from "@vis.gl/react-google-maps"
|
} from "@vis.gl/react-google-maps"
|
||||||
import { useMediaQuery } from "usehooks-ts"
|
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 { HotelMarkerByType } from "@scandic-hotels/design-system/Map/Markers/HotelMarkerByType"
|
||||||
|
|
||||||
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
|
||||||
@@ -69,12 +69,7 @@ export default function HotelMarker({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{isActive && isDesktop && (
|
{isActive && isDesktop && (
|
||||||
<InfoWindow
|
<InfoWindow position={position} headerDisabled={true} minWidth={450}>
|
||||||
position={position}
|
|
||||||
pixelOffset={[0, -24]}
|
|
||||||
headerDisabled={true}
|
|
||||||
minWidth={450}
|
|
||||||
>
|
|
||||||
<HotelMapCard
|
<HotelMapCard
|
||||||
amenities={properties.amenities.slice(0, 3)}
|
amenities={properties.amenities.slice(0, 3)}
|
||||||
tripadvisorRating={properties.tripadvisor}
|
tripadvisorRating={properties.tripadvisor}
|
||||||
|
|||||||
@@ -7,16 +7,28 @@ export type SelectedMarker = {
|
|||||||
|
|
||||||
interface DestinationPageCitiesMapState {
|
interface DestinationPageCitiesMapState {
|
||||||
hoveredCityMarker: string | null
|
hoveredCityMarker: string | null
|
||||||
|
hoveredInfoWindow: string | null
|
||||||
activeCityMarker: SelectedMarker
|
activeCityMarker: SelectedMarker
|
||||||
|
setHoveredInfoWindow: (hovered: string | null) => void
|
||||||
setHoveredCityMarker: (cityId: string | null) => void
|
setHoveredCityMarker: (cityId: string | null) => void
|
||||||
setActiveCityMarker: (marker: SelectedMarker) => void
|
setActiveCityMarker: (marker: SelectedMarker) => void
|
||||||
|
resetActiveAndHoveredState: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDestinationPageCitiesMapStore =
|
export const useDestinationPageCitiesMapStore =
|
||||||
create<DestinationPageCitiesMapState>((set) => ({
|
create<DestinationPageCitiesMapState>((set) => ({
|
||||||
hoveredCityMarker: null,
|
hoveredCityMarker: null,
|
||||||
|
hoveredInfoWindow: null,
|
||||||
activeCityMarker: null,
|
activeCityMarker: null,
|
||||||
|
setHoveredInfoWindow: (hovered) =>
|
||||||
|
set(() => ({ hoveredInfoWindow: hovered })),
|
||||||
setHoveredCityMarker: (cityId) => set({ hoveredCityMarker: cityId }),
|
setHoveredCityMarker: (cityId) => set({ hoveredCityMarker: cityId }),
|
||||||
setActiveCityMarker: (selectedMarker) =>
|
setActiveCityMarker: (selectedMarker) =>
|
||||||
set({ activeCityMarker: 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",
|
"./Link": "./lib/components/Link/index.tsx",
|
||||||
"./LoadingSpinner": "./lib/components/LoadingSpinner/index.tsx",
|
"./LoadingSpinner": "./lib/components/LoadingSpinner/index.tsx",
|
||||||
"./LoginButton": "./lib/components/LoginButton/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/InteractiveMap": "./lib/components/Map/InteractiveMap/index.tsx",
|
||||||
"./Map/mapConstants": "./lib/components/Map/mapConstants.ts",
|
"./Map/mapConstants": "./lib/components/Map/mapConstants.ts",
|
||||||
"./Map/Markers/HotelMarkerByType": "./lib/components/Map/Markers/HotelMarkerByType.tsx",
|
"./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)
|
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(
|
const cityPages = await Promise.all(
|
||||||
publishedCities.map(async (city) => {
|
uniquePublishedCities.map(async (city) => {
|
||||||
if (!city.cityIdentifier) {
|
if (!city.cityIdentifier) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user