Merged in feat/SW-1353 (pull request #1513)

feat: add multiroom tracking to booking flow

Approved-by: Linus Flood
This commit is contained in:
Simon.Emanuelsson
2025-03-17 09:35:12 +00:00
72 changed files with 2277 additions and 1308 deletions

View File

@@ -18,11 +18,14 @@ import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/D
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
import { generateChildrenString } from "@/components/HotelReservation/utils"
import Alert from "@/components/TempDesignSystem/Alert"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import RoomProvider from "@/providers/Details/RoomProvider"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { convertSearchParamsToObj } from "@/utils/url"
import { getTracking } from "./tracking"
import styles from "./page.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
@@ -57,34 +60,30 @@ export default async function DetailsPage({
const childrenAsString =
room.childrenInRoom && generateChildrenString(room.childrenInRoom)
const selectedRoomAvailabilityInput = {
const packages = room.packages
? await getPackages({
adults: room.adults,
children: room.childrenInRoom?.length,
endDate: booking.toDate,
hotelId: booking.hotelId,
packageCodes: room.packages,
startDate: booking.fromDate,
lang,
})
: null
const roomAvailability = await getSelectedRoomAvailability({
adults: room.adults,
bookingCode: booking.bookingCode,
children: childrenAsString,
counterRateCode: room.counterRateCode,
hotelId: booking.hotelId,
packageCodes: room.packages,
rateCode: room.rateCode,
roomStayStartDate: booking.fromDate,
roomStayEndDate: booking.toDate,
roomStayStartDate: booking.fromDate,
roomTypeCode: room.roomTypeCode,
counterRateCode: room.counterRateCode,
bookingCode: booking.bookingCode,
}
const packages = room.packages
? await getPackages({
adults: room.adults,
children: room.childrenInRoom?.length,
endDate: booking.toDate,
hotelId: booking.hotelId,
packageCodes: room.packages,
startDate: booking.fromDate,
lang,
})
: null
const roomAvailability = await getSelectedRoomAvailability(
selectedRoomAvailabilityInput
)
})
if (!roomAvailability) {
// redirect back to select-rate if availability call fails
@@ -98,8 +97,11 @@ export default async function DetailsPage({
mustBeGuaranteed: roomAvailability.mustBeGuaranteed,
memberMustBeGuaranteed: roomAvailability.memberMustBeGuaranteed,
packages,
rateTitle: roomAvailability.rateTitle,
rate: roomAvailability.rate,
rateDefinitionTitle: roomAvailability.rateDefinitionTitle,
rateDetails: roomAvailability.rateDetails ?? [],
rateTitle: roomAvailability.rateTitle,
rateType: roomAvailability.rateType,
roomType: roomAvailability.selectedRoom.roomType,
roomTypeCode: roomAvailability.selectedRoom.roomTypeCode,
roomRate: {
@@ -122,41 +124,28 @@ export default async function DetailsPage({
language: lang,
})
const user = await getProfileSafely()
// const userTrackingData = await getUserTracking()
if (!hotelData || !rooms) {
return notFound()
}
// const arrivalDate = new Date(booking.fromDate)
// const departureDate = new Date(booking.toDate)
const { hotel } = hotelData
// TODO: add tracking
// const initialHotelsTrackingData: TrackingSDKHotelInfo = {
// searchTerm: searchParams.city,
// arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
// departureDate: format(departureDate, "yyyy-MM-dd"),
// noOfAdults: adults,
// 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: hotel?.address.country,
// hotelID: hotel?.operaId,
// region: hotel?.address.city,
// }
const { hotelsTrackingData, pageTrackingData } = getTracking(
booking,
hotel,
rooms,
!!breakfastPackages?.length,
searchParams.city,
!!user,
lang
)
const intl = await getIntl()
const firstRoom = rooms[0]
const multirooms = rooms.slice(1)
const isRoomNotAvailable = rooms.some((room) => !room.isAvailable)
return (
<EnterDetailsProvider
@@ -214,6 +203,7 @@ export default async function DetailsPage({
</aside>
</div>
</main>
<TrackingSDK hotelInfo={hotelsTrackingData} pageData={pageTrackingData} />
</EnterDetailsProvider>
)
}

View File

@@ -0,0 +1,101 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { getSpecialRoomType } from "@/utils/specialRoomType"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type { Hotel } from "@/types/hotel"
import type { Room } from "@/types/providers/details/room"
import type { Lang } from "@/constants/languages"
import type { SelectHotelParams } from "@/utils/url"
export function getTracking(
booking: SelectHotelParams<SelectRateSearchParams>,
hotel: Hotel,
rooms: Room[],
offersBreakfast: boolean,
city: string | undefined,
isMember: boolean,
lang: Lang
) {
const arrivalDate = new Date(booking.fromDate)
const departureDate = new Date(booking.toDate)
const pageTrackingData: TrackingSDKPageData = {
channel: TrackingChannelEnum.hotelreservation,
domainLanguage: lang,
pageId: "details",
pageName: "hotelreservation|details",
pageType: "bookingroomsandratespage",
siteSections: "hotelreservation|details",
siteVersion: "new-web",
}
const hotelsTrackingData: TrackingSDKHotelInfo = {
ageOfChildren: booking.rooms
.map(
(room) => room.childrenInRoom?.map((kid) => kid.age).join(",") ?? "-"
)
.join("|"),
analyticsRateCode: rooms.map((room) => room.rate).join("|"),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
breakfastOption: rooms
.map(() => (offersBreakfast ? "breakfast buffet" : "no breakfast"))
.join(","),
childBedPreference: booking.rooms
.map(
(room) =>
room.childrenInRoom
?.map((kid) => ChildBedMapEnum[kid.bed])
.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: booking.rooms.map((room) => room.adults).join(","),
noOfChildren: booking.rooms
.map((room) => room.childrenInRoom?.length ?? 0)
.join(","),
noOfRooms: booking.rooms.length,
rateCode: rooms
.map((room, idx) => {
if (idx === 0 && isMember && room.roomRate.memberRate) {
return room.roomRate.memberRate?.rateCode
}
return room.roomRate.publicRate?.rateCode
})
.join("|"),
rateCodeCancellationRule: rooms
.map((room) => room.cancellationText.toLowerCase())
.join(","),
rateCodeName: rooms.map((room) => room.rateDefinitionTitle).join(","),
rateCodeType: rooms.map((room) => room.rateType.toLowerCase()).join(","),
region: hotel?.address.city,
revenueCurrencyCode: rooms
.map(
(room) =>
room.roomRate.publicRate?.localPrice.currency ??
room.roomRate.memberRate?.localPrice.currency
)
.join(","),
searchTerm: city,
searchType: "hotel",
specialRoomType: rooms
.map((room) => getSpecialRoomType(room.packages))
.join(","),
}
return {
hotelsTrackingData,
pageTrackingData,
}
}

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

@@ -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: [

View File

@@ -19,7 +19,7 @@ import { DetailsContext } from "@/contexts/Details"
import type { DetailsStore } from "@/types/contexts/enter-details"
import { StepEnum } from "@/types/enums/step"
import type { DetailsProviderProps } from "@/types/providers/enter-details"
import type { InitialState } from "@/types/stores/enter-details"
import type { InitialState, RoomState } from "@/types/stores/enter-details"
export default function EnterDetailsProvider({
booking,
@@ -50,9 +50,9 @@ export default function EnterDetailsProvider({
bedType:
room.bedTypes?.length === 1
? {
roomTypeCode: room.bedTypes[0].value,
description: room.bedTypes[0].description,
}
roomTypeCode: room.bedTypes[0].value,
description: room.bedTypes[0].description,
}
: undefined,
mustBeGuaranteed: room.mustBeGuaranteed,
isFlexRate: room.isFlexRate,
@@ -85,9 +85,13 @@ export default function EnterDetailsProvider({
}
const updatedRooms = storedValues.rooms.map((storedRoom, idx) => {
const room = store.rooms[idx]
if (!room) {
return null
}
// Need to create a deep new copy
// since store is readonly
const currentRoom = deepmerge({}, store.rooms[idx])
const currentRoom = deepmerge({}, room)
if (!currentRoom.room.isAvailable) {
return currentRoom
@@ -142,27 +146,38 @@ export default function EnterDetailsProvider({
})
const canProceedToPayment = updatedRooms.every(
(room) => room.isComplete && room.room.isAvailable
(room) => room?.isComplete && room?.room.isAvailable
)
const filteredOutMissingRooms = updatedRooms.filter(
(room): room is RoomState => !!room
)
const nights = dt(booking.toDate).diff(booking.fromDate, "days")
const currency = (updatedRooms[0].room.roomRate.publicRate?.localPrice
.currency ||
updatedRooms[0].room.roomRate.memberRate?.localPrice.currency)!
const totalPrice = calcTotalPrice(updatedRooms, currency, !!user, nights)
const currency = (filteredOutMissingRooms[0].room.roomRate.publicRate
?.localPrice.currency ||
filteredOutMissingRooms[0].room.roomRate.memberRate?.localPrice.currency)!
const totalPrice = calcTotalPrice(
filteredOutMissingRooms,
currency,
!!user,
nights
)
const activeRoom = updatedRooms.findIndex((room) => !room.isComplete)
const activeRoom = filteredOutMissingRooms.findIndex(
(room) => !room.isComplete
)
writeToSessionStorage({
activeRoom,
booking,
rooms: updatedRooms,
rooms: filteredOutMissingRooms,
})
storeRef.current?.setState({
activeRoom: storedValues.activeRoom,
canProceedToPayment,
rooms: updatedRooms,
rooms: filteredOutMissingRooms,
totalPrice,
})
}, [booking, rooms, user])

View File

@@ -14,9 +14,11 @@ export default function RoomProvider({
const activeRoom = useRatesStore((state) => state.activeRoom)
const closeSection = useRatesStore((state) => state.actions.closeSection(idx))
const modifyRate = useRatesStore((state) => state.actions.modifyRate(idx))
const roomAvailability = useRatesStore(
(state) => state.roomsAvailability?.[idx]
)
const selectFilter = useRatesStore((state) => state.actions.selectFilter(idx))
const selectRate = useRatesStore((state) => state.actions.selectRate(idx))
const totalRooms = useRatesStore((state) => state.allRooms.length)
const roomNr = idx + 1
return (
<RoomContext.Provider
@@ -30,8 +32,9 @@ export default function RoomProvider({
},
isActiveRoom: activeRoom === idx,
isMainRoom: roomNr === 1,
roomAvailability,
roomNr,
totalRooms,
totalRooms: room.rooms.length,
}}
>
{children}

View File

@@ -3,52 +3,67 @@ import { z } from "zod"
import { ChildBedTypeEnum } from "@/constants/booking"
import { Lang, langToApiLang } from "@/constants/languages"
const signupSchema = z.discriminatedUnion("becomeMember", [
z.object({
dateOfBirth: z.string(),
postalCode: z.string(),
becomeMember: z.literal<boolean>(true),
}),
z.object({ becomeMember: z.literal<boolean>(false) }),
])
const roomsSchema = z.array(
z.object({
adults: z.number().int().nonnegative(),
childrenAges: z
.array(
z.object({
age: z.number().int().nonnegative(),
bedType: z.nativeEnum(ChildBedTypeEnum),
})
)
.default([]),
rateCode: z.string(),
roomTypeCode: z.coerce.string(),
guest: z.intersection(
z.object({
const roomsSchema = z
.array(
z.object({
adults: z.number().int().nonnegative(),
childrenAges: z
.array(
z.object({
age: z.number().int().nonnegative(),
bedType: z.nativeEnum(ChildBedTypeEnum),
})
)
.default([]),
rateCode: z.string(),
roomTypeCode: z.coerce.string(),
guest: z.object({
becomeMember: z.boolean(),
countryCode: z.string(),
dateOfBirth: z.string().nullish(),
email: z.string().email(),
firstName: z.string(),
lastName: z.string(),
email: z.string().email(),
membershipNumber: z.string().nullish(),
postalCode: z.string().nullish(),
phoneNumber: z.string(),
countryCode: z.string(),
membershipNumber: z.string().optional(),
}),
signupSchema
),
smsConfirmationRequested: z.boolean(),
packages: z.object({
breakfast: z.boolean(),
allergyFriendly: z.boolean(),
petFriendly: z.boolean(),
accessibility: z.boolean(),
}),
roomPrice: z.object({
memberPrice: z.number().nullish(),
publicPrice: z.number().nullish(),
}),
smsConfirmationRequested: z.boolean(),
packages: z.object({
breakfast: z.boolean(),
allergyFriendly: z.boolean(),
petFriendly: z.boolean(),
accessibility: z.boolean(),
}),
roomPrice: z.object({
memberPrice: z.number().nullish(),
publicPrice: z.number().nullish(),
}),
})
)
.superRefine((data, ctx) => {
data.forEach((room, idx) => {
if (idx === 0 && room.guest.becomeMember) {
if (!room.guest.dateOfBirth) {
ctx.addIssue({
code: z.ZodIssueCode.invalid_type,
expected: "string",
received: typeof room.guest.dateOfBirth,
path: ["guest", "dateOfBirth"],
})
}
if (!room.guest.postalCode) {
ctx.addIssue({
code: z.ZodIssueCode.invalid_type,
expected: "string",
received: typeof room.guest.postalCode,
path: ["guest", "postalCode"],
})
}
}
})
})
)
const paymentSchema = z.object({
paymentMethod: z.string(),

View File

@@ -26,21 +26,25 @@ export const getHotelsByHotelIdsAvailabilityInputSchema = z.object({
})
export const roomsCombinedAvailabilityInputSchema = z.object({
hotelId: z.number(),
roomStayStartDate: z.string(),
roomStayEndDate: z.string(),
uniqueAdultsCount: z.array(z.number()),
adultsCount: z.array(z.number()),
bookingCode: z.string().optional(),
childArray: z
.array(
z.object({
bed: z.nativeEnum(ChildBedMapEnum),
age: z.number(),
})
z
.array(
z.object({
age: z.number(),
bed: z.nativeEnum(ChildBedMapEnum),
})
)
.nullable()
)
.optional(),
bookingCode: z.string().optional(),
rateCode: z.string().optional(),
.nullish(),
hotelId: z.number(),
lang: z.nativeEnum(Lang),
rateCode: z.string().optional(),
roomStayEndDate: z.string(),
roomStayStartDate: z.string(),
})
export const selectedRoomAvailabilityInputSchema = z.object({

View File

@@ -22,6 +22,7 @@ import { relationshipsSchema } from "./schemas/relationships"
import { roomConfigurationSchema } from "./schemas/roomAvailability/configuration"
import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type {
AdditionalData,
City,
@@ -137,6 +138,13 @@ const cancellationRules = {
NotCancellable: 0,
} as const
// Used to ensure `Available` rooms
// are shown before all `NotAvailable`
const statusLookup = {
[AvailabilityEnum.Available]: 1,
[AvailabilityEnum.NotAvailable]: 2,
}
export const roomsAvailabilitySchema = z
.object({
data: z.object({
@@ -161,8 +169,8 @@ export const roomsAvailabilitySchema = z
return acc
}, {})
attributes.roomConfigurations = attributes.roomConfigurations.map(
(room) => {
const roomConfigurations = attributes.roomConfigurations
.map((room) => {
if (room.products.length) {
room.breakfastIncludedInAllRatesMember = room.products.every(
(product) =>
@@ -222,10 +230,16 @@ export const roomsAvailabilitySchema = z
}
return room
}
)
})
.sort(
// @ts-expect-error - array indexing
(a, b) => statusLookup[a.status] - statusLookup[b.status]
)
return attributes
return {
...attributes,
roomConfigurations,
}
})
export const ratesSchema = z.array(rateSchema)

View File

@@ -499,20 +499,20 @@ export const hotelQueryRouter = router({
const { lang } = input
const apiLang = toApiLang(lang)
const {
hotelId,
roomStayStartDate,
roomStayEndDate,
uniqueAdultsCount,
childArray,
adultsCount,
bookingCode,
childArray,
hotelId,
rateCode,
roomStayEndDate,
roomStayStartDate,
} = input
const metricsData = {
hotelId,
roomStayStartDate,
roomStayEndDate,
uniqueAdultsCount,
adultsCount,
childArray: childArray ? JSON.stringify(childArray) : undefined,
bookingCode,
}
@@ -525,15 +525,15 @@ export const hotelQueryRouter = router({
)
const availabilityResponses = await Promise.allSettled(
uniqueAdultsCount.map(async (adultCount: number) => {
adultsCount.map(async (adultCount: number, idx: number) => {
const kids = childArray?.[idx]
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults: adultCount,
...(childArray &&
childArray.length > 0 && {
children: generateChildrenString(childArray),
}),
...(kids?.length && {
children: generateChildrenString(kids),
}),
...(bookingCode && { bookingCode }),
language: apiLang,
}
@@ -769,9 +769,9 @@ export const hotelQueryRouter = router({
type: matchingRoom.mainBed.type,
extraBed: matchingRoom.fixedExtraBed
? {
type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description,
}
type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description,
}
: undefined,
}
}
@@ -794,23 +794,27 @@ export const hotelQueryRouter = router({
)
return {
selectedRoom,
rateDetails: rateDefinition?.generalTerms,
bedTypes,
breakfastIncluded: !!rateDefinition?.breakfastIncluded,
cancellationRule: rateDefinition?.cancellationRule,
cancellationText: rateDefinition?.cancellationText ?? "",
isFlexRate:
rateDefinition?.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM,
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
memberMustBeGuaranteed: !!memberRateDefinition?.mustBeGuaranteed,
breakfastIncluded: !!rateDefinition?.breakfastIncluded,
memberRate: rates?.member,
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
publicRate: rates?.public,
rate: selectedRoom.products[0].rate,
rateDefinitionTitle: rateDefinition?.title ?? "",
rateDetails: rateDefinition?.generalTerms,
// Send rate Title when it is a booking code rate
rateTitle:
rateDefinition?.rateType !== RateTypeEnum.Regular
? rateDefinition?.title
: undefined,
memberRate: rates?.member,
publicRate: rates?.public,
bedTypes,
rateType: rateDefinition?.rateType ?? "",
selectedRoom,
}
}),
hotelsByCityWithBookingCode: serviceProcedure
@@ -1096,9 +1100,9 @@ export const hotelQueryRouter = router({
return hotelData
? {
...hotelData,
url: hotelPage?.url ?? null,
}
...hotelData,
url: hotelPage?.url ?? null,
}
: null
})
)

View File

@@ -38,9 +38,15 @@ export const roomConfigurationSchema = z
(product) => !product.public?.rateCode && !product.member?.rateCode
)
if (allProductsMissBothRateCodes) {
data.status = AvailabilityEnum.NotAvailable
return {
...data,
status: AvailabilityEnum.NotAvailable,
}
}
}
return data
// Creating a new objekt since data is frozen (readony)
// and can cause errors to be thrown if trying to manipulate
// object elsewhere
return { ...data }
})

View File

@@ -211,12 +211,20 @@ export function calcTotalPrice(
? parseInt(room.breakfast.localPrice?.price ?? 0)
: 0
const roomFeaturesTotal = room.roomFeatures?.reduce((total, pkg) => {
if (pkg.requestedPrice.totalPrice) {
total = add(total, pkg.requestedPrice.totalPrice)
}
return total
}, 0)
const roomFeaturesTotal = room.roomFeatures?.reduce(
(total, pkg) => {
if (pkg.requestedPrice.totalPrice) {
total.requestedPrice = add(
total.requestedPrice,
pkg.requestedPrice.totalPrice
)
}
total.local = add(total.local, pkg.localPrice.totalPrice)
return total
},
{ local: 0, requestedPrice: 0 }
)
const result: Price = {
requested: roomPrice.perStay.requested
@@ -235,13 +243,13 @@ export function calcTotalPrice(
acc.local.price,
roomPrice.perStay.local.price,
breakfastLocalPrice * room.adults * nights,
roomFeaturesTotal
roomFeaturesTotal?.local ?? 0
),
regularPrice: add(
acc.local.regularPrice,
roomPrice.perStay.local.regularPrice,
breakfastLocalPrice * room.adults * nights,
roomFeaturesTotal
roomFeaturesTotal?.requestedPrice ?? 0
),
},
}

View File

@@ -369,24 +369,33 @@ export function createDetailsStore(
return set(
produce((state: DetailsState) => {
state.rooms[idx].steps[StepEnum.details].isValid = true
const currentRoom = state.rooms[idx].room
state.rooms[idx].room.guest.countryCode = data.countryCode
state.rooms[idx].room.guest.dateOfBirth = data.dateOfBirth
state.rooms[idx].room.guest.email = data.email
state.rooms[idx].room.guest.firstName = data.firstName
state.rooms[idx].room.guest.join = data.join
state.rooms[idx].room.guest.lastName = data.lastName
currentRoom.guest.countryCode = data.countryCode
currentRoom.guest.email = data.email
currentRoom.guest.firstName = data.firstName
currentRoom.guest.join = data.join
currentRoom.guest.lastName = data.lastName
if (data.join) {
state.rooms[idx].room.guest.membershipNo = undefined
currentRoom.guest.membershipNo = undefined
} else {
state.rooms[idx].room.guest.membershipNo = data.membershipNo
currentRoom.guest.membershipNo = data.membershipNo
}
state.rooms[idx].room.guest.phoneNumber = data.phoneNumber
state.rooms[idx].room.guest.zipCode = data.zipCode
currentRoom.guest.phoneNumber = data.phoneNumber
state.rooms[idx].room.roomPrice = getRoomPrice(
state.rooms[idx].room.roomRate,
// Only valid for room 1
if (idx === 0 && data.join && !isMember) {
if ("dateOfBirth" in currentRoom.guest) {
currentRoom.guest.dateOfBirth = data.dateOfBirth
}
if ("zipCode" in currentRoom.guest) {
currentRoom.guest.zipCode = data.zipCode
}
}
currentRoom.roomPrice = getRoomPrice(
currentRoom.roomRate,
Boolean(data.join || data.membershipNo || isMember)
)

View File

@@ -1,126 +0,0 @@
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
/**
* Get the lowest priced room for each room type that appears more than once.
*/
export function filterDuplicateRoomTypesByLowestPrice(
roomConfigurations: RoomConfiguration[]
): RoomConfiguration[] {
const roomTypeCount = roomConfigurations.reduce<Record<string, number>>(
(roomTypeTally, currentRoom) => {
const currentRoomType = currentRoom.roomType
const currentCount = roomTypeTally[currentRoomType] || 0
return {
...roomTypeTally,
[currentRoomType]: currentCount + 1,
}
},
{}
)
const duplicateRoomTypes = new Set(
Object.keys(roomTypeCount).filter((roomType) => roomTypeCount[roomType] > 1)
)
const roomMap = new Map()
roomConfigurations.forEach((room) => {
const { roomType, products, status } = room
if (!duplicateRoomTypes.has(roomType)) {
roomMap.set(roomType, room)
return
}
const previousRoom = roomMap.get(roomType)
// Prioritize 'Available' status
if (
status === AvailabilityEnum.Available &&
previousRoom?.status === AvailabilityEnum.NotAvailable
) {
roomMap.set(roomType, room)
return
}
if (
status === AvailabilityEnum.NotAvailable &&
previousRoom?.status === AvailabilityEnum.Available
) {
return
}
if (previousRoom) {
products.forEach((product) => {
const publicProduct = product?.public || {
requestedPrice: null,
localPrice: null,
}
const memberProduct = product?.member || {
requestedPrice: null,
localPrice: null,
}
const {
requestedPrice: publicRequestedPrice,
localPrice: publicLocalPrice,
} = publicProduct
const {
requestedPrice: memberRequestedPrice,
localPrice: memberLocalPrice,
} = memberProduct
const previousLowest = roomMap.get(roomType)
const currentRequestedPrice = Math.min(
Number(publicRequestedPrice?.pricePerNight) ?? Infinity,
Number(memberRequestedPrice?.pricePerNight) ?? Infinity
)
const currentLocalPrice = Math.min(
Number(publicLocalPrice?.pricePerNight) ?? Infinity,
Number(memberLocalPrice?.pricePerNight) ?? Infinity
)
if (
!previousLowest ||
currentRequestedPrice <
Math.min(
Number(
previousLowest.products[0].public?.requestedPrice?.pricePerNight
) ?? Infinity,
Number(
previousLowest.products[0].member?.requestedPrice?.pricePerNight
) ?? Infinity
) ||
(currentRequestedPrice ===
Math.min(
Number(
previousLowest.products[0].public?.requestedPrice?.pricePerNight
) ?? Infinity,
Number(
previousLowest.products[0].member?.requestedPrice?.pricePerNight
) ?? Infinity
) &&
currentLocalPrice <
Math.min(
Number(
previousLowest.products[0].public?.localPrice?.pricePerNight
) ?? Infinity,
Number(
previousLowest.products[0].member?.localPrice?.pricePerNight
) ?? Infinity
))
) {
roomMap.set(roomType, room)
}
})
} else {
roomMap.set(roomType, room)
}
})
return Array.from(roomMap.values())
}

View File

@@ -3,26 +3,25 @@ import { ReadonlyURLSearchParams } from "next/navigation"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { filterDuplicateRoomTypesByLowestPrice } from "@/stores/select-rate/helper"
import { RatesContext } from "@/contexts/Rates"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { InitialState, RatesState } from "@/types/stores/rates"
import type {
AvailabilityError,
InitialState,
RatesState,
} from "@/types/stores/rates"
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
const statusLookup = {
[AvailabilityEnum.Available]: 1,
[AvailabilityEnum.NotAvailable]: 2,
}
function findSelectedRate(
rateCode: string,
roomTypeCode: string,
rooms: RoomConfiguration[]
rooms: RoomConfiguration[] | AvailabilityError
) {
if (!Array.isArray(rooms)) {
return null
}
return rooms.find(
(room) =>
room.roomTypeCode === roomTypeCode &&
@@ -70,23 +69,26 @@ export function createRatesStore({
},
]
let allRooms: RoomConfiguration[] = []
if (roomsAvailability?.roomConfigurations) {
allRooms = filterDuplicateRoomTypesByLowestPrice(
roomsAvailability.roomConfigurations
).sort(
// @ts-expect-error - array indexing
(a, b) => statusLookup[a.status] - statusLookup[b.status]
)
let roomConfigurations: RatesState["roomConfigurations"] = []
if (roomsAvailability) {
for (const availability of roomsAvailability) {
if ("error" in availability) {
// Availability request failed, default to empty array
roomConfigurations.push([])
} else {
roomConfigurations.push(availability.roomConfigurations)
}
}
}
const rateSummary: RatesState["rateSummary"] = []
booking.rooms.forEach((room, idx) => {
if (room.rateCode && room.roomTypeCode) {
const selectedRoom = roomsAvailability?.roomConfigurations.find(
(roomConf) =>
roomConf.roomTypeCode === room.roomTypeCode &&
roomConf.products.find(
const roomConfiguration = roomConfigurations?.[idx]
const selectedRoom = roomConfiguration.find(
(rc) =>
rc.roomTypeCode === room.roomTypeCode &&
rc.products.find(
(product) =>
product.public?.rateCode === room.rateCode ||
product.member?.rateCode === room.rateCode
@@ -149,31 +151,34 @@ export function createRatesStore({
return set(
produce((state: RatesState) => {
state.rooms[idx].selectedPackage = code
const searchParams = new URLSearchParams(state.searchParams)
if (code) {
state.rooms[idx].rooms = state.allRooms.filter((room) =>
room.features.find((feat) => feat.code === code)
const roomConfiguration = state.roomConfigurations[idx]
if (roomConfiguration) {
const searchParams = new URLSearchParams(state.searchParams)
if (code) {
state.rooms[idx].rooms = roomConfiguration.filter((room) =>
room.features.find((feat) => feat.code === code)
)
searchParams.set(`room[${idx}].packages`, code)
if (state.rateSummary[idx]) {
state.rateSummary[idx].package = code
}
} else {
state.rooms[idx].rooms = roomConfiguration
searchParams.delete(`room[${idx}].packages`)
if (state.rateSummary[idx]) {
state.rateSummary[idx].package = undefined
}
}
state.searchParams = new ReadonlyURLSearchParams(searchParams)
window.history.pushState(
{},
"",
`${state.pathname}?${searchParams}`
)
searchParams.set(`room[${idx}].packages`, code)
if (state.rateSummary[idx]) {
state.rateSummary[idx].package = code
}
} else {
state.rooms[idx].rooms = state.allRooms
searchParams.delete(`room[${idx}].packages`)
if (state.rateSummary[idx]) {
state.rateSummary[idx].package = undefined
}
}
state.searchParams = new ReadonlyURLSearchParams(searchParams)
window.history.pushState(
{},
"",
`${state.pathname}?${searchParams}`
)
})
)
}
@@ -247,7 +252,6 @@ export function createRatesStore({
},
},
activeRoom,
allRooms,
booking,
filterOptions,
hotelType,
@@ -258,9 +262,12 @@ export function createRatesStore({
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
),
rateSummary,
rooms: booking.rooms.map((room) => {
roomConfigurations,
rooms: booking.rooms.map((room, idx) => {
const roomConfiguration = roomConfigurations[idx]
const selectedRate =
findSelectedRate(room.rateCode, room.roomTypeCode, allRooms) ?? null
findSelectedRate(room.rateCode, room.roomTypeCode, roomConfiguration) ??
null
const product = selectedRate?.products.find(
(prd) =>
@@ -270,22 +277,25 @@ export function createRatesStore({
const selectedPackage = room.packages?.[0]
let rooms: RoomConfiguration[] = roomConfiguration
if (selectedPackage) {
rooms = roomConfiguration.filter((r) =>
r.features.find((f) => f.code === selectedPackage)
)
}
return {
bookingRoom: room,
rooms: selectedPackage
? allRooms.filter((r) =>
r.features.find((f) => f.code === selectedPackage)
)
: allRooms,
rooms,
selectedPackage,
selectedRate:
selectedRate && product
? {
features: selectedRate.features,
product,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
}
features: selectedRate.features,
product,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
}
: null,
}
}),

View File

@@ -1,5 +1,6 @@
import type { Hotel } from "@/types/hotel"
import type { ProductType } from "@/types/trpc/routers/hotel/availability"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
export enum HotelCardListingTypeEnum {
MapListing = "mapListing",
@@ -12,7 +13,7 @@ export type HotelData = {
}
export type HotelCardListingProps = {
hotelData: HotelData[]
hotelData: HotelResponse[]
type?: HotelCardListingTypeEnum
}

View File

@@ -1,10 +1,8 @@
import {
type HotelCardListingTypeEnum,
type HotelData,
} from "./hotelCardListingProps"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
import type { HotelCardListingTypeEnum } from "./hotelCardListingProps"
export type HotelCardProps = {
hotel: HotelData
hotelData: HotelResponse
isUserLoggedIn: boolean
type?: HotelCardListingTypeEnum
state?: "default" | "active"

View File

@@ -2,8 +2,8 @@ import type { z } from "zod"
import type { Coordinates } from "@/types/components/maps/coordinates"
import type { Amenities } from "@/types/hotel"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
import type { imageSchema } from "@/server/routers/hotels/schemas/image"
import type { HotelData } from "./hotelCardListingProps"
import type { CategorizedFilters } from "./hotelFilters"
import type {
AlternativeHotelsSearchParams,
@@ -11,14 +11,14 @@ import type {
} from "./selectHotelSearchParams"
export interface HotelListingProps {
hotels: HotelData[]
hotels: HotelResponse[]
}
export interface SelectHotelMapProps {
apiKey: string
hotelPins: HotelPin[]
mapId: string
hotels: HotelData[]
hotels: HotelResponse[]
filterList: CategorizedFilters
cityCoordinates: Coordinates
bookingCode: string | undefined
@@ -66,7 +66,7 @@ export interface HotelCardDialogImageProps {
}
export interface HotelCardDialogListingProps {
hotels: HotelData[] | null
hotels: HotelResponse[]
}
export type SelectHotelMapContainerProps = {

View File

@@ -1,7 +1,8 @@
import type { HotelData } from "./hotelCardListingProps"
import type { Hotel } from "@/types/hotel"
export type NoAvailabilityAlertProp = {
hotelsLength: number
isAllUnavailable: boolean
isAlternative?: boolean
hotels: HotelData[]
operaId: Hotel["operaId"]
}

View File

@@ -1,11 +1,12 @@
import type { HotelData } from "@/types/hotel"
import type { Child, SelectRateSearchParams } from "./selectRate"
import type { ChildrenInRoom } from "@/utils/hotelSearchDetails"
import type { SelectRateSearchParams } from "./selectRate"
export interface RoomsContainerProps {
adultArray: number[]
booking: SelectRateSearchParams
bookingCode?: string
childArray?: Child[]
childArray: ChildrenInRoom
fromDate: Date
hotelData: HotelData | null
hotelId: number

View File

@@ -47,46 +47,46 @@ export type TrackingSDKUserData = {
}
export type TrackingSDKHotelInfo = {
hotelID?: string
arrivalDate?: string
departureDate?: string
noOfAdults?: number
noOfChildren?: number
ageOfChildren?: string // "10", "2,5,10"
//rewardNight?: boolean
ancillaries?: Ancillary[]
analyticsRateCode?: "flex" | "change" | "save" | string
arrivalDate?: string
availableResults?: number // Number of hotels to choose from after a city search
bedType?: string
bedTypePosition?: number // Which position the bed type had in the list of available bed types
bnr?: string // Booking number
breakfastOption?: string // "no breakfast" or "breakfast buffet"
//bonuscheque?: boolean
//bookingCode?: string
//bookingCodeAvailability?: boolean
leadTime?: number // Number of days from booking date until arrivalDate
noOfRooms?: number
//bonuscheque?: boolean
childBedPreference?: string
duration?: number // Number of nights to stay
availableResults?: number // Number of hotels to choose from after a city search
bookingTypeofDay?: "weekend" | "weekday"
searchTerm?: string
roomPrice?: number
childBedPreference?: string
country?: string // Country of the hotel
departureDate?: string
discount?: number
duration?: number // Number of nights to stay
hotelID?: string
leadTime?: number // Number of days from booking date until arrivalDate
lowestRoomPrice?: number
//modifyValues?: string // <price:<value>,roomtype:value>,bed:<value,<breakfast:value>
noOfAdults?: number | string // multiroom support, "2,1,3"
noOfChildren?: number | string // multiroom support, "2,1,3"
noOfRooms?: number
//rewardNight?: boolean
rateCode?: string
rateCodeCancellationRule?: string
rateCodeName?: string // Scandic Friends - full flex inkl. frukost
rateCodeType?: string // regular, promotion etc
revenueCurrencyCode?: string // SEK, DKK, NOK, EUR
roomTypeCode?: string
roomTypePosition?: number // Which position the room had in the list of available rooms
roomTypeName?: string
bedType?: string
bedTypePosition?: number // Which position the bed type had in the list of available bed types
breakfastOption?: string // "no breakfast" or "breakfast buffet"
bnr?: string // Booking number
analyticsrateCode?: "flex" | "change" | "save" | string
specialRoomType?: string // allergy room, pet-friendly, accesibillity room
//modifyValues?: string // <price:<value>,roomtype:value>,bed:<value,<breakfast:value>
country?: string // Country of the hotel
region?: string // Region of the hotel
discount?: number
totalPrice?: number
lowestRoomPrice?: number
revenueCurrencyCode?: string // SEK, DKK, NOK, EUR
roomPrice?: number | string
roomTypeCode?: string
roomTypeName?: string
roomTypePosition?: number // Which position the room had in the list of available rooms
searchTerm?: string
searchType?: "destination" | "hotel"
ancillaries?: Ancillary[]
specialRoomType?: string // allergy room, pet-friendly, accesibillity room
totalPrice?: number | string
}
export type Ancillary = {

View File

@@ -1,5 +1,9 @@
import type { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectedRate, SelectedRoom } from "@/types/stores/rates"
import type {
RatesState,
SelectedRate,
SelectedRoom,
} from "@/types/stores/rates"
export interface RoomContextValue extends SelectedRoom {
actions: {
@@ -10,6 +14,9 @@ export interface RoomContextValue extends SelectedRoom {
}
isActiveRoom: boolean
isMainRoom: boolean
roomAvailability:
| NonNullable<RatesState["roomsAvailability"]>[number]
| undefined
roomNr: number
totalRooms: number
}

View File

@@ -10,8 +10,11 @@ export interface Room {
mustBeGuaranteed: boolean
memberMustBeGuaranteed?: boolean
packages: Packages | null
rate: "change" | "flex" | "save"
rateDefinitionTitle: string
rateDetails: string[]
rateTitle?: string
rateType: string
roomRate: RoomRate
roomType: string
roomTypeCode: string

View File

@@ -2,6 +2,7 @@ import type { Room } from "@/types/hotel"
import type { Packages } from "@/types/requests/packages"
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate"
import type { AvailabilityError } from "../stores/rates"
export interface RatesProviderProps extends React.PropsWithChildren {
booking: SelectRateSearchParams
@@ -9,6 +10,6 @@ export interface RatesProviderProps extends React.PropsWithChildren {
isUserLoggedIn: boolean
packages: Packages | null
roomCategories: Room[]
roomsAvailability: RoomsAvailability | null
roomsAvailability: (RoomsAvailability | AvailabilityError)[] | undefined
vat: number
}

View File

@@ -16,12 +16,16 @@ export interface Room {
breakfastIncluded: boolean
children?: number
childBedPreferences: ChildBedPreference[]
childrenAges?: number[]
confirmationNumber: string
currencyCode: string
fromDate: Date
name: string
packages: BookingConfirmation["booking"]["packages"]
rateDefinition: BookingConfirmation["booking"]["rateDefinition"]
roomFeatures?: PackageSchema[] | null
roomPrice: number
roomTypeCode: string | null
toDate: Date
totalPrice: number
totalPriceExVat: number

View File

@@ -22,20 +22,21 @@ import type {
import type { Packages } from "../requests/packages"
export interface InitialRoomData {
isAvailable: boolean
bedType?: BedTypeSchema // used when there is only one bedtype to preselect it
// used when there is only one bedtype to preselect it
bedType?: BedTypeSchema
bedTypes: BedTypeSelection[]
breakfastIncluded: boolean
cancellationText: string
cancellationRule?: string
isAvailable: boolean
isFlexRate: boolean
mustBeGuaranteed: boolean
rateDetails: string[] | undefined
rateTitle?: string
roomFeatures: Packages | null
roomRate: RoomRate
roomType: string
roomTypeCode: string
mustBeGuaranteed: boolean
isFlexRate: boolean
}
export type RoomStep = {
@@ -43,17 +44,19 @@ export type RoomStep = {
isValid: boolean
}
export interface Room extends InitialRoomData {
adults: number
bedType: BedTypeSchema | undefined
breakfast: BreakfastPackage | false | undefined
childrenInRoom: Child[] | undefined
guest: DetailsSchema | MultiroomDetailsSchema | SignedInDetailsSchema
roomPrice: RoomPrice
}
export interface RoomState {
currentStep: StepEnum | null
isComplete: boolean
room: InitialRoomData & {
adults: number
bedType: BedTypeSchema | undefined
breakfast: BreakfastPackage | false | undefined
childrenInRoom: Child[] | undefined
guest: DetailsSchema | SignedInDetailsSchema
roomPrice: RoomPrice
}
room: Room
steps: {
[StepEnum.selectBed]: RoomStep
[StepEnum.breakfast]?: RoomStep

View File

@@ -17,6 +17,11 @@ import type {
RoomsAvailability,
} from "@/types/trpc/routers/hotel/roomAvailability"
export interface AvailabilityError {
details: string
error: string
}
interface Actions {
closeSection: (idx: number) => () => void
modifyRate: (idx: number) => () => void
@@ -41,7 +46,6 @@ export interface SelectedRoom {
export interface RatesState {
actions: Actions
activeRoom: number
allRooms: RoomConfiguration[]
booking: SelectRateSearchParams
filterOptions: DefaultFilterOptions[]
hotelType: string | undefined
@@ -52,7 +56,8 @@ export interface RatesState {
rateSummary: Rate[]
rooms: SelectedRoom[]
roomCategories: Room[]
roomsAvailability: RoomsAvailability | null
roomConfigurations: RoomConfiguration[][]
roomsAvailability: (RoomsAvailability | AvailabilityError)[] | undefined
searchParams: ReadonlyURLSearchParams
vat: number
}

View File

@@ -21,22 +21,26 @@ import {
type Location,
} from "@/types/trpc/routers/hotel/locations"
export type ChildrenInRoom = (Child[] | null)[] | null
export type ChildrenInRoomString = (string | null)[] | null
interface HotelSearchDetails<T> {
adultsInRoom: number[]
bookingCode?: string
childrenInRoom: ChildrenInRoom
childrenInRoomString: ChildrenInRoomString
city: Location | null
hotel: HotelLocation | null
selectHotelParams: SelectHotelParams<T> & { city: string | undefined }
adultsInRoom: number[]
childrenInRoomString?: string
childrenInRoom?: Child[]
bookingCode?: string
noOfRooms: number
redemption?: boolean
selectHotelParams: SelectHotelParams<T> & { city: string | undefined }
}
export async function getHotelSearchDetails<
T extends
| SelectHotelSearchParams
| SelectRateSearchParams
| AlternativeHotelsSearchParams,
| SelectHotelSearchParams
| SelectRateSearchParams
| AlternativeHotelsSearchParams,
>(
{
searchParams,
@@ -85,28 +89,29 @@ export async function getHotelSearchDetails<
if (isAlternativeHotels && (!city || !hotel)) return notFound()
let adultsInRoom: number[] = []
let childrenInRoomString: HotelSearchDetails<T>["childrenInRoomString"] =
undefined
let childrenInRoom: HotelSearchDetails<T>["childrenInRoom"] = undefined
let childrenInRoom: ChildrenInRoom = null
let childrenInRoomString: ChildrenInRoomString = null
const { rooms } = selectHotelParams
if (rooms && rooms.length > 0) {
if (rooms?.length) {
adultsInRoom = rooms.map((room) => room.adults ?? 0)
childrenInRoomString = rooms[0].childrenInRoom
? generateChildrenString(rooms[0].childrenInRoom)
: undefined // TODO: Handle multiple rooms
childrenInRoom = rooms[0].childrenInRoom // TODO: Handle multiple rooms
childrenInRoom = rooms.map((room) => room.childrenInRoom ?? null)
childrenInRoomString = rooms.map((room) =>
room.childrenInRoom ? generateChildrenString(room.childrenInRoom) : null
)
}
return {
adultsInRoom,
bookingCode: selectHotelParams.bookingCode ?? undefined,
childrenInRoom,
childrenInRoomString,
city,
hotel,
selectHotelParams: { city: cityName, ...selectHotelParams },
adultsInRoom,
childrenInRoomString,
childrenInRoom,
bookingCode: selectHotelParams.bookingCode ?? undefined,
noOfRooms: rooms?.length ?? 0,
redemption: selectHotelParams.searchType === REDEMPTION,
selectHotelParams: { city: cityName, ...selectHotelParams },
}
}

View File

@@ -0,0 +1,20 @@
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { Packages } from "@/types/requests/packages"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export function getSpecialRoomType(
packages: BookingConfirmation["booking"]["packages"] | Packages | null
) {
const packageCodes = packages
?.filter((pkg) => pkg.code)
.map((pkg) => pkg.code)
if (packageCodes?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM)) {
return "accesibillity"
} else if (packageCodes?.includes(RoomPackageCodeEnum.ALLERGY_ROOM)) {
return "allergy friendly"
} else if (packageCodes?.includes(RoomPackageCodeEnum.PET_ROOM)) {
return "pet room"
} else {
return "-"
}
}