Merged in feat/sw-2873-move-selecthotel-to-booking-flow (pull request #2727)
feat(SW-2873): Move select-hotel to booking flow * crude setup of select-hotel in partner-sas * wip * Fix linting * restructure tracking files * Remove dependency on trpc in tracking hooks * Move pageview tracking to common * Fix some lint and import issues * Add AlternativeHotelsPage * Add SelectHotelMapPage * Add AlternativeHotelsMapPage * remove next dependency in tracking store * Remove dependency on react in tracking hooks * move isSameBooking to booking-flow * Inject searchParamsComparator into tracking store * Move useTrackHardNavigation to common * Move useTrackSoftNavigation to common * Add TrackingSDK to partner-sas * call serverclient in layout * Remove unused css * Update types * Move HotelPin type * Fix todos * Merge branch 'master' into feat/sw-2873-move-selecthotel-to-booking-flow * Merge branch 'master' into feat/sw-2873-move-selecthotel-to-booking-flow * Fix component Approved-by: Joakim Jäderberg
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
.hotelCardDialogListing {
|
||||
display: flex;
|
||||
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);
|
||||
}
|
||||
|
||||
.hotelCard {
|
||||
height: 100%;
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelFilterStore } from "../../stores/hotel-filters"
|
||||
import { useHotelsMapStore } from "../../stores/hotels-map"
|
||||
import ListingHotelCardDialog from "../ListingHotelCardDialog"
|
||||
import { getHotelPins } from "./utils"
|
||||
|
||||
import styles from "./hotelCardDialogListing.module.css"
|
||||
|
||||
import type { HotelResponse } from "../SelectHotel/helpers"
|
||||
|
||||
interface HotelCardDialogListingProps {
|
||||
hotels: HotelResponse[]
|
||||
unfilteredHotelCount: number
|
||||
}
|
||||
|
||||
export default function HotelCardDialogListing({
|
||||
hotels,
|
||||
unfilteredHotelCount,
|
||||
}: HotelCardDialogListingProps) {
|
||||
const intl = useIntl()
|
||||
const isRedemption = hotels?.find(
|
||||
(hotel) => hotel.availability.productType?.redemptions?.length
|
||||
)
|
||||
const currencyValue = isRedemption
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Points",
|
||||
})
|
||||
: undefined
|
||||
const hotelsPinData = getHotelPins(hotels, currencyValue)
|
||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const isScrollingRef = useRef<boolean>(false)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const { activeHotel, activate, deactivate } = useHotelsMapStore()
|
||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||
|
||||
const handleIntersection = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
// 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)
|
||||
},
|
||||
[activate, activeHotel]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(handleIntersection, {
|
||||
root: null,
|
||||
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) {
|
||||
isScrollingRef.current = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
activeCardRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
isScrollingRef.current = false
|
||||
}, 800)
|
||||
})
|
||||
}
|
||||
}, [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])
|
||||
|
||||
useEffect(() => {
|
||||
setResultCount(hotels.length, unfilteredHotelCount)
|
||||
}, [hotels, setResultCount, unfilteredHotelCount])
|
||||
|
||||
return (
|
||||
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
|
||||
{hotelsPinData?.map((data) => {
|
||||
const isActive = data.name === activeHotel
|
||||
return (
|
||||
<div
|
||||
key={data.name}
|
||||
ref={isActive ? activeCardRef : null}
|
||||
data-name={data.name}
|
||||
className={styles.hotelCard}
|
||||
>
|
||||
<ListingHotelCardDialog data={data} handleClose={deactivate} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { imageSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/image"
|
||||
import type { ProductTypeCheque } from "@scandic-hotels/trpc/types/availability"
|
||||
import type { Amenities } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { z } from "zod"
|
||||
|
||||
import type { HotelResponse } from "../SelectHotel/helpers"
|
||||
|
||||
type ImageSizes = z.infer<typeof imageSchema>["imageSizes"]
|
||||
type ImageMetaData = z.infer<typeof imageSchema>["metaData"]
|
||||
|
||||
interface Coordinates {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
export type HotelPin = {
|
||||
bookingCode?: string | null
|
||||
name: string
|
||||
coordinates: Coordinates
|
||||
chequePrice: ProductTypeCheque["localPrice"] | null
|
||||
publicPrice: number | null
|
||||
memberPrice: number | null
|
||||
redemptionPrice: number | null
|
||||
voucherPrice: number | null
|
||||
rateType: string | null
|
||||
currency: string
|
||||
images: {
|
||||
imageSizes: ImageSizes
|
||||
metaData: ImageMetaData
|
||||
}[]
|
||||
amenities: Amenities
|
||||
ratings: number | null
|
||||
operaId: string
|
||||
facilityIds: number[]
|
||||
hasEnoughPoints: boolean
|
||||
}
|
||||
|
||||
export function getHotelPins(
|
||||
hotels: HotelResponse[],
|
||||
currencyValue?: string
|
||||
): HotelPin[] {
|
||||
if (!hotels.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return hotels.map(({ availability, hotel, additionalData }) => {
|
||||
const productType = availability.productType
|
||||
const redemptionRate = productType?.redemptions?.find(
|
||||
(r) => r?.localPrice.pointsPerStay
|
||||
)
|
||||
const chequePrice = productType?.bonusCheque?.localPrice
|
||||
const voucherPrice = productType?.voucher?.numberOfVouchers
|
||||
if (chequePrice || voucherPrice) {
|
||||
currencyValue = chequePrice ? "CC" : "Voucher"
|
||||
}
|
||||
return {
|
||||
bookingCode: availability.bookingCode,
|
||||
coordinates: {
|
||||
lat: hotel.location.latitude,
|
||||
lng: hotel.location.longitude,
|
||||
},
|
||||
name: hotel.name,
|
||||
chequePrice: chequePrice ?? null,
|
||||
publicPrice: productType?.public?.localPrice.pricePerNight ?? null,
|
||||
memberPrice: productType?.member?.localPrice.pricePerNight ?? null,
|
||||
redemptionPrice: redemptionRate?.localPrice.pointsPerStay ?? null,
|
||||
voucherPrice: voucherPrice ?? null,
|
||||
rateType:
|
||||
productType?.public?.rateType ?? productType?.member?.rateType ?? null,
|
||||
currency:
|
||||
productType?.public?.localPrice.currency ||
|
||||
productType?.member?.localPrice.currency ||
|
||||
currencyValue ||
|
||||
"N/A",
|
||||
images: [
|
||||
hotel.hotelContent.images,
|
||||
...(additionalData.gallery?.heroImages ?? []),
|
||||
],
|
||||
amenities: hotel.detailedFacilities
|
||||
.map((facility) => ({
|
||||
...facility,
|
||||
icon: facility.icon ?? "None",
|
||||
}))
|
||||
.slice(0, 5),
|
||||
ratings: hotel.ratings?.tripAdvisor.rating ?? null,
|
||||
operaId: hotel.operaId,
|
||||
facilityIds: hotel.detailedFacilities.map((facility) => facility.id),
|
||||
hasEnoughPoints: !!availability.productType?.redemptions?.some(
|
||||
(r) => r.hasEnoughPoints
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user