Merged in feat/enter-details-multiroom (pull request #1280)

feat(SW-1259): Enter details multiroom

* refactor: remove per-step URLs

* WIP: map multiroom data

* fix: lint errors in details page

* fix: made useEnterDetailsStore tests pass

* fix: WIP refactor enter details store

* fix: WIP enter details store update

* fix: added room index to select correct room

* fix: added logic for navigating between steps and rooms

* fix: update summary to work with store changes

* fix: added room and total price calculation

* fix: removed unused code and added test for breakfast included

* refactor: move store selectors into helpers

* refactor: session storage state for multiroom booking

* feat: update enter details accordion navigation

* fix: added room index to each form component so they select correct room

* fix: added unique id to input to handle case when multiple inputs have same name

* fix: update payment step with store changes

* fix: rebase issues

* fix: now you should only be able to go to a step if previous room is completed

* refactor: cleanup

* fix: if no availability just skip that room for now

* fix: add select-rate Summary and adjust typings


Approved-by: Arvid Norlin
This commit is contained in:
Tobias Johansson
2025-02-11 14:24:24 +00:00
committed by Arvid Norlin
parent f43ee4a0e6
commit b394d54c3f
48 changed files with 1870 additions and 1150 deletions

View File

@@ -6,7 +6,7 @@ import {
} from "@/constants/booking"
import {
bookingConfirmation,
payment,
details,
} from "@/constants/routes/hotelReservation"
import { serverClient } from "@/lib/trpc/server"
@@ -38,7 +38,7 @@ export default async function PaymentCallbackPage({
redirect(confirmationUrl)
}
const returnUrl = payment(lang)
const returnUrl = details(lang)
const searchObject = new URLSearchParams()
let errorMessage = undefined

View File

@@ -6,6 +6,13 @@
.content {
width: var(--max-width-page);
margin: var(--Spacing-x3) auto 0;
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
}
.header {
padding-bottom: var(--Spacing-x3);
}
.summary {

View File

@@ -0,0 +1,259 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import {
getBreakfastPackages,
getHotel,
getPackages,
getProfileSafely,
getSelectedRoomAvailability,
} from "@/lib/trpc/memoizedRequests"
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
import Details from "@/components/HotelReservation/EnterDetails/Details"
import HotelHeader from "@/components/HotelReservation/EnterDetails/Header"
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
import { generateChildrenString } from "@/components/HotelReservation/utils"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { setLang } from "@/i18n/serverContext"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { convertSearchParamsToObj } from "@/utils/url"
import styles from "./page.module.css"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import { type SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { StepEnum } from "@/types/enums/step"
import type { LangParams, PageArgs } from "@/types/params"
import type { Packages } from "@/types/requests/packages"
export interface RoomData {
bedTypes?: BedTypeSelection[]
mustBeGuaranteed?: boolean
breakfastIncluded?: boolean
packages: Packages | null
cancellationText: string
rateDetails: string[]
roomType: string
roomRate: RoomRate
}
export default async function DetailsPage({
params: { lang },
searchParams,
}: PageArgs<LangParams, SelectRateSearchParams>) {
setLang(lang)
const intl = await getIntl()
const selectRoomParams = new URLSearchParams(searchParams)
const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
void getProfileSafely()
const breakfastInput = {
adults: 1,
fromDate: booking.fromDate,
hotelId: booking.hotelId,
toDate: booking.toDate,
}
const breakfastPackages = await getBreakfastPackages(breakfastInput)
const roomsData: RoomData[] = []
for (let room of booking.rooms) {
const childrenAsString =
room.childrenInRoom && generateChildrenString(room.childrenInRoom)
const selectedRoomAvailabilityInput = {
adults: room.adults,
children: childrenAsString,
hotelId: booking.hotelId,
packageCodes: room.packages,
rateCode: room.rateCode,
roomStayStartDate: booking.fromDate,
roomStayEndDate: booking.toDate,
roomTypeCode: room.roomTypeCode,
}
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,
})
: null
const roomAvailability = await getSelectedRoomAvailability(
selectedRoomAvailabilityInput //
)
if (!roomAvailability) {
continue // TODO: handle no room availability
}
roomsData.push({
bedTypes: roomAvailability.bedTypes,
packages,
mustBeGuaranteed: roomAvailability.mustBeGuaranteed,
breakfastIncluded: roomAvailability.breakfastIncluded,
cancellationText: roomAvailability.cancellationText,
rateDetails: roomAvailability.rateDetails ?? [],
roomType: roomAvailability.selectedRoom.roomType,
roomRate: {
memberRate: roomAvailability?.memberRate,
publicRate: roomAvailability.publicRate,
},
})
}
const isCardOnlyPayment = roomsData.some((room) => room?.mustBeGuaranteed)
const hotelData = await getHotel({
hotelId: booking.hotelId,
isCardOnlyPayment,
language: lang,
})
const user = await getProfileSafely()
// const userTrackingData = await getUserTracking()
if (!hotelData || !roomsData) {
return notFound()
}
// const arrivalDate = new Date(booking.fromDate)
// const departureDate = new Date(booking.toDate)
const hotelAttributes = hotelData.hotel
// TODO: add tracking
// const initialHotelsTrackingData: TrackingSDKHotelInfo = {
// searchTerm: searchParams.city,
// arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
// departureDate: format(departureDate, "yyyy-MM-dd"),
// noOfAdults: adults,
// noOfChildren: childrenInRoom?.length,
// ageOfChildren: childrenInRoom?.map((c) => c.age).join(","),
// childBedPreference: childrenInRoom
// ?.map((c) => ChildBedMapEnum[c.bed])
// .join("|"),
// noOfRooms: 1, // // TODO: Handle multiple rooms
// duration: differenceInCalendarDays(departureDate, arrivalDate),
// leadTime: differenceInCalendarDays(arrivalDate, new Date()),
// searchType: "hotel",
// bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
// country: hotelAttributes?.address.country,
// hotelID: hotelAttributes?.operaId,
// region: hotelAttributes?.address.city,
// }
const showBreakfastStep = Boolean(
breakfastPackages?.length && !roomsData[0].breakfastIncluded
)
return (
<EnterDetailsProvider
booking={booking}
showBreakfastStep={showBreakfastStep}
roomsData={roomsData}
searchParamsStr={selectRoomParams.toString()}
user={user}
vat={hotelAttributes.vat}
>
<main>
<HotelHeader hotelData={hotelData} />
<div className={styles.container}>
<div className={styles.content}>
{roomsData.map((room, idx) => (
<section key={idx}>
{roomsData.length > 1 && (
<header className={styles.header}>
<Title level="h2" as="h4">
{intl.formatMessage({ id: "Room" })} {idx + 1}
</Title>
</header>
)}
<SelectedRoom
hotelId={booking.hotelId}
roomType={room.roomType}
roomTypeCode={booking.rooms[idx].roomTypeCode}
rateDescription={room.cancellationText}
/>
{room.bedTypes ? (
<SectionAccordion
header={intl.formatMessage({ id: "Select bed" })}
label={intl.formatMessage({ id: "Request bedtype" })}
step={StepEnum.selectBed}
roomIndex={idx}
>
<BedType bedTypes={room.bedTypes} roomIndex={idx} />
</SectionAccordion>
) : null}
{showBreakfastStep ? (
<SectionAccordion
header={intl.formatMessage({ id: "Food options" })}
label={intl.formatMessage({
id: "Select breakfast options",
})}
step={StepEnum.breakfast}
roomIndex={idx}
>
<Breakfast packages={breakfastPackages!} roomIndex={idx} />
</SectionAccordion>
) : null}
<SectionAccordion
header={intl.formatMessage({ id: "Details" })}
step={StepEnum.details}
label={intl.formatMessage({ id: "Enter your details" })}
roomIndex={idx}
>
<Details
user={idx === 0 ? user : null}
memberPrice={{
currency:
room?.roomRate.memberRate?.localPrice.currency ?? "", // TODO: how to handle undefined,
price:
room?.roomRate.memberRate?.localPrice.pricePerNight ??
0, // TODO: how to handle undefined,
}}
roomIndex={idx}
/>
</SectionAccordion>
</section>
))}
<Suspense>
<Payment
user={user}
otherPaymentOptions={
hotelAttributes.merchantInformationData
.alternatePaymentOptions
}
supportedCards={hotelAttributes.merchantInformationData.cards}
mustBeGuaranteed={isCardOnlyPayment}
/>
</Suspense>
</div>
<aside className={styles.summary}>
<MobileSummary
isMember={!!user}
breakfastIncluded={roomsData[0].breakfastIncluded ?? false}
/>
<DesktopSummary
isMember={!!user}
breakfastIncluded={roomsData[0].breakfastIncluded ?? false}
/>
</aside>
</div>
</main>
</EnterDetailsProvider>
)
}

View File

@@ -3,6 +3,7 @@ import { usePathname } from "next/navigation"
import { useEffect, useMemo, useRef } from "react"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRoom } from "@/stores/enter-details/helpers"
import { useSessionId } from "@/hooks/useSessionId"
import { createSDKPageObject, trackPageView } from "@/utils/tracking"
@@ -36,8 +37,11 @@ export default function EnterDetailsTracking(props: Props) {
cancellationRule,
} = props
const { bedType, breakfast, totalPrice, roomPrice, roomRate, packages } =
useEnterDetailsStore((state) => state)
const { bedType, breakfast, roomPrice, roomRate, roomFeatures } =
useEnterDetailsStore(selectRoom)
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
const pathName = usePathname()
const sessionId = useSessionId()
@@ -128,7 +132,7 @@ export default function EnterDetailsTracking(props: Props) {
revenueCurrencyCode: totalPrice.local?.currency,
breakfastOption: breakfast ? "breakfast buffet" : "no breakfast",
totalPrice: totalPrice.local?.price,
specialRoomType: getSpecialRoomType(packages),
specialRoomType: getSpecialRoomType(roomFeatures),
roomTypeName: selectedRoom.roomType,
bedType: bedType?.description,
roomTypeCode: bedType?.roomTypeCode,
@@ -148,7 +152,7 @@ export default function EnterDetailsTracking(props: Props) {
totalPrice,
roomPrice,
roomRate,
packages,
roomFeatures,
initialHotelsTrackingData,
cancellationRule,
selectedRoom.roomType,

View File

@@ -1,286 +0,0 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import {
getBreakfastPackages,
getHotel,
getPackages,
getProfileSafely,
getSelectedRoomAvailability,
getUserTracking,
} from "@/lib/trpc/memoizedRequests"
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
import Details from "@/components/HotelReservation/EnterDetails/Details"
import HotelHeader from "@/components/HotelReservation/EnterDetails/Header"
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
import { generateChildrenString } from "@/components/HotelReservation/utils"
import { getIntl } from "@/i18n"
import { setLang } from "@/i18n/serverContext"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { convertSearchParamsToObj } from "@/utils/url"
import EnterDetailsTracking from "./enterDetailsTracking"
import styles from "./page.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import { type SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { type TrackingSDKHotelInfo } from "@/types/components/tracking"
import { StepEnum } from "@/types/enums/step"
import type { LangParams, PageArgs } from "@/types/params"
function isValidStep(step: string): step is StepEnum {
return Object.values(StepEnum).includes(step as StepEnum)
}
export default async function StepPage({
params: { lang },
searchParams,
}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) {
if (!isValidStep(searchParams.step)) {
return notFound()
}
setLang(lang)
const intl = await getIntl()
const selectRoomParams = new URLSearchParams(searchParams)
// Deleting step to avoid double searchparams after rewrite
selectRoomParams.delete("step")
const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
const {
hotelId,
rooms: [
{
adults,
childrenInRoom,
roomTypeCode,
rateCode,
packages: packageCodes,
},
], // TODO: Handle multiple rooms
fromDate,
toDate,
} = booking
const childrenAsString =
childrenInRoom && generateChildrenString(childrenInRoom)
const breakfastInput = { adults, fromDate, hotelId, toDate }
const selectedRoomAvailabilityInput = {
adults,
children: childrenAsString,
hotelId,
packageCodes,
rateCode,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
roomTypeCode,
}
void getProfileSafely()
void getBreakfastPackages(breakfastInput)
void getSelectedRoomAvailability(selectedRoomAvailabilityInput)
if (packageCodes?.length) {
void getPackages({
adults,
children: childrenInRoom?.length,
endDate: toDate,
hotelId,
packageCodes,
startDate: fromDate,
})
}
const packages = packageCodes
? await getPackages({
adults,
children: childrenInRoom?.length,
endDate: toDate,
hotelId,
packageCodes,
startDate: fromDate,
})
: null
const roomAvailability = await getSelectedRoomAvailability(
selectedRoomAvailabilityInput
)
const hotelData = await getHotel({
hotelId,
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
language: lang,
})
const breakfastPackages = await getBreakfastPackages(breakfastInput)
const user = await getProfileSafely()
const userTrackingData = await getUserTracking()
if (!hotelData || !roomAvailability) {
return notFound()
}
const { mustBeGuaranteed, breakfastIncluded } = roomAvailability
const paymentGuarantee = intl.formatMessage({
id: "Payment Guarantee",
})
const payment = intl.formatMessage({
id: "Payment",
})
const guaranteeWithCard = intl.formatMessage({
id: "Guarantee booking with credit card",
})
const selectPaymentMethod = intl.formatMessage({
id: "Select payment method",
})
const roomPrice = {
memberPrice: roomAvailability.memberRate?.localPrice.pricePerStay,
publicPrice: roomAvailability.publicRate!.localPrice.pricePerStay,
}
const memberPrice = roomAvailability.memberRate
? {
price: roomAvailability.memberRate.localPrice.pricePerStay,
currency: roomAvailability.memberRate.localPrice.currency,
}
: undefined
const arrivalDate = new Date(fromDate)
const departureDate = new Date(toDate)
const hotelAttributes = hotelData?.hotel
const initialHotelsTrackingData: TrackingSDKHotelInfo = {
searchTerm: searchParams.city,
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: adults,
noOfChildren: childrenInRoom?.length,
ageOfChildren: childrenInRoom?.map((c) => c.age).join(","),
childBedPreference: childrenInRoom
?.map((c) => ChildBedMapEnum[c.bed])
.join("|"),
noOfRooms: 1, // // TODO: Handle multiple rooms
duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
searchType: "hotel",
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
country: hotelAttributes?.address.country,
hotelID: hotelAttributes?.operaId,
region: hotelAttributes?.address.city,
}
const summary = {
cancellationText: roomAvailability.cancellationText,
isMember: !!user,
rateDetails: roomAvailability.rateDetails,
roomType: roomAvailability.selectedRoom.roomType,
breakfastIncluded,
}
const showBreakfastStep = Boolean(
breakfastPackages?.length && !breakfastIncluded
)
return (
<EnterDetailsProvider
bedTypes={roomAvailability.bedTypes}
booking={booking}
showBreakfastStep={showBreakfastStep}
packages={packages}
roomRate={{
memberRate: roomAvailability.memberRate,
publicRate: roomAvailability.publicRate,
}}
searchParamsStr={selectRoomParams.toString()}
step={searchParams.step}
user={user}
vat={hotelAttributes.vat}
>
<main>
<HotelHeader hotelData={hotelData} />
<div className={styles.container}>
<div className={styles.content}>
<section>
<HistoryStateManager />
<SelectedRoom
hotelId={hotelId}
room={roomAvailability.selectedRoom}
rateDescription={roomAvailability.cancellationText}
/>
{/* TODO: How to handle no beds found? */}
{roomAvailability.bedTypes ? (
<SectionAccordion
header={intl.formatMessage({ id: "Bed type" })}
label={intl.formatMessage({ id: "Request bedtype" })}
step={StepEnum.selectBed}
>
<BedType bedTypes={roomAvailability.bedTypes} />
</SectionAccordion>
) : null}
{showBreakfastStep ? (
<SectionAccordion
header={intl.formatMessage({ id: "Food options" })}
label={intl.formatMessage({ id: "Select breakfast options" })}
step={StepEnum.breakfast}
>
<Breakfast packages={breakfastPackages!} />
</SectionAccordion>
) : null}
<SectionAccordion
header={intl.formatMessage({ id: "Details" })}
step={StepEnum.details}
label={intl.formatMessage({ id: "Enter your details" })}
>
<Details user={user} memberPrice={memberPrice} />
</SectionAccordion>
<SectionAccordion
header={mustBeGuaranteed ? paymentGuarantee : payment}
step={StepEnum.payment}
label={
mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod
}
>
<Suspense>
<Payment
user={user}
roomPrice={roomPrice}
otherPaymentOptions={
hotelData.hotel.merchantInformationData
.alternatePaymentOptions
}
supportedCards={
hotelData.hotel.merchantInformationData.cards
}
mustBeGuaranteed={mustBeGuaranteed}
/>
</Suspense>
</SectionAccordion>
</section>
</div>
<aside className={styles.summary}>
<MobileSummary {...summary} />
<DesktopSummary {...summary} />
</aside>
</div>
</main>
<EnterDetailsTracking
initialHotelsTrackingData={initialHotelsTrackingData}
userTrackingData={userTrackingData}
selectedRoom={roomAvailability.selectedRoom}
cancellationRule={roomAvailability.cancellationText}
lang={lang}
/>
</EnterDetailsProvider>
)
}