Merged in feat/SW-1549-map-improvements (pull request #1783)
Feat/SW-1549 map improvements * fix: imported new icon * refactor: rename component and set map handling to 'greedy' * fix: show cards for 3s after hover * refactor: update styles and added HotelPin component * fix: change from close to back icon * refactor: update to only use 1 state value for active pin and card * fix: add click handler when dialog is opened * fix: performance fixes for the dialog carousel * fix: added border * fix: clear timeout on mouseenter * fix: changed to absolute import * fix: moved hover state into the store * fix: renamed store actions Approved-by: Michael Zetterberg
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useParams } from "next/dist/client/components/navigation"
|
import { useParams } from "next/dist/client/components/navigation"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { memo, useCallback } from "react"
|
import { memo } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
|
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
|
||||||
@@ -53,16 +53,7 @@ function HotelCard({
|
|||||||
|
|
||||||
const lang = params.lang as Lang
|
const lang = params.lang as Lang
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const { setActiveHotelPin, setActiveHotelCard } = useHotelsMapStore()
|
const { activate, engage, disengageAfterDelay } = useHotelsMapStore()
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
|
||||||
setActiveHotelPin(hotel.name)
|
|
||||||
}, [setActiveHotelPin, hotel])
|
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
|
||||||
setActiveHotelPin(null)
|
|
||||||
setActiveHotelCard(null)
|
|
||||||
}, [setActiveHotelPin, setActiveHotelCard])
|
|
||||||
|
|
||||||
const amenities = hotel.detailedFacilities.slice(0, 5)
|
const amenities = hotel.detailedFacilities.slice(0, 5)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -73,8 +64,7 @@ function HotelCard({
|
|||||||
|
|
||||||
const handleAddressClick = (event: React.MouseEvent) => {
|
const handleAddressClick = (event: React.MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setActiveHotelPin(hotel.name)
|
activate(hotel.name)
|
||||||
setActiveHotelCard(hotel.name)
|
|
||||||
router.push(`${selectHotelMap(lang)}?${searchParams.toString()}`)
|
router.push(`${selectHotelMap(lang)}?${searchParams.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +85,8 @@ function HotelCard({
|
|||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={classNames}
|
className={classNames}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={() => engage(hotel.name)}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={() => disengageAfterDelay()}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.imageContainer}>
|
<div className={styles.imageContainer}>
|
||||||
|
|||||||
@@ -62,28 +62,20 @@ export default function ListingHotelCardDialog({
|
|||||||
setImageError={setImageError}
|
setImageError={setImageError}
|
||||||
position="top"
|
position="top"
|
||||||
/>
|
/>
|
||||||
<div className={styles.detailsContainer}>
|
<div>
|
||||||
<div className={styles.name}>
|
<div className={styles.name}>
|
||||||
<Subtitle type="two">{name}</Subtitle>
|
<Subtitle type="two">{name}</Subtitle>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.facilities}>
|
<div className={styles.facilities}>
|
||||||
{amenities.map((facility) => {
|
{amenities.map((facility) => (
|
||||||
const Icon = (
|
<div className={styles.facilitiesItem} key={facility.id}>
|
||||||
<FacilityToIcon
|
<FacilityToIcon
|
||||||
id={facility.id}
|
id={facility.id}
|
||||||
size={20}
|
size={20}
|
||||||
color="Icon/Default"
|
color="Icon/Default"
|
||||||
/>
|
/>
|
||||||
)
|
</div>
|
||||||
return (
|
))}
|
||||||
<div className={styles.facilitiesItem} key={facility.id}>
|
|
||||||
{Icon && Icon}
|
|
||||||
<Caption color="uiTextMediumContrast">
|
|
||||||
{facility.name}
|
|
||||||
</Caption>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +107,7 @@ export default function ListingHotelCardDialog({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{memberPrice && (
|
{memberPrice && (
|
||||||
<Subtitle type="two" color="red" className={styles.memberPrice}>
|
<Subtitle type="two" color="red">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{
|
{
|
||||||
defaultMessage: "{price} {currency}",
|
defaultMessage: "{price} {currency}",
|
||||||
|
|||||||
@@ -65,10 +65,8 @@ export default function StandaloneHotelCardDialog({
|
|||||||
/>
|
/>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.detailsContainer}>
|
<div className={styles.name}>
|
||||||
<div className={styles.name}>
|
<Body textTransform="bold">{name}</Body>
|
||||||
<Body textTransform="bold">{name}</Body>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.facilities}>
|
<div className={styles.facilities}>
|
||||||
@@ -126,11 +124,7 @@ export default function StandaloneHotelCardDialog({
|
|||||||
</Subtitle>
|
</Subtitle>
|
||||||
)}
|
)}
|
||||||
{memberPrice && (
|
{memberPrice && (
|
||||||
<Subtitle
|
<Subtitle type="two" color="red">
|
||||||
type="two"
|
|
||||||
color="red"
|
|
||||||
className={styles.memberPrice}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{
|
{
|
||||||
defaultMessage: "{price} {currency}",
|
defaultMessage: "{price} {currency}",
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
gap: 0 var(--Spacing-x1);
|
gap: 0 var(--Spacing-x-one-and-half);
|
||||||
max-width: 242px;
|
max-width: 242px;
|
||||||
}
|
}
|
||||||
.dialogContainer[data-type="listing"] .facilities::after {
|
.dialogContainer[data-type="listing"] .facilities::after {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function HotelCardDialog({
|
|||||||
<dialog open={isOpen} className={styles.dialog}>
|
<dialog open={isOpen} className={styles.dialog}>
|
||||||
<div className={styles.dialogContainer} data-type={type}>
|
<div className={styles.dialogContainer} data-type={type}>
|
||||||
<div onClick={handleClose}>
|
<div onClick={handleClose}>
|
||||||
<MaterialIcon icon="close" className={styles.closeIcon} size={16} />
|
<MaterialIcon icon="close" className={styles.closeIcon} size={22} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{type === "standalone" ? (
|
{type === "standalone" ? (
|
||||||
|
|||||||
@@ -3,6 +3,23 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: var(--Spacing-x1);
|
gap: var(--Spacing-x1);
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
overflow-x: scroll;
|
||||||
|
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
-webkit-overflow-scrolling: touch; /* Needed to work on iOS Safari */
|
||||||
|
padding-inline: var(--Spacing-x2);
|
||||||
|
scroll-padding-inline: var(--Spacing-x2);
|
||||||
|
overscroll-behavior-inline: contain;
|
||||||
|
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
will-change: transform;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotelCardDialogListing > div {
|
||||||
|
height: 100%;
|
||||||
|
scroll-snap-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hotelCardDialogListing dialog {
|
.hotelCardDialogListing dialog {
|
||||||
@@ -10,11 +27,3 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hotelCardDialogListing > div:first-child {
|
|
||||||
margin-left: var(--Spacing-x2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hotelCardDialogListing > div:last-child {
|
|
||||||
margin-right: var(--Spacing-x2);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react"
|
import { useCallback, useEffect, useRef } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
import { useMediaQuery } from "usehooks-ts"
|
|
||||||
|
|
||||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||||
|
|
||||||
import useClickOutside from "@/hooks/useClickOutside"
|
|
||||||
|
|
||||||
import HotelCardDialog from "../HotelCardDialog"
|
import HotelCardDialog from "../HotelCardDialog"
|
||||||
import { getHotelPins } from "./utils"
|
import { getHotelPins } from "./utils"
|
||||||
|
|
||||||
@@ -27,89 +24,119 @@ export default function HotelCardDialogListing({
|
|||||||
defaultMessage: "Points",
|
defaultMessage: "Points",
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
const hotelsPinData = hotels ? getHotelPins(hotels, currencyValue) : []
|
const hotelsPinData = getHotelPins(hotels, currencyValue)
|
||||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||||
const dialogRef = useRef<HTMLDivElement>(null)
|
const dialogRef = useRef<HTMLDivElement>(null)
|
||||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
const isScrollingRef = useRef<boolean>(false)
|
||||||
const { activeHotelCard, setActiveHotelCard, setActiveHotelPin } =
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
useHotelsMapStore()
|
const { activeHotel, activate, deactivate } = useHotelsMapStore()
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
setActiveHotelCard(null)
|
|
||||||
setActiveHotelPin(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
useClickOutside(dialogRef, !!activeHotelCard && isMobile, handleClose)
|
|
||||||
|
|
||||||
const handleIntersection = useCallback(
|
const handleIntersection = useCallback(
|
||||||
(entries: IntersectionObserverEntry[]) => {
|
(entries: IntersectionObserverEntry[]) => {
|
||||||
entries.forEach((entry) => {
|
// skip intersection handling during scrolling
|
||||||
if (entry.isIntersecting) {
|
if (isScrollingRef.current) return
|
||||||
const cardName = entry.target.getAttribute("data-name")
|
|
||||||
if (cardName) {
|
if (debounceTimerRef.current) {
|
||||||
setActiveHotelCard(cardName)
|
clearTimeout(debounceTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const cardName = entry.target.getAttribute("data-name")
|
||||||
|
if (cardName && cardName !== activeHotel) {
|
||||||
|
activate(cardName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
}, 100)
|
||||||
},
|
},
|
||||||
[setActiveHotelCard]
|
[activate, activeHotel]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
observerRef.current = new IntersectionObserver(handleIntersection, {
|
observerRef.current = new IntersectionObserver(handleIntersection, {
|
||||||
root: null,
|
root: null,
|
||||||
threshold: 0.5,
|
threshold: [0.3, 0.5, 0.7],
|
||||||
})
|
})
|
||||||
|
|
||||||
const elements = document.querySelectorAll("[data-name]")
|
const elements = document.querySelectorAll("[data-name]")
|
||||||
elements.forEach((el) => observerRef.current?.observe(el))
|
elements.forEach((el) => observerRef.current?.observe(el))
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
elements.forEach((el) => observerRef.current?.unobserve(el))
|
elements.forEach((el) => observerRef.current?.unobserve(el))
|
||||||
observerRef.current?.disconnect()
|
observerRef.current?.disconnect()
|
||||||
|
observerRef.current = null
|
||||||
}
|
}
|
||||||
}, [handleIntersection])
|
}, [handleIntersection])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeCardRef.current) {
|
if (activeCardRef.current) {
|
||||||
// Temporarily disconnect the observer
|
isScrollingRef.current = true
|
||||||
observerRef.current?.disconnect()
|
|
||||||
|
|
||||||
activeCardRef.current.scrollIntoView({
|
requestAnimationFrame(() => {
|
||||||
behavior: "smooth",
|
activeCardRef.current?.scrollIntoView({
|
||||||
block: "nearest",
|
behavior: "smooth",
|
||||||
inline: "center",
|
block: "nearest",
|
||||||
|
inline: "center",
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isScrollingRef.current = false
|
||||||
|
}, 800)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reconnect the observer after scrolling
|
|
||||||
const elements = document.querySelectorAll("[data-name]")
|
|
||||||
setTimeout(() => {
|
|
||||||
elements.forEach((el) => observerRef.current?.observe(el))
|
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
}, [activeHotelCard])
|
}, [activeHotel])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMapClick = (e: MouseEvent) => {
|
||||||
|
// ignore clicks within the dialog
|
||||||
|
if (dialogRef.current?.contains(e.target as Node)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore clicks on hotel pins
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.closest("[data-hotelpin]")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeHotel) {
|
||||||
|
document.addEventListener("click", handleMapClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", handleMapClick)
|
||||||
|
}
|
||||||
|
}, [dialogRef, activeHotel, deactivate])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
|
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
|
||||||
{!!hotelsPinData?.length &&
|
{hotelsPinData?.map((data) => {
|
||||||
hotelsPinData.map((data) => {
|
const isActive = data.name === activeHotel
|
||||||
const isActive = data.name === activeHotelCard
|
return (
|
||||||
return (
|
<div
|
||||||
<div
|
key={data.name}
|
||||||
key={data.name}
|
ref={isActive ? activeCardRef : null}
|
||||||
ref={isActive ? activeCardRef : null}
|
data-name={data.name}
|
||||||
data-name={data.name}
|
>
|
||||||
>
|
<HotelCardDialog
|
||||||
<HotelCardDialog
|
data={data}
|
||||||
data={data}
|
isOpen={!!activeHotel}
|
||||||
isOpen={!!activeHotelCard}
|
handleClose={deactivate}
|
||||||
handleClose={handleClose}
|
type="listing"
|
||||||
type="listing"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function HotelCardListing({
|
|||||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const { activeHotelCard } = useHotelsMapStore()
|
const { activeHotel } = useHotelsMapStore()
|
||||||
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
|
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
|
||||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export default function HotelCardListing({
|
|||||||
inline: "center",
|
inline: "center",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [activeHotelCard, type])
|
}, [activeHotel, type])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResultCount(hotels.length)
|
setResultCount(hotels.length)
|
||||||
@@ -116,8 +116,7 @@ export default function HotelCardListing({
|
|||||||
|
|
||||||
function isHotelActiveInMapView(hotelName: string): boolean {
|
function isHotelActiveInMapView(hotelName: string): boolean {
|
||||||
return (
|
return (
|
||||||
hotelName === activeHotelCard &&
|
hotelName === activeHotel && type === HotelCardListingTypeEnum.MapListing
|
||||||
type === HotelCardListingTypeEnum.MapListing
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,22 +13,19 @@ import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/se
|
|||||||
import type { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
import type { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||||
|
|
||||||
export default function HotelListing({ hotels }: HotelListingProps) {
|
export default function HotelListing({ hotels }: HotelListingProps) {
|
||||||
const { activeHotelPin } = useHotelsMapStore()
|
const { activeHotel } = useHotelsMapStore()
|
||||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||||
return (
|
|
||||||
<>
|
return isMobile ? (
|
||||||
{isMobile ? (
|
<div className={styles.hotelListingMobile} data-open={!!activeHotel}>
|
||||||
<div className={styles.hotelListingMobile} data-open={!!activeHotelPin}>
|
<HotelCardDialogListing hotels={hotels} />
|
||||||
<HotelCardDialogListing hotels={hotels} />
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className={styles.hotelListing}>
|
||||||
<div className={styles.hotelListing}>
|
<HotelCardListing
|
||||||
<HotelCardListing
|
hotelData={hotels}
|
||||||
hotelData={hotels}
|
type={HotelCardListingTypeEnum.MapListing}
|
||||||
type={HotelCardListingTypeEnum.MapListing}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function SelectHotelContent({
|
|||||||
const listingContainerRef = useRef<HTMLDivElement | null>(null)
|
const listingContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||||
const { activeHotelPin } = useHotelsMapStore()
|
const { activeHotel } = useHotelsMapStore()
|
||||||
|
|
||||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||||
threshold: 490,
|
threshold: 490,
|
||||||
@@ -65,8 +65,8 @@ export default function SelectHotelContent({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const coordinates = useMemo(() => {
|
const coordinates = useMemo(() => {
|
||||||
if (activeHotelPin) {
|
if (activeHotel) {
|
||||||
const hotel = hotels.find((hotel) => hotel.hotel.name === activeHotelPin)
|
const hotel = hotels.find((hotel) => hotel.hotel.name === activeHotel)
|
||||||
|
|
||||||
if (hotel && hotel.hotel.location) {
|
if (hotel && hotel.hotel.location) {
|
||||||
return {
|
return {
|
||||||
@@ -79,7 +79,7 @@ export default function SelectHotelContent({
|
|||||||
return isAboveMobile
|
return isAboveMobile
|
||||||
? cityCoordinates
|
? cityCoordinates
|
||||||
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
|
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
|
||||||
}, [activeHotelPin, hotels, isAboveMobile, cityCoordinates])
|
}, [activeHotel, hotels, isAboveMobile, cityCoordinates])
|
||||||
|
|
||||||
const filteredHotelPins = useMemo(() => {
|
const filteredHotelPins = useMemo(() => {
|
||||||
const updatedHotelsList = bookingCode
|
const updatedHotelsList = bookingCode
|
||||||
@@ -159,14 +159,15 @@ export default function SelectHotelContent({
|
|||||||
<div className={styles.filterContainer}>
|
<div className={styles.filterContainer}>
|
||||||
<Button
|
<Button
|
||||||
intent="text"
|
intent="text"
|
||||||
size="small"
|
type="button"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
wrapping
|
size="small"
|
||||||
className={styles.filterContainerCloseButton}
|
className={styles.filterContainerCloseButton}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link href={selectHotel(lang)} keepSearchParams>
|
<Link href={selectHotel(lang)} keepSearchParams>
|
||||||
<MaterialIcon icon="close" color="CurrentColor" />
|
<MaterialIcon icon="arrow_back" color="CurrentColor" size={20} />
|
||||||
|
{intl.formatMessage({ id: "Back" })}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<FilterAndSortModal
|
<FilterAndSortModal
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filterContainerCloseButton {
|
||||||
|
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.container .closeButton {
|
.container .closeButton {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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,46 @@
|
|||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import HotelMarker from "@/components/Maps/Markers/HotelMarker"
|
||||||
|
import { formatPrice } from "@/utils/numberFormatting"
|
||||||
|
|
||||||
|
import styles from "./hotelPin.module.css"
|
||||||
|
|
||||||
|
interface HotelPinProps {
|
||||||
|
isActive: boolean
|
||||||
|
hotelPrice: number | null
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HotelPin({
|
||||||
|
isActive,
|
||||||
|
hotelPrice,
|
||||||
|
currency,
|
||||||
|
}: 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)}</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,62 +1,11 @@
|
|||||||
.advancedMarker {
|
.advancedMarker {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
min-width: 109px !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
|
|
||||||
}
|
|
||||||
|
|
||||||
.advancedMarker.active {
|
|
||||||
height: 32px;
|
|
||||||
min-width: 109px !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialogContainer {
|
.dialogContainer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pin.active {
|
|
||||||
background-color: var(--Primary-Dark-Surface-Normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pin.active .pinIcon {
|
|
||||||
background: var(--Base-Surface-Primary-light-Normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -2,62 +2,39 @@ import {
|
|||||||
AdvancedMarker,
|
AdvancedMarker,
|
||||||
AdvancedMarkerAnchorPoint,
|
AdvancedMarkerAnchorPoint,
|
||||||
} from "@vis.gl/react-google-maps"
|
} from "@vis.gl/react-google-maps"
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback } from "react"
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||||
|
|
||||||
import HotelCardDialog from "@/components/HotelReservation/HotelCardDialog"
|
import HotelCardDialog from "@/components/HotelReservation/HotelCardDialog"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
|
||||||
import { formatPrice } from "@/utils/numberFormatting"
|
|
||||||
|
|
||||||
import HotelMarker from "../../Markers/HotelMarker"
|
import HotelPin from "./HotelPin"
|
||||||
|
|
||||||
import styles from "./hotelListingMapContent.module.css"
|
import styles from "./hotelListingMapContent.module.css"
|
||||||
|
|
||||||
import type { HotelListingMapContentProps } from "@/types/components/hotelReservation/selectHotel/map"
|
import type { HotelListingMapContentProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||||
|
|
||||||
function HotelListingMapContent({ hotelPins }: HotelListingMapContentProps) {
|
function HotelListingMapContent({ hotelPins }: HotelListingMapContentProps) {
|
||||||
const intl = useIntl()
|
const { activeHotel, hoveredHotel, activate, deactivate, engage, disengage } =
|
||||||
const [hoveredHotelPin, setHoveredHotelPin] = useState<string | null>(null)
|
|
||||||
const { activeHotelPin, setActiveHotelPin, setActiveHotelCard } =
|
|
||||||
useHotelsMapStore()
|
useHotelsMapStore()
|
||||||
|
|
||||||
const toggleActiveHotelPin = useCallback(
|
const toggleActiveHotelPin = useCallback(
|
||||||
(pinName: string | null) => {
|
(pinName: string | null) => {
|
||||||
if (setActiveHotelPin) {
|
if (activeHotel === pinName || pinName === null) {
|
||||||
const newActivePin = activeHotelPin === pinName ? null : pinName
|
deactivate()
|
||||||
setActiveHotelPin(newActivePin)
|
return
|
||||||
setActiveHotelCard(newActivePin)
|
|
||||||
if (newActivePin === null) {
|
|
||||||
setHoveredHotelPin(null)
|
|
||||||
setActiveHotelCard(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[activeHotelPin, setActiveHotelPin, setActiveHotelCard]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleHover = useCallback(
|
activate(pinName)
|
||||||
(pinName: string | null) => {
|
|
||||||
if (pinName !== null && activeHotelPin !== pinName) {
|
|
||||||
setHoveredHotelPin(pinName)
|
|
||||||
if (activeHotelPin && setActiveHotelPin) {
|
|
||||||
setActiveHotelPin(null)
|
|
||||||
setActiveHotelCard(null)
|
|
||||||
}
|
|
||||||
} else if (pinName === null) {
|
|
||||||
setHoveredHotelPin(null)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[activeHotelPin, setActiveHotelPin, setActiveHotelCard]
|
[activeHotel, activate, deactivate]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{hotelPins.map((pin) => {
|
{hotelPins.map((pin) => {
|
||||||
const isActiveOrHovered =
|
const isActiveOrHovered =
|
||||||
activeHotelPin === pin.name || hoveredHotelPin === pin.name
|
activeHotel === pin.name || hoveredHotel === pin.name
|
||||||
const hotelPrice =
|
const hotelPrice =
|
||||||
pin.memberPrice ?? pin.publicPrice ?? pin.redemptionPrice
|
pin.memberPrice ?? pin.publicPrice ?? pin.redemptionPrice
|
||||||
return (
|
return (
|
||||||
@@ -67,8 +44,8 @@ function HotelListingMapContent({ hotelPins }: HotelListingMapContentProps) {
|
|||||||
position={pin.coordinates}
|
position={pin.coordinates}
|
||||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||||
zIndex={isActiveOrHovered ? 2 : 0}
|
zIndex={isActiveOrHovered ? 2 : 0}
|
||||||
onMouseEnter={() => handleHover(pin.name)}
|
onMouseEnter={() => engage(pin.name)}
|
||||||
onMouseLeave={() => handleHover(null)}
|
onMouseLeave={() => disengage()}
|
||||||
onClick={() => toggleActiveHotelPin(pin.name)}
|
onClick={() => toggleActiveHotelPin(pin.name)}
|
||||||
>
|
>
|
||||||
<div className={styles.dialogContainer}>
|
<div className={styles.dialogContainer}>
|
||||||
@@ -76,37 +53,17 @@ function HotelListingMapContent({ hotelPins }: HotelListingMapContentProps) {
|
|||||||
isOpen={isActiveOrHovered}
|
isOpen={isActiveOrHovered}
|
||||||
handleClose={(event: { stopPropagation: () => void }) => {
|
handleClose={(event: { stopPropagation: () => void }) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (activeHotelPin === pin.name) {
|
deactivate()
|
||||||
toggleActiveHotelPin(null)
|
disengage()
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
data={pin}
|
data={pin}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<HotelPin
|
||||||
className={`${styles.pin} ${isActiveOrHovered ? styles.active : ""}`}
|
isActive={isActiveOrHovered}
|
||||||
>
|
hotelPrice={hotelPrice}
|
||||||
<span className={styles.pinIcon}>
|
currency={pin.currency}
|
||||||
<HotelMarker
|
/>
|
||||||
width={16}
|
|
||||||
color={isActiveOrHovered ? "burgundy" : "white"}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Body
|
|
||||||
asChild
|
|
||||||
color={isActiveOrHovered ? "white" : "baseTextHighContrast"}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{/* TODO: Handle when no price is available */}
|
|
||||||
{hotelPrice
|
|
||||||
? formatPrice(intl, hotelPrice, pin.currency)
|
|
||||||
: intl.formatMessage({
|
|
||||||
defaultMessage: "N/A",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Body>
|
|
||||||
</span>
|
|
||||||
</AdvancedMarker>
|
</AdvancedMarker>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
|
|||||||
import PoiMarker from "../../Markers/Poi"
|
import PoiMarker from "../../Markers/Poi"
|
||||||
import ScandicMarker from "../../Markers/Scandic"
|
import ScandicMarker from "../../Markers/Scandic"
|
||||||
|
|
||||||
import styles from "./hotelMapContent.module.css"
|
import styles from "./poiMapMarkers.module.css"
|
||||||
|
|
||||||
import type { HotelMapContentProps } from "@/types/hotel"
|
import type { PoiMapMarkersProps } from "@/types/hotel"
|
||||||
|
|
||||||
export default function HotelMapContent({
|
export default function PoiMapMarkers({
|
||||||
coordinates,
|
coordinates,
|
||||||
pointsOfInterest,
|
pointsOfInterest,
|
||||||
onActivePoiChange,
|
onActivePoiChange,
|
||||||
activePoi,
|
activePoi,
|
||||||
}: HotelMapContentProps) {
|
}: PoiMapMarkersProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
function toggleActivePoi(poiName: string) {
|
function toggleActivePoi(poiName: string) {
|
||||||
@@ -9,7 +9,7 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
|||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
|
|
||||||
import HotelListingMapContent from "./HotelListingMapContent"
|
import HotelListingMapContent from "./HotelListingMapContent"
|
||||||
import HotelMapContent from "./HotelMapContent"
|
import PoiMapMarkers from "./PoiMapMarkers"
|
||||||
|
|
||||||
import styles from "./interactiveMap.module.css"
|
import styles from "./interactiveMap.module.css"
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ export default function InteractiveMap({
|
|||||||
disableDefaultUI: true,
|
disableDefaultUI: true,
|
||||||
clickableIcons: false,
|
clickableIcons: false,
|
||||||
mapId,
|
mapId,
|
||||||
|
gestureHandling: "greedy",
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomIn() {
|
function zoomIn() {
|
||||||
@@ -67,7 +68,7 @@ export default function InteractiveMap({
|
|||||||
<Map {...mapOptions} onTilesLoaded={onTilesLoaded}>
|
<Map {...mapOptions} onTilesLoaded={onTilesLoaded}>
|
||||||
{hotelPins && <HotelListingMapContent hotelPins={hotelPins} />}
|
{hotelPins && <HotelListingMapContent hotelPins={hotelPins} />}
|
||||||
{pointsOfInterest && (
|
{pointsOfInterest && (
|
||||||
<HotelMapContent
|
<PoiMapMarkers
|
||||||
coordinates={coordinates}
|
coordinates={coordinates}
|
||||||
pointsOfInterest={pointsOfInterest}
|
pointsOfInterest={pointsOfInterest}
|
||||||
onActivePoiChange={onActivePoiChange}
|
onActivePoiChange={onActivePoiChange}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
.zoomButtons {
|
.zoomButtons {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x1);
|
gap: var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton {
|
.closeButton {
|
||||||
@@ -55,8 +55,8 @@
|
|||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
.ctaButtons {
|
.ctaButtons {
|
||||||
top: var(--Spacing-x4);
|
top: var(--Spacing-x4);
|
||||||
right: var(--Spacing-x4);
|
right: var(--Spacing-x5);
|
||||||
bottom: var(--Spacing-x4);
|
bottom: var(--Spacing-x7);
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,15 +1,43 @@
|
|||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
|
|
||||||
interface HotelsMapState {
|
interface HotelsMapState {
|
||||||
activeHotelCard: string | null
|
activeHotel: string | null
|
||||||
activeHotelPin: string | null
|
hoveredHotel: string | null
|
||||||
setActiveHotelCard: (hotelCard: string | null) => void
|
hoverTimeout: number | null
|
||||||
setActiveHotelPin: (hotelPin: string | null) => void
|
activate: (hotel: string | null) => void
|
||||||
|
deactivate: () => void
|
||||||
|
engage: (hotel: string | null) => void
|
||||||
|
disengage: () => void
|
||||||
|
disengageAfterDelay: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useHotelsMapStore = create<HotelsMapState>((set) => ({
|
export const useHotelsMapStore = create<HotelsMapState>((set, get) => ({
|
||||||
activeHotelCard: null,
|
activeHotel: null,
|
||||||
activeHotelPin: null,
|
hoveredHotel: null,
|
||||||
setActiveHotelCard: (hotelCard) => set({ activeHotelCard: hotelCard }),
|
hoverTimeout: null,
|
||||||
setActiveHotelPin: (hotelPin) => set({ activeHotelPin: hotelPin }),
|
activate: (hotel) => set({ activeHotel: hotel }),
|
||||||
|
deactivate: () => set({ activeHotel: null }),
|
||||||
|
engage: (hotel) => {
|
||||||
|
const state = get()
|
||||||
|
|
||||||
|
if (state.hoverTimeout) {
|
||||||
|
window.clearTimeout(state.hoverTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hotel && state.activeHotel) {
|
||||||
|
set({ activeHotel: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ hoveredHotel: hotel })
|
||||||
|
},
|
||||||
|
disengage: () => {
|
||||||
|
set({ hoveredHotel: null })
|
||||||
|
},
|
||||||
|
disengageAfterDelay: () => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
set({ hoveredHotel: null, activeHotel: null, hoverTimeout: null })
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
set({ hoverTimeout: timeoutId })
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export type RestaurantOpeningHoursDay = z.output<
|
|||||||
>
|
>
|
||||||
export type Room = ReturnType<typeof transformRoomCategories>
|
export type Room = ReturnType<typeof transformRoomCategories>
|
||||||
|
|
||||||
export type HotelMapContentProps = {
|
export type PoiMapMarkersProps = {
|
||||||
activePoi?: string | null
|
activePoi?: string | null
|
||||||
coordinates: { lat: number; lng: number }
|
coordinates: { lat: number; lng: number }
|
||||||
onActivePoiChange?: (poiName: string | null) => void
|
onActivePoiChange?: (poiName: string | null) => void
|
||||||
|
|||||||
@@ -269,7 +269,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: block;
|
font-display: block;
|
||||||
src: url(/_static/fonts/material-symbols/rounded-ad9423f9.woff2)
|
src: url(/_static/fonts/material-symbols/rounded-112272ae.woff2)
|
||||||
format('woff2');
|
format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -45,6 +45,7 @@ const icons = [
|
|||||||
'box',
|
'box',
|
||||||
'business_center',
|
'business_center',
|
||||||
'calendar_add_on',
|
'calendar_add_on',
|
||||||
|
'calendar_clock',
|
||||||
'calendar_month',
|
'calendar_month',
|
||||||
'calendar_today',
|
'calendar_today',
|
||||||
'call',
|
'call',
|
||||||
|
|||||||
Reference in New Issue
Block a user