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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
119
components/ContentType/DestinationPage/Map/DynamicMap/index.tsx
Normal file
119
components/ContentType/DestinationPage/Map/DynamicMap/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))
|
||||
}
|
||||
15
components/ContentType/DestinationPage/Map/MapProvider.tsx
Normal file
15
components/ContentType/DestinationPage/Map/MapProvider.tsx
Normal 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>
|
||||
}
|
||||
129
components/ContentType/DestinationPage/Map/index.tsx
Normal file
129
components/ContentType/DestinationPage/Map/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
components/ContentType/DestinationPage/Map/map.module.css
Normal file
35
components/ContentType/DestinationPage/Map/map.module.css
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user