Merged in feat/SW-1447-selected-hotel-map-component (pull request #1340)

Feat(SW-1447): hotel map card destination page

Approved-by: Erik Tiekstra
This commit is contained in:
Matilda Landström
2025-02-18 19:05:31 +00:00
parent cd8b2e4b73
commit ba3ecedaee
21 changed files with 411 additions and 18 deletions

View File

@@ -25,6 +25,7 @@ export default async function CityMap({ city, cityIdentifier }: CityMapProps) {
hotels={hotels}
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
apiKey={env.GOOGLE_STATIC_MAP_KEY}
pageType="city"
>
<Title level="h2" as="h3" textTransform="regular">
{intl.formatMessage({ id: `Hotels in {city}` }, { city: city.name })}

View File

@@ -32,6 +32,7 @@ export default async function CountryMap({ country }: CountryMapProps) {
hotels={hotels}
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
apiKey={env.GOOGLE_STATIC_MAP_KEY}
pageType="country"
>
<Title level="h2" as="h3" textTransform="regular">
{intl.formatMessage({ id: `Destinations in {country}` }, { country })}

View File

@@ -21,7 +21,7 @@ export default async function OverviewMapContainer() {
const geoJson = mapMarkerDataToGeoJson(markers)
return (
<MapProvider apiKey={googleMapsApiKey}>
<MapProvider apiKey={googleMapsApiKey} pageType="overview">
<InputForm />
<DynamicMap mapId={googleMapId} markers={markers}>
<MapContent geojson={geoJson} />

View File

@@ -0,0 +1,33 @@
.imagePlaceholder {
height: 100%;
width: 100%;
background-color: #fff;
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);
background-size: 120px 120px;
background-position:
0 0,
0 60px,
60px -60px,
-60px 0;
}
.imageContainer {
position: relative;
min-width: 177px;
border-radius: var(--Corner-radius-Medium) 0 0 var(--Corner-radius-Medium);
overflow: hidden;
}
.imageContainer img {
object-fit: cover;
}
.imageContainer .tripAdvisor {
position: absolute;
left: 7px;
top: 7px;
border-radius: var(--Corner-radius-Small);
}

View File

@@ -0,0 +1,41 @@
import { TripAdvisorIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Chip from "@/components/TempDesignSystem/Chip"
import styles from "./dialogImage.module.css"
interface DialogImageProps {
image: string | undefined
altText: string | undefined
ratings: number
imageError: boolean
setImageError: (error: boolean) => void
}
export default function DialogImage({
image,
altText,
ratings,
imageError,
setImageError,
}: DialogImageProps) {
return (
<div className={styles.imageContainer}>
{!image || imageError ? (
<div className={styles.imagePlaceholder} />
) : (
<Image
src={image}
alt={altText || ""}
fill
onError={() => setImageError(true)}
/>
)}
<div className={styles.tripAdvisor}>
<Chip className={styles.tripAdvisor}>
<TripAdvisorIcon color="burgundy" />
{ratings}
</Chip>
</div>
</div>
)
}

View File

@@ -0,0 +1,90 @@
.dialog {
bottom: 0;
left: 50%;
transform: translateX(-50%);
border: none;
background: transparent;
}
.dialogContent {
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
min-width: 402px;
background: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
flex-direction: row;
display: flex;
position: relative;
}
.dialogContent::after {
content: "";
display: block;
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 0;
border: 20px solid transparent;
border-top-color: var(--Base-Surface-Primary-light-Normal);
border-bottom: 0;
margin-left: -10px;
margin-bottom: -10px;
}
.name {
height: 48px;
max-width: 180px;
margin-bottom: var(--Spacing-x-half);
display: flex;
align-items: center;
}
.closeButton {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
}
.closeButton:hover .closeIcon {
background-color: var(--Base-Surface-Subtle-Normal);
color: var(--Primary-Dark-Surface-Hover);
border-radius: 50%;
}
.content {
width: 100%;
min-width: 220px;
padding: var(--Spacing-x-one-and-half);
display: flex;
flex-direction: column;
}
.hiddenFacilities,
.iconFootnote {
display: none;
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
}
.content .button {
margin-top: auto;
}
.facilities {
display: flex;
flex-wrap: wrap;
gap: 0 var(--Spacing-x1);
padding-bottom: var(--Spacing-x1);
}
@media (min-width: 768px) {
.iconFootnote {
display: block;
}
}

View File

@@ -0,0 +1,110 @@
"use client"
import Link from "next/link"
import { useState } from "react"
import { useIntl } from "react-intl"
import CloseLargeIcon from "@/components/Icons/CloseLarge"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import { mapFacilityToIcon } from "../../HotelPage/data"
import { usePageType } from "../Map/PageTypeProvider"
import DialogImage from "./DialogImage"
import styles from "./hotelMapCard.module.css"
import type { GalleryImage } from "@/types/components/imageGallery"
import type { Amenities } from "@/types/hotel"
interface HotelMapCardProps {
isActive: boolean
amenities: Amenities
tripadvisorRating: number | undefined
hotelName: string
image: GalleryImage | null
url: string
onClose: () => void
}
export default function HotelMapCard({
isActive,
amenities,
tripadvisorRating,
hotelName,
image,
url,
onClose,
}: HotelMapCardProps) {
const intl = useIntl()
const pageType = usePageType()
const [imageError, setImageError] = useState(false)
return (
<dialog open={isActive} className={styles.dialog}>
<div className={styles.dialogContent}>
<Button
intent="text"
size="medium"
variant="icon"
className={styles.closeButton}
onClick={onClose}
aria-label={intl.formatMessage({ id: "Close" })}
>
<CloseLargeIcon className={styles.closeIcon} width={16} height={16} />
</Button>
{image ? (
<DialogImage
image={image.src}
altText={image.alt}
ratings={tripadvisorRating || 0}
imageError={imageError}
setImageError={setImageError}
/>
) : (
<div className={styles.imagePlaceholder} />
)}
<div className={styles.content}>
<div className={styles.name}>
<Body asChild textTransform="bold">
<h4>{hotelName}</h4>
</Body>
</div>
<div
className={
pageType === "country"
? styles.hiddenFacilities
: styles.facilities
}
>
{amenities.map((facility) => {
const IconComponent = mapFacilityToIcon(facility.id)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{IconComponent && (
<IconComponent width={16} height={16} color="grey80" />
)}
<Footnote
className={styles.iconFootnote}
color="uiTextMediumContrast"
>
{facility.name}
</Footnote>
</div>
)
})}
</div>
{url && (
<Button intent="tertiary" theme="base" asChild size="small">
<Link href={url}>
{intl.formatMessage({ id: "See hotel information" })}
</Link>
</Button>
)}
</div>
</div>
</dialog>
)
}

View File

@@ -13,6 +13,14 @@
transition: all 0.3s;
}
.clusterMarker:hover {
background: linear-gradient(
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.2)
),
var(--Base-Text-High-contrast);
}
.count {
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Subtitle-2-fontSize);

View File

@@ -5,41 +5,69 @@ import {
AdvancedMarkerAnchorPoint,
useAdvancedMarkerRef,
} from "@vis.gl/react-google-maps"
import { useCallback } from "react"
import { useCallback, useState } from "react"
import HotelMarkerByType from "@/components/Maps/Markers"
import HotelMapCard from "../../../HotelMapCard"
import type { MarkerProperties } from "@/types/components/maps/destinationMarkers"
interface MarkerProps {
position: google.maps.LatLngLiteral
properties: MarkerProperties
onMarkerClick?: (
position: google.maps.LatLngLiteral,
properties: MarkerProperties
) => void
isActive: boolean
onMarkerClick?: (properties: MarkerProperties) => void
onCloseMapCard?: () => void
}
export default function Marker({
position,
properties,
isActive,
onMarkerClick,
onCloseMapCard,
}: MarkerProps) {
const [markerRef] = useAdvancedMarkerRef()
const [isHovered, setIsHovered] = useState(false)
const handleClick = useCallback(() => {
if (onMarkerClick) {
onMarkerClick(position, properties)
onMarkerClick(properties)
}
}, [onMarkerClick, position, properties])
}, [onMarkerClick, properties])
function handleCloseCard() {
if (onCloseMapCard) {
onCloseMapCard()
}
}
return (
<AdvancedMarker
ref={markerRef}
position={position}
onClick={handleClick}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
anchorPoint={AdvancedMarkerAnchorPoint.BOTTOM_CENTER}
zIndex={isActive ? 10 : 0}
>
<HotelMarkerByType hotelId={properties.id} hotelType={properties.type} />
<HotelMarkerByType
smallSize={!isHovered && !isActive}
hotelId={properties.id}
hotelType={properties.type}
/>
<HotelMapCard
isActive={isActive}
amenities={properties.amenities}
tripadvisorRating={properties.tripadvisor}
hotelName={properties.name}
image={properties.image}
url={properties.url}
onClose={handleCloseCard}
/>
</AdvancedMarker>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import { useMap } from "@vis.gl/react-google-maps"
import { useEffect, useState } from "react"
import { useSupercluster } from "@/hooks/maps/use-supercluster"
@@ -27,12 +28,24 @@ const CLUSTER_OPTIONS = {
}
export default function MapContent({ geojson }: MapContentProps) {
const [activeMarker, setActiveMarker] = useState<string | undefined>(
undefined
)
const map = useMap()
const { clusters } = useSupercluster<MarkerProperties>(
geojson,
CLUSTER_OPTIONS
)
useEffect(() => {
map?.addListener("click", () => {
if (activeMarker) {
setActiveMarker(undefined)
}
})
}, [activeMarker, map])
function handleClusterClick(position: google.maps.LatLngLiteral) {
const currentZoom = map && map.getZoom()
if (currentZoom) {
@@ -41,11 +54,12 @@ export default function MapContent({ geojson }: MapContentProps) {
}
}
function handleMarkerClick(
position: google.maps.LatLngLiteral,
properties: MarkerProperties
) {
console.log("Marker clicked", position, properties)
function handleMarkerClick(properties: MarkerProperties) {
setActiveMarker(properties?.id)
}
function handleCloseMapCard() {
setActiveMarker(undefined)
}
return clusters.map((feature) => {
@@ -69,6 +83,8 @@ export default function MapContent({ geojson }: MapContentProps) {
position={{ lat, lng }}
properties={markerProperties}
onMarkerClick={handleMarkerClick}
onCloseMapCard={handleCloseMapCard}
isActive={activeMarker === feature.id}
/>
)
})

View File

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

View File

@@ -0,0 +1,29 @@
"use client"
import { createContext, type PropsWithChildren, useContext } from "react"
export type PageTypeContextType = PageTypeProviderProps["value"]
const PageTypeContext = createContext<PageTypeContextType>(null)
export const usePageType = () => {
const context = useContext(PageTypeContext)
if (!context) {
throw new Error("usePageType must be used within PageTypeProvider")
}
return context
}
export type PageTypeProviderProps = {
value: "city" | "country" | "overview" | null
}
export function PageTypeProvider({
value,
children,
}: PropsWithChildren<PageTypeProviderProps>) {
return (
<PageTypeContext.Provider value={value}>
{children}
</PageTypeContext.Provider>
)
}

View File

@@ -26,6 +26,7 @@ interface MapProps {
hotels: HotelDataWithUrl[]
mapId: string
apiKey: string
pageType: "city" | "country"
}
export default function Map({
@@ -33,6 +34,7 @@ export default function Map({
mapId,
apiKey,
children,
pageType,
}: PropsWithChildren<MapProps>) {
const searchParams = useSearchParams()
const isMapView = useMemo(
@@ -93,7 +95,7 @@ export default function Map({
}
return (
<MapProvider apiKey={apiKey}>
<MapProvider apiKey={apiKey} pageType={pageType}>
<div className={styles.wrapper} ref={rootDiv}>
<Modal
isOpen={isMapView}

View File

@@ -30,7 +30,7 @@ export function mapMarkerDataToGeoJson(markers: DestinationMarker[]) {
export function getHotelMapMarkers(hotels: HotelDataWithUrl[]) {
const markers = hotels
.map(({ hotel }) => ({
.map(({ hotel, url }) => ({
id: hotel.id,
type: hotel.hotelType || "regular",
name: hotel.name,
@@ -40,7 +40,20 @@ export function getHotelMapMarkers(hotels: HotelDataWithUrl[]) {
lng: hotel.location.longitude,
}
: null,
url: url,
tripadvisor: hotel.ratings?.tripAdvisor.rating,
amenities: hotel.detailedFacilities.slice(0, 3),
image:
hotel.galleryImages && hotel.galleryImages[0]
? {
src: hotel.galleryImages[0].imageSizes.medium,
alt:
hotel.galleryImages[0].metaData.altText ||
hotel.galleryImages[0].metaData.altText_En,
}
: null,
}))
.filter((item): item is DestinationMarker => !!item.coordinates)
return markers

View File

@@ -519,6 +519,7 @@
"See destination": "Se destination",
"See details": "Se detaljer",
"See hotel details": "Se hoteloplysninger",
"See hotel information": "Se hoteloplysninger",
"See map": "Vis kort",
"See on map": "Se på kort",
"See results ({ count })": "Se resultater ({ count })",

View File

@@ -520,6 +520,7 @@
"See destination": "Siehe Ziel",
"See details": "Siehe Einzelheiten",
"See hotel details": "Hotelinformationen ansehen",
"See hotel information": "Siehe Hotelinformationen",
"See map": "Karte anzeigen",
"See on map": "Karte ansehen",
"See results ({ count })": "Ergebnisse anzeigen ({ count })",

View File

@@ -526,6 +526,7 @@
"See destination": "See destination",
"See details": "See details",
"See hotel details": "See hotel details",
"See hotel information": "See hotel information",
"See map": "See map",
"See on map": "See on map",
"See results ({ count })": "See results ({ count })",

View File

@@ -520,6 +520,7 @@
"See destination": "Katso kohde",
"See details": "Katso tiedot",
"See hotel details": "Katso hotellin tiedot",
"See hotel information": "Katso hotellin tiedot",
"See map": "Näytä kartta",
"See on map": "Näytä kartalla",
"See results ({ count })": "Katso tulokset ({ count })",

View File

@@ -518,6 +518,7 @@
"See destination": "Se destinasjon",
"See details": "Se detaljer",
"See hotel details": "Se hotellinformasjon",
"See hotel information": "Se hotellinformasjon",
"See map": "Vis kart",
"See on map": "Se på kart",
"See results ({ count })": "Se resultater ({ count })",

View File

@@ -518,6 +518,7 @@
"See destination": "Se destination",
"See details": "Se detaljer",
"See hotel details": "Se hotellinformation",
"See hotel information": "Se hotellinformation",
"See map": "Visa karta",
"See on map": "Se på karta",
"See results ({ count })": "Se resultat ({ count })",

View File

@@ -1,10 +1,17 @@
import type { FeatureCollection, Point } from "geojson"
import type { Amenities } from "@/types/hotel"
import type { GalleryImage } from "../imageGallery"
export interface DestinationMarker {
id: string
type: string
name: string
coordinates: google.maps.LatLngLiteral
url: string
tripadvisor: number | undefined
amenities: Amenities
image: GalleryImage
}
export type MarkerProperties = Omit<DestinationMarker, "coordinates">