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:
Anton Gunnarsson
2025-09-01 08:37:00 +00:00
parent 93a90bef9d
commit 87402a2092
157 changed files with 2026 additions and 1376 deletions

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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
),
}
})
}