Feat/SW-1937 destination overview map mobile

* feat(SW-1937): Added gestureHandling prop to be able to scroll past the map on the overview page

* feat(SW-1937): Added active map card on overview page for smaller viewports

Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-03-24 07:34:36 +00:00
parent 542e20e69c
commit f93afdbfbf
17 changed files with 197 additions and 59 deletions

View File

@@ -32,10 +32,10 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
const amenities = hotel.detailedFacilities.slice(0, 5) const amenities = hotel.detailedFacilities.slice(0, 5)
const itemRef = useRef<HTMLElement>(null) const itemRef = useRef<HTMLElement>(null)
const { setHoveredHotel, activeHotel } = useDestinationPageHotelsMapStore() const { setHoveredMarker, activeMarker } = useDestinationPageHotelsMapStore()
useEffect(() => { useEffect(() => {
if (activeHotel === hotel.operaId) { if (activeMarker === hotel.operaId) {
const element = itemRef.current const element = itemRef.current
if (element) { if (element) {
element.scrollIntoView({ element.scrollIntoView({
@@ -45,24 +45,24 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
}) })
} }
} }
}, [activeHotel, hotel.operaId]) }, [activeMarker, hotel.operaId])
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
if (hotel.operaId) { if (hotel.operaId) {
setHoveredHotel(hotel.operaId) setHoveredMarker(hotel.operaId)
} }
}, [setHoveredHotel, hotel.operaId]) }, [setHoveredMarker, hotel.operaId])
const handleMouseLeave = useCallback(() => { const handleMouseLeave = useCallback(() => {
setHoveredHotel(null) setHoveredMarker(null)
}, [setHoveredHotel]) }, [setHoveredMarker])
return ( return (
<article <article
ref={itemRef} ref={itemRef}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
className={`${styles.hotelListItem} ${activeHotel === hotel.operaId ? styles.activeCard : ""}`} className={`${styles.hotelListItem} ${activeMarker === hotel.operaId ? styles.activeCard : ""}`}
> >
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
<ImageGallery <ImageGallery

View File

@@ -0,0 +1,9 @@
.activeMapCard {
position: absolute;
bottom: var(--Space-x2);
left: var(--Space-x2);
right: var(--Space-x2);
max-width: 400px;
margin: 0 auto;
z-index: 1;
}

View File

@@ -0,0 +1,42 @@
"use client"
import { useMediaQuery } from "usehooks-ts"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
import HotelMapCard from "../../../HotelMapCard"
import styles from "./activeMapCard.module.css"
import type { DestinationMarker } from "@/types/components/maps/destinationMarkers"
interface ActiveMapCardProps {
markers: DestinationMarker[]
}
export default function ActiveMapCard({ markers }: ActiveMapCardProps) {
const { activeMarker } = useDestinationPageHotelsMapStore()
const activeMapCardVisible = useMediaQuery("(max-width: 949px)")
if (!activeMarker || !activeMapCardVisible) {
return null
}
const activeHotel = markers.find((marker) => marker.id === activeMarker)
if (!activeHotel) {
return null
}
return (
<HotelMapCard
className={styles.activeMapCard}
amenities={activeHotel.amenities}
tripadvisorRating={activeHotel.tripadvisor}
hotelName={activeHotel.name}
image={activeHotel.image}
url={activeHotel.url}
type="article"
/>
)
}

View File

@@ -5,6 +5,7 @@ import DynamicMap from "../../Map/DynamicMap"
import MapContent from "../../Map/MapContent" import MapContent from "../../Map/MapContent"
import MapProvider from "../../Map/MapProvider" import MapProvider from "../../Map/MapProvider"
import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "../../Map/utils" import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "../../Map/utils"
import ActiveMapCard from "./ActiveMapCard"
import InputForm from "./InputForm" import InputForm from "./InputForm"
import type { MapLocation } from "@/types/components/mapLocation" import type { MapLocation } from "@/types/components/mapLocation"
@@ -43,8 +44,10 @@ export default async function OverviewMapContainer({
markers={markers} markers={markers}
defaultCoordinates={defaultCoordinates} defaultCoordinates={defaultCoordinates}
defaultZoom={defaultZoom} defaultZoom={defaultZoom}
gestureHandling="cooperative"
> >
<MapContent geojson={geoJson} /> <MapContent geojson={geoJson} />
<ActiveMapCard markers={markers} />
</DynamicMap> </DynamicMap>
</MapProvider> </MapProvider>
) )

View File

@@ -19,19 +19,19 @@ interface MapCardCarouselProps {
export default function HotelCardCarousel({ export default function HotelCardCarousel({
visibleHotels, visibleHotels,
}: MapCardCarouselProps) { }: MapCardCarouselProps) {
const { activeHotel, setActiveHotel } = useDestinationPageHotelsMapStore() const { activeMarker, setActiveMarker } = useDestinationPageHotelsMapStore()
const selectedHotelIdx = visibleHotels.findIndex( const selectedHotelIdx = visibleHotels.findIndex(
({ hotel }) => hotel.operaId === activeHotel ({ hotel }) => hotel.operaId === activeMarker
) )
const handleScrollSelect = useCallback( const handleScrollSelect = useCallback(
(idx: number) => { (idx: number) => {
if (selectedHotelIdx !== -1) { if (selectedHotelIdx !== -1) {
setActiveHotel(visibleHotels[idx]?.hotel.operaId) setActiveMarker(visibleHotels[idx]?.hotel.operaId)
} }
}, },
[setActiveHotel, visibleHotels, selectedHotelIdx] [setActiveMarker, visibleHotels, selectedHotelIdx]
) )
return ( return (
@@ -47,7 +47,7 @@ export default function HotelCardCarousel({
<Carousel.Item key={hotel.operaId} className={styles.item}> <Carousel.Item key={hotel.operaId} className={styles.item}>
<HotelMapCard <HotelMapCard
className={cx(styles.carouselCard, { className={cx(styles.carouselCard, {
[styles.noActiveHotel]: !activeHotel, [styles.noActiveHotel]: !activeMarker,
})} })}
tripadvisorRating={hotel.ratings?.tripAdvisor.rating} tripadvisorRating={hotel.ratings?.tripAdvisor.rating}
hotelName={hotel.name} hotelName={hotel.name}

View File

@@ -34,7 +34,7 @@ export default function HotelListingItem({
}: HotelListingItemProps) { }: HotelListingItemProps) {
const intl = useIntl() const intl = useIntl()
const params = useParams() const params = useParams()
const { setActiveHotel } = useDestinationPageHotelsMapStore() const { setActiveMarker } = useDestinationPageHotelsMapStore()
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || []) const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const amenities = hotel.detailedFacilities.slice(0, 5) const amenities = hotel.detailedFacilities.slice(0, 5)
const [mapUrl, setMapUrl] = useState<string | null>(null) const [mapUrl, setMapUrl] = useState<string | null>(null)
@@ -108,7 +108,7 @@ export default function HotelListingItem({
<Link <Link
href={mapUrl} href={mapUrl}
scroll={true} scroll={true}
onClick={() => setActiveHotel(hotel.operaId)} onClick={() => setActiveMarker(hotel.operaId)}
> >
{intl.formatMessage({ id: "See on map" })} {intl.formatMessage({ id: "See on map" })}
<ChevronRightSmallIcon /> <ChevronRightSmallIcon />

View File

@@ -9,8 +9,14 @@
} }
.listHeader { .listHeader {
display: grid;
gap: var(--Space-x2);
}
.cta {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: var(--Spacing-x2);
} }
.hotelList { .hotelList {
@@ -26,3 +32,14 @@
); );
} }
} }
@media screen and (min-width: 1367px) {
.listHeader {
display: flex;
justify-content: space-between;
}
.mapButton {
display: none !important; /* Important to override button higher specificy */
}
}

View File

@@ -1,13 +1,17 @@
"use client" "use client"
import { useRef } from "react" import Link from "next/link"
import { useParams } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useDestinationDataStore } from "@/stores/destination-data" import { useDestinationDataStore } from "@/stores/destination-data"
import DestinationFilterAndSort from "@/components/DestinationFilterAndSort" import DestinationFilterAndSort from "@/components/DestinationFilterAndSort"
import { MapIcon } from "@/components/Icons"
import Alert from "@/components/TempDesignSystem/Alert" import Alert from "@/components/TempDesignSystem/Alert"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton" import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import Button from "@/components/TempDesignSystem/Button"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useScrollToTop } from "@/hooks/useScrollToTop" import { useScrollToTop } from "@/hooks/useScrollToTop"
@@ -21,6 +25,8 @@ import { AlertTypeEnum } from "@/types/enums/alert"
export default function HotelListing() { export default function HotelListing() {
const intl = useIntl() const intl = useIntl()
const scrollRef = useRef<HTMLElement>(null) const scrollRef = useRef<HTMLElement>(null)
const params = useParams()
const [mapUrl, setMapUrl] = useState<string | null>(null)
const { showBackToTop, scrollToTop } = useScrollToTop({ const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 300, threshold: 300,
elementRef: scrollRef, elementRef: scrollRef,
@@ -30,6 +36,12 @@ export default function HotelListing() {
isLoading: state.isLoading, isLoading: state.isLoading,
})) }))
useEffect(() => {
const url = new URL(window.location.href)
url.searchParams.set("view", "map")
setMapUrl(url.toString())
}, [params])
return isLoading ? ( return isLoading ? (
<HotelListingSkeleton /> <HotelListingSkeleton />
) : ( ) : (
@@ -43,7 +55,24 @@ export default function HotelListing() {
{ count: activeHotels.length } { count: activeHotels.length }
)} )}
</Subtitle> </Subtitle>
<DestinationFilterAndSort listType="hotel" /> <div className={styles.cta}>
{mapUrl && (
<Button
className={styles.mapButton}
asChild
intent="secondary"
variant="icon"
size="small"
theme="base"
>
<Link href={mapUrl}>
<MapIcon />
{intl.formatMessage({ id: "See on map" })}
</Link>
</Button>
)}
<DestinationFilterAndSort listType="hotel" />
</div>
</div> </div>
{activeHotels.length === 0 ? ( {activeHotels.length === 0 ? (
<Alert <Alert

View File

@@ -44,10 +44,10 @@ export default function HotelMapCard({
const intl = useIntl() const intl = useIntl()
const pageType = usePageType() const pageType = usePageType()
const [imageError, setImageError] = useState(false) const [imageError, setImageError] = useState(false)
const { setActiveHotel } = useDestinationPageHotelsMapStore() const { setActiveMarker } = useDestinationPageHotelsMapStore()
function handleClose() { function handleClose() {
setActiveHotel(null) setActiveMarker(null)
} }
function Wrapper({ children }: React.PropsWithChildren) { function Wrapper({ children }: React.PropsWithChildren) {

View File

@@ -3,14 +3,18 @@
import "client-only" import "client-only"
import { Map, type MapProps, useMap } from "@vis.gl/react-google-maps" import { Map, type MapProps, useMap } from "@vis.gl/react-google-maps"
import { type PropsWithChildren, useEffect } from "react" import { type PropsWithChildren, useEffect, useRef } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
import ErrorBoundary from "@/components/ErrorBoundary/ErrorBoundary" import ErrorBoundary from "@/components/ErrorBoundary/ErrorBoundary"
import { CloseLargeIcon, MinusIcon, PlusIcon } from "@/components/Icons" import { CloseLargeIcon, MinusIcon, PlusIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import { usePageType } from "../PageTypeProvider"
import styles from "./dynamicMap.module.css" import styles from "./dynamicMap.module.css"
import type { DestinationMarker } from "@/types/components/maps/destinationMarkers" import type { DestinationMarker } from "@/types/components/maps/destinationMarkers"
@@ -26,6 +30,7 @@ interface DynamicMapProps {
defaultCoordinates: google.maps.LatLngLiteral | null defaultCoordinates: google.maps.LatLngLiteral | null
defaultZoom: number defaultZoom: number
fitBounds?: boolean fitBounds?: boolean
gestureHandling?: "greedy" | "cooperative" | "auto" | "none"
onClose?: () => void onClose?: () => void
} }
@@ -36,10 +41,23 @@ export default function DynamicMap({
defaultZoom, defaultZoom,
fitBounds = true, fitBounds = true,
onClose, onClose,
gestureHandling = "auto",
children, children,
}: PropsWithChildren<DynamicMapProps>) { }: PropsWithChildren<DynamicMapProps>) {
const intl = useIntl() const intl = useIntl()
const map = useMap() const map = useMap()
const pageType = usePageType()
const { activeMarker } = useDestinationPageHotelsMapStore()
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current && activeMarker && pageType === "overview") {
ref.current.scrollIntoView({
behavior: "smooth",
block: "end",
})
}
}, [activeMarker, pageType])
useEffect(() => { useEffect(() => {
if (map && fitBounds) { if (map && fitBounds) {
@@ -81,12 +99,12 @@ export default function DynamicMap({
defaultZoom, defaultZoom,
disableDefaultUI: true, disableDefaultUI: true,
clickableIcons: false, clickableIcons: false,
gestureHandling: "greedy", gestureHandling,
mapId, mapId,
} }
return ( return (
<div className={styles.mapWrapper}> <div className={styles.mapWrapper} ref={ref}>
<ErrorBoundary fallback={<h2>Unable to display map</h2>}> <ErrorBoundary fallback={<h2>Unable to display map</h2>}>
<Map {...mapOptions}>{children}</Map> <Map {...mapOptions}>{children}</Map>
</ErrorBoundary> </ErrorBoundary>

View File

@@ -27,10 +27,10 @@ export default function ClusterMarker({
onMarkerClick, onMarkerClick,
hotelIds, hotelIds,
}: ClusterMarkerProps) { }: ClusterMarkerProps) {
const { hoveredHotel, activeHotel } = useDestinationPageHotelsMapStore() const { hoveredMarker, activeMarker } = useDestinationPageHotelsMapStore()
const isActive = const isActive =
hotelIds.includes(Number(hoveredHotel)) || hotelIds.includes(Number(hoveredMarker)) ||
hotelIds.includes(Number(activeHotel)) hotelIds.includes(Number(activeMarker))
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (onMarkerClick) { if (onMarkerClick) {

View File

@@ -5,7 +5,6 @@ import {
AdvancedMarkerAnchorPoint, AdvancedMarkerAnchorPoint,
useAdvancedMarkerRef, useAdvancedMarkerRef,
} from "@vis.gl/react-google-maps" } from "@vis.gl/react-google-maps"
import { useCallback } from "react"
import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map" import { useDestinationPageHotelsMapStore } from "@/stores/destination-page-hotels-map"
@@ -24,30 +23,30 @@ interface MarkerProps {
export default function Marker({ position, properties }: MarkerProps) { export default function Marker({ position, properties }: MarkerProps) {
const [markerRef] = useAdvancedMarkerRef() const [markerRef] = useAdvancedMarkerRef()
const { setHoveredHotel, setActiveHotel, hoveredHotel, activeHotel } = const { setHoveredMarker, setActiveMarker, hoveredMarker, activeMarker } =
useDestinationPageHotelsMapStore() useDestinationPageHotelsMapStore()
const handleClick = useCallback(() => { function handleMarkerClick() {
setActiveHotel(properties.id) setActiveMarker(properties.id)
trackMapClick(properties.name) trackMapClick(properties.name)
}, [setActiveHotel, properties]) }
const handleMouseEnter = useCallback(() => { function handleMouseEnter() {
setHoveredHotel(properties.id) setHoveredMarker(properties.id)
}, [setHoveredHotel, properties.id]) }
const handleMouseLeave = useCallback(() => { function handleMouseLeave() {
setHoveredHotel(null) setHoveredMarker(null)
}, [setHoveredHotel]) }
const isHovered = hoveredHotel === properties.id const isHovered = hoveredMarker === properties.id
const isActive = activeHotel === properties.id const isActive = activeMarker === properties.id
return ( return (
<AdvancedMarker <AdvancedMarker
ref={markerRef} ref={markerRef}
position={position} position={position}
onClick={handleClick} onClick={handleMarkerClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
anchorPoint={AdvancedMarkerAnchorPoint.BOTTOM_CENTER} anchorPoint={AdvancedMarkerAnchorPoint.BOTTOM_CENTER}

View File

@@ -34,30 +34,33 @@ export default function MapContent({
geojson, geojson,
hasActiveFilters, hasActiveFilters,
}: MapContentProps) { }: MapContentProps) {
const { setActiveHotel, activeHotel } = useDestinationPageHotelsMapStore() const { setActiveMarker, activeMarker } = useDestinationPageHotelsMapStore()
const map = useMap() const map = useMap()
const { clusters, containedHotels } = useSupercluster<MarkerProperties>( const { clusters, containedHotels, getClusterZoom } =
geojson, useSupercluster<MarkerProperties>(geojson, CLUSTER_OPTIONS)
CLUSTER_OPTIONS
)
// Based on the length of active filters, we decide if should show clusters or individual markers // Based on the length of active filters, we decide if should show clusters or individual markers
const markerList = hasActiveFilters ? geojson.features : clusters const markerList = hasActiveFilters ? geojson.features : clusters
useEffect(() => { useEffect(() => {
map?.addListener("click", () => { map?.addListener("click", () => {
if (activeHotel) { if (activeMarker) {
setActiveHotel(null) setActiveMarker(null)
} }
}) })
}, [activeHotel, map, setActiveHotel]) }, [activeMarker, map, setActiveMarker])
function handleClusterClick(position: google.maps.LatLngLiteral) { function handleClusterClick(
position: google.maps.LatLngLiteral,
clusterProperties: ClusterProperties
) {
const currentZoom = map && map.getZoom() const currentZoom = map && map.getZoom()
const clusterZoom = getClusterZoom(clusterProperties.cluster_id)
if (currentZoom) { if (currentZoom) {
map.panTo(position) map.panTo(position)
map.setZoom(currentZoom + 2) map.setZoom(clusterZoom ?? currentZoom + 2)
} }
} }
@@ -73,7 +76,9 @@ export default function MapContent({
position={{ lat, lng }} position={{ lat, lng }}
size={clusterProperties.point_count} size={clusterProperties.point_count}
sizeAsText={String(clusterProperties.point_count_abbreviated)} sizeAsText={String(clusterProperties.point_count_abbreviated)}
onMarkerClick={handleClusterClick} onMarkerClick={(position) =>
handleClusterClick(position, clusterProperties)
}
hotelIds={containedHotels[idx]} hotelIds={containedHotels[idx]}
/> />
) : ( ) : (

View File

@@ -48,7 +48,7 @@ export default function Map({
}: PropsWithChildren<MapProps>) { }: PropsWithChildren<MapProps>) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { activeHotel: activeHotelId } = useDestinationPageHotelsMapStore() const { activeMarker: activeHotelId } = useDestinationPageHotelsMapStore()
const isMapView = useMemo( const isMapView = useMemo(
() => searchParams.get("view") === "map", () => searchParams.get("view") === "map",
[searchParams] [searchParams]
@@ -169,6 +169,7 @@ export default function Map({
defaultCoordinates={defaultCoordinates} defaultCoordinates={defaultCoordinates}
defaultZoom={defaultZoom} defaultZoom={defaultZoom}
fitBounds={!activeHotel} fitBounds={!activeHotel}
gestureHandling="greedy"
> >
<MapContent <MapContent
geojson={geoJson} geojson={geoJson}

View File

@@ -17,3 +17,9 @@
right: var(--Spacing-x2); right: var(--Spacing-x2);
bottom: var(--Spacing-x2); bottom: var(--Spacing-x2);
} }
@media screen and (max-width: 1366px) {
.mapWrapper {
display: none;
}
}

View File

@@ -36,6 +36,14 @@ export function useSupercluster<T extends GeoJsonProperties>(
return clusterer.getClusters(bbox, zoom) return clusterer.getClusters(bbox, zoom)
}, [version, clusterer, bbox, zoom]) }, [version, clusterer, bbox, zoom])
function getClusterZoom(clusterId: number) {
if (!clusterer || version === 0) {
return null
}
return clusterer.getClusterExpansionZoom(clusterId)
}
// retrieve the hotel ids included in the cluster // retrieve the hotel ids included in the cluster
const containedHotels = clusters.map((cluster) => { const containedHotels = clusters.map((cluster) => {
if (cluster.properties?.cluster && typeof cluster.id === "number") { if (cluster.properties?.cluster && typeof cluster.id === "number") {
@@ -47,5 +55,6 @@ export function useSupercluster<T extends GeoJsonProperties>(
return { return {
clusters, clusters,
containedHotels, containedHotels,
getClusterZoom,
} }
} }

View File

@@ -1,16 +1,16 @@
import { create } from "zustand" import { create } from "zustand"
interface DestinationPageHotelsMapState { interface DestinationPageHotelsMapState {
hoveredHotel: string | null hoveredMarker: string | null
activeHotel: string | null activeMarker: string | null
setHoveredHotel: (hotelId: string | null) => void setHoveredMarker: (hotelId: string | null) => void
setActiveHotel: (hotelId: string | null) => void setActiveMarker: (hotelId: string | null) => void
} }
export const useDestinationPageHotelsMapStore = export const useDestinationPageHotelsMapStore =
create<DestinationPageHotelsMapState>((set) => ({ create<DestinationPageHotelsMapState>((set) => ({
hoveredHotel: null, hoveredMarker: null,
activeHotel: null, activeMarker: null,
setHoveredHotel: (hotelId) => set({ hoveredHotel: hotelId }), setHoveredMarker: (hotelId) => set({ hoveredMarker: hotelId }),
setActiveHotel: (hotelId) => set({ activeHotel: hotelId }), setActiveMarker: (hotelId) => set({ activeMarker: hotelId }),
})) }))