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 { useRouter, useSearchParams } from "next/navigation"
|
||||
import { memo, useCallback } from "react"
|
||||
import { memo } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
|
||||
@@ -53,16 +53,7 @@ function HotelCard({
|
||||
|
||||
const lang = params.lang as Lang
|
||||
const intl = useIntl()
|
||||
const { setActiveHotelPin, setActiveHotelCard } = useHotelsMapStore()
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setActiveHotelPin(hotel.name)
|
||||
}, [setActiveHotelPin, hotel])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setActiveHotelPin(null)
|
||||
setActiveHotelCard(null)
|
||||
}, [setActiveHotelPin, setActiveHotelCard])
|
||||
const { activate, engage, disengageAfterDelay } = useHotelsMapStore()
|
||||
|
||||
const amenities = hotel.detailedFacilities.slice(0, 5)
|
||||
const router = useRouter()
|
||||
@@ -73,8 +64,7 @@ function HotelCard({
|
||||
|
||||
const handleAddressClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setActiveHotelPin(hotel.name)
|
||||
setActiveHotelCard(hotel.name)
|
||||
activate(hotel.name)
|
||||
router.push(`${selectHotelMap(lang)}?${searchParams.toString()}`)
|
||||
}
|
||||
|
||||
@@ -95,8 +85,8 @@ function HotelCard({
|
||||
return (
|
||||
<article
|
||||
className={classNames}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseEnter={() => engage(hotel.name)}
|
||||
onMouseLeave={() => disengageAfterDelay()}
|
||||
>
|
||||
<div>
|
||||
<div className={styles.imageContainer}>
|
||||
|
||||
@@ -62,28 +62,20 @@ export default function ListingHotelCardDialog({
|
||||
setImageError={setImageError}
|
||||
position="top"
|
||||
/>
|
||||
<div className={styles.detailsContainer}>
|
||||
<div>
|
||||
<div className={styles.name}>
|
||||
<Subtitle type="two">{name}</Subtitle>
|
||||
</div>
|
||||
<div className={styles.facilities}>
|
||||
{amenities.map((facility) => {
|
||||
const Icon = (
|
||||
{amenities.map((facility) => (
|
||||
<div className={styles.facilitiesItem} key={facility.id}>
|
||||
<FacilityToIcon
|
||||
id={facility.id}
|
||||
size={20}
|
||||
color="Icon/Default"
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div className={styles.facilitiesItem} key={facility.id}>
|
||||
{Icon && Icon}
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{facility.name}
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +107,7 @@ export default function ListingHotelCardDialog({
|
||||
</>
|
||||
)}
|
||||
{memberPrice && (
|
||||
<Subtitle type="two" color="red" className={styles.memberPrice}>
|
||||
<Subtitle type="two" color="red">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{price} {currency}",
|
||||
|
||||
@@ -65,10 +65,8 @@ export default function StandaloneHotelCardDialog({
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.detailsContainer}>
|
||||
<div className={styles.name}>
|
||||
<Body textTransform="bold">{name}</Body>
|
||||
</div>
|
||||
<div className={styles.name}>
|
||||
<Body textTransform="bold">{name}</Body>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.facilities}>
|
||||
@@ -126,11 +124,7 @@ export default function StandaloneHotelCardDialog({
|
||||
</Subtitle>
|
||||
)}
|
||||
{memberPrice && (
|
||||
<Subtitle
|
||||
type="two"
|
||||
color="red"
|
||||
className={styles.memberPrice}
|
||||
>
|
||||
<Subtitle type="two" color="red">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{price} {currency}",
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
padding-right: 20px;
|
||||
gap: 0 var(--Spacing-x1);
|
||||
gap: 0 var(--Spacing-x-one-and-half);
|
||||
max-width: 242px;
|
||||
}
|
||||
.dialogContainer[data-type="listing"] .facilities::after {
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function HotelCardDialog({
|
||||
<dialog open={isOpen} className={styles.dialog}>
|
||||
<div className={styles.dialogContainer} data-type={type}>
|
||||
<div onClick={handleClose}>
|
||||
<MaterialIcon icon="close" className={styles.closeIcon} size={16} />
|
||||
<MaterialIcon icon="close" className={styles.closeIcon} size={22} />
|
||||
</div>
|
||||
|
||||
{type === "standalone" ? (
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
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 {
|
||||
@@ -10,11 +27,3 @@
|
||||
padding: 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 { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { useHotelsMapStore } from "@/stores/hotels-map"
|
||||
|
||||
import useClickOutside from "@/hooks/useClickOutside"
|
||||
|
||||
import HotelCardDialog from "../HotelCardDialog"
|
||||
import { getHotelPins } from "./utils"
|
||||
|
||||
@@ -27,89 +24,119 @@ export default function HotelCardDialogListing({
|
||||
defaultMessage: "Points",
|
||||
})
|
||||
: undefined
|
||||
const hotelsPinData = hotels ? getHotelPins(hotels, currencyValue) : []
|
||||
const hotelsPinData = getHotelPins(hotels, currencyValue)
|
||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||
const { activeHotelCard, setActiveHotelCard, setActiveHotelPin } =
|
||||
useHotelsMapStore()
|
||||
|
||||
function handleClose() {
|
||||
setActiveHotelCard(null)
|
||||
setActiveHotelPin(null)
|
||||
}
|
||||
|
||||
useClickOutside(dialogRef, !!activeHotelCard && isMobile, handleClose)
|
||||
const isScrollingRef = useRef<boolean>(false)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const { activeHotel, activate, deactivate } = useHotelsMapStore()
|
||||
|
||||
const handleIntersection = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const cardName = entry.target.getAttribute("data-name")
|
||||
if (cardName) {
|
||||
setActiveHotelCard(cardName)
|
||||
// skip intersection handling during scrolling
|
||||
if (isScrollingRef.current) return
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
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(() => {
|
||||
observerRef.current = new IntersectionObserver(handleIntersection, {
|
||||
root: null,
|
||||
threshold: 0.5,
|
||||
threshold: [0.3, 0.5, 0.7],
|
||||
})
|
||||
|
||||
const elements = document.querySelectorAll("[data-name]")
|
||||
elements.forEach((el) => observerRef.current?.observe(el))
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
elements.forEach((el) => observerRef.current?.unobserve(el))
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
}
|
||||
}, [handleIntersection])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCardRef.current) {
|
||||
// Temporarily disconnect the observer
|
||||
observerRef.current?.disconnect()
|
||||
isScrollingRef.current = true
|
||||
|
||||
activeCardRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
requestAnimationFrame(() => {
|
||||
activeCardRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
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 (
|
||||
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
|
||||
{!!hotelsPinData?.length &&
|
||||
hotelsPinData.map((data) => {
|
||||
const isActive = data.name === activeHotelCard
|
||||
return (
|
||||
<div
|
||||
key={data.name}
|
||||
ref={isActive ? activeCardRef : null}
|
||||
data-name={data.name}
|
||||
>
|
||||
<HotelCardDialog
|
||||
data={data}
|
||||
isOpen={!!activeHotelCard}
|
||||
handleClose={handleClose}
|
||||
type="listing"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{hotelsPinData?.map((data) => {
|
||||
const isActive = data.name === activeHotel
|
||||
return (
|
||||
<div
|
||||
key={data.name}
|
||||
ref={isActive ? activeCardRef : null}
|
||||
data-name={data.name}
|
||||
>
|
||||
<HotelCardDialog
|
||||
data={data}
|
||||
isOpen={!!activeHotel}
|
||||
handleClose={deactivate}
|
||||
type="listing"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function HotelCardListing({
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||
const intl = useIntl()
|
||||
const { activeHotelCard } = useHotelsMapStore()
|
||||
const { activeHotel } = useHotelsMapStore()
|
||||
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
|
||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function HotelCardListing({
|
||||
inline: "center",
|
||||
})
|
||||
}
|
||||
}, [activeHotelCard, type])
|
||||
}, [activeHotel, type])
|
||||
|
||||
useEffect(() => {
|
||||
setResultCount(hotels.length)
|
||||
@@ -116,8 +116,7 @@ export default function HotelCardListing({
|
||||
|
||||
function isHotelActiveInMapView(hotelName: string): boolean {
|
||||
return (
|
||||
hotelName === activeHotelCard &&
|
||||
type === HotelCardListingTypeEnum.MapListing
|
||||
hotelName === activeHotel && type === HotelCardListingTypeEnum.MapListing
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,22 +13,19 @@ import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/se
|
||||
import type { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function HotelListing({ hotels }: HotelListingProps) {
|
||||
const { activeHotelPin } = useHotelsMapStore()
|
||||
const { activeHotel } = useHotelsMapStore()
|
||||
const isMobile = useMediaQuery("(max-width: 767px)")
|
||||
return (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<div className={styles.hotelListingMobile} data-open={!!activeHotelPin}>
|
||||
<HotelCardDialogListing hotels={hotels} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hotelListing}>
|
||||
<HotelCardListing
|
||||
hotelData={hotels}
|
||||
type={HotelCardListingTypeEnum.MapListing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
return isMobile ? (
|
||||
<div className={styles.hotelListingMobile} data-open={!!activeHotel}>
|
||||
<HotelCardDialogListing hotels={hotels} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hotelListing}>
|
||||
<HotelCardListing
|
||||
hotelData={hotels}
|
||||
type={HotelCardListingTypeEnum.MapListing}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function SelectHotelContent({
|
||||
const listingContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const { activeHotelPin } = useHotelsMapStore()
|
||||
const { activeHotel } = useHotelsMapStore()
|
||||
|
||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||
threshold: 490,
|
||||
@@ -65,8 +65,8 @@ export default function SelectHotelContent({
|
||||
)
|
||||
|
||||
const coordinates = useMemo(() => {
|
||||
if (activeHotelPin) {
|
||||
const hotel = hotels.find((hotel) => hotel.hotel.name === activeHotelPin)
|
||||
if (activeHotel) {
|
||||
const hotel = hotels.find((hotel) => hotel.hotel.name === activeHotel)
|
||||
|
||||
if (hotel && hotel.hotel.location) {
|
||||
return {
|
||||
@@ -79,7 +79,7 @@ export default function SelectHotelContent({
|
||||
return isAboveMobile
|
||||
? cityCoordinates
|
||||
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
|
||||
}, [activeHotelPin, hotels, isAboveMobile, cityCoordinates])
|
||||
}, [activeHotel, hotels, isAboveMobile, cityCoordinates])
|
||||
|
||||
const filteredHotelPins = useMemo(() => {
|
||||
const updatedHotelsList = bookingCode
|
||||
@@ -159,14 +159,15 @@ export default function SelectHotelContent({
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
type="button"
|
||||
variant="icon"
|
||||
wrapping
|
||||
size="small"
|
||||
className={styles.filterContainerCloseButton}
|
||||
asChild
|
||||
>
|
||||
<Link href={selectHotel(lang)} keepSearchParams>
|
||||
<MaterialIcon icon="close" color="CurrentColor" />
|
||||
<MaterialIcon icon="arrow_back" color="CurrentColor" size={20} />
|
||||
{intl.formatMessage({ id: "Back" })}
|
||||
</Link>
|
||||
</Button>
|
||||
<FilterAndSortModal
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filterContainerCloseButton {
|
||||
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container .closeButton {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user