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:
Joakim Jäderberg
2025-08-25 11:26:16 +00:00
parent 4f8c51298f
commit c54c1ec540
139 changed files with 2511 additions and 1557 deletions
@@ -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);
}
@@ -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>
)
}
@@ -0,0 +1,3 @@
.advancedMarker {
height: 32px;
}
@@ -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>
))}
</>
)
}
@@ -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,
}
}