diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CityMapCard/cityMapCard.module.css b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CityMapCard/cityMapCard.module.css index e98c09dd7..a7bc3299c 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CityMapCard/cityMapCard.module.css +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CityMapCard/cityMapCard.module.css @@ -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; } } diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CityMapCard/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CityMapCard/index.tsx index 19b3ce3ad..e05216b67 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CityMapCard/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CityMapCard/index.tsx @@ -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 ( -
-
- - - - {image ? ( - - ) : ( - - )} +
+ + + + {image ? ( + + ) : ( + + )} -
-
- -

{cityName}

-
-
- {url && cityMapUrl && ( - - setActiveCityMarker(null)} - > - -

- {intl.formatMessage({ - defaultMessage: "See hotels on map", - })} -

-
-
- - - - - {intl.formatMessage({ - defaultMessage: "Explore city", - })} - - - - -
- )} +
+
+ +

{cityName}

+
+ {url && cityMapUrl && ( + + setActiveCityMarker(null)} + > + +

+ {intl.formatMessage({ + defaultMessage: "See hotels on map", + })} +

+
+
+ + + {intl.formatMessage({ + defaultMessage: "Explore city", + })} + +
+ )}
) diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CountryMap/CityListItem/cityListItem.module.css b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CountryMap/CityListItem/cityListItem.module.css index 02573b9ca..f7539a0ad 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CountryMap/CityListItem/cityListItem.module.css +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CountryMap/CityListItem/cityListItem.module.css @@ -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; diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CountryMap/CityListItem/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CountryMap/CityListItem/index.tsx index b1d774750..f6271865c 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CountryMap/CityListItem/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/CountryMap/CityListItem/index.tsx @@ -104,15 +104,10 @@ export function CityListItem({

{cityName}

- - - - {intl.formatMessage({ - defaultMessage: "Explore city", - })} - - - + + {intl.formatMessage({ + defaultMessage: "Explore city", + })}
diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/LocationsList/locationsList.module.css b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/LocationsList/locationsList.module.css index fa5af6d48..8d3ef26db 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/LocationsList/locationsList.module.css +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationCountryPage/LocationsList/locationsList.module.css @@ -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); + } +} diff --git a/apps/scandic-web/components/ContentType/DestinationPage/Map/DynamicMap/dynamicMap.module.css b/apps/scandic-web/components/ContentType/DestinationPage/Map/DynamicMap/dynamicMap.module.css index ddcf93f2b..933befaf0 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/Map/DynamicMap/dynamicMap.module.css +++ b/apps/scandic-web/components/ContentType/DestinationPage/Map/DynamicMap/dynamicMap.module.css @@ -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; + } } diff --git a/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/CityMarker/cityMarker.module.css b/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/CityMarker/cityMarker.module.css index be3b5154a..d40866a57 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/CityMarker/cityMarker.module.css +++ b/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/CityMarker/cityMarker.module.css @@ -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; + } } diff --git a/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/CityMarker/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/CityMarker/index.tsx index 71c8ba546..c69c45b8b 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/CityMarker/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/CityMarker/index.tsx @@ -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(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 ( + {isDesktop && (isActive || isHovered) ? ( - setInfoWindowHovered(true)} - onMouseLeave={() => setInfoWindowHovered(false)} + - - - - - ) : ( - - )} + + + ) : null} ) } diff --git a/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/CityClusterMarker.tsx b/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/CityClusterMarker.tsx index 51caff5c7..7668823da 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/CityClusterMarker.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/CityClusterMarker.tsx @@ -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(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 ( {sizeAsText} - {isDesktop && (isHoveredOnMap || infoWindowHovered) ? ( - setInfoWindowHovered(true)} - onMouseLeave={() => setInfoWindowHovered(false)} + {isDesktop && isHovered ? ( + - - - - + + ) : null} ) diff --git a/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/clusterMarker.module.css b/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/clusterMarker.module.css index 474072fc5..907cee462 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/clusterMarker.module.css +++ b/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/ClusterMarker/clusterMarker.module.css @@ -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 { diff --git a/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/HotelMarker/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/HotelMarker/index.tsx index 97d0a3f09..e9843c313 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/HotelMarker/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/Map/MapContent/HotelMarker/index.tsx @@ -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 && ( - + void setHoveredCityMarker: (cityId: string | null) => void setActiveCityMarker: (marker: SelectedMarker) => void + resetActiveAndHoveredState: () => void } export const useDestinationPageCitiesMapStore = create((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, + }), })) diff --git a/packages/common/hooks/map/useMarkerHover.ts b/packages/common/hooks/map/useMarkerHover.ts new file mode 100644 index 000000000..2bf2478ce --- /dev/null +++ b/packages/common/hooks/map/useMarkerHover.ts @@ -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, + } +} diff --git a/packages/design-system/lib/components/Map/InfoWindow/index.tsx b/packages/design-system/lib/components/Map/InfoWindow/index.tsx new file mode 100644 index 000000000..6b8108770 --- /dev/null +++ b/packages/design-system/lib/components/Map/InfoWindow/index.tsx @@ -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 + > { + pixelOffsetY?: number + onMouseEnter?: MouseEventHandler + onMouseLeave?: MouseEventHandler +} + +export function InfoWindow({ + children, + pixelOffsetY = -12, + onMouseEnter, + onMouseLeave, + ...options +}: InfoWindowProps) { + function onMouseEnterHandler(e: React.MouseEvent) { + if (onMouseEnter) { + onMouseEnter(e) + } + } + + function onMouseLeaveHandler(e: React.MouseEvent) { + if (onMouseLeave) { + onMouseLeave(e) + } + } + + return ( + +
+
{children}
+ +
+
+ ) +} diff --git a/packages/design-system/lib/components/Map/InfoWindow/infoWindow.module.css b/packages/design-system/lib/components/Map/InfoWindow/infoWindow.module.css new file mode 100644 index 000000000..52ce0cea3 --- /dev/null +++ b/packages/design-system/lib/components/Map/InfoWindow/infoWindow.module.css @@ -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; + } +} diff --git a/packages/design-system/package.json b/packages/design-system/package.json index ef9a2b9cf..a8e4eb662 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -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", diff --git a/packages/trpc/lib/routers/contentstack/destinationCountryPage/utils.ts b/packages/trpc/lib/routers/contentstack/destinationCountryPage/utils.ts index af3f15dda..055ebb162 100644 --- a/packages/trpc/lib/routers/contentstack/destinationCountryPage/utils.ts +++ b/packages/trpc/lib/routers/contentstack/destinationCountryPage/utils.ts @@ -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 }