diff --git a/__mocks__/hotelReservation/index.ts b/__mocks__/hotelReservation/index.ts index 7d820b749..eb095b4c0 100644 --- a/__mocks__/hotelReservation/index.ts +++ b/__mocks__/hotelReservation/index.ts @@ -5,13 +5,14 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" import type { DetailsSchema, + RoomPrice, + RoomRate, SignedInDetailsSchema, } from "@/types/components/hotelReservation/enterDetails/details" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import { CurrencyEnum } from "@/types/enums/currency" import { PackageTypeEnum } from "@/types/enums/packages" -import type { RoomPrice, RoomRate } from "@/types/stores/enter-details" export const booking: SelectRateSearchParams = { city: "Stockholm", @@ -27,6 +28,14 @@ export const booking: SelectRateSearchParams = { childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }], packages: [RoomPackageCodeEnum.PET_ROOM], }, + { + adults: 2, + roomTypeCode: "", + rateCode: "", + counterRateCode: "", + childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }], + packages: [RoomPackageCodeEnum.PET_ROOM], + }, ], } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx index 2b91bcc19..a47196d51 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx @@ -6,7 +6,7 @@ import { } from "@/constants/booking" import { bookingConfirmation, - payment, + details, } from "@/constants/routes/hotelReservation" import { serverClient } from "@/lib/trpc/server" @@ -38,7 +38,7 @@ export default async function PaymentCallbackPage({ redirect(confirmationUrl) } - const returnUrl = payment(lang) + const returnUrl = details(lang) const searchObject = new URLSearchParams() let errorMessage = undefined diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.module.css similarity index 83% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.module.css rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.module.css index a9e612141..9d3decbfb 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.module.css @@ -6,6 +6,13 @@ .content { width: var(--max-width-page); margin: var(--Spacing-x3) auto 0; + display: flex; + flex-direction: column; + gap: var(--Spacing-x4); +} + +.header { + padding-bottom: var(--Spacing-x3); } .summary { diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx new file mode 100644 index 000000000..1e4ae867e --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx @@ -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) { + setLang(lang) + + const intl = await getIntl() + const selectRoomParams = new URLSearchParams(searchParams) + const booking = convertSearchParamsToObj(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 ( + +
+ +
+
+ {roomsData.map((room, idx) => ( +
+ {roomsData.length > 1 && ( +
+ + {intl.formatMessage({ id: "Room" })} {idx + 1} + +
+ )} + + + {room.bedTypes ? ( + + + + ) : null} + + {showBreakfastStep ? ( + + + + ) : null} + + +
+ +
+ ))} + + + +
+ +
+
+
+ ) +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsTracking.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsTracking.tsx index 2868a6f2c..60da0031b 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsTracking.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsTracking.tsx @@ -3,6 +3,7 @@ import { usePathname } from "next/navigation" import { useEffect, useMemo, useRef } from "react" import { useEnterDetailsStore } from "@/stores/enter-details" +import { selectRoom } from "@/stores/enter-details/helpers" import { useSessionId } from "@/hooks/useSessionId" import { createSDKPageObject, trackPageView } from "@/utils/tracking" @@ -36,8 +37,11 @@ export default function EnterDetailsTracking(props: Props) { cancellationRule, } = props - const { bedType, breakfast, totalPrice, roomPrice, roomRate, packages } = - useEnterDetailsStore((state) => state) + const { bedType, breakfast, roomPrice, roomRate, roomFeatures } = + useEnterDetailsStore(selectRoom) + + const totalPrice = useEnterDetailsStore((state) => state.totalPrice) + const pathName = usePathname() const sessionId = useSessionId() @@ -128,7 +132,7 @@ export default function EnterDetailsTracking(props: Props) { revenueCurrencyCode: totalPrice.local?.currency, breakfastOption: breakfast ? "breakfast buffet" : "no breakfast", totalPrice: totalPrice.local?.price, - specialRoomType: getSpecialRoomType(packages), + specialRoomType: getSpecialRoomType(roomFeatures), roomTypeName: selectedRoom.roomType, bedType: bedType?.description, roomTypeCode: bedType?.roomTypeCode, @@ -148,7 +152,7 @@ export default function EnterDetailsTracking(props: Props) { totalPrice, roomPrice, roomRate, - packages, + roomFeatures, initialHotelsTrackingData, cancellationRule, selectedRoom.roomType, diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx deleted file mode 100644 index 6801f6b5b..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx +++ /dev/null @@ -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) { - 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(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 ( - -
- -
-
-
- - - - {/* TODO: How to handle no beds found? */} - {roomAvailability.bedTypes ? ( - - - - ) : null} - - {showBreakfastStep ? ( - - - - ) : null} - - -
- - - - - - - -
-
- -
-
- -
- ) -} diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index 064eb47a3..3a6c44767 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -10,6 +10,7 @@ import { type ExtraBedTypeEnum, } from "@/constants/booking" import { useEnterDetailsStore } from "@/stores/enter-details" +import { selectRoom } from "@/stores/enter-details/helpers" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" @@ -24,10 +25,12 @@ import type { } from "@/types/components/hotelReservation/enterDetails/bedType" import type { IconProps } from "@/types/components/icon" -export default function BedType({ bedTypes }: BedTypeProps) { - const initialBedType = useEnterDetailsStore( - (state) => state.formValues?.bedType?.roomTypeCode - ) +export default function BedType({ + bedTypes, + roomIndex, +}: BedTypeProps & { roomIndex: number }) { + const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex)) + const initialBedType = room.bedType?.roomTypeCode const updateBedType = useEnterDetailsStore( (state) => state.actions.updateBedType @@ -57,6 +60,12 @@ export default function BedType({ bedTypes }: BedTypeProps) { [bedTypes, updateBedType] ) + useEffect(() => { + if (initialBedType) { + methods.setValue("bedType", initialBedType) + } + }, [initialBedType, methods]) + useEffect(() => { if (methods.formState.isSubmitting) { return @@ -109,9 +118,13 @@ function BedIconRenderer({ extraBedType: ExtraBedTypeEnum | undefined props: IconProps }) { - const MainBedIcon = BED_TYPE_ICONS[mainBedType] + const MainBedIcon = BED_TYPE_ICONS[mainBedType] ?? BED_TYPE_ICONS.Other const ExtraBedIcon = extraBedType ? BED_TYPE_ICONS[extraBedType] : null + if (!MainBedIcon) { + return null + } + return (
diff --git a/components/HotelReservation/EnterDetails/Breakfast/index.tsx b/components/HotelReservation/EnterDetails/Breakfast/index.tsx index 2da0664e7..fd1f62cca 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/index.tsx +++ b/components/HotelReservation/EnterDetails/Breakfast/index.tsx @@ -6,6 +6,7 @@ import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" import { useEnterDetailsStore } from "@/stores/enter-details" +import { selectRoom } from "@/stores/enter-details/helpers" import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" @@ -22,16 +23,19 @@ import type { } from "@/types/components/hotelReservation/enterDetails/breakfast" import { BreakfastPackageEnum } from "@/types/enums/breakfast" -export default function Breakfast({ packages }: BreakfastProps) { +export default function Breakfast({ + packages, + roomIndex, +}: BreakfastProps & { roomIndex: number }) { const intl = useIntl() - const formValuesBreakfast = useEnterDetailsStore(({ formValues }) => - formValues?.breakfast - ? formValues.breakfast.code - : formValues?.breakfast === false - ? "false" - : undefined - ) + const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex)) + + const breakfastSelection = room?.breakfast + ? room.breakfast.code + : room?.breakfast === false + ? "false" + : undefined const updateBreakfast = useEnterDetailsStore( (state) => state.actions.updateBreakfast @@ -42,8 +46,8 @@ export default function Breakfast({ packages }: BreakfastProps) { ) const methods = useForm({ - defaultValues: formValuesBreakfast - ? { breakfast: formValuesBreakfast } + defaultValues: breakfastSelection + ? { breakfast: breakfastSelection } : undefined, criteriaMode: "all", mode: "all", @@ -63,6 +67,12 @@ export default function Breakfast({ packages }: BreakfastProps) { [packages, updateBreakfast] ) + useEffect(() => { + if (breakfastSelection) { + methods.setValue("breakfast", breakfastSelection) + } + }, [breakfastSelection, methods]) + useEffect(() => { if (methods.formState.isSubmitting) { return diff --git a/components/HotelReservation/EnterDetails/Details/MemberPriceModal/index.tsx b/components/HotelReservation/EnterDetails/Details/MemberPriceModal/index.tsx index 214b6e794..64b5bf920 100644 --- a/components/HotelReservation/EnterDetails/Details/MemberPriceModal/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/MemberPriceModal/index.tsx @@ -3,6 +3,7 @@ import { useIntl } from "react-intl" import { useEnterDetailsStore } from "@/stores/enter-details" +import { selectRoom } from "@/stores/enter-details/helpers" import { MagicWandIcon } from "@/components/Icons" import Modal from "@/components/Modal" @@ -23,7 +24,8 @@ export default function MemberPriceModal({ isOpen: boolean setIsOpen: Dispatch> }) { - const memberRate = useEnterDetailsStore((state) => state.roomRate.memberRate) + const room = useEnterDetailsStore(selectRoom) + const memberRate = room.roomRate.memberRate const intl = useIntl() const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice diff --git a/components/HotelReservation/EnterDetails/Details/index.tsx b/components/HotelReservation/EnterDetails/Details/index.tsx index 7e2c3a23d..3481f70fe 100644 --- a/components/HotelReservation/EnterDetails/Details/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/index.tsx @@ -5,6 +5,10 @@ import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" import { useEnterDetailsStore } from "@/stores/enter-details" +import { + selectBookingProgress, + selectRoom, +} from "@/stores/enter-details/helpers" import Button from "@/components/TempDesignSystem/Button" import CountrySelect from "@/components/TempDesignSystem/Form/Country" @@ -26,15 +30,25 @@ import type { } from "@/types/components/hotelReservation/enterDetails/details" const formID = "enter-details" -export default function Details({ user, memberPrice }: DetailsProps) { +export default function Details({ + user, + memberPrice, + roomIndex, +}: DetailsProps & { roomIndex: number }) { const intl = useIntl() const [isMemberPriceModalOpen, setIsMemberPriceModalOpen] = useState(false) - const initialData = useEnterDetailsStore((state) => state.guest) + const { currentRoomIndex, canProceedToPayment, roomStatuses } = + useEnterDetailsStore(selectBookingProgress) + const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex)) + const initialData = room.guest + const updateDetails = useEnterDetailsStore( (state) => state.actions.updateDetails ) + const isPaymentNext = currentRoomIndex === roomStatuses.length - 1 + const methods = useForm({ criteriaMode: "all", mode: "all", @@ -68,7 +82,7 @@ export default function Details({ user, memberPrice }: DetailsProps) {
{user ? null : ( @@ -127,13 +141,23 @@ export default function Details({ user, memberPrice }: DetailsProps) {
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 -} diff --git a/components/HotelReservation/EnterDetails/Payment/PaymentCallback/index.tsx b/components/HotelReservation/EnterDetails/Payment/PaymentCallback/index.tsx index 81510f33d..6c8620ce6 100644 --- a/components/HotelReservation/EnterDetails/Payment/PaymentCallback/index.tsx +++ b/components/HotelReservation/EnterDetails/Payment/PaymentCallback/index.tsx @@ -9,7 +9,7 @@ import LoadingSpinner from "@/components/LoadingSpinner" import { trackPaymentEvent } from "@/utils/tracking" import { convertObjToSearchParams } from "@/utils/url" -import type { PersistedState } from "@/types/stores/enter-details" +// import type { PersistedState } from "@/types/stores/enter-details" export default function PaymentCallback({ returnUrl, @@ -28,7 +28,7 @@ export default function PaymentCallback({ const bookingData = window.sessionStorage.getItem(detailsStorageName) if (bookingData) { - const detailsStorage: PersistedState = JSON.parse(bookingData) + const detailsStorage: any = JSON.parse(bookingData) // TODO: fix type here const searchParams = convertObjToSearchParams( detailsStorage.booking, searchObject diff --git a/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx b/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx index ca8d67171..e642e1844 100644 --- a/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx +++ b/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx @@ -26,6 +26,7 @@ import Checkbox from "@/components/TempDesignSystem/Form/Checkbox" import Link from "@/components/TempDesignSystem/Link" import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" +import Title from "@/components/TempDesignSystem/Text/Title" import { toast } from "@/components/TempDesignSystem/Toasts" import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions" import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus" @@ -55,7 +56,6 @@ function isPaymentMethodEnum(value: string): value is PaymentMethodEnum { export default function PaymentClient({ user, - roomPrice, otherPaymentOptions, savedCreditCards, mustBeGuaranteed, @@ -65,13 +65,18 @@ export default function PaymentClient({ const intl = useIntl() const searchParams = useSearchParams() - const totalPrice = useEnterDetailsStore((state) => state.totalPrice) - const { bedType, booking, breakfast } = useEnterDetailsStore((state) => ({ - bedType: state.bedType, - booking: state.booking, - breakfast: state.breakfast, - })) - const userData = useEnterDetailsStore((state) => state.guest) + const { totalPrice, booking, rooms, bookingProgress } = useEnterDetailsStore( + (state) => { + return { + totalPrice: state.totalPrice, + booking: state.booking, + rooms: state.rooms, + bookingProgress: state.bookingProgress, + } + } + ) + const canProceedToPayment = bookingProgress.canProceedToPayment + const setIsSubmittingDisabled = useEnterDetailsStore( (state) => state.actions.setIsSubmittingDisabled ) @@ -87,7 +92,7 @@ export default function PaymentClient({ newPrice: number } | null>() - const { toDate, fromDate, rooms, hotelId } = booking + const { toDate, fromDate, hotelId } = booking usePaymentFailedToast() @@ -115,7 +120,7 @@ export default function PaymentClient({ if (priceChange) { setPriceChangeData({ - oldPrice: roomPrice.publicPrice, + oldPrice: rooms[0].roomPrice.perStay.local.price, newPrice: priceChange.totalPrice, }) } else { @@ -202,18 +207,6 @@ export default function PaymentClient({ const handleSubmit = useCallback( (data: PaymentFormData) => { - const { - firstName, - lastName, - email, - phoneNumber, - countryCode, - membershipNo, - join, - dateOfBirth, - zipCode, - } = userData - // set payment method to card if saved card is submitted const paymentMethod = isPaymentMethodEnum(data.paymentMethod) ? data.paymentMethod @@ -239,41 +232,50 @@ export default function PaymentClient({ hotelId, checkInDate: fromDate, checkOutDate: toDate, - rooms: rooms.map((room) => ({ + rooms: rooms.map((room, idx) => ({ adults: room.adults, childrenAges: room.childrenInRoom?.map((child) => ({ age: child.age, bedType: bedTypeMap[parseInt(child.bed.toString())], })), rateCode: - (user || join || membershipNo) && room.counterRateCode - ? room.counterRateCode - : room.rateCode, - roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step. + (user || room.guest.join || room.guest.membershipNo) && + booking.rooms[idx].counterRateCode + ? booking.rooms[idx].counterRateCode + : booking.rooms[idx].rateCode, + roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step. guest: { - firstName, - lastName, - email, - phoneNumber, - countryCode, - membershipNumber: membershipNo, - becomeMember: join, - dateOfBirth, - postalCode: zipCode, + firstName: room.guest.firstName, + lastName: room.guest.lastName, + email: room.guest.email, + phoneNumber: room.guest.phoneNumber, + countryCode: room.guest.countryCode, + membershipNumber: room.guest.membershipNo, + becomeMember: room.guest.join, + dateOfBirth: room.guest.dateOfBirth, + postalCode: room.guest.zipCode, }, packages: { - breakfast: !!(breakfast && breakfast.code), + breakfast: !!(room.breakfast && room.breakfast.code), allergyFriendly: - room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? - false, + room.roomFeatures?.some( + (feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM + ) ?? false, petFriendly: - room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false, + room.roomFeatures?.some( + (feature) => feature.code === RoomPackageCodeEnum.PET_ROOM + ) ?? false, accessibility: - room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ?? - false, + room.roomFeatures?.some( + (feature) => + feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM + ) ?? false, }, smsConfirmationRequested: data.smsConfirmation, - roomPrice, + roomPrice: { + memberPrice: room.roomRate.memberRate?.localPrice.pricePerStay, + publicPrice: room.roomRate.publicRate.localPrice.pricePerStay, + }, })), payment: { paymentMethod, @@ -292,7 +294,6 @@ export default function PaymentClient({ }) }, [ - userData, savedCreditCards, lang, initiateBooking, @@ -301,9 +302,7 @@ export default function PaymentClient({ toDate, rooms, user, - bedType, - breakfast, - roomPrice, + booking, ] ) @@ -316,8 +315,22 @@ export default function PaymentClient({ return } + const paymentGuarantee = intl.formatMessage({ + id: "Payment Guarantee", + }) + const payment = intl.formatMessage({ + id: "Payment", + }) + return ( - <> +
+
+ + {mustBeGuaranteed ? paymentGuarantee : payment} + +
) : null} - +
) } diff --git a/components/HotelReservation/EnterDetails/Payment/index.tsx b/components/HotelReservation/EnterDetails/Payment/index.tsx index 6ceca19ee..3a8f16f61 100644 --- a/components/HotelReservation/EnterDetails/Payment/index.tsx +++ b/components/HotelReservation/EnterDetails/Payment/index.tsx @@ -6,7 +6,6 @@ import type { PaymentProps } from "@/types/components/hotelReservation/selectRat export default async function Payment({ user, - roomPrice, otherPaymentOptions, mustBeGuaranteed, supportedCards, @@ -18,7 +17,6 @@ export default async function Payment({ return ( ) { const intl = useIntl() - const currentStep = useEnterDetailsStore((state) => state.currentStep) - const steps = useEnterDetailsStore((state) => state.steps) + const roomStatus = useEnterDetailsStore((state) => + selectRoomStatus(state, roomIndex) + ) + + const setStep = useEnterDetailsStore((state) => state.actions.setStep) + const { bedType, breakfast } = useEnterDetailsStore((state) => + selectRoom(state, roomIndex) + ) + const { roomStatuses, currentRoomIndex } = useEnterDetailsStore((state) => + selectBookingProgress(state) + ) + const [isComplete, setIsComplete] = useState(false) const [isOpen, setIsOpen] = useState(false) - const isValid = useEnterDetailsStore((state) => state.isValid[step]) - const navigate = useEnterDetailsStore((state) => state.actions.navigate) - const { bedType, breakfast } = useEnterDetailsStore((state) => ({ - bedType: state.bedType, - breakfast: state.breakfast, - })) + const isValid = roomStatus.steps[step]?.isValid ?? false + const [title, setTitle] = useState(label) const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" }) const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" }) - useScrollToActiveSection(step, steps, currentStep === step) + // useScrollToActiveSection(step, steps, roomStatus.currentStep === step) useEffect(() => { if (step === StepEnum.selectBed && bedType) { @@ -57,11 +70,29 @@ export default function SectionAccordion({ }, [isValid, setIsComplete]) useEffect(() => { - setIsOpen(currentStep === step) - }, [currentStep, setIsOpen, step]) + setIsOpen(roomStatus.currentStep === step && currentRoomIndex === roomIndex) + }, [currentRoomIndex, roomIndex, roomStatus.currentStep, setIsOpen, step]) function onModify() { - navigate(step) + setStep(step, roomIndex) + } + + function close() { + setIsOpen(false) + const isLastStep = step === StepEnum.details + const hasNextRoom = roomIndex + 1 <= roomStatuses.length + + if (!isLastStep) { + const nextStep = selectNextStep(roomStatus) + if (nextStep) { + setStep(nextStep, roomIndex) + } + } else if (isLastStep && hasNextRoom) { + setStep(StepEnum.selectBed, roomIndex + 1) + } else { + // Time for payment, collapse any open step + setStep(null) + } } const textColor = @@ -81,7 +112,7 @@ export default function SectionAccordion({
diff --git a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css index c7dd7530d..92c3b5227 100644 --- a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css +++ b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css @@ -16,10 +16,6 @@ transform-origin: top; } -.accordion:last-child { - border-bottom: none; -} - .header { grid-area: header; } @@ -46,8 +42,13 @@ .button { grid-area: button; justify-self: flex-end; + transform-origin: 50% 50%; + transition: transform 0.3s; } +.buttonOpen { + transform: rotate(180deg); +} .selection { grid-area: selection; } @@ -85,22 +86,21 @@ } .contentWrapper { + opacity: 0; padding-bottom: var(--Spacing-x3); } +.accordion[data-section-open="true"] .contentWrapper { + opacity: 1; +} .content { overflow: hidden; grid-area: content; - opacity: 0; border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); transform-origin: top; transition: opacity 0.2s linear; } -.accordion[data-section-open="true"] .content { - opacity: 1; -} - .content:has([data-section-open="true"]) { overflow: visible; } diff --git a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx index 3db090c20..8944d4baf 100644 --- a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx +++ b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx @@ -18,7 +18,8 @@ import type { SelectedRoomProps } from "@/types/components/hotelReservation/ente export default function SelectedRoom({ hotelId, - room, + roomType, + roomTypeCode, rateDescription, }: SelectedRoomProps) { const intl = useIntl() @@ -50,7 +51,7 @@ export default function SelectedRoom({ {intl.formatMessage( { id: "{roomType} {rateDescription}" }, { - roomType: room.roomType, + roomType: roomType, rateDescription, rate: (str) => { return {str} @@ -70,12 +71,9 @@ export default function SelectedRoom({ {intl.formatMessage({ id: "Change room" })}{" "} - {room?.roomTypeCode && ( + {roomTypeCode && (
- +
)} diff --git a/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css b/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css index 08732466f..654cb24c0 100644 --- a/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css +++ b/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css @@ -65,7 +65,6 @@ @media screen and (min-width: 768px) { .wrapper { gap: var(--Spacing-x3); - padding-top: var(--Spacing-x3); } .iconWrapper { diff --git a/components/HotelReservation/EnterDetails/Summary/Desktop.tsx b/components/HotelReservation/EnterDetails/Summary/Desktop.tsx index fd4e7f8bc..d89e3c64b 100644 --- a/components/HotelReservation/EnterDetails/Summary/Desktop.tsx +++ b/components/HotelReservation/EnterDetails/Summary/Desktop.tsx @@ -7,54 +7,16 @@ import SidePanel from "@/components/HotelReservation/SidePanel" import SummaryUI from "./UI" import type { SummaryProps } from "@/types/components/hotelReservation/summary" -import type { DetailsState } from "@/types/stores/enter-details" - -function storeSelector(state: DetailsState) { - return { - bedType: state.bedType, - booking: state.booking, - breakfast: state.breakfast, - guest: state.guest, - packages: state.packages, - roomRate: state.roomRate, - roomPrice: state.roomPrice, - toggleSummaryOpen: state.actions.toggleSummaryOpen, - togglePriceDetailsModalOpen: state.actions.togglePriceDetailsModalOpen, - totalPrice: state.totalPrice, - vat: state.vat, - } -} export default function DesktopSummary(props: SummaryProps) { const { - bedType, booking, - breakfast, - guest, - packages, - roomPrice, - roomRate, - toggleSummaryOpen, - togglePriceDetailsModalOpen, + actions: { toggleSummaryOpen, togglePriceDetailsModalOpen }, totalPrice, vat, - } = useEnterDetailsStore(storeSelector) + } = useEnterDetailsStore((state) => state) - // TODO: rooms should be part of store - const rooms = [ - { - adults: booking.rooms[0].adults, - childrenInRoom: booking.rooms[0].childrenInRoom, - bedType, - breakfast, - guest, - roomRate, - roomPrice, - roomType: props.roomType, - rateDetails: props.rateDetails, - cancellationText: props.cancellationText, - }, - ] + const rooms = useEnterDetailsStore((state) => state.rooms) return ( @@ -63,7 +25,6 @@ export default function DesktopSummary(props: SummaryProps) { rooms={rooms} isMember={props.isMember} breakfastIncluded={props.breakfastIncluded} - packages={packages} totalPrice={totalPrice} vat={vat} toggleSummaryOpen={toggleSummaryOpen} diff --git a/components/HotelReservation/EnterDetails/Summary/Mobile/index.tsx b/components/HotelReservation/EnterDetails/Summary/Mobile/index.tsx index ec16da6f7..e4011af56 100644 --- a/components/HotelReservation/EnterDetails/Summary/Mobile/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/Mobile/index.tsx @@ -10,55 +10,23 @@ import SummaryBottomSheet from "./BottomSheet" import styles from "./mobile.module.css" import type { SummaryProps } from "@/types/components/hotelReservation/summary" -import type { DetailsState } from "@/types/stores/enter-details" - -function storeSelector(state: DetailsState) { - return { - bedType: state.bedType, - booking: state.booking, - breakfast: state.breakfast, - guest: state.guest, - packages: state.packages, - roomRate: state.roomRate, - roomPrice: state.roomPrice, - toggleSummaryOpen: state.actions.toggleSummaryOpen, - togglePriceDetailsModalOpen: state.actions.togglePriceDetailsModalOpen, - totalPrice: state.totalPrice, - vat: state.vat, - } -} export default function MobileSummary(props: SummaryProps) { const { - bedType, booking, - breakfast, - guest, - packages, - roomPrice, - roomRate, - toggleSummaryOpen, - togglePriceDetailsModalOpen, + actions: { toggleSummaryOpen, togglePriceDetailsModalOpen }, totalPrice, vat, - } = useEnterDetailsStore(storeSelector) + } = useEnterDetailsStore((state) => state) + + const rooms = useEnterDetailsStore((state) => state.rooms) + + const showPromo = + !props.isMember && + rooms.length === 1 && + !rooms[0].guest.join && + !rooms[0].guest.membershipNo - // TODO: rooms should be part of store - const rooms = [ - { - adults: booking.rooms[0].adults, - childrenInRoom: booking.rooms[0].childrenInRoom, - bedType, - breakfast, - guest, - roomRate, - roomPrice, - roomType: props.roomType, - rateDetails: props.rateDetails, - cancellationText: props.cancellationText, - }, - ] - const showPromo = !props.isMember && !guest.join && !guest.membershipNo return (
{showPromo ? : null} @@ -69,7 +37,6 @@ export default function MobileSummary(props: SummaryProps) { rooms={rooms} isMember={props.isMember} breakfastIncluded={props.breakfastIncluded} - packages={packages} totalPrice={totalPrice} vat={vat} toggleSummaryOpen={toggleSummaryOpen} diff --git a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx index 4fde95531..37edb7399 100644 --- a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx @@ -24,20 +24,19 @@ import { formatPrice } from "@/utils/numberFormatting" import styles from "./ui.module.css" import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" -import type { SummaryUIProps } from "@/types/components/hotelReservation/summary" -import type { DetailsProviderProps } from "@/types/providers/enter-details" +import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details" +import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary" export default function SummaryUI({ booking, rooms, - packages, totalPrice, isMember, breakfastIncluded, vat, toggleSummaryOpen, togglePriceDetailsModalOpen, -}: SummaryUIProps) { +}: EnterDetailsSummaryProps) { const intl = useIntl() const lang = useLang() @@ -60,8 +59,8 @@ export default function SummaryUI({ } } - function getMemberPrice(roomRate: DetailsProviderProps["roomRate"]) { - return roomRate.memberRate + function getMemberPrice(roomRate: RoomRate) { + return roomRate?.memberRate ? { currency: roomRate.memberRate.localPrice.currency, pricePerNight: roomRate.memberRate.localPrice.pricePerNight, @@ -74,7 +73,7 @@ export default function SummaryUI({ rooms.length === 1 && rooms .slice(0, 1) - .some((r) => !isMember || !r?.guest?.join || !r?.guest?.membershipNo) + .some((r) => !isMember || !r.guest.join || !r.guest.membershipNo) const memberPrice = getMemberPrice(rooms[0].roomRate) @@ -127,11 +126,8 @@ export default function SummaryUI({ const isFirstRoomMember = roomNumber === 1 && isMember const showMemberPrice = - !!( - isFirstRoomMember || - room?.guest?.join || - room?.guest?.membershipNo - ) && memberPrice + !!(isFirstRoomMember || room.guest.join || room.guest.membershipNo) && + memberPrice const adultsMsg = intl.formatMessage( { id: "{totalAdults, plural, one {# adult} other {# adults}}" }, @@ -206,20 +202,20 @@ export default function SummaryUI({
- {packages - ? packages.map((roomPackage) => ( -
+ {room.roomFeatures + ? room.roomFeatures.map((feature) => ( +
- {roomPackage.description} + {feature.description}
{formatPrice( intl, - parseInt(roomPackage.localPrice.price), - roomPackage.localPrice.currency + parseInt(feature.localPrice.price), + feature.localPrice.currency )}
diff --git a/components/HotelReservation/EnterDetails/Summary/summary.test.tsx b/components/HotelReservation/EnterDetails/Summary/summary.test.tsx index b10c019a1..de1000252 100644 --- a/components/HotelReservation/EnterDetails/Summary/summary.test.tsx +++ b/components/HotelReservation/EnterDetails/Summary/summary.test.tsx @@ -20,6 +20,7 @@ import SummaryUI from "./UI" import type { PropsWithChildren } from "react" import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" +import type { RoomState } from "@/types/stores/enter-details" jest.mock("@/lib/api", () => ({ fetchRetry: jest.fn((fn) => fn), @@ -39,8 +40,7 @@ function createWrapper(intlConfig: IntlConfig) { } } -// TODO: add type definition to this object -export const rooms = [ +const rooms: RoomState[] = [ { adults: 2, childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }], @@ -55,6 +55,7 @@ export const rooms = [ roomType: "Standard", rateDetails: [], cancellationText: "Non-refundable", + roomFeatures: [], }, { adults: 1, @@ -70,6 +71,7 @@ export const rooms = [ roomType: "Standard", rateDetails: [], cancellationText: "Non-refundable", + roomFeatures: [], }, ] @@ -88,7 +90,6 @@ describe("EnterDetails Summary", () => { rooms={rooms.slice(0, 1)} isMember={false} breakfastIncluded={false} - packages={[]} totalPrice={{ requested: { currency: "EUR", @@ -128,7 +129,6 @@ describe("EnterDetails Summary", () => { rooms={rooms} isMember={false} breakfastIncluded={false} - packages={[]} totalPrice={{ requested: { currency: "EUR", diff --git a/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/index.tsx b/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/index.tsx index 5a99030bb..0a14d58cc 100644 --- a/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/index.tsx +++ b/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/index.tsx @@ -14,8 +14,9 @@ import styles from "./priceDetailsTable.module.css" import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" +import type { RoomPrice } from "@/types/components/hotelReservation/enterDetails/details" +import type { Price } from "@/types/components/hotelReservation/price" import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate" -import type { Price, RoomPrice } from "@/types/stores/enter-details" function Row({ label, @@ -67,8 +68,8 @@ interface PriceDetailsTableProps { childrenInRoom: Child[] | undefined roomType: string roomPrice: RoomPrice - bedType: BedTypeSchema | undefined - breakfast: BreakfastPackage | false | undefined + bedType?: BedTypeSchema + breakfast?: BreakfastPackage | false }[] totalPrice: Price vat: number diff --git a/components/HotelReservation/PriceDetailsModal/index.tsx b/components/HotelReservation/PriceDetailsModal/index.tsx index 002e25c94..dfa2d7bcf 100644 --- a/components/HotelReservation/PriceDetailsModal/index.tsx +++ b/components/HotelReservation/PriceDetailsModal/index.tsx @@ -9,8 +9,9 @@ import PriceDetailsTable from "./PriceDetailsTable" import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" +import type { RoomPrice } from "@/types/components/hotelReservation/enterDetails/details" +import type { Price } from "@/types/components/hotelReservation/price" import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate" -import type { Price, RoomPrice } from "@/types/stores/enter-details" interface PriceDetailsModalProps { fromDate: string @@ -20,8 +21,8 @@ interface PriceDetailsModalProps { childrenInRoom: Child[] | undefined roomType: string roomPrice: RoomPrice - bedType: BedTypeSchema | undefined - breakfast: BreakfastPackage | false | undefined + bedType?: BedTypeSchema + breakfast?: BreakfastPackage | false }[] totalPrice: Price vat: number diff --git a/components/HotelReservation/SelectRate/RateSummary/MobileSummary/Summary.tsx b/components/HotelReservation/SelectRate/RateSummary/MobileSummary/Summary.tsx new file mode 100644 index 000000000..1f5bae966 --- /dev/null +++ b/components/HotelReservation/SelectRate/RateSummary/MobileSummary/Summary.tsx @@ -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 ( +
+
+ + {intl.formatMessage({ id: "Booking summary" })} + + + {dt(booking.fromDate).locale(lang).format("ddd, D MMM")} + + {dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights}) + + +
+ + {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.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 ( + +
+
+ {rooms.length > 1 ? ( + + {intl.formatMessage({ id: "Room" })} {roomNumber} + + ) : null} +
+ {room.roomType} + + {formatPrice( + intl, + room.roomPrice.perStay.local.price, + room.roomPrice.perStay.local.currency + )} + +
+ + {guestsParts.join(", ")} + + + {room.cancellationText} + + + + {intl.formatMessage({ id: "Rate details" })} + + + } + title={room.cancellationText} + > +
+ {room.rateDetails?.map((info) => ( + + + {info} + + ))} +
+
+
+ + {childBedCrib ? ( +
+
+ + {intl.formatMessage( + { id: "Crib (child) × {count}" }, + { count: childBedCrib } + )} + + + {intl.formatMessage({ id: "Based on availability" })} + +
+ + {formatPrice( + intl, + 0, + room.roomPrice.perStay.local.currency + )} + +
+ ) : null} + {childBedExtraBed ? ( +
+
+ + {intl.formatMessage( + { id: "Extra bed (child) × {count}" }, + { + count: childBedExtraBed, + } + )} + +
+ + {formatPrice( + intl, + 0, + room.roomPrice.perStay.local.currency + )} + +
+ ) : null} +
+ +
+ ) + })} +
+
+
+ + {intl.formatMessage( + { id: "Total price (incl VAT)" }, + { b: (str) => {str} } + )} + + ({ + adults: r.adults, + childrenInRoom: r.childrenInRoom, + roomPrice: r.roomPrice, + roomType: r.roomType, + }))} + totalPrice={totalPrice} + vat={vat} + toggleModal={handleTogglePriceDetailsModal} + /> +
+
+ + {formatPrice( + intl, + totalPrice.local.price, + totalPrice.local.currency + )} + + {totalPrice.requested && ( + + {intl.formatMessage( + { id: "Approx. {value}" }, + { + value: formatPrice( + intl, + totalPrice.requested.price, + totalPrice.requested.currency + ), + } + )} + + )} +
+
+ +
+ {!isMember && memberPrice ? ( + + ) : null} +
+ ) +} diff --git a/components/HotelReservation/SelectRate/RateSummary/MobileSummary/index.tsx b/components/HotelReservation/SelectRate/RateSummary/MobileSummary/index.tsx index f0f6a6d44..3636b54aa 100644 --- a/components/HotelReservation/SelectRate/RateSummary/MobileSummary/index.tsx +++ b/components/HotelReservation/SelectRate/RateSummary/MobileSummary/index.tsx @@ -3,12 +3,13 @@ import { useIntl } from "react-intl" import { useRateSelectionStore } from "@/stores/select-rate/rate-selection" -import SummaryUI from "@/components/HotelReservation/EnterDetails/Summary/UI" import Button from "@/components/TempDesignSystem/Button" import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import { formatPrice } from "@/utils/numberFormatting" +import Summary from "./Summary" + import styles from "./mobileSummary.module.css" import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary" @@ -55,9 +56,6 @@ export default function MobileSummary({ }, currency: room.public.localPrice.currency, }, - bedType: undefined, - breakfast: undefined, - guest: undefined, roomRate: { ...room.public, memberRate: room.member, @@ -99,14 +97,12 @@ export default function MobileSummary({
- diff --git a/components/HotelReservation/SelectRate/RateSummary/MobileSummary/summary.module.css b/components/HotelReservation/SelectRate/RateSummary/MobileSummary/summary.module.css new file mode 100644 index 000000000..66785ae67 --- /dev/null +++ b/components/HotelReservation/SelectRate/RateSummary/MobileSummary/summary.module.css @@ -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; + } +} diff --git a/components/HotelReservation/SelectRate/RateSummary/utils.ts b/components/HotelReservation/SelectRate/RateSummary/utils.ts index 34fdd4731..935e65abd 100644 --- a/components/HotelReservation/SelectRate/RateSummary/utils.ts +++ b/components/HotelReservation/SelectRate/RateSummary/utils.ts @@ -1,9 +1,9 @@ +import type { Price } from "@/types/components/hotelReservation/price" import { type RoomPackage, RoomPackageCodeEnum, } from "@/types/components/hotelReservation/selectRate/roomFilter" import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate" -import type { Price } from "@/types/stores/enter-details" export const calculateTotalPrice = ( selectedRateSummary: Rate[], diff --git a/components/HotelReservation/SelectRate/Rooms/index.tsx b/components/HotelReservation/SelectRate/Rooms/index.tsx index 27b662396..58fe103c7 100644 --- a/components/HotelReservation/SelectRate/Rooms/index.tsx +++ b/components/HotelReservation/SelectRate/Rooms/index.tsx @@ -178,7 +178,7 @@ export default function Rooms({ e.preventDefault() window.history.pushState(null, "", `${pathname}?${queryParams.toString()}`) - router.push(`select-bed?${queryParams}`) + router.push(`details?${queryParams}`) } useEffect(() => { @@ -270,7 +270,7 @@ export default function Rooms({ {rateSummary && ( ) { + const uniqueId = useId() + const inputId = `${uniqueId}-${props.name}` + return ( - + - + diff --git a/constants/routes/hotelReservation.js b/constants/routes/hotelReservation.js index 943f09e37..f4020e26f 100644 --- a/constants/routes/hotelReservation.js +++ b/constants/routes/hotelReservation.js @@ -23,20 +23,6 @@ export function details(lang) { return `${hotelreservation(lang)}/details` } -/** - * @param {Lang} lang - */ -export function payment(lang) { - return `${hotelreservation(lang)}/payment` -} - -/** - * @param {Lang} lang - */ -export function selectBed(lang) { - return `${hotelreservation(lang)}/select-bed` -} - /** * @param {Lang} lang */ diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 7dd036c61..24d0635a8 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -135,6 +135,7 @@ "Contact our memberservice": "Contact our memberservice", "Contact us": "Contact us", "Continue": "Continue", + "Continue to room {nextRoomNumber}": "Continue to room {nextRoomNumber}", "Copied to clipboard": "Copied to clipboard", "Copy promotion code": "Copy promotion code", "Could not find requested resource": "Could not find requested resource", diff --git a/next.config.js b/next.config.js index cad1e8cb1..08c78e0f7 100644 --- a/next.config.js +++ b/next.config.js @@ -82,8 +82,7 @@ const nextConfig = { // ---------------------------------------- // hotel (hotelId) param missing // ---------------------------------------- - source: - "/:lang/hotelreservation/(select-bed|breakfast|details|payment)", + source: "/:lang/hotelreservation/details", destination: "/:lang/hotelreservation/select-rate", missing: [ { @@ -98,8 +97,7 @@ const nextConfig = { // ---------------------------------------- // hotel (hotelId) param has to be an integer // ---------------------------------------- - source: - "/:lang/hotelreservation/(select-bed|breakfast|details|payment)", + source: "/:lang/hotelreservation/details", destination: "/:lang/hotelreservation/select-rate", missing: [ { @@ -114,8 +112,7 @@ const nextConfig = { // ---------------------------------------- // fromdate param missing // ---------------------------------------- - source: - "/:lang/hotelreservation/(select-bed|breakfast|details|payment)", + source: "/:lang/hotelreservation/details", destination: "/:lang/hotelreservation/select-rate", missing: [ { @@ -130,8 +127,7 @@ const nextConfig = { // ---------------------------------------- // fromdate param has to be a date // ---------------------------------------- - source: - "/:lang/hotelreservation/(select-bed|breakfast|details|payment)", + source: "/:lang/hotelreservation/details", destination: "/:lang/hotelreservation/select-rate", missing: [ { @@ -146,8 +142,7 @@ const nextConfig = { // ---------------------------------------- // todate param missing // ---------------------------------------- - source: - "/:lang/hotelreservation/(select-bed|breakfast|details|payment)", + source: "/:lang/hotelreservation/details", destination: "/:lang/hotelreservation/select-rate", missing: [ { @@ -162,8 +157,7 @@ const nextConfig = { // ---------------------------------------- // todate param has to be a date // ---------------------------------------- - source: - "/:lang/hotelreservation/(select-bed|breakfast|details|payment)", + source: "/:lang/hotelreservation/details", destination: "/:lang/hotelreservation/select-rate", missing: [ { @@ -178,8 +172,7 @@ const nextConfig = { // ---------------------------------------- // room[0].adults param missing // ---------------------------------------- - source: - "/:lang/hotelreservation/(select-bed|breakfast|details|payment)", + source: "/:lang/hotelreservation/details", destination: "/:lang/hotelreservation/select-rate", missing: [ { @@ -194,8 +187,7 @@ const nextConfig = { // ---------------------------------------- // room[0].adults param has to be an integer // ---------------------------------------- - source: - "/:lang/hotelreservation/(select-bed|breakfast|details|payment)", + source: "/:lang/hotelreservation/details", destination: "/:lang/hotelreservation/select-rate", missing: [ { @@ -210,8 +202,7 @@ const nextConfig = { // ---------------------------------------- // room[0].ratecode param missing // ---------------------------------------- - source: - "/:lang/hotelreservation/(select-bed|breakfast|details|payment)", + source: "/:lang/hotelreservation/details", destination: "/:lang/hotelreservation/select-rate", missing: [ { @@ -226,8 +217,7 @@ const nextConfig = { // ---------------------------------------- // room[0].roomtype param missing // ---------------------------------------- - source: - "/:lang/hotelreservation/(select-bed|breakfast|details|payment)", + source: "/:lang/hotelreservation/details", destination: "/:lang/hotelreservation/select-rate", missing: [ { @@ -278,11 +268,6 @@ const nextConfig = { source: `${myPages.sv}/:path*`, destination: `/sv/my-pages/:path*`, }, - { - source: - "/:lang/hotelreservation/:step(breakfast|details|payment|select-bed)", - destination: "/:lang/hotelreservation/step?step=:step", - }, { source: "/:lang/hotelreservation/payment-callback/:status", destination: diff --git a/providers/EnterDetailsProvider.tsx b/providers/EnterDetailsProvider.tsx index 826116eb3..916e96689 100644 --- a/providers/EnterDetailsProvider.tsx +++ b/providers/EnterDetailsProvider.tsx @@ -3,136 +3,74 @@ import { useEffect, useRef } from "react" import { createDetailsStore } from "@/stores/enter-details" import { - calcTotalMemberPrice, - calcTotalPublicPrice, - navigate, - writeToSessionStorage, + checkIsSameRoom, + clearSessionStorage, + readFromSessionStorage, } from "@/stores/enter-details/helpers" -import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" -import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" -import { - guestDetailsSchema, - signedInDetailsSchema, -} from "@/components/HotelReservation/EnterDetails/Details/schema" import { DetailsContext } from "@/contexts/Details" import type { DetailsStore } from "@/types/contexts/enter-details" -import { StepEnum } from "@/types/enums/step" import type { DetailsProviderProps } from "@/types/providers/enter-details" -import type { DetailsState, InitialState } from "@/types/stores/enter-details" +import type { InitialState } from "@/types/stores/enter-details" export default function EnterDetailsProvider({ - bedTypes, booking, showBreakfastStep, children, - packages, - roomRate, + roomsData, searchParamsStr, - step, user, vat, }: DetailsProviderProps) { const storeRef = useRef() - if (!storeRef.current) { - const initialData: InitialState = { booking, packages, roomRate, vat } - if (bedTypes.length === 1) { - initialData.bedType = { - description: bedTypes[0].description, - roomTypeCode: bedTypes[0].value, - } + const initialData: InitialState = { + booking, + rooms: roomsData + .filter((r) => r.bedTypes?.length) // TODO: how to handle room without bedtypes? + .map((room) => ({ + roomFeatures: room.packages, + roomRate: room.roomRate, + roomType: room.roomType, + cancellationText: room.cancellationText, + rateDetails: room.rateDetails, + bedType: + room.bedTypes?.length === 1 + ? { + roomTypeCode: room.bedTypes[0].value, + description: room.bedTypes[0].description, + } + : undefined, + })), + vat, } + if (!showBreakfastStep) { initialData.breakfast = false } - storeRef.current = createDetailsStore( - initialData, - step, - searchParamsStr, - user - ) + storeRef.current = createDetailsStore(initialData, searchParamsStr, user) } useEffect(() => { - if (storeRef.current) { - storeRef.current.setState((state) => { - const newState: DetailsState = { ...state } - - newState.bedType = state.formValues.bedType - newState.breakfast = state.formValues.breakfast - - if (state.formValues.guest && !user) { - newState.guest = state.formValues.guest - } - - if ( - (newState.guest!.join || newState.guest!.membershipNo || user) && - state.roomRate.memberRate - ) { - const memberPrice = calcTotalMemberPrice(newState) - newState.roomPrice = memberPrice.roomPrice - newState.totalPrice = memberPrice.totalPrice - } else { - const publicPrice = calcTotalPublicPrice(newState) - newState.roomPrice = publicPrice.roomPrice - newState.totalPrice = publicPrice.totalPrice - } - - const isValid = { ...newState.isValid } - - const validateBooking = state.formValues - const validPaths = [StepEnum.selectBed] - const validatedBedType = bedTypeSchema.safeParse(validateBooking) - if (validatedBedType.success) { - isValid[StepEnum.selectBed] = true - validPaths.push(state.steps[1]) - } - - const validatedBreakfast = - breakfastStoreSchema.safeParse(validateBooking) - if (validatedBreakfast.success) { - isValid[StepEnum.breakfast] = true - validPaths.push(StepEnum.details) - } - - const detailsSchema = user ? signedInDetailsSchema : guestDetailsSchema - const validatedDetails = detailsSchema.safeParse(validateBooking.guest) - /** - * Need to add the breakfast check here too since - * when a member comes into the flow, their data is - * already added and valid, and thus to avoid showing a - * step the user hasn't been on yet as complete - */ - if (isValid.breakfast && validatedDetails.success) { - isValid[StepEnum.details] = true - validPaths.push(StepEnum.payment) - } - - if (!validPaths.includes(newState.currentStep!)) { - newState.currentStep = validPaths.at(-1)! - } - - if (step !== newState.currentStep) { - const stateCurrentStep = newState.currentStep! - setTimeout(() => { - navigate(stateCurrentStep, searchParamsStr) - }) - } - - writeToSessionStorage({ - bedType: newState.bedType, - booking: newState.booking, - breakfast: newState.breakfast, - guest: newState.guest, - }) - - return { ...newState, isValid } - }) + const storedValues = readFromSessionStorage() + if (!storedValues) { + return } - }, [searchParamsStr, step, user]) + const isSameRoom = checkIsSameRoom(storedValues.booking, booking) + if (!isSameRoom) { + clearSessionStorage() + return + } + + const state = storeRef.current?.getState() + storeRef.current?.setState({ + ...state, + rooms: storedValues.rooms, + bookingProgress: storedValues.bookingProgress, + }) + }, [booking]) return ( diff --git a/stores/enter-details/helpers.ts b/stores/enter-details/helpers.ts index f1f22a983..11527514c 100644 --- a/stores/enter-details/helpers.ts +++ b/stores/enter-details/helpers.ts @@ -1,17 +1,16 @@ -import deepmerge from "deepmerge" import isEqual from "fast-deep-equal" -import { arrayMerge } from "@/utils/merge" - import { detailsStorageName } from "." +import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details" +import type { Price } from "@/types/components/hotelReservation/price" import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" -import type { StepEnum } from "@/types/enums/step" +import { StepEnum } from "@/types/enums/step" import type { DetailsState, PersistedState, - PersistedStatePart, - RoomRate, + RoomState, + RoomStatus, } from "@/types/stores/enter-details" import type { SafeUser } from "@/types/user" @@ -27,10 +26,6 @@ export function extractGuestFromUser(user: NonNullable) { } } -export function navigate(step: StepEnum, searchParams: string) { - window.history.pushState({ step }, "", `${step}?${searchParams}`) -} - export function checkIsSameRoom( prev: SelectRateSearchParams, next: SelectRateSearchParams @@ -84,7 +79,7 @@ export function subtract(...nums: (number | string | undefined)[]) { }, 0) } -export function getInitialRoomPrice(roomRate: RoomRate, isMember: boolean) { +export function getRoomPrice(roomRate: RoomRate, isMember: boolean) { if (isMember && roomRate.memberRate) { return { perNight: { @@ -134,158 +129,234 @@ export function getInitialRoomPrice(roomRate: RoomRate, isMember: boolean) { } } -export function getInitialTotalPrice(roomRate: RoomRate, isMember: boolean) { - if (isMember && roomRate.memberRate) { - return { - requested: roomRate.memberRate.requestedPrice && { - currency: roomRate.memberRate.requestedPrice.currency, - price: roomRate.memberRate.requestedPrice.pricePerStay, - }, +type TotalPrice = { + requested: { currency: string; price: number } | undefined + local: { currency: string; price: number } +} + +export function getTotalPrice(roomRates: RoomRate[], isMember: boolean) { + return roomRates.reduce( + (total, roomRate, idx) => { + const isFirstRoom = idx === 0 + const rate = + isFirstRoom && isMember && roomRate.memberRate + ? roomRate.memberRate + : roomRate.publicRate + + return { + requested: rate.requestedPrice + ? { + currency: rate.requestedPrice.currency, + price: add( + total.requested?.price ?? 0, + rate.requestedPrice.pricePerStay + ), + } + : undefined, + local: { + currency: rate.localPrice.currency, + price: add(total.local.price ?? 0, rate.localPrice.pricePerStay), + }, + } + }, + { + requested: undefined, local: { - currency: roomRate.memberRate.localPrice.currency, - price: roomRate.memberRate.localPrice.pricePerStay, + currency: roomRates[0].publicRate.localPrice.currency, + price: 0, }, } - } - - return { - requested: roomRate.publicRate.requestedPrice && { - currency: roomRate.publicRate.requestedPrice.currency, - price: roomRate.publicRate.requestedPrice.pricePerStay, - }, - local: { - currency: roomRate.publicRate.localPrice.currency, - price: roomRate.publicRate.localPrice.pricePerStay, - }, - } -} - -export function calcTotalMemberPrice(state: DetailsState) { - if (!state.roomRate.memberRate) { - return { - roomPrice: state.roomPrice, - totalPrice: state.totalPrice, - } - } - - return calcTotalPrice({ - breakfast: state.breakfast, - packages: state.packages, - roomPrice: state.roomPrice, - totalPrice: state.totalPrice, - ...state.roomRate.memberRate, - }) -} - -export function calcTotalPublicPrice(state: DetailsState) { - return calcTotalPrice({ - breakfast: state.breakfast, - packages: state.packages, - roomPrice: state.roomPrice, - totalPrice: state.totalPrice, - ...state.roomRate.publicRate, - }) + ) } export function calcTotalPrice( - state: Pick< - DetailsState, - "breakfast" | "packages" | "roomPrice" | "totalPrice" - > & - DetailsState["roomRate"]["publicRate"] + rooms: RoomState[], + totalPrice: Price, + isMember: boolean ) { - // state is sometimes read-only, thus we - // need to create a deep copy of the values - const roomAndTotalPrice = { - roomPrice: { - perNight: { - local: { ...state.roomPrice.perNight.local }, - requested: state.roomPrice.perNight.requested - ? { ...state.roomPrice.perNight.requested } - : state.roomPrice.perNight.requested, - }, - perStay: { - local: { ...state.roomPrice.perStay.local }, - requested: state.roomPrice.perStay.requested - ? { ...state.roomPrice.perStay.requested } - : state.roomPrice.perStay.requested, - }, - }, - totalPrice: { - local: { ...state.totalPrice.local }, - requested: state.totalPrice.requested - ? { ...state.totalPrice.requested } - : state.totalPrice.requested, - }, - } - if (state.requestedPrice?.pricePerStay) { - roomAndTotalPrice.roomPrice.perStay.requested = { - currency: state.requestedPrice.currency, - price: state.requestedPrice.pricePerStay, - } + return rooms.reduce( + (acc, room, index) => { + const isFirstRoomAndMember = index === 0 && isMember + const join = Boolean(room.guest.join || room.guest.membershipNo) - let totalPriceRequested = state.requestedPrice.pricePerStay - if (state.breakfast) { - totalPriceRequested = add( - totalPriceRequested, - state.breakfast.requestedPrice.totalPrice + const roomPrice = getRoomPrice( + room.roomRate, + isFirstRoomAndMember || join ) - } - if (state.packages) { - totalPriceRequested = state.packages.reduce((total, pkg) => { + const breakfastRequestedPrice = room.breakfast + ? room.breakfast.requestedPrice?.totalPrice ?? 0 + : 0 + const breakfastLocalPrice = room.breakfast + ? room.breakfast.localPrice?.totalPrice ?? 0 + : 0 + + const roomFeaturesTotal = room.roomFeatures?.reduce((total, pkg) => { if (pkg.requestedPrice.totalPrice) { total = add(total, pkg.requestedPrice.totalPrice) } return total - }, totalPriceRequested) - } + }, 0) - roomAndTotalPrice.totalPrice.requested = { - currency: state.requestedPrice.currency, - price: totalPriceRequested, - } - } - - const roomPriceLocal = state.localPrice - roomAndTotalPrice.roomPrice.perStay.local = { - currency: roomPriceLocal.currency, - price: roomPriceLocal.pricePerStay, - } - - let totalPriceLocal = roomPriceLocal.pricePerStay - if (state.breakfast) { - totalPriceLocal = add( - totalPriceLocal, - state.breakfast.localPrice.totalPrice - ) - } - - if (state.packages) { - totalPriceLocal = state.packages.reduce((total, pkg) => { - if (pkg.localPrice.totalPrice) { - total = add(total, pkg.localPrice.totalPrice) + const result: Price = { + requested: roomPrice.perStay.requested + ? { + currency: roomPrice.perStay.requested.currency, + price: add( + acc.requested?.price ?? 0, + roomPrice.perStay.requested.price, + breakfastRequestedPrice + ), + } + : undefined, + local: { + currency: roomPrice.perStay.local.currency, + price: add( + acc.local.price, + roomPrice.perStay.local.price, + breakfastLocalPrice, + roomFeaturesTotal + ), + }, } - return total - }, totalPriceLocal) - } - roomAndTotalPrice.totalPrice.local = { - currency: roomPriceLocal.currency, - price: totalPriceLocal, - } - return roomAndTotalPrice + return result + }, + { + requested: undefined, + local: { currency: totalPrice.local.currency, price: 0 }, + } + ) } -export function writeToSessionStorage(part: PersistedStatePart) { - const unparsedData = sessionStorage.getItem(detailsStorageName) - if (unparsedData) { - const data: PersistedState = JSON.parse(unparsedData) - // @ts-expect-error - deepmerge is not to happy with - // the part type - const updated = deepmerge(data, part, { arrayMerge }) - sessionStorage.setItem(detailsStorageName, JSON.stringify(updated)) - } else { - sessionStorage.setItem(detailsStorageName, JSON.stringify(part)) +export const selectRoomStatus = (state: DetailsState, index?: number) => + state.bookingProgress.roomStatuses[ + index ?? state.bookingProgress.currentRoomIndex + ] + +export const selectRoom = (state: DetailsState, index?: number) => + state.rooms[index ?? state.bookingProgress.currentRoomIndex] + +export const selectRoomSteps = (state: DetailsState, index?: number) => + state.bookingProgress.roomStatuses[ + index ?? state.bookingProgress.currentRoomIndex + ].steps + +export const selectNextStep = (roomStatus: RoomStatus) => { + if (!roomStatus.currentStep) { + throw new Error("getNextStep: currentStep is undefined") + } + + if (!roomStatus.steps[roomStatus.currentStep]?.isValid) { + return roomStatus.currentStep + } + + const stepsArray = Object.values(roomStatus.steps) + const currentIndex = stepsArray.findIndex( + (step) => step?.step === roomStatus.currentStep + ) + if (currentIndex === stepsArray.length - 1) { + return null + } + + const nextInvalidStep = stepsArray + .slice(currentIndex + 1) + .find((step) => !step.isValid) + + return nextInvalidStep?.step ?? null +} + +export const selectBookingProgress = (state: DetailsState) => + state.bookingProgress + +export const checkBookingProgress = (state: DetailsState) => { + return state.bookingProgress.roomStatuses.every((r) => r.isComplete) +} + +export const checkRoomProgress = (state: DetailsState) => { + const steps = selectRoomSteps(state) + return Object.values(steps) + .filter(Boolean) + .every((step) => step.isValid) +} + +export function handleStepProgression(state: DetailsState) { + const isAllRoomsCompleted = checkBookingProgress(state) + if (isAllRoomsCompleted) { + const roomStatus = selectRoomStatus(state) + roomStatus.currentStep = null + state.bookingProgress.canProceedToPayment = true + return + } + + const roomStatus = selectRoomStatus(state) + if (roomStatus.isComplete) { + const nextRoomIndex = state.bookingProgress.currentRoomIndex + 1 + + roomStatus.lastCompletedStep = roomStatus.currentStep ?? undefined + roomStatus.currentStep = null + const nextRoomStatus = selectRoomStatus(state, nextRoomIndex) + nextRoomStatus.currentStep = + Object.values(nextRoomStatus.steps).find((step) => !step.isValid)?.step ?? + StepEnum.selectBed + + const nextStep = selectNextStep(nextRoomStatus) + nextRoomStatus.currentStep = nextStep + state.bookingProgress.currentRoomIndex = nextRoomIndex + return + } + + const nextStep = selectNextStep(roomStatus) + if (nextStep && roomStatus.currentStep) { + roomStatus.lastCompletedStep = roomStatus.currentStep + roomStatus.currentStep = nextStep + return } } + +export function readFromSessionStorage(): PersistedState | undefined { + if (typeof window === "undefined") { + return undefined + } + + try { + const storedData = sessionStorage.getItem(detailsStorageName) + if (!storedData) { + return undefined + } + + const parsedData = JSON.parse(storedData) as PersistedState + + if ( + !parsedData.booking || + !parsedData.rooms || + !parsedData.bookingProgress + ) { + return undefined + } + + return parsedData + } catch (error) { + console.error("Error reading from session storage:", error) + return undefined + } +} + +export function writeToSessionStorage(state: PersistedState) { + if (typeof window === "undefined") { + return + } + + try { + sessionStorage.setItem(detailsStorageName, JSON.stringify(state)) + } catch (error) { + console.error("Error writing to session storage:", error) + } +} + +export function clearSessionStorage() { + if (typeof window === "undefined") { + return + } + sessionStorage.removeItem(detailsStorageName) +} diff --git a/stores/enter-details/index.ts b/stores/enter-details/index.ts index cb5263909..032568498 100644 --- a/stores/enter-details/index.ts +++ b/stores/enter-details/index.ts @@ -7,22 +7,23 @@ import { DetailsContext } from "@/contexts/Details" import { add, - calcTotalMemberPrice, - calcTotalPublicPrice, - checkIsSameRoom, + calcTotalPrice, + checkRoomProgress, extractGuestFromUser, - getInitialRoomPrice, - getInitialTotalPrice, - navigate, + getRoomPrice, + getTotalPrice, + handleStepProgression, + selectRoom, + selectRoomStatus, writeToSessionStorage, } from "./helpers" import { StepEnum } from "@/types/enums/step" import type { DetailsState, - FormValues, InitialState, - PersistedState, + RoomState, + RoomStatus, } from "@/types/stores/enter-details" import type { SafeUser } from "@/types/user" @@ -38,109 +39,128 @@ const defaultGuestState = { zipCode: "", } -export const detailsStorageName = "details-storage" +export const detailsStorageName = "rooms-details-storage" + export function createDetailsStore( initialState: InitialState, - currentStep: StepEnum, searchParams: string, user: SafeUser ) { const isMember = !!user - const formValues: FormValues = { - bedType: initialState.bedType, - booking: initialState.booking, - /** TODO: Needs adjustment when breakfast included in rate is added */ - breakfast: - initialState.breakfast === false ? initialState.breakfast : undefined, - guest: isMember - ? deepmerge(defaultGuestState, extractGuestFromUser(user)) - : defaultGuestState, - } - if (typeof window !== "undefined") { - const unparsedStorage = sessionStorage.getItem(detailsStorageName) - if (unparsedStorage) { - const detailsStorage: PersistedState = JSON.parse(unparsedStorage) - const isSameRoom = detailsStorage.booking - ? checkIsSameRoom(initialState.booking, detailsStorage.booking) - : false - if (isSameRoom) { - formValues.bedType = detailsStorage.bedType - formValues.breakfast = detailsStorage.breakfast - } - if (!isMember) { - formValues.guest = detailsStorage.guest - } - } - } - - let steps = [ - StepEnum.selectBed, - StepEnum.breakfast, - StepEnum.details, - StepEnum.payment, - ] - /** - * TODO: - * - when included in rate, can packages still be received? - * - no hotels yet with breakfast included in the rate so - * impossible to build for atm. - * - * checking against initialState since that means the - * hotel doesn't offer breakfast - * - * matching breakfast first so the steps array is altered - * before the bedTypes possible step altering - */ - if (initialState.breakfast === false) { - steps = steps.filter((step) => step !== StepEnum.breakfast) - if (currentStep === StepEnum.breakfast) { - currentStep = steps[1] - } - } - - if (initialState.bedType && currentStep === StepEnum.selectBed) { - currentStep = steps[1] - } - - const initialRoomPrice = getInitialRoomPrice(initialState.roomRate, isMember) - const initialTotalPrice = getInitialTotalPrice( - initialState.roomRate, + const initialTotalPrice = getTotalPrice( + initialState.rooms.map((r) => r.roomRate), isMember ) - if (initialState.packages) { - initialState.packages.forEach((pkg) => { - if (initialTotalPrice.requested) { - initialTotalPrice.requested.price = add( - initialTotalPrice.requested.price, - pkg.requestedPrice.totalPrice + initialState.rooms.forEach((room) => { + if (room.roomFeatures) { + room.roomFeatures.forEach((pkg) => { + if (initialTotalPrice.requested) { + initialTotalPrice.requested.price = add( + initialTotalPrice.requested.price, + pkg.requestedPrice.totalPrice + ) + } + initialTotalPrice.local.price = add( + initialTotalPrice.local.price, + pkg.localPrice.totalPrice ) - } - initialTotalPrice.local.price = add( - initialTotalPrice.local.price, - pkg.localPrice.totalPrice - ) - }) - } + }) + } + }) + + const rooms: RoomState[] = initialState.rooms.map((room, idx) => { + return { + ...room, + adults: initialState.booking.rooms[idx].adults, + childrenInRoom: initialState.booking.rooms[idx].childrenInRoom, + bedType: room.bedType, + breakfast: + initialState.breakfast === false ? initialState.breakfast : undefined, + guest: isMember + ? deepmerge(defaultGuestState, extractGuestFromUser(user)) + : defaultGuestState, + roomPrice: getRoomPrice(room.roomRate, isMember && idx === 0), + } + }) + + const roomStatuses: RoomStatus[] = initialState.rooms.map((room, idx) => { + const steps: RoomStatus["steps"] = { + [StepEnum.selectBed]: { + step: StepEnum.selectBed, + isValid: !!room.bedType, + }, + [StepEnum.breakfast]: { + step: StepEnum.breakfast, + isValid: false, + }, + [StepEnum.details]: { + step: StepEnum.details, + isValid: false, + }, + } + + if (initialState.breakfast === false) { + delete steps[StepEnum.breakfast] + } + + const currentStep = + idx === 0 + ? Object.values(steps).find((step) => !step.isValid)?.step ?? + StepEnum.selectBed + : null + + return { + isComplete: false, + currentStep: currentStep, + lastCompletedStep: undefined, + steps, + } + }) + + return create()((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()((set) => ({ actions: { - completeStep() { + setStep(step: StepEnum | null, roomIndex?: number) { + if (!step) { + return + } + return set( produce((state: DetailsState) => { - const currentStepIndex = state.steps.indexOf(state.currentStep) - const nextStep = state.steps[currentStepIndex + 1] - state.currentStep = nextStep - navigate(nextStep, state.searchParamString) - }) - ) - }, - navigate(step: StepEnum) { - return set( - produce((state) => { - state.currentStep = step - navigate(step, state.searchParamString) + const currentRoomIndex = + roomIndex ?? state.bookingProgress.currentRoomIndex + + const arePreviousRoomsCompleted = state.bookingProgress.roomStatuses + .slice(0, currentRoomIndex) + .every((room) => room.isComplete) + + const roomStatus = selectRoomStatus(state, roomIndex) + const roomStep = roomStatus.steps[step] + + if (arePreviousRoomsCompleted && roomStep?.isValid) { + roomStatus.currentStep = step + + if (roomIndex !== undefined) { + state.bookingProgress.currentRoomIndex = roomIndex + } + } }) ) }, @@ -151,13 +171,6 @@ export function createDetailsStore( }) ) }, - setStep(step: StepEnum) { - return set( - produce((state: DetailsState) => { - state.currentStep = step - }) - ) - }, setTotalPrice(totalPrice) { return set( produce((state: DetailsState) => { @@ -183,29 +196,39 @@ export function createDetailsStore( updateBedType(bedType) { return set( produce((state: DetailsState) => { - state.isValid["select-bed"] = true - state.bedType = bedType + const roomStatus = selectRoomStatus(state) + roomStatus.steps[StepEnum.selectBed].isValid = true - writeToSessionStorage({ bedType }) + const room = selectRoom(state) + room.bedType = bedType - const currentStepIndex = state.steps.indexOf(state.currentStep) - const nextStep = state.steps[currentStepIndex + 1] - state.currentStep = nextStep - navigate(nextStep, state.searchParamString) + handleStepProgression(state) + + writeToSessionStorage({ + booking: state.booking, + rooms: state.rooms, + bookingProgress: state.bookingProgress, + }) }) ) }, updateBreakfast(breakfast) { return set( produce((state: DetailsState) => { - state.isValid.breakfast = true + const roomStatus = selectRoomStatus(state) + if (roomStatus.steps[StepEnum.breakfast]) { + roomStatus.steps[StepEnum.breakfast].isValid = true + } + const stateTotalRequestedPrice = state.totalPrice.requested?.price || 0 + const stateTotalLocalPrice = state.totalPrice.local.price const addToTotalPrice = (state.breakfast === undefined || state.breakfast === false) && !!breakfast + const subtractFromTotalPrice = (state.breakfast === undefined || state.breakfast) && breakfast === false @@ -267,50 +290,64 @@ export function createDetailsStore( } } - state.breakfast = breakfast - writeToSessionStorage({ breakfast }) - const currentStepIndex = state.steps.indexOf(state.currentStep) - const nextStep = state.steps[currentStepIndex + 1] - state.currentStep = nextStep - navigate(nextStep, state.searchParamString) + const room = selectRoom(state) + room.breakfast = breakfast + + handleStepProgression(state) + + writeToSessionStorage({ + booking: state.booking, + rooms: state.rooms, + bookingProgress: state.bookingProgress, + }) }) ) }, updateDetails(data) { return set( produce((state: DetailsState) => { - state.isValid.details = true + const roomStatus = selectRoomStatus(state) + roomStatus.steps[StepEnum.details].isValid = true + + const room = selectRoom(state) + room.guest.countryCode = data.countryCode + room.guest.dateOfBirth = data.dateOfBirth + room.guest.email = data.email + room.guest.firstName = data.firstName + room.guest.join = data.join + room.guest.lastName = data.lastName - state.guest.countryCode = data.countryCode - state.guest.dateOfBirth = data.dateOfBirth - state.guest.email = data.email - state.guest.firstName = data.firstName - state.guest.join = data.join - state.guest.lastName = data.lastName if (data.join) { - state.guest.membershipNo = undefined + room.guest.membershipNo = undefined } else { - state.guest.membershipNo = data.membershipNo + room.guest.membershipNo = data.membershipNo } - state.guest.phoneNumber = data.phoneNumber - state.guest.zipCode = data.zipCode + room.guest.phoneNumber = data.phoneNumber + room.guest.zipCode = data.zipCode - if (data.join || data.membershipNo || isMember) { - const memberPrice = calcTotalMemberPrice(state) - state.roomPrice = memberPrice.roomPrice - state.totalPrice = memberPrice.totalPrice - } else { - const publicPrice = calcTotalPublicPrice(state) - state.roomPrice = publicPrice.roomPrice - state.totalPrice = publicPrice.totalPrice + room.roomPrice = getRoomPrice( + room.roomRate, + Boolean(data.join || data.membershipNo || isMember) + ) + + state.totalPrice = calcTotalPrice( + state.rooms, + state.totalPrice, + isMember + ) + + const isAllStepsCompleted = checkRoomProgress(state) + if (isAllStepsCompleted) { + roomStatus.isComplete = true } - writeToSessionStorage({ guest: data }) + handleStepProgression(state) - const currentStepIndex = state.steps.indexOf(state.currentStep) - const nextStep = state.steps[currentStepIndex + 1] - state.currentStep = nextStep - navigate(nextStep, state.searchParamString) + writeToSessionStorage({ + booking: state.booking, + rooms: state.rooms, + bookingProgress: state.bookingProgress, + }) }) ) }, @@ -322,31 +359,6 @@ export function createDetailsStore( ) }, }, - searchParamString: searchParams, - bedType: initialState.bedType ?? undefined, - booking: initialState.booking, - breakfast: - initialState.breakfast === false ? initialState.breakfast : undefined, - currentStep, - formValues, - guest: isMember - ? deepmerge(defaultGuestState, extractGuestFromUser(user)) - : defaultGuestState, - isSubmittingDisabled: false, - isSummaryOpen: false, - isPriceDetailsModalOpen: false, - isValid: { - [StepEnum.selectBed]: false, - [StepEnum.breakfast]: false, - [StepEnum.details]: false, - [StepEnum.payment]: false, - }, - packages: initialState.packages, - roomPrice: initialRoomPrice, - roomRate: initialState.roomRate, - steps, - totalPrice: initialTotalPrice, - vat: initialState.vat, })) } diff --git a/stores/enter-details/useEnterDetailsStore.test.tsx b/stores/enter-details/useEnterDetailsStore.test.tsx index ab3b15cf0..261b68da2 100644 --- a/stores/enter-details/useEnterDetailsStore.test.tsx +++ b/stores/enter-details/useEnterDetailsStore.test.tsx @@ -8,11 +8,14 @@ import { bedType, booking, breakfastPackage, + guestDetailsMember, guestDetailsNonMember, + roomPrice, roomRate, } from "@/__mocks__/hotelReservation" import EnterDetailsProvider from "@/providers/EnterDetailsProvider" +import { selectRoom, selectRoomStatus } from "./helpers" import { detailsStorageName, useEnterDetailsStore } from "." import { StepEnum } from "@/types/enums/step" @@ -31,22 +34,60 @@ jest.mock("@/lib/api", () => ({ fetchRetry: jest.fn((fn) => fn), })) -function Wrapper({ children }: PropsWithChildren) { - return ( - - {children} - - ) +interface CreateWrapperParams { + showBreakfastStep?: boolean + breakfastIncluded?: boolean + mustBeGuaranteed?: boolean + onlyOneBedType?: boolean +} + +function createWrapper(params: Partial = {}) { + const { + showBreakfastStep = true, + breakfastIncluded = false, + mustBeGuaranteed = false, + onlyOneBedType = false, + } = params + + return function Wrapper({ children }: PropsWithChildren) { + return ( + + {children} + + ) + } } describe("Enter Details Store", () => { @@ -58,27 +99,84 @@ describe("Enter Details Store", () => { const { result } = renderHook( () => useEnterDetailsStore((state) => state), { - wrapper: Wrapper, + wrapper: createWrapper(), } ) const state = result.current - expect(state.currentStep).toBe(StepEnum.selectBed) expect(state.booking).toEqual(booking) - expect(state.bedType).toEqual(undefined) expect(state.breakfast).toEqual(undefined) - expect(Object.values(state.guest).every((value) => value === "")) + + // room 1 + const room1Status = selectRoomStatus(result.current, 0) + const room1 = selectRoom(result.current, 0) + + expect(room1Status.currentStep).toBe(StepEnum.selectBed) + + expect(room1.roomPrice.perNight.local.price).toEqual( + roomRate.publicRate.localPrice.pricePerNight + ) + expect(room1.bedType).toEqual(undefined) + expect(Object.values(room1.guest).every((value) => value === "")) + + // room 2 + const room2Status = selectRoomStatus(result.current, 1) + const room2 = selectRoom(result.current, 1) + + expect(room2Status.currentStep).toBe(null) + expect(room2.roomPrice.perNight.local.price).toEqual( + room2.roomRate.publicRate.localPrice.pricePerNight + ) + expect(room2.bedType).toEqual(undefined) + expect(Object.values(room2.guest).every((value) => value === "")) }) - test("initialize with correct values from sessionStorage", async () => { + test("initialize with correct values from session storage", () => { const storage: PersistedState = { - bedType: { - roomTypeCode: bedType.queen.value, - description: bedType.queen.description, + booking: booking, + bookingProgress: { + currentRoomIndex: 0, + canProceedToPayment: true, + roomStatuses: [ + { + isComplete: false, + currentStep: StepEnum.selectBed, + lastCompletedStep: undefined, + steps: { + [StepEnum.selectBed]: { + step: StepEnum.selectBed, + isValid: true, + }, + [StepEnum.breakfast]: { + step: StepEnum.breakfast, + isValid: true, + }, + [StepEnum.details]: { + step: StepEnum.details, + isValid: true, + }, + }, + }, + ], }, - breakfast: breakfastPackage, - booking, - guest: guestDetailsNonMember, + rooms: [ + { + roomFeatures: null, + roomRate: roomRate, + roomType: "Classic Double", + cancellationText: "Non-refundable", + rateDetails: [], + bedType: { + roomTypeCode: bedType.king.value, + description: bedType.king.description, + }, + adults: 1, + childrenInRoom: [], + breakfast: breakfastPackage, + guest: guestDetailsNonMember, + roomPrice: roomPrice, + }, + ], } window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage)) @@ -86,26 +184,57 @@ describe("Enter Details Store", () => { const { result } = renderHook( () => useEnterDetailsStore((state) => state), { - wrapper: Wrapper, + wrapper: createWrapper(), } ) - const state = result.current - expect(state.bedType).toEqual(storage.bedType) - expect(state.guest).toEqual(storage.guest) - expect(state.booking).toEqual(storage.booking) - expect(state.breakfast).toEqual(storage.breakfast) + expect(result.current.booking).toEqual(storage.booking) + expect(result.current.rooms[0]).toEqual(storage.rooms[0]) + expect(result.current.bookingProgress).toEqual(storage.bookingProgress) + }) + + test("add bedtype and proceed to next step", async () => { + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: createWrapper(), + } + ) + + let roomStatus = selectRoomStatus(result.current) + expect(roomStatus.currentStep).toEqual(StepEnum.selectBed) + + const selectedBedType = { + roomTypeCode: bedType.king.value, + description: bedType.king.description, + } + + await act(async () => { + result.current.actions.updateBedType(selectedBedType) + }) + + roomStatus = selectRoomStatus(result.current) + const room = selectRoom(result.current) + + expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true) + expect(room.bedType).toEqual(selectedBedType) + + expect(roomStatus.currentStep).toEqual(StepEnum.breakfast) }) test("complete step and navigate to next step", async () => { const { result } = renderHook( () => useEnterDetailsStore((state) => state), { - wrapper: Wrapper, + wrapper: createWrapper(), } ) - expect(result.current.currentStep).toEqual(StepEnum.selectBed) + // Room 1 + expect(result.current.bookingProgress.currentRoomIndex).toEqual(0) + + let roomStatus = selectRoomStatus(result.current) + expect(roomStatus.currentStep).toEqual(StepEnum.selectBed) await act(async () => { result.current.actions.updateBedType({ @@ -114,24 +243,221 @@ describe("Enter Details Store", () => { }) }) - expect(result.current.isValid[StepEnum.selectBed]).toEqual(true) - expect(result.current.currentStep).toEqual(StepEnum.breakfast) - expect(window.location.pathname.slice(1)).toBe(StepEnum.breakfast) + roomStatus = selectRoomStatus(result.current) + expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true) + expect(roomStatus.currentStep).toEqual(StepEnum.breakfast) await act(async () => { result.current.actions.updateBreakfast(breakfastPackage) }) - expect(result.current.isValid[StepEnum.breakfast]).toEqual(true) - expect(result.current.currentStep).toEqual(StepEnum.details) - expect(window.location.pathname.slice(1)).toBe(StepEnum.details) + roomStatus = selectRoomStatus(result.current) + expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toEqual(true) + expect(roomStatus.currentStep).toEqual(StepEnum.details) await act(async () => { result.current.actions.updateDetails(guestDetailsNonMember) }) - expect(result.current.isValid[StepEnum.details]).toEqual(true) - expect(result.current.currentStep).toEqual(StepEnum.payment) - expect(window.location.pathname.slice(1)).toBe(StepEnum.payment) + expect(result.current.bookingProgress.canProceedToPayment).toBe(false) + + // Room 2 + expect(result.current.bookingProgress.currentRoomIndex).toEqual(1) + + roomStatus = selectRoomStatus(result.current) + expect(roomStatus.currentStep).toEqual(StepEnum.selectBed) + + await act(async () => { + const selectedBedType = { + roomTypeCode: bedType.king.value, + description: bedType.king.description, + } + result.current.actions.updateBedType(selectedBedType) + }) + + roomStatus = selectRoomStatus(result.current) + expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true) + expect(roomStatus.currentStep).toEqual(StepEnum.breakfast) + + await act(async () => { + result.current.actions.updateBreakfast(breakfastPackage) + }) + + roomStatus = selectRoomStatus(result.current) + expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toEqual(true) + expect(roomStatus.currentStep).toEqual(StepEnum.details) + + await act(async () => { + result.current.actions.updateDetails(guestDetailsNonMember) + }) + + expect(result.current.bookingProgress.canProceedToPayment).toBe(true) + }) + + test("all steps needs to be completed before going to next room", async () => { + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: createWrapper(), + } + ) + + await act(async () => { + result.current.actions.updateDetails(guestDetailsNonMember) + }) + + expect(result.current.bookingProgress.currentRoomIndex).toEqual(0) + + await act(async () => { + result.current.actions.setStep(StepEnum.breakfast, 1) + }) + + expect(result.current.bookingProgress.currentRoomIndex).toEqual(0) + }) + + test("can go back and modify room 1 after completion", async () => { + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: createWrapper(), + } + ) + + await act(async () => { + result.current.actions.updateBedType({ + roomTypeCode: bedType.king.value, + description: bedType.king.description, + }) + result.current.actions.updateBreakfast(breakfastPackage) + result.current.actions.updateDetails(guestDetailsNonMember) + }) + + // now we are at room 2 + expect(result.current.bookingProgress.currentRoomIndex).toEqual(1) + + await act(async () => { + result.current.actions.setStep(StepEnum.breakfast, 0) // click "modify" + }) + + expect(result.current.bookingProgress.currentRoomIndex).toEqual(0) + + await act(async () => { + result.current.actions.updateBreakfast(breakfastPackage) + }) + + // going back to room 2 + expect(result.current.bookingProgress.currentRoomIndex).toEqual(1) + }) + + test("total price should be set properly", async () => { + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: createWrapper(), + } + ) + + const publicRate = roomRate.publicRate.localPrice.pricePerStay + const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0 + + const initialTotalPrice = publicRate * result.current.rooms.length + expect(result.current.totalPrice.local.price).toEqual(initialTotalPrice) + + // room 1 + await act(async () => { + result.current.actions.updateBedType({ + roomTypeCode: bedType.king.value, + description: bedType.king.description, + }) + result.current.actions.updateBreakfast(breakfastPackage) + }) + + let expectedTotalPrice = + initialTotalPrice + Number(breakfastPackage.localPrice.price) + expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice) + + await act(async () => { + result.current.actions.updateDetails(guestDetailsMember) + }) + + expectedTotalPrice = + memberRate + publicRate + Number(breakfastPackage.localPrice.price) + expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice) + + // room 2 + await act(async () => { + result.current.actions.updateBedType({ + roomTypeCode: bedType.king.value, + description: bedType.king.description, + }) + result.current.actions.updateBreakfast(breakfastPackage) + }) + + expectedTotalPrice = + memberRate + publicRate + Number(breakfastPackage.localPrice.price) * 2 + expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice) + + await act(async () => { + result.current.actions.updateDetails(guestDetailsNonMember) + }) + + expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice) + }) + + test("room price should be set properly", async () => { + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: createWrapper(), + } + ) + + const publicRate = roomRate.publicRate.localPrice.pricePerStay + const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0 + + let room1 = selectRoom(result.current, 0) + expect(room1.roomPrice.perStay.local.price).toEqual(publicRate) + + let room2 = selectRoom(result.current, 0) + expect(room2.roomPrice.perStay.local.price).toEqual(publicRate) + + await act(async () => { + result.current.actions.updateDetails(guestDetailsMember) + }) + + room1 = selectRoom(result.current, 0) + expect(room1.roomPrice.perStay.local.price).toEqual(memberRate) + }) + + test("breakfast step should be hidden when breakfast is included", async () => { + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: createWrapper({ showBreakfastStep: false }), + } + ) + + const room1Status = selectRoomStatus(result.current, 0) + expect(Object.keys(room1Status.steps)).not.toContain(StepEnum.breakfast) + + const room2Status = selectRoomStatus(result.current, 1) + expect(Object.keys(room2Status.steps)).not.toContain(StepEnum.breakfast) + }) + + test("select bed step should be skipped when there is only one bedtype", async () => { + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: createWrapper({ onlyOneBedType: true }), + } + ) + + const room1Status = selectRoomStatus(result.current, 0) + expect(room1Status.steps[StepEnum.selectBed].isValid).toEqual(true) + expect(room1Status.currentStep).toEqual(StepEnum.breakfast) + + const room2Status = selectRoomStatus(result.current, 1) + expect(room2Status.steps[StepEnum.selectBed].isValid).toEqual(true) + expect(room2Status.currentStep).toEqual(null) }) }) diff --git a/types/components/hotelReservation/enterDetails/details.ts b/types/components/hotelReservation/enterDetails/details.ts index 786f48570..32171535c 100644 --- a/types/components/hotelReservation/enterDetails/details.ts +++ b/types/components/hotelReservation/enterDetails/details.ts @@ -1,14 +1,21 @@ import type { z } from "zod" +import type { Product } from "@/types/trpc/routers/hotel/roomAvailability" import type { SafeUser } from "@/types/user" import type { guestDetailsSchema, signedInDetailsSchema, } from "@/components/HotelReservation/EnterDetails/Details/schema" +import type { Price } from "../price" export type DetailsSchema = z.output export type SignedInDetailsSchema = z.output +export interface RoomPrice { + perNight: Price + perStay: Price +} + type MemberPrice = { currency: string price: number @@ -23,3 +30,8 @@ export type JoinScandicFriendsCardProps = { name: string memberPrice?: MemberPrice } + +export type RoomRate = { + publicRate: Product["productType"]["public"] + memberRate?: Product["productType"]["member"] +} diff --git a/types/components/hotelReservation/enterDetails/room.ts b/types/components/hotelReservation/enterDetails/room.ts index f96146620..b133a7d34 100644 --- a/types/components/hotelReservation/enterDetails/room.ts +++ b/types/components/hotelReservation/enterDetails/room.ts @@ -2,6 +2,7 @@ import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailabil export interface SelectedRoomProps { hotelId: string + roomType: string + roomTypeCode: string rateDescription: string - room: RoomConfiguration } diff --git a/types/components/hotelReservation/price.ts b/types/components/hotelReservation/price.ts new file mode 100644 index 000000000..d8db8321d --- /dev/null +++ b/types/components/hotelReservation/price.ts @@ -0,0 +1,9 @@ +interface TPrice { + currency: string + price: number +} + +export interface Price { + requested: TPrice | undefined + local: TPrice +} diff --git a/types/components/hotelReservation/selectRate/rateSummary.ts b/types/components/hotelReservation/selectRate/rateSummary.ts index 96fea385e..728170be2 100644 --- a/types/components/hotelReservation/selectRate/rateSummary.ts +++ b/types/components/hotelReservation/selectRate/rateSummary.ts @@ -1,5 +1,5 @@ -import type { Price } from "@/types/stores/enter-details" import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability" +import type { Price } from "../price" import type { RoomPackages } from "./roomFilter" import type { SelectRateSearchParams } from "./selectRate" diff --git a/types/components/hotelReservation/selectRate/section.ts b/types/components/hotelReservation/selectRate/section.ts index c36fe45ed..bf58f8d78 100644 --- a/types/components/hotelReservation/selectRate/section.ts +++ b/types/components/hotelReservation/selectRate/section.ts @@ -31,7 +31,6 @@ export interface DetailsProps extends SectionProps {} export interface PaymentProps { user: SafeUser - roomPrice: { publicPrice: number; memberPrice: number | undefined } otherPaymentOptions: PaymentMethodEnum[] mustBeGuaranteed: boolean supportedCards: PaymentMethodEnum[] diff --git a/types/components/hotelReservation/selectRate/sectionAccordion.ts b/types/components/hotelReservation/selectRate/sectionAccordion.ts index c50207f3a..c3049cde0 100644 --- a/types/components/hotelReservation/selectRate/sectionAccordion.ts +++ b/types/components/hotelReservation/selectRate/sectionAccordion.ts @@ -1,7 +1,8 @@ -import { StepEnum } from "@/types/enums/step" +import type { StepEnum } from "@/types/enums/step" export interface SectionAccordionProps { header: string label: string step: StepEnum + roomIndex: number } diff --git a/types/components/hotelReservation/summary.ts b/types/components/hotelReservation/summary.ts index f5f57b7b9..f08289af1 100644 --- a/types/components/hotelReservation/summary.ts +++ b/types/components/hotelReservation/summary.ts @@ -1,50 +1,46 @@ -import type { DetailsProviderProps } from "@/types/providers/enter-details" import type { Packages } from "@/types/requests/packages" -import type { - DetailsState, - Price, - RoomPrice, -} from "@/types/stores/enter-details" -import type { RoomAvailability } from "@/types/trpc/routers/hotel/roomAvailability" -import type { BedTypeSchema } from "./enterDetails/bedType" -import type { BreakfastPackage } from "./enterDetails/breakfast" -import type { DetailsSchema } from "./enterDetails/details" +import type { RoomState } from "@/types/stores/enter-details" +import type { RoomPrice, RoomRate } from "./enterDetails/details" +import type { Price } from "./price" import type { Child, SelectRateSearchParams } from "./selectRate/selectRate" -export type RoomsData = Pick & - Pick & - Pick & { - adults: number - children?: Child[] - packages: Packages | null - } +export type RoomsData = { + rateDetails: string[] | undefined + roomType: string + cancellationText: string + roomPrice: RoomPrice + adults: number + children?: Child[] + packages: Packages | null +} -export interface SummaryProps - extends Pick, - Pick { +export interface SummaryProps { isMember: boolean breakfastIncluded: boolean } export interface SummaryUIProps { booking: SelectRateSearchParams + isMember: boolean + totalPrice: Price + toggleSummaryOpen: () => void + togglePriceDetailsModalOpen: () => void + vat: number +} + +export interface EnterDetailsSummaryProps extends SummaryUIProps { + breakfastIncluded: boolean + rooms: RoomState[] +} + +export interface SelectRateSummaryProps extends SummaryUIProps { rooms: { adults: number childrenInRoom: Child[] | undefined - bedType: BedTypeSchema | undefined - breakfast: BreakfastPackage | false | undefined - guest: DetailsSchema | undefined - roomRate: DetailsProviderProps["roomRate"] - roomPrice: RoomPrice roomType: string + roomPrice: RoomPrice + roomRate: RoomRate rateDetails: string[] | undefined cancellationText: string }[] - isMember: boolean - breakfastIncluded: boolean - packages: Packages | null - totalPrice: Price - vat: number - toggleSummaryOpen: () => void - togglePriceDetailsModalOpen: () => void } diff --git a/types/enums/step.ts b/types/enums/step.ts index e52d3c856..c1dd14960 100644 --- a/types/enums/step.ts +++ b/types/enums/step.ts @@ -2,5 +2,4 @@ export enum StepEnum { selectBed = "select-bed", breakfast = "breakfast", details = "details", - payment = "payment", } diff --git a/types/providers/enter-details.ts b/types/providers/enter-details.ts index 12a03251e..b802c5c6c 100644 --- a/types/providers/enter-details.ts +++ b/types/providers/enter-details.ts @@ -2,17 +2,15 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter import type { StepEnum } from "@/types/enums/step" import type { RoomAvailability } from "@/types/trpc/routers/hotel/roomAvailability" import type { SafeUser } from "@/types/user" +import type { RoomData } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page" import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate" import type { Packages } from "../requests/packages" export interface DetailsProviderProps extends React.PropsWithChildren { booking: SelectRateSearchParams - bedTypes: BedTypeSelection[] showBreakfastStep: boolean - packages: Packages | null - roomRate: Pick + roomsData: RoomData[] searchParamsStr: string - step: StepEnum user: SafeUser vat: number } diff --git a/types/stores/enter-details.ts b/types/stores/enter-details.ts index 3d35afa57..271b3332c 100644 --- a/types/stores/enter-details.ts +++ b/types/stores/enter-details.ts @@ -2,41 +2,47 @@ import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDet import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" import type { DetailsSchema, + RoomPrice, + RoomRate, SignedInDetailsSchema, } from "@/types/components/hotelReservation/enterDetails/details" import type { StepEnum } from "@/types/enums/step" -import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate" -import type { DetailsProviderProps } from "../providers/enter-details" +import type { Price } from "../components/hotelReservation/price" +import type { + Child, + SelectRateSearchParams, +} from "../components/hotelReservation/selectRate/selectRate" import type { Packages } from "../requests/packages" -interface TPrice { - currency: string - price: number +export interface InitialRoomData { + roomRate: RoomRate + roomType: string + rateDetails: string[] | undefined + cancellationText: string + roomFeatures: Packages | null + bedType?: BedTypeSchema // used when there is only one bedtype to preselect it } -export interface RoomPrice { - perNight: Price - perStay: Price -} - -export interface Price { - requested: TPrice | undefined - local: TPrice -} - -export interface FormValues { +export interface RoomState extends InitialRoomData { + adults: number + childrenInRoom: Child[] | undefined bedType: BedTypeSchema | undefined - booking: SelectRateSearchParams breakfast: BreakfastPackage | false | undefined guest: DetailsSchema | SignedInDetailsSchema + roomPrice: RoomPrice +} + +export type InitialState = { + booking: SelectRateSearchParams + vat: number + rooms: InitialRoomData[] + breakfast?: false } export interface DetailsState { actions: { - completeStep: () => void - navigate: (step: StepEnum) => void + setStep: (step: StepEnum | null, roomIndex?: number) => void setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void - setStep: (step: StepEnum) => void setTotalPrice: (totalPrice: Price) => void toggleSummaryOpen: () => void togglePriceDetailsModalOpen: () => void @@ -45,40 +51,42 @@ export interface DetailsState { updateDetails: (data: DetailsSchema) => void updateSeachParamString: (searchParamString: string) => void } - bedType: BedTypeSchema | undefined booking: SelectRateSearchParams breakfast: BreakfastPackage | false | undefined - currentStep: StepEnum - formValues: FormValues - guest: DetailsSchema isSubmittingDisabled: boolean isSummaryOpen: boolean isPriceDetailsModalOpen: boolean - isValid: Record - packages: Packages | null - roomRate: DetailsProviderProps["roomRate"] - roomPrice: RoomPrice - steps: StepEnum[] + rooms: RoomState[] totalPrice: Price searchParamString: string vat: number + bookingProgress: BookingProgress } -export type InitialState = Pick & - Pick & { - bedType?: BedTypeSchema - breakfast?: false +export type PersistedState = { + booking: SelectRateSearchParams + bookingProgress: BookingProgress + rooms: RoomState[] +} + +export type RoomStep = { + step: StepEnum + isValid: boolean +} + +export type RoomStatus = { + isComplete: boolean + currentStep: StepEnum | null + lastCompletedStep: StepEnum | undefined + steps: { + [StepEnum.selectBed]: RoomStep + [StepEnum.breakfast]?: RoomStep + [StepEnum.details]: RoomStep } +} -export type RoomRate = DetailsProviderProps["roomRate"] - -export type PersistedState = Pick< - DetailsState, - "bedType" | "booking" | "breakfast" | "guest" -> - -export type PersistedStatePart = - | Pick - | Pick - | Pick - | Pick +export type BookingProgress = { + currentRoomIndex: number + roomStatuses: RoomStatus[] + canProceedToPayment: boolean +}