feat(SW-340): Added hotel pins

This commit is contained in:
Pontus Dreij
2024-11-06 14:27:55 +01:00
parent fe6582ccbb
commit 378225f995
14 changed files with 321 additions and 129 deletions

View File

@@ -14,7 +14,7 @@ import { setLang } from "@/i18n/serverContext"
import { import {
fetchAvailableHotels, fetchAvailableHotels,
getCentralCoordinates, getCentralCoordinates,
getPointOfInterests, getHotelPins,
} from "../../utils" } from "../../utils"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
@@ -59,16 +59,16 @@ export default async function SelectHotelMapPage({
children, children,
}) })
const pointOfInterests = getPointOfInterests(hotels) const hotelPins = getHotelPins(hotels)
const centralCoordinates = getCentralCoordinates(pointOfInterests) const centralCoordinates = getCentralCoordinates(hotelPins)
return ( return (
<MapModal> <MapModal>
<SelectHotelMap <SelectHotelMap
apiKey={googleMapsApiKey} apiKey={googleMapsApiKey}
coordinates={centralCoordinates} coordinates={centralCoordinates}
pointsOfInterest={pointOfInterests} hotelPins={hotelPins}
mapId={googleMapId} mapId={googleMapId}
isModal={true} isModal={true}
/> />

View File

@@ -3,19 +3,14 @@ import { serverClient } from "@/lib/trpc/server"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput" import type { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput"
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps" import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { import type {
CategorizedFilters, CategorizedFilters,
Filter, Filter,
} from "@/types/components/hotelReservation/selectHotel/hotelFilters" } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate" import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import { import { HotelListingEnum } from "@/types/enums/hotelListing"
type PointOfInterest,
PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum,
} from "@/types/hotel"
const hotelSurroundingsFilterNames = [ const hotelSurroundingsFilterNames = [
"Hotel surroundings", "Hotel surroundings",
@@ -34,7 +29,24 @@ export async function fetchAvailableHotels(
if (!availableHotels) throw new Error() if (!availableHotels) throw new Error()
const language = getLang() const language = getLang()
const hotels = availableHotels.availability.map(async (hotel) => { const hotelMap = new Map<number, any>()
availableHotels.availability.forEach((hotel) => {
const existingHotel = hotelMap.get(hotel.hotelId)
if (existingHotel) {
if (hotel.ratePlanSet === HotelListingEnum.RatePlanSet.PUBLIC) {
existingHotel.bestPricePerNight.regularAmount =
hotel.bestPricePerNight?.regularAmount
} else if (hotel.ratePlanSet === HotelListingEnum.RatePlanSet.MEMBER) {
existingHotel.bestPricePerNight.memberAmount =
hotel.bestPricePerNight?.memberAmount
}
} else {
hotelMap.set(hotel.hotelId, { ...hotel })
}
})
const hotels = Array.from(hotelMap.values()).map(async (hotel) => {
const hotelData = await getHotelData({ const hotelData = await getHotelData({
hotelId: hotel.hotelId.toString(), hotelId: hotel.hotelId.toString(),
language, language,
@@ -76,32 +88,36 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
) )
} }
export function getPointOfInterests(hotels: HotelData[]): PointOfInterest[] { export function getHotelPins(hotels: HotelData[]): HotelPin[] {
// TODO: this is just a quick transformation to get something there. May need rework
return hotels.map((hotel) => ({ return hotels.map((hotel) => ({
coordinates: { coordinates: {
lat: hotel.hotelData.location.latitude, lat: hotel.hotelData.location.latitude,
lng: hotel.hotelData.location.longitude, lng: hotel.hotelData.location.longitude,
}, },
name: hotel.hotelData.name, name: hotel.hotelData.name,
distance: hotel.hotelData.location.distanceToCentre, price: hotel.price
categoryName: PointOfInterestCategoryNameEnum.HOTEL, ? `${Math.min(
group: PointOfInterestGroupEnum.LOCATION, parseFloat(hotel.price.memberAmount ?? "Infinity"),
parseFloat(hotel.price.regularAmount ?? "Infinity")
)}`
: "N/A",
currency: hotel.price?.currency || "Unknown",
image: "default-image-url",
})) }))
} }
export function getCentralCoordinates(pointOfInterests: PointOfInterest[]) { export function getCentralCoordinates(hotels: HotelPin[]) {
const centralCoordinates = pointOfInterests.reduce( const centralCoordinates = hotels.reduce(
(acc, poi) => { (acc, pin) => {
acc.lat += poi.coordinates.lat acc.lat += pin.coordinates.lat
acc.lng += poi.coordinates.lng acc.lng += pin.coordinates.lng
return acc return acc
}, },
{ lat: 0, lng: 0 } { lat: 0, lng: 0 }
) )
centralCoordinates.lat /= pointOfInterests.length centralCoordinates.lat /= hotels.length
centralCoordinates.lng /= pointOfInterests.length centralCoordinates.lng /= hotels.length
return centralCoordinates return centralCoordinates
} }

View File

@@ -20,7 +20,7 @@ import { SelectHotelMapProps } from "@/types/components/hotelReservation/selectH
export default function SelectHotelMap({ export default function SelectHotelMap({
apiKey, apiKey,
coordinates, coordinates,
pointsOfInterest, hotelPins,
mapId, mapId,
isModal, isModal,
}: SelectHotelMapProps) { }: SelectHotelMapProps) {
@@ -28,7 +28,7 @@ export default function SelectHotelMap({
const router = useRouter() const router = useRouter()
const lang = useLang() const lang = useLang()
const intl = useIntl() const intl = useIntl()
const [activePoi, setActivePoi] = useState<string | null>(null) const [activeHotelPin, setActiveHotelPin] = useState<string | null>(null)
function handleModalDismiss() { function handleModalDismiss() {
router.back() router.back()
@@ -70,9 +70,9 @@ export default function SelectHotelMap({
<InteractiveMap <InteractiveMap
closeButton={closeButton} closeButton={closeButton}
coordinates={coordinates} coordinates={coordinates}
pointsOfInterest={pointsOfInterest} hotelPins={hotelPins}
activePoi={activePoi} activeHotelPin={activeHotelPin}
onActivePoiChange={setActivePoi} onActiveHotelPinChange={setActiveHotelPin}
mapId={mapId} mapId={mapId}
/> />
</div> </div>

View File

@@ -12,14 +12,13 @@ export function MapModal({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter()
const [mapHeight, setMapHeight] = useState("0px") const [mapHeight, setMapHeight] = useState("0px")
const [mapTop, setMapTop] = useState("0px") const [mapTop, setMapTop] = useState("0px")
const [isOpen, setOpen] = useState(true) const [isOpen, setIsOpen] = useState(true)
const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0) const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0)
const rootDiv = useRef<HTMLDivElement | null>(null) const rootDiv = useRef<HTMLDivElement | null>(null)
const handleOnOpenChange = (open: boolean) => { const handleOnOpenChange = (open: boolean) => {
setOpen(open) setIsOpen(open)
if (!open) { if (!open) {
router.back() router.back()
} }

View File

@@ -0,0 +1,51 @@
.advancedMarker {
height: 32px;
min-width: 109px !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
}
.advancedMarker.active {
height: var(--Spacing-x5);
width: var(
--Spacing-x5
) !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
}
.pin {
position: absolute;
top: 0;
left: 0;
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-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);
}
.pin.active {
padding-right: var(--Spacing-x-one-and-half);
}
.pinLabel {
display: none;
}
.pin.active .pinLabel {
display: flex;
align-items: center;
gap: var(--Spacing-x2);
text-wrap: nowrap;
}
.pinIcon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--Primary-Dark-Surface-Normal);
}

View File

@@ -0,0 +1,47 @@
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
} from "@vis.gl/react-google-maps"
import Body from "@/components/TempDesignSystem/Text/Body"
import HotelMarker from "../../Markers/Hotel"
import styles from "./hotelListingMapContent.module.css"
import { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelListingMapContent({
activeHotelPin,
hotelPins,
}: {
activeHotelPin?: HotelPin["name"] | null
hotelPins: HotelPin[]
}) {
return (
<div>
{hotelPins.map((pin) => (
<AdvancedMarker
key={pin.name}
className={styles.advancedMarker}
position={pin.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
zIndex={activeHotelPin === pin.name ? 2 : 0}
>
<span
className={`${styles.pin} ${activeHotelPin === pin.name ? styles.active : ""}`}
>
<span className={styles.pinIcon}>
<HotelMarker width={16} />
</span>
<Body asChild>
<span>
{pin.price} {pin.currency}
</span>
</Body>
</span>
</AdvancedMarker>
))}
</div>
)
}

View File

@@ -0,0 +1,42 @@
.advancedMarker {
height: var(--Spacing-x4);
width: var(
--Spacing-x4
) !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
}
.advancedMarker.active {
height: var(--Spacing-x5);
width: var(
--Spacing-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(--Spacing-x-half);
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);
}
.poi.active {
padding-right: var(--Spacing-x-one-and-half);
}
.poiLabel {
display: none;
}
.poi.active .poiLabel {
display: flex;
align-items: center;
gap: var(--Spacing-x2);
text-wrap: nowrap;
}

View File

@@ -0,0 +1,68 @@
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
} from "@vis.gl/react-google-maps"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import PoiMarker from "../../Markers/Poi"
import ScandicMarker from "../../Markers/Scandic"
import styles from "./hotelMapContent.module.css"
import { PointOfInterest } from "@/types/hotel"
export default function HotelMapContent({
coordinates,
pointsOfInterest,
onActivePoiChange,
activePoi,
}: {
coordinates: { lat: number; lng: number }
pointsOfInterest: PointOfInterest[]
onActivePoiChange?: (poiName: string | null) => void
activePoi?: string | null
}) {
function toggleActivePoi(poiName: string) {
onActivePoiChange?.(activePoi === poiName ? null : poiName)
}
return (
<>
<AdvancedMarker position={coordinates} zIndex={1}>
<ScandicMarker />
</AdvancedMarker>
{pointsOfInterest.map((poi) => (
<AdvancedMarker
key={poi.name}
className={styles.advancedMarker}
position={poi.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
zIndex={activePoi === poi.name ? 2 : 0}
onMouseEnter={() => onActivePoiChange?.(poi.name)}
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 ? 20 : 16}
/>
<Body className={styles.poiLabel} asChild>
<span>
{poi.name}
<Caption asChild>
<span>{poi.distance} km</span>
</Caption>
</span>
</Body>
</span>
</AdvancedMarker>
))}
</>
)
}

View File

@@ -1,19 +1,12 @@
"use client" "use client"
import { import { Map, type MapProps, useMap } from "@vis.gl/react-google-maps"
AdvancedMarker,
AdvancedMarkerAnchorPoint,
Map,
type MapProps,
useMap,
} from "@vis.gl/react-google-maps"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { MinusIcon, PlusIcon } from "@/components/Icons" import { MinusIcon, PlusIcon } from "@/components/Icons"
import PoiMarker from "@/components/Maps/Markers/Poi"
import ScandicMarker from "@/components/Maps/Markers/Scandic"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import HotelListingMapContent from "./HotelListingMapContent"
import HotelMapContent from "./HotelMapContent"
import styles from "./interactiveMap.module.css" import styles from "./interactiveMap.module.css"
@@ -23,9 +16,12 @@ export default function InteractiveMap({
coordinates, coordinates,
pointsOfInterest, pointsOfInterest,
activePoi, activePoi,
hotelPins,
activeHotelPin,
mapId, mapId,
onActivePoiChange,
closeButton, closeButton,
onActivePoiChange,
onActiveHotelPinChange,
}: InteractiveMapProps) { }: InteractiveMapProps) {
const intl = useIntl() const intl = useIntl()
const map = useMap() const map = useMap()
@@ -51,46 +47,23 @@ export default function InteractiveMap({
} }
} }
function toggleActivePoi(poiName: string) {
onActivePoiChange(activePoi === poiName ? null : poiName)
}
return ( return (
<div className={styles.mapContainer}> <div className={styles.mapContainer}>
<Map {...mapOptions}> <Map {...mapOptions}>
<AdvancedMarker position={coordinates} zIndex={1}> {hotelPins && (
<ScandicMarker /> <HotelListingMapContent
</AdvancedMarker> activeHotelPin={activeHotelPin}
hotelPins={hotelPins}
{pointsOfInterest.map((poi) => ( />
<AdvancedMarker )}
key={poi.name} {pointsOfInterest && (
className={styles.advancedMarker} <HotelMapContent
position={poi.coordinates} coordinates={coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER} pointsOfInterest={pointsOfInterest}
zIndex={activePoi === poi.name ? 2 : 0} onActivePoiChange={onActivePoiChange}
onMouseEnter={() => onActivePoiChange(poi.name)} activePoi={activePoi}
onClick={() => toggleActivePoi(poi.name)} />
> )}
<span
className={`${styles.poi} ${activePoi === poi.name ? styles.active : ""}`}
>
<PoiMarker
group={poi.group}
categoryName={poi.categoryName}
size={activePoi === poi.name ? 20 : 16}
/>
<Body className={styles.poiLabel} asChild>
<span>
{poi.name}
<Caption asChild>
<span>{poi.distance} km</span>
</Caption>
</span>
</Body>
</span>
</AdvancedMarker>
))}
</Map> </Map>
<div className={styles.ctaButtons}> <div className={styles.ctaButtons}>
{closeButton} {closeButton}

View File

@@ -52,49 +52,6 @@
box-shadow: var(--button-box-shadow); box-shadow: var(--button-box-shadow);
} }
.advancedMarker {
height: var(--Spacing-x4);
width: var(
--Spacing-x4
) !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
}
.advancedMarker.active {
height: var(--Spacing-x5);
width: var(
--Spacing-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(--Spacing-x-half);
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);
}
.poi.active {
padding-right: var(--Spacing-x-one-and-half);
}
.poiLabel {
display: none;
}
.poi.active .poiLabel {
display: flex;
align-items: center;
gap: var(--Spacing-x2);
text-wrap: nowrap;
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.ctaButtons { .ctaButtons {
top: var(--Spacing-x4); top: var(--Spacing-x4);

View File

@@ -0,0 +1,21 @@
export default function HotelMarker({
className,
...props
}: React.SVGAttributes<HTMLOrSVGElement>) {
return (
<svg
className={className}
width="16"
height="11"
viewBox="0 0 16 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M1.45898 10.5331C1.28676 10.5331 1.13954 10.472 1.01732 10.3498C0.895095 10.2276 0.833984 10.0804 0.833984 9.90814V1.4248C0.833984 1.25258 0.895095 1.10536 1.01732 0.983138C1.13954 0.860916 1.28676 0.799805 1.45898 0.799805C1.63121 0.799805 1.77843 0.860916 1.90065 0.983138C2.02287 1.10536 2.08398 1.25258 2.08398 1.4248V7.2998H7.38398V3.36647C7.38398 3.02272 7.50638 2.72844 7.75117 2.48365C7.99596 2.23887 8.29023 2.11647 8.63398 2.11647H12.6007C13.3118 2.11647 13.9173 2.36647 14.4173 2.86647C14.9173 3.36647 15.1673 3.97203 15.1673 4.68314V9.90814C15.1673 10.0804 15.1062 10.2276 14.984 10.3498C14.8618 10.472 14.7145 10.5331 14.5423 10.5331C14.3701 10.5331 14.2229 10.472 14.1007 10.3498C13.9784 10.2276 13.9173 10.0804 13.9173 9.90814V8.5498H2.08398V9.90814C2.08398 10.0804 2.02287 10.2276 1.90065 10.3498C1.77843 10.472 1.63121 10.5331 1.45898 10.5331ZM4.71633 6.5998C4.18366 6.5998 3.73121 6.41337 3.35898 6.04049C2.98676 5.66761 2.80065 5.21483 2.80065 4.68215C2.80065 4.14948 2.98709 3.69703 3.35997 3.3248C3.73285 2.95258 4.18562 2.76647 4.7183 2.76647C5.25098 2.76647 5.70343 2.95291 6.07565 3.32579C6.44787 3.69867 6.63398 4.15144 6.63398 4.68412C6.63398 5.2168 6.44755 5.66925 6.07467 6.04147C5.70179 6.41369 5.24901 6.5998 4.71633 6.5998ZM8.63398 7.2998H13.9173V4.6815C13.9173 4.32148 13.788 4.0123 13.5293 3.75397C13.2707 3.49564 12.9597 3.36647 12.5965 3.36647H8.63398V7.2998ZM4.71732 5.3498C4.90621 5.3498 5.06454 5.28592 5.19232 5.15814C5.3201 5.03036 5.38398 4.87203 5.38398 4.68314C5.38398 4.49425 5.3201 4.33592 5.19232 4.20814C5.06454 4.08036 4.90621 4.01647 4.71732 4.01647C4.52843 4.01647 4.3701 4.08036 4.24232 4.20814C4.11454 4.33592 4.05065 4.49425 4.05065 4.68314C4.05065 4.87203 4.11454 5.03036 4.24232 5.15814C4.3701 5.28592 4.52843 5.3498 4.71732 5.3498Z"
fill="white"
/>
</svg>
)
}

View File

@@ -1,13 +1,18 @@
import { ReactElement } from "react" import { ReactElement } from "react"
import { HotelPin } from "../../hotelReservation/selectHotel/map"
import type { Coordinates } from "@/types/components/maps/coordinates" import type { Coordinates } from "@/types/components/maps/coordinates"
import type { PointOfInterest } from "@/types/hotel" import type { PointOfInterest } from "@/types/hotel"
export interface InteractiveMapProps { export interface InteractiveMapProps {
coordinates: Coordinates coordinates: Coordinates
pointsOfInterest: PointOfInterest[] pointsOfInterest?: PointOfInterest[]
activePoi: PointOfInterest["name"] | null activePoi?: PointOfInterest["name"] | null
hotelPins?: HotelPin[]
activeHotelPin?: HotelPin["name"] | null
mapId: string mapId: string
onActivePoiChange: (poi: PointOfInterest["name"] | null) => void
closeButton: ReactElement closeButton: ReactElement
onActivePoiChange?: (poi: PointOfInterest["name"] | null) => void
onActiveHotelPinChange?: (hotelPin: PointOfInterest["name"] | null) => void
} }

View File

@@ -1,5 +1,4 @@
import { Coordinates } from "@/types/components/maps/coordinates" import { Coordinates } from "@/types/components/maps/coordinates"
import type { PointOfInterest } from "@/types/hotel"
export interface HotelListingProps { export interface HotelListingProps {
// pointsOfInterest: PointOfInterest[] // pointsOfInterest: PointOfInterest[]
@@ -10,7 +9,15 @@ export interface HotelListingProps {
export interface SelectHotelMapProps { export interface SelectHotelMapProps {
apiKey: string apiKey: string
coordinates: Coordinates coordinates: Coordinates
pointsOfInterest: PointOfInterest[] hotelPins: HotelPin[]
mapId: string mapId: string
isModal: boolean isModal: boolean
} }
export type HotelPin = {
name: string
coordinates: Coordinates
price: string
currency: string
image: string
}

View File

@@ -0,0 +1,6 @@
export namespace HotelListingEnum {
export const enum RatePlanSet {
PUBLIC = "PUBLIC",
MEMBER = "MEMBER",
}
}