feat: add multiroom tracking to booking flow

This commit is contained in:
Simon Emanuelsson
2025-03-05 11:53:05 +01:00
parent 540402b969
commit 1812591903
72 changed files with 2277 additions and 1308 deletions

View File

@@ -21,7 +21,7 @@ export default function HotelCardCarousel({
const { clickedHotel } = useDestinationPageHotelsMapStore()
const selectedHotelIdx = visibleHotels.findIndex(
(hotel) => hotel.hotel.operaId === clickedHotel
({ hotel }) => hotel.operaId === clickedHotel
)
return (

View File

@@ -0,0 +1,40 @@
"use client"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import TrackingSDK from "@/components/TrackingSDK"
import useLang from "@/hooks/useLang"
import { getTracking } from "./tracking"
import type { Room } from "@/types/stores/booking-confirmation"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export default function Tracking({
bookingConfirmation,
}: {
bookingConfirmation: BookingConfirmation
}) {
const lang = useLang()
const bookingRooms = useBookingConfirmationStore((state) => state.rooms)
if (!bookingRooms.every(Boolean)) {
return null
}
const rooms = bookingRooms.filter((room): room is Room => !!room)
const { hotelsTrackingData, pageTrackingData, paymentInfo } = getTracking(
lang,
bookingConfirmation.booking,
bookingConfirmation.hotel,
rooms
)
return (
<TrackingSDK
pageData={pageTrackingData}
hotelInfo={hotelsTrackingData}
paymentInfo={paymentInfo}
/>
)
}

View File

@@ -0,0 +1,157 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { getSpecialRoomType } from "@/utils/specialRoomType"
import { invertedBedTypeMap } from "../../utils"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
type TrackingSDKPaymentInfo,
} from "@/types/components/tracking"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import type { Room } from "@/types/stores/booking-confirmation"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { RateDefinition } from "@/types/trpc/routers/hotel/roomAvailability"
import type { Lang } from "@/constants/languages"
function getRate(cancellationRule: RateDefinition["cancellationRule"] | null) {
switch (cancellationRule) {
case "CancellableBefore6PM":
return "flex"
case "Changeable":
return "change"
case "NotCancellable":
return "save"
default:
return "-"
}
}
function findBreakfastPackage(
packages: BookingConfirmation["booking"]["packages"]
) {
return packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
}
function mapBreakfastPackage(
breakfastPackage: BookingConfirmation["booking"]["packages"][number],
adults: number,
operaId: string
) {
return {
hotelid: operaId,
productCategory: "", // TODO: Add category
productId: breakfastPackage.code!, // Is not found unless code exists
productName: "BreakfastAdult",
productPoints: 0,
productPrice: +breakfastPackage.unitPrice,
productType: "food",
productUnits: adults,
}
}
export function getTracking(
lang: Lang,
booking: BookingConfirmation["booking"],
hotel: BookingConfirmation["hotel"],
rooms: Room[]
) {
const arrivalDate = new Date(booking.checkInDate)
const departureDate = new Date(booking.checkOutDate)
const pageTrackingData: TrackingSDKPageData = {
channel: TrackingChannelEnum.hotelreservation,
domainLanguage: lang,
pageId: "booking-confirmation",
pageName: `hotelreservation|confirmation`,
pageType: "confirmation",
siteSections: `hotelreservation|confirmation`,
siteVersion: "new-web",
}
const noOfAdults = rooms.map((r) => r.adults).join(",")
const noOfChildren = rooms.map((r) => r.children ?? 0).join(",")
const noOfRooms = rooms.length
const hotelsTrackingData: TrackingSDKHotelInfo = {
ageOfChildren: rooms.map((r) => r.childrenAges?.join(",") ?? "-").join("|"),
analyticsRateCode: rooms
.map((r) => getRate(r.rateDefinition.cancellationRule))
.join("|"),
ancillaries: rooms
.filter((r) => findBreakfastPackage(r.packages))
.map((r) => {
return mapBreakfastPackage(
findBreakfastPackage(r.packages)!,
r.adults,
hotel.operaId
)
}),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
bedType: rooms
.map((r) => r.bedDescription)
.join(",")
.toLowerCase(),
bnr: rooms.map((r) => r.confirmationNumber).join(","),
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
breakfastOption: rooms
.map((r) => {
if (r.breakfastIncluded || r.breakfast) {
return "breakfast buffet"
}
return "no breakfast"
})
.join(","),
childBedPreference: rooms
.map(
(r) =>
r.childBedPreferences
.map((cbp) =>
Array(cbp.quantity).fill(invertedBedTypeMap[cbp.bedType])
)
.join(",") ?? "-"
)
.join("|"),
country: hotel?.address.country,
departureDate: format(departureDate, "yyyy-MM-dd"),
duration: differenceInCalendarDays(departureDate, arrivalDate),
hotelID: hotel.operaId,
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
noOfAdults,
noOfChildren,
noOfRooms,
rateCode: rooms.map((r) => r.rateDefinition.rateCode).join(","),
rateCodeCancellationRule: rooms
.map((r) => r.rateDefinition.cancellationText)
.join(",")
.toLowerCase(),
rateCodeName: rooms
.map((r) => r.rateDefinition.title)
.join(",")
.toLowerCase(),
//rateCodeType: , //TODO: Add when available in API. "regular, promotion, corporate etx",
region: hotel?.address.city,
revenueCurrencyCode: rooms.map((r) => r.currencyCode).join(","),
roomPrice: rooms.map((r) => r.roomPrice).join(","),
roomTypeCode: rooms.map((r) => r.roomTypeCode ?? "-").join(","),
searchType: "hotel",
specialRoomType: rooms
.map((room) => getSpecialRoomType(room.packages))
.join(","),
totalPrice: rooms.map((r) => r.totalPrice).join(","),
}
const paymentInfo: TrackingSDKPaymentInfo = {
paymentStatus: "confirmed",
}
return {
hotelsTrackingData,
pageTrackingData,
paymentInfo,
}
}

View File

@@ -1,4 +1,3 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
@@ -10,30 +9,20 @@ import Receipt from "@/components/HotelReservation/BookingConfirmation/Receipt"
import Rooms from "@/components/HotelReservation/BookingConfirmation/Rooms"
import SidePanel from "@/components/HotelReservation/SidePanel"
import Divider from "@/components/TempDesignSystem/Divider"
import TrackingSDK from "@/components/TrackingSDK"
import { getLang } from "@/i18n/serverContext"
import BookingConfirmationProvider from "@/providers/BookingConfirmationProvider"
import { invertedBedTypeMap } from "../utils"
import Alerts from "./Alerts"
import Confirmation from "./Confirmation"
import Tracking from "./Tracking"
import { mapRoomState } from "./utils"
import styles from "./bookingConfirmation.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
type TrackingSDKPaymentInfo,
} from "@/types/components/tracking"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default async function BookingConfirmation({
confirmationNumber,
}: BookingConfirmationProps) {
const lang = getLang()
const bookingConfirmation = await getBookingConfirmation(confirmationNumber)
if (!bookingConfirmation) {
@@ -44,74 +33,6 @@ export default async function BookingConfirmation({
return notFound()
}
const arrivalDate = new Date(booking.checkInDate)
const departureDate = new Date(booking.checkOutDate)
const selectedBreakfast = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
const breakfastAncillary = selectedBreakfast && {
hotelid: hotel.operaId,
productName: "BreakfastAdult",
productCategory: "", // TODO: Add category
productId: selectedBreakfast.code ?? "",
productPrice: +selectedBreakfast.unitPrice,
productUnits: booking.adults,
productPoints: 0,
productType: "food",
}
const initialPageTrackingData: TrackingSDKPageData = {
pageId: "booking-confirmation",
domainLanguage: lang,
channel: TrackingChannelEnum["hotelreservation"],
pageName: `hotelreservation|confirmation`,
siteSections: `hotelreservation|confirmation`,
pageType: "confirmation",
siteVersion: "new-web",
}
const initialHotelsTrackingData: TrackingSDKHotelInfo = {
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: booking.adults,
noOfChildren: booking.childrenAges?.length,
ageOfChildren: booking.childrenAges?.join(","),
childBedPreference: booking?.childBedPreferences
?.flatMap((c) => Array(c.quantity).fill(invertedBedTypeMap[c.bedType]))
.join("|"),
noOfRooms: 1, // // TODO: Handle multiple rooms
duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
searchType: "hotel",
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
country: hotel?.address.country,
hotelID: hotel.operaId,
region: hotel?.address.city,
rateCode: booking.rateDefinition.rateCode ?? undefined,
//rateCodeType: , //TODO: Add when available in API. "regular, promotion, corporate etx",
rateCodeName: booking.rateDefinition.title ?? undefined,
rateCodeCancellationRule:
booking.rateDefinition?.cancellationText ?? undefined,
revenueCurrencyCode: booking.currencyCode,
breakfastOption: booking.rateDefinition.breakfastIncluded
? "breakfast buffet"
: "no breakfast",
totalPrice: booking.totalPrice,
//specialRoomType: getSpecialRoomType(booking.packages), TODO: Add
//roomTypeName: booking.roomTypeCode ?? undefined, TODO: Do we get the name?
bedType: room?.bedType.name,
roomTypeCode: booking.roomTypeCode ?? undefined,
roomPrice: booking.roomPrice,
bnr: booking.confirmationNumber ?? undefined,
ancillaries: breakfastAncillary ? [breakfastAncillary] : [],
}
const paymentInfo: TrackingSDKPaymentInfo = {
paymentStatus: "confirmed",
}
return (
<BookingConfirmationProvider
bookingCode={booking.bookingCode}
@@ -151,11 +72,7 @@ export default async function BookingConfirmation({
</SidePanel>
</aside>
</Confirmation>
<TrackingSDK
pageData={initialPageTrackingData}
hotelInfo={initialHotelsTrackingData}
paymentInfo={paymentInfo}
/>
<Tracking bookingConfirmation={bookingConfirmation} />
</BookingConfirmationProvider>
)
}

View File

@@ -19,13 +19,17 @@ export function mapRoomState(
breakfast,
breakfastIncluded,
children: booking.childrenAges.length,
childrenAges: booking.childrenAges,
childBedPreferences: booking.childBedPreferences,
confirmationNumber: booking.confirmationNumber,
currencyCode: booking.currencyCode,
fromDate: booking.checkInDate,
name: room.name,
packages: booking.packages,
rateDefinition: booking.rateDefinition,
roomFeatures: booking.packages.filter((p) => p.type === "RoomFeature"),
roomPrice: booking.roomPrice,
roomTypeCode: booking.roomTypeCode,
toDate: booking.checkOutDate,
totalPrice: booking.totalPrice,
totalPriceExVat: booking.totalPriceExVat,

View File

@@ -55,15 +55,19 @@ export default function Details({ user }: DetailsProps) {
reValidateMode: "onChange",
values: {
countryCode: user?.address?.countryCode ?? initialData.countryCode,
dateOfBirth: initialData.dateOfBirth,
dateOfBirth:
"dateOfBirth" in initialData ? initialData.dateOfBirth : undefined,
email: user?.email ?? initialData.email,
firstName: user?.firstName ?? initialData.firstName,
join: initialData.join,
lastName: user?.lastName ?? initialData.lastName,
membershipNo: initialData.membershipNo,
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
zipCode: initialData.zipCode,
specialRequests: initialData.specialRequests,
zipCode: "zipCode" in initialData ? initialData.zipCode : undefined,
specialRequests:
"specialRequests" in initialData
? initialData.specialRequests
: undefined,
},
})

View File

@@ -255,24 +255,24 @@ export default function PaymentClient({
const guarantee = data.guarantee
const useSavedCard = savedCreditCard
? {
card: {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
},
}
card: {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
},
}
: {}
const shouldUsePayment = !isFlexRate || guarantee
const payment = shouldUsePayment
? {
paymentMethod: paymentMethod,
...useSavedCard,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
}
paymentMethod: paymentMethod,
...useSavedCard,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
}
: undefined
trackPaymentEvent({
@@ -285,56 +285,65 @@ export default function PaymentClient({
})
initiateBooking.mutate({
language: lang,
hotelId,
checkInDate: fromDate,
checkOutDate: toDate,
hotelId,
language: lang,
payment,
rooms: rooms.map(({ room }, idx) => ({
adults: room.adults,
childrenAges: room.childrenInRoom?.map((child) => ({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
rateCode:
(room.guest.join || room.guest.membershipNo) &&
booking.rooms[idx].counterRateCode
? booking.rooms[idx].counterRateCode
: booking.rooms[idx].rateCode,
roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: {
becomeMember: room.guest.join,
countryCode: room.guest.countryCode,
dateOfBirth: room.guest.dateOfBirth,
email: room.guest.email,
firstName: room.guest.firstName,
lastName: room.guest.lastName,
membershipNumber: room.guest.membershipNo,
phoneNumber: room.guest.phoneNumber,
postalCode: room.guest.zipCode,
// Only allowed for room one
...(idx === 0 && {
dateOfBirth:
"dateOfBirth" in room.guest && room.guest.dateOfBirth
? room.guest.dateOfBirth
: undefined,
postalCode:
"zipCode" in room.guest && room.guest.zipCode
? room.guest.zipCode
: undefined,
}),
},
packages: {
breakfast: !!(room.breakfast && room.breakfast.code),
allergyFriendly:
room.roomFeatures?.some(
(feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM
) ?? false,
petFriendly:
room.roomFeatures?.some(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
) ?? false,
accessibility:
room.roomFeatures?.some(
(feature) =>
feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
) ?? false,
allergyFriendly:
room.roomFeatures?.some(
(feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM
) ?? false,
breakfast: !!(room.breakfast && room.breakfast.code),
petFriendly:
room.roomFeatures?.some(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
) ?? false,
},
smsConfirmationRequested: data.smsConfirmation,
rateCode:
(room.guest.join || room.guest.membershipNo) &&
booking.rooms[idx].counterRateCode
? booking.rooms[idx].counterRateCode
: booking.rooms[idx].rateCode,
roomPrice: {
memberPrice: room.roomRate.memberRate?.localPrice.pricePerStay,
publicPrice: room.roomRate.publicRate?.localPrice.pricePerStay,
},
roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step.
smsConfirmationRequested: data.smsConfirmation,
})),
payment,
})
},
[
@@ -436,7 +445,7 @@ export default function PaymentClient({
value={paymentMethod}
label={
PAYMENT_METHOD_TITLES[
paymentMethod as PaymentMethodEnum
paymentMethod as PaymentMethodEnum
]
}
/>

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, Fragment } from "react"
import { Fragment, useState } from "react"
import {
Dialog,
DialogTrigger,

View File

@@ -15,7 +15,8 @@ import { calculateTotalRoomPrice } from "../Payment/helpers"
import PriceChangeSummary from "./PriceChangeSummary"
import styles from "./priceChangeDialog.module.css"
import { PriceChangeData } from "@/types/components/hotelReservation/enterDetails/payment"
import type { PriceChangeData } from "@/types/components/hotelReservation/enterDetails/payment"
type PriceDetailsState = {
newTotalPrice: number

View File

@@ -13,12 +13,8 @@ import { formatPrice } from "@/utils/numberFormatting"
import styles from "./priceDetailsTable.module.css"
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { RoomPrice } from "@/types/components/hotelReservation/enterDetails/details"
import type { Price } from "@/types/components/hotelReservation/price"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Packages } from "@/types/requests/packages"
import type { RoomState } from "@/types/stores/enter-details"
function Row({
label,
@@ -62,30 +58,37 @@ function TableSectionHeader({
)
}
interface PriceDetailsTableProps {
export type Room = Pick<
RoomState["room"],
| "adults"
| "bedType"
| "breakfast"
| "childrenInRoom"
| "roomFeatures"
| "roomRate"
| "roomType"
> & {
guest?: RoomState["room"]["guest"]
}
export interface PriceDetailsTableProps {
bookingCode?: string
fromDate: string
isMember: boolean
rooms: Room[]
toDate: string
rooms: {
adults: number
childrenInRoom: Child[] | undefined
roomType: string
roomPrice: RoomPrice
bedType?: BedTypeSchema
breakfast?: BreakfastPackage | false
roomFeatures?: Packages | null
}[]
totalPrice: Price
vat: number
bookingCode?: string
}
export default function PriceDetailsTable({
bookingCode,
fromDate,
toDate,
isMember,
rooms,
toDate,
totalPrice,
vat,
bookingCode,
}: PriceDetailsTableProps) {
const intl = useIntl()
const lang = useLang()
@@ -105,112 +108,116 @@ export default function PriceDetailsTable({
${dt(toDate).locale(lang).format("ddd, D MMM")} (${nights})`
return (
<table className={styles.priceDetailsTable}>
{rooms.map((room, idx) => (
<Fragment key={idx}>
<TableSection>
{rooms.length > 1 && (
<Body textTransform="bold">
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{
roomIndex: idx + 1,
}
)}
</Body>
)}
<TableSectionHeader title={room.roomType} subtitle={duration} />
<Row
label={intl.formatMessage({ id: "Average price per night" })}
value={formatPrice(
intl,
room.roomPrice.perNight.local.price,
room.roomPrice.perNight.local.currency
{rooms.map((room, idx) => {
const getMemberRate =
room.guest?.join ||
room.guest?.membershipNo ||
(idx === 0 && isMember)
const price =
getMemberRate && room.roomRate.memberRate
? room.roomRate.memberRate
: room.roomRate.publicRate
if (!price) {
return null
}
return (
<Fragment key={idx}>
<TableSection>
{rooms.length > 1 && (
<Body textTransform="bold">
{intl.formatMessage({ id: "Room" })} {idx + 1}
</Body>
)}
/>
{room.roomFeatures
? room.roomFeatures.map((feature) => (
<TableSectionHeader title={room.roomType} subtitle={duration} />
<Row
label={intl.formatMessage({ id: "Average price per night" })}
value={formatPrice(
intl,
price.localPrice.pricePerNight,
price.localPrice.currency
)}
/>
{room.roomFeatures
? room.roomFeatures.map((feature) => (
<Row
key={feature.code}
label={feature.description}
value={formatPrice(
intl,
parseInt(feature.localPrice.price),
+feature.localPrice.totalPrice,
feature.localPrice.currency
)}
/>
))
: null}
{room.bedType ? (
<Row
label={room.bedType.description}
value={formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
/>
) : null}
<Row
bold
label={intl.formatMessage({ id: "Room charge" })}
value={formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
/>
</TableSection>
{room.breakfast ? (
<TableSection>
<Row
label={intl.formatMessage(
{
id: "Breakfast ({totalAdults, plural, one {# adult} other {# adults}}) x {totalBreakfasts}",
},
{ totalAdults: room.adults, totalBreakfasts: diff }
)}
value={formatPrice(
intl,
parseInt(room.breakfast.localPrice.price) * room.adults,
room.breakfast.localPrice.currency
)}
/>
{room.childrenInRoom?.length ? (
: null}
{room.bedType ? (
<Row
label={intl.formatMessage(
{
id: "Breakfast ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}",
},
{
totalChildren: room.childrenInRoom.length,
totalBreakfasts: diff,
}
)}
value={formatPrice(
intl,
0,
room.breakfast.localPrice.currency
)}
label={room.bedType.description}
value={formatPrice(intl, 0, price.localPrice.currency)}
/>
) : null}
<Row
bold
label={intl.formatMessage({
id: "Breakfast charge",
})}
label={intl.formatMessage({ id: "Room charge" })}
value={formatPrice(
intl,
parseInt(room.breakfast.localPrice.totalPrice) *
room.adults *
diff,
room.breakfast.localPrice.currency
price.localPrice.pricePerStay,
price.localPrice.currency
)}
/>
</TableSection>
) : null}
</Fragment>
))}
{room.breakfast ? (
<TableSection>
<Row
label={intl.formatMessage(
{
id: "Breakfast ({totalAdults, plural, one {# adult} other {# adults}}) x {totalBreakfasts}",
},
{ totalAdults: room.adults, totalBreakfasts: diff }
)}
value={formatPrice(
intl,
parseInt(room.breakfast.localPrice.price) * room.adults,
room.breakfast.localPrice.currency
)}
/>
{room.childrenInRoom?.length ? (
<Row
label={intl.formatMessage(
{
id: "Breakfast ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}",
},
{
totalChildren: room.childrenInRoom.length,
totalBreakfasts: diff,
}
)}
value={formatPrice(
intl,
0,
room.breakfast.localPrice.currency
)}
/>
) : null}
<Row
bold
label={intl.formatMessage({
id: "Breakfast charge",
})}
value={formatPrice(
intl,
parseInt(room.breakfast.localPrice.price) *
room.adults *
diff,
room.breakfast.localPrice.currency
)}
/>
</TableSection>
) : null}
</Fragment>
)
})}
<TableSection>
<TableSectionHeader title={intl.formatMessage({ id: "Total" })} />
<Row
@@ -261,6 +268,6 @@ export default function PriceDetailsTable({
</tr>
)}
</TableSection>
</table>
</table >
)
}

View File

@@ -22,6 +22,8 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import PriceDetailsTable from "./PriceDetailsTable"
import styles from "./ui.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
@@ -62,15 +64,14 @@ export default function SummaryUI({
: null
}
const roomOneGuest = rooms[0].room.guest
const showSignupPromo =
rooms.length === 1 &&
rooms
.slice(0, 1)
.some(
(r) => !isMember || !r.room.guest.join || !r.room.guest.membershipNo
)
!isMember &&
!roomOneGuest.membershipNo &&
!roomOneGuest.join
const memberPrice = getMemberPrice(rooms[0].room.roomRate)
const roomOneMemberPrice = getMemberPrice(rooms[0].room.roomRate)
return (
<section className={styles.summary}>
@@ -120,9 +121,12 @@ export default function SummaryUI({
const memberPrice = getMemberPrice(room.roomRate)
const isFirstRoomMember = roomNumber === 1 && isMember
const showMemberPrice =
!!(isFirstRoomMember || room.guest.join || room.guest.membershipNo) &&
memberPrice
const isOrWillBecomeMember = !!(
room.guest.join ||
room.guest.membershipNo ||
isFirstRoomMember
)
const showMemberPrice = !!(isOrWillBecomeMember && memberPrice)
const adultsMsg = intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
@@ -160,11 +164,17 @@ export default function SummaryUI({
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
{formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
{showMemberPrice
? formatPrice(
intl,
memberPrice.amount,
memberPrice.currency
)
: formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
<Caption color="uiTextMediumContrast">
@@ -361,22 +371,17 @@ export default function SummaryUI({
{ b: (str) => <b>{str}</b> }
)}
</Body>
<PriceDetailsModal
fromDate={booking.fromDate}
toDate={booking.toDate}
rooms={rooms.map((r) => ({
adults: r.room.adults,
bedType: r.room.bedType,
breakfast: r.room.breakfast,
childrenInRoom: r.room.childrenInRoom,
roomFeatures: r.room.roomFeatures,
roomPrice: r.room.roomPrice,
roomType: r.room.roomType,
}))}
totalPrice={totalPrice}
vat={vat}
bookingCode={booking.bookingCode}
/>
<PriceDetailsModal>
<PriceDetailsTable
bookingCode={booking.bookingCode}
fromDate={booking.fromDate}
isMember={isMember}
rooms={rooms.map((r) => r.room)}
toDate={booking.toDate}
totalPrice={totalPrice}
vat={vat}
/>
</PriceDetailsModal>
</div>
<div>
<Body textTransform="bold" data-testid="total-price">
@@ -419,8 +424,11 @@ export default function SummaryUI({
)}
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div>
{showSignupPromo && memberPrice && !isMember ? (
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
{showSignupPromo && roomOneMemberPrice && !isMember ? (
<SignupPromoDesktop
memberPrice={roomOneMemberPrice}
badgeContent={"✌️"}
/>
) : null}
</section>
)

View File

@@ -34,7 +34,7 @@ import type { HotelCardProps } from "@/types/components/hotelReservation/selectH
import type { Lang } from "@/constants/languages"
function HotelCard({
hotel,
hotelData: { availability, hotel },
isUserLoggedIn,
state = "default",
type = HotelCardListingTypeEnum.PageListing,
@@ -45,36 +45,27 @@ function HotelCard({
const intl = useIntl()
const { setActiveHotelPin, setActiveHotelCard } = useHotelsMapStore()
const { hotelData } = hotel
const { price } = hotel
const handleMouseEnter = useCallback(() => {
if (hotelData) {
setActiveHotelPin(hotelData.name)
}
}, [setActiveHotelPin, hotelData])
setActiveHotelPin(hotel.name)
}, [setActiveHotelPin, hotel])
const handleMouseLeave = useCallback(() => {
if (hotelData) {
setActiveHotelPin(null)
setActiveHotelCard(null)
}
}, [setActiveHotelPin, hotelData, setActiveHotelCard])
setActiveHotelPin(null)
setActiveHotelCard(null)
}, [setActiveHotelPin, setActiveHotelCard])
if (!hotel || !hotelData) return null
const amenities = hotelData.detailedFacilities.slice(0, 5)
const amenities = hotel.detailedFacilities.slice(0, 5)
const classNames = hotelCardVariants({
type,
state,
})
const addressStr = `${hotelData.address.streetAddress}, ${hotelData.address.city}`
const galleryImages = mapApiImagesToGalleryImages(
hotelData.galleryImages || []
)
const fullPrice = hotel.price?.public?.rateType?.toLowerCase() === "regular"
const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}`
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const fullPrice =
availability.productType?.public?.rateType?.toLowerCase() === "regular"
const price = availability.productType
return (
<article
@@ -84,21 +75,18 @@ function HotelCard({
>
<div>
<div className={styles.imageContainer}>
<ImageGallery title={hotelData.name} images={galleryImages} fill />
{hotelData.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotelData.ratings.tripAdvisor.rating} />
<ImageGallery title={hotel.name} images={galleryImages} fill />
{hotel.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotel.ratings.tripAdvisor.rating} />
)}
</div>
</div>
<div className={styles.hotelContent}>
<section className={styles.hotelInformation}>
<div className={styles.titleContainer}>
<HotelLogo
hotelId={hotelData.operaId}
hotelType={hotelData.hotelType}
/>
<HotelLogo hotelId={hotel.operaId} hotelType={hotel.hotelType} />
<Subtitle textTransform="capitalize" color="uiTextHighContrast">
{hotelData.name}
{hotel.name}
</Subtitle>
<div className={styles.addressContainer}>
<address className={styles.address}>
@@ -107,7 +95,7 @@ function HotelCard({
<address className={styles.addressMobile}>
<Caption color="burgundy" type="underline" asChild>
<Link
href={`https://www.google.com/maps/dir/?api=1&destination=${hotelData.location.latitude},${hotelData.location.longitude}`}
href={`https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`}
target="_blank"
aria-label={intl.formatMessage({
id: "Driving directions",
@@ -130,7 +118,7 @@ function HotelCard({
{ id: "{number} km to city center" },
{
number: getSingleDecimal(
hotelData.location.distanceToCentre / 1000
hotel.location.distanceToCentre / 1000
),
}
)}
@@ -138,7 +126,7 @@ function HotelCard({
</div>
</div>
<Body className={styles.hotelDescription}>
{hotelData.hotelContent.texts.descriptions?.short}
{hotel.hotelContent.texts.descriptions?.short}
</Body>
<div className={styles.facilities}>
{amenities.map((facility) => {
@@ -155,13 +143,13 @@ function HotelCard({
</div>
<ReadMore
label={intl.formatMessage({ id: "See hotel details" })}
hotelId={hotelData.operaId}
hotel={hotelData}
hotelId={hotel.operaId}
hotel={hotel}
showCTA={true}
/>
</section>
<div className={styles.prices}>
{!price ? (
{!availability.productType ? (
<NoPriceAvailableCard />
) : (
<>
@@ -174,18 +162,18 @@ function HotelCard({
</span>
)}
{(!isUserLoggedIn ||
!price.member ||
!price?.member ||
(bookingCode && !fullPrice)) &&
price.public && (
price?.public && (
<HotelPriceCard productTypePrices={price.public} />
)}
{price.member && (
{availability.productType.member && (
<HotelPriceCard
productTypePrices={price.member}
productTypePrices={availability.productType.member}
isMemberPrice
/>
)}
{price.redemption && (
{price?.redemption && (
<div className={styles.pointsCard}>
<Caption>
{intl.formatMessage({ id: "Available rates" })}
@@ -210,7 +198,7 @@ function HotelCard({
className={styles.button}
>
<Link
href={`${selectRate(lang)}?hotel=${hotel.hotelData.operaId}`}
href={`${selectRate(lang)}?hotel=${hotel.operaId}`}
color="none"
keepSearchParams
>

View File

@@ -19,7 +19,9 @@ export default function HotelCardDialogListing({
hotels,
}: HotelCardDialogListingProps) {
const intl = useIntl()
const isRedemption = hotels?.find((hotel) => hotel.price?.redemption)
const isRedemption = hotels?.find(
(hotel) => hotel.availability.productType?.redemption
)
const currencyValue = isRedemption
? intl.formatMessage({ id: "Points" })
: undefined

View File

@@ -1,45 +1,42 @@
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
export function getHotelPins(
hotels: HotelData[],
hotels: HotelResponse[],
currencyValue?: string
): HotelPin[] {
if (hotels.length === 0) return []
if (!hotels.length) {
return []
}
return hotels
.filter((hotel) => hotel.hotelData)
.map((hotel) => ({
return hotels.map(({ availability, hotel }) => {
const productType = availability.productType
return {
coordinates: {
lat: hotel.hotelData.location.latitude,
lng: hotel.hotelData.location.longitude,
lat: hotel.location.latitude,
lng: hotel.location.longitude,
},
name: hotel.hotelData.name,
publicPrice: hotel.price?.public?.localPrice.pricePerNight ?? null,
memberPrice: hotel.price?.member?.localPrice.pricePerNight ?? null,
redemptionPrice:
hotel.price?.redemption?.localPrice.pointsPerNight ?? null,
name: hotel.name,
publicPrice: productType?.public?.localPrice.pricePerNight ?? null,
memberPrice: productType?.member?.localPrice.pricePerNight ?? null,
redemptionPrice: productType?.redemption?.localPrice.pointsPerNight ?? null,
rateType:
hotel.price?.public?.rateType ?? hotel.price?.member?.rateType ?? null,
productType?.public?.rateType ?? productType?.member?.rateType ?? null,
currency:
hotel.price?.public?.localPrice.currency ||
hotel.price?.member?.localPrice.currency ||
productType?.public?.localPrice.currency ||
productType?.member?.localPrice.currency ||
currencyValue ||
"N/A",
images: [
hotel.hotelData.hotelContent.images,
...(hotel.hotelData.gallery?.heroImages ?? []),
],
amenities: hotel.hotelData.detailedFacilities
images: [hotel.hotelContent.images, ...(hotel.gallery?.heroImages ?? [])],
amenities: hotel.detailedFacilities
.map((facility) => ({
...facility,
icon: facility.icon ?? "None",
}))
.slice(0, 5),
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
operaId: hotel.hotelData.operaId,
facilityIds: hotel.hotelData.detailedFacilities.map(
(facility) => facility.id
),
}))
ratings: hotel.ratings?.tripAdvisor.rating ?? null,
operaId: hotel.operaId,
facilityIds: hotel.detailedFacilities.map((facility) => facility.id),
}
})
}

View File

@@ -47,61 +47,66 @@ export default function HotelCardListing({
(state) => state.activeCodeFilter
)
const sortedHotels = useMemo(() => {
if (!hotelData) return []
return getSortedHotels({ hotels: hotelData, sortBy, bookingCode })
}, [hotelData, sortBy, bookingCode])
const hotels = useMemo(() => {
const sortedHotels = getSortedHotels({
hotels: hotelData,
sortBy,
bookingCode,
})
const updatedHotelsList = bookingCode
? sortedHotels.filter(
(hotel) =>
!hotel.price ||
!hotel.availability.productType ||
activeCodeFilter === BookingCodeFilterEnum.All ||
(activeCodeFilter === BookingCodeFilterEnum.Discounted &&
hotel.price?.public?.rateType !== RateTypeEnum.Regular) ||
hotel.availability.productType.public?.rateType !==
RateTypeEnum.Regular) ||
(activeCodeFilter === BookingCodeFilterEnum.Regular &&
hotel.price?.public?.rateType === RateTypeEnum.Regular)
hotel.availability.productType.public?.rateType ===
RateTypeEnum.Regular)
)
: sortedHotels
if (activeFilters.length === 0) return updatedHotelsList
if (!activeFilters.length) {
return updatedHotelsList
}
return updatedHotelsList.filter((hotel) =>
activeFilters.every((appliedFilterId) =>
hotel.hotelData.detailedFacilities.some(
hotel.hotel.detailedFacilities.some(
(facility) => facility.id.toString() === appliedFilterId
)
)
)
}, [activeFilters, sortedHotels, bookingCode, activeCodeFilter])
}, [activeCodeFilter, activeFilters, bookingCode, hotelData, sortBy])
useEffect(() => {
setResultCount(hotels?.length ?? 0)
setResultCount(hotels.length)
}, [hotels, setResultCount])
return (
<section className={styles.hotelCards}>
{hotels?.length ? (
hotels.map((hotel) => (
<div
key={hotel.hotelData.operaId}
data-active={
hotel.hotelData.name === activeHotelCard ? "true" : "false"
}
>
<HotelCard
hotel={hotel}
isUserLoggedIn={isUserLoggedIn}
state={
hotel.hotelData.name === activeHotelCard ? "active" : "default"
{hotels?.length
? hotels.map((hotel) => (
<div
key={hotel.hotel.operaId}
data-active={
hotel.hotel.name === activeHotelCard ? "true" : "false"
}
type={type}
bookingCode={bookingCode}
/>
</div>
))
) : activeFilters ? (
>
<HotelCard
hotelData={hotel}
isUserLoggedIn={isUserLoggedIn}
state={
hotel.hotel.name === activeHotelCard ? "active" : "default"
}
type={type}
bookingCode={bookingCode}
/>
</div>
))
: null}
{!hotels?.length && activeFilters ? (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No hotels match your filters" })}

View File

@@ -1,37 +1,43 @@
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
function getPricePerNight(hotel: HotelResponse): number {
return (
hotel.availability.productType?.member?.localPrice?.pricePerNight ??
hotel.availability.productType?.public?.localPrice?.pricePerNight ??
Infinity
)
}
export function getSortedHotels({
hotels,
sortBy,
bookingCode,
}: {
hotels: HotelData[]
hotels: HotelResponse[]
sortBy: string
bookingCode: string | null
}) {
const getPricePerNight = (hotel: HotelData): number =>
hotel.price?.member?.localPrice?.pricePerNight ??
hotel.price?.public?.localPrice?.pricePerNight ??
hotel.price?.redemption?.localPrice?.pointsPerNight ??
Infinity
const availableHotels = hotels.filter((hotel) => !!hotel?.price)
const unAvailableHotels = hotels.filter((hotel) => !hotel?.price)
const availableHotels = hotels.filter(
(hotel) => !!hotel.availability.productType
)
const unavailableHotels = hotels.filter(
(hotel) => !hotel.availability.productType
)
const sortingStrategies: Record<
string,
(a: HotelData, b: HotelData) => number
(a: HotelResponse, b: HotelResponse) => number
> = {
[SortOrder.Name]: (a: HotelData, b: HotelData) =>
a.hotelData.name.localeCompare(b.hotelData.name),
[SortOrder.TripAdvisorRating]: (a: HotelData, b: HotelData) =>
(b.hotelData.ratings?.tripAdvisor.rating ?? 0) -
(a.hotelData.ratings?.tripAdvisor.rating ?? 0),
[SortOrder.Price]: (a: HotelData, b: HotelData) =>
[SortOrder.Name]: (a: HotelResponse, b: HotelResponse) =>
a.hotel.name.localeCompare(b.hotel.name),
[SortOrder.TripAdvisorRating]: (a: HotelResponse, b: HotelResponse) =>
(b.hotel.ratings?.tripAdvisor.rating ?? 0) -
(a.hotel.ratings?.tripAdvisor.rating ?? 0),
[SortOrder.Price]: (a: HotelResponse, b: HotelResponse) =>
getPricePerNight(a) - getPricePerNight(b),
[SortOrder.Distance]: (a: HotelData, b: HotelData) =>
a.hotelData.location.distanceToCentre -
b.hotelData.location.distanceToCentre,
[SortOrder.Distance]: (a: HotelResponse, b: HotelResponse) =>
a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre,
}
const sortStrategy =
@@ -40,21 +46,25 @@ export function getSortedHotels({
if (bookingCode) {
const bookingCodeHotels = hotels.filter(
(hotel) =>
(hotel?.price?.public?.rateType?.toLowerCase() !== "regular" ||
hotel?.price?.member?.rateType?.toLowerCase() !== "regular") &&
!!hotel?.price
(hotel.availability.productType?.public?.rateType?.toLowerCase() !==
"regular" ||
hotel.availability.productType?.member?.rateType?.toLowerCase() !==
"regular") &&
!!hotel.availability.productType
)
const regularHotels = hotels.filter(
(hotel) => hotel?.price?.public?.rateType?.toLowerCase() === "regular"
(hotel) =>
hotel.availability.productType?.public?.rateType?.toLowerCase() ===
"regular"
)
return [...bookingCodeHotels]
return bookingCodeHotels
.sort(sortStrategy)
.concat([...regularHotels].sort(sortStrategy))
.concat([...unAvailableHotels].sort(sortStrategy))
.concat(regularHotels.sort(sortStrategy))
.concat(unavailableHotels.sort(sortStrategy))
}
return [...availableHotels]
return availableHotels
.sort(sortStrategy)
.concat([...unAvailableHotels].sort(sortStrategy))
.concat(unavailableHotels.sort(sortStrategy))
}

View File

@@ -0,0 +1,187 @@
"use client"
import { Fragment } from "react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { PriceTagIcon } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./priceDetailsTable.module.css"
import type { RoomPrice } from "@/types/components/hotelReservation/enterDetails/details"
import type { Price } from "@/types/components/hotelReservation/price"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
function Row({
label,
value,
bold,
}: {
label: string
value: string
bold?: boolean
}) {
return (
<tr className={styles.row}>
<td>
<Caption type={bold ? "bold" : undefined}>{label}</Caption>
</td>
<td className={styles.price}>
<Caption type={bold ? "bold" : undefined}>{value}</Caption>
</td>
</tr>
)
}
function TableSection({ children }: React.PropsWithChildren) {
return <tbody className={styles.tableSection}>{children}</tbody>
}
function TableSectionHeader({
title,
subtitle,
}: {
title: string
subtitle?: string
}) {
return (
<tr>
<th colSpan={2}>
<Body>{title}</Body>
{subtitle ? <Body>{subtitle}</Body> : null}
</th>
</tr>
)
}
interface Room {
adults: number
childrenInRoom: Child[] | undefined
roomPrice: RoomPrice
roomType: string
}
export interface PriceDetailsTableProps {
bookingCode?: string | null
fromDate: string
rooms: Room[]
toDate: string
totalPrice: Price
vat: number
}
export default function PriceDetailsTable({
bookingCode,
fromDate,
rooms,
toDate,
totalPrice,
vat,
}: PriceDetailsTableProps) {
const intl = useIntl()
const lang = useLang()
const diff = dt(toDate).diff(fromDate, "days")
const nights = intl.formatMessage(
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
{ totalNights: diff }
)
const vatPercentage = vat / 100
const vatAmount = totalPrice.local.price * vatPercentage
const priceExclVat = totalPrice.local.price - vatAmount
const duration = ` ${dt(fromDate).locale(lang).format("ddd, D MMM")}
-
${dt(toDate).locale(lang).format("ddd, D MMM")} (${nights})`
return (
<table className={styles.priceDetailsTable}>
{rooms.map((room, idx) => {
return (
<Fragment key={idx}>
<TableSection>
{rooms.length > 1 && (
<Body textTransform="bold">
{intl.formatMessage({ id: "Room" })} {idx + 1}
</Body>
)}
<TableSectionHeader title={room.roomType} subtitle={duration} />
<Row
label={intl.formatMessage({ id: "Average price per night" })}
value={formatPrice(
intl,
room.roomPrice.perNight.local.price,
room.roomPrice.perNight.local.currency
)}
/>
<Row
bold
label={intl.formatMessage({ id: "Room charge" })}
value={formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
/>
</TableSection>
</Fragment>
)
})}
<TableSection>
<TableSectionHeader title={intl.formatMessage({ id: "Total" })} />
<Row
label={intl.formatMessage({ id: "Price excluding VAT" })}
value={formatPrice(intl, priceExclVat, totalPrice.local.currency)}
/>
<Row
label={intl.formatMessage({ id: "VAT {vat}%" }, { vat })}
value={formatPrice(intl, vatAmount, totalPrice.local.currency)}
/>
<tr className={styles.row}>
<td>
<Body textTransform="bold">
{intl.formatMessage({ id: "Price including VAT" })}
</Body>
</td>
<td className={styles.price}>
<Body textTransform="bold">
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency
)}
</Body>
</td>
</tr>
{totalPrice.local.regularPrice && (
<tr className={styles.row}>
<td></td>
<td className={styles.price}>
<Caption color="uiTextMediumContrast" striked={true}>
{formatPrice(
intl,
totalPrice.local.regularPrice,
totalPrice.local.currency
)}
</Caption>
</td>
</tr>
)}
{bookingCode && totalPrice.local.regularPrice && (
<tr className={styles.row}>
<td>
<PriceTagIcon />
{bookingCode}
</td>
<td></td>
</tr>
)}
</TableSection>
</table>
)
}

View File

@@ -0,0 +1,36 @@
.priceDetailsTable {
border-collapse: collapse;
width: 100%;
}
.price {
text-align: end;
}
.tableSection {
display: flex;
gap: var(--Spacing-x-half);
flex-direction: column;
width: 100%;
}
.tableSection:has(tr > th) {
padding-top: var(--Spacing-x2);
}
.tableSection:has(tr > th):not(:first-of-type) {
border-top: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
.tableSection:not(:last-child) {
padding-bottom: var(--Spacing-x2);
}
.row {
display: flex;
justify-content: space-between;
}
@media screen and (min-width: 768px) {
.priceDetailsTable {
min-width: 512px;
}
}

View File

@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
import {
BedDoubleIcon,
@@ -22,8 +23,8 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import PriceDetailsModal from "../../PriceDetailsModal"
import GuestDetails from "./GuestDetails"
import PriceDetailsTable from "./PriceDetailsTable"
import ToggleSidePeek from "./ToggleSidePeek"
import styles from "./room.module.css"
@@ -287,41 +288,44 @@ export function Room({ booking, room, hotel, user }: RoomProps) {
</Body>
</div>
<PriceDetailsModal
fromDate={dt(booking.checkInDate).format("YYYY-MM-DD")}
toDate={dt(booking.checkOutDate).format("YYYY-MM-DD")}
rooms={[
{
adults: booking.adults,
childrenInRoom: undefined,
roomPrice: {
perNight: {
requested: undefined,
local: {
currency: booking.currencyCode,
price: booking.totalPrice,
},
},
perStay: {
requested: undefined,
local: {
currency: booking.currencyCode,
price: booking.totalPrice,
<PriceDetailsModal>
<PriceDetailsTable
bookingCode={booking.bookingCode}
fromDate={dt(booking.checkInDate).format("YYYY-MM-DD")}
rooms={[
{
adults: booking.adults,
childrenInRoom: undefined,
roomPrice: {
perNight: {
requested: undefined,
local: {
currency: booking.currencyCode,
price: booking.totalPrice,
},
},
perStay: {
requested: undefined,
local: {
currency: booking.currencyCode,
price: booking.totalPrice,
},
},
},
roomType: room.name,
},
roomType: room.name,
},
]}
totalPrice={{
requested: undefined,
local: {
currency: booking.currencyCode,
price: booking.totalPrice,
},
}}
vat={booking.vatPercentage}
/>
]}
toDate={dt(booking.checkOutDate).format("YYYY-MM-DD")}
totalPrice={{
requested: undefined,
local: {
currency: booking.currencyCode,
price: booking.totalPrice,
},
}}
vat={booking.vatPercentage}
/>
</PriceDetailsModal>
</div>
</div>
</div>

View File

@@ -0,0 +1,29 @@
"use client"
import { useIntl } from "react-intl"
import ChevronRightSmallIcon from "@/components/Icons/ChevronRightSmall"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
export default function PriceDetailsModal({
children,
}: React.PropsWithChildren) {
const intl = useIntl()
return (
<Modal
title={intl.formatMessage({ id: "Price details" })}
trigger={
<Button intent="text">
<Caption color="burgundy">
{intl.formatMessage({ id: "Price details" })}
</Caption>
<ChevronRightSmallIcon color="burgundy" height="20px" width="20px" />
</Button>
}
>
{children}
</Modal>
)
}

View File

@@ -1,67 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import ChevronRightSmallIcon from "@/components/Icons/ChevronRightSmall"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import PriceDetailsTable from "./PriceDetailsTable"
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { RoomPrice } from "@/types/components/hotelReservation/enterDetails/details"
import type { Price } from "@/types/components/hotelReservation/price"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Packages } from "@/types/requests/packages"
interface PriceDetailsModalProps {
fromDate: string
toDate: string
rooms: {
adults: number
childrenInRoom: Child[] | undefined
roomType: string
roomPrice: RoomPrice
bedType?: BedTypeSchema
breakfast?: BreakfastPackage | false
roomFeatures?: Packages | null
}[]
totalPrice: Price
vat: number
bookingCode?: string
}
export default function PriceDetailsModal({
fromDate,
toDate,
rooms,
totalPrice,
vat,
bookingCode,
}: PriceDetailsModalProps) {
const intl = useIntl()
return (
<Modal
title={intl.formatMessage({ id: "Price details" })}
trigger={
<Button intent="text">
<Caption color="burgundy">
{intl.formatMessage({ id: "Price details" })}
</Caption>
<ChevronRightSmallIcon color="burgundy" height="20px" width="20px" />
</Button>
}
>
<PriceDetailsTable
fromDate={fromDate}
toDate={toDate}
rooms={rooms}
totalPrice={totalPrice}
vat={vat}
bookingCode={bookingCode}
/>
</Modal>
)
}

View File

@@ -8,9 +8,10 @@ import type { NoAvailabilityAlertProp } from "@/types/components/hotelReservatio
import { AlertTypeEnum } from "@/types/enums/alert"
export default async function NoAvailabilityAlert({
hotels,
hotelsLength,
isAllUnavailable,
isAlternative,
operaId,
}: NoAvailabilityAlertProp) {
const intl = await getIntl()
const lang = getLang()
@@ -19,7 +20,7 @@ export default async function NoAvailabilityAlert({
return null
}
if (hotels.length === 1 && !isAlternative) {
if (hotelsLength === 1 && !isAlternative && operaId) {
return (
<Alert
type={AlertTypeEnum.Info}
@@ -29,7 +30,7 @@ export default async function NoAvailabilityAlert({
})}
link={{
title: intl.formatMessage({ id: "See alternative hotels" }),
url: `${alternativeHotels(lang)}?hotel=${hotels[0].hotelData.operaId}`,
url: `${alternativeHotels(lang)}?hotel=${operaId}`,
keepSearchParams: true,
}}
/>

View File

@@ -1,40 +1,26 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation"
import { env } from "@/env/server"
import { getCityCoordinates } from "@/lib/trpc/memoizedRequests"
import {
fetchAlternativeHotels,
fetchAvailableHotels,
fetchBookingCodeAvailableHotels,
getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
import { safeTry } from "@/utils/safeTry"
import { getHotelPins } from "../../HotelCardDialogListing/utils"
import { getFiltersFromHotels, getHotels } from "../helpers"
import { getTracking } from "./tracking"
import SelectHotelMap from "."
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { SelectHotelMapContainerProps } from "@/types/components/hotelReservation/selectHotel/map"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
export async function SelectHotelMapContainer({
searchParams,
isAlternativeHotels,
}: SelectHotelMapContainerProps) {
const lang = getLang()
const intl = await getIntl()
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
const getHotelSearchDetailsPromise = safeTry(
@@ -50,106 +36,58 @@ export async function SelectHotelMapContainer({
const [searchDetails] = await getHotelSearchDetailsPromise
if (!searchDetails) return notFound()
if (!searchDetails) {
return notFound()
}
const {
city,
selectHotelParams,
adultsInRoom,
childrenInRoom,
childrenInRoomString,
hotel: isAlternativeFor,
bookingCode,
childrenInRoom,
city,
hotel: isAlternativeFor,
noOfRooms,
redemption,
selectHotelParams,
} = searchDetails
if (!city) return notFound()
if (!city) {
return notFound()
}
const fetchAvailableHotelsPromise = isAlternativeFor
? safeTry(
fetchAlternativeHotels(isAlternativeFor.id, {
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
bookingCode,
redemption,
})
)
: bookingCode
? safeTry(
fetchBookingCodeAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
bookingCode,
})
)
: safeTry(
fetchAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
redemption,
})
)
const hotels = await getHotels(
selectHotelParams,
isAlternativeFor,
bookingCode,
city,
!!redemption
)
const [hotels] = await fetchAvailableHotelsPromise
const validHotels = (hotels?.filter(Boolean) as HotelData[]) || []
const currencyValue = redemption
? intl.formatMessage({ id: "Points" })
: undefined
const hotelPins = getHotelPins(validHotels, currencyValue)
const filterList = getFiltersFromHotels(validHotels)
const hotelPins = getHotelPins(hotels)
const filterList = getFiltersFromHotels(hotels)
const cityCoordinates = await getCityCoordinates({
city: city.name,
hotel: { address: hotels?.[0]?.hotelData?.address.streetAddress },
hotel: { address: hotels?.[0]?.hotel?.address.streetAddress },
})
const arrivalDate = new Date(selectHotelParams.fromDate)
const departureDate = new Date(selectHotelParams.toDate)
const pageTrackingData: TrackingSDKPageData = {
pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel",
domainLanguage: lang,
channel: TrackingChannelEnum["hotelreservation"],
pageName: isAlternativeHotels
? "hotelreservation|alternative-hotels|mapview"
: "hotelreservation|select-hotel|mapview",
siteSections: isAlternativeHotels
? "hotelreservation|altervative-hotels|mapview"
: "hotelreservation|select-hotel|mapview",
pageType: "bookinghotelsmapviewpage",
siteVersion: "new-web",
}
const hotelsTrackingData: TrackingSDKHotelInfo = {
availableResults: validHotels.length,
searchTerm: isAlternativeFor
? selectHotelParams.hotelId
: (selectHotelParams.city as string),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: adultsInRoom[0], // TODO: Handle multiple rooms
noOfChildren: childrenInRoom?.length,
ageOfChildren: childrenInRoom?.map((c) => c.age).join(","),
childBedPreference: childrenInRoom
?.map((c) => ChildBedMapEnum[c.bed])
.join("|"),
noOfRooms: 1, // // TODO: Handle multiple rooms
duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
searchType: "destination",
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
country: validHotels?.[0]?.hotelData.address.country,
region: validHotels?.[0]?.hotelData.address.city,
}
const { hotelsTrackingData, pageTrackingData } = getTracking(
lang,
!!isAlternativeFor,
!!isAlternativeHotels,
arrivalDate,
departureDate,
adultsInRoom,
childrenInRoom,
hotels.length,
selectHotelParams.hotelId,
noOfRooms,
hotels?.[0]?.hotel.address.country,
hotels?.[0]?.hotel.address.city,
selectHotelParams.city
)
return (
<>
@@ -157,7 +95,7 @@ export async function SelectHotelMapContainer({
apiKey={googleMapsApiKey}
hotelPins={hotelPins}
mapId={googleMapId}
hotels={validHotels}
hotels={hotels}
filterList={filterList}
cityCoordinates={cityCoordinates}
bookingCode={bookingCode ?? ""}

View File

@@ -26,10 +26,10 @@ import { getVisibleHotels } from "./utils"
import styles from "./selectHotelMapContent.module.css"
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
const SKELETON_LOAD_DELAY = 750
@@ -46,7 +46,7 @@ export default function SelectHotelContent({
const map = useMap()
const isAboveMobile = useMediaQuery("(min-width: 768px)")
const [visibleHotels, setVisibleHotels] = useState<HotelData[]>([])
const [visibleHotels, setVisibleHotels] = useState<HotelResponse[]>([])
const [showSkeleton, setShowSkeleton] = useState<boolean>(true)
const listingContainerRef = useRef<HTMLDivElement | null>(null)

View File

@@ -1,5 +1,5 @@
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
export function getVisibleHotelPins(
map: google.maps.Map | null,
@@ -17,13 +17,13 @@ export function getVisibleHotelPins(
}
export function getVisibleHotels(
hotels: HotelData[],
hotels: HotelResponse[],
filteredHotelPins: HotelPin[],
map: google.maps.Map | null
) {
const visibleHotelPins = getVisibleHotelPins(map, filteredHotelPins)
const visibleHotels = hotels.filter((hotel) =>
visibleHotelPins.some((pin) => pin.operaId === hotel.hotelData.operaId)
visibleHotelPins.some((pin) => pin.operaId === hotel.hotel.operaId)
)
return visibleHotels
}

View File

@@ -0,0 +1,67 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type { Lang } from "@/constants/languages"
import type { ChildrenInRoom } from "@/utils/hotelSearchDetails"
export function getTracking(
lang: Lang,
isAlternativeFor: boolean,
isAlternativeHotels: boolean,
arrivalDate: Date,
departureDate: Date,
adultsInRoom: number[],
childrenInRoom: ChildrenInRoom,
hotelsResult: number,
hotelId: string,
noOfRooms: number,
country: string | undefined,
hotelCity: string | undefined,
paramCity: string | undefined
) {
const pageTrackingData: TrackingSDKPageData = {
channel: TrackingChannelEnum["hotelreservation"],
domainLanguage: lang,
pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel",
pageName: isAlternativeHotels
? "hotelreservation|alternative-hotels|mapview"
: "hotelreservation|select-hotel|mapview",
pageType: "bookinghotelsmapviewpage",
siteSections: isAlternativeHotels
? "hotelreservation|altervative-hotels|mapview"
: "hotelreservation|select-hotel|mapview",
siteVersion: "new-web",
}
const hotelsTrackingData: TrackingSDKHotelInfo = {
ageOfChildren: childrenInRoom
?.map((c) => c?.map((k) => k.age).join(",") ?? "-")
.join("|"),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
availableResults: hotelsResult,
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
childBedPreference: childrenInRoom
?.map((c) => c?.map((k) => ChildBedMapEnum[k.bed]).join(",") ?? "-")
.join("|"),
country,
departureDate: format(departureDate, "yyyy-MM-dd"),
duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
noOfAdults: adultsInRoom.join(","),
noOfChildren: childrenInRoom?.map((kids) => kids?.length ?? 0).join(","),
noOfRooms,
region: hotelCity,
searchTerm: isAlternativeFor ? hotelId : (paramCity as string),
searchType: "destination",
}
return {
hotelsTrackingData,
pageTrackingData,
}
}

View File

@@ -0,0 +1,270 @@
import { getHotel } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import { getLang } from "@/i18n/serverContext"
import { generateChildrenString } from "../utils"
import type {
AlternativeHotelsAvailabilityInput,
AvailabilityInput,
} from "@/types/components/hotelReservation/selectHotel/availabilityInput"
import type { CategorizedFilters } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { DetailedFacility, Hotel } from "@/types/hotel"
import type { HotelsAvailabilityItem } from "@/types/trpc/routers/hotel/availability"
import type {
HotelLocation,
Location,
} from "@/types/trpc/routers/hotel/locations"
interface AvailabilityResponse {
availability: HotelsAvailabilityItem[]
}
export interface HotelResponse {
availability: HotelsAvailabilityItem
hotel: Hotel
}
type Result = AvailabilityResponse | null
type SettledResult = PromiseSettledResult<Result>[]
async function enhanceHotels(hotels: HotelsAvailabilityItem[]) {
const language = getLang()
return await Promise.allSettled(
hotels.map(async (availability) => {
const hotelData = await getHotel({
hotelId: availability.hotelId.toString(),
isCardOnlyPayment: false,
language,
})
if (!hotelData) {
return null
}
return {
availability,
hotel: hotelData.hotel,
}
})
)
}
async function fetchAlternativeHotels(
hotelId: string,
input: AlternativeHotelsAvailabilityInput
) {
const alternativeHotelIds = await serverClient().hotel.nearbyHotelIds({
hotelId,
})
if (!alternativeHotelIds) {
return null
}
return await serverClient().hotel.availability.hotelsByHotelIds({
...input,
hotelIds: alternativeHotelIds,
})
}
async function fetchAvailableHotels(input: AvailabilityInput) {
return await serverClient().hotel.availability.hotelsByCity(input)
}
async function fetchBookingCodeAvailableHotels(input: AvailabilityInput) {
return await serverClient().hotel.availability.hotelsByCityWithBookingCode(
input
)
}
function getFulfilledResponses<T>(result: PromiseSettledResult<T | null>[]) {
const fulfilledResponses: NonNullable<T>[] = []
for (const res of result) {
if (res.status === "fulfilled" && res.value) {
fulfilledResponses.push(res.value)
}
}
return fulfilledResponses
}
function getHotelAvailabilityItems(hotels: AvailabilityResponse[]) {
return hotels.map((hotel) => hotel.availability)
}
// Filter out hotels that are unavailable for
// at least one room.
function sortAndFilterHotelsByAvailability(
fulfilledHotels: HotelsAvailabilityItem[][]
) {
const availableHotels = new Map<
HotelsAvailabilityItem["hotelId"],
HotelsAvailabilityItem
>()
const unavailableHotels = new Map<
HotelsAvailabilityItem["hotelId"],
HotelsAvailabilityItem
>()
const unavailableHotelIds = new Set<HotelsAvailabilityItem["hotelId"]>()
for (const availabilityHotels of fulfilledHotels) {
for (const hotel of availabilityHotels) {
if (hotel.status === AvailabilityEnum.Available) {
if (availableHotels.has(hotel.hotelId)) {
const currentAddedHotel = availableHotels.get(hotel.hotelId)
// Make sure the cheapest version of the room is the one
// we keep so that it matches the cheapest room on select-rate
if (
(hotel.productType?.public &&
currentAddedHotel?.productType?.public &&
hotel.productType.public.localPrice.pricePerNight <
currentAddedHotel.productType.public.localPrice
.pricePerNight) ||
(hotel.productType?.member &&
currentAddedHotel?.productType?.member &&
hotel.productType.member.localPrice.pricePerNight <
currentAddedHotel.productType.member.localPrice.pricePerNight)
) {
availableHotels.set(hotel.hotelId, hotel)
}
} else {
availableHotels.set(hotel.hotelId, hotel)
}
} else {
unavailableHotels.set(hotel.hotelId, hotel)
unavailableHotelIds.add(hotel.hotelId)
}
}
}
for (const [hotelId] of unavailableHotelIds.entries()) {
if (availableHotels.has(hotelId)) {
availableHotels.delete(hotelId)
}
}
return [
Array.from(availableHotels.values()),
Array.from(unavailableHotels.values()),
].flat()
}
export async function getHotels(
booking: SelectHotelSearchParams,
isAlternativeFor: HotelLocation | null,
bookingCode: string | undefined,
city: Location,
redemption: boolean
) {
let availableHotelsResponse: SettledResult = []
if (isAlternativeFor) {
availableHotelsResponse = await Promise.allSettled(
booking.rooms.map(async (room) => {
return fetchAlternativeHotels(isAlternativeFor.id, {
adults: room.adults,
bookingCode,
children: room.childrenInRoom
? generateChildrenString(room.childrenInRoom)
: undefined,
redemption,
roomStayEndDate: booking.toDate,
roomStayStartDate: booking.fromDate,
})
})
)
} else if (bookingCode) {
availableHotelsResponse = await Promise.allSettled(
booking.rooms.map(async (room) => {
return fetchBookingCodeAvailableHotels({
adults: room.adults,
bookingCode,
children: room.childrenInRoom
? generateChildrenString(room.childrenInRoom)
: undefined,
cityId: city.id,
roomStayStartDate: booking.fromDate,
roomStayEndDate: booking.toDate,
})
})
)
} else {
availableHotelsResponse = await Promise.allSettled(
booking.rooms.map(
async (room) =>
await fetchAvailableHotels({
adults: room.adults,
children: room.childrenInRoom
? generateChildrenString(room.childrenInRoom)
: undefined,
cityId: city.id,
redemption,
roomStayEndDate: booking.toDate,
roomStayStartDate: booking.fromDate,
})
)
)
}
const fulfilledAvailabilities = getFulfilledResponses<AvailabilityResponse>(
availableHotelsResponse
)
const availablilityItems = getHotelAvailabilityItems(fulfilledAvailabilities)
const availableHotels = sortAndFilterHotelsByAvailability(availablilityItems)
if (!availableHotels.length) {
return []
}
const hotelsResponse = await enhanceHotels(availableHotels)
const hotels = getFulfilledResponses<HotelResponse>(hotelsResponse)
return hotels
}
const hotelSurroundingsFilterNames = [
"Hotel surroundings",
"Hotel omgivelser",
"Hotelumgebung",
"Hotellia lähellä",
"Hotellomgivelser",
"Omgivningar",
]
const hotelFacilitiesFilterNames = [
"Hotel facilities",
"Hotellfaciliteter",
"Hotelfaciliteter",
"Hotel faciliteter",
"Hotel-Infos",
"Hotellin palvelut",
]
export function getFiltersFromHotels(
hotels: HotelResponse[]
): CategorizedFilters {
const defaultFilters = { facilityFilters: [], surroundingsFilters: [] }
if (!hotels.length) {
return defaultFilters
}
const filters = hotels.flatMap(({ hotel }) => hotel.detailedFacilities)
const uniqueFilterIds = [...new Set(filters.map((filter) => filter.id))]
const filterList: DetailedFacility[] = uniqueFilterIds
.map((filterId) => filters.find((filter) => filter.id === filterId))
.filter((filter): filter is DetailedFacility => filter !== undefined)
.sort((a, b) => b.sortOrder - a.sortOrder)
return filterList.reduce<CategorizedFilters>((filters, filter) => {
if (filter.filter && hotelSurroundingsFilterNames.includes(filter.filter)) {
filters.surroundingsFilters.push(filter)
}
if (filter.filter && hotelFacilitiesFilterNames.includes(filter.filter)) {
filters.facilityFilters.push(filter)
}
return filters
}, defaultFilters)
}

View File

@@ -1,4 +1,4 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import stringify from "json-stable-stringify-without-jsonify"
import { notFound } from "next/navigation"
import { Suspense } from "react"
@@ -9,13 +9,6 @@ import {
selectHotelMap,
} from "@/constants/routes/hotelReservation"
import {
fetchAlternativeHotels,
fetchAvailableHotels,
fetchBookingCodeAvailableHotels,
getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils"
import { ChevronRightIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap"
import Breadcrumbs from "@/components/TempDesignSystem/Breadcrumbs"
@@ -24,28 +17,23 @@ import Link from "@/components/TempDesignSystem/Link"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import { safeTry } from "@/utils/safeTry"
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
import { convertObjToSearchParams } from "@/utils/url"
import HotelCardListing from "../HotelCardListing"
import BookingCodeFilter from "./BookingCodeFilter"
import { getFiltersFromHotels, getHotels } from "./helpers"
import HotelCount from "./HotelCount"
import HotelFilter from "./HotelFilter"
import HotelSorter from "./HotelSorter"
import MobileMapButtonContainer from "./MobileMapButtonContainer"
import NoAvailabilityAlert from "./NoAvailabilityAlert"
import { getTracking } from "./tracking"
import styles from "./selectHotel.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { SelectHotelProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
export default async function SelectHotel({
params,
@@ -54,78 +42,44 @@ export default async function SelectHotel({
}: SelectHotelProps) {
const intl = await getIntl()
const getHotelSearchDetailsPromise = safeTry(
getHotelSearchDetails(
{
searchParams: searchParams as SelectHotelSearchParams & {
[key: string]: string
},
const searchDetails = await getHotelSearchDetails(
{
searchParams: searchParams as SelectHotelSearchParams & {
[key: string]: string
},
isAlternativeHotels
)
},
isAlternativeHotels
)
const [searchDetails] = await getHotelSearchDetailsPromise
if (!searchDetails) return notFound()
const {
city,
selectHotelParams,
adultsInRoom,
childrenInRoomString,
childrenInRoom,
hotel: isAlternativeFor,
bookingCode,
childrenInRoom,
city,
hotel: isAlternativeFor,
noOfRooms,
redemption,
selectHotelParams,
} = searchDetails
if (!city) return notFound()
const hotelsPromise = isAlternativeFor
? safeTry(
fetchAlternativeHotels(isAlternativeFor.id, {
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
bookingCode,
redemption,
})
)
: bookingCode
? safeTry(
fetchBookingCodeAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
bookingCode,
})
)
: safeTry(
fetchAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
redemption,
})
)
const [hotels] = await hotelsPromise
const hotels = await getHotels(
selectHotelParams,
isAlternativeFor,
bookingCode,
city,
!!redemption,
)
const arrivalDate = new Date(selectHotelParams.fromDate)
const departureDate = new Date(selectHotelParams.toDate)
const isCityWithCountry = (city: any): city is { country: string } =>
"country" in city
const validHotels =
hotels?.filter((hotel): hotel is HotelData => hotel?.hotelData !== null) ||
[]
const filterList = getFiltersFromHotels(validHotels)
const filterList = getFiltersFromHotels(hotels)
const convertedSearchParams = convertObjToSearchParams(selectHotelParams)
const breadcrumbs = [
@@ -141,65 +95,44 @@ export default async function SelectHotel({
},
isAlternativeFor
? {
title: intl.formatMessage({ id: "Alternative hotels" }),
href: `${alternativeHotels(params.lang)}/?${convertedSearchParams}`,
uid: "alternative-hotels",
}
title: intl.formatMessage({ id: "Alternative hotels" }),
href: `${alternativeHotels(params.lang)}/?${convertedSearchParams}`,
uid: "alternative-hotels",
}
: {
title: intl.formatMessage({ id: "Select hotel" }),
href: `${selectHotel(params.lang)}/?${convertedSearchParams}`,
uid: "select-hotel",
},
title: intl.formatMessage({ id: "Select hotel" }),
href: `${selectHotel(params.lang)}/?${convertedSearchParams}`,
uid: "select-hotel",
},
isAlternativeFor
? {
title: isAlternativeFor.name,
uid: isAlternativeFor.id,
}
title: isAlternativeFor.name,
uid: isAlternativeFor.id,
}
: {
title: city.name,
uid: city.id,
},
title: city.name,
uid: city.id,
},
]
const isAllUnavailable =
hotels?.every((hotel) => hotel.price === undefined) || false
const isAllUnavailable = !hotels.length
const pageTrackingData: TrackingSDKPageData = {
pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel",
domainLanguage: params.lang,
channel: TrackingChannelEnum["hotelreservation"],
pageName: isAlternativeFor
? "hotelreservation|alternative-hotels"
: "hotelreservation|select-hotel",
siteSections: isAlternativeFor
? "hotelreservation|alternative-hotels"
: "hotelreservation|select-hotel",
pageType: "bookinghotelspage",
siteVersion: "new-web",
}
const hotelsTrackingData: TrackingSDKHotelInfo = {
availableResults: validHotels.length,
searchTerm: isAlternativeFor
? selectHotelParams.hotelId
: (selectHotelParams.city as string),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: adultsInRoom[0], // TODO: Handle multiple rooms,
noOfChildren: childrenInRoom?.length,
ageOfChildren: childrenInRoom?.map((c) => c.age).join(","),
childBedPreference: childrenInRoom
?.map((c) => ChildBedMapEnum[c.bed])
.join("|"),
noOfRooms: 1, // // TODO: Handle multiple rooms
duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
searchType: "destination",
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
country: validHotels?.[0]?.hotelData.address.country,
region: validHotels?.[0]?.hotelData.address.city,
}
const { hotelsTrackingData, pageTrackingData } = getTracking(
params.lang,
!!isAlternativeFor,
arrivalDate,
departureDate,
adultsInRoom,
childrenInRoom,
hotels.length,
selectHotelParams.hotelId,
noOfRooms,
hotels?.[0]?.hotel.address.country,
hotels?.[0]?.hotel.address.city,
selectHotelParams.city
)
const suspenseKey = stringify(searchParams)
return (
<>
<header className={styles.header}>
@@ -229,7 +162,7 @@ export default async function SelectHotel({
<main className={styles.main}>
{bookingCode ? <BookingCodeFilter /> : null}
<div className={styles.sideBar}>
{hotels && hotels.length > 0 ? ( // TODO: Temp fix until API returns hotels that are not available
{hotels.length ? (
<Link
className={styles.link}
color="burgundy"
@@ -276,14 +209,15 @@ export default async function SelectHotel({
</div>
<div className={styles.hotelList}>
<NoAvailabilityAlert
hotelsLength={hotels.length}
isAlternative={!!isAlternativeFor}
hotels={validHotels}
isAllUnavailable={isAllUnavailable}
operaId={hotels?.[0]?.hotel.operaId}
/>
<HotelCardListing hotelData={validHotels} />
<HotelCardListing hotelData={hotels} />
</div>
</main>
<Suspense fallback={null}>
<Suspense key={`${suspenseKey}-tracking`} fallback={null}>
<TrackingSDK
pageData={pageTrackingData}
hotelInfo={hotelsTrackingData}

View File

@@ -0,0 +1,66 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type { Lang } from "@/constants/languages"
import type { ChildrenInRoom } from "@/utils/hotelSearchDetails"
export function getTracking(
lang: Lang,
isAlternativeFor: boolean,
arrivalDate: Date,
departureDate: Date,
adultsInRoom: number[],
childrenInRoom: ChildrenInRoom,
hotelsResult: number,
hotelId: string,
noOfRooms: number,
country: string | undefined,
hotelCity: string | undefined,
paramCity: string | undefined
) {
const pageTrackingData: TrackingSDKPageData = {
channel: TrackingChannelEnum["hotelreservation"],
domainLanguage: lang,
pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel",
pageName: isAlternativeFor
? "hotelreservation|alternative-hotels"
: "hotelreservation|select-hotel",
pageType: "bookinghotelspage",
siteSections: isAlternativeFor
? "hotelreservation|alternative-hotels"
: "hotelreservation|select-hotel",
siteVersion: "new-web",
}
const hotelsTrackingData: TrackingSDKHotelInfo = {
ageOfChildren: childrenInRoom
?.map((c) => c?.map((k) => k.age).join(",") ?? "-")
.join("|"),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
availableResults: hotelsResult,
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
childBedPreference: childrenInRoom
?.map((c) => c?.map((k) => ChildBedMapEnum[k.bed]).join(",") ?? "-")
.join("|"),
country,
departureDate: format(departureDate, "yyyy-MM-dd"),
duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
noOfAdults: adultsInRoom.join(","),
noOfChildren: childrenInRoom?.map((kids) => kids?.length ?? 0).join(","),
noOfRooms,
region: hotelCity,
searchTerm: isAlternativeFor ? hotelId : (paramCity as string),
searchType: "destination",
}
return {
hotelsTrackingData,
pageTrackingData,
}
}

View File

@@ -0,0 +1,189 @@
"use client"
import { Fragment } from "react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { PriceTagIcon } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./priceDetailsTable.module.css"
import type { Price } from "@/types/components/hotelReservation/price"
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
function Row({
label,
value,
bold,
}: {
label: string
value: string
bold?: boolean
}) {
return (
<tr className={styles.row}>
<td>
<Caption type={bold ? "bold" : undefined}>{label}</Caption>
</td>
<td className={styles.price}>
<Caption type={bold ? "bold" : undefined}>{value}</Caption>
</td>
</tr>
)
}
function TableSection({ children }: React.PropsWithChildren) {
return <tbody className={styles.tableSection}>{children}</tbody>
}
function TableSectionHeader({
title,
subtitle,
}: {
title: string
subtitle?: string
}) {
return (
<tr>
<th colSpan={2}>
<Body>{title}</Body>
{subtitle ? <Body>{subtitle}</Body> : null}
</th>
</tr>
)
}
export interface PriceDetailsTableProps {
bookingCode?: string
fromDate: string
isMember: boolean
rooms: SelectRateSummaryProps["rooms"]
toDate: string
totalPrice: Price
vat: number
}
export default function PriceDetailsTable({
bookingCode,
fromDate,
isMember,
rooms,
toDate,
totalPrice,
vat,
}: PriceDetailsTableProps) {
const intl = useIntl()
const lang = useLang()
const diff = dt(toDate).diff(fromDate, "days")
const nights = intl.formatMessage(
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
{ totalNights: diff }
)
const vatPercentage = vat / 100
const vatAmount = totalPrice.local.price * vatPercentage
const priceExclVat = totalPrice.local.price - vatAmount
const duration = ` ${dt(fromDate).locale(lang).format("ddd, D MMM")}
-
${dt(toDate).locale(lang).format("ddd, D MMM")} (${nights})`
return (
<table className={styles.priceDetailsTable}>
{rooms.map((room, idx) => {
const getMemberRate = idx === 0 && isMember
const price =
getMemberRate && room.roomRate.memberRate
? room.roomRate.memberRate
: room.roomRate.publicRate
if (!price) {
return null
}
return (
<Fragment key={idx}>
<TableSection>
{rooms.length > 1 && (
<Body textTransform="bold">
{intl.formatMessage({ id: "Room" })} {idx + 1}
</Body>
)}
<TableSectionHeader title={room.roomType} subtitle={duration} />
<Row
label={intl.formatMessage({ id: "Average price per night" })}
value={formatPrice(
intl,
price.localPrice.pricePerNight,
price.localPrice.currency
)}
/>
<Row
bold
label={intl.formatMessage({ id: "Room charge" })}
value={formatPrice(
intl,
price.localPrice.pricePerStay,
price.localPrice.currency
)}
/>
</TableSection>
</Fragment>
)
})}
<TableSection>
<TableSectionHeader title={intl.formatMessage({ id: "Total" })} />
<Row
label={intl.formatMessage({ id: "Price excluding VAT" })}
value={formatPrice(intl, priceExclVat, totalPrice.local.currency)}
/>
<Row
label={intl.formatMessage({ id: "VAT {vat}%" }, { vat })}
value={formatPrice(intl, vatAmount, totalPrice.local.currency)}
/>
<tr className={styles.row}>
<td>
<Body textTransform="bold">
{intl.formatMessage({ id: "Price including VAT" })}
</Body>
</td>
<td className={styles.price}>
<Body textTransform="bold">
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency
)}
</Body>
</td>
</tr>
{totalPrice.local.regularPrice && (
<tr className={styles.row}>
<td></td>
<td className={styles.price}>
<Caption color="uiTextMediumContrast" striked={true}>
{formatPrice(
intl,
totalPrice.local.regularPrice,
totalPrice.local.currency
)}
</Caption>
</td>
</tr>
)}
{bookingCode && totalPrice.local.regularPrice && (
<tr className={styles.row}>
<td>
<PriceTagIcon />
{bookingCode}
</td>
<td></td>
</tr>
)}
</TableSection>
</table>
)
}

View File

@@ -0,0 +1,36 @@
.priceDetailsTable {
border-collapse: collapse;
width: 100%;
}
.price {
text-align: end;
}
.tableSection {
display: flex;
gap: var(--Spacing-x-half);
flex-direction: column;
width: 100%;
}
.tableSection:has(tr > th) {
padding-top: var(--Spacing-x2);
}
.tableSection:has(tr > th):not(:first-of-type) {
border-top: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
.tableSection:not(:last-child) {
padding-bottom: var(--Spacing-x2);
}
.row {
display: flex;
justify-content: space-between;
}
@media screen and (min-width: 768px) {
.priceDetailsTable {
min-width: 512px;
}
}

View File

@@ -20,6 +20,8 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import PriceDetailsTable from "./PriceDetailsTable"
import styles from "./summary.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
@@ -249,19 +251,17 @@ export default function Summary({
{ b: (str) => <b>{str}</b> }
)}
</Body>
<PriceDetailsModal
fromDate={booking.fromDate}
toDate={booking.toDate}
rooms={rooms.map((r) => ({
adults: r.adults,
childrenInRoom: r.childrenInRoom,
roomPrice: r.roomPrice,
roomType: r.roomType,
}))}
totalPrice={totalPrice}
vat={vat}
bookingCode={booking.bookingCode}
/>
<PriceDetailsModal>
<PriceDetailsTable
bookingCode={booking.bookingCode}
fromDate={booking.fromDate}
isMember={isMember}
rooms={rooms}
toDate={booking.toDate}
totalPrice={totalPrice}
vat={vat}
/>
</PriceDetailsModal>
</div>
<div>
<Body

View File

@@ -15,6 +15,7 @@ import styles from "./mobileSummary.module.css"
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
export default function MobileSummary({
isAllRoomsSelected,
@@ -25,11 +26,11 @@ export default function MobileSummary({
const scrollY = useRef(0)
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
const { booking, bookingRooms, rateDefinitions, rateSummary, vat } =
const { booking, bookingRooms, roomsAvailability, rateSummary, vat } =
useRatesStore((state) => ({
booking: state.booking,
bookingRooms: state.booking.rooms,
rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomsAvailability: state.roomsAvailability,
rateSummary: state.rateSummary,
vat: state.vat,
}))
@@ -61,10 +62,15 @@ export default function MobileSummary({
}
}, [isSummaryOpen])
if (!rateDefinitions) {
const roomRateDefinitions = roomsAvailability?.find(
(ra): ra is RoomsAvailability => "rateDefinitions" in ra
)
if (!roomRateDefinitions) {
return null
}
const rateDefinitions = roomRateDefinitions.rateDefinitions
const rooms = rateSummary.map((room, index) => ({
adults: bookingRooms[index].adults,
childrenInRoom: bookingRooms[index].childrenInRoom ?? undefined,

View File

@@ -28,12 +28,17 @@ import { RateTypeEnum } from "@/types/enums/rateType"
export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
const {
bookingRooms,
dates,
petRoomPackage,
rateSummary,
roomsAvailability,
searchParams,
} = useRatesStore((state) => ({
bookingRooms: state.booking.rooms,
dates: {
checkInDate: state.booking.fromDate,
checkOutDate: state.booking.toDate,
},
petRoomPackage: state.petRoomPackage,
rateSummary: state.rateSummary,
roomsAvailability: state.roomsAvailability,
@@ -50,8 +55,8 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
return null
}
const checkInDate = new Date(roomsAvailability.checkInDate)
const checkOutDate = new Date(roomsAvailability.checkOutDate)
const checkInDate = new Date(dates.checkInDate)
const checkOutDate = new Date(dates.checkOutDate)
const nights = dt(checkOutDate).diff(dt(checkInDate), "days")
const bookingCode = params.get("bookingCode")
@@ -186,8 +191,15 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
<SignupPromoDesktop
memberPrice={{
amount: rateSummary.reduce((total, room) => {
const memberPrice =
room.member?.localPrice.pricePerStay ?? 0
const memberPrice = room.member?.localPrice.pricePerStay
if (!memberPrice) {
return total
}
const hasSelectedPetRoom =
room.package === RoomPackageCodeEnum.PET_ROOM
if (!hasSelectedPetRoom) {
return total + memberPrice
}
const isPetRoom = room.features.find(
(feature) =>
feature.code === RoomPackageCodeEnum.PET_ROOM

View File

@@ -20,7 +20,6 @@ export default function SelectedRoomPanel() {
const intl = useIntl()
const { isUserLoggedIn, roomCategories } = useRatesStore((state) => ({
isUserLoggedIn: state.isUserLoggedIn,
rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories,
}))
const {

View File

@@ -75,24 +75,18 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const searchParams = useSearchParams()
const bookingCode = searchParams.get("bookingCode")
const {
hotelId,
hotelType,
isUserLoggedIn,
petRoomPackage,
rateDefinitions,
roomCategories,
} = useRatesStore((state) => ({
hotelId: state.booking.hotelId,
hotelType: state.hotelType,
isUserLoggedIn: state.isUserLoggedIn,
petRoomPackage: state.petRoomPackage,
rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories,
}))
const { isMainRoom, roomNr, selectedPackage } = useRoomContext()
const { hotelId, hotelType, isUserLoggedIn, petRoomPackage, roomCategories } =
useRatesStore((state) => ({
hotelId: state.booking.hotelId,
hotelType: state.hotelType,
isUserLoggedIn: state.isUserLoggedIn,
petRoomPackage: state.petRoomPackage,
roomCategories: state.roomCategories,
}))
const { isMainRoom, roomAvailability, roomNr, selectedPackage } =
useRoomContext()
if (!rateDefinitions) {
if (!roomAvailability || !("rateDefinitions" in roomAvailability)) {
return null
}
@@ -217,16 +211,16 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
<Caption color="uiTextMediumContrast">
{occupancy.max === occupancy.min
? intl.formatMessage(
{ id: "{guests, plural, one {# guest} other {# guests}}" },
{ guests: occupancy.max }
)
{ id: "{guests, plural, one {# guest} other {# guests}}" },
{ guests: occupancy.max }
)
: intl.formatMessage(
{ id: "{min}-{max} guests" },
{
min: occupancy.min,
max: occupancy.max,
}
)}
{ id: "{min}-{max} guests" },
{
min: occupancy.min,
max: occupancy.max,
}
)}
</Caption>
)}
<RoomSize roomSize={roomSize} />
@@ -282,7 +276,10 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const isAvailable =
product.public ||
(product.member && isUserLoggedIn && isMainRoom)
const rateDefinition = getRateDefinition(product, rateDefinitions)
const rateDefinition = getRateDefinition(
product,
roomAvailability.rateDefinitions
)
return (
<FlexibilityOption
key={product.rate}
@@ -296,7 +293,7 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
title={rateTitle}
rateTitle={
product.public &&
product.public?.rateType !== RateTypeEnum.Regular
product.public?.rateType !== RateTypeEnum.Regular
? rateDefinition?.title
: undefined
}

View File

@@ -27,7 +27,7 @@ export default function RoomTypeFilter() {
const intl = useIntl()
const availableRooms = rooms.filter(
(r) => r.status === AvailabilityEnum.Available
(room) => room.status === AvailabilityEnum.Available
).length
// const tooltipText = intl.formatMessage({
@@ -48,7 +48,7 @@ export default function RoomTypeFilter() {
id: "{availableRooms}/{numberOfRooms, plural, one {# room type} other {# room types}} available",
},
{
availableRooms: availableRooms,
availableRooms,
numberOfRooms: totalRooms,
}
)
@@ -81,7 +81,7 @@ export default function RoomTypeFilter() {
aria-label={option.description}
className={styles.radio}
id={option.code}
key={option.itemCode}
key={option.code}
>
<div className={styles.circle} />
<Caption color="uiTextHighContrast">{option.description}</Caption>

View File

@@ -25,19 +25,21 @@ export default function Rooms() {
departureDate: state.booking.toDate,
hotelId: state.booking.hotelId,
rooms: state.rooms,
visibleRooms: state.allRooms,
visibleRooms: state.roomConfigurations,
}))
useEffect(() => {
const pricesWithCurrencies = visibleRooms.flatMap((room) =>
room.products
.filter((product) => product.member || product.public)
.map((product) => ({
currency: (product.public?.localPrice.currency ||
product.member?.localPrice.currency)!,
price: (product.public?.localPrice.pricePerNight ||
product.member?.localPrice.pricePerNight)!,
}))
const pricesWithCurrencies = visibleRooms.flatMap((roomConfiguration) =>
roomConfiguration.flatMap((room) =>
room.products
.filter((product) => product.member || product.public)
.map((product) => ({
currency: (product.public?.localPrice.currency ||
product.member?.localPrice.currency)!,
price: (product.public?.localPrice.pricePerNight ||
product.member?.localPrice.pricePerNight)!,
}))
)
)
const lowestPrice = pricesWithCurrencies.reduce(
(minPrice, { price }) => Math.min(minPrice, price),

View File

@@ -26,11 +26,9 @@ export function RoomsContainer({
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
const toDateString = dt(toDate).format("YYYY-MM-DD")
const uniqueAdultsCount = Array.from(new Set(adultArray))
const { isPending: isLoadingAvailability, data: roomsAvailability } =
const { data: roomsAvailability, isPending: isLoadingAvailability } =
useRoomsAvailability(
uniqueAdultsCount,
adultArray,
hotelId,
fromDateString,
toDateString,

View File

@@ -0,0 +1,54 @@
import { beforeAll, describe, expect, it } from "@jest/globals"
import { getValidFromDate, getValidToDate } from "./getValidDates"
const NOW = new Date("2020-10-01T00:00:00Z")
describe("getValidFromDate", () => {
beforeAll(() => {
jest.useFakeTimers({ now: NOW })
})
afterAll(() => {
jest.useRealTimers()
})
describe("getValidFromDate", () => {
it("returns today when empty string is provided", () => {
const actual = getValidFromDate("")
expect(actual.toISOString()).toBe("2020-10-01T00:00:00.000Z")
})
it("returns today when undefined is provided", () => {
const actual = getValidFromDate(undefined)
expect(actual.toISOString()).toBe("2020-10-01T00:00:00.000Z")
})
it("returns given date in utc", () => {
const actual = getValidFromDate("2024-01-01")
expect(actual.toISOString()).toBe("2024-01-01T00:00:00.000Z")
})
})
describe("getValidToDate", () => {
it("returns day after fromDate when empty string is provided", () => {
const actual = getValidToDate("", NOW)
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
})
it("returns day after fromDate when undefined is provided", () => {
const actual = getValidToDate(undefined, NOW)
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
})
it("returns given date in utc", () => {
const actual = getValidToDate("2024-01-01", NOW)
expect(actual.toISOString()).toBe("2024-01-01T00:00:00.000Z")
})
it("fallsback to day after fromDate when given date is before fromDate", () => {
const actual = getValidToDate("2020-09-30", NOW)
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
})
})
})

View File

@@ -0,0 +1,55 @@
import { dt } from "@/lib/dt"
import type { Dayjs } from "dayjs"
/**
* Get valid dates from stringFromDate and stringToDate making sure that they are not in the past and chronologically correct
* @example const { fromDate, toDate} = getValidDates("2021-01-01", "2021-01-02")
*/
export function getValidDates(
stringFromDate: string | undefined,
stringToDate: string | undefined
): { fromDate: Dayjs; toDate: Dayjs } {
const fromDate = getValidFromDate(stringFromDate)
const toDate = getValidToDate(stringToDate, fromDate)
return { fromDate, toDate }
}
/**
* Get valid fromDate from stringFromDate making sure that it is not in the past
*/
export function getValidFromDate(stringFromDate: string | undefined): Dayjs {
const now = dt().utc()
if (!stringFromDate) {
return now
}
const toDate = dt(stringFromDate)
const yesterday = now.subtract(1, "day")
if (!toDate.isAfter(yesterday)) {
return now
}
return toDate
}
/**
* Get valid toDate from stringToDate making sure that it is after fromDate
*/
export function getValidToDate(
stringToDate: string | undefined,
fromDate: Dayjs | Date
): Dayjs {
const tomorrow = dt().utc().add(1, "day")
if (!stringToDate) {
return tomorrow
}
const toDate = dt(stringToDate)
if (toDate.isAfter(fromDate)) {
return toDate
}
return tomorrow
}

View File

@@ -1,12 +1,9 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import stringify from "json-stable-stringify-without-jsonify"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { getHotel } from "@/lib/trpc/memoizedRequests"
import { getValidDates } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/getValidDates"
import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils"
import { auth } from "@/auth"
import HotelInfoCard, {
HotelInfoCardSkeleton,
@@ -14,16 +11,14 @@ import HotelInfoCard, {
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/RoomsContainer"
import TrackingSDK from "@/components/TrackingSDK"
import { setLang } from "@/i18n/serverContext"
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
import { isValidSession } from "@/utils/session"
import { convertSearchParamsToObj } from "@/utils/url"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import { getValidDates } from "./getValidDates"
import { getTracking } from "./tracking"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectRatePage({
@@ -35,7 +30,7 @@ export default async function SelectRatePage({
if (!searchDetails?.hotel) {
return notFound()
}
const { hotel, adultsInRoom, childrenInRoom, selectHotelParams } =
const { adultsInRoom, childrenInRoom, hotel, noOfRooms, selectHotelParams } =
searchDetails
const { fromDate, toDate } = getValidDates(
@@ -55,41 +50,24 @@ export default async function SelectRatePage({
const arrivalDate = fromDate.toDate()
const departureDate = toDate.toDate()
const pageTrackingData: TrackingSDKPageData = {
pageId: "select-rate",
domainLanguage: params.lang,
channel: TrackingChannelEnum["hotelreservation"],
pageName: "hotelreservation|select-rate",
siteSections: "hotelreservation|select-rate",
pageType: "bookingroomsandratespage",
siteVersion: "new-web",
}
const hotelsTrackingData: TrackingSDKHotelInfo = {
searchTerm: selectHotelParams.city ?? hotel?.name,
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: adultsInRoom[0], // TODO: Handle multiple rooms
noOfChildren: childrenInRoom?.length,
ageOfChildren: childrenInRoom?.map((c) => c.age).join(","),
childBedPreference: childrenInRoom
?.map((c) => ChildBedMapEnum[c.bed])
.join("|"),
noOfRooms: 1, // // TODO: Handle multiple rooms
duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
searchType: "hotel",
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
country: hotelData?.hotel.address.country,
hotelID: hotel?.id,
region: hotelData?.hotel.address.city,
}
const hotelId = +hotel.id
const { hotelsTrackingData, pageTrackingData } = getTracking(
params.lang,
arrivalDate,
departureDate,
adultsInRoom,
childrenInRoom,
hotel.id,
hotel.name,
noOfRooms,
hotelData?.hotel.address.country,
hotelData?.hotel.address.city,
selectHotelParams.city
)
const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
const suspenseKey = stringify(searchParams)
const hotelId = +hotel.id
const suspenseKey = stringify(searchParams)
return (
<>
<Suspense fallback={<HotelInfoCardSkeleton />}>

View File

@@ -0,0 +1,61 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type { Lang } from "@/constants/languages"
import type { ChildrenInRoom } from "@/utils/hotelSearchDetails"
export function getTracking(
lang: Lang,
arrivalDate: Date,
departureDate: Date,
adultsInRoom: number[],
childrenInRoom: ChildrenInRoom,
hotelId: string,
hotelName: string,
noOfRooms: number,
country: string | undefined,
hotelCity: string | undefined,
paramCity: string | undefined
) {
const pageTrackingData: TrackingSDKPageData = {
channel: TrackingChannelEnum.hotelreservation,
domainLanguage: lang,
pageId: "select-rate",
pageName: "hotelreservation|select-rate",
pageType: "bookingroomsandratespage",
siteSections: "hotelreservation|select-rate",
siteVersion: "new-web",
}
const hotelsTrackingData: TrackingSDKHotelInfo = {
ageOfChildren: childrenInRoom
?.map((c) => c?.map((k) => k.age).join(",") ?? "none")
.join("|"),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
childBedPreference: childrenInRoom
?.map((c) => c?.map((k) => ChildBedMapEnum[k.bed]).join(",") ?? "-")
.join("|"),
country,
departureDate: format(departureDate, "yyyy-MM-dd"),
duration: differenceInCalendarDays(departureDate, arrivalDate),
hotelID: hotelId,
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
noOfAdults: adultsInRoom.join(","),
noOfChildren: childrenInRoom?.map((kids) => kids?.length ?? 0).join(","),
noOfRooms,
region: hotelCity,
searchTerm: paramCity ?? hotelName,
searchType: "hotel",
}
return {
hotelsTrackingData,
pageTrackingData,
}
}

View File

@@ -1,73 +1,48 @@
import { trpc } from "@/lib/trpc/client"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
import type { Lang } from "@/constants/languages"
export function combineRoomAvailabilities(
availabilityResults: PromiseSettledResult<RoomsAvailability | null>[]
) {
return availabilityResults.reduce<RoomsAvailability | null>((acc, result) => {
if (result.status === "fulfilled" && result.value) {
if (acc) {
acc = {
...acc,
roomConfigurations: [
...acc.roomConfigurations,
...result.value.roomConfigurations,
],
}
} else {
acc = result.value
}
}
// Ping monitoring about fail?
if (result.status === "rejected") {
console.info(`RoomAvailability fetch failed`)
console.error(result.reason)
}
return acc
}, null)
}
import type { ChildrenInRoom } from "@/utils/hotelSearchDetails"
export function useRoomsAvailability(
uniqueAdultsCount: number[],
adultsCount: number[],
hotelId: number,
fromDateString: string,
toDateString: string,
lang: Lang,
childArray?: Child[],
childArray: ChildrenInRoom,
bookingCode?: string
) {
const returnValue =
const roomsAvailability =
trpc.hotel.availability.roomsCombinedAvailability.useQuery({
hotelId,
roomStayStartDate: fromDateString,
roomStayEndDate: toDateString,
uniqueAdultsCount,
childArray,
lang,
adultsCount,
bookingCode,
childArray,
hotelId,
lang,
roomStayEndDate: toDateString,
roomStayStartDate: fromDateString,
})
const combinedAvailability = returnValue.data?.length
? combineRoomAvailabilities(
returnValue.data as PromiseSettledResult<RoomsAvailability | null>[]
)
: null
const data = roomsAvailability.data?.map((ra) => {
if (ra.status === "fulfilled") {
return ra.value
}
return {
details: ra.reason,
error: "request_failure",
}
})
return {
...returnValue,
data: combinedAvailability,
...roomsAvailability,
data,
}
}
export function useHotelPackages(
adultArray: number[],
childArray: Child[] | undefined,
childArray: ChildrenInRoom,
fromDateString: string,
toDateString: string,
hotelId: number,
@@ -75,7 +50,7 @@ export function useHotelPackages(
) {
return trpc.hotel.packages.get.useQuery({
adults: adultArray[0], // Using the first adult count
children: childArray ? childArray.length : undefined,
children: childArray?.[0]?.length, // Using the first children count
endDate: toDateString,
hotelId: hotelId.toString(),
packageCodes: [