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

@@ -5,13 +5,14 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import type {
DetailsSchema,
RoomPrice,
RoomRate,
SignedInDetailsSchema,
} from "@/types/components/hotelReservation/enterDetails/details"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { CurrencyEnum } from "@/types/enums/currency"
import { PackageTypeEnum } from "@/types/enums/packages"
import type { RoomPrice, RoomRate } from "@/types/stores/enter-details"
export const booking: SelectRateSearchParams = {
city: "Stockholm",
@@ -27,6 +28,14 @@ export const booking: SelectRateSearchParams = {
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
packages: [RoomPackageCodeEnum.PET_ROOM],
},
{
adults: 2,
roomTypeCode: "",
rateCode: "",
counterRateCode: "",
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
packages: [RoomPackageCodeEnum.PET_ROOM],
},
],
}

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

View File

@@ -10,6 +10,7 @@ import {
type ExtraBedTypeEnum,
} from "@/constants/booking"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRoom } from "@/stores/enter-details/helpers"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -24,10 +25,12 @@ import type {
} from "@/types/components/hotelReservation/enterDetails/bedType"
import type { IconProps } from "@/types/components/icon"
export default function BedType({ bedTypes }: BedTypeProps) {
const initialBedType = useEnterDetailsStore(
(state) => state.formValues?.bedType?.roomTypeCode
)
export default function BedType({
bedTypes,
roomIndex,
}: BedTypeProps & { roomIndex: number }) {
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
const initialBedType = room.bedType?.roomTypeCode
const updateBedType = useEnterDetailsStore(
(state) => state.actions.updateBedType
@@ -57,6 +60,12 @@ export default function BedType({ bedTypes }: BedTypeProps) {
[bedTypes, updateBedType]
)
useEffect(() => {
if (initialBedType) {
methods.setValue("bedType", initialBedType)
}
}, [initialBedType, methods])
useEffect(() => {
if (methods.formState.isSubmitting) {
return
@@ -109,9 +118,13 @@ function BedIconRenderer({
extraBedType: ExtraBedTypeEnum | undefined
props: IconProps
}) {
const MainBedIcon = BED_TYPE_ICONS[mainBedType]
const MainBedIcon = BED_TYPE_ICONS[mainBedType] ?? BED_TYPE_ICONS.Other
const ExtraBedIcon = extraBedType ? BED_TYPE_ICONS[extraBedType] : null
if (!MainBedIcon) {
return null
}
return (
<div className={`${props.className} ${styles.iconContainer}`}>
<MainBedIcon height={32} color="uiTextMediumContrast" />

View File

@@ -6,6 +6,7 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRoom } from "@/stores/enter-details/helpers"
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -22,16 +23,19 @@ import type {
} from "@/types/components/hotelReservation/enterDetails/breakfast"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Breakfast({ packages }: BreakfastProps) {
export default function Breakfast({
packages,
roomIndex,
}: BreakfastProps & { roomIndex: number }) {
const intl = useIntl()
const formValuesBreakfast = useEnterDetailsStore(({ formValues }) =>
formValues?.breakfast
? formValues.breakfast.code
: formValues?.breakfast === false
? "false"
: undefined
)
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
const breakfastSelection = room?.breakfast
? room.breakfast.code
: room?.breakfast === false
? "false"
: undefined
const updateBreakfast = useEnterDetailsStore(
(state) => state.actions.updateBreakfast
@@ -42,8 +46,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
)
const methods = useForm<BreakfastFormSchema>({
defaultValues: formValuesBreakfast
? { breakfast: formValuesBreakfast }
defaultValues: breakfastSelection
? { breakfast: breakfastSelection }
: undefined,
criteriaMode: "all",
mode: "all",
@@ -63,6 +67,12 @@ export default function Breakfast({ packages }: BreakfastProps) {
[packages, updateBreakfast]
)
useEffect(() => {
if (breakfastSelection) {
methods.setValue("breakfast", breakfastSelection)
}
}, [breakfastSelection, methods])
useEffect(() => {
if (methods.formState.isSubmitting) {
return

View File

@@ -3,6 +3,7 @@
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRoom } from "@/stores/enter-details/helpers"
import { MagicWandIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
@@ -23,7 +24,8 @@ export default function MemberPriceModal({
isOpen: boolean
setIsOpen: Dispatch<SetStateAction<boolean>>
}) {
const memberRate = useEnterDetailsStore((state) => state.roomRate.memberRate)
const room = useEnterDetailsStore(selectRoom)
const memberRate = room.roomRate.memberRate
const intl = useIntl()
const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice

View File

@@ -5,6 +5,10 @@ import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import {
selectBookingProgress,
selectRoom,
} from "@/stores/enter-details/helpers"
import Button from "@/components/TempDesignSystem/Button"
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
@@ -26,15 +30,25 @@ import type {
} from "@/types/components/hotelReservation/enterDetails/details"
const formID = "enter-details"
export default function Details({ user, memberPrice }: DetailsProps) {
export default function Details({
user,
memberPrice,
roomIndex,
}: DetailsProps & { roomIndex: number }) {
const intl = useIntl()
const [isMemberPriceModalOpen, setIsMemberPriceModalOpen] = useState(false)
const initialData = useEnterDetailsStore((state) => state.guest)
const { currentRoomIndex, canProceedToPayment, roomStatuses } =
useEnterDetailsStore(selectBookingProgress)
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
const initialData = room.guest
const updateDetails = useEnterDetailsStore(
(state) => state.actions.updateDetails
)
const isPaymentNext = currentRoomIndex === roomStatuses.length - 1
const methods = useForm<DetailsSchema>({
criteriaMode: "all",
mode: "all",
@@ -68,7 +82,7 @@ export default function Details({ user, memberPrice }: DetailsProps) {
<FormProvider {...methods}>
<form
className={styles.form}
id={formID}
id={`${formID}-room-${roomIndex + 1}`}
onSubmit={methods.handleSubmit(onSubmit)}
>
{user ? null : (
@@ -127,13 +141,23 @@ export default function Details({ user, memberPrice }: DetailsProps) {
</div>
<footer className={styles.footer}>
<Button
disabled={!methods.formState.isValid}
disabled={
!(
methods.formState.isValid ||
(isPaymentNext && canProceedToPayment)
)
}
intent="secondary"
size="small"
theme="base"
type="submit"
>
{intl.formatMessage({ id: "Proceed to payment" })}
{isPaymentNext
? intl.formatMessage({ id: "Proceed to payment method" })
: intl.formatMessage(
{ id: "Continue to room {nextRoomNumber}" },
{ nextRoomNumber: currentRoomIndex + 2 }
)}
</Button>
</footer>
<MemberPriceModal

View File

@@ -1,39 +0,0 @@
"use client"
import { useCallback, useEffect } from "react"
import { useEnterDetailsStore } from "@/stores/enter-details"
export default function HistoryStateManager() {
const setCurrentStep = useEnterDetailsStore((state) => state.actions.setStep)
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const handleBackButton = useCallback(
(event: PopStateEvent) => {
if (event.state.step) {
setCurrentStep(event.state.step)
}
},
[setCurrentStep]
)
useEffect(() => {
window.addEventListener("popstate", handleBackButton)
return () => {
window.removeEventListener("popstate", handleBackButton)
}
}, [handleBackButton])
useEffect(() => {
if (!window.history.state.step) {
window.history.replaceState(
{ step: currentStep },
"",
document.location.href
)
}
}, [currentStep])
return null
}

View File

@@ -9,7 +9,7 @@ import LoadingSpinner from "@/components/LoadingSpinner"
import { trackPaymentEvent } from "@/utils/tracking"
import { convertObjToSearchParams } from "@/utils/url"
import type { PersistedState } from "@/types/stores/enter-details"
// import type { PersistedState } from "@/types/stores/enter-details"
export default function PaymentCallback({
returnUrl,
@@ -28,7 +28,7 @@ export default function PaymentCallback({
const bookingData = window.sessionStorage.getItem(detailsStorageName)
if (bookingData) {
const detailsStorage: PersistedState = JSON.parse(bookingData)
const detailsStorage: any = JSON.parse(bookingData) // TODO: fix type here
const searchParams = convertObjToSearchParams(
detailsStorage.booking,
searchObject

View File

@@ -26,6 +26,7 @@ import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
@@ -55,7 +56,6 @@ function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
export default function PaymentClient({
user,
roomPrice,
otherPaymentOptions,
savedCreditCards,
mustBeGuaranteed,
@@ -65,13 +65,18 @@ export default function PaymentClient({
const intl = useIntl()
const searchParams = useSearchParams()
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
const { bedType, booking, breakfast } = useEnterDetailsStore((state) => ({
bedType: state.bedType,
booking: state.booking,
breakfast: state.breakfast,
}))
const userData = useEnterDetailsStore((state) => state.guest)
const { totalPrice, booking, rooms, bookingProgress } = useEnterDetailsStore(
(state) => {
return {
totalPrice: state.totalPrice,
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
}
}
)
const canProceedToPayment = bookingProgress.canProceedToPayment
const setIsSubmittingDisabled = useEnterDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
)
@@ -87,7 +92,7 @@ export default function PaymentClient({
newPrice: number
} | null>()
const { toDate, fromDate, rooms, hotelId } = booking
const { toDate, fromDate, hotelId } = booking
usePaymentFailedToast()
@@ -115,7 +120,7 @@ export default function PaymentClient({
if (priceChange) {
setPriceChangeData({
oldPrice: roomPrice.publicPrice,
oldPrice: rooms[0].roomPrice.perStay.local.price,
newPrice: priceChange.totalPrice,
})
} else {
@@ -202,18 +207,6 @@ export default function PaymentClient({
const handleSubmit = useCallback(
(data: PaymentFormData) => {
const {
firstName,
lastName,
email,
phoneNumber,
countryCode,
membershipNo,
join,
dateOfBirth,
zipCode,
} = userData
// set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod
@@ -239,41 +232,50 @@ export default function PaymentClient({
hotelId,
checkInDate: fromDate,
checkOutDate: toDate,
rooms: rooms.map((room) => ({
rooms: rooms.map((room, idx) => ({
adults: room.adults,
childrenAges: room.childrenInRoom?.map((child) => ({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
rateCode:
(user || join || membershipNo) && room.counterRateCode
? room.counterRateCode
: room.rateCode,
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
(user || 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: {
firstName,
lastName,
email,
phoneNumber,
countryCode,
membershipNumber: membershipNo,
becomeMember: join,
dateOfBirth,
postalCode: zipCode,
firstName: room.guest.firstName,
lastName: room.guest.lastName,
email: room.guest.email,
phoneNumber: room.guest.phoneNumber,
countryCode: room.guest.countryCode,
membershipNumber: room.guest.membershipNo,
becomeMember: room.guest.join,
dateOfBirth: room.guest.dateOfBirth,
postalCode: room.guest.zipCode,
},
packages: {
breakfast: !!(breakfast && breakfast.code),
breakfast: !!(room.breakfast && room.breakfast.code),
allergyFriendly:
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ??
false,
room.roomFeatures?.some(
(feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM
) ?? false,
petFriendly:
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
room.roomFeatures?.some(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
) ?? false,
accessibility:
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
false,
room.roomFeatures?.some(
(feature) =>
feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
) ?? false,
},
smsConfirmationRequested: data.smsConfirmation,
roomPrice,
roomPrice: {
memberPrice: room.roomRate.memberRate?.localPrice.pricePerStay,
publicPrice: room.roomRate.publicRate.localPrice.pricePerStay,
},
})),
payment: {
paymentMethod,
@@ -292,7 +294,6 @@ export default function PaymentClient({
})
},
[
userData,
savedCreditCards,
lang,
initiateBooking,
@@ -301,9 +302,7 @@ export default function PaymentClient({
toDate,
rooms,
user,
bedType,
breakfast,
roomPrice,
booking,
]
)
@@ -316,8 +315,22 @@ export default function PaymentClient({
return <LoadingSpinner />
}
const paymentGuarantee = intl.formatMessage({
id: "Payment Guarantee",
})
const payment = intl.formatMessage({
id: "Payment",
})
return (
<>
<section
className={`${styles.paymentSection} ${canProceedToPayment ? "" : styles.disabled}`}
>
<header>
<Title level="h2" as="h4">
{mustBeGuaranteed ? paymentGuarantee : payment}
</Title>
</header>
<FormProvider {...methods}>
<form
className={styles.paymentContainer}
@@ -460,6 +473,6 @@ export default function PaymentClient({
}
/>
) : null}
</>
</section>
)
}

View File

@@ -6,7 +6,6 @@ import type { PaymentProps } from "@/types/components/hotelReservation/selectRat
export default async function Payment({
user,
roomPrice,
otherPaymentOptions,
mustBeGuaranteed,
supportedCards,
@@ -18,7 +17,6 @@ export default async function Payment({
return (
<PaymentClient
user={user}
roomPrice={roomPrice}
otherPaymentOptions={otherPaymentOptions}
savedCreditCards={savedCreditCards}
mustBeGuaranteed={mustBeGuaranteed}

View File

@@ -1,3 +1,14 @@
.paymentSection {
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
.paymentContainer {
display: flex;
flex-direction: column;

View File

@@ -3,8 +3,14 @@ import { useEffect, useState } from "react"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import {
selectBookingProgress,
selectNextStep,
selectRoom,
selectRoomStatus,
} from "@/stores/enter-details/helpers"
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@/components/Icons"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useScrollToActiveSection from "@/hooks/booking/useScrollToActiveSection"
@@ -19,24 +25,31 @@ export default function SectionAccordion({
header,
label,
step,
roomIndex,
}: React.PropsWithChildren<SectionAccordionProps>) {
const intl = useIntl()
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const steps = useEnterDetailsStore((state) => state.steps)
const roomStatus = useEnterDetailsStore((state) =>
selectRoomStatus(state, roomIndex)
)
const setStep = useEnterDetailsStore((state) => state.actions.setStep)
const { bedType, breakfast } = useEnterDetailsStore((state) =>
selectRoom(state, roomIndex)
)
const { roomStatuses, currentRoomIndex } = useEnterDetailsStore((state) =>
selectBookingProgress(state)
)
const [isComplete, setIsComplete] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const isValid = useEnterDetailsStore((state) => state.isValid[step])
const navigate = useEnterDetailsStore((state) => state.actions.navigate)
const { bedType, breakfast } = useEnterDetailsStore((state) => ({
bedType: state.bedType,
breakfast: state.breakfast,
}))
const isValid = roomStatus.steps[step]?.isValid ?? false
const [title, setTitle] = useState(label)
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
useScrollToActiveSection(step, steps, currentStep === step)
// useScrollToActiveSection(step, steps, roomStatus.currentStep === step)
useEffect(() => {
if (step === StepEnum.selectBed && bedType) {
@@ -57,11 +70,29 @@ export default function SectionAccordion({
}, [isValid, setIsComplete])
useEffect(() => {
setIsOpen(currentStep === step)
}, [currentStep, setIsOpen, step])
setIsOpen(roomStatus.currentStep === step && currentRoomIndex === roomIndex)
}, [currentRoomIndex, roomIndex, roomStatus.currentStep, setIsOpen, step])
function onModify() {
navigate(step)
setStep(step, roomIndex)
}
function close() {
setIsOpen(false)
const isLastStep = step === StepEnum.details
const hasNextRoom = roomIndex + 1 <= roomStatuses.length
if (!isLastStep) {
const nextStep = selectNextStep(roomStatus)
if (nextStep) {
setStep(nextStep, roomIndex)
}
} else if (isLastStep && hasNextRoom) {
setStep(StepEnum.selectBed, roomIndex + 1)
} else {
// Time for payment, collapse any open step
setStep(null)
}
}
const textColor =
@@ -81,7 +112,7 @@ export default function SectionAccordion({
</div>
<header className={styles.header}>
<button
onClick={onModify}
onClick={isOpen ? close : onModify}
disabled={!isComplete}
className={styles.modifyButton}
>
@@ -97,9 +128,11 @@ export default function SectionAccordion({
<Subtitle className={styles.selection} type="two" color={textColor}>
{title}
</Subtitle>
{isComplete && !isOpen && (
<ChevronDownIcon className={styles.button} color="burgundy" />
{isComplete && (
<ChevronDownIcon
className={`${styles.button} ${isOpen ? styles.buttonOpen : ""}`}
color="burgundy"
/>
)}
</button>
</header>

View File

@@ -16,10 +16,6 @@
transform-origin: top;
}
.accordion:last-child {
border-bottom: none;
}
.header {
grid-area: header;
}
@@ -46,8 +42,13 @@
.button {
grid-area: button;
justify-self: flex-end;
transform-origin: 50% 50%;
transition: transform 0.3s;
}
.buttonOpen {
transform: rotate(180deg);
}
.selection {
grid-area: selection;
}
@@ -85,22 +86,21 @@
}
.contentWrapper {
opacity: 0;
padding-bottom: var(--Spacing-x3);
}
.accordion[data-section-open="true"] .contentWrapper {
opacity: 1;
}
.content {
overflow: hidden;
grid-area: content;
opacity: 0;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
transform-origin: top;
transition: opacity 0.2s linear;
}
.accordion[data-section-open="true"] .content {
opacity: 1;
}
.content:has([data-section-open="true"]) {
overflow: visible;
}

View File

@@ -18,7 +18,8 @@ import type { SelectedRoomProps } from "@/types/components/hotelReservation/ente
export default function SelectedRoom({
hotelId,
room,
roomType,
roomTypeCode,
rateDescription,
}: SelectedRoomProps) {
const intl = useIntl()
@@ -50,7 +51,7 @@ export default function SelectedRoom({
{intl.formatMessage<React.ReactNode>(
{ id: "{roomType} <rate>{rateDescription}</rate>" },
{
roomType: room.roomType,
roomType: roomType,
rateDescription,
rate: (str) => {
return <span className={styles.rate}>{str}</span>
@@ -70,12 +71,9 @@ export default function SelectedRoom({
{intl.formatMessage({ id: "Change room" })}{" "}
</Link>
</div>
{room?.roomTypeCode && (
{roomTypeCode && (
<div className={styles.details}>
<ToggleSidePeek
hotelId={hotelId}
roomTypeCode={room.roomTypeCode}
/>
<ToggleSidePeek hotelId={hotelId} roomTypeCode={roomTypeCode} />
</div>
)}
</div>

View File

@@ -65,7 +65,6 @@
@media screen and (min-width: 768px) {
.wrapper {
gap: var(--Spacing-x3);
padding-top: var(--Spacing-x3);
}
.iconWrapper {

View File

@@ -7,54 +7,16 @@ import SidePanel from "@/components/HotelReservation/SidePanel"
import SummaryUI from "./UI"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
import type { DetailsState } from "@/types/stores/enter-details"
function storeSelector(state: DetailsState) {
return {
bedType: state.bedType,
booking: state.booking,
breakfast: state.breakfast,
guest: state.guest,
packages: state.packages,
roomRate: state.roomRate,
roomPrice: state.roomPrice,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
togglePriceDetailsModalOpen: state.actions.togglePriceDetailsModalOpen,
totalPrice: state.totalPrice,
vat: state.vat,
}
}
export default function DesktopSummary(props: SummaryProps) {
const {
bedType,
booking,
breakfast,
guest,
packages,
roomPrice,
roomRate,
toggleSummaryOpen,
togglePriceDetailsModalOpen,
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
totalPrice,
vat,
} = useEnterDetailsStore(storeSelector)
} = useEnterDetailsStore((state) => state)
// TODO: rooms should be part of store
const rooms = [
{
adults: booking.rooms[0].adults,
childrenInRoom: booking.rooms[0].childrenInRoom,
bedType,
breakfast,
guest,
roomRate,
roomPrice,
roomType: props.roomType,
rateDetails: props.rateDetails,
cancellationText: props.cancellationText,
},
]
const rooms = useEnterDetailsStore((state) => state.rooms)
return (
<SidePanel variant="summary">
@@ -63,7 +25,6 @@ export default function DesktopSummary(props: SummaryProps) {
rooms={rooms}
isMember={props.isMember}
breakfastIncluded={props.breakfastIncluded}
packages={packages}
totalPrice={totalPrice}
vat={vat}
toggleSummaryOpen={toggleSummaryOpen}

View File

@@ -10,55 +10,23 @@ import SummaryBottomSheet from "./BottomSheet"
import styles from "./mobile.module.css"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
import type { DetailsState } from "@/types/stores/enter-details"
function storeSelector(state: DetailsState) {
return {
bedType: state.bedType,
booking: state.booking,
breakfast: state.breakfast,
guest: state.guest,
packages: state.packages,
roomRate: state.roomRate,
roomPrice: state.roomPrice,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
togglePriceDetailsModalOpen: state.actions.togglePriceDetailsModalOpen,
totalPrice: state.totalPrice,
vat: state.vat,
}
}
export default function MobileSummary(props: SummaryProps) {
const {
bedType,
booking,
breakfast,
guest,
packages,
roomPrice,
roomRate,
toggleSummaryOpen,
togglePriceDetailsModalOpen,
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
totalPrice,
vat,
} = useEnterDetailsStore(storeSelector)
} = useEnterDetailsStore((state) => state)
const rooms = useEnterDetailsStore((state) => state.rooms)
const showPromo =
!props.isMember &&
rooms.length === 1 &&
!rooms[0].guest.join &&
!rooms[0].guest.membershipNo
// TODO: rooms should be part of store
const rooms = [
{
adults: booking.rooms[0].adults,
childrenInRoom: booking.rooms[0].childrenInRoom,
bedType,
breakfast,
guest,
roomRate,
roomPrice,
roomType: props.roomType,
rateDetails: props.rateDetails,
cancellationText: props.cancellationText,
},
]
const showPromo = !props.isMember && !guest.join && !guest.membershipNo
return (
<div className={styles.mobileSummary}>
{showPromo ? <SignupPromoMobile /> : null}
@@ -69,7 +37,6 @@ export default function MobileSummary(props: SummaryProps) {
rooms={rooms}
isMember={props.isMember}
breakfastIncluded={props.breakfastIncluded}
packages={packages}
totalPrice={totalPrice}
vat={vat}
toggleSummaryOpen={toggleSummaryOpen}

View File

@@ -24,20 +24,19 @@ import { formatPrice } from "@/utils/numberFormatting"
import styles from "./ui.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { SummaryUIProps } from "@/types/components/hotelReservation/summary"
import type { DetailsProviderProps } from "@/types/providers/enter-details"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary"
export default function SummaryUI({
booking,
rooms,
packages,
totalPrice,
isMember,
breakfastIncluded,
vat,
toggleSummaryOpen,
togglePriceDetailsModalOpen,
}: SummaryUIProps) {
}: EnterDetailsSummaryProps) {
const intl = useIntl()
const lang = useLang()
@@ -60,8 +59,8 @@ export default function SummaryUI({
}
}
function getMemberPrice(roomRate: DetailsProviderProps["roomRate"]) {
return roomRate.memberRate
function getMemberPrice(roomRate: RoomRate) {
return roomRate?.memberRate
? {
currency: roomRate.memberRate.localPrice.currency,
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
@@ -74,7 +73,7 @@ export default function SummaryUI({
rooms.length === 1 &&
rooms
.slice(0, 1)
.some((r) => !isMember || !r?.guest?.join || !r?.guest?.membershipNo)
.some((r) => !isMember || !r.guest.join || !r.guest.membershipNo)
const memberPrice = getMemberPrice(rooms[0].roomRate)
@@ -127,11 +126,8 @@ export default function SummaryUI({
const isFirstRoomMember = roomNumber === 1 && isMember
const showMemberPrice =
!!(
isFirstRoomMember ||
room?.guest?.join ||
room?.guest?.membershipNo
) && memberPrice
!!(isFirstRoomMember || room.guest.join || room.guest.membershipNo) &&
memberPrice
const adultsMsg = intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
@@ -206,20 +202,20 @@ export default function SummaryUI({
</div>
</Modal>
</div>
{packages
? packages.map((roomPackage) => (
<div className={styles.entry} key={roomPackage.code}>
{room.roomFeatures
? room.roomFeatures.map((feature) => (
<div className={styles.entry} key={feature.code}>
<div>
<Body color="uiTextHighContrast">
{roomPackage.description}
{feature.description}
</Body>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
parseInt(roomPackage.localPrice.price),
roomPackage.localPrice.currency
parseInt(feature.localPrice.price),
feature.localPrice.currency
)}
</Body>
</div>

View File

@@ -20,6 +20,7 @@ import SummaryUI from "./UI"
import type { PropsWithChildren } from "react"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { RoomState } from "@/types/stores/enter-details"
jest.mock("@/lib/api", () => ({
fetchRetry: jest.fn((fn) => fn),
@@ -39,8 +40,7 @@ function createWrapper(intlConfig: IntlConfig) {
}
}
// TODO: add type definition to this object
export const rooms = [
const rooms: RoomState[] = [
{
adults: 2,
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
@@ -55,6 +55,7 @@ export const rooms = [
roomType: "Standard",
rateDetails: [],
cancellationText: "Non-refundable",
roomFeatures: [],
},
{
adults: 1,
@@ -70,6 +71,7 @@ export const rooms = [
roomType: "Standard",
rateDetails: [],
cancellationText: "Non-refundable",
roomFeatures: [],
},
]
@@ -88,7 +90,6 @@ describe("EnterDetails Summary", () => {
rooms={rooms.slice(0, 1)}
isMember={false}
breakfastIncluded={false}
packages={[]}
totalPrice={{
requested: {
currency: "EUR",
@@ -128,7 +129,6 @@ describe("EnterDetails Summary", () => {
rooms={rooms}
isMember={false}
breakfastIncluded={false}
packages={[]}
totalPrice={{
requested: {
currency: "EUR",

View File

@@ -14,8 +14,9 @@ import styles from "./priceDetailsTable.module.css"
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
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 { Price, RoomPrice } from "@/types/stores/enter-details"
function Row({
label,
@@ -67,8 +68,8 @@ interface PriceDetailsTableProps {
childrenInRoom: Child[] | undefined
roomType: string
roomPrice: RoomPrice
bedType: BedTypeSchema | undefined
breakfast: BreakfastPackage | false | undefined
bedType?: BedTypeSchema
breakfast?: BreakfastPackage | false
}[]
totalPrice: Price
vat: number

View File

@@ -9,8 +9,9 @@ import PriceDetailsTable from "./PriceDetailsTable"
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
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 { Price, RoomPrice } from "@/types/stores/enter-details"
interface PriceDetailsModalProps {
fromDate: string
@@ -20,8 +21,8 @@ interface PriceDetailsModalProps {
childrenInRoom: Child[] | undefined
roomType: string
roomPrice: RoomPrice
bedType: BedTypeSchema | undefined
breakfast: BreakfastPackage | false | undefined
bedType?: BedTypeSchema
breakfast?: BreakfastPackage | false
}[]
totalPrice: Price
vat: number

View File

@@ -0,0 +1,297 @@
"use client"
import React from "react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import {
ArrowRightIcon,
CheckIcon,
ChevronDownSmallIcon,
} from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./summary.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
export default function Summary({
booking,
rooms,
totalPrice,
isMember,
vat,
toggleSummaryOpen,
togglePriceDetailsModalOpen,
}: SelectRateSummaryProps) {
const intl = useIntl()
const lang = useLang()
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
const nights = intl.formatMessage(
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
{ totalNights: diff }
)
function handleToggleSummary() {
if (toggleSummaryOpen) {
toggleSummaryOpen()
}
}
function handleTogglePriceDetailsModal() {
if (togglePriceDetailsModalOpen) {
togglePriceDetailsModalOpen()
}
}
function getMemberPrice(roomRate: RoomRate) {
return roomRate?.memberRate
? {
currency: roomRate.memberRate.localPrice.currency,
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
amount: roomRate.memberRate.localPrice.pricePerStay,
}
: null
}
const memberPrice = getMemberPrice(rooms[0].roomRate)
return (
<section className={styles.summary}>
<header className={styles.header}>
<Subtitle className={styles.title} type="two">
{intl.formatMessage({ id: "Booking summary" })}
</Subtitle>
<Body className={styles.date} color="baseTextMediumContrast">
{dt(booking.fromDate).locale(lang).format("ddd, D MMM")}
<ArrowRightIcon color="peach80" height={15} width={15} />
{dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights})
</Body>
<Button
intent="text"
size="small"
className={styles.chevronButton}
onClick={handleToggleSummary}
>
<ChevronDownSmallIcon height="20" width="20" />
</Button>
</header>
<Divider color="primaryLightSubtle" />
{rooms.map((room, idx) => {
const roomNumber = idx + 1
const adults = room.adults
const childrenInRoom = room.childrenInRoom
const childrenBeds = childrenInRoom?.reduce(
(acc, value) => {
const bedType = Number(value.bed)
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
return acc
}
const count = acc.get(bedType) ?? 0
acc.set(bedType, count + 1)
return acc
},
new Map<ChildBedMapEnum, number>([
[ChildBedMapEnum.IN_CRIB, 0],
[ChildBedMapEnum.IN_EXTRA_BED, 0],
])
)
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
const memberPrice = getMemberPrice(room.roomRate)
const showMemberPrice = !!(isMember && memberPrice)
const adultsMsg = intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
{ totalAdults: adults }
)
const guestsParts = [adultsMsg]
if (childrenInRoom?.length) {
const childrenMsg = intl.formatMessage(
{
id: "{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: childrenInRoom.length }
)
guestsParts.push(childrenMsg)
}
return (
<React.Fragment key={idx}>
<div
className={styles.addOns}
data-testid={`summary-room-${roomNumber}`}
>
<div>
{rooms.length > 1 ? (
<Body textTransform="bold">
{intl.formatMessage({ id: "Room" })} {roomNumber}
</Body>
) : null}
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
{formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
<Caption color="uiTextMediumContrast">
{guestsParts.join(", ")}
</Caption>
<Caption color="uiTextMediumContrast">
{room.cancellationText}
</Caption>
<Modal
trigger={
<Button intent="text">
<Caption color="burgundy" type="underline">
{intl.formatMessage({ id: "Rate details" })}
</Caption>
</Button>
}
title={room.cancellationText}
>
<div className={styles.terms}>
{room.rateDetails?.map((info) => (
<Body
key={info}
color="uiTextHighContrast"
className={styles.termsText}
>
<CheckIcon
color="uiSemanticSuccess"
width={20}
height={20}
className={styles.termsIcon}
></CheckIcon>
{info}
</Body>
))}
</div>
</Modal>
</div>
{childBedCrib ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Crib (child) × {count}" },
{ count: childBedCrib }
)}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Based on availability" })}
</Caption>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{childBedExtraBed ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Extra bed (child) × {count}" },
{
count: childBedExtraBed,
}
)}
</Body>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
</div>
<Divider color="primaryLightSubtle" />
</React.Fragment>
)
})}
<div className={styles.total}>
<div className={styles.entry}>
<div>
<Body>
{intl.formatMessage<React.ReactNode>(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</Body>
<PriceDetailsModal
fromDate={booking.fromDate}
toDate={booking.toDate}
rooms={rooms.map((r) => ({
adults: r.adults,
childrenInRoom: r.childrenInRoom,
roomPrice: r.roomPrice,
roomType: r.roomType,
}))}
totalPrice={totalPrice}
vat={vat}
toggleModal={handleTogglePriceDetailsModal}
/>
</div>
<div>
<Body textTransform="bold" data-testid="total-price">
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency
)}
</Body>
{totalPrice.requested && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "Approx. {value}" },
{
value: formatPrice(
intl,
totalPrice.requested.price,
totalPrice.requested.currency
),
}
)}
</Caption>
)}
</div>
</div>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div>
{!isMember && memberPrice ? (
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
) : null}
</section>
)
}

View File

@@ -3,12 +3,13 @@ import { useIntl } from "react-intl"
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
import SummaryUI from "@/components/HotelReservation/EnterDetails/Summary/UI"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { formatPrice } from "@/utils/numberFormatting"
import Summary from "./Summary"
import styles from "./mobileSummary.module.css"
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
@@ -55,9 +56,6 @@ export default function MobileSummary({
},
currency: room.public.localPrice.currency,
},
bedType: undefined,
breakfast: undefined,
guest: undefined,
roomRate: {
...room.public,
memberRate: room.member,
@@ -99,14 +97,12 @@ export default function MobileSummary({
<div className={styles.wrapper} data-open={isSummaryOpen}>
<div className={styles.content}>
<div className={styles.summaryAccordion}>
<SummaryUI
<Summary
booking={booking}
rooms={rooms}
isMember={isUserLoggedIn}
packages={undefined}
totalPrice={totalPriceToShow}
vat={vat}
breakfastIncluded={false}
toggleSummaryOpen={toggleSummaryOpen}
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
/>

View File

@@ -0,0 +1,101 @@
.summary {
border-radius: var(--Corner-radius-Large);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x3);
height: 100%;
}
.header {
display: grid;
grid-template-areas: "title button" "date button";
}
.title {
grid-area: title;
}
.chevronButton {
grid-area: button;
justify-self: end;
align-items: center;
margin-right: calc(0px - var(--Spacing-x2));
}
.date {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: flex-start;
grid-area: date;
}
.link {
margin-top: var(--Spacing-x1);
}
.addOns {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
overflow-y: auto;
}
.rateDetailsPopover {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
max-width: 360px;
}
.entry {
display: flex;
gap: var(--Spacing-x-half);
justify-content: space-between;
}
.entry > :last-child {
justify-items: flex-end;
}
.total {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.bottomDivider {
display: none;
}
.modalContent {
width: 560px;
}
.terms {
margin-top: var(--Spacing-x3);
margin-bottom: var(--Spacing-x3);
}
.termsText:nth-child(n) {
display: flex;
align-items: center;
margin-bottom: var(--Spacing-x1);
}
.terms .termsIcon {
margin-right: var(--Spacing-x1);
}
@media screen and (min-width: 1367px) {
.bottomDivider {
display: block;
}
.header {
display: block;
}
.summary .header .chevronButton {
display: none;
}
}

View File

@@ -1,9 +1,9 @@
import type { Price } from "@/types/components/hotelReservation/price"
import {
type RoomPackage,
RoomPackageCodeEnum,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Price } from "@/types/stores/enter-details"
export const calculateTotalPrice = (
selectedRateSummary: Rate[],

View File

@@ -178,7 +178,7 @@ export default function Rooms({
e.preventDefault()
window.history.pushState(null, "", `${pathname}?${queryParams.toString()}`)
router.push(`select-bed?${queryParams}`)
router.push(`details?${queryParams}`)
}
useEffect(() => {
@@ -270,7 +270,7 @@ export default function Rooms({
{rateSummary && (
<form
method="GET"
action={`select-bed?${searchParams}`}
action={`details?${searchParams}`}
onSubmit={handleSubmit}
>
<RateSummary

View File

@@ -1,4 +1,4 @@
import { type ForwardedRef, forwardRef } from "react"
import { type ForwardedRef, forwardRef, useId } from "react"
import { Input as AriaInput, Label as AriaLabel } from "react-aria-components"
import Label from "@/components/TempDesignSystem/Form/Label"
@@ -12,10 +12,13 @@ const AriaInputWithLabel = forwardRef(function AriaInputWithLabelComponent(
{ label, ...props }: AriaInputWithLabelProps,
ref: ForwardedRef<HTMLInputElement>
) {
const uniqueId = useId()
const inputId = `${uniqueId}-${props.name}`
return (
<AriaLabel className={styles.container} htmlFor={props.name}>
<AriaLabel className={styles.container} htmlFor={inputId}>
<Body asChild fontOnly>
<AriaInput {...props} className={styles.input} ref={ref} />
<AriaInput {...props} className={styles.input} ref={ref} id={inputId} />
</Body>
<Label required={!!props.required}>{label}</Label>
</AriaLabel>

View File

@@ -23,20 +23,6 @@ export function details(lang) {
return `${hotelreservation(lang)}/details`
}
/**
* @param {Lang} lang
*/
export function payment(lang) {
return `${hotelreservation(lang)}/payment`
}
/**
* @param {Lang} lang
*/
export function selectBed(lang) {
return `${hotelreservation(lang)}/select-bed`
}
/**
* @param {Lang} lang
*/

View File

@@ -135,6 +135,7 @@
"Contact our memberservice": "Contact our memberservice",
"Contact us": "Contact us",
"Continue": "Continue",
"Continue to room {nextRoomNumber}": "Continue to room {nextRoomNumber}",
"Copied to clipboard": "Copied to clipboard",
"Copy promotion code": "Copy promotion code",
"Could not find requested resource": "Could not find requested resource",

View File

@@ -82,8 +82,7 @@ const nextConfig = {
// ----------------------------------------
// hotel (hotelId) param missing
// ----------------------------------------
source:
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
source: "/:lang/hotelreservation/details",
destination: "/:lang/hotelreservation/select-rate",
missing: [
{
@@ -98,8 +97,7 @@ const nextConfig = {
// ----------------------------------------
// hotel (hotelId) param has to be an integer
// ----------------------------------------
source:
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
source: "/:lang/hotelreservation/details",
destination: "/:lang/hotelreservation/select-rate",
missing: [
{
@@ -114,8 +112,7 @@ const nextConfig = {
// ----------------------------------------
// fromdate param missing
// ----------------------------------------
source:
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
source: "/:lang/hotelreservation/details",
destination: "/:lang/hotelreservation/select-rate",
missing: [
{
@@ -130,8 +127,7 @@ const nextConfig = {
// ----------------------------------------
// fromdate param has to be a date
// ----------------------------------------
source:
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
source: "/:lang/hotelreservation/details",
destination: "/:lang/hotelreservation/select-rate",
missing: [
{
@@ -146,8 +142,7 @@ const nextConfig = {
// ----------------------------------------
// todate param missing
// ----------------------------------------
source:
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
source: "/:lang/hotelreservation/details",
destination: "/:lang/hotelreservation/select-rate",
missing: [
{
@@ -162,8 +157,7 @@ const nextConfig = {
// ----------------------------------------
// todate param has to be a date
// ----------------------------------------
source:
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
source: "/:lang/hotelreservation/details",
destination: "/:lang/hotelreservation/select-rate",
missing: [
{
@@ -178,8 +172,7 @@ const nextConfig = {
// ----------------------------------------
// room[0].adults param missing
// ----------------------------------------
source:
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
source: "/:lang/hotelreservation/details",
destination: "/:lang/hotelreservation/select-rate",
missing: [
{
@@ -194,8 +187,7 @@ const nextConfig = {
// ----------------------------------------
// room[0].adults param has to be an integer
// ----------------------------------------
source:
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
source: "/:lang/hotelreservation/details",
destination: "/:lang/hotelreservation/select-rate",
missing: [
{
@@ -210,8 +202,7 @@ const nextConfig = {
// ----------------------------------------
// room[0].ratecode param missing
// ----------------------------------------
source:
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
source: "/:lang/hotelreservation/details",
destination: "/:lang/hotelreservation/select-rate",
missing: [
{
@@ -226,8 +217,7 @@ const nextConfig = {
// ----------------------------------------
// room[0].roomtype param missing
// ----------------------------------------
source:
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
source: "/:lang/hotelreservation/details",
destination: "/:lang/hotelreservation/select-rate",
missing: [
{
@@ -278,11 +268,6 @@ const nextConfig = {
source: `${myPages.sv}/:path*`,
destination: `/sv/my-pages/:path*`,
},
{
source:
"/:lang/hotelreservation/:step(breakfast|details|payment|select-bed)",
destination: "/:lang/hotelreservation/step?step=:step",
},
{
source: "/:lang/hotelreservation/payment-callback/:status",
destination:

View File

@@ -3,136 +3,74 @@ import { useEffect, useRef } from "react"
import { createDetailsStore } from "@/stores/enter-details"
import {
calcTotalMemberPrice,
calcTotalPublicPrice,
navigate,
writeToSessionStorage,
checkIsSameRoom,
clearSessionStorage,
readFromSessionStorage,
} from "@/stores/enter-details/helpers"
import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema"
import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema"
import {
guestDetailsSchema,
signedInDetailsSchema,
} from "@/components/HotelReservation/EnterDetails/Details/schema"
import { DetailsContext } from "@/contexts/Details"
import type { DetailsStore } from "@/types/contexts/enter-details"
import { StepEnum } from "@/types/enums/step"
import type { DetailsProviderProps } from "@/types/providers/enter-details"
import type { DetailsState, InitialState } from "@/types/stores/enter-details"
import type { InitialState } from "@/types/stores/enter-details"
export default function EnterDetailsProvider({
bedTypes,
booking,
showBreakfastStep,
children,
packages,
roomRate,
roomsData,
searchParamsStr,
step,
user,
vat,
}: DetailsProviderProps) {
const storeRef = useRef<DetailsStore>()
if (!storeRef.current) {
const initialData: InitialState = { booking, packages, roomRate, vat }
if (bedTypes.length === 1) {
initialData.bedType = {
description: bedTypes[0].description,
roomTypeCode: bedTypes[0].value,
}
const initialData: InitialState = {
booking,
rooms: roomsData
.filter((r) => r.bedTypes?.length) // TODO: how to handle room without bedtypes?
.map((room) => ({
roomFeatures: room.packages,
roomRate: room.roomRate,
roomType: room.roomType,
cancellationText: room.cancellationText,
rateDetails: room.rateDetails,
bedType:
room.bedTypes?.length === 1
? {
roomTypeCode: room.bedTypes[0].value,
description: room.bedTypes[0].description,
}
: undefined,
})),
vat,
}
if (!showBreakfastStep) {
initialData.breakfast = false
}
storeRef.current = createDetailsStore(
initialData,
step,
searchParamsStr,
user
)
storeRef.current = createDetailsStore(initialData, searchParamsStr, user)
}
useEffect(() => {
if (storeRef.current) {
storeRef.current.setState((state) => {
const newState: DetailsState = { ...state }
newState.bedType = state.formValues.bedType
newState.breakfast = state.formValues.breakfast
if (state.formValues.guest && !user) {
newState.guest = state.formValues.guest
}
if (
(newState.guest!.join || newState.guest!.membershipNo || user) &&
state.roomRate.memberRate
) {
const memberPrice = calcTotalMemberPrice(newState)
newState.roomPrice = memberPrice.roomPrice
newState.totalPrice = memberPrice.totalPrice
} else {
const publicPrice = calcTotalPublicPrice(newState)
newState.roomPrice = publicPrice.roomPrice
newState.totalPrice = publicPrice.totalPrice
}
const isValid = { ...newState.isValid }
const validateBooking = state.formValues
const validPaths = [StepEnum.selectBed]
const validatedBedType = bedTypeSchema.safeParse(validateBooking)
if (validatedBedType.success) {
isValid[StepEnum.selectBed] = true
validPaths.push(state.steps[1])
}
const validatedBreakfast =
breakfastStoreSchema.safeParse(validateBooking)
if (validatedBreakfast.success) {
isValid[StepEnum.breakfast] = true
validPaths.push(StepEnum.details)
}
const detailsSchema = user ? signedInDetailsSchema : guestDetailsSchema
const validatedDetails = detailsSchema.safeParse(validateBooking.guest)
/**
* Need to add the breakfast check here too since
* when a member comes into the flow, their data is
* already added and valid, and thus to avoid showing a
* step the user hasn't been on yet as complete
*/
if (isValid.breakfast && validatedDetails.success) {
isValid[StepEnum.details] = true
validPaths.push(StepEnum.payment)
}
if (!validPaths.includes(newState.currentStep!)) {
newState.currentStep = validPaths.at(-1)!
}
if (step !== newState.currentStep) {
const stateCurrentStep = newState.currentStep!
setTimeout(() => {
navigate(stateCurrentStep, searchParamsStr)
})
}
writeToSessionStorage({
bedType: newState.bedType,
booking: newState.booking,
breakfast: newState.breakfast,
guest: newState.guest,
})
return { ...newState, isValid }
})
const storedValues = readFromSessionStorage()
if (!storedValues) {
return
}
}, [searchParamsStr, step, user])
const isSameRoom = checkIsSameRoom(storedValues.booking, booking)
if (!isSameRoom) {
clearSessionStorage()
return
}
const state = storeRef.current?.getState()
storeRef.current?.setState({
...state,
rooms: storedValues.rooms,
bookingProgress: storedValues.bookingProgress,
})
}, [booking])
return (
<DetailsContext.Provider value={storeRef.current}>

View File

@@ -1,17 +1,16 @@
import deepmerge from "deepmerge"
import isEqual from "fast-deep-equal"
import { arrayMerge } from "@/utils/merge"
import { detailsStorageName } from "."
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { Price } from "@/types/components/hotelReservation/price"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { StepEnum } from "@/types/enums/step"
import { StepEnum } from "@/types/enums/step"
import type {
DetailsState,
PersistedState,
PersistedStatePart,
RoomRate,
RoomState,
RoomStatus,
} from "@/types/stores/enter-details"
import type { SafeUser } from "@/types/user"
@@ -27,10 +26,6 @@ export function extractGuestFromUser(user: NonNullable<SafeUser>) {
}
}
export function navigate(step: StepEnum, searchParams: string) {
window.history.pushState({ step }, "", `${step}?${searchParams}`)
}
export function checkIsSameRoom(
prev: SelectRateSearchParams,
next: SelectRateSearchParams
@@ -84,7 +79,7 @@ export function subtract(...nums: (number | string | undefined)[]) {
}, 0)
}
export function getInitialRoomPrice(roomRate: RoomRate, isMember: boolean) {
export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
if (isMember && roomRate.memberRate) {
return {
perNight: {
@@ -134,158 +129,234 @@ export function getInitialRoomPrice(roomRate: RoomRate, isMember: boolean) {
}
}
export function getInitialTotalPrice(roomRate: RoomRate, isMember: boolean) {
if (isMember && roomRate.memberRate) {
return {
requested: roomRate.memberRate.requestedPrice && {
currency: roomRate.memberRate.requestedPrice.currency,
price: roomRate.memberRate.requestedPrice.pricePerStay,
},
type TotalPrice = {
requested: { currency: string; price: number } | undefined
local: { currency: string; price: number }
}
export function getTotalPrice(roomRates: RoomRate[], isMember: boolean) {
return roomRates.reduce<TotalPrice>(
(total, roomRate, idx) => {
const isFirstRoom = idx === 0
const rate =
isFirstRoom && isMember && roomRate.memberRate
? roomRate.memberRate
: roomRate.publicRate
return {
requested: rate.requestedPrice
? {
currency: rate.requestedPrice.currency,
price: add(
total.requested?.price ?? 0,
rate.requestedPrice.pricePerStay
),
}
: undefined,
local: {
currency: rate.localPrice.currency,
price: add(total.local.price ?? 0, rate.localPrice.pricePerStay),
},
}
},
{
requested: undefined,
local: {
currency: roomRate.memberRate.localPrice.currency,
price: roomRate.memberRate.localPrice.pricePerStay,
currency: roomRates[0].publicRate.localPrice.currency,
price: 0,
},
}
}
return {
requested: roomRate.publicRate.requestedPrice && {
currency: roomRate.publicRate.requestedPrice.currency,
price: roomRate.publicRate.requestedPrice.pricePerStay,
},
local: {
currency: roomRate.publicRate.localPrice.currency,
price: roomRate.publicRate.localPrice.pricePerStay,
},
}
}
export function calcTotalMemberPrice(state: DetailsState) {
if (!state.roomRate.memberRate) {
return {
roomPrice: state.roomPrice,
totalPrice: state.totalPrice,
}
}
return calcTotalPrice({
breakfast: state.breakfast,
packages: state.packages,
roomPrice: state.roomPrice,
totalPrice: state.totalPrice,
...state.roomRate.memberRate,
})
}
export function calcTotalPublicPrice(state: DetailsState) {
return calcTotalPrice({
breakfast: state.breakfast,
packages: state.packages,
roomPrice: state.roomPrice,
totalPrice: state.totalPrice,
...state.roomRate.publicRate,
})
)
}
export function calcTotalPrice(
state: Pick<
DetailsState,
"breakfast" | "packages" | "roomPrice" | "totalPrice"
> &
DetailsState["roomRate"]["publicRate"]
rooms: RoomState[],
totalPrice: Price,
isMember: boolean
) {
// state is sometimes read-only, thus we
// need to create a deep copy of the values
const roomAndTotalPrice = {
roomPrice: {
perNight: {
local: { ...state.roomPrice.perNight.local },
requested: state.roomPrice.perNight.requested
? { ...state.roomPrice.perNight.requested }
: state.roomPrice.perNight.requested,
},
perStay: {
local: { ...state.roomPrice.perStay.local },
requested: state.roomPrice.perStay.requested
? { ...state.roomPrice.perStay.requested }
: state.roomPrice.perStay.requested,
},
},
totalPrice: {
local: { ...state.totalPrice.local },
requested: state.totalPrice.requested
? { ...state.totalPrice.requested }
: state.totalPrice.requested,
},
}
if (state.requestedPrice?.pricePerStay) {
roomAndTotalPrice.roomPrice.perStay.requested = {
currency: state.requestedPrice.currency,
price: state.requestedPrice.pricePerStay,
}
return rooms.reduce<Price>(
(acc, room, index) => {
const isFirstRoomAndMember = index === 0 && isMember
const join = Boolean(room.guest.join || room.guest.membershipNo)
let totalPriceRequested = state.requestedPrice.pricePerStay
if (state.breakfast) {
totalPriceRequested = add(
totalPriceRequested,
state.breakfast.requestedPrice.totalPrice
const roomPrice = getRoomPrice(
room.roomRate,
isFirstRoomAndMember || join
)
}
if (state.packages) {
totalPriceRequested = state.packages.reduce((total, pkg) => {
const breakfastRequestedPrice = room.breakfast
? room.breakfast.requestedPrice?.totalPrice ?? 0
: 0
const breakfastLocalPrice = room.breakfast
? room.breakfast.localPrice?.totalPrice ?? 0
: 0
const roomFeaturesTotal = room.roomFeatures?.reduce((total, pkg) => {
if (pkg.requestedPrice.totalPrice) {
total = add(total, pkg.requestedPrice.totalPrice)
}
return total
}, totalPriceRequested)
}
}, 0)
roomAndTotalPrice.totalPrice.requested = {
currency: state.requestedPrice.currency,
price: totalPriceRequested,
}
}
const roomPriceLocal = state.localPrice
roomAndTotalPrice.roomPrice.perStay.local = {
currency: roomPriceLocal.currency,
price: roomPriceLocal.pricePerStay,
}
let totalPriceLocal = roomPriceLocal.pricePerStay
if (state.breakfast) {
totalPriceLocal = add(
totalPriceLocal,
state.breakfast.localPrice.totalPrice
)
}
if (state.packages) {
totalPriceLocal = state.packages.reduce((total, pkg) => {
if (pkg.localPrice.totalPrice) {
total = add(total, pkg.localPrice.totalPrice)
const result: Price = {
requested: roomPrice.perStay.requested
? {
currency: roomPrice.perStay.requested.currency,
price: add(
acc.requested?.price ?? 0,
roomPrice.perStay.requested.price,
breakfastRequestedPrice
),
}
: undefined,
local: {
currency: roomPrice.perStay.local.currency,
price: add(
acc.local.price,
roomPrice.perStay.local.price,
breakfastLocalPrice,
roomFeaturesTotal
),
},
}
return total
}, totalPriceLocal)
}
roomAndTotalPrice.totalPrice.local = {
currency: roomPriceLocal.currency,
price: totalPriceLocal,
}
return roomAndTotalPrice
return result
},
{
requested: undefined,
local: { currency: totalPrice.local.currency, price: 0 },
}
)
}
export function writeToSessionStorage(part: PersistedStatePart) {
const unparsedData = sessionStorage.getItem(detailsStorageName)
if (unparsedData) {
const data: PersistedState = JSON.parse(unparsedData)
// @ts-expect-error - deepmerge is not to happy with
// the part type
const updated = deepmerge(data, part, { arrayMerge })
sessionStorage.setItem(detailsStorageName, JSON.stringify(updated))
} else {
sessionStorage.setItem(detailsStorageName, JSON.stringify(part))
export const selectRoomStatus = (state: DetailsState, index?: number) =>
state.bookingProgress.roomStatuses[
index ?? state.bookingProgress.currentRoomIndex
]
export const selectRoom = (state: DetailsState, index?: number) =>
state.rooms[index ?? state.bookingProgress.currentRoomIndex]
export const selectRoomSteps = (state: DetailsState, index?: number) =>
state.bookingProgress.roomStatuses[
index ?? state.bookingProgress.currentRoomIndex
].steps
export const selectNextStep = (roomStatus: RoomStatus) => {
if (!roomStatus.currentStep) {
throw new Error("getNextStep: currentStep is undefined")
}
if (!roomStatus.steps[roomStatus.currentStep]?.isValid) {
return roomStatus.currentStep
}
const stepsArray = Object.values(roomStatus.steps)
const currentIndex = stepsArray.findIndex(
(step) => step?.step === roomStatus.currentStep
)
if (currentIndex === stepsArray.length - 1) {
return null
}
const nextInvalidStep = stepsArray
.slice(currentIndex + 1)
.find((step) => !step.isValid)
return nextInvalidStep?.step ?? null
}
export const selectBookingProgress = (state: DetailsState) =>
state.bookingProgress
export const checkBookingProgress = (state: DetailsState) => {
return state.bookingProgress.roomStatuses.every((r) => r.isComplete)
}
export const checkRoomProgress = (state: DetailsState) => {
const steps = selectRoomSteps(state)
return Object.values(steps)
.filter(Boolean)
.every((step) => step.isValid)
}
export function handleStepProgression(state: DetailsState) {
const isAllRoomsCompleted = checkBookingProgress(state)
if (isAllRoomsCompleted) {
const roomStatus = selectRoomStatus(state)
roomStatus.currentStep = null
state.bookingProgress.canProceedToPayment = true
return
}
const roomStatus = selectRoomStatus(state)
if (roomStatus.isComplete) {
const nextRoomIndex = state.bookingProgress.currentRoomIndex + 1
roomStatus.lastCompletedStep = roomStatus.currentStep ?? undefined
roomStatus.currentStep = null
const nextRoomStatus = selectRoomStatus(state, nextRoomIndex)
nextRoomStatus.currentStep =
Object.values(nextRoomStatus.steps).find((step) => !step.isValid)?.step ??
StepEnum.selectBed
const nextStep = selectNextStep(nextRoomStatus)
nextRoomStatus.currentStep = nextStep
state.bookingProgress.currentRoomIndex = nextRoomIndex
return
}
const nextStep = selectNextStep(roomStatus)
if (nextStep && roomStatus.currentStep) {
roomStatus.lastCompletedStep = roomStatus.currentStep
roomStatus.currentStep = nextStep
return
}
}
export function readFromSessionStorage(): PersistedState | undefined {
if (typeof window === "undefined") {
return undefined
}
try {
const storedData = sessionStorage.getItem(detailsStorageName)
if (!storedData) {
return undefined
}
const parsedData = JSON.parse(storedData) as PersistedState
if (
!parsedData.booking ||
!parsedData.rooms ||
!parsedData.bookingProgress
) {
return undefined
}
return parsedData
} catch (error) {
console.error("Error reading from session storage:", error)
return undefined
}
}
export function writeToSessionStorage(state: PersistedState) {
if (typeof window === "undefined") {
return
}
try {
sessionStorage.setItem(detailsStorageName, JSON.stringify(state))
} catch (error) {
console.error("Error writing to session storage:", error)
}
}
export function clearSessionStorage() {
if (typeof window === "undefined") {
return
}
sessionStorage.removeItem(detailsStorageName)
}

View File

@@ -7,22 +7,23 @@ import { DetailsContext } from "@/contexts/Details"
import {
add,
calcTotalMemberPrice,
calcTotalPublicPrice,
checkIsSameRoom,
calcTotalPrice,
checkRoomProgress,
extractGuestFromUser,
getInitialRoomPrice,
getInitialTotalPrice,
navigate,
getRoomPrice,
getTotalPrice,
handleStepProgression,
selectRoom,
selectRoomStatus,
writeToSessionStorage,
} from "./helpers"
import { StepEnum } from "@/types/enums/step"
import type {
DetailsState,
FormValues,
InitialState,
PersistedState,
RoomState,
RoomStatus,
} from "@/types/stores/enter-details"
import type { SafeUser } from "@/types/user"
@@ -38,109 +39,128 @@ const defaultGuestState = {
zipCode: "",
}
export const detailsStorageName = "details-storage"
export const detailsStorageName = "rooms-details-storage"
export function createDetailsStore(
initialState: InitialState,
currentStep: StepEnum,
searchParams: string,
user: SafeUser
) {
const isMember = !!user
const formValues: FormValues = {
bedType: initialState.bedType,
booking: initialState.booking,
/** TODO: Needs adjustment when breakfast included in rate is added */
breakfast:
initialState.breakfast === false ? initialState.breakfast : undefined,
guest: isMember
? deepmerge(defaultGuestState, extractGuestFromUser(user))
: defaultGuestState,
}
if (typeof window !== "undefined") {
const unparsedStorage = sessionStorage.getItem(detailsStorageName)
if (unparsedStorage) {
const detailsStorage: PersistedState = JSON.parse(unparsedStorage)
const isSameRoom = detailsStorage.booking
? checkIsSameRoom(initialState.booking, detailsStorage.booking)
: false
if (isSameRoom) {
formValues.bedType = detailsStorage.bedType
formValues.breakfast = detailsStorage.breakfast
}
if (!isMember) {
formValues.guest = detailsStorage.guest
}
}
}
let steps = [
StepEnum.selectBed,
StepEnum.breakfast,
StepEnum.details,
StepEnum.payment,
]
/**
* TODO:
* - when included in rate, can packages still be received?
* - no hotels yet with breakfast included in the rate so
* impossible to build for atm.
*
* checking against initialState since that means the
* hotel doesn't offer breakfast
*
* matching breakfast first so the steps array is altered
* before the bedTypes possible step altering
*/
if (initialState.breakfast === false) {
steps = steps.filter((step) => step !== StepEnum.breakfast)
if (currentStep === StepEnum.breakfast) {
currentStep = steps[1]
}
}
if (initialState.bedType && currentStep === StepEnum.selectBed) {
currentStep = steps[1]
}
const initialRoomPrice = getInitialRoomPrice(initialState.roomRate, isMember)
const initialTotalPrice = getInitialTotalPrice(
initialState.roomRate,
const initialTotalPrice = getTotalPrice(
initialState.rooms.map((r) => r.roomRate),
isMember
)
if (initialState.packages) {
initialState.packages.forEach((pkg) => {
if (initialTotalPrice.requested) {
initialTotalPrice.requested.price = add(
initialTotalPrice.requested.price,
pkg.requestedPrice.totalPrice
initialState.rooms.forEach((room) => {
if (room.roomFeatures) {
room.roomFeatures.forEach((pkg) => {
if (initialTotalPrice.requested) {
initialTotalPrice.requested.price = add(
initialTotalPrice.requested.price,
pkg.requestedPrice.totalPrice
)
}
initialTotalPrice.local.price = add(
initialTotalPrice.local.price,
pkg.localPrice.totalPrice
)
}
initialTotalPrice.local.price = add(
initialTotalPrice.local.price,
pkg.localPrice.totalPrice
)
})
}
})
}
})
const rooms: RoomState[] = initialState.rooms.map((room, idx) => {
return {
...room,
adults: initialState.booking.rooms[idx].adults,
childrenInRoom: initialState.booking.rooms[idx].childrenInRoom,
bedType: room.bedType,
breakfast:
initialState.breakfast === false ? initialState.breakfast : undefined,
guest: isMember
? deepmerge(defaultGuestState, extractGuestFromUser(user))
: defaultGuestState,
roomPrice: getRoomPrice(room.roomRate, isMember && idx === 0),
}
})
const roomStatuses: RoomStatus[] = initialState.rooms.map((room, idx) => {
const steps: RoomStatus["steps"] = {
[StepEnum.selectBed]: {
step: StepEnum.selectBed,
isValid: !!room.bedType,
},
[StepEnum.breakfast]: {
step: StepEnum.breakfast,
isValid: false,
},
[StepEnum.details]: {
step: StepEnum.details,
isValid: false,
},
}
if (initialState.breakfast === false) {
delete steps[StepEnum.breakfast]
}
const currentStep =
idx === 0
? Object.values(steps).find((step) => !step.isValid)?.step ??
StepEnum.selectBed
: null
return {
isComplete: false,
currentStep: currentStep,
lastCompletedStep: undefined,
steps,
}
})
return create<DetailsState>()((set, get) => ({
searchParamString: searchParams,
booking: initialState.booking,
breakfast:
initialState.breakfast === false ? initialState.breakfast : undefined,
isSubmittingDisabled: false,
isSummaryOpen: false,
isPriceDetailsModalOpen: false,
totalPrice: initialTotalPrice,
vat: initialState.vat,
rooms,
bookingProgress: {
currentRoomIndex: 0,
roomStatuses,
canProceedToPayment: false,
},
return create<DetailsState>()((set) => ({
actions: {
completeStep() {
setStep(step: StepEnum | null, roomIndex?: number) {
if (!step) {
return
}
return set(
produce((state: DetailsState) => {
const currentStepIndex = state.steps.indexOf(state.currentStep)
const nextStep = state.steps[currentStepIndex + 1]
state.currentStep = nextStep
navigate(nextStep, state.searchParamString)
})
)
},
navigate(step: StepEnum) {
return set(
produce((state) => {
state.currentStep = step
navigate(step, state.searchParamString)
const currentRoomIndex =
roomIndex ?? state.bookingProgress.currentRoomIndex
const arePreviousRoomsCompleted = state.bookingProgress.roomStatuses
.slice(0, currentRoomIndex)
.every((room) => room.isComplete)
const roomStatus = selectRoomStatus(state, roomIndex)
const roomStep = roomStatus.steps[step]
if (arePreviousRoomsCompleted && roomStep?.isValid) {
roomStatus.currentStep = step
if (roomIndex !== undefined) {
state.bookingProgress.currentRoomIndex = roomIndex
}
}
})
)
},
@@ -151,13 +171,6 @@ export function createDetailsStore(
})
)
},
setStep(step: StepEnum) {
return set(
produce((state: DetailsState) => {
state.currentStep = step
})
)
},
setTotalPrice(totalPrice) {
return set(
produce((state: DetailsState) => {
@@ -183,29 +196,39 @@ export function createDetailsStore(
updateBedType(bedType) {
return set(
produce((state: DetailsState) => {
state.isValid["select-bed"] = true
state.bedType = bedType
const roomStatus = selectRoomStatus(state)
roomStatus.steps[StepEnum.selectBed].isValid = true
writeToSessionStorage({ bedType })
const room = selectRoom(state)
room.bedType = bedType
const currentStepIndex = state.steps.indexOf(state.currentStep)
const nextStep = state.steps[currentStepIndex + 1]
state.currentStep = nextStep
navigate(nextStep, state.searchParamString)
handleStepProgression(state)
writeToSessionStorage({
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
})
})
)
},
updateBreakfast(breakfast) {
return set(
produce((state: DetailsState) => {
state.isValid.breakfast = true
const roomStatus = selectRoomStatus(state)
if (roomStatus.steps[StepEnum.breakfast]) {
roomStatus.steps[StepEnum.breakfast].isValid = true
}
const stateTotalRequestedPrice =
state.totalPrice.requested?.price || 0
const stateTotalLocalPrice = state.totalPrice.local.price
const addToTotalPrice =
(state.breakfast === undefined || state.breakfast === false) &&
!!breakfast
const subtractFromTotalPrice =
(state.breakfast === undefined || state.breakfast) &&
breakfast === false
@@ -267,50 +290,64 @@ export function createDetailsStore(
}
}
state.breakfast = breakfast
writeToSessionStorage({ breakfast })
const currentStepIndex = state.steps.indexOf(state.currentStep)
const nextStep = state.steps[currentStepIndex + 1]
state.currentStep = nextStep
navigate(nextStep, state.searchParamString)
const room = selectRoom(state)
room.breakfast = breakfast
handleStepProgression(state)
writeToSessionStorage({
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
})
})
)
},
updateDetails(data) {
return set(
produce((state: DetailsState) => {
state.isValid.details = true
const roomStatus = selectRoomStatus(state)
roomStatus.steps[StepEnum.details].isValid = true
const room = selectRoom(state)
room.guest.countryCode = data.countryCode
room.guest.dateOfBirth = data.dateOfBirth
room.guest.email = data.email
room.guest.firstName = data.firstName
room.guest.join = data.join
room.guest.lastName = data.lastName
state.guest.countryCode = data.countryCode
state.guest.dateOfBirth = data.dateOfBirth
state.guest.email = data.email
state.guest.firstName = data.firstName
state.guest.join = data.join
state.guest.lastName = data.lastName
if (data.join) {
state.guest.membershipNo = undefined
room.guest.membershipNo = undefined
} else {
state.guest.membershipNo = data.membershipNo
room.guest.membershipNo = data.membershipNo
}
state.guest.phoneNumber = data.phoneNumber
state.guest.zipCode = data.zipCode
room.guest.phoneNumber = data.phoneNumber
room.guest.zipCode = data.zipCode
if (data.join || data.membershipNo || isMember) {
const memberPrice = calcTotalMemberPrice(state)
state.roomPrice = memberPrice.roomPrice
state.totalPrice = memberPrice.totalPrice
} else {
const publicPrice = calcTotalPublicPrice(state)
state.roomPrice = publicPrice.roomPrice
state.totalPrice = publicPrice.totalPrice
room.roomPrice = getRoomPrice(
room.roomRate,
Boolean(data.join || data.membershipNo || isMember)
)
state.totalPrice = calcTotalPrice(
state.rooms,
state.totalPrice,
isMember
)
const isAllStepsCompleted = checkRoomProgress(state)
if (isAllStepsCompleted) {
roomStatus.isComplete = true
}
writeToSessionStorage({ guest: data })
handleStepProgression(state)
const currentStepIndex = state.steps.indexOf(state.currentStep)
const nextStep = state.steps[currentStepIndex + 1]
state.currentStep = nextStep
navigate(nextStep, state.searchParamString)
writeToSessionStorage({
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
})
})
)
},
@@ -322,31 +359,6 @@ export function createDetailsStore(
)
},
},
searchParamString: searchParams,
bedType: initialState.bedType ?? undefined,
booking: initialState.booking,
breakfast:
initialState.breakfast === false ? initialState.breakfast : undefined,
currentStep,
formValues,
guest: isMember
? deepmerge(defaultGuestState, extractGuestFromUser(user))
: defaultGuestState,
isSubmittingDisabled: false,
isSummaryOpen: false,
isPriceDetailsModalOpen: false,
isValid: {
[StepEnum.selectBed]: false,
[StepEnum.breakfast]: false,
[StepEnum.details]: false,
[StepEnum.payment]: false,
},
packages: initialState.packages,
roomPrice: initialRoomPrice,
roomRate: initialState.roomRate,
steps,
totalPrice: initialTotalPrice,
vat: initialState.vat,
}))
}

View File

@@ -8,11 +8,14 @@ import {
bedType,
booking,
breakfastPackage,
guestDetailsMember,
guestDetailsNonMember,
roomPrice,
roomRate,
} from "@/__mocks__/hotelReservation"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { selectRoom, selectRoomStatus } from "./helpers"
import { detailsStorageName, useEnterDetailsStore } from "."
import { StepEnum } from "@/types/enums/step"
@@ -31,22 +34,60 @@ jest.mock("@/lib/api", () => ({
fetchRetry: jest.fn((fn) => fn),
}))
function Wrapper({ children }: PropsWithChildren) {
return (
<EnterDetailsProvider
bedTypes={[bedType.king, bedType.queen]}
booking={booking}
showBreakfastStep={true}
packages={null}
roomRate={roomRate}
searchParamsStr=""
step={StepEnum.selectBed}
user={null}
vat={0}
>
{children}
</EnterDetailsProvider>
)
interface CreateWrapperParams {
showBreakfastStep?: boolean
breakfastIncluded?: boolean
mustBeGuaranteed?: boolean
onlyOneBedType?: boolean
}
function createWrapper(params: Partial<CreateWrapperParams> = {}) {
const {
showBreakfastStep = true,
breakfastIncluded = false,
mustBeGuaranteed = false,
onlyOneBedType = false,
} = params
return function Wrapper({ children }: PropsWithChildren) {
return (
<EnterDetailsProvider
booking={booking}
showBreakfastStep={showBreakfastStep}
roomsData={[
{
bedTypes: onlyOneBedType
? [bedType.king]
: [bedType.king, bedType.queen],
packages: null,
mustBeGuaranteed,
breakfastIncluded,
cancellationText: "",
rateDetails: [],
roomType: "Standard",
roomRate: roomRate,
},
{
bedTypes: onlyOneBedType
? [bedType.king]
: [bedType.king, bedType.queen],
packages: null,
mustBeGuaranteed,
breakfastIncluded,
cancellationText: "",
rateDetails: [],
roomType: "Standard",
roomRate: roomRate,
},
]}
searchParamsStr=""
user={null}
vat={0}
>
{children}
</EnterDetailsProvider>
)
}
}
describe("Enter Details Store", () => {
@@ -58,27 +99,84 @@ describe("Enter Details Store", () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: Wrapper,
wrapper: createWrapper(),
}
)
const state = result.current
expect(state.currentStep).toBe(StepEnum.selectBed)
expect(state.booking).toEqual(booking)
expect(state.bedType).toEqual(undefined)
expect(state.breakfast).toEqual(undefined)
expect(Object.values(state.guest).every((value) => value === ""))
// room 1
const room1Status = selectRoomStatus(result.current, 0)
const room1 = selectRoom(result.current, 0)
expect(room1Status.currentStep).toBe(StepEnum.selectBed)
expect(room1.roomPrice.perNight.local.price).toEqual(
roomRate.publicRate.localPrice.pricePerNight
)
expect(room1.bedType).toEqual(undefined)
expect(Object.values(room1.guest).every((value) => value === ""))
// room 2
const room2Status = selectRoomStatus(result.current, 1)
const room2 = selectRoom(result.current, 1)
expect(room2Status.currentStep).toBe(null)
expect(room2.roomPrice.perNight.local.price).toEqual(
room2.roomRate.publicRate.localPrice.pricePerNight
)
expect(room2.bedType).toEqual(undefined)
expect(Object.values(room2.guest).every((value) => value === ""))
})
test("initialize with correct values from sessionStorage", async () => {
test("initialize with correct values from session storage", () => {
const storage: PersistedState = {
bedType: {
roomTypeCode: bedType.queen.value,
description: bedType.queen.description,
booking: booking,
bookingProgress: {
currentRoomIndex: 0,
canProceedToPayment: true,
roomStatuses: [
{
isComplete: false,
currentStep: StepEnum.selectBed,
lastCompletedStep: undefined,
steps: {
[StepEnum.selectBed]: {
step: StepEnum.selectBed,
isValid: true,
},
[StepEnum.breakfast]: {
step: StepEnum.breakfast,
isValid: true,
},
[StepEnum.details]: {
step: StepEnum.details,
isValid: true,
},
},
},
],
},
breakfast: breakfastPackage,
booking,
guest: guestDetailsNonMember,
rooms: [
{
roomFeatures: null,
roomRate: roomRate,
roomType: "Classic Double",
cancellationText: "Non-refundable",
rateDetails: [],
bedType: {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
},
adults: 1,
childrenInRoom: [],
breakfast: breakfastPackage,
guest: guestDetailsNonMember,
roomPrice: roomPrice,
},
],
}
window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage))
@@ -86,26 +184,57 @@ describe("Enter Details Store", () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: Wrapper,
wrapper: createWrapper(),
}
)
const state = result.current
expect(state.bedType).toEqual(storage.bedType)
expect(state.guest).toEqual(storage.guest)
expect(state.booking).toEqual(storage.booking)
expect(state.breakfast).toEqual(storage.breakfast)
expect(result.current.booking).toEqual(storage.booking)
expect(result.current.rooms[0]).toEqual(storage.rooms[0])
expect(result.current.bookingProgress).toEqual(storage.bookingProgress)
})
test("add bedtype and proceed to next step", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
let roomStatus = selectRoomStatus(result.current)
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
await act(async () => {
result.current.actions.updateBedType(selectedBedType)
})
roomStatus = selectRoomStatus(result.current)
const room = selectRoom(result.current)
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(room.bedType).toEqual(selectedBedType)
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
})
test("complete step and navigate to next step", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: Wrapper,
wrapper: createWrapper(),
}
)
expect(result.current.currentStep).toEqual(StepEnum.selectBed)
// Room 1
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
let roomStatus = selectRoomStatus(result.current)
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
await act(async () => {
result.current.actions.updateBedType({
@@ -114,24 +243,221 @@ describe("Enter Details Store", () => {
})
})
expect(result.current.isValid[StepEnum.selectBed]).toEqual(true)
expect(result.current.currentStep).toEqual(StepEnum.breakfast)
expect(window.location.pathname.slice(1)).toBe(StepEnum.breakfast)
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
await act(async () => {
result.current.actions.updateBreakfast(breakfastPackage)
})
expect(result.current.isValid[StepEnum.breakfast]).toEqual(true)
expect(result.current.currentStep).toEqual(StepEnum.details)
expect(window.location.pathname.slice(1)).toBe(StepEnum.details)
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.details)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.isValid[StepEnum.details]).toEqual(true)
expect(result.current.currentStep).toEqual(StepEnum.payment)
expect(window.location.pathname.slice(1)).toBe(StepEnum.payment)
expect(result.current.bookingProgress.canProceedToPayment).toBe(false)
// Room 2
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
await act(async () => {
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
result.current.actions.updateBedType(selectedBedType)
})
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
await act(async () => {
result.current.actions.updateBreakfast(breakfastPackage)
})
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.details)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.bookingProgress.canProceedToPayment).toBe(true)
})
test("all steps needs to be completed before going to next room", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
await act(async () => {
result.current.actions.setStep(StepEnum.breakfast, 1)
})
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
})
test("can go back and modify room 1 after completion", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
await act(async () => {
result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
result.current.actions.updateBreakfast(breakfastPackage)
result.current.actions.updateDetails(guestDetailsNonMember)
})
// now we are at room 2
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
await act(async () => {
result.current.actions.setStep(StepEnum.breakfast, 0) // click "modify"
})
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
await act(async () => {
result.current.actions.updateBreakfast(breakfastPackage)
})
// going back to room 2
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
})
test("total price should be set properly", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
const publicRate = roomRate.publicRate.localPrice.pricePerStay
const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0
const initialTotalPrice = publicRate * result.current.rooms.length
expect(result.current.totalPrice.local.price).toEqual(initialTotalPrice)
// room 1
await act(async () => {
result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
result.current.actions.updateBreakfast(breakfastPackage)
})
let expectedTotalPrice =
initialTotalPrice + Number(breakfastPackage.localPrice.price)
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
await act(async () => {
result.current.actions.updateDetails(guestDetailsMember)
})
expectedTotalPrice =
memberRate + publicRate + Number(breakfastPackage.localPrice.price)
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
// room 2
await act(async () => {
result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
result.current.actions.updateBreakfast(breakfastPackage)
})
expectedTotalPrice =
memberRate + publicRate + Number(breakfastPackage.localPrice.price) * 2
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
})
test("room price should be set properly", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
const publicRate = roomRate.publicRate.localPrice.pricePerStay
const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0
let room1 = selectRoom(result.current, 0)
expect(room1.roomPrice.perStay.local.price).toEqual(publicRate)
let room2 = selectRoom(result.current, 0)
expect(room2.roomPrice.perStay.local.price).toEqual(publicRate)
await act(async () => {
result.current.actions.updateDetails(guestDetailsMember)
})
room1 = selectRoom(result.current, 0)
expect(room1.roomPrice.perStay.local.price).toEqual(memberRate)
})
test("breakfast step should be hidden when breakfast is included", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ showBreakfastStep: false }),
}
)
const room1Status = selectRoomStatus(result.current, 0)
expect(Object.keys(room1Status.steps)).not.toContain(StepEnum.breakfast)
const room2Status = selectRoomStatus(result.current, 1)
expect(Object.keys(room2Status.steps)).not.toContain(StepEnum.breakfast)
})
test("select bed step should be skipped when there is only one bedtype", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ onlyOneBedType: true }),
}
)
const room1Status = selectRoomStatus(result.current, 0)
expect(room1Status.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(room1Status.currentStep).toEqual(StepEnum.breakfast)
const room2Status = selectRoomStatus(result.current, 1)
expect(room2Status.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(room2Status.currentStep).toEqual(null)
})
})

View File

@@ -1,14 +1,21 @@
import type { z } from "zod"
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
import type { SafeUser } from "@/types/user"
import type {
guestDetailsSchema,
signedInDetailsSchema,
} from "@/components/HotelReservation/EnterDetails/Details/schema"
import type { Price } from "../price"
export type DetailsSchema = z.output<typeof guestDetailsSchema>
export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>
export interface RoomPrice {
perNight: Price
perStay: Price
}
type MemberPrice = {
currency: string
price: number
@@ -23,3 +30,8 @@ export type JoinScandicFriendsCardProps = {
name: string
memberPrice?: MemberPrice
}
export type RoomRate = {
publicRate: Product["productType"]["public"]
memberRate?: Product["productType"]["member"]
}

View File

@@ -2,6 +2,7 @@ import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailabil
export interface SelectedRoomProps {
hotelId: string
roomType: string
roomTypeCode: string
rateDescription: string
room: RoomConfiguration
}

View File

@@ -0,0 +1,9 @@
interface TPrice {
currency: string
price: number
}
export interface Price {
requested: TPrice | undefined
local: TPrice
}

View File

@@ -1,5 +1,5 @@
import type { Price } from "@/types/stores/enter-details"
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
import type { Price } from "../price"
import type { RoomPackages } from "./roomFilter"
import type { SelectRateSearchParams } from "./selectRate"

View File

@@ -31,7 +31,6 @@ export interface DetailsProps extends SectionProps {}
export interface PaymentProps {
user: SafeUser
roomPrice: { publicPrice: number; memberPrice: number | undefined }
otherPaymentOptions: PaymentMethodEnum[]
mustBeGuaranteed: boolean
supportedCards: PaymentMethodEnum[]

View File

@@ -1,7 +1,8 @@
import { StepEnum } from "@/types/enums/step"
import type { StepEnum } from "@/types/enums/step"
export interface SectionAccordionProps {
header: string
label: string
step: StepEnum
roomIndex: number
}

View File

@@ -1,50 +1,46 @@
import type { DetailsProviderProps } from "@/types/providers/enter-details"
import type { Packages } from "@/types/requests/packages"
import type {
DetailsState,
Price,
RoomPrice,
} from "@/types/stores/enter-details"
import type { RoomAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
import type { BedTypeSchema } from "./enterDetails/bedType"
import type { BreakfastPackage } from "./enterDetails/breakfast"
import type { DetailsSchema } from "./enterDetails/details"
import type { RoomState } from "@/types/stores/enter-details"
import type { RoomPrice, RoomRate } from "./enterDetails/details"
import type { Price } from "./price"
import type { Child, SelectRateSearchParams } from "./selectRate/selectRate"
export type RoomsData = Pick<DetailsState, "roomPrice"> &
Pick<RoomAvailability, "cancellationText" | "rateDetails"> &
Pick<RoomAvailability["selectedRoom"], "roomType"> & {
adults: number
children?: Child[]
packages: Packages | null
}
export type RoomsData = {
rateDetails: string[] | undefined
roomType: string
cancellationText: string
roomPrice: RoomPrice
adults: number
children?: Child[]
packages: Packages | null
}
export interface SummaryProps
extends Pick<RoomAvailability, "cancellationText" | "rateDetails">,
Pick<RoomAvailability["selectedRoom"], "roomType"> {
export interface SummaryProps {
isMember: boolean
breakfastIncluded: boolean
}
export interface SummaryUIProps {
booking: SelectRateSearchParams
isMember: boolean
totalPrice: Price
toggleSummaryOpen: () => void
togglePriceDetailsModalOpen: () => void
vat: number
}
export interface EnterDetailsSummaryProps extends SummaryUIProps {
breakfastIncluded: boolean
rooms: RoomState[]
}
export interface SelectRateSummaryProps extends SummaryUIProps {
rooms: {
adults: number
childrenInRoom: Child[] | undefined
bedType: BedTypeSchema | undefined
breakfast: BreakfastPackage | false | undefined
guest: DetailsSchema | undefined
roomRate: DetailsProviderProps["roomRate"]
roomPrice: RoomPrice
roomType: string
roomPrice: RoomPrice
roomRate: RoomRate
rateDetails: string[] | undefined
cancellationText: string
}[]
isMember: boolean
breakfastIncluded: boolean
packages: Packages | null
totalPrice: Price
vat: number
toggleSummaryOpen: () => void
togglePriceDetailsModalOpen: () => void
}

View File

@@ -2,5 +2,4 @@ export enum StepEnum {
selectBed = "select-bed",
breakfast = "breakfast",
details = "details",
payment = "payment",
}

View File

@@ -2,17 +2,15 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
import type { StepEnum } from "@/types/enums/step"
import type { RoomAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
import type { SafeUser } from "@/types/user"
import type { RoomData } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page"
import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate"
import type { Packages } from "../requests/packages"
export interface DetailsProviderProps extends React.PropsWithChildren {
booking: SelectRateSearchParams
bedTypes: BedTypeSelection[]
showBreakfastStep: boolean
packages: Packages | null
roomRate: Pick<RoomAvailability, "memberRate" | "publicRate">
roomsData: RoomData[]
searchParamsStr: string
step: StepEnum
user: SafeUser
vat: number
}

View File

@@ -2,41 +2,47 @@ import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDet
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import type {
DetailsSchema,
RoomPrice,
RoomRate,
SignedInDetailsSchema,
} from "@/types/components/hotelReservation/enterDetails/details"
import type { StepEnum } from "@/types/enums/step"
import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate"
import type { DetailsProviderProps } from "../providers/enter-details"
import type { Price } from "../components/hotelReservation/price"
import type {
Child,
SelectRateSearchParams,
} from "../components/hotelReservation/selectRate/selectRate"
import type { Packages } from "../requests/packages"
interface TPrice {
currency: string
price: number
export interface InitialRoomData {
roomRate: RoomRate
roomType: string
rateDetails: string[] | undefined
cancellationText: string
roomFeatures: Packages | null
bedType?: BedTypeSchema // used when there is only one bedtype to preselect it
}
export interface RoomPrice {
perNight: Price
perStay: Price
}
export interface Price {
requested: TPrice | undefined
local: TPrice
}
export interface FormValues {
export interface RoomState extends InitialRoomData {
adults: number
childrenInRoom: Child[] | undefined
bedType: BedTypeSchema | undefined
booking: SelectRateSearchParams
breakfast: BreakfastPackage | false | undefined
guest: DetailsSchema | SignedInDetailsSchema
roomPrice: RoomPrice
}
export type InitialState = {
booking: SelectRateSearchParams
vat: number
rooms: InitialRoomData[]
breakfast?: false
}
export interface DetailsState {
actions: {
completeStep: () => void
navigate: (step: StepEnum) => void
setStep: (step: StepEnum | null, roomIndex?: number) => void
setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void
setStep: (step: StepEnum) => void
setTotalPrice: (totalPrice: Price) => void
toggleSummaryOpen: () => void
togglePriceDetailsModalOpen: () => void
@@ -45,40 +51,42 @@ export interface DetailsState {
updateDetails: (data: DetailsSchema) => void
updateSeachParamString: (searchParamString: string) => void
}
bedType: BedTypeSchema | undefined
booking: SelectRateSearchParams
breakfast: BreakfastPackage | false | undefined
currentStep: StepEnum
formValues: FormValues
guest: DetailsSchema
isSubmittingDisabled: boolean
isSummaryOpen: boolean
isPriceDetailsModalOpen: boolean
isValid: Record<StepEnum, boolean>
packages: Packages | null
roomRate: DetailsProviderProps["roomRate"]
roomPrice: RoomPrice
steps: StepEnum[]
rooms: RoomState[]
totalPrice: Price
searchParamString: string
vat: number
bookingProgress: BookingProgress
}
export type InitialState = Pick<DetailsState, "booking" | "packages"> &
Pick<DetailsProviderProps, "roomRate" | "vat"> & {
bedType?: BedTypeSchema
breakfast?: false
export type PersistedState = {
booking: SelectRateSearchParams
bookingProgress: BookingProgress
rooms: RoomState[]
}
export type RoomStep = {
step: StepEnum
isValid: boolean
}
export type RoomStatus = {
isComplete: boolean
currentStep: StepEnum | null
lastCompletedStep: StepEnum | undefined
steps: {
[StepEnum.selectBed]: RoomStep
[StepEnum.breakfast]?: RoomStep
[StepEnum.details]: RoomStep
}
}
export type RoomRate = DetailsProviderProps["roomRate"]
export type PersistedState = Pick<
DetailsState,
"bedType" | "booking" | "breakfast" | "guest"
>
export type PersistedStatePart =
| Pick<DetailsState, "bedType">
| Pick<DetailsState, "booking">
| Pick<DetailsState, "breakfast">
| Pick<DetailsState, "guest">
export type BookingProgress = {
currentRoomIndex: number
roomStatuses: RoomStatus[]
canProceedToPayment: boolean
}