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 MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
import { generateChildrenString } from "@/components/HotelReservation/utils" import { generateChildrenString } from "@/components/HotelReservation/utils"
import Alert from "@/components/TempDesignSystem/Alert" import Alert from "@/components/TempDesignSystem/Alert"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import RoomProvider from "@/providers/Details/RoomProvider" import RoomProvider from "@/providers/Details/RoomProvider"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider" import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { convertSearchParamsToObj } from "@/utils/url" import { convertSearchParamsToObj } from "@/utils/url"
import { getTracking } from "./tracking"
import styles from "./page.module.css" import styles from "./page.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
@@ -57,34 +60,30 @@ export default async function DetailsPage({
const childrenAsString = const childrenAsString =
room.childrenInRoom && generateChildrenString(room.childrenInRoom) 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, adults: room.adults,
bookingCode: booking.bookingCode,
children: childrenAsString, children: childrenAsString,
counterRateCode: room.counterRateCode,
hotelId: booking.hotelId, hotelId: booking.hotelId,
packageCodes: room.packages, packageCodes: room.packages,
rateCode: room.rateCode, rateCode: room.rateCode,
roomStayStartDate: booking.fromDate,
roomStayEndDate: booking.toDate, roomStayEndDate: booking.toDate,
roomStayStartDate: booking.fromDate,
roomTypeCode: room.roomTypeCode, 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) { if (!roomAvailability) {
// redirect back to select-rate if availability call fails // redirect back to select-rate if availability call fails
@@ -98,8 +97,11 @@ export default async function DetailsPage({
mustBeGuaranteed: roomAvailability.mustBeGuaranteed, mustBeGuaranteed: roomAvailability.mustBeGuaranteed,
memberMustBeGuaranteed: roomAvailability.memberMustBeGuaranteed, memberMustBeGuaranteed: roomAvailability.memberMustBeGuaranteed,
packages, packages,
rateTitle: roomAvailability.rateTitle, rate: roomAvailability.rate,
rateDefinitionTitle: roomAvailability.rateDefinitionTitle,
rateDetails: roomAvailability.rateDetails ?? [], rateDetails: roomAvailability.rateDetails ?? [],
rateTitle: roomAvailability.rateTitle,
rateType: roomAvailability.rateType,
roomType: roomAvailability.selectedRoom.roomType, roomType: roomAvailability.selectedRoom.roomType,
roomTypeCode: roomAvailability.selectedRoom.roomTypeCode, roomTypeCode: roomAvailability.selectedRoom.roomTypeCode,
roomRate: { roomRate: {
@@ -122,41 +124,28 @@ export default async function DetailsPage({
language: lang, language: lang,
}) })
const user = await getProfileSafely() const user = await getProfileSafely()
// const userTrackingData = await getUserTracking()
if (!hotelData || !rooms) { if (!hotelData || !rooms) {
return notFound() return notFound()
} }
// const arrivalDate = new Date(booking.fromDate)
// const departureDate = new Date(booking.toDate)
const { hotel } = hotelData const { hotel } = hotelData
// TODO: add tracking const { hotelsTrackingData, pageTrackingData } = getTracking(
// const initialHotelsTrackingData: TrackingSDKHotelInfo = { booking,
// searchTerm: searchParams.city, hotel,
// arrivalDate: format(arrivalDate, "yyyy-MM-dd"), rooms,
// departureDate: format(departureDate, "yyyy-MM-dd"), !!breakfastPackages?.length,
// noOfAdults: adults, searchParams.city,
// noOfChildren: childrenInRoom?.length, !!user,
// ageOfChildren: childrenInRoom?.map((c) => c.age).join(","), lang
// 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 intl = await getIntl() const intl = await getIntl()
const firstRoom = rooms[0] const firstRoom = rooms[0]
const multirooms = rooms.slice(1) const multirooms = rooms.slice(1)
const isRoomNotAvailable = rooms.some((room) => !room.isAvailable) const isRoomNotAvailable = rooms.some((room) => !room.isAvailable)
return ( return (
<EnterDetailsProvider <EnterDetailsProvider
@@ -214,6 +203,7 @@ export default async function DetailsPage({
</aside> </aside>
</div> </div>
</main> </main>
<TrackingSDK hotelInfo={hotelsTrackingData} pageData={pageTrackingData} />
</EnterDetailsProvider> </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 { clickedHotel } = useDestinationPageHotelsMapStore()
const selectedHotelIdx = visibleHotels.findIndex( const selectedHotelIdx = visibleHotels.findIndex(
(hotel) => hotel.hotel.operaId === clickedHotel ({ hotel }) => hotel.operaId === clickedHotel
) )
return ( 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 { notFound } from "next/navigation"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" 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 Rooms from "@/components/HotelReservation/BookingConfirmation/Rooms"
import SidePanel from "@/components/HotelReservation/SidePanel" import SidePanel from "@/components/HotelReservation/SidePanel"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
import TrackingSDK from "@/components/TrackingSDK"
import { getLang } from "@/i18n/serverContext"
import BookingConfirmationProvider from "@/providers/BookingConfirmationProvider" import BookingConfirmationProvider from "@/providers/BookingConfirmationProvider"
import { invertedBedTypeMap } from "../utils"
import Alerts from "./Alerts" import Alerts from "./Alerts"
import Confirmation from "./Confirmation" import Confirmation from "./Confirmation"
import Tracking from "./Tracking"
import { mapRoomState } from "./utils" import { mapRoomState } from "./utils"
import styles from "./bookingConfirmation.module.css" import styles from "./bookingConfirmation.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" 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({ export default async function BookingConfirmation({
confirmationNumber, confirmationNumber,
}: BookingConfirmationProps) { }: BookingConfirmationProps) {
const lang = getLang()
const bookingConfirmation = await getBookingConfirmation(confirmationNumber) const bookingConfirmation = await getBookingConfirmation(confirmationNumber)
if (!bookingConfirmation) { if (!bookingConfirmation) {
@@ -44,74 +33,6 @@ export default async function BookingConfirmation({
return notFound() 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 ( return (
<BookingConfirmationProvider <BookingConfirmationProvider
bookingCode={booking.bookingCode} bookingCode={booking.bookingCode}
@@ -151,11 +72,7 @@ export default async function BookingConfirmation({
</SidePanel> </SidePanel>
</aside> </aside>
</Confirmation> </Confirmation>
<TrackingSDK <Tracking bookingConfirmation={bookingConfirmation} />
pageData={initialPageTrackingData}
hotelInfo={initialHotelsTrackingData}
paymentInfo={paymentInfo}
/>
</BookingConfirmationProvider> </BookingConfirmationProvider>
) )
} }

View File

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

View File

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

View File

@@ -255,24 +255,24 @@ export default function PaymentClient({
const guarantee = data.guarantee const guarantee = data.guarantee
const useSavedCard = savedCreditCard const useSavedCard = savedCreditCard
? { ? {
card: { card: {
alias: savedCreditCard.alias, alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate, expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType, cardType: savedCreditCard.cardType,
}, },
} }
: {} : {}
const shouldUsePayment = !isFlexRate || guarantee const shouldUsePayment = !isFlexRate || guarantee
const payment = shouldUsePayment const payment = shouldUsePayment
? { ? {
paymentMethod: paymentMethod, paymentMethod: paymentMethod,
...useSavedCard, ...useSavedCard,
success: `${paymentRedirectUrl}/success`, success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`, error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`, cancel: `${paymentRedirectUrl}/cancel`,
} }
: undefined : undefined
trackPaymentEvent({ trackPaymentEvent({
@@ -285,56 +285,65 @@ export default function PaymentClient({
}) })
initiateBooking.mutate({ initiateBooking.mutate({
language: lang,
hotelId,
checkInDate: fromDate, checkInDate: fromDate,
checkOutDate: toDate, checkOutDate: toDate,
hotelId,
language: lang,
payment,
rooms: rooms.map(({ room }, idx) => ({ rooms: rooms.map(({ room }, idx) => ({
adults: room.adults, adults: room.adults,
childrenAges: room.childrenInRoom?.map((child) => ({ childrenAges: room.childrenInRoom?.map((child) => ({
age: child.age, age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())], 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: { guest: {
becomeMember: room.guest.join, becomeMember: room.guest.join,
countryCode: room.guest.countryCode, countryCode: room.guest.countryCode,
dateOfBirth: room.guest.dateOfBirth,
email: room.guest.email, email: room.guest.email,
firstName: room.guest.firstName, firstName: room.guest.firstName,
lastName: room.guest.lastName, lastName: room.guest.lastName,
membershipNumber: room.guest.membershipNo, membershipNumber: room.guest.membershipNo,
phoneNumber: room.guest.phoneNumber, 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: { 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: accessibility:
room.roomFeatures?.some( room.roomFeatures?.some(
(feature) => (feature) =>
feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
) ?? false, ) ?? 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: { roomPrice: {
memberPrice: room.roomRate.memberRate?.localPrice.pricePerStay, memberPrice: room.roomRate.memberRate?.localPrice.pricePerStay,
publicPrice: room.roomRate.publicRate?.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} value={paymentMethod}
label={ label={
PAYMENT_METHOD_TITLES[ PAYMENT_METHOD_TITLES[
paymentMethod as PaymentMethodEnum paymentMethod as PaymentMethodEnum
] ]
} }
/> />

View File

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

View File

@@ -15,7 +15,8 @@ import { calculateTotalRoomPrice } from "../Payment/helpers"
import PriceChangeSummary from "./PriceChangeSummary" import PriceChangeSummary from "./PriceChangeSummary"
import styles from "./priceChangeDialog.module.css" 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 = { type PriceDetailsState = {
newTotalPrice: number newTotalPrice: number

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,61 +47,66 @@ export default function HotelCardListing({
(state) => state.activeCodeFilter (state) => state.activeCodeFilter
) )
const sortedHotels = useMemo(() => {
if (!hotelData) return []
return getSortedHotels({ hotels: hotelData, sortBy, bookingCode })
}, [hotelData, sortBy, bookingCode])
const hotels = useMemo(() => { const hotels = useMemo(() => {
const sortedHotels = getSortedHotels({
hotels: hotelData,
sortBy,
bookingCode,
})
const updatedHotelsList = bookingCode const updatedHotelsList = bookingCode
? sortedHotels.filter( ? sortedHotels.filter(
(hotel) => (hotel) =>
!hotel.price || !hotel.availability.productType ||
activeCodeFilter === BookingCodeFilterEnum.All || activeCodeFilter === BookingCodeFilterEnum.All ||
(activeCodeFilter === BookingCodeFilterEnum.Discounted && (activeCodeFilter === BookingCodeFilterEnum.Discounted &&
hotel.price?.public?.rateType !== RateTypeEnum.Regular) || hotel.availability.productType.public?.rateType !==
RateTypeEnum.Regular) ||
(activeCodeFilter === BookingCodeFilterEnum.Regular && (activeCodeFilter === BookingCodeFilterEnum.Regular &&
hotel.price?.public?.rateType === RateTypeEnum.Regular) hotel.availability.productType.public?.rateType ===
RateTypeEnum.Regular)
) )
: sortedHotels : sortedHotels
if (activeFilters.length === 0) return updatedHotelsList if (!activeFilters.length) {
return updatedHotelsList
}
return updatedHotelsList.filter((hotel) => return updatedHotelsList.filter((hotel) =>
activeFilters.every((appliedFilterId) => activeFilters.every((appliedFilterId) =>
hotel.hotelData.detailedFacilities.some( hotel.hotel.detailedFacilities.some(
(facility) => facility.id.toString() === appliedFilterId (facility) => facility.id.toString() === appliedFilterId
) )
) )
) )
}, [activeFilters, sortedHotels, bookingCode, activeCodeFilter]) }, [activeCodeFilter, activeFilters, bookingCode, hotelData, sortBy])
useEffect(() => { useEffect(() => {
setResultCount(hotels?.length ?? 0) setResultCount(hotels.length)
}, [hotels, setResultCount]) }, [hotels, setResultCount])
return ( return (
<section className={styles.hotelCards}> <section className={styles.hotelCards}>
{hotels?.length ? ( {hotels?.length
hotels.map((hotel) => ( ? hotels.map((hotel) => (
<div <div
key={hotel.hotelData.operaId} key={hotel.hotel.operaId}
data-active={ data-active={
hotel.hotelData.name === activeHotelCard ? "true" : "false" hotel.hotel.name === activeHotelCard ? "true" : "false"
}
>
<HotelCard
hotel={hotel}
isUserLoggedIn={isUserLoggedIn}
state={
hotel.hotelData.name === activeHotelCard ? "active" : "default"
} }
type={type} >
bookingCode={bookingCode} <HotelCard
/> hotelData={hotel}
</div> isUserLoggedIn={isUserLoggedIn}
)) state={
) : activeFilters ? ( hotel.hotel.name === activeHotelCard ? "active" : "default"
}
type={type}
bookingCode={bookingCode}
/>
</div>
))
: null}
{!hotels?.length && activeFilters ? (
<Alert <Alert
type={AlertTypeEnum.Info} type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No hotels match your filters" })} 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 { 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({ export function getSortedHotels({
hotels, hotels,
sortBy, sortBy,
bookingCode, bookingCode,
}: { }: {
hotels: HotelData[] hotels: HotelResponse[]
sortBy: string sortBy: string
bookingCode: string | null bookingCode: string | null
}) { }) {
const getPricePerNight = (hotel: HotelData): number => const availableHotels = hotels.filter(
hotel.price?.member?.localPrice?.pricePerNight ?? (hotel) => !!hotel.availability.productType
hotel.price?.public?.localPrice?.pricePerNight ?? )
hotel.price?.redemption?.localPrice?.pointsPerNight ?? const unavailableHotels = hotels.filter(
Infinity (hotel) => !hotel.availability.productType
const availableHotels = hotels.filter((hotel) => !!hotel?.price) )
const unAvailableHotels = hotels.filter((hotel) => !hotel?.price)
const sortingStrategies: Record< const sortingStrategies: Record<
string, string,
(a: HotelData, b: HotelData) => number (a: HotelResponse, b: HotelResponse) => number
> = { > = {
[SortOrder.Name]: (a: HotelData, b: HotelData) => [SortOrder.Name]: (a: HotelResponse, b: HotelResponse) =>
a.hotelData.name.localeCompare(b.hotelData.name), a.hotel.name.localeCompare(b.hotel.name),
[SortOrder.TripAdvisorRating]: (a: HotelData, b: HotelData) => [SortOrder.TripAdvisorRating]: (a: HotelResponse, b: HotelResponse) =>
(b.hotelData.ratings?.tripAdvisor.rating ?? 0) - (b.hotel.ratings?.tripAdvisor.rating ?? 0) -
(a.hotelData.ratings?.tripAdvisor.rating ?? 0), (a.hotel.ratings?.tripAdvisor.rating ?? 0),
[SortOrder.Price]: (a: HotelData, b: HotelData) => [SortOrder.Price]: (a: HotelResponse, b: HotelResponse) =>
getPricePerNight(a) - getPricePerNight(b), getPricePerNight(a) - getPricePerNight(b),
[SortOrder.Distance]: (a: HotelData, b: HotelData) => [SortOrder.Distance]: (a: HotelResponse, b: HotelResponse) =>
a.hotelData.location.distanceToCentre - a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre,
b.hotelData.location.distanceToCentre,
} }
const sortStrategy = const sortStrategy =
@@ -40,21 +46,25 @@ export function getSortedHotels({
if (bookingCode) { if (bookingCode) {
const bookingCodeHotels = hotels.filter( const bookingCodeHotels = hotels.filter(
(hotel) => (hotel) =>
(hotel?.price?.public?.rateType?.toLowerCase() !== "regular" || (hotel.availability.productType?.public?.rateType?.toLowerCase() !==
hotel?.price?.member?.rateType?.toLowerCase() !== "regular") && "regular" ||
!!hotel?.price hotel.availability.productType?.member?.rateType?.toLowerCase() !==
"regular") &&
!!hotel.availability.productType
) )
const regularHotels = hotels.filter( 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) .sort(sortStrategy)
.concat([...regularHotels].sort(sortStrategy)) .concat(regularHotels.sort(sortStrategy))
.concat([...unAvailableHotels].sort(sortStrategy)) .concat(unavailableHotels.sort(sortStrategy))
} }
return [...availableHotels] return availableHotels
.sort(sortStrategy) .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 { dt } from "@/lib/dt"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import { getIconForFeatureCode } from "@/components/HotelReservation/utils" import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
import { import {
BedDoubleIcon, BedDoubleIcon,
@@ -22,8 +23,8 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting" import { formatPrice } from "@/utils/numberFormatting"
import PriceDetailsModal from "../../PriceDetailsModal"
import GuestDetails from "./GuestDetails" import GuestDetails from "./GuestDetails"
import PriceDetailsTable from "./PriceDetailsTable"
import ToggleSidePeek from "./ToggleSidePeek" import ToggleSidePeek from "./ToggleSidePeek"
import styles from "./room.module.css" import styles from "./room.module.css"
@@ -287,41 +288,44 @@ export function Room({ booking, room, hotel, user }: RoomProps) {
</Body> </Body>
</div> </div>
<PriceDetailsModal <PriceDetailsModal>
fromDate={dt(booking.checkInDate).format("YYYY-MM-DD")} <PriceDetailsTable
toDate={dt(booking.checkOutDate).format("YYYY-MM-DD")} bookingCode={booking.bookingCode}
rooms={[ fromDate={dt(booking.checkInDate).format("YYYY-MM-DD")}
{ rooms={[
adults: booking.adults, {
childrenInRoom: undefined, adults: booking.adults,
roomPrice: { childrenInRoom: undefined,
perNight: { roomPrice: {
requested: undefined, perNight: {
local: { requested: undefined,
currency: booking.currencyCode, local: {
price: booking.totalPrice, currency: booking.currencyCode,
}, price: booking.totalPrice,
}, },
perStay: { },
requested: undefined, perStay: {
local: { requested: undefined,
currency: booking.currencyCode, local: {
price: booking.totalPrice, currency: booking.currencyCode,
price: booking.totalPrice,
},
}, },
}, },
roomType: room.name,
}, },
roomType: room.name, ]}
}, toDate={dt(booking.checkOutDate).format("YYYY-MM-DD")}
]} totalPrice={{
totalPrice={{ requested: undefined,
requested: undefined, local: {
local: { currency: booking.currencyCode,
currency: booking.currencyCode, price: booking.totalPrice,
price: booking.totalPrice, },
}, }}
}} vat={booking.vatPercentage}
vat={booking.vatPercentage} />
/> </PriceDetailsModal>
</div> </div>
</div> </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" import { AlertTypeEnum } from "@/types/enums/alert"
export default async function NoAvailabilityAlert({ export default async function NoAvailabilityAlert({
hotels, hotelsLength,
isAllUnavailable, isAllUnavailable,
isAlternative, isAlternative,
operaId,
}: NoAvailabilityAlertProp) { }: NoAvailabilityAlertProp) {
const intl = await getIntl() const intl = await getIntl()
const lang = getLang() const lang = getLang()
@@ -19,7 +20,7 @@ export default async function NoAvailabilityAlert({
return null return null
} }
if (hotels.length === 1 && !isAlternative) { if (hotelsLength === 1 && !isAlternative && operaId) {
return ( return (
<Alert <Alert
type={AlertTypeEnum.Info} type={AlertTypeEnum.Info}
@@ -29,7 +30,7 @@ export default async function NoAvailabilityAlert({
})} })}
link={{ link={{
title: intl.formatMessage({ id: "See alternative hotels" }), title: intl.formatMessage({ id: "See alternative hotels" }),
url: `${alternativeHotels(lang)}?hotel=${hotels[0].hotelData.operaId}`, url: `${alternativeHotels(lang)}?hotel=${operaId}`,
keepSearchParams: true, keepSearchParams: true,
}} }}
/> />

View File

@@ -1,40 +1,26 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { env } from "@/env/server" import { env } from "@/env/server"
import { getCityCoordinates } from "@/lib/trpc/memoizedRequests" 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 TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
import { safeTry } from "@/utils/safeTry" import { safeTry } from "@/utils/safeTry"
import { getHotelPins } from "../../HotelCardDialogListing/utils" import { getHotelPins } from "../../HotelCardDialogListing/utils"
import { getFiltersFromHotels, getHotels } from "../helpers"
import { getTracking } from "./tracking"
import SelectHotelMap from "." 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 { SelectHotelMapContainerProps } from "@/types/components/hotelReservation/selectHotel/map"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
export async function SelectHotelMapContainer({ export async function SelectHotelMapContainer({
searchParams, searchParams,
isAlternativeHotels, isAlternativeHotels,
}: SelectHotelMapContainerProps) { }: SelectHotelMapContainerProps) {
const lang = getLang() const lang = getLang()
const intl = await getIntl()
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
const getHotelSearchDetailsPromise = safeTry( const getHotelSearchDetailsPromise = safeTry(
@@ -50,106 +36,58 @@ export async function SelectHotelMapContainer({
const [searchDetails] = await getHotelSearchDetailsPromise const [searchDetails] = await getHotelSearchDetailsPromise
if (!searchDetails) return notFound() if (!searchDetails) {
return notFound()
}
const { const {
city,
selectHotelParams,
adultsInRoom, adultsInRoom,
childrenInRoom,
childrenInRoomString,
hotel: isAlternativeFor,
bookingCode, bookingCode,
childrenInRoom,
city,
hotel: isAlternativeFor,
noOfRooms,
redemption, redemption,
selectHotelParams,
} = searchDetails } = searchDetails
if (!city) return notFound() if (!city) {
return notFound()
}
const fetchAvailableHotelsPromise = isAlternativeFor const hotels = await getHotels(
? safeTry( selectHotelParams,
fetchAlternativeHotels(isAlternativeFor.id, { isAlternativeFor,
roomStayStartDate: selectHotelParams.fromDate, bookingCode,
roomStayEndDate: selectHotelParams.toDate, city,
adults: adultsInRoom[0], !!redemption
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 fetchAvailableHotelsPromise const hotelPins = getHotelPins(hotels)
const filterList = getFiltersFromHotels(hotels)
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 cityCoordinates = await getCityCoordinates({ const cityCoordinates = await getCityCoordinates({
city: city.name, city: city.name,
hotel: { address: hotels?.[0]?.hotelData?.address.streetAddress }, hotel: { address: hotels?.[0]?.hotel?.address.streetAddress },
}) })
const arrivalDate = new Date(selectHotelParams.fromDate) const arrivalDate = new Date(selectHotelParams.fromDate)
const departureDate = new Date(selectHotelParams.toDate) const departureDate = new Date(selectHotelParams.toDate)
const pageTrackingData: TrackingSDKPageData = { const { hotelsTrackingData, pageTrackingData } = getTracking(
pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel", lang,
domainLanguage: lang, !!isAlternativeFor,
channel: TrackingChannelEnum["hotelreservation"], !!isAlternativeHotels,
pageName: isAlternativeHotels arrivalDate,
? "hotelreservation|alternative-hotels|mapview" departureDate,
: "hotelreservation|select-hotel|mapview", adultsInRoom,
siteSections: isAlternativeHotels childrenInRoom,
? "hotelreservation|altervative-hotels|mapview" hotels.length,
: "hotelreservation|select-hotel|mapview", selectHotelParams.hotelId,
pageType: "bookinghotelsmapviewpage", noOfRooms,
siteVersion: "new-web", hotels?.[0]?.hotel.address.country,
} hotels?.[0]?.hotel.address.city,
selectHotelParams.city
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,
}
return ( return (
<> <>
@@ -157,7 +95,7 @@ export async function SelectHotelMapContainer({
apiKey={googleMapsApiKey} apiKey={googleMapsApiKey}
hotelPins={hotelPins} hotelPins={hotelPins}
mapId={googleMapId} mapId={googleMapId}
hotels={validHotels} hotels={hotels}
filterList={filterList} filterList={filterList}
cityCoordinates={cityCoordinates} cityCoordinates={cityCoordinates}
bookingCode={bookingCode ?? ""} bookingCode={bookingCode ?? ""}

View File

@@ -26,10 +26,10 @@ import { getVisibleHotels } from "./utils"
import styles from "./selectHotelMapContent.module.css" 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 type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter" import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
import { RateTypeEnum } from "@/types/enums/rateType" import { RateTypeEnum } from "@/types/enums/rateType"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
const SKELETON_LOAD_DELAY = 750 const SKELETON_LOAD_DELAY = 750
@@ -46,7 +46,7 @@ export default function SelectHotelContent({
const map = useMap() const map = useMap()
const isAboveMobile = useMediaQuery("(min-width: 768px)") const isAboveMobile = useMediaQuery("(min-width: 768px)")
const [visibleHotels, setVisibleHotels] = useState<HotelData[]>([]) const [visibleHotels, setVisibleHotels] = useState<HotelResponse[]>([])
const [showSkeleton, setShowSkeleton] = useState<boolean>(true) const [showSkeleton, setShowSkeleton] = useState<boolean>(true)
const listingContainerRef = useRef<HTMLDivElement | null>(null) 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 { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
export function getVisibleHotelPins( export function getVisibleHotelPins(
map: google.maps.Map | null, map: google.maps.Map | null,
@@ -17,13 +17,13 @@ export function getVisibleHotelPins(
} }
export function getVisibleHotels( export function getVisibleHotels(
hotels: HotelData[], hotels: HotelResponse[],
filteredHotelPins: HotelPin[], filteredHotelPins: HotelPin[],
map: google.maps.Map | null map: google.maps.Map | null
) { ) {
const visibleHotelPins = getVisibleHotelPins(map, filteredHotelPins) const visibleHotelPins = getVisibleHotelPins(map, filteredHotelPins)
const visibleHotels = hotels.filter((hotel) => const visibleHotels = hotels.filter((hotel) =>
visibleHotelPins.some((pin) => pin.operaId === hotel.hotelData.operaId) visibleHotelPins.some((pin) => pin.operaId === hotel.hotel.operaId)
) )
return visibleHotels 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 { notFound } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
@@ -9,13 +9,6 @@ import {
selectHotelMap, selectHotelMap,
} from "@/constants/routes/hotelReservation" } 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 { ChevronRightIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap" import StaticMap from "@/components/Maps/StaticMap"
import Breadcrumbs from "@/components/TempDesignSystem/Breadcrumbs" import Breadcrumbs from "@/components/TempDesignSystem/Breadcrumbs"
@@ -24,28 +17,23 @@ import Link from "@/components/TempDesignSystem/Link"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import TrackingSDK from "@/components/TrackingSDK" import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { safeTry } from "@/utils/safeTry" import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
import { convertObjToSearchParams } from "@/utils/url" import { convertObjToSearchParams } from "@/utils/url"
import HotelCardListing from "../HotelCardListing" import HotelCardListing from "../HotelCardListing"
import BookingCodeFilter from "./BookingCodeFilter" import BookingCodeFilter from "./BookingCodeFilter"
import { getFiltersFromHotels, getHotels } from "./helpers"
import HotelCount from "./HotelCount" import HotelCount from "./HotelCount"
import HotelFilter from "./HotelFilter" import HotelFilter from "./HotelFilter"
import HotelSorter from "./HotelSorter" import HotelSorter from "./HotelSorter"
import MobileMapButtonContainer from "./MobileMapButtonContainer" import MobileMapButtonContainer from "./MobileMapButtonContainer"
import NoAvailabilityAlert from "./NoAvailabilityAlert" import NoAvailabilityAlert from "./NoAvailabilityAlert"
import { getTracking } from "./tracking"
import styles from "./selectHotel.module.css" 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 { SelectHotelProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
export default async function SelectHotel({ export default async function SelectHotel({
params, params,
@@ -54,78 +42,44 @@ export default async function SelectHotel({
}: SelectHotelProps) { }: SelectHotelProps) {
const intl = await getIntl() const intl = await getIntl()
const getHotelSearchDetailsPromise = safeTry( const searchDetails = await getHotelSearchDetails(
getHotelSearchDetails( {
{ searchParams: searchParams as SelectHotelSearchParams & {
searchParams: searchParams as SelectHotelSearchParams & { [key: string]: string
[key: string]: string
},
}, },
isAlternativeHotels },
) isAlternativeHotels
) )
const [searchDetails] = await getHotelSearchDetailsPromise
if (!searchDetails) return notFound() if (!searchDetails) return notFound()
const { const {
city,
selectHotelParams,
adultsInRoom, adultsInRoom,
childrenInRoomString,
childrenInRoom,
hotel: isAlternativeFor,
bookingCode, bookingCode,
childrenInRoom,
city,
hotel: isAlternativeFor,
noOfRooms,
redemption, redemption,
selectHotelParams,
} = searchDetails } = searchDetails
if (!city) return notFound() if (!city) return notFound()
const hotelsPromise = isAlternativeFor const hotels = await getHotels(
? safeTry( selectHotelParams,
fetchAlternativeHotels(isAlternativeFor.id, { isAlternativeFor,
roomStayStartDate: selectHotelParams.fromDate, bookingCode,
roomStayEndDate: selectHotelParams.toDate, city,
adults: adultsInRoom[0], !!redemption,
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 arrivalDate = new Date(selectHotelParams.fromDate) const arrivalDate = new Date(selectHotelParams.fromDate)
const departureDate = new Date(selectHotelParams.toDate) const departureDate = new Date(selectHotelParams.toDate)
const isCityWithCountry = (city: any): city is { country: string } => const isCityWithCountry = (city: any): city is { country: string } =>
"country" in city "country" in city
const validHotels = const filterList = getFiltersFromHotels(hotels)
hotels?.filter((hotel): hotel is HotelData => hotel?.hotelData !== null) ||
[]
const filterList = getFiltersFromHotels(validHotels)
const convertedSearchParams = convertObjToSearchParams(selectHotelParams) const convertedSearchParams = convertObjToSearchParams(selectHotelParams)
const breadcrumbs = [ const breadcrumbs = [
@@ -141,65 +95,44 @@ export default async function SelectHotel({
}, },
isAlternativeFor isAlternativeFor
? { ? {
title: intl.formatMessage({ id: "Alternative hotels" }), title: intl.formatMessage({ id: "Alternative hotels" }),
href: `${alternativeHotels(params.lang)}/?${convertedSearchParams}`, href: `${alternativeHotels(params.lang)}/?${convertedSearchParams}`,
uid: "alternative-hotels", uid: "alternative-hotels",
} }
: { : {
title: intl.formatMessage({ id: "Select hotel" }), title: intl.formatMessage({ id: "Select hotel" }),
href: `${selectHotel(params.lang)}/?${convertedSearchParams}`, href: `${selectHotel(params.lang)}/?${convertedSearchParams}`,
uid: "select-hotel", uid: "select-hotel",
}, },
isAlternativeFor isAlternativeFor
? { ? {
title: isAlternativeFor.name, title: isAlternativeFor.name,
uid: isAlternativeFor.id, uid: isAlternativeFor.id,
} }
: { : {
title: city.name, title: city.name,
uid: city.id, uid: city.id,
}, },
] ]
const isAllUnavailable = const isAllUnavailable = !hotels.length
hotels?.every((hotel) => hotel.price === undefined) || false
const pageTrackingData: TrackingSDKPageData = { const { hotelsTrackingData, pageTrackingData } = getTracking(
pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel", params.lang,
domainLanguage: params.lang, !!isAlternativeFor,
channel: TrackingChannelEnum["hotelreservation"], arrivalDate,
pageName: isAlternativeFor departureDate,
? "hotelreservation|alternative-hotels" adultsInRoom,
: "hotelreservation|select-hotel", childrenInRoom,
siteSections: isAlternativeFor hotels.length,
? "hotelreservation|alternative-hotels" selectHotelParams.hotelId,
: "hotelreservation|select-hotel", noOfRooms,
pageType: "bookinghotelspage", hotels?.[0]?.hotel.address.country,
siteVersion: "new-web", hotels?.[0]?.hotel.address.city,
} selectHotelParams.city
)
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 suspenseKey = stringify(searchParams)
return ( return (
<> <>
<header className={styles.header}> <header className={styles.header}>
@@ -229,7 +162,7 @@ export default async function SelectHotel({
<main className={styles.main}> <main className={styles.main}>
{bookingCode ? <BookingCodeFilter /> : null} {bookingCode ? <BookingCodeFilter /> : null}
<div className={styles.sideBar}> <div className={styles.sideBar}>
{hotels && hotels.length > 0 ? ( // TODO: Temp fix until API returns hotels that are not available {hotels.length ? (
<Link <Link
className={styles.link} className={styles.link}
color="burgundy" color="burgundy"
@@ -276,14 +209,15 @@ export default async function SelectHotel({
</div> </div>
<div className={styles.hotelList}> <div className={styles.hotelList}>
<NoAvailabilityAlert <NoAvailabilityAlert
hotelsLength={hotels.length}
isAlternative={!!isAlternativeFor} isAlternative={!!isAlternativeFor}
hotels={validHotels}
isAllUnavailable={isAllUnavailable} isAllUnavailable={isAllUnavailable}
operaId={hotels?.[0]?.hotel.operaId}
/> />
<HotelCardListing hotelData={validHotels} /> <HotelCardListing hotelData={hotels} />
</div> </div>
</main> </main>
<Suspense fallback={null}> <Suspense key={`${suspenseKey}-tracking`} fallback={null}>
<TrackingSDK <TrackingSDK
pageData={pageTrackingData} pageData={pageTrackingData}
hotelInfo={hotelsTrackingData} 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 useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting" import { formatPrice } from "@/utils/numberFormatting"
import PriceDetailsTable from "./PriceDetailsTable"
import styles from "./summary.module.css" import styles from "./summary.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
@@ -249,19 +251,17 @@ export default function Summary({
{ b: (str) => <b>{str}</b> } { b: (str) => <b>{str}</b> }
)} )}
</Body> </Body>
<PriceDetailsModal <PriceDetailsModal>
fromDate={booking.fromDate} <PriceDetailsTable
toDate={booking.toDate} bookingCode={booking.bookingCode}
rooms={rooms.map((r) => ({ fromDate={booking.fromDate}
adults: r.adults, isMember={isMember}
childrenInRoom: r.childrenInRoom, rooms={rooms}
roomPrice: r.roomPrice, toDate={booking.toDate}
roomType: r.roomType, totalPrice={totalPrice}
}))} vat={vat}
totalPrice={totalPrice} />
vat={vat} </PriceDetailsModal>
bookingCode={booking.bookingCode}
/>
</div> </div>
<div> <div>
<Body <Body

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,9 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import stringify from "json-stable-stringify-without-jsonify" import stringify from "json-stable-stringify-without-jsonify"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { getHotel } from "@/lib/trpc/memoizedRequests" 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 { auth } from "@/auth"
import HotelInfoCard, { import HotelInfoCard, {
HotelInfoCardSkeleton, HotelInfoCardSkeleton,
@@ -14,16 +11,14 @@ import HotelInfoCard, {
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/RoomsContainer" import { RoomsContainer } from "@/components/HotelReservation/SelectRate/RoomsContainer"
import TrackingSDK from "@/components/TrackingSDK" import TrackingSDK from "@/components/TrackingSDK"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
import { isValidSession } from "@/utils/session" import { isValidSession } from "@/utils/session"
import { convertSearchParamsToObj } from "@/utils/url" 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 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" import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectRatePage({ export default async function SelectRatePage({
@@ -35,7 +30,7 @@ export default async function SelectRatePage({
if (!searchDetails?.hotel) { if (!searchDetails?.hotel) {
return notFound() return notFound()
} }
const { hotel, adultsInRoom, childrenInRoom, selectHotelParams } = const { adultsInRoom, childrenInRoom, hotel, noOfRooms, selectHotelParams } =
searchDetails searchDetails
const { fromDate, toDate } = getValidDates( const { fromDate, toDate } = getValidDates(
@@ -55,41 +50,24 @@ export default async function SelectRatePage({
const arrivalDate = fromDate.toDate() const arrivalDate = fromDate.toDate()
const departureDate = toDate.toDate() const departureDate = toDate.toDate()
const pageTrackingData: TrackingSDKPageData = { const { hotelsTrackingData, pageTrackingData } = getTracking(
pageId: "select-rate", params.lang,
domainLanguage: params.lang, arrivalDate,
channel: TrackingChannelEnum["hotelreservation"], departureDate,
pageName: "hotelreservation|select-rate", adultsInRoom,
siteSections: "hotelreservation|select-rate", childrenInRoom,
pageType: "bookingroomsandratespage", hotel.id,
siteVersion: "new-web", hotel.name,
} noOfRooms,
hotelData?.hotel.address.country,
const hotelsTrackingData: TrackingSDKHotelInfo = { hotelData?.hotel.address.city,
searchTerm: selectHotelParams.city ?? hotel?.name, selectHotelParams.city
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 booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams) const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
const suspenseKey = stringify(searchParams)
const hotelId = +hotel.id
const suspenseKey = stringify(searchParams)
return ( return (
<> <>
<Suspense fallback={<HotelInfoCardSkeleton />}> <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 { trpc } from "@/lib/trpc/client"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" 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" import type { Lang } from "@/constants/languages"
import type { ChildrenInRoom } from "@/utils/hotelSearchDetails"
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)
}
export function useRoomsAvailability( export function useRoomsAvailability(
uniqueAdultsCount: number[], adultsCount: number[],
hotelId: number, hotelId: number,
fromDateString: string, fromDateString: string,
toDateString: string, toDateString: string,
lang: Lang, lang: Lang,
childArray?: Child[], childArray: ChildrenInRoom,
bookingCode?: string bookingCode?: string
) { ) {
const returnValue = const roomsAvailability =
trpc.hotel.availability.roomsCombinedAvailability.useQuery({ trpc.hotel.availability.roomsCombinedAvailability.useQuery({
hotelId, adultsCount,
roomStayStartDate: fromDateString,
roomStayEndDate: toDateString,
uniqueAdultsCount,
childArray,
lang,
bookingCode, bookingCode,
childArray,
hotelId,
lang,
roomStayEndDate: toDateString,
roomStayStartDate: fromDateString,
}) })
const combinedAvailability = returnValue.data?.length const data = roomsAvailability.data?.map((ra) => {
? combineRoomAvailabilities( if (ra.status === "fulfilled") {
returnValue.data as PromiseSettledResult<RoomsAvailability | null>[] return ra.value
) }
: null return {
details: ra.reason,
error: "request_failure",
}
})
return { return {
...returnValue, ...roomsAvailability,
data: combinedAvailability, data,
} }
} }
export function useHotelPackages( export function useHotelPackages(
adultArray: number[], adultArray: number[],
childArray: Child[] | undefined, childArray: ChildrenInRoom,
fromDateString: string, fromDateString: string,
toDateString: string, toDateString: string,
hotelId: number, hotelId: number,
@@ -75,7 +50,7 @@ export function useHotelPackages(
) { ) {
return trpc.hotel.packages.get.useQuery({ return trpc.hotel.packages.get.useQuery({
adults: adultArray[0], // Using the first adult count adults: adultArray[0], // Using the first adult count
children: childArray ? childArray.length : undefined, children: childArray?.[0]?.length, // Using the first children count
endDate: toDateString, endDate: toDateString,
hotelId: hotelId.toString(), hotelId: hotelId.toString(),
packageCodes: [ packageCodes: [

View File

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

View File

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

View File

@@ -3,52 +3,67 @@ import { z } from "zod"
import { ChildBedTypeEnum } from "@/constants/booking" import { ChildBedTypeEnum } from "@/constants/booking"
import { Lang, langToApiLang } from "@/constants/languages" import { Lang, langToApiLang } from "@/constants/languages"
const signupSchema = z.discriminatedUnion("becomeMember", [ const roomsSchema = z
z.object({ .array(
dateOfBirth: z.string(), z.object({
postalCode: z.string(), adults: z.number().int().nonnegative(),
becomeMember: z.literal<boolean>(true), childrenAges: z
}), .array(
z.object({ becomeMember: z.literal<boolean>(false) }), z.object({
]) age: z.number().int().nonnegative(),
bedType: z.nativeEnum(ChildBedTypeEnum),
const roomsSchema = z.array( })
z.object({ )
adults: z.number().int().nonnegative(), .default([]),
childrenAges: z rateCode: z.string(),
.array( roomTypeCode: z.coerce.string(),
z.object({ guest: z.object({
age: z.number().int().nonnegative(), becomeMember: z.boolean(),
bedType: z.nativeEnum(ChildBedTypeEnum), countryCode: z.string(),
}) dateOfBirth: z.string().nullish(),
) email: z.string().email(),
.default([]),
rateCode: z.string(),
roomTypeCode: z.coerce.string(),
guest: z.intersection(
z.object({
firstName: z.string(), firstName: z.string(),
lastName: z.string(), lastName: z.string(),
email: z.string().email(), membershipNumber: z.string().nullish(),
postalCode: z.string().nullish(),
phoneNumber: z.string(), phoneNumber: z.string(),
countryCode: z.string(),
membershipNumber: z.string().optional(),
}), }),
signupSchema smsConfirmationRequested: z.boolean(),
), packages: z.object({
smsConfirmationRequested: z.boolean(), breakfast: z.boolean(),
packages: z.object({ allergyFriendly: z.boolean(),
breakfast: z.boolean(), petFriendly: z.boolean(),
allergyFriendly: z.boolean(), accessibility: z.boolean(),
petFriendly: z.boolean(), }),
accessibility: z.boolean(), roomPrice: z.object({
}), memberPrice: z.number().nullish(),
roomPrice: z.object({ publicPrice: z.number().nullish(),
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({ const paymentSchema = z.object({
paymentMethod: z.string(), paymentMethod: z.string(),

View File

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

View File

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

View File

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

View File

@@ -38,9 +38,15 @@ export const roomConfigurationSchema = z
(product) => !product.public?.rateCode && !product.member?.rateCode (product) => !product.public?.rateCode && !product.member?.rateCode
) )
if (allProductsMissBothRateCodes) { 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) ? parseInt(room.breakfast.localPrice?.price ?? 0)
: 0 : 0
const roomFeaturesTotal = room.roomFeatures?.reduce((total, pkg) => { const roomFeaturesTotal = room.roomFeatures?.reduce(
if (pkg.requestedPrice.totalPrice) { (total, pkg) => {
total = add(total, pkg.requestedPrice.totalPrice) if (pkg.requestedPrice.totalPrice) {
} total.requestedPrice = add(
return total total.requestedPrice,
}, 0) pkg.requestedPrice.totalPrice
)
}
total.local = add(total.local, pkg.localPrice.totalPrice)
return total
},
{ local: 0, requestedPrice: 0 }
)
const result: Price = { const result: Price = {
requested: roomPrice.perStay.requested requested: roomPrice.perStay.requested
@@ -235,13 +243,13 @@ export function calcTotalPrice(
acc.local.price, acc.local.price,
roomPrice.perStay.local.price, roomPrice.perStay.local.price,
breakfastLocalPrice * room.adults * nights, breakfastLocalPrice * room.adults * nights,
roomFeaturesTotal roomFeaturesTotal?.local ?? 0
), ),
regularPrice: add( regularPrice: add(
acc.local.regularPrice, acc.local.regularPrice,
roomPrice.perStay.local.regularPrice, roomPrice.perStay.local.regularPrice,
breakfastLocalPrice * room.adults * nights, breakfastLocalPrice * room.adults * nights,
roomFeaturesTotal roomFeaturesTotal?.requestedPrice ?? 0
), ),
}, },
} }

View File

@@ -369,24 +369,33 @@ export function createDetailsStore(
return set( return set(
produce((state: DetailsState) => { produce((state: DetailsState) => {
state.rooms[idx].steps[StepEnum.details].isValid = true state.rooms[idx].steps[StepEnum.details].isValid = true
const currentRoom = state.rooms[idx].room
state.rooms[idx].room.guest.countryCode = data.countryCode currentRoom.guest.countryCode = data.countryCode
state.rooms[idx].room.guest.dateOfBirth = data.dateOfBirth currentRoom.guest.email = data.email
state.rooms[idx].room.guest.email = data.email currentRoom.guest.firstName = data.firstName
state.rooms[idx].room.guest.firstName = data.firstName currentRoom.guest.join = data.join
state.rooms[idx].room.guest.join = data.join currentRoom.guest.lastName = data.lastName
state.rooms[idx].room.guest.lastName = data.lastName
if (data.join) { if (data.join) {
state.rooms[idx].room.guest.membershipNo = undefined currentRoom.guest.membershipNo = undefined
} else { } else {
state.rooms[idx].room.guest.membershipNo = data.membershipNo currentRoom.guest.membershipNo = data.membershipNo
} }
state.rooms[idx].room.guest.phoneNumber = data.phoneNumber currentRoom.guest.phoneNumber = data.phoneNumber
state.rooms[idx].room.guest.zipCode = data.zipCode
state.rooms[idx].room.roomPrice = getRoomPrice( // Only valid for room 1
state.rooms[idx].room.roomRate, 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) 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 { useContext } from "react"
import { create, useStore } from "zustand" import { create, useStore } from "zustand"
import { filterDuplicateRoomTypesByLowestPrice } from "@/stores/select-rate/helper"
import { RatesContext } from "@/contexts/Rates" import { RatesContext } from "@/contexts/Rates"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { RateTypeEnum } from "@/types/enums/rateType" 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" import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
const statusLookup = {
[AvailabilityEnum.Available]: 1,
[AvailabilityEnum.NotAvailable]: 2,
}
function findSelectedRate( function findSelectedRate(
rateCode: string, rateCode: string,
roomTypeCode: string, roomTypeCode: string,
rooms: RoomConfiguration[] rooms: RoomConfiguration[] | AvailabilityError
) { ) {
if (!Array.isArray(rooms)) {
return null
}
return rooms.find( return rooms.find(
(room) => (room) =>
room.roomTypeCode === roomTypeCode && room.roomTypeCode === roomTypeCode &&
@@ -70,23 +69,26 @@ export function createRatesStore({
}, },
] ]
let allRooms: RoomConfiguration[] = [] let roomConfigurations: RatesState["roomConfigurations"] = []
if (roomsAvailability?.roomConfigurations) { if (roomsAvailability) {
allRooms = filterDuplicateRoomTypesByLowestPrice( for (const availability of roomsAvailability) {
roomsAvailability.roomConfigurations if ("error" in availability) {
).sort( // Availability request failed, default to empty array
// @ts-expect-error - array indexing roomConfigurations.push([])
(a, b) => statusLookup[a.status] - statusLookup[b.status] } else {
) roomConfigurations.push(availability.roomConfigurations)
}
}
} }
const rateSummary: RatesState["rateSummary"] = [] const rateSummary: RatesState["rateSummary"] = []
booking.rooms.forEach((room, idx) => { booking.rooms.forEach((room, idx) => {
if (room.rateCode && room.roomTypeCode) { if (room.rateCode && room.roomTypeCode) {
const selectedRoom = roomsAvailability?.roomConfigurations.find( const roomConfiguration = roomConfigurations?.[idx]
(roomConf) => const selectedRoom = roomConfiguration.find(
roomConf.roomTypeCode === room.roomTypeCode && (rc) =>
roomConf.products.find( rc.roomTypeCode === room.roomTypeCode &&
rc.products.find(
(product) => (product) =>
product.public?.rateCode === room.rateCode || product.public?.rateCode === room.rateCode ||
product.member?.rateCode === room.rateCode product.member?.rateCode === room.rateCode
@@ -149,31 +151,34 @@ export function createRatesStore({
return set( return set(
produce((state: RatesState) => { produce((state: RatesState) => {
state.rooms[idx].selectedPackage = code state.rooms[idx].selectedPackage = code
const searchParams = new URLSearchParams(state.searchParams) const roomConfiguration = state.roomConfigurations[idx]
if (code) { if (roomConfiguration) {
state.rooms[idx].rooms = state.allRooms.filter((room) => const searchParams = new URLSearchParams(state.searchParams)
room.features.find((feat) => feat.code === code) 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, activeRoom,
allRooms,
booking, booking,
filterOptions, filterOptions,
hotelType, hotelType,
@@ -258,9 +262,12 @@ export function createRatesStore({
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM (pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
), ),
rateSummary, rateSummary,
rooms: booking.rooms.map((room) => { roomConfigurations,
rooms: booking.rooms.map((room, idx) => {
const roomConfiguration = roomConfigurations[idx]
const selectedRate = const selectedRate =
findSelectedRate(room.rateCode, room.roomTypeCode, allRooms) ?? null findSelectedRate(room.rateCode, room.roomTypeCode, roomConfiguration) ??
null
const product = selectedRate?.products.find( const product = selectedRate?.products.find(
(prd) => (prd) =>
@@ -270,22 +277,25 @@ export function createRatesStore({
const selectedPackage = room.packages?.[0] const selectedPackage = room.packages?.[0]
let rooms: RoomConfiguration[] = roomConfiguration
if (selectedPackage) {
rooms = roomConfiguration.filter((r) =>
r.features.find((f) => f.code === selectedPackage)
)
}
return { return {
bookingRoom: room, bookingRoom: room,
rooms: selectedPackage rooms,
? allRooms.filter((r) =>
r.features.find((f) => f.code === selectedPackage)
)
: allRooms,
selectedPackage, selectedPackage,
selectedRate: selectedRate:
selectedRate && product selectedRate && product
? { ? {
features: selectedRate.features, features: selectedRate.features,
product, product,
roomType: selectedRate.roomType, roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode, roomTypeCode: selectedRate.roomTypeCode,
} }
: null, : null,
} }
}), }),

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
import type { HotelData } from "@/types/hotel" 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 { export interface RoomsContainerProps {
adultArray: number[] adultArray: number[]
booking: SelectRateSearchParams booking: SelectRateSearchParams
bookingCode?: string bookingCode?: string
childArray?: Child[] childArray: ChildrenInRoom
fromDate: Date fromDate: Date
hotelData: HotelData | null hotelData: HotelData | null
hotelId: number hotelId: number

View File

@@ -47,46 +47,46 @@ export type TrackingSDKUserData = {
} }
export type TrackingSDKHotelInfo = { export type TrackingSDKHotelInfo = {
hotelID?: string
arrivalDate?: string
departureDate?: string
noOfAdults?: number
noOfChildren?: number
ageOfChildren?: string // "10", "2,5,10" 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 //bookingCode?: string
//bookingCodeAvailability?: boolean //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" bookingTypeofDay?: "weekend" | "weekday"
searchTerm?: string childBedPreference?: string
roomPrice?: number 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 rateCode?: string
rateCodeCancellationRule?: string rateCodeCancellationRule?: string
rateCodeName?: string // Scandic Friends - full flex inkl. frukost rateCodeName?: string // Scandic Friends - full flex inkl. frukost
rateCodeType?: string // regular, promotion etc 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 region?: string // Region of the hotel
discount?: number revenueCurrencyCode?: string // SEK, DKK, NOK, EUR
totalPrice?: number roomPrice?: number | string
lowestRoomPrice?: number roomTypeCode?: string
roomTypeName?: string
roomTypePosition?: number // Which position the room had in the list of available rooms
searchTerm?: string
searchType?: "destination" | "hotel" searchType?: "destination" | "hotel"
ancillaries?: Ancillary[] specialRoomType?: string // allergy room, pet-friendly, accesibillity room
totalPrice?: number | string
} }
export type Ancillary = { export type Ancillary = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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