Merged in SW-3270-move-interactive-map-to-design-system-or-booking-flow (pull request #2681)
SW-3270 move interactive map to design system or booking flow * wip * wip * merge * wip * add support for locales in design-system * add story for HotelCard * setup alias * . * remove tracking from design-system for hotelcard * pass isUserLoggedIn * export design-system-new-deprecated.css from design-system * Add HotelMarkerByType to Storybook * Add interactive map to Storybook * fix reactintl in vitest * rename env variables * . * fix background colors * add storybook stories for <Link /> * merge * fix tracking for when clicking 'See rooms' in InteractiveMap * Merge branch 'master' of bitbucket.org:scandic-swap/web into SW-3270-move-interactive-map-to-design-system-or-booking-flow * remove deprecated comment Approved-by: Anton Gunnarsson
This commit is contained in:
+32
@@ -0,0 +1,32 @@
|
||||
.pin {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1) var(--Spacing-x-half)
|
||||
var(--Spacing-x-half);
|
||||
border: 2px solid var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-rounded);
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
||||
gap: var(--Spacing-x1);
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.pin.active {
|
||||
background-color: var(--Primary-Dark-Surface-Normal);
|
||||
color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.pinIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--Surface-Brand-Primary-2-Default);
|
||||
}
|
||||
|
||||
.pin.active .pinIcon {
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { useIntl } from 'react-intl'
|
||||
|
||||
import { formatPrice } from '@scandic-hotels/common/utils/numberFormatting'
|
||||
import { MaterialIcon } from '@scandic-hotels/design-system/Icons/MaterialIcon'
|
||||
import { Typography } from '@scandic-hotels/design-system/Typography'
|
||||
|
||||
import HotelMarker from '../../../Markers/HotelMarker'
|
||||
|
||||
import styles from './hotelPin.module.css'
|
||||
|
||||
interface HotelPinProps {
|
||||
isActive: boolean
|
||||
hotelPrice: number | null
|
||||
currency: string
|
||||
hotelAdditionalPrice?: number
|
||||
hotelAdditionalCurrency?: string
|
||||
}
|
||||
|
||||
export function HotelPin({
|
||||
isActive,
|
||||
hotelPrice,
|
||||
currency,
|
||||
hotelAdditionalPrice,
|
||||
hotelAdditionalCurrency,
|
||||
}: HotelPinProps) {
|
||||
const intl = useIntl()
|
||||
const isNotAvailable = !hotelPrice
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.pin} ${isActive ? styles.active : ''}`}
|
||||
data-hotelpin
|
||||
>
|
||||
<span className={styles.pinIcon}>
|
||||
{isNotAvailable ? (
|
||||
<MaterialIcon
|
||||
icon="calendar_clock"
|
||||
size={16}
|
||||
color={isActive ? 'Icon/Interactive/Default' : 'Icon/Inverted'}
|
||||
/>
|
||||
) : (
|
||||
<HotelMarker width={16} color={isActive ? 'burgundy' : 'white'} />
|
||||
)}
|
||||
</span>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{isNotAvailable
|
||||
? '—'
|
||||
: formatPrice(
|
||||
intl,
|
||||
hotelPrice,
|
||||
currency,
|
||||
hotelAdditionalPrice,
|
||||
hotelAdditionalCurrency
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.advancedMarker {
|
||||
height: 32px;
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
AdvancedMarker,
|
||||
AdvancedMarkerAnchorPoint,
|
||||
InfoWindow,
|
||||
} from '@vis.gl/react-google-maps'
|
||||
import { useMediaQuery } from 'usehooks-ts'
|
||||
|
||||
import { HotelPin } from './HotelPin'
|
||||
import type { HotelPin as HotelPinType } from '../../types'
|
||||
import styles from './hotelListingMapContent.module.css'
|
||||
import { StandaloneHotelCardDialog } from '../../../HotelCard/HotelDialogCard/StandaloneHotelCardDialog'
|
||||
import { Lang } from '@scandic-hotels/common/constants/language'
|
||||
|
||||
export type HotelListingMapContentProps = {
|
||||
hotelPins: HotelPinType[]
|
||||
activeHotel?: string | null
|
||||
hoveredHotel?: string | null
|
||||
lang: Lang
|
||||
isUserLoggedIn: boolean
|
||||
onClickHotel?: (hotelId: string) => void
|
||||
setActiveHotel?: (args: { hotelName: string; hotelId: string } | null) => void
|
||||
setHoveredHotel?: (
|
||||
args: { hotelName: string; hotelId: string } | null
|
||||
) => void
|
||||
}
|
||||
export function HotelListingMapContent({
|
||||
hotelPins,
|
||||
activeHotel,
|
||||
hoveredHotel,
|
||||
isUserLoggedIn,
|
||||
setActiveHotel,
|
||||
setHoveredHotel,
|
||||
lang,
|
||||
onClickHotel,
|
||||
}: HotelListingMapContentProps) {
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||
|
||||
const toggleActiveHotelPin = (
|
||||
args: { hotelName: string; hotelId: string } | null
|
||||
) => {
|
||||
if (!args) {
|
||||
setActiveHotel?.(null)
|
||||
return
|
||||
}
|
||||
|
||||
setActiveHotel?.({ hotelName: args.hotelName, hotelId: args.hotelId })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hotelPins.map((pin) => {
|
||||
const isActiveOrHovered =
|
||||
activeHotel === pin.name || hoveredHotel === pin.name
|
||||
const hotelPrice =
|
||||
pin.memberPrice ??
|
||||
pin.publicPrice ??
|
||||
pin.redemptionPrice ??
|
||||
pin.voucherPrice ??
|
||||
pin.chequePrice?.numberOfCheques ??
|
||||
null
|
||||
|
||||
const hotelAdditionalPrice = pin.chequePrice
|
||||
? pin.chequePrice.additionalPricePerStay
|
||||
: undefined
|
||||
const hotelAdditionalCurrency = pin.chequePrice
|
||||
? pin.chequePrice.currency?.toString()
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<AdvancedMarker
|
||||
key={pin.name}
|
||||
className={styles.advancedMarker}
|
||||
position={pin.coordinates}
|
||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||
zIndex={isActiveOrHovered ? 2 : 0}
|
||||
onMouseEnter={() => {
|
||||
setHoveredHotel?.({ hotelName: pin.name, hotelId: pin.operaId })
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredHotel?.(null)
|
||||
}}
|
||||
onClick={() =>
|
||||
toggleActiveHotelPin({
|
||||
hotelName: pin.name,
|
||||
hotelId: pin.operaId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{isActiveOrHovered && isDesktop && (
|
||||
<InfoWindow
|
||||
position={pin.coordinates}
|
||||
pixelOffset={[0, -24]}
|
||||
headerDisabled={true}
|
||||
shouldFocus={false}
|
||||
>
|
||||
<StandaloneHotelCardDialog
|
||||
data={pin}
|
||||
lang={lang}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
handleClose={() => {
|
||||
setActiveHotel?.(null)
|
||||
setHoveredHotel?.(null)
|
||||
}}
|
||||
onClick={() => {
|
||||
onClickHotel?.(pin.operaId)
|
||||
}}
|
||||
/>
|
||||
</InfoWindow>
|
||||
)}
|
||||
<HotelPin
|
||||
isActive={isActiveOrHovered}
|
||||
hotelPrice={hotelPrice}
|
||||
currency={pin.currency}
|
||||
hotelAdditionalPrice={hotelAdditionalPrice}
|
||||
hotelAdditionalCurrency={hotelAdditionalCurrency}
|
||||
/>
|
||||
</AdvancedMarker>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
|
||||
// import { expect, fn } from 'storybook/test'
|
||||
import { InteractiveMap } from '.'
|
||||
import { Lang } from '@scandic-hotels/common/constants/language'
|
||||
import { APIProvider } from '@vis.gl/react-google-maps'
|
||||
import { useState } from 'react'
|
||||
|
||||
const meta: Meta<typeof InteractiveMap> = {
|
||||
title: 'Components/Map/Interactive Map',
|
||||
component: InteractiveMap,
|
||||
argTypes: {},
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof InteractiveMap>
|
||||
|
||||
export const PrimaryDefault: Story = {
|
||||
args: {
|
||||
lang: Lang.en,
|
||||
hotelPins: [
|
||||
{
|
||||
coordinates: {
|
||||
lat: 59.331303,
|
||||
lng: 18.065542,
|
||||
},
|
||||
name: 'Downtown Camper by Scandic',
|
||||
chequePrice: null,
|
||||
publicPrice: 1100,
|
||||
memberPrice: 1067,
|
||||
redemptionPrice: null,
|
||||
voucherPrice: null,
|
||||
rateType: 'Regular',
|
||||
currency: 'SEK',
|
||||
|
||||
amenities: [
|
||||
{
|
||||
filter: 'Hotel facilities',
|
||||
icon: 'Pool',
|
||||
id: 1831,
|
||||
name: 'Pool',
|
||||
public: true,
|
||||
sortOrder: 7000,
|
||||
slug: 'pool',
|
||||
},
|
||||
{
|
||||
filter: 'Hotel facilities',
|
||||
icon: 'Restaurant',
|
||||
id: 1383,
|
||||
name: 'Restaurant',
|
||||
public: true,
|
||||
sortOrder: 6000,
|
||||
slug: 'restaurant',
|
||||
},
|
||||
{
|
||||
filter: 'None',
|
||||
icon: 'KayaksForLoan',
|
||||
id: 162585,
|
||||
name: 'Kayaks for loan',
|
||||
public: true,
|
||||
sortOrder: 5000,
|
||||
slug: 'kayaks-for-loan',
|
||||
},
|
||||
{
|
||||
filter: 'Hotel facilities',
|
||||
icon: 'None',
|
||||
id: 239348,
|
||||
name: 'Rooftop bar',
|
||||
public: false,
|
||||
sortOrder: 4000,
|
||||
slug: 'rooftop-bar',
|
||||
},
|
||||
{
|
||||
filter: 'None',
|
||||
icon: 'BikesForLoan',
|
||||
id: 5550,
|
||||
name: 'Bikes for loan',
|
||||
public: true,
|
||||
sortOrder: 3000,
|
||||
slug: 'bikes-for-loan',
|
||||
},
|
||||
],
|
||||
ratings: {
|
||||
tripAdvisor: 4.4,
|
||||
},
|
||||
operaId: '879',
|
||||
facilityIds: [
|
||||
1831, 1383, 162585, 239348, 5550, 162586, 5806, 1014, 1835, 1829,
|
||||
1379, 1382, 162587, 1017, 1378, 1408, 1833, 971, 1834, 162584, 1381,
|
||||
229144, 267806,
|
||||
],
|
||||
hasEnoughPoints: false,
|
||||
image: {
|
||||
alt: 'Bar of Downtown Camper by Scandic in Stockholm',
|
||||
url: 'https://images-test.scandichotels.com/publishedmedia/z68596isempb61xm2ns9/Scandic_Downtown_Camper_spa_wellness_the_nest_swim.jpg',
|
||||
},
|
||||
},
|
||||
{
|
||||
coordinates: {
|
||||
lat: 59.33469,
|
||||
lng: 18.061586,
|
||||
},
|
||||
name: 'Haymarket by Scandic',
|
||||
chequePrice: null,
|
||||
publicPrice: null,
|
||||
memberPrice: 9999,
|
||||
redemptionPrice: null,
|
||||
voucherPrice: null,
|
||||
rateType: 'Regular',
|
||||
currency: 'SEK',
|
||||
|
||||
amenities: [
|
||||
{
|
||||
filter: 'Hotel facilities',
|
||||
icon: 'Restaurant',
|
||||
id: 1383,
|
||||
name: 'Restaurant',
|
||||
public: true,
|
||||
sortOrder: 6000,
|
||||
slug: 'restaurant',
|
||||
},
|
||||
{
|
||||
filter: 'None',
|
||||
icon: 'None',
|
||||
id: 5806,
|
||||
name: 'Meeting / conference facilities',
|
||||
public: true,
|
||||
sortOrder: 1500,
|
||||
slug: 'meeting-conference-facilities',
|
||||
},
|
||||
{
|
||||
filter: 'Hotel facilities',
|
||||
icon: 'Bar',
|
||||
id: 1014,
|
||||
name: 'Bar',
|
||||
public: true,
|
||||
sortOrder: 1401,
|
||||
slug: 'bar',
|
||||
},
|
||||
{
|
||||
filter: 'Hotel facilities',
|
||||
icon: 'PetFriendlyRooms',
|
||||
id: 1835,
|
||||
name: 'Pet-friendly rooms',
|
||||
public: true,
|
||||
sortOrder: 1201,
|
||||
slug: 'pet-friendly-rooms',
|
||||
},
|
||||
{
|
||||
filter: 'Hotel facilities',
|
||||
icon: 'Gym',
|
||||
id: 1829,
|
||||
name: 'Gym',
|
||||
public: true,
|
||||
sortOrder: 1101,
|
||||
slug: 'gym',
|
||||
},
|
||||
],
|
||||
ratings: {
|
||||
tripAdvisor: 4.1,
|
||||
},
|
||||
operaId: '890',
|
||||
facilityIds: [
|
||||
1383, 5806, 1014, 1835, 1829, 1382, 162587, 1017, 1833, 971, 1834,
|
||||
1381, 1406, 1913, 345180, 375885,
|
||||
],
|
||||
hasEnoughPoints: false,
|
||||
image: {
|
||||
alt: 'Bar',
|
||||
url: 'https://images-test.scandichotels.com/publishedmedia/6wobp0j1ocvoopy1dmce/haymarket-by-scandic-bar-pauls_-3-.jpg',
|
||||
},
|
||||
},
|
||||
],
|
||||
isUserLoggedIn: false,
|
||||
coordinates: {
|
||||
lat: 59.32644916839965,
|
||||
lng: 18.067759400301135,
|
||||
},
|
||||
},
|
||||
render: (args) => {
|
||||
const mapKey = import.meta.env.VITE_GOOGLE_STATIC_MAP_KEY
|
||||
const mapId = import.meta.env.VITE_GOOGLE_DYNAMIC_MAP_ID
|
||||
if (!mapKey || !mapId) {
|
||||
throw new Error(
|
||||
'VITE_GOOGLE_STATIC_MAP_KEY or VITE_GOOGLE_DYNAMIC_MAP_ID is not defined in your .env file. Please add it to run this story.'
|
||||
)
|
||||
}
|
||||
|
||||
const [hoveredHotelPin, setHoveredHotelPin] = useState<string | null>()
|
||||
const [activeHotelPin, setActiveHotelPin] = useState<string | null>()
|
||||
|
||||
return (
|
||||
<APIProvider apiKey={mapKey}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
'--hotel-map-height': '300px',
|
||||
height: 'max(500px, 90vh)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<InteractiveMap
|
||||
{...args}
|
||||
mapId={mapId}
|
||||
hoveredHotelPin={hoveredHotelPin}
|
||||
onHoverHotelPin={(args) => {
|
||||
setHoveredHotelPin(args?.hotelName ?? null)
|
||||
}}
|
||||
activeHotelPin={activeHotelPin}
|
||||
onSetActiveHotelPin={(args) => {
|
||||
setActiveHotelPin(args?.hotelName ?? null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</APIProvider>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
AdvancedMarker,
|
||||
AdvancedMarkerAnchorPoint,
|
||||
} from '@vis.gl/react-google-maps'
|
||||
import { useIntl } from 'react-intl'
|
||||
|
||||
import { Typography } from '@scandic-hotels/design-system/Typography'
|
||||
|
||||
import { HotelMarkerByType } from '../../Markers/HotelMarkerByType'
|
||||
import { PoiMarker } from '../../Markers/PoiMarker'
|
||||
|
||||
import styles from './poiMapMarkers.module.css'
|
||||
|
||||
import type { PointOfInterest } from '@scandic-hotels/trpc/types/hotel'
|
||||
import type { MarkerInfo } from '@scandic-hotels/trpc/types/marker'
|
||||
|
||||
export type PoiMapMarkersProps = {
|
||||
activePoi?: string | null
|
||||
coordinates: { lat: number; lng: number }
|
||||
onActivePoiChange?: (poiName: string | null) => void
|
||||
pointsOfInterest: PointOfInterest[]
|
||||
markerInfo: MarkerInfo
|
||||
}
|
||||
|
||||
export default function PoiMapMarkers({
|
||||
coordinates,
|
||||
pointsOfInterest,
|
||||
onActivePoiChange,
|
||||
activePoi,
|
||||
markerInfo,
|
||||
}: PoiMapMarkersProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
function toggleActivePoi(poiName: string) {
|
||||
onActivePoiChange?.(activePoi === poiName ? null : poiName)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<AdvancedMarker position={coordinates} zIndex={1}>
|
||||
<HotelMarkerByType
|
||||
hotelId={markerInfo.hotelId}
|
||||
hotelType={markerInfo.hotelType}
|
||||
/>
|
||||
</AdvancedMarker>
|
||||
|
||||
{pointsOfInterest.map((poi) => (
|
||||
<AdvancedMarker
|
||||
key={poi.name + poi.categoryName}
|
||||
className={styles.advancedMarker}
|
||||
position={poi.coordinates}
|
||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||
zIndex={activePoi === poi.name ? 2 : 0}
|
||||
onMouseEnter={() => onActivePoiChange?.(poi.name ?? null)}
|
||||
onMouseLeave={() => onActivePoiChange?.(null)}
|
||||
onClick={() => toggleActivePoi(poi.name ?? '')}
|
||||
>
|
||||
<span
|
||||
className={`${styles.poi} ${activePoi === poi.name ? styles.active : ''}`}
|
||||
>
|
||||
<PoiMarker
|
||||
group={poi.group}
|
||||
categoryName={poi.categoryName}
|
||||
size={activePoi === poi.name ? 'large' : 'small'}
|
||||
/>
|
||||
<span className={styles.poiLabel}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>{poi.name}</span>
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
className={styles.distance}
|
||||
>
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: '{distanceInKm} km',
|
||||
},
|
||||
{
|
||||
distanceInKm: poi.distance,
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</Typography>
|
||||
</span>
|
||||
</span>
|
||||
</AdvancedMarker>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
.advancedMarker {
|
||||
height: var(--Space-x4);
|
||||
width: var(
|
||||
--Space-x4
|
||||
) !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
|
||||
}
|
||||
|
||||
.advancedMarker.active {
|
||||
height: var(--Space-x5);
|
||||
width: var(
|
||||
--Space-x5
|
||||
) !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
|
||||
}
|
||||
|
||||
.poi {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--Space-x05);
|
||||
border-radius: var(--Corner-radius-rounded);
|
||||
background-color: var(--Surface-UI-Fill-Default);
|
||||
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.poi.active {
|
||||
padding-right: var(--Space-x15);
|
||||
}
|
||||
|
||||
.poiLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.poi.active .poiLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x2);
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.distance {
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
'use client'
|
||||
|
||||
import { Map, type MapProps, useMap } from '@vis.gl/react-google-maps'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useIntl } from 'react-intl'
|
||||
|
||||
import { IconButton } from '@scandic-hotels/design-system/IconButton'
|
||||
import { MaterialIcon } from '@scandic-hotels/design-system/Icons/MaterialIcon'
|
||||
|
||||
import {
|
||||
DEFAULT_ZOOM,
|
||||
MAP_RESTRICTIONS,
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
} from '../mapConstants'
|
||||
|
||||
import { useZoomControls } from './useZoomControls'
|
||||
|
||||
import { HotelListingMapContent } from './HotelListingMapContent'
|
||||
import PoiMapMarkers from './PoiMapMarkers'
|
||||
|
||||
import styles from './interactiveMap.module.css'
|
||||
|
||||
import type { PointOfInterest } from '@scandic-hotels/trpc/types/hotel'
|
||||
import type { MarkerInfo } from '@scandic-hotels/trpc/types/marker'
|
||||
import { HotelPin } from '../types'
|
||||
import { Lang } from '@scandic-hotels/common/constants/language'
|
||||
|
||||
export type InteractiveMapProps = {
|
||||
lang: Lang
|
||||
coordinates: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
activePoi?: PointOfInterest['name'] | null
|
||||
hotelPins?: HotelPin[]
|
||||
pointsOfInterest?: PointOfInterest[]
|
||||
markerInfo?: MarkerInfo
|
||||
mapId: string
|
||||
closeButton: React.ReactNode
|
||||
fitBounds?: boolean
|
||||
hoveredHotelPin?: string | null
|
||||
activeHotelPin?: string | null
|
||||
|
||||
isUserLoggedIn: boolean
|
||||
onTilesLoaded?: () => void
|
||||
onActivePoiChange?: (poi: PointOfInterest['name'] | null) => void
|
||||
|
||||
onClickHotel?: (hotelId: string) => void
|
||||
|
||||
/**
|
||||
* Called when a hotel pin is hovered.
|
||||
* @param args when null, it means the hover has ended
|
||||
* @returns
|
||||
*/
|
||||
onHoverHotelPin?: (
|
||||
args: { hotelName: string; hotelId: string } | null
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Called when a hotel pin is activated.
|
||||
* @param args when null, it means nothing is active
|
||||
* @returns
|
||||
*/
|
||||
onSetActiveHotelPin?: (
|
||||
args: { hotelName: string; hotelId: string } | null
|
||||
) => void
|
||||
}
|
||||
|
||||
export function InteractiveMap({
|
||||
lang,
|
||||
coordinates,
|
||||
pointsOfInterest,
|
||||
activePoi,
|
||||
hotelPins,
|
||||
mapId,
|
||||
closeButton,
|
||||
markerInfo,
|
||||
fitBounds = true,
|
||||
hoveredHotelPin,
|
||||
activeHotelPin,
|
||||
isUserLoggedIn,
|
||||
onClickHotel,
|
||||
onHoverHotelPin,
|
||||
onSetActiveHotelPin,
|
||||
onTilesLoaded,
|
||||
onActivePoiChange,
|
||||
}: InteractiveMapProps) {
|
||||
const intl = useIntl()
|
||||
const map = useMap()
|
||||
const [hasInitializedBounds, setHasInitializedBounds] = useState(false)
|
||||
const { zoomIn, zoomOut, isMaxZoom, isMinZoom } = useZoomControls()
|
||||
|
||||
const mapOptions: MapProps = {
|
||||
defaultZoom: DEFAULT_ZOOM,
|
||||
minZoom: MIN_ZOOM,
|
||||
maxZoom: MAX_ZOOM,
|
||||
defaultCenter: coordinates,
|
||||
disableDefaultUI: true,
|
||||
clickableIcons: false,
|
||||
mapId,
|
||||
gestureHandling: 'greedy',
|
||||
restriction: MAP_RESTRICTIONS,
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (map && hotelPins?.length && !hasInitializedBounds) {
|
||||
if (fitBounds) {
|
||||
const bounds = new google.maps.LatLngBounds()
|
||||
hotelPins.forEach((marker) => {
|
||||
bounds.extend(marker.coordinates)
|
||||
})
|
||||
map.fitBounds(bounds, 100)
|
||||
}
|
||||
setHasInitializedBounds(true)
|
||||
}
|
||||
}, [map, fitBounds, hotelPins, hasInitializedBounds])
|
||||
|
||||
return (
|
||||
<div className={styles.mapContainer}>
|
||||
<Map {...mapOptions} onTilesLoaded={onTilesLoaded}>
|
||||
{hotelPins && (
|
||||
<HotelListingMapContent
|
||||
lang={lang}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
hotelPins={hotelPins}
|
||||
setActiveHotel={onSetActiveHotelPin}
|
||||
setHoveredHotel={onHoverHotelPin}
|
||||
activeHotel={activeHotelPin}
|
||||
hoveredHotel={hoveredHotelPin}
|
||||
onClickHotel={onClickHotel}
|
||||
/>
|
||||
)}
|
||||
{pointsOfInterest && markerInfo && (
|
||||
<PoiMapMarkers
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
onActivePoiChange={onActivePoiChange}
|
||||
activePoi={activePoi}
|
||||
markerInfo={markerInfo}
|
||||
/>
|
||||
)}
|
||||
</Map>
|
||||
<div className={styles.ctaButtons}>
|
||||
{closeButton}
|
||||
<div className={styles.zoomButtons}>
|
||||
<IconButton
|
||||
theme="Inverted"
|
||||
style="Elevated"
|
||||
className={styles.zoomButton}
|
||||
onClick={zoomOut}
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: 'Zoom out',
|
||||
})}
|
||||
isDisabled={isMinZoom}
|
||||
>
|
||||
<MaterialIcon icon="remove" color="CurrentColor" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
theme="Inverted"
|
||||
style="Elevated"
|
||||
className={styles.zoomButton}
|
||||
onClick={zoomIn}
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: 'Zoom in',
|
||||
})}
|
||||
isDisabled={isMaxZoom}
|
||||
>
|
||||
<MaterialIcon icon="add" color="CurrentColor" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.mapContainer {
|
||||
--button-box-shadow: 0 0 8px 1px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.mapContainer :global(.gm-style .gm-style-iw-d) {
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
max-height: none !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.mapContainer :global(.gm-style .gm-style-iw-c) {
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
max-height: none !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.mapContainer::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-x2);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
pointer-events: initial;
|
||||
box-shadow: var(--button-box-shadow);
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.zoomButton {
|
||||
width: var(--Space-x5);
|
||||
height: var(--Space-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-x5);
|
||||
bottom: var(--Spacing-x7);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.zoomButtons {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMap } from '@vis.gl/react-google-maps'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { DEFAULT_ZOOM, MAX_ZOOM, MIN_ZOOM } from '../mapConstants'
|
||||
|
||||
export function useZoomControls() {
|
||||
const map = useMap()
|
||||
const [zoomLevel, setZoomLevel] = useState(DEFAULT_ZOOM)
|
||||
|
||||
const zoomIn = () => {
|
||||
if (map && zoomLevel < MAX_ZOOM) {
|
||||
map.setZoom(zoomLevel + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
if (map && zoomLevel > MIN_ZOOM) {
|
||||
map.setZoom(zoomLevel - 1)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
|
||||
const handleZoomChanged = () => {
|
||||
const currentZoom = map.getZoom()
|
||||
if (currentZoom != null) {
|
||||
setZoomLevel(currentZoom)
|
||||
}
|
||||
}
|
||||
|
||||
const listener = map.addListener('zoom_changed', handleZoomChanged)
|
||||
return () => listener.remove()
|
||||
}, [map])
|
||||
|
||||
return {
|
||||
zoomLevel,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
isMinZoom: zoomLevel <= MIN_ZOOM,
|
||||
isMaxZoom: zoomLevel >= MAX_ZOOM,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user