Merged in feat/SW-1456-country-dynamic-map (pull request #1310)

feat(SW-1456): Added map and fetching hotels by country

* feat(SW-1456): Added map and fetching hotels by country


Approved-by: Fredrik Thorsson
Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-02-12 09:15:33 +00:00
parent 3901c3ac77
commit bcfa84324f
33 changed files with 2211 additions and 187 deletions

View File

@@ -0,0 +1,32 @@
import { env } from "@/env/server"
import { getHotelsByCountry } from "@/lib/trpc/memoizedRequests"
import Title from "@/components/TempDesignSystem/Text/Title"
import Map from "../../Map"
import type { Country } from "@/types/enums/country"
interface CountryMapProps {
country: Country
}
export function preloadHotels(country: Country) {
void getHotelsByCountry(country)
}
export default async function CountryMap({ country }: CountryMapProps) {
const hotels = await getHotelsByCountry(country)
return (
<Map
hotels={hotels}
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
apiKey={env.GOOGLE_STATIC_MAP_KEY}
>
<div>
<Title level="h2" as="h3">
{country}
</Title>
</div>
</Map>
)
}

View File

@@ -0,0 +1,23 @@
.countryMap {
--destination-map-height: 100dvh;
position: absolute;
top: 0;
left: 0;
height: var(--destination-map-height);
width: 100dvw;
z-index: var(--hotel-dynamic-map-z-index);
display: flex;
background-color: var(--Base-Surface-Primary-light-Normal);
}
.wrapper {
position: absolute;
top: 0;
left: 0;
}
.closeButton {
pointer-events: initial;
box-shadow: var(--button-box-shadow);
gap: var(--Spacing-x-half);
}

View File

@@ -16,6 +16,7 @@ import SidebarContentWrapper from "../SidebarContentWrapper"
import DestinationPageSidePeek from "../Sidepeek"
import StaticMap from "../StaticMap"
import TopImages from "../TopImages"
import CountryMap, { preloadHotels } from "./CountryMap"
import styles from "./destinationCountryPage.module.css"
@@ -41,6 +42,8 @@ export default async function DestinationCountryPage() {
destination_settings,
} = destinationCountryPage
preloadHotels(destination_settings.country)
return (
<>
<div className={styles.pageContainer}>
@@ -72,6 +75,7 @@ export default async function DestinationCountryPage() {
</SidebarContentWrapper>
</aside>
</div>
<CountryMap country={destination_settings.country} />
<Suspense fallback={null}>
<TrackingSDK pageData={tracking} />
</Suspense>

View File

@@ -0,0 +1,66 @@
.mapWrapper {
--button-box-shadow: 0 0 8px 1px rgba(0, 0, 0, 0.1);
width: 100%;
height: 100%;
position: relative;
z-index: 0;
}
.mapWrapper::after {
content: "";
position: absolute;
top: 0;
right: 0;
background: linear-gradient(
43deg,
rgba(172, 172, 172, 0) 57.66%,
rgba(0, 0, 0, 0.25) 92.45%
);
width: 100%;
height: 100%;
pointer-events: none;
}
.ctaButtons {
position: absolute;
top: var(--Spacing-x2);
right: var(--Spacing-x2);
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--Spacing-x7);
align-items: flex-end;
pointer-events: none;
}
.zoomButtons {
display: grid;
gap: var(--Spacing-x1);
}
.closeButton {
pointer-events: initial;
box-shadow: var(--button-box-shadow);
gap: var(--Spacing-x-half);
}
.zoomButton {
width: var(--Spacing-x5);
height: var(--Spacing-x5);
padding: 0;
pointer-events: initial;
box-shadow: var(--button-box-shadow);
}
@media screen and (min-width: 768px) {
.ctaButtons {
top: var(--Spacing-x4);
right: var(--Spacing-x4);
bottom: var(--Spacing-x4);
justify-content: space-between;
}
.zoomButtons {
display: flex;
}
}

View File

@@ -0,0 +1,119 @@
"use client"
import "client-only"
import { Map, type MapProps, useMap } from "@vis.gl/react-google-maps"
import { type PropsWithChildren, useEffect } from "react"
import { useIntl } from "react-intl"
import { CloseLargeIcon, MinusIcon, PlusIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import styles from "./dynamicMap.module.css"
import type { DestinationMarker } from "@/types/components/maps/destinationMarkers"
interface DynamicMapProps {
markers: DestinationMarker[]
mapId: string
onTilesLoaded?: () => void
onClose?: () => void
}
export default function DynamicMap({
markers,
mapId,
onTilesLoaded,
onClose,
children,
}: PropsWithChildren<DynamicMapProps>) {
const intl = useIntl()
const map = useMap()
useEffect(() => {
if (map) {
const bounds = new google.maps.LatLngBounds()
markers.forEach((marker) => {
bounds.extend(marker.coordinates)
})
map.fitBounds(bounds)
}
}, [map, markers])
useHandleKeyUp((event: KeyboardEvent) => {
if (event.key === "Escape" && onClose) {
onClose()
}
})
function zoomIn() {
const currentZoom = map && map.getZoom()
console.log(currentZoom)
if (currentZoom) {
map.setZoom(currentZoom + 1)
}
}
function zoomOut() {
const currentZoom = map && map.getZoom()
console.log(currentZoom)
if (currentZoom) {
map.setZoom(currentZoom - 1)
}
}
const mapOptions: MapProps = {
defaultCenter: markers[0].coordinates, // Default center will be overridden by the bounds
minZoom: 3,
defaultZoom: 8,
disableDefaultUI: true,
clickableIcons: false,
gestureHandling: "greedy",
mapId,
}
return (
<div className={styles.mapWrapper}>
<Map {...mapOptions} onTilesLoaded={onTilesLoaded}>
{children}
</Map>
<div className={styles.ctaButtons}>
<Button
theme="base"
intent="inverted"
variant="icon"
size="small"
className={styles.closeButton}
onClick={onClose}
>
<CloseLargeIcon color="burgundy" />
<span>{intl.formatMessage({ id: "Close the map" })}</span>
</Button>
<div className={styles.zoomButtons}>
<Button
theme="base"
intent="inverted"
variant="icon"
size="small"
className={styles.zoomButton}
onClick={zoomOut}
aria-label={intl.formatMessage({ id: "Zoom in" })}
>
<MinusIcon color="burgundy" width={20} height={20} />
</Button>
<Button
theme="base"
intent="inverted"
variant="icon"
size="small"
className={styles.zoomButton}
onClick={zoomIn}
aria-label={intl.formatMessage({ id: "Zoom out" })}
>
<PlusIcon color="burgundy" width={20} height={20} />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
"use client"
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
} from "@vis.gl/react-google-maps"
import HotelMarkerByType from "@/components/Maps/Markers"
import styles from "./mapContent.module.css"
import type { DestinationMarker } from "@/types/components/maps/destinationMarkers"
interface MapContentProps {
markers: DestinationMarker[]
}
export default function MapContent({ markers }: MapContentProps) {
return markers.map((item) => (
<AdvancedMarker
key={item.name}
className={styles.advancedMarker}
position={item.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.BOTTOM_CENTER}
>
<HotelMarkerByType hotelId={item.id} hotelType={item.type} />
</AdvancedMarker>
))
}

View File

@@ -0,0 +1,15 @@
"use client"
import { APIProvider } from "@vis.gl/react-google-maps"
import type { PropsWithChildren } from "react"
interface MapContainerProps {
apiKey: string
}
export default function MapProvider({
apiKey,
children,
}: PropsWithChildren<MapContainerProps>) {
return <APIProvider apiKey={apiKey}>{children}</APIProvider>
}

View File

@@ -0,0 +1,129 @@
"use client"
import { useSearchParams } from "next/navigation"
import {
type PropsWithChildren,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { Dialog, Modal } from "react-aria-components"
import { debounce } from "@/utils/debounce"
import DynamicMap from "./DynamicMap"
import MapContent from "./MapContent"
import MapProvider from "./MapProvider"
import styles from "./map.module.css"
import type { DestinationMarker } from "@/types/components/maps/destinationMarkers"
import type { HotelDataWithUrl } from "@/types/hotel"
interface MapProps {
hotels: HotelDataWithUrl[]
mapId: string
apiKey: string
}
export default function Map({
hotels,
mapId,
apiKey,
children,
}: PropsWithChildren<MapProps>) {
const searchParams = useSearchParams()
const isMapView = useMemo(
() => searchParams.get("view") === "map",
[searchParams]
)
const rootDiv = useRef<HTMLDivElement | null>(null)
const [mapHeight, setMapHeight] = useState("0px")
const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0)
const markers = hotels
.map(({ hotel }) => ({
id: hotel.id,
type: hotel.hotelType || "regular",
name: hotel.name,
coordinates: hotel.location
? {
lat: hotel.location.latitude,
lng: hotel.location.longitude,
}
: null,
}))
.filter((item): item is DestinationMarker => !!item.coordinates)
// Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget)
const handleMapHeight = useCallback(() => {
const topPosition = rootDiv.current?.getBoundingClientRect().top ?? 0
const scrollY = window.scrollY
setMapHeight(`calc(100dvh - ${topPosition + scrollY}px)`)
}, [])
// Making sure the map is always opened at the top of the page,
// just below the header and booking widget as these should stay visible.
// When closing, the page should scroll back to the position it was before opening the map.
useEffect(() => {
// Skip the first render
if (!rootDiv.current) {
return
}
if (isMapView && scrollHeightWhenOpened === 0) {
const scrollY = window.scrollY
setScrollHeightWhenOpened(scrollY)
window.scrollTo({ top: 0, behavior: "instant" })
} else if (!isMapView && scrollHeightWhenOpened !== 0) {
window.scrollTo({ top: scrollHeightWhenOpened, behavior: "instant" })
setScrollHeightWhenOpened(0)
}
}, [isMapView, scrollHeightWhenOpened, rootDiv])
useEffect(() => {
const debouncedResizeHandler = debounce(function () {
handleMapHeight()
})
const observer = new ResizeObserver(debouncedResizeHandler)
observer.observe(document.documentElement)
return () => {
if (observer) {
observer.unobserve(document.documentElement)
}
}
}, [rootDiv, isMapView, handleMapHeight])
function handleClose() {
window.history.pushState({}, "", window.location.pathname)
}
return (
<MapProvider apiKey={apiKey}>
<div className={styles.wrapper} ref={rootDiv}>
<Modal
isOpen={isMapView}
UNSTABLE_portalContainer={rootDiv.current || undefined}
>
<Dialog
className={styles.dialog}
style={
{ "--destination-map-height": mapHeight } as React.CSSProperties
}
aria-label={"Mapview"}
>
<aside className={styles.sidebar}>{children}</aside>
<DynamicMap markers={markers} mapId={mapId} onClose={handleClose}>
<MapContent markers={markers} />
</DynamicMap>
</Dialog>
</Modal>
</div>
</MapProvider>
)
}

View File

@@ -0,0 +1,35 @@
.dialog {
--destination-map-height: 100dvh;
position: absolute;
top: 0;
left: 0;
height: var(--destination-map-height);
width: 100dvw;
z-index: var(--hotel-dynamic-map-z-index);
display: flex;
background-color: var(--Base-Surface-Primary-light-Normal);
}
.sidebar {
width: 100%;
max-width: 400px;
background-color: var(--Base-Surface-Primary-Normal);
overflow-y: auto;
padding: var(--Spacing-x4);
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
}
.wrapper {
position: absolute;
top: 0;
left: 0;
}
.closeButton {
pointer-events: initial;
box-shadow: var(--button-box-shadow);
gap: var(--Spacing-x-half);
}

View File

@@ -1,3 +1,5 @@
import Link from "next/link"
import { MapIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap"
import Button from "@/components/TempDesignSystem/Button"
@@ -36,10 +38,12 @@ export default async function DestinationStaticMap({
size="small"
theme="base"
className={styles.button}
asChild
>
{/* TODO: Decide on how the map should load */}
<MapIcon />
{intl.formatMessage({ id: "See on map" })}
<Link href="?view=map">
<MapIcon />
{intl.formatMessage({ id: "See on map" })}
</Link>
</Button>
</div>
)