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:
committed by
Arvid Norlin
parent
f43ee4a0e6
commit
b394d54c3f
@@ -5,13 +5,14 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
|
|||||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||||
import type {
|
import type {
|
||||||
DetailsSchema,
|
DetailsSchema,
|
||||||
|
RoomPrice,
|
||||||
|
RoomRate,
|
||||||
SignedInDetailsSchema,
|
SignedInDetailsSchema,
|
||||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import { CurrencyEnum } from "@/types/enums/currency"
|
import { CurrencyEnum } from "@/types/enums/currency"
|
||||||
import { PackageTypeEnum } from "@/types/enums/packages"
|
import { PackageTypeEnum } from "@/types/enums/packages"
|
||||||
import type { RoomPrice, RoomRate } from "@/types/stores/enter-details"
|
|
||||||
|
|
||||||
export const booking: SelectRateSearchParams = {
|
export const booking: SelectRateSearchParams = {
|
||||||
city: "Stockholm",
|
city: "Stockholm",
|
||||||
@@ -27,6 +28,14 @@ export const booking: SelectRateSearchParams = {
|
|||||||
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
||||||
packages: [RoomPackageCodeEnum.PET_ROOM],
|
packages: [RoomPackageCodeEnum.PET_ROOM],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
adults: 2,
|
||||||
|
roomTypeCode: "",
|
||||||
|
rateCode: "",
|
||||||
|
counterRateCode: "",
|
||||||
|
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
||||||
|
packages: [RoomPackageCodeEnum.PET_ROOM],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "@/constants/booking"
|
} from "@/constants/booking"
|
||||||
import {
|
import {
|
||||||
bookingConfirmation,
|
bookingConfirmation,
|
||||||
payment,
|
details,
|
||||||
} from "@/constants/routes/hotelReservation"
|
} from "@/constants/routes/hotelReservation"
|
||||||
import { serverClient } from "@/lib/trpc/server"
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export default async function PaymentCallbackPage({
|
|||||||
redirect(confirmationUrl)
|
redirect(confirmationUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnUrl = payment(lang)
|
const returnUrl = details(lang)
|
||||||
const searchObject = new URLSearchParams()
|
const searchObject = new URLSearchParams()
|
||||||
|
|
||||||
let errorMessage = undefined
|
let errorMessage = undefined
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
.content {
|
.content {
|
||||||
width: var(--max-width-page);
|
width: var(--max-width-page);
|
||||||
margin: var(--Spacing-x3) auto 0;
|
margin: var(--Spacing-x3) auto 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding-bottom: var(--Spacing-x3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary {
|
.summary {
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { usePathname } from "next/navigation"
|
|||||||
import { useEffect, useMemo, useRef } from "react"
|
import { useEffect, useMemo, useRef } from "react"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
|
import { selectRoom } from "@/stores/enter-details/helpers"
|
||||||
|
|
||||||
import { useSessionId } from "@/hooks/useSessionId"
|
import { useSessionId } from "@/hooks/useSessionId"
|
||||||
import { createSDKPageObject, trackPageView } from "@/utils/tracking"
|
import { createSDKPageObject, trackPageView } from "@/utils/tracking"
|
||||||
@@ -36,8 +37,11 @@ export default function EnterDetailsTracking(props: Props) {
|
|||||||
cancellationRule,
|
cancellationRule,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const { bedType, breakfast, totalPrice, roomPrice, roomRate, packages } =
|
const { bedType, breakfast, roomPrice, roomRate, roomFeatures } =
|
||||||
useEnterDetailsStore((state) => state)
|
useEnterDetailsStore(selectRoom)
|
||||||
|
|
||||||
|
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
|
||||||
|
|
||||||
const pathName = usePathname()
|
const pathName = usePathname()
|
||||||
const sessionId = useSessionId()
|
const sessionId = useSessionId()
|
||||||
|
|
||||||
@@ -128,7 +132,7 @@ export default function EnterDetailsTracking(props: Props) {
|
|||||||
revenueCurrencyCode: totalPrice.local?.currency,
|
revenueCurrencyCode: totalPrice.local?.currency,
|
||||||
breakfastOption: breakfast ? "breakfast buffet" : "no breakfast",
|
breakfastOption: breakfast ? "breakfast buffet" : "no breakfast",
|
||||||
totalPrice: totalPrice.local?.price,
|
totalPrice: totalPrice.local?.price,
|
||||||
specialRoomType: getSpecialRoomType(packages),
|
specialRoomType: getSpecialRoomType(roomFeatures),
|
||||||
roomTypeName: selectedRoom.roomType,
|
roomTypeName: selectedRoom.roomType,
|
||||||
bedType: bedType?.description,
|
bedType: bedType?.description,
|
||||||
roomTypeCode: bedType?.roomTypeCode,
|
roomTypeCode: bedType?.roomTypeCode,
|
||||||
@@ -148,7 +152,7 @@ export default function EnterDetailsTracking(props: Props) {
|
|||||||
totalPrice,
|
totalPrice,
|
||||||
roomPrice,
|
roomPrice,
|
||||||
roomRate,
|
roomRate,
|
||||||
packages,
|
roomFeatures,
|
||||||
initialHotelsTrackingData,
|
initialHotelsTrackingData,
|
||||||
cancellationRule,
|
cancellationRule,
|
||||||
selectedRoom.roomType,
|
selectedRoom.roomType,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type ExtraBedTypeEnum,
|
type ExtraBedTypeEnum,
|
||||||
} from "@/constants/booking"
|
} from "@/constants/booking"
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
|
import { selectRoom } from "@/stores/enter-details/helpers"
|
||||||
|
|
||||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||||
|
|
||||||
@@ -24,10 +25,12 @@ import type {
|
|||||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||||
import type { IconProps } from "@/types/components/icon"
|
import type { IconProps } from "@/types/components/icon"
|
||||||
|
|
||||||
export default function BedType({ bedTypes }: BedTypeProps) {
|
export default function BedType({
|
||||||
const initialBedType = useEnterDetailsStore(
|
bedTypes,
|
||||||
(state) => state.formValues?.bedType?.roomTypeCode
|
roomIndex,
|
||||||
)
|
}: BedTypeProps & { roomIndex: number }) {
|
||||||
|
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
|
||||||
|
const initialBedType = room.bedType?.roomTypeCode
|
||||||
|
|
||||||
const updateBedType = useEnterDetailsStore(
|
const updateBedType = useEnterDetailsStore(
|
||||||
(state) => state.actions.updateBedType
|
(state) => state.actions.updateBedType
|
||||||
@@ -57,6 +60,12 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
|||||||
[bedTypes, updateBedType]
|
[bedTypes, updateBedType]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialBedType) {
|
||||||
|
methods.setValue("bedType", initialBedType)
|
||||||
|
}
|
||||||
|
}, [initialBedType, methods])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (methods.formState.isSubmitting) {
|
if (methods.formState.isSubmitting) {
|
||||||
return
|
return
|
||||||
@@ -109,9 +118,13 @@ function BedIconRenderer({
|
|||||||
extraBedType: ExtraBedTypeEnum | undefined
|
extraBedType: ExtraBedTypeEnum | undefined
|
||||||
props: IconProps
|
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
|
const ExtraBedIcon = extraBedType ? BED_TYPE_ICONS[extraBedType] : null
|
||||||
|
|
||||||
|
if (!MainBedIcon) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${props.className} ${styles.iconContainer}`}>
|
<div className={`${props.className} ${styles.iconContainer}`}>
|
||||||
<MainBedIcon height={32} color="uiTextMediumContrast" />
|
<MainBedIcon height={32} color="uiTextMediumContrast" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { FormProvider, useForm } from "react-hook-form"
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
|
import { selectRoom } from "@/stores/enter-details/helpers"
|
||||||
|
|
||||||
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
||||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||||
@@ -22,16 +23,19 @@ import type {
|
|||||||
} from "@/types/components/hotelReservation/enterDetails/breakfast"
|
} from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/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 intl = useIntl()
|
||||||
|
|
||||||
const formValuesBreakfast = useEnterDetailsStore(({ formValues }) =>
|
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
|
||||||
formValues?.breakfast
|
|
||||||
? formValues.breakfast.code
|
const breakfastSelection = room?.breakfast
|
||||||
: formValues?.breakfast === false
|
? room.breakfast.code
|
||||||
? "false"
|
: room?.breakfast === false
|
||||||
: undefined
|
? "false"
|
||||||
)
|
: undefined
|
||||||
|
|
||||||
const updateBreakfast = useEnterDetailsStore(
|
const updateBreakfast = useEnterDetailsStore(
|
||||||
(state) => state.actions.updateBreakfast
|
(state) => state.actions.updateBreakfast
|
||||||
@@ -42,8 +46,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const methods = useForm<BreakfastFormSchema>({
|
const methods = useForm<BreakfastFormSchema>({
|
||||||
defaultValues: formValuesBreakfast
|
defaultValues: breakfastSelection
|
||||||
? { breakfast: formValuesBreakfast }
|
? { breakfast: breakfastSelection }
|
||||||
: undefined,
|
: undefined,
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
mode: "all",
|
mode: "all",
|
||||||
@@ -63,6 +67,12 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
|||||||
[packages, updateBreakfast]
|
[packages, updateBreakfast]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (breakfastSelection) {
|
||||||
|
methods.setValue("breakfast", breakfastSelection)
|
||||||
|
}
|
||||||
|
}, [breakfastSelection, methods])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (methods.formState.isSubmitting) {
|
if (methods.formState.isSubmitting) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
|
import { selectRoom } from "@/stores/enter-details/helpers"
|
||||||
|
|
||||||
import { MagicWandIcon } from "@/components/Icons"
|
import { MagicWandIcon } from "@/components/Icons"
|
||||||
import Modal from "@/components/Modal"
|
import Modal from "@/components/Modal"
|
||||||
@@ -23,7 +24,8 @@ export default function MemberPriceModal({
|
|||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
setIsOpen: Dispatch<SetStateAction<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 intl = useIntl()
|
||||||
|
|
||||||
const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice
|
const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { FormProvider, useForm } from "react-hook-form"
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||||
|
import {
|
||||||
|
selectBookingProgress,
|
||||||
|
selectRoom,
|
||||||
|
} from "@/stores/enter-details/helpers"
|
||||||
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||||
@@ -26,15 +30,25 @@ import type {
|
|||||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||||
|
|
||||||
const formID = "enter-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 intl = useIntl()
|
||||||
const [isMemberPriceModalOpen, setIsMemberPriceModalOpen] = useState(false)
|
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(
|
const updateDetails = useEnterDetailsStore(
|
||||||
(state) => state.actions.updateDetails
|
(state) => state.actions.updateDetails
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isPaymentNext = currentRoomIndex === roomStatuses.length - 1
|
||||||
|
|
||||||
const methods = useForm<DetailsSchema>({
|
const methods = useForm<DetailsSchema>({
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
mode: "all",
|
mode: "all",
|
||||||
@@ -68,7 +82,7 @@ export default function Details({ user, memberPrice }: DetailsProps) {
|
|||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form
|
<form
|
||||||
className={styles.form}
|
className={styles.form}
|
||||||
id={formID}
|
id={`${formID}-room-${roomIndex + 1}`}
|
||||||
onSubmit={methods.handleSubmit(onSubmit)}
|
onSubmit={methods.handleSubmit(onSubmit)}
|
||||||
>
|
>
|
||||||
{user ? null : (
|
{user ? null : (
|
||||||
@@ -127,13 +141,23 @@ export default function Details({ user, memberPrice }: DetailsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<Button
|
<Button
|
||||||
disabled={!methods.formState.isValid}
|
disabled={
|
||||||
|
!(
|
||||||
|
methods.formState.isValid ||
|
||||||
|
(isPaymentNext && canProceedToPayment)
|
||||||
|
)
|
||||||
|
}
|
||||||
intent="secondary"
|
intent="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
theme="base"
|
theme="base"
|
||||||
type="submit"
|
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>
|
</Button>
|
||||||
</footer>
|
</footer>
|
||||||
<MemberPriceModal
|
<MemberPriceModal
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import LoadingSpinner from "@/components/LoadingSpinner"
|
|||||||
import { trackPaymentEvent } from "@/utils/tracking"
|
import { trackPaymentEvent } from "@/utils/tracking"
|
||||||
import { convertObjToSearchParams } from "@/utils/url"
|
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({
|
export default function PaymentCallback({
|
||||||
returnUrl,
|
returnUrl,
|
||||||
@@ -28,7 +28,7 @@ export default function PaymentCallback({
|
|||||||
const bookingData = window.sessionStorage.getItem(detailsStorageName)
|
const bookingData = window.sessionStorage.getItem(detailsStorageName)
|
||||||
|
|
||||||
if (bookingData) {
|
if (bookingData) {
|
||||||
const detailsStorage: PersistedState = JSON.parse(bookingData)
|
const detailsStorage: any = JSON.parse(bookingData) // TODO: fix type here
|
||||||
const searchParams = convertObjToSearchParams(
|
const searchParams = convertObjToSearchParams(
|
||||||
detailsStorage.booking,
|
detailsStorage.booking,
|
||||||
searchObject
|
searchObject
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
|||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||||
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
|
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
|
||||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||||
@@ -55,7 +56,6 @@ function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
|||||||
|
|
||||||
export default function PaymentClient({
|
export default function PaymentClient({
|
||||||
user,
|
user,
|
||||||
roomPrice,
|
|
||||||
otherPaymentOptions,
|
otherPaymentOptions,
|
||||||
savedCreditCards,
|
savedCreditCards,
|
||||||
mustBeGuaranteed,
|
mustBeGuaranteed,
|
||||||
@@ -65,13 +65,18 @@ export default function PaymentClient({
|
|||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
|
const { totalPrice, booking, rooms, bookingProgress } = useEnterDetailsStore(
|
||||||
const { bedType, booking, breakfast } = useEnterDetailsStore((state) => ({
|
(state) => {
|
||||||
bedType: state.bedType,
|
return {
|
||||||
booking: state.booking,
|
totalPrice: state.totalPrice,
|
||||||
breakfast: state.breakfast,
|
booking: state.booking,
|
||||||
}))
|
rooms: state.rooms,
|
||||||
const userData = useEnterDetailsStore((state) => state.guest)
|
bookingProgress: state.bookingProgress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const canProceedToPayment = bookingProgress.canProceedToPayment
|
||||||
|
|
||||||
const setIsSubmittingDisabled = useEnterDetailsStore(
|
const setIsSubmittingDisabled = useEnterDetailsStore(
|
||||||
(state) => state.actions.setIsSubmittingDisabled
|
(state) => state.actions.setIsSubmittingDisabled
|
||||||
)
|
)
|
||||||
@@ -87,7 +92,7 @@ export default function PaymentClient({
|
|||||||
newPrice: number
|
newPrice: number
|
||||||
} | null>()
|
} | null>()
|
||||||
|
|
||||||
const { toDate, fromDate, rooms, hotelId } = booking
|
const { toDate, fromDate, hotelId } = booking
|
||||||
|
|
||||||
usePaymentFailedToast()
|
usePaymentFailedToast()
|
||||||
|
|
||||||
@@ -115,7 +120,7 @@ export default function PaymentClient({
|
|||||||
|
|
||||||
if (priceChange) {
|
if (priceChange) {
|
||||||
setPriceChangeData({
|
setPriceChangeData({
|
||||||
oldPrice: roomPrice.publicPrice,
|
oldPrice: rooms[0].roomPrice.perStay.local.price,
|
||||||
newPrice: priceChange.totalPrice,
|
newPrice: priceChange.totalPrice,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -202,18 +207,6 @@ export default function PaymentClient({
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(data: PaymentFormData) => {
|
(data: PaymentFormData) => {
|
||||||
const {
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
email,
|
|
||||||
phoneNumber,
|
|
||||||
countryCode,
|
|
||||||
membershipNo,
|
|
||||||
join,
|
|
||||||
dateOfBirth,
|
|
||||||
zipCode,
|
|
||||||
} = userData
|
|
||||||
|
|
||||||
// set payment method to card if saved card is submitted
|
// set payment method to card if saved card is submitted
|
||||||
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
|
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
|
||||||
? data.paymentMethod
|
? data.paymentMethod
|
||||||
@@ -239,41 +232,50 @@ export default function PaymentClient({
|
|||||||
hotelId,
|
hotelId,
|
||||||
checkInDate: fromDate,
|
checkInDate: fromDate,
|
||||||
checkOutDate: toDate,
|
checkOutDate: toDate,
|
||||||
rooms: rooms.map((room) => ({
|
rooms: rooms.map((room, idx) => ({
|
||||||
adults: room.adults,
|
adults: room.adults,
|
||||||
childrenAges: room.childrenInRoom?.map((child) => ({
|
childrenAges: room.childrenInRoom?.map((child) => ({
|
||||||
age: child.age,
|
age: child.age,
|
||||||
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
||||||
})),
|
})),
|
||||||
rateCode:
|
rateCode:
|
||||||
(user || join || membershipNo) && room.counterRateCode
|
(user || room.guest.join || room.guest.membershipNo) &&
|
||||||
? room.counterRateCode
|
booking.rooms[idx].counterRateCode
|
||||||
: room.rateCode,
|
? booking.rooms[idx].counterRateCode
|
||||||
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
: booking.rooms[idx].rateCode,
|
||||||
|
roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
||||||
guest: {
|
guest: {
|
||||||
firstName,
|
firstName: room.guest.firstName,
|
||||||
lastName,
|
lastName: room.guest.lastName,
|
||||||
email,
|
email: room.guest.email,
|
||||||
phoneNumber,
|
phoneNumber: room.guest.phoneNumber,
|
||||||
countryCode,
|
countryCode: room.guest.countryCode,
|
||||||
membershipNumber: membershipNo,
|
membershipNumber: room.guest.membershipNo,
|
||||||
becomeMember: join,
|
becomeMember: room.guest.join,
|
||||||
dateOfBirth,
|
dateOfBirth: room.guest.dateOfBirth,
|
||||||
postalCode: zipCode,
|
postalCode: room.guest.zipCode,
|
||||||
},
|
},
|
||||||
packages: {
|
packages: {
|
||||||
breakfast: !!(breakfast && breakfast.code),
|
breakfast: !!(room.breakfast && room.breakfast.code),
|
||||||
allergyFriendly:
|
allergyFriendly:
|
||||||
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ??
|
room.roomFeatures?.some(
|
||||||
false,
|
(feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||||
|
) ?? false,
|
||||||
petFriendly:
|
petFriendly:
|
||||||
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
|
room.roomFeatures?.some(
|
||||||
|
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||||
|
) ?? false,
|
||||||
accessibility:
|
accessibility:
|
||||||
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
|
room.roomFeatures?.some(
|
||||||
false,
|
(feature) =>
|
||||||
|
feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||||
|
) ?? false,
|
||||||
},
|
},
|
||||||
smsConfirmationRequested: data.smsConfirmation,
|
smsConfirmationRequested: data.smsConfirmation,
|
||||||
roomPrice,
|
roomPrice: {
|
||||||
|
memberPrice: room.roomRate.memberRate?.localPrice.pricePerStay,
|
||||||
|
publicPrice: room.roomRate.publicRate.localPrice.pricePerStay,
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
payment: {
|
payment: {
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
@@ -292,7 +294,6 @@ export default function PaymentClient({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
userData,
|
|
||||||
savedCreditCards,
|
savedCreditCards,
|
||||||
lang,
|
lang,
|
||||||
initiateBooking,
|
initiateBooking,
|
||||||
@@ -301,9 +302,7 @@ export default function PaymentClient({
|
|||||||
toDate,
|
toDate,
|
||||||
rooms,
|
rooms,
|
||||||
user,
|
user,
|
||||||
bedType,
|
booking,
|
||||||
breakfast,
|
|
||||||
roomPrice,
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -316,8 +315,22 @@ export default function PaymentClient({
|
|||||||
return <LoadingSpinner />
|
return <LoadingSpinner />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paymentGuarantee = intl.formatMessage({
|
||||||
|
id: "Payment Guarantee",
|
||||||
|
})
|
||||||
|
const payment = intl.formatMessage({
|
||||||
|
id: "Payment",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<section
|
||||||
|
className={`${styles.paymentSection} ${canProceedToPayment ? "" : styles.disabled}`}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<Title level="h2" as="h4">
|
||||||
|
{mustBeGuaranteed ? paymentGuarantee : payment}
|
||||||
|
</Title>
|
||||||
|
</header>
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form
|
<form
|
||||||
className={styles.paymentContainer}
|
className={styles.paymentContainer}
|
||||||
@@ -460,6 +473,6 @@ export default function PaymentClient({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { PaymentProps } from "@/types/components/hotelReservation/selectRat
|
|||||||
|
|
||||||
export default async function Payment({
|
export default async function Payment({
|
||||||
user,
|
user,
|
||||||
roomPrice,
|
|
||||||
otherPaymentOptions,
|
otherPaymentOptions,
|
||||||
mustBeGuaranteed,
|
mustBeGuaranteed,
|
||||||
supportedCards,
|
supportedCards,
|
||||||
@@ -18,7 +17,6 @@ export default async function Payment({
|
|||||||
return (
|
return (
|
||||||
<PaymentClient
|
<PaymentClient
|
||||||
user={user}
|
user={user}
|
||||||
roomPrice={roomPrice}
|
|
||||||
otherPaymentOptions={otherPaymentOptions}
|
otherPaymentOptions={otherPaymentOptions}
|
||||||
savedCreditCards={savedCreditCards}
|
savedCreditCards={savedCreditCards}
|
||||||
mustBeGuaranteed={mustBeGuaranteed}
|
mustBeGuaranteed={mustBeGuaranteed}
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
.paymentSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.paymentContainer {
|
.paymentContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ import { useEffect, useState } from "react"
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
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 Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import useScrollToActiveSection from "@/hooks/booking/useScrollToActiveSection"
|
import useScrollToActiveSection from "@/hooks/booking/useScrollToActiveSection"
|
||||||
@@ -19,24 +25,31 @@ export default function SectionAccordion({
|
|||||||
header,
|
header,
|
||||||
label,
|
label,
|
||||||
step,
|
step,
|
||||||
|
roomIndex,
|
||||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
const roomStatus = useEnterDetailsStore((state) =>
|
||||||
const steps = useEnterDetailsStore((state) => state.steps)
|
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 [isComplete, setIsComplete] = useState(false)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
const isValid = roomStatus.steps[step]?.isValid ?? false
|
||||||
const navigate = useEnterDetailsStore((state) => state.actions.navigate)
|
|
||||||
const { bedType, breakfast } = useEnterDetailsStore((state) => ({
|
|
||||||
bedType: state.bedType,
|
|
||||||
breakfast: state.breakfast,
|
|
||||||
}))
|
|
||||||
const [title, setTitle] = useState(label)
|
const [title, setTitle] = useState(label)
|
||||||
|
|
||||||
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
|
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
|
||||||
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
|
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
|
||||||
|
|
||||||
useScrollToActiveSection(step, steps, currentStep === step)
|
// useScrollToActiveSection(step, steps, roomStatus.currentStep === step)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step === StepEnum.selectBed && bedType) {
|
if (step === StepEnum.selectBed && bedType) {
|
||||||
@@ -57,11 +70,29 @@ export default function SectionAccordion({
|
|||||||
}, [isValid, setIsComplete])
|
}, [isValid, setIsComplete])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsOpen(currentStep === step)
|
setIsOpen(roomStatus.currentStep === step && currentRoomIndex === roomIndex)
|
||||||
}, [currentStep, setIsOpen, step])
|
}, [currentRoomIndex, roomIndex, roomStatus.currentStep, setIsOpen, step])
|
||||||
|
|
||||||
function onModify() {
|
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 =
|
const textColor =
|
||||||
@@ -81,7 +112,7 @@ export default function SectionAccordion({
|
|||||||
</div>
|
</div>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<button
|
<button
|
||||||
onClick={onModify}
|
onClick={isOpen ? close : onModify}
|
||||||
disabled={!isComplete}
|
disabled={!isComplete}
|
||||||
className={styles.modifyButton}
|
className={styles.modifyButton}
|
||||||
>
|
>
|
||||||
@@ -97,9 +128,11 @@ export default function SectionAccordion({
|
|||||||
<Subtitle className={styles.selection} type="two" color={textColor}>
|
<Subtitle className={styles.selection} type="two" color={textColor}>
|
||||||
{title}
|
{title}
|
||||||
</Subtitle>
|
</Subtitle>
|
||||||
|
{isComplete && (
|
||||||
{isComplete && !isOpen && (
|
<ChevronDownIcon
|
||||||
<ChevronDownIcon className={styles.button} color="burgundy" />
|
className={`${styles.button} ${isOpen ? styles.buttonOpen : ""}`}
|
||||||
|
color="burgundy"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -16,10 +16,6 @@
|
|||||||
transform-origin: top;
|
transform-origin: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accordion:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
}
|
}
|
||||||
@@ -46,8 +42,13 @@
|
|||||||
.button {
|
.button {
|
||||||
grid-area: button;
|
grid-area: button;
|
||||||
justify-self: flex-end;
|
justify-self: flex-end;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
transition: transform 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buttonOpen {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
.selection {
|
.selection {
|
||||||
grid-area: selection;
|
grid-area: selection;
|
||||||
}
|
}
|
||||||
@@ -85,22 +86,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.contentWrapper {
|
.contentWrapper {
|
||||||
|
opacity: 0;
|
||||||
padding-bottom: var(--Spacing-x3);
|
padding-bottom: var(--Spacing-x3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.accordion[data-section-open="true"] .contentWrapper {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
.content {
|
.content {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
opacity: 0;
|
|
||||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||||
transform-origin: top;
|
transform-origin: top;
|
||||||
transition: opacity 0.2s linear;
|
transition: opacity 0.2s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accordion[data-section-open="true"] .content {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content:has([data-section-open="true"]) {
|
.content:has([data-section-open="true"]) {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import type { SelectedRoomProps } from "@/types/components/hotelReservation/ente
|
|||||||
|
|
||||||
export default function SelectedRoom({
|
export default function SelectedRoom({
|
||||||
hotelId,
|
hotelId,
|
||||||
room,
|
roomType,
|
||||||
|
roomTypeCode,
|
||||||
rateDescription,
|
rateDescription,
|
||||||
}: SelectedRoomProps) {
|
}: SelectedRoomProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
@@ -50,7 +51,7 @@ export default function SelectedRoom({
|
|||||||
{intl.formatMessage<React.ReactNode>(
|
{intl.formatMessage<React.ReactNode>(
|
||||||
{ id: "{roomType} <rate>{rateDescription}</rate>" },
|
{ id: "{roomType} <rate>{rateDescription}</rate>" },
|
||||||
{
|
{
|
||||||
roomType: room.roomType,
|
roomType: roomType,
|
||||||
rateDescription,
|
rateDescription,
|
||||||
rate: (str) => {
|
rate: (str) => {
|
||||||
return <span className={styles.rate}>{str}</span>
|
return <span className={styles.rate}>{str}</span>
|
||||||
@@ -70,12 +71,9 @@ export default function SelectedRoom({
|
|||||||
{intl.formatMessage({ id: "Change room" })}{" "}
|
{intl.formatMessage({ id: "Change room" })}{" "}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{room?.roomTypeCode && (
|
{roomTypeCode && (
|
||||||
<div className={styles.details}>
|
<div className={styles.details}>
|
||||||
<ToggleSidePeek
|
<ToggleSidePeek hotelId={hotelId} roomTypeCode={roomTypeCode} />
|
||||||
hotelId={hotelId}
|
|
||||||
roomTypeCode={room.roomTypeCode}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,6 @@
|
|||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
.wrapper {
|
.wrapper {
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x3);
|
||||||
padding-top: var(--Spacing-x3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconWrapper {
|
.iconWrapper {
|
||||||
|
|||||||
@@ -7,54 +7,16 @@ import SidePanel from "@/components/HotelReservation/SidePanel"
|
|||||||
import SummaryUI from "./UI"
|
import SummaryUI from "./UI"
|
||||||
|
|
||||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
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) {
|
export default function DesktopSummary(props: SummaryProps) {
|
||||||
const {
|
const {
|
||||||
bedType,
|
|
||||||
booking,
|
booking,
|
||||||
breakfast,
|
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
|
||||||
guest,
|
|
||||||
packages,
|
|
||||||
roomPrice,
|
|
||||||
roomRate,
|
|
||||||
toggleSummaryOpen,
|
|
||||||
togglePriceDetailsModalOpen,
|
|
||||||
totalPrice,
|
totalPrice,
|
||||||
vat,
|
vat,
|
||||||
} = useEnterDetailsStore(storeSelector)
|
} = useEnterDetailsStore((state) => state)
|
||||||
|
|
||||||
// TODO: rooms should be part of store
|
const rooms = useEnterDetailsStore((state) => state.rooms)
|
||||||
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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidePanel variant="summary">
|
<SidePanel variant="summary">
|
||||||
@@ -63,7 +25,6 @@ export default function DesktopSummary(props: SummaryProps) {
|
|||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
isMember={props.isMember}
|
isMember={props.isMember}
|
||||||
breakfastIncluded={props.breakfastIncluded}
|
breakfastIncluded={props.breakfastIncluded}
|
||||||
packages={packages}
|
|
||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
vat={vat}
|
vat={vat}
|
||||||
toggleSummaryOpen={toggleSummaryOpen}
|
toggleSummaryOpen={toggleSummaryOpen}
|
||||||
|
|||||||
@@ -10,55 +10,23 @@ import SummaryBottomSheet from "./BottomSheet"
|
|||||||
import styles from "./mobile.module.css"
|
import styles from "./mobile.module.css"
|
||||||
|
|
||||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
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) {
|
export default function MobileSummary(props: SummaryProps) {
|
||||||
const {
|
const {
|
||||||
bedType,
|
|
||||||
booking,
|
booking,
|
||||||
breakfast,
|
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
|
||||||
guest,
|
|
||||||
packages,
|
|
||||||
roomPrice,
|
|
||||||
roomRate,
|
|
||||||
toggleSummaryOpen,
|
|
||||||
togglePriceDetailsModalOpen,
|
|
||||||
totalPrice,
|
totalPrice,
|
||||||
vat,
|
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 (
|
return (
|
||||||
<div className={styles.mobileSummary}>
|
<div className={styles.mobileSummary}>
|
||||||
{showPromo ? <SignupPromoMobile /> : null}
|
{showPromo ? <SignupPromoMobile /> : null}
|
||||||
@@ -69,7 +37,6 @@ export default function MobileSummary(props: SummaryProps) {
|
|||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
isMember={props.isMember}
|
isMember={props.isMember}
|
||||||
breakfastIncluded={props.breakfastIncluded}
|
breakfastIncluded={props.breakfastIncluded}
|
||||||
packages={packages}
|
|
||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
vat={vat}
|
vat={vat}
|
||||||
toggleSummaryOpen={toggleSummaryOpen}
|
toggleSummaryOpen={toggleSummaryOpen}
|
||||||
|
|||||||
@@ -24,20 +24,19 @@ import { formatPrice } from "@/utils/numberFormatting"
|
|||||||
import styles from "./ui.module.css"
|
import styles from "./ui.module.css"
|
||||||
|
|
||||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||||
import type { SummaryUIProps } from "@/types/components/hotelReservation/summary"
|
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||||
import type { DetailsProviderProps } from "@/types/providers/enter-details"
|
import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary"
|
||||||
|
|
||||||
export default function SummaryUI({
|
export default function SummaryUI({
|
||||||
booking,
|
booking,
|
||||||
rooms,
|
rooms,
|
||||||
packages,
|
|
||||||
totalPrice,
|
totalPrice,
|
||||||
isMember,
|
isMember,
|
||||||
breakfastIncluded,
|
breakfastIncluded,
|
||||||
vat,
|
vat,
|
||||||
toggleSummaryOpen,
|
toggleSummaryOpen,
|
||||||
togglePriceDetailsModalOpen,
|
togglePriceDetailsModalOpen,
|
||||||
}: SummaryUIProps) {
|
}: EnterDetailsSummaryProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
|
|
||||||
@@ -60,8 +59,8 @@ export default function SummaryUI({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMemberPrice(roomRate: DetailsProviderProps["roomRate"]) {
|
function getMemberPrice(roomRate: RoomRate) {
|
||||||
return roomRate.memberRate
|
return roomRate?.memberRate
|
||||||
? {
|
? {
|
||||||
currency: roomRate.memberRate.localPrice.currency,
|
currency: roomRate.memberRate.localPrice.currency,
|
||||||
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
|
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
|
||||||
@@ -74,7 +73,7 @@ export default function SummaryUI({
|
|||||||
rooms.length === 1 &&
|
rooms.length === 1 &&
|
||||||
rooms
|
rooms
|
||||||
.slice(0, 1)
|
.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)
|
const memberPrice = getMemberPrice(rooms[0].roomRate)
|
||||||
|
|
||||||
@@ -127,11 +126,8 @@ export default function SummaryUI({
|
|||||||
|
|
||||||
const isFirstRoomMember = roomNumber === 1 && isMember
|
const isFirstRoomMember = roomNumber === 1 && isMember
|
||||||
const showMemberPrice =
|
const showMemberPrice =
|
||||||
!!(
|
!!(isFirstRoomMember || room.guest.join || room.guest.membershipNo) &&
|
||||||
isFirstRoomMember ||
|
memberPrice
|
||||||
room?.guest?.join ||
|
|
||||||
room?.guest?.membershipNo
|
|
||||||
) && memberPrice
|
|
||||||
|
|
||||||
const adultsMsg = intl.formatMessage(
|
const adultsMsg = intl.formatMessage(
|
||||||
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
||||||
@@ -206,20 +202,20 @@ export default function SummaryUI({
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
{packages
|
{room.roomFeatures
|
||||||
? packages.map((roomPackage) => (
|
? room.roomFeatures.map((feature) => (
|
||||||
<div className={styles.entry} key={roomPackage.code}>
|
<div className={styles.entry} key={feature.code}>
|
||||||
<div>
|
<div>
|
||||||
<Body color="uiTextHighContrast">
|
<Body color="uiTextHighContrast">
|
||||||
{roomPackage.description}
|
{feature.description}
|
||||||
</Body>
|
</Body>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Body color="uiTextHighContrast">
|
<Body color="uiTextHighContrast">
|
||||||
{formatPrice(
|
{formatPrice(
|
||||||
intl,
|
intl,
|
||||||
parseInt(roomPackage.localPrice.price),
|
parseInt(feature.localPrice.price),
|
||||||
roomPackage.localPrice.currency
|
feature.localPrice.currency
|
||||||
)}
|
)}
|
||||||
</Body>
|
</Body>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import SummaryUI from "./UI"
|
|||||||
import type { PropsWithChildren } from "react"
|
import type { PropsWithChildren } from "react"
|
||||||
|
|
||||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||||
|
import type { RoomState } from "@/types/stores/enter-details"
|
||||||
|
|
||||||
jest.mock("@/lib/api", () => ({
|
jest.mock("@/lib/api", () => ({
|
||||||
fetchRetry: jest.fn((fn) => fn),
|
fetchRetry: jest.fn((fn) => fn),
|
||||||
@@ -39,8 +40,7 @@ function createWrapper(intlConfig: IntlConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: add type definition to this object
|
const rooms: RoomState[] = [
|
||||||
export const rooms = [
|
|
||||||
{
|
{
|
||||||
adults: 2,
|
adults: 2,
|
||||||
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
||||||
@@ -55,6 +55,7 @@ export const rooms = [
|
|||||||
roomType: "Standard",
|
roomType: "Standard",
|
||||||
rateDetails: [],
|
rateDetails: [],
|
||||||
cancellationText: "Non-refundable",
|
cancellationText: "Non-refundable",
|
||||||
|
roomFeatures: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
adults: 1,
|
adults: 1,
|
||||||
@@ -70,6 +71,7 @@ export const rooms = [
|
|||||||
roomType: "Standard",
|
roomType: "Standard",
|
||||||
rateDetails: [],
|
rateDetails: [],
|
||||||
cancellationText: "Non-refundable",
|
cancellationText: "Non-refundable",
|
||||||
|
roomFeatures: [],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -88,7 +90,6 @@ describe("EnterDetails Summary", () => {
|
|||||||
rooms={rooms.slice(0, 1)}
|
rooms={rooms.slice(0, 1)}
|
||||||
isMember={false}
|
isMember={false}
|
||||||
breakfastIncluded={false}
|
breakfastIncluded={false}
|
||||||
packages={[]}
|
|
||||||
totalPrice={{
|
totalPrice={{
|
||||||
requested: {
|
requested: {
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
@@ -128,7 +129,6 @@ describe("EnterDetails Summary", () => {
|
|||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
isMember={false}
|
isMember={false}
|
||||||
breakfastIncluded={false}
|
breakfastIncluded={false}
|
||||||
packages={[]}
|
|
||||||
totalPrice={{
|
totalPrice={{
|
||||||
requested: {
|
requested: {
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import styles from "./priceDetailsTable.module.css"
|
|||||||
|
|
||||||
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
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 { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import type { Price, RoomPrice } from "@/types/stores/enter-details"
|
|
||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
label,
|
label,
|
||||||
@@ -67,8 +68,8 @@ interface PriceDetailsTableProps {
|
|||||||
childrenInRoom: Child[] | undefined
|
childrenInRoom: Child[] | undefined
|
||||||
roomType: string
|
roomType: string
|
||||||
roomPrice: RoomPrice
|
roomPrice: RoomPrice
|
||||||
bedType: BedTypeSchema | undefined
|
bedType?: BedTypeSchema
|
||||||
breakfast: BreakfastPackage | false | undefined
|
breakfast?: BreakfastPackage | false
|
||||||
}[]
|
}[]
|
||||||
totalPrice: Price
|
totalPrice: Price
|
||||||
vat: number
|
vat: number
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import PriceDetailsTable from "./PriceDetailsTable"
|
|||||||
|
|
||||||
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
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 { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import type { Price, RoomPrice } from "@/types/stores/enter-details"
|
|
||||||
|
|
||||||
interface PriceDetailsModalProps {
|
interface PriceDetailsModalProps {
|
||||||
fromDate: string
|
fromDate: string
|
||||||
@@ -20,8 +21,8 @@ interface PriceDetailsModalProps {
|
|||||||
childrenInRoom: Child[] | undefined
|
childrenInRoom: Child[] | undefined
|
||||||
roomType: string
|
roomType: string
|
||||||
roomPrice: RoomPrice
|
roomPrice: RoomPrice
|
||||||
bedType: BedTypeSchema | undefined
|
bedType?: BedTypeSchema
|
||||||
breakfast: BreakfastPackage | false | undefined
|
breakfast?: BreakfastPackage | false
|
||||||
}[]
|
}[]
|
||||||
totalPrice: Price
|
totalPrice: Price
|
||||||
vat: number
|
vat: number
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ import { useIntl } from "react-intl"
|
|||||||
|
|
||||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
||||||
|
|
||||||
import SummaryUI from "@/components/HotelReservation/EnterDetails/Summary/UI"
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import { formatPrice } from "@/utils/numberFormatting"
|
import { formatPrice } from "@/utils/numberFormatting"
|
||||||
|
|
||||||
|
import Summary from "./Summary"
|
||||||
|
|
||||||
import styles from "./mobileSummary.module.css"
|
import styles from "./mobileSummary.module.css"
|
||||||
|
|
||||||
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||||
@@ -55,9 +56,6 @@ export default function MobileSummary({
|
|||||||
},
|
},
|
||||||
currency: room.public.localPrice.currency,
|
currency: room.public.localPrice.currency,
|
||||||
},
|
},
|
||||||
bedType: undefined,
|
|
||||||
breakfast: undefined,
|
|
||||||
guest: undefined,
|
|
||||||
roomRate: {
|
roomRate: {
|
||||||
...room.public,
|
...room.public,
|
||||||
memberRate: room.member,
|
memberRate: room.member,
|
||||||
@@ -99,14 +97,12 @@ export default function MobileSummary({
|
|||||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.summaryAccordion}>
|
<div className={styles.summaryAccordion}>
|
||||||
<SummaryUI
|
<Summary
|
||||||
booking={booking}
|
booking={booking}
|
||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
isMember={isUserLoggedIn}
|
isMember={isUserLoggedIn}
|
||||||
packages={undefined}
|
|
||||||
totalPrice={totalPriceToShow}
|
totalPrice={totalPriceToShow}
|
||||||
vat={vat}
|
vat={vat}
|
||||||
breakfastIncluded={false}
|
|
||||||
toggleSummaryOpen={toggleSummaryOpen}
|
toggleSummaryOpen={toggleSummaryOpen}
|
||||||
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import type { Price } from "@/types/components/hotelReservation/price"
|
||||||
import {
|
import {
|
||||||
type RoomPackage,
|
type RoomPackage,
|
||||||
RoomPackageCodeEnum,
|
RoomPackageCodeEnum,
|
||||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import type { Price } from "@/types/stores/enter-details"
|
|
||||||
|
|
||||||
export const calculateTotalPrice = (
|
export const calculateTotalPrice = (
|
||||||
selectedRateSummary: Rate[],
|
selectedRateSummary: Rate[],
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export default function Rooms({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
window.history.pushState(null, "", `${pathname}?${queryParams.toString()}`)
|
window.history.pushState(null, "", `${pathname}?${queryParams.toString()}`)
|
||||||
router.push(`select-bed?${queryParams}`)
|
router.push(`details?${queryParams}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -270,7 +270,7 @@ export default function Rooms({
|
|||||||
{rateSummary && (
|
{rateSummary && (
|
||||||
<form
|
<form
|
||||||
method="GET"
|
method="GET"
|
||||||
action={`select-bed?${searchParams}`}
|
action={`details?${searchParams}`}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
<RateSummary
|
<RateSummary
|
||||||
|
|||||||
@@ -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 { Input as AriaInput, Label as AriaLabel } from "react-aria-components"
|
||||||
|
|
||||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||||
@@ -12,10 +12,13 @@ const AriaInputWithLabel = forwardRef(function AriaInputWithLabelComponent(
|
|||||||
{ label, ...props }: AriaInputWithLabelProps,
|
{ label, ...props }: AriaInputWithLabelProps,
|
||||||
ref: ForwardedRef<HTMLInputElement>
|
ref: ForwardedRef<HTMLInputElement>
|
||||||
) {
|
) {
|
||||||
|
const uniqueId = useId()
|
||||||
|
const inputId = `${uniqueId}-${props.name}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AriaLabel className={styles.container} htmlFor={props.name}>
|
<AriaLabel className={styles.container} htmlFor={inputId}>
|
||||||
<Body asChild fontOnly>
|
<Body asChild fontOnly>
|
||||||
<AriaInput {...props} className={styles.input} ref={ref} />
|
<AriaInput {...props} className={styles.input} ref={ref} id={inputId} />
|
||||||
</Body>
|
</Body>
|
||||||
<Label required={!!props.required}>{label}</Label>
|
<Label required={!!props.required}>{label}</Label>
|
||||||
</AriaLabel>
|
</AriaLabel>
|
||||||
|
|||||||
@@ -23,20 +23,6 @@ export function details(lang) {
|
|||||||
return `${hotelreservation(lang)}/details`
|
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
|
* @param {Lang} lang
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -135,6 +135,7 @@
|
|||||||
"Contact our memberservice": "Contact our memberservice",
|
"Contact our memberservice": "Contact our memberservice",
|
||||||
"Contact us": "Contact us",
|
"Contact us": "Contact us",
|
||||||
"Continue": "Continue",
|
"Continue": "Continue",
|
||||||
|
"Continue to room {nextRoomNumber}": "Continue to room {nextRoomNumber}",
|
||||||
"Copied to clipboard": "Copied to clipboard",
|
"Copied to clipboard": "Copied to clipboard",
|
||||||
"Copy promotion code": "Copy promotion code",
|
"Copy promotion code": "Copy promotion code",
|
||||||
"Could not find requested resource": "Could not find requested resource",
|
"Could not find requested resource": "Could not find requested resource",
|
||||||
|
|||||||
@@ -82,8 +82,7 @@ const nextConfig = {
|
|||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// hotel (hotelId) param missing
|
// hotel (hotelId) param missing
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
source:
|
source: "/:lang/hotelreservation/details",
|
||||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
|
||||||
destination: "/:lang/hotelreservation/select-rate",
|
destination: "/:lang/hotelreservation/select-rate",
|
||||||
missing: [
|
missing: [
|
||||||
{
|
{
|
||||||
@@ -98,8 +97,7 @@ const nextConfig = {
|
|||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// hotel (hotelId) param has to be an integer
|
// hotel (hotelId) param has to be an integer
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
source:
|
source: "/:lang/hotelreservation/details",
|
||||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
|
||||||
destination: "/:lang/hotelreservation/select-rate",
|
destination: "/:lang/hotelreservation/select-rate",
|
||||||
missing: [
|
missing: [
|
||||||
{
|
{
|
||||||
@@ -114,8 +112,7 @@ const nextConfig = {
|
|||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// fromdate param missing
|
// fromdate param missing
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
source:
|
source: "/:lang/hotelreservation/details",
|
||||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
|
||||||
destination: "/:lang/hotelreservation/select-rate",
|
destination: "/:lang/hotelreservation/select-rate",
|
||||||
missing: [
|
missing: [
|
||||||
{
|
{
|
||||||
@@ -130,8 +127,7 @@ const nextConfig = {
|
|||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// fromdate param has to be a date
|
// fromdate param has to be a date
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
source:
|
source: "/:lang/hotelreservation/details",
|
||||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
|
||||||
destination: "/:lang/hotelreservation/select-rate",
|
destination: "/:lang/hotelreservation/select-rate",
|
||||||
missing: [
|
missing: [
|
||||||
{
|
{
|
||||||
@@ -146,8 +142,7 @@ const nextConfig = {
|
|||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// todate param missing
|
// todate param missing
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
source:
|
source: "/:lang/hotelreservation/details",
|
||||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
|
||||||
destination: "/:lang/hotelreservation/select-rate",
|
destination: "/:lang/hotelreservation/select-rate",
|
||||||
missing: [
|
missing: [
|
||||||
{
|
{
|
||||||
@@ -162,8 +157,7 @@ const nextConfig = {
|
|||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// todate param has to be a date
|
// todate param has to be a date
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
source:
|
source: "/:lang/hotelreservation/details",
|
||||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
|
||||||
destination: "/:lang/hotelreservation/select-rate",
|
destination: "/:lang/hotelreservation/select-rate",
|
||||||
missing: [
|
missing: [
|
||||||
{
|
{
|
||||||
@@ -178,8 +172,7 @@ const nextConfig = {
|
|||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// room[0].adults param missing
|
// room[0].adults param missing
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
source:
|
source: "/:lang/hotelreservation/details",
|
||||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
|
||||||
destination: "/:lang/hotelreservation/select-rate",
|
destination: "/:lang/hotelreservation/select-rate",
|
||||||
missing: [
|
missing: [
|
||||||
{
|
{
|
||||||
@@ -194,8 +187,7 @@ const nextConfig = {
|
|||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// room[0].adults param has to be an integer
|
// room[0].adults param has to be an integer
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
source:
|
source: "/:lang/hotelreservation/details",
|
||||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
|
||||||
destination: "/:lang/hotelreservation/select-rate",
|
destination: "/:lang/hotelreservation/select-rate",
|
||||||
missing: [
|
missing: [
|
||||||
{
|
{
|
||||||
@@ -210,8 +202,7 @@ const nextConfig = {
|
|||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// room[0].ratecode param missing
|
// room[0].ratecode param missing
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
source:
|
source: "/:lang/hotelreservation/details",
|
||||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
|
||||||
destination: "/:lang/hotelreservation/select-rate",
|
destination: "/:lang/hotelreservation/select-rate",
|
||||||
missing: [
|
missing: [
|
||||||
{
|
{
|
||||||
@@ -226,8 +217,7 @@ const nextConfig = {
|
|||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// room[0].roomtype param missing
|
// room[0].roomtype param missing
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
source:
|
source: "/:lang/hotelreservation/details",
|
||||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
|
||||||
destination: "/:lang/hotelreservation/select-rate",
|
destination: "/:lang/hotelreservation/select-rate",
|
||||||
missing: [
|
missing: [
|
||||||
{
|
{
|
||||||
@@ -278,11 +268,6 @@ const nextConfig = {
|
|||||||
source: `${myPages.sv}/:path*`,
|
source: `${myPages.sv}/:path*`,
|
||||||
destination: `/sv/my-pages/: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",
|
source: "/:lang/hotelreservation/payment-callback/:status",
|
||||||
destination:
|
destination:
|
||||||
|
|||||||
@@ -3,136 +3,74 @@ import { useEffect, useRef } from "react"
|
|||||||
|
|
||||||
import { createDetailsStore } from "@/stores/enter-details"
|
import { createDetailsStore } from "@/stores/enter-details"
|
||||||
import {
|
import {
|
||||||
calcTotalMemberPrice,
|
checkIsSameRoom,
|
||||||
calcTotalPublicPrice,
|
clearSessionStorage,
|
||||||
navigate,
|
readFromSessionStorage,
|
||||||
writeToSessionStorage,
|
|
||||||
} from "@/stores/enter-details/helpers"
|
} 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 { DetailsContext } from "@/contexts/Details"
|
||||||
|
|
||||||
import type { DetailsStore } from "@/types/contexts/enter-details"
|
import type { DetailsStore } from "@/types/contexts/enter-details"
|
||||||
import { StepEnum } from "@/types/enums/step"
|
|
||||||
import type { DetailsProviderProps } from "@/types/providers/enter-details"
|
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({
|
export default function EnterDetailsProvider({
|
||||||
bedTypes,
|
|
||||||
booking,
|
booking,
|
||||||
showBreakfastStep,
|
showBreakfastStep,
|
||||||
children,
|
children,
|
||||||
packages,
|
roomsData,
|
||||||
roomRate,
|
|
||||||
searchParamsStr,
|
searchParamsStr,
|
||||||
step,
|
|
||||||
user,
|
user,
|
||||||
vat,
|
vat,
|
||||||
}: DetailsProviderProps) {
|
}: DetailsProviderProps) {
|
||||||
const storeRef = useRef<DetailsStore>()
|
const storeRef = useRef<DetailsStore>()
|
||||||
|
|
||||||
if (!storeRef.current) {
|
if (!storeRef.current) {
|
||||||
const initialData: InitialState = { booking, packages, roomRate, vat }
|
const initialData: InitialState = {
|
||||||
if (bedTypes.length === 1) {
|
booking,
|
||||||
initialData.bedType = {
|
rooms: roomsData
|
||||||
description: bedTypes[0].description,
|
.filter((r) => r.bedTypes?.length) // TODO: how to handle room without bedtypes?
|
||||||
roomTypeCode: bedTypes[0].value,
|
.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) {
|
if (!showBreakfastStep) {
|
||||||
initialData.breakfast = false
|
initialData.breakfast = false
|
||||||
}
|
}
|
||||||
|
|
||||||
storeRef.current = createDetailsStore(
|
storeRef.current = createDetailsStore(initialData, searchParamsStr, user)
|
||||||
initialData,
|
|
||||||
step,
|
|
||||||
searchParamsStr,
|
|
||||||
user
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (storeRef.current) {
|
const storedValues = readFromSessionStorage()
|
||||||
storeRef.current.setState((state) => {
|
if (!storedValues) {
|
||||||
const newState: DetailsState = { ...state }
|
return
|
||||||
|
|
||||||
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 }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [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 (
|
return (
|
||||||
<DetailsContext.Provider value={storeRef.current}>
|
<DetailsContext.Provider value={storeRef.current}>
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import deepmerge from "deepmerge"
|
|
||||||
import isEqual from "fast-deep-equal"
|
import isEqual from "fast-deep-equal"
|
||||||
|
|
||||||
import { arrayMerge } from "@/utils/merge"
|
|
||||||
|
|
||||||
import { detailsStorageName } from "."
|
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 { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import type { StepEnum } from "@/types/enums/step"
|
import { StepEnum } from "@/types/enums/step"
|
||||||
import type {
|
import type {
|
||||||
DetailsState,
|
DetailsState,
|
||||||
PersistedState,
|
PersistedState,
|
||||||
PersistedStatePart,
|
RoomState,
|
||||||
RoomRate,
|
RoomStatus,
|
||||||
} from "@/types/stores/enter-details"
|
} from "@/types/stores/enter-details"
|
||||||
import type { SafeUser } from "@/types/user"
|
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(
|
export function checkIsSameRoom(
|
||||||
prev: SelectRateSearchParams,
|
prev: SelectRateSearchParams,
|
||||||
next: SelectRateSearchParams
|
next: SelectRateSearchParams
|
||||||
@@ -84,7 +79,7 @@ export function subtract(...nums: (number | string | undefined)[]) {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitialRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
||||||
if (isMember && roomRate.memberRate) {
|
if (isMember && roomRate.memberRate) {
|
||||||
return {
|
return {
|
||||||
perNight: {
|
perNight: {
|
||||||
@@ -134,158 +129,234 @@ export function getInitialRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitialTotalPrice(roomRate: RoomRate, isMember: boolean) {
|
type TotalPrice = {
|
||||||
if (isMember && roomRate.memberRate) {
|
requested: { currency: string; price: number } | undefined
|
||||||
return {
|
local: { currency: string; price: number }
|
||||||
requested: roomRate.memberRate.requestedPrice && {
|
}
|
||||||
currency: roomRate.memberRate.requestedPrice.currency,
|
|
||||||
price: roomRate.memberRate.requestedPrice.pricePerStay,
|
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: {
|
local: {
|
||||||
currency: roomRate.memberRate.localPrice.currency,
|
currency: roomRates[0].publicRate.localPrice.currency,
|
||||||
price: roomRate.memberRate.localPrice.pricePerStay,
|
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(
|
export function calcTotalPrice(
|
||||||
state: Pick<
|
rooms: RoomState[],
|
||||||
DetailsState,
|
totalPrice: Price,
|
||||||
"breakfast" | "packages" | "roomPrice" | "totalPrice"
|
isMember: boolean
|
||||||
> &
|
|
||||||
DetailsState["roomRate"]["publicRate"]
|
|
||||||
) {
|
) {
|
||||||
// state is sometimes read-only, thus we
|
return rooms.reduce<Price>(
|
||||||
// need to create a deep copy of the values
|
(acc, room, index) => {
|
||||||
const roomAndTotalPrice = {
|
const isFirstRoomAndMember = index === 0 && isMember
|
||||||
roomPrice: {
|
const join = Boolean(room.guest.join || room.guest.membershipNo)
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalPriceRequested = state.requestedPrice.pricePerStay
|
const roomPrice = getRoomPrice(
|
||||||
if (state.breakfast) {
|
room.roomRate,
|
||||||
totalPriceRequested = add(
|
isFirstRoomAndMember || join
|
||||||
totalPriceRequested,
|
|
||||||
state.breakfast.requestedPrice.totalPrice
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (state.packages) {
|
const breakfastRequestedPrice = room.breakfast
|
||||||
totalPriceRequested = state.packages.reduce((total, pkg) => {
|
? 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) {
|
if (pkg.requestedPrice.totalPrice) {
|
||||||
total = add(total, pkg.requestedPrice.totalPrice)
|
total = add(total, pkg.requestedPrice.totalPrice)
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
}, totalPriceRequested)
|
}, 0)
|
||||||
}
|
|
||||||
|
|
||||||
roomAndTotalPrice.totalPrice.requested = {
|
const result: Price = {
|
||||||
currency: state.requestedPrice.currency,
|
requested: roomPrice.perStay.requested
|
||||||
price: totalPriceRequested,
|
? {
|
||||||
}
|
currency: roomPrice.perStay.requested.currency,
|
||||||
}
|
price: add(
|
||||||
|
acc.requested?.price ?? 0,
|
||||||
const roomPriceLocal = state.localPrice
|
roomPrice.perStay.requested.price,
|
||||||
roomAndTotalPrice.roomPrice.perStay.local = {
|
breakfastRequestedPrice
|
||||||
currency: roomPriceLocal.currency,
|
),
|
||||||
price: roomPriceLocal.pricePerStay,
|
}
|
||||||
}
|
: undefined,
|
||||||
|
local: {
|
||||||
let totalPriceLocal = roomPriceLocal.pricePerStay
|
currency: roomPrice.perStay.local.currency,
|
||||||
if (state.breakfast) {
|
price: add(
|
||||||
totalPriceLocal = add(
|
acc.local.price,
|
||||||
totalPriceLocal,
|
roomPrice.perStay.local.price,
|
||||||
state.breakfast.localPrice.totalPrice
|
breakfastLocalPrice,
|
||||||
)
|
roomFeaturesTotal
|
||||||
}
|
),
|
||||||
|
},
|
||||||
if (state.packages) {
|
|
||||||
totalPriceLocal = state.packages.reduce((total, pkg) => {
|
|
||||||
if (pkg.localPrice.totalPrice) {
|
|
||||||
total = add(total, pkg.localPrice.totalPrice)
|
|
||||||
}
|
}
|
||||||
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) {
|
export const selectRoomStatus = (state: DetailsState, index?: number) =>
|
||||||
const unparsedData = sessionStorage.getItem(detailsStorageName)
|
state.bookingProgress.roomStatuses[
|
||||||
if (unparsedData) {
|
index ?? state.bookingProgress.currentRoomIndex
|
||||||
const data: PersistedState = JSON.parse(unparsedData)
|
]
|
||||||
// @ts-expect-error - deepmerge is not to happy with
|
|
||||||
// the part type
|
export const selectRoom = (state: DetailsState, index?: number) =>
|
||||||
const updated = deepmerge(data, part, { arrayMerge })
|
state.rooms[index ?? state.bookingProgress.currentRoomIndex]
|
||||||
sessionStorage.setItem(detailsStorageName, JSON.stringify(updated))
|
|
||||||
} else {
|
export const selectRoomSteps = (state: DetailsState, index?: number) =>
|
||||||
sessionStorage.setItem(detailsStorageName, JSON.stringify(part))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,22 +7,23 @@ import { DetailsContext } from "@/contexts/Details"
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
calcTotalMemberPrice,
|
calcTotalPrice,
|
||||||
calcTotalPublicPrice,
|
checkRoomProgress,
|
||||||
checkIsSameRoom,
|
|
||||||
extractGuestFromUser,
|
extractGuestFromUser,
|
||||||
getInitialRoomPrice,
|
getRoomPrice,
|
||||||
getInitialTotalPrice,
|
getTotalPrice,
|
||||||
navigate,
|
handleStepProgression,
|
||||||
|
selectRoom,
|
||||||
|
selectRoomStatus,
|
||||||
writeToSessionStorage,
|
writeToSessionStorage,
|
||||||
} from "./helpers"
|
} from "./helpers"
|
||||||
|
|
||||||
import { StepEnum } from "@/types/enums/step"
|
import { StepEnum } from "@/types/enums/step"
|
||||||
import type {
|
import type {
|
||||||
DetailsState,
|
DetailsState,
|
||||||
FormValues,
|
|
||||||
InitialState,
|
InitialState,
|
||||||
PersistedState,
|
RoomState,
|
||||||
|
RoomStatus,
|
||||||
} from "@/types/stores/enter-details"
|
} from "@/types/stores/enter-details"
|
||||||
import type { SafeUser } from "@/types/user"
|
import type { SafeUser } from "@/types/user"
|
||||||
|
|
||||||
@@ -38,109 +39,128 @@ const defaultGuestState = {
|
|||||||
zipCode: "",
|
zipCode: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const detailsStorageName = "details-storage"
|
export const detailsStorageName = "rooms-details-storage"
|
||||||
|
|
||||||
export function createDetailsStore(
|
export function createDetailsStore(
|
||||||
initialState: InitialState,
|
initialState: InitialState,
|
||||||
currentStep: StepEnum,
|
|
||||||
searchParams: string,
|
searchParams: string,
|
||||||
user: SafeUser
|
user: SafeUser
|
||||||
) {
|
) {
|
||||||
const isMember = !!user
|
const isMember = !!user
|
||||||
|
|
||||||
const formValues: FormValues = {
|
const initialTotalPrice = getTotalPrice(
|
||||||
bedType: initialState.bedType,
|
initialState.rooms.map((r) => r.roomRate),
|
||||||
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,
|
|
||||||
isMember
|
isMember
|
||||||
)
|
)
|
||||||
|
|
||||||
if (initialState.packages) {
|
initialState.rooms.forEach((room) => {
|
||||||
initialState.packages.forEach((pkg) => {
|
if (room.roomFeatures) {
|
||||||
if (initialTotalPrice.requested) {
|
room.roomFeatures.forEach((pkg) => {
|
||||||
initialTotalPrice.requested.price = add(
|
if (initialTotalPrice.requested) {
|
||||||
initialTotalPrice.requested.price,
|
initialTotalPrice.requested.price = add(
|
||||||
pkg.requestedPrice.totalPrice
|
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: {
|
actions: {
|
||||||
completeStep() {
|
setStep(step: StepEnum | null, roomIndex?: number) {
|
||||||
|
if (!step) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return set(
|
return set(
|
||||||
produce((state: DetailsState) => {
|
produce((state: DetailsState) => {
|
||||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
const currentRoomIndex =
|
||||||
const nextStep = state.steps[currentStepIndex + 1]
|
roomIndex ?? state.bookingProgress.currentRoomIndex
|
||||||
state.currentStep = nextStep
|
|
||||||
navigate(nextStep, state.searchParamString)
|
const arePreviousRoomsCompleted = state.bookingProgress.roomStatuses
|
||||||
})
|
.slice(0, currentRoomIndex)
|
||||||
)
|
.every((room) => room.isComplete)
|
||||||
},
|
|
||||||
navigate(step: StepEnum) {
|
const roomStatus = selectRoomStatus(state, roomIndex)
|
||||||
return set(
|
const roomStep = roomStatus.steps[step]
|
||||||
produce((state) => {
|
|
||||||
state.currentStep = step
|
if (arePreviousRoomsCompleted && roomStep?.isValid) {
|
||||||
navigate(step, state.searchParamString)
|
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) {
|
setTotalPrice(totalPrice) {
|
||||||
return set(
|
return set(
|
||||||
produce((state: DetailsState) => {
|
produce((state: DetailsState) => {
|
||||||
@@ -183,29 +196,39 @@ export function createDetailsStore(
|
|||||||
updateBedType(bedType) {
|
updateBedType(bedType) {
|
||||||
return set(
|
return set(
|
||||||
produce((state: DetailsState) => {
|
produce((state: DetailsState) => {
|
||||||
state.isValid["select-bed"] = true
|
const roomStatus = selectRoomStatus(state)
|
||||||
state.bedType = bedType
|
roomStatus.steps[StepEnum.selectBed].isValid = true
|
||||||
|
|
||||||
writeToSessionStorage({ bedType })
|
const room = selectRoom(state)
|
||||||
|
room.bedType = bedType
|
||||||
|
|
||||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
handleStepProgression(state)
|
||||||
const nextStep = state.steps[currentStepIndex + 1]
|
|
||||||
state.currentStep = nextStep
|
writeToSessionStorage({
|
||||||
navigate(nextStep, state.searchParamString)
|
booking: state.booking,
|
||||||
|
rooms: state.rooms,
|
||||||
|
bookingProgress: state.bookingProgress,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
updateBreakfast(breakfast) {
|
updateBreakfast(breakfast) {
|
||||||
return set(
|
return set(
|
||||||
produce((state: DetailsState) => {
|
produce((state: DetailsState) => {
|
||||||
state.isValid.breakfast = true
|
const roomStatus = selectRoomStatus(state)
|
||||||
|
if (roomStatus.steps[StepEnum.breakfast]) {
|
||||||
|
roomStatus.steps[StepEnum.breakfast].isValid = true
|
||||||
|
}
|
||||||
|
|
||||||
const stateTotalRequestedPrice =
|
const stateTotalRequestedPrice =
|
||||||
state.totalPrice.requested?.price || 0
|
state.totalPrice.requested?.price || 0
|
||||||
|
|
||||||
const stateTotalLocalPrice = state.totalPrice.local.price
|
const stateTotalLocalPrice = state.totalPrice.local.price
|
||||||
|
|
||||||
const addToTotalPrice =
|
const addToTotalPrice =
|
||||||
(state.breakfast === undefined || state.breakfast === false) &&
|
(state.breakfast === undefined || state.breakfast === false) &&
|
||||||
!!breakfast
|
!!breakfast
|
||||||
|
|
||||||
const subtractFromTotalPrice =
|
const subtractFromTotalPrice =
|
||||||
(state.breakfast === undefined || state.breakfast) &&
|
(state.breakfast === undefined || state.breakfast) &&
|
||||||
breakfast === false
|
breakfast === false
|
||||||
@@ -267,50 +290,64 @@ export function createDetailsStore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.breakfast = breakfast
|
const room = selectRoom(state)
|
||||||
writeToSessionStorage({ breakfast })
|
room.breakfast = breakfast
|
||||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
|
||||||
const nextStep = state.steps[currentStepIndex + 1]
|
handleStepProgression(state)
|
||||||
state.currentStep = nextStep
|
|
||||||
navigate(nextStep, state.searchParamString)
|
writeToSessionStorage({
|
||||||
|
booking: state.booking,
|
||||||
|
rooms: state.rooms,
|
||||||
|
bookingProgress: state.bookingProgress,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
updateDetails(data) {
|
updateDetails(data) {
|
||||||
return set(
|
return set(
|
||||||
produce((state: DetailsState) => {
|
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) {
|
if (data.join) {
|
||||||
state.guest.membershipNo = undefined
|
room.guest.membershipNo = undefined
|
||||||
} else {
|
} else {
|
||||||
state.guest.membershipNo = data.membershipNo
|
room.guest.membershipNo = data.membershipNo
|
||||||
}
|
}
|
||||||
state.guest.phoneNumber = data.phoneNumber
|
room.guest.phoneNumber = data.phoneNumber
|
||||||
state.guest.zipCode = data.zipCode
|
room.guest.zipCode = data.zipCode
|
||||||
|
|
||||||
if (data.join || data.membershipNo || isMember) {
|
room.roomPrice = getRoomPrice(
|
||||||
const memberPrice = calcTotalMemberPrice(state)
|
room.roomRate,
|
||||||
state.roomPrice = memberPrice.roomPrice
|
Boolean(data.join || data.membershipNo || isMember)
|
||||||
state.totalPrice = memberPrice.totalPrice
|
)
|
||||||
} else {
|
|
||||||
const publicPrice = calcTotalPublicPrice(state)
|
state.totalPrice = calcTotalPrice(
|
||||||
state.roomPrice = publicPrice.roomPrice
|
state.rooms,
|
||||||
state.totalPrice = publicPrice.totalPrice
|
state.totalPrice,
|
||||||
|
isMember
|
||||||
|
)
|
||||||
|
|
||||||
|
const isAllStepsCompleted = checkRoomProgress(state)
|
||||||
|
if (isAllStepsCompleted) {
|
||||||
|
roomStatus.isComplete = true
|
||||||
}
|
}
|
||||||
|
|
||||||
writeToSessionStorage({ guest: data })
|
handleStepProgression(state)
|
||||||
|
|
||||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
writeToSessionStorage({
|
||||||
const nextStep = state.steps[currentStepIndex + 1]
|
booking: state.booking,
|
||||||
state.currentStep = nextStep
|
rooms: state.rooms,
|
||||||
navigate(nextStep, state.searchParamString)
|
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,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import {
|
|||||||
bedType,
|
bedType,
|
||||||
booking,
|
booking,
|
||||||
breakfastPackage,
|
breakfastPackage,
|
||||||
|
guestDetailsMember,
|
||||||
guestDetailsNonMember,
|
guestDetailsNonMember,
|
||||||
|
roomPrice,
|
||||||
roomRate,
|
roomRate,
|
||||||
} from "@/__mocks__/hotelReservation"
|
} from "@/__mocks__/hotelReservation"
|
||||||
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
|
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
|
||||||
|
|
||||||
|
import { selectRoom, selectRoomStatus } from "./helpers"
|
||||||
import { detailsStorageName, useEnterDetailsStore } from "."
|
import { detailsStorageName, useEnterDetailsStore } from "."
|
||||||
|
|
||||||
import { StepEnum } from "@/types/enums/step"
|
import { StepEnum } from "@/types/enums/step"
|
||||||
@@ -31,22 +34,60 @@ jest.mock("@/lib/api", () => ({
|
|||||||
fetchRetry: jest.fn((fn) => fn),
|
fetchRetry: jest.fn((fn) => fn),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function Wrapper({ children }: PropsWithChildren) {
|
interface CreateWrapperParams {
|
||||||
return (
|
showBreakfastStep?: boolean
|
||||||
<EnterDetailsProvider
|
breakfastIncluded?: boolean
|
||||||
bedTypes={[bedType.king, bedType.queen]}
|
mustBeGuaranteed?: boolean
|
||||||
booking={booking}
|
onlyOneBedType?: boolean
|
||||||
showBreakfastStep={true}
|
}
|
||||||
packages={null}
|
|
||||||
roomRate={roomRate}
|
function createWrapper(params: Partial<CreateWrapperParams> = {}) {
|
||||||
searchParamsStr=""
|
const {
|
||||||
step={StepEnum.selectBed}
|
showBreakfastStep = true,
|
||||||
user={null}
|
breakfastIncluded = false,
|
||||||
vat={0}
|
mustBeGuaranteed = false,
|
||||||
>
|
onlyOneBedType = false,
|
||||||
{children}
|
} = params
|
||||||
</EnterDetailsProvider>
|
|
||||||
)
|
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", () => {
|
describe("Enter Details Store", () => {
|
||||||
@@ -58,27 +99,84 @@ describe("Enter Details Store", () => {
|
|||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useEnterDetailsStore((state) => state),
|
() => useEnterDetailsStore((state) => state),
|
||||||
{
|
{
|
||||||
wrapper: Wrapper,
|
wrapper: createWrapper(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const state = result.current
|
const state = result.current
|
||||||
|
|
||||||
expect(state.currentStep).toBe(StepEnum.selectBed)
|
|
||||||
expect(state.booking).toEqual(booking)
|
expect(state.booking).toEqual(booking)
|
||||||
expect(state.bedType).toEqual(undefined)
|
|
||||||
expect(state.breakfast).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 = {
|
const storage: PersistedState = {
|
||||||
bedType: {
|
booking: booking,
|
||||||
roomTypeCode: bedType.queen.value,
|
bookingProgress: {
|
||||||
description: bedType.queen.description,
|
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,
|
rooms: [
|
||||||
booking,
|
{
|
||||||
guest: guestDetailsNonMember,
|
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))
|
window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage))
|
||||||
@@ -86,26 +184,57 @@ describe("Enter Details Store", () => {
|
|||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useEnterDetailsStore((state) => state),
|
() => useEnterDetailsStore((state) => state),
|
||||||
{
|
{
|
||||||
wrapper: Wrapper,
|
wrapper: createWrapper(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const state = result.current
|
|
||||||
|
|
||||||
expect(state.bedType).toEqual(storage.bedType)
|
expect(result.current.booking).toEqual(storage.booking)
|
||||||
expect(state.guest).toEqual(storage.guest)
|
expect(result.current.rooms[0]).toEqual(storage.rooms[0])
|
||||||
expect(state.booking).toEqual(storage.booking)
|
expect(result.current.bookingProgress).toEqual(storage.bookingProgress)
|
||||||
expect(state.breakfast).toEqual(storage.breakfast)
|
})
|
||||||
|
|
||||||
|
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 () => {
|
test("complete step and navigate to next step", async () => {
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useEnterDetailsStore((state) => state),
|
() => 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 () => {
|
await act(async () => {
|
||||||
result.current.actions.updateBedType({
|
result.current.actions.updateBedType({
|
||||||
@@ -114,24 +243,221 @@ describe("Enter Details Store", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.current.isValid[StepEnum.selectBed]).toEqual(true)
|
roomStatus = selectRoomStatus(result.current)
|
||||||
expect(result.current.currentStep).toEqual(StepEnum.breakfast)
|
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
|
||||||
expect(window.location.pathname.slice(1)).toBe(StepEnum.breakfast)
|
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
result.current.actions.updateBreakfast(breakfastPackage)
|
result.current.actions.updateBreakfast(breakfastPackage)
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.current.isValid[StepEnum.breakfast]).toEqual(true)
|
roomStatus = selectRoomStatus(result.current)
|
||||||
expect(result.current.currentStep).toEqual(StepEnum.details)
|
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toEqual(true)
|
||||||
expect(window.location.pathname.slice(1)).toBe(StepEnum.details)
|
expect(roomStatus.currentStep).toEqual(StepEnum.details)
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
result.current.actions.updateDetails(guestDetailsNonMember)
|
result.current.actions.updateDetails(guestDetailsNonMember)
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.current.isValid[StepEnum.details]).toEqual(true)
|
expect(result.current.bookingProgress.canProceedToPayment).toBe(false)
|
||||||
expect(result.current.currentStep).toEqual(StepEnum.payment)
|
|
||||||
expect(window.location.pathname.slice(1)).toBe(StepEnum.payment)
|
// 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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import type { z } from "zod"
|
import type { z } from "zod"
|
||||||
|
|
||||||
|
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
import type { SafeUser } from "@/types/user"
|
import type { SafeUser } from "@/types/user"
|
||||||
import type {
|
import type {
|
||||||
guestDetailsSchema,
|
guestDetailsSchema,
|
||||||
signedInDetailsSchema,
|
signedInDetailsSchema,
|
||||||
} from "@/components/HotelReservation/EnterDetails/Details/schema"
|
} from "@/components/HotelReservation/EnterDetails/Details/schema"
|
||||||
|
import type { Price } from "../price"
|
||||||
|
|
||||||
export type DetailsSchema = z.output<typeof guestDetailsSchema>
|
export type DetailsSchema = z.output<typeof guestDetailsSchema>
|
||||||
export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>
|
export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>
|
||||||
|
|
||||||
|
export interface RoomPrice {
|
||||||
|
perNight: Price
|
||||||
|
perStay: Price
|
||||||
|
}
|
||||||
|
|
||||||
type MemberPrice = {
|
type MemberPrice = {
|
||||||
currency: string
|
currency: string
|
||||||
price: number
|
price: number
|
||||||
@@ -23,3 +30,8 @@ export type JoinScandicFriendsCardProps = {
|
|||||||
name: string
|
name: string
|
||||||
memberPrice?: MemberPrice
|
memberPrice?: MemberPrice
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RoomRate = {
|
||||||
|
publicRate: Product["productType"]["public"]
|
||||||
|
memberRate?: Product["productType"]["member"]
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailabil
|
|||||||
|
|
||||||
export interface SelectedRoomProps {
|
export interface SelectedRoomProps {
|
||||||
hotelId: string
|
hotelId: string
|
||||||
|
roomType: string
|
||||||
|
roomTypeCode: string
|
||||||
rateDescription: string
|
rateDescription: string
|
||||||
room: RoomConfiguration
|
|
||||||
}
|
}
|
||||||
|
|||||||
9
types/components/hotelReservation/price.ts
Normal file
9
types/components/hotelReservation/price.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
interface TPrice {
|
||||||
|
currency: string
|
||||||
|
price: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Price {
|
||||||
|
requested: TPrice | undefined
|
||||||
|
local: TPrice
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Price } from "@/types/stores/enter-details"
|
|
||||||
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
|
import type { Price } from "../price"
|
||||||
import type { RoomPackages } from "./roomFilter"
|
import type { RoomPackages } from "./roomFilter"
|
||||||
import type { SelectRateSearchParams } from "./selectRate"
|
import type { SelectRateSearchParams } from "./selectRate"
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export interface DetailsProps extends SectionProps {}
|
|||||||
|
|
||||||
export interface PaymentProps {
|
export interface PaymentProps {
|
||||||
user: SafeUser
|
user: SafeUser
|
||||||
roomPrice: { publicPrice: number; memberPrice: number | undefined }
|
|
||||||
otherPaymentOptions: PaymentMethodEnum[]
|
otherPaymentOptions: PaymentMethodEnum[]
|
||||||
mustBeGuaranteed: boolean
|
mustBeGuaranteed: boolean
|
||||||
supportedCards: PaymentMethodEnum[]
|
supportedCards: PaymentMethodEnum[]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { StepEnum } from "@/types/enums/step"
|
import type { StepEnum } from "@/types/enums/step"
|
||||||
|
|
||||||
export interface SectionAccordionProps {
|
export interface SectionAccordionProps {
|
||||||
header: string
|
header: string
|
||||||
label: string
|
label: string
|
||||||
step: StepEnum
|
step: StepEnum
|
||||||
|
roomIndex: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,46 @@
|
|||||||
import type { DetailsProviderProps } from "@/types/providers/enter-details"
|
|
||||||
import type { Packages } from "@/types/requests/packages"
|
import type { Packages } from "@/types/requests/packages"
|
||||||
import type {
|
import type { RoomState } from "@/types/stores/enter-details"
|
||||||
DetailsState,
|
import type { RoomPrice, RoomRate } from "./enterDetails/details"
|
||||||
Price,
|
import type { Price } from "./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 { Child, SelectRateSearchParams } from "./selectRate/selectRate"
|
import type { Child, SelectRateSearchParams } from "./selectRate/selectRate"
|
||||||
|
|
||||||
export type RoomsData = Pick<DetailsState, "roomPrice"> &
|
export type RoomsData = {
|
||||||
Pick<RoomAvailability, "cancellationText" | "rateDetails"> &
|
rateDetails: string[] | undefined
|
||||||
Pick<RoomAvailability["selectedRoom"], "roomType"> & {
|
roomType: string
|
||||||
adults: number
|
cancellationText: string
|
||||||
children?: Child[]
|
roomPrice: RoomPrice
|
||||||
packages: Packages | null
|
adults: number
|
||||||
}
|
children?: Child[]
|
||||||
|
packages: Packages | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface SummaryProps
|
export interface SummaryProps {
|
||||||
extends Pick<RoomAvailability, "cancellationText" | "rateDetails">,
|
|
||||||
Pick<RoomAvailability["selectedRoom"], "roomType"> {
|
|
||||||
isMember: boolean
|
isMember: boolean
|
||||||
breakfastIncluded: boolean
|
breakfastIncluded: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SummaryUIProps {
|
export interface SummaryUIProps {
|
||||||
booking: SelectRateSearchParams
|
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: {
|
rooms: {
|
||||||
adults: number
|
adults: number
|
||||||
childrenInRoom: Child[] | undefined
|
childrenInRoom: Child[] | undefined
|
||||||
bedType: BedTypeSchema | undefined
|
|
||||||
breakfast: BreakfastPackage | false | undefined
|
|
||||||
guest: DetailsSchema | undefined
|
|
||||||
roomRate: DetailsProviderProps["roomRate"]
|
|
||||||
roomPrice: RoomPrice
|
|
||||||
roomType: string
|
roomType: string
|
||||||
|
roomPrice: RoomPrice
|
||||||
|
roomRate: RoomRate
|
||||||
rateDetails: string[] | undefined
|
rateDetails: string[] | undefined
|
||||||
cancellationText: string
|
cancellationText: string
|
||||||
}[]
|
}[]
|
||||||
isMember: boolean
|
|
||||||
breakfastIncluded: boolean
|
|
||||||
packages: Packages | null
|
|
||||||
totalPrice: Price
|
|
||||||
vat: number
|
|
||||||
toggleSummaryOpen: () => void
|
|
||||||
togglePriceDetailsModalOpen: () => void
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,4 @@ export enum StepEnum {
|
|||||||
selectBed = "select-bed",
|
selectBed = "select-bed",
|
||||||
breakfast = "breakfast",
|
breakfast = "breakfast",
|
||||||
details = "details",
|
details = "details",
|
||||||
payment = "payment",
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
|
|||||||
import type { StepEnum } from "@/types/enums/step"
|
import type { StepEnum } from "@/types/enums/step"
|
||||||
import type { RoomAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
import type { RoomAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
import type { SafeUser } from "@/types/user"
|
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 { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate"
|
||||||
import type { Packages } from "../requests/packages"
|
import type { Packages } from "../requests/packages"
|
||||||
|
|
||||||
export interface DetailsProviderProps extends React.PropsWithChildren {
|
export interface DetailsProviderProps extends React.PropsWithChildren {
|
||||||
booking: SelectRateSearchParams
|
booking: SelectRateSearchParams
|
||||||
bedTypes: BedTypeSelection[]
|
|
||||||
showBreakfastStep: boolean
|
showBreakfastStep: boolean
|
||||||
packages: Packages | null
|
roomsData: RoomData[]
|
||||||
roomRate: Pick<RoomAvailability, "memberRate" | "publicRate">
|
|
||||||
searchParamsStr: string
|
searchParamsStr: string
|
||||||
step: StepEnum
|
|
||||||
user: SafeUser
|
user: SafeUser
|
||||||
vat: number
|
vat: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,47 @@ import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDet
|
|||||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||||
import type {
|
import type {
|
||||||
DetailsSchema,
|
DetailsSchema,
|
||||||
|
RoomPrice,
|
||||||
|
RoomRate,
|
||||||
SignedInDetailsSchema,
|
SignedInDetailsSchema,
|
||||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||||
import type { StepEnum } from "@/types/enums/step"
|
import type { StepEnum } from "@/types/enums/step"
|
||||||
import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate"
|
import type { Price } from "../components/hotelReservation/price"
|
||||||
import type { DetailsProviderProps } from "../providers/enter-details"
|
import type {
|
||||||
|
Child,
|
||||||
|
SelectRateSearchParams,
|
||||||
|
} from "../components/hotelReservation/selectRate/selectRate"
|
||||||
import type { Packages } from "../requests/packages"
|
import type { Packages } from "../requests/packages"
|
||||||
|
|
||||||
interface TPrice {
|
export interface InitialRoomData {
|
||||||
currency: string
|
roomRate: RoomRate
|
||||||
price: number
|
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 {
|
export interface RoomState extends InitialRoomData {
|
||||||
perNight: Price
|
adults: number
|
||||||
perStay: Price
|
childrenInRoom: Child[] | undefined
|
||||||
}
|
|
||||||
|
|
||||||
export interface Price {
|
|
||||||
requested: TPrice | undefined
|
|
||||||
local: TPrice
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormValues {
|
|
||||||
bedType: BedTypeSchema | undefined
|
bedType: BedTypeSchema | undefined
|
||||||
booking: SelectRateSearchParams
|
|
||||||
breakfast: BreakfastPackage | false | undefined
|
breakfast: BreakfastPackage | false | undefined
|
||||||
guest: DetailsSchema | SignedInDetailsSchema
|
guest: DetailsSchema | SignedInDetailsSchema
|
||||||
|
roomPrice: RoomPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InitialState = {
|
||||||
|
booking: SelectRateSearchParams
|
||||||
|
vat: number
|
||||||
|
rooms: InitialRoomData[]
|
||||||
|
breakfast?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetailsState {
|
export interface DetailsState {
|
||||||
actions: {
|
actions: {
|
||||||
completeStep: () => void
|
setStep: (step: StepEnum | null, roomIndex?: number) => void
|
||||||
navigate: (step: StepEnum) => void
|
|
||||||
setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void
|
setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void
|
||||||
setStep: (step: StepEnum) => void
|
|
||||||
setTotalPrice: (totalPrice: Price) => void
|
setTotalPrice: (totalPrice: Price) => void
|
||||||
toggleSummaryOpen: () => void
|
toggleSummaryOpen: () => void
|
||||||
togglePriceDetailsModalOpen: () => void
|
togglePriceDetailsModalOpen: () => void
|
||||||
@@ -45,40 +51,42 @@ export interface DetailsState {
|
|||||||
updateDetails: (data: DetailsSchema) => void
|
updateDetails: (data: DetailsSchema) => void
|
||||||
updateSeachParamString: (searchParamString: string) => void
|
updateSeachParamString: (searchParamString: string) => void
|
||||||
}
|
}
|
||||||
bedType: BedTypeSchema | undefined
|
|
||||||
booking: SelectRateSearchParams
|
booking: SelectRateSearchParams
|
||||||
breakfast: BreakfastPackage | false | undefined
|
breakfast: BreakfastPackage | false | undefined
|
||||||
currentStep: StepEnum
|
|
||||||
formValues: FormValues
|
|
||||||
guest: DetailsSchema
|
|
||||||
isSubmittingDisabled: boolean
|
isSubmittingDisabled: boolean
|
||||||
isSummaryOpen: boolean
|
isSummaryOpen: boolean
|
||||||
isPriceDetailsModalOpen: boolean
|
isPriceDetailsModalOpen: boolean
|
||||||
isValid: Record<StepEnum, boolean>
|
rooms: RoomState[]
|
||||||
packages: Packages | null
|
|
||||||
roomRate: DetailsProviderProps["roomRate"]
|
|
||||||
roomPrice: RoomPrice
|
|
||||||
steps: StepEnum[]
|
|
||||||
totalPrice: Price
|
totalPrice: Price
|
||||||
searchParamString: string
|
searchParamString: string
|
||||||
vat: number
|
vat: number
|
||||||
|
bookingProgress: BookingProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InitialState = Pick<DetailsState, "booking" | "packages"> &
|
export type PersistedState = {
|
||||||
Pick<DetailsProviderProps, "roomRate" | "vat"> & {
|
booking: SelectRateSearchParams
|
||||||
bedType?: BedTypeSchema
|
bookingProgress: BookingProgress
|
||||||
breakfast?: false
|
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 BookingProgress = {
|
||||||
|
currentRoomIndex: number
|
||||||
export type PersistedState = Pick<
|
roomStatuses: RoomStatus[]
|
||||||
DetailsState,
|
canProceedToPayment: boolean
|
||||||
"bedType" | "booking" | "breakfast" | "guest"
|
}
|
||||||
>
|
|
||||||
|
|
||||||
export type PersistedStatePart =
|
|
||||||
| Pick<DetailsState, "bedType">
|
|
||||||
| Pick<DetailsState, "booking">
|
|
||||||
| Pick<DetailsState, "breakfast">
|
|
||||||
| Pick<DetailsState, "guest">
|
|
||||||
|
|||||||
Reference in New Issue
Block a user