Merged in feat/enter-details-multiroom (pull request #1280)
feat(SW-1259): Enter details multiroom * refactor: remove per-step URLs * WIP: map multiroom data * fix: lint errors in details page * fix: made useEnterDetailsStore tests pass * fix: WIP refactor enter details store * fix: WIP enter details store update * fix: added room index to select correct room * fix: added logic for navigating between steps and rooms * fix: update summary to work with store changes * fix: added room and total price calculation * fix: removed unused code and added test for breakfast included * refactor: move store selectors into helpers * refactor: session storage state for multiroom booking * feat: update enter details accordion navigation * fix: added room index to each form component so they select correct room * fix: added unique id to input to handle case when multiple inputs have same name * fix: update payment step with store changes * fix: rebase issues * fix: now you should only be able to go to a step if previous room is completed * refactor: cleanup * fix: if no availability just skip that room for now * fix: add select-rate Summary and adjust typings Approved-by: Arvid Norlin
This commit is contained in:
committed by
Arvid Norlin
parent
f43ee4a0e6
commit
b394d54c3f
@@ -10,6 +10,7 @@ import {
|
||||
type ExtraBedTypeEnum,
|
||||
} from "@/constants/booking"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { selectRoom } from "@/stores/enter-details/helpers"
|
||||
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
|
||||
@@ -24,10 +25,12 @@ import type {
|
||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
const initialBedType = useEnterDetailsStore(
|
||||
(state) => state.formValues?.bedType?.roomTypeCode
|
||||
)
|
||||
export default function BedType({
|
||||
bedTypes,
|
||||
roomIndex,
|
||||
}: BedTypeProps & { roomIndex: number }) {
|
||||
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
|
||||
const initialBedType = room.bedType?.roomTypeCode
|
||||
|
||||
const updateBedType = useEnterDetailsStore(
|
||||
(state) => state.actions.updateBedType
|
||||
@@ -57,6 +60,12 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
[bedTypes, updateBedType]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (initialBedType) {
|
||||
methods.setValue("bedType", initialBedType)
|
||||
}
|
||||
}, [initialBedType, methods])
|
||||
|
||||
useEffect(() => {
|
||||
if (methods.formState.isSubmitting) {
|
||||
return
|
||||
@@ -109,9 +118,13 @@ function BedIconRenderer({
|
||||
extraBedType: ExtraBedTypeEnum | undefined
|
||||
props: IconProps
|
||||
}) {
|
||||
const MainBedIcon = BED_TYPE_ICONS[mainBedType]
|
||||
const MainBedIcon = BED_TYPE_ICONS[mainBedType] ?? BED_TYPE_ICONS.Other
|
||||
const ExtraBedIcon = extraBedType ? BED_TYPE_ICONS[extraBedType] : null
|
||||
|
||||
if (!MainBedIcon) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${props.className} ${styles.iconContainer}`}>
|
||||
<MainBedIcon height={32} color="uiTextMediumContrast" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { selectRoom } from "@/stores/enter-details/helpers"
|
||||
|
||||
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
@@ -22,16 +23,19 @@ import type {
|
||||
} from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default function Breakfast({ packages }: BreakfastProps) {
|
||||
export default function Breakfast({
|
||||
packages,
|
||||
roomIndex,
|
||||
}: BreakfastProps & { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
|
||||
const formValuesBreakfast = useEnterDetailsStore(({ formValues }) =>
|
||||
formValues?.breakfast
|
||||
? formValues.breakfast.code
|
||||
: formValues?.breakfast === false
|
||||
? "false"
|
||||
: undefined
|
||||
)
|
||||
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
|
||||
|
||||
const breakfastSelection = room?.breakfast
|
||||
? room.breakfast.code
|
||||
: room?.breakfast === false
|
||||
? "false"
|
||||
: undefined
|
||||
|
||||
const updateBreakfast = useEnterDetailsStore(
|
||||
(state) => state.actions.updateBreakfast
|
||||
@@ -42,8 +46,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
)
|
||||
|
||||
const methods = useForm<BreakfastFormSchema>({
|
||||
defaultValues: formValuesBreakfast
|
||||
? { breakfast: formValuesBreakfast }
|
||||
defaultValues: breakfastSelection
|
||||
? { breakfast: breakfastSelection }
|
||||
: undefined,
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
@@ -63,6 +67,12 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
[packages, updateBreakfast]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (breakfastSelection) {
|
||||
methods.setValue("breakfast", breakfastSelection)
|
||||
}
|
||||
}, [breakfastSelection, methods])
|
||||
|
||||
useEffect(() => {
|
||||
if (methods.formState.isSubmitting) {
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { selectRoom } from "@/stores/enter-details/helpers"
|
||||
|
||||
import { MagicWandIcon } from "@/components/Icons"
|
||||
import Modal from "@/components/Modal"
|
||||
@@ -23,7 +24,8 @@ export default function MemberPriceModal({
|
||||
isOpen: boolean
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>
|
||||
}) {
|
||||
const memberRate = useEnterDetailsStore((state) => state.roomRate.memberRate)
|
||||
const room = useEnterDetailsStore(selectRoom)
|
||||
const memberRate = room.roomRate.memberRate
|
||||
const intl = useIntl()
|
||||
|
||||
const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice
|
||||
|
||||
@@ -5,6 +5,10 @@ import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import {
|
||||
selectBookingProgress,
|
||||
selectRoom,
|
||||
} from "@/stores/enter-details/helpers"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
@@ -26,15 +30,25 @@ import type {
|
||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
const formID = "enter-details"
|
||||
export default function Details({ user, memberPrice }: DetailsProps) {
|
||||
export default function Details({
|
||||
user,
|
||||
memberPrice,
|
||||
roomIndex,
|
||||
}: DetailsProps & { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
const [isMemberPriceModalOpen, setIsMemberPriceModalOpen] = useState(false)
|
||||
|
||||
const initialData = useEnterDetailsStore((state) => state.guest)
|
||||
const { currentRoomIndex, canProceedToPayment, roomStatuses } =
|
||||
useEnterDetailsStore(selectBookingProgress)
|
||||
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
|
||||
const initialData = room.guest
|
||||
|
||||
const updateDetails = useEnterDetailsStore(
|
||||
(state) => state.actions.updateDetails
|
||||
)
|
||||
|
||||
const isPaymentNext = currentRoomIndex === roomStatuses.length - 1
|
||||
|
||||
const methods = useForm<DetailsSchema>({
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
@@ -68,7 +82,7 @@ export default function Details({ user, memberPrice }: DetailsProps) {
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.form}
|
||||
id={formID}
|
||||
id={`${formID}-room-${roomIndex + 1}`}
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
>
|
||||
{user ? null : (
|
||||
@@ -127,13 +141,23 @@ export default function Details({ user, memberPrice }: DetailsProps) {
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<Button
|
||||
disabled={!methods.formState.isValid}
|
||||
disabled={
|
||||
!(
|
||||
methods.formState.isValid ||
|
||||
(isPaymentNext && canProceedToPayment)
|
||||
)
|
||||
}
|
||||
intent="secondary"
|
||||
size="small"
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
{intl.formatMessage({ id: "Proceed to payment" })}
|
||||
{isPaymentNext
|
||||
? intl.formatMessage({ id: "Proceed to payment method" })
|
||||
: intl.formatMessage(
|
||||
{ id: "Continue to room {nextRoomNumber}" },
|
||||
{ nextRoomNumber: currentRoomIndex + 2 }
|
||||
)}
|
||||
</Button>
|
||||
</footer>
|
||||
<MemberPriceModal
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
export default function HistoryStateManager() {
|
||||
const setCurrentStep = useEnterDetailsStore((state) => state.actions.setStep)
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
|
||||
const handleBackButton = useCallback(
|
||||
(event: PopStateEvent) => {
|
||||
if (event.state.step) {
|
||||
setCurrentStep(event.state.step)
|
||||
}
|
||||
},
|
||||
[setCurrentStep]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("popstate", handleBackButton)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handleBackButton)
|
||||
}
|
||||
}, [handleBackButton])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.history.state.step) {
|
||||
window.history.replaceState(
|
||||
{ step: currentStep },
|
||||
"",
|
||||
document.location.href
|
||||
)
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { trackPaymentEvent } from "@/utils/tracking"
|
||||
import { 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
|
||||
|
||||
@@ -26,6 +26,7 @@ import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
@@ -55,7 +56,6 @@ function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
||||
|
||||
export default function PaymentClient({
|
||||
user,
|
||||
roomPrice,
|
||||
otherPaymentOptions,
|
||||
savedCreditCards,
|
||||
mustBeGuaranteed,
|
||||
@@ -65,13 +65,18 @@ export default function PaymentClient({
|
||||
const intl = useIntl()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
|
||||
const { bedType, booking, breakfast } = useEnterDetailsStore((state) => ({
|
||||
bedType: state.bedType,
|
||||
booking: state.booking,
|
||||
breakfast: state.breakfast,
|
||||
}))
|
||||
const userData = useEnterDetailsStore((state) => state.guest)
|
||||
const { totalPrice, booking, rooms, bookingProgress } = useEnterDetailsStore(
|
||||
(state) => {
|
||||
return {
|
||||
totalPrice: state.totalPrice,
|
||||
booking: state.booking,
|
||||
rooms: state.rooms,
|
||||
bookingProgress: state.bookingProgress,
|
||||
}
|
||||
}
|
||||
)
|
||||
const canProceedToPayment = bookingProgress.canProceedToPayment
|
||||
|
||||
const setIsSubmittingDisabled = useEnterDetailsStore(
|
||||
(state) => state.actions.setIsSubmittingDisabled
|
||||
)
|
||||
@@ -87,7 +92,7 @@ export default function PaymentClient({
|
||||
newPrice: number
|
||||
} | null>()
|
||||
|
||||
const { toDate, fromDate, rooms, hotelId } = booking
|
||||
const { toDate, fromDate, hotelId } = booking
|
||||
|
||||
usePaymentFailedToast()
|
||||
|
||||
@@ -115,7 +120,7 @@ export default function PaymentClient({
|
||||
|
||||
if (priceChange) {
|
||||
setPriceChangeData({
|
||||
oldPrice: roomPrice.publicPrice,
|
||||
oldPrice: rooms[0].roomPrice.perStay.local.price,
|
||||
newPrice: priceChange.totalPrice,
|
||||
})
|
||||
} else {
|
||||
@@ -202,18 +207,6 @@ export default function PaymentClient({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: PaymentFormData) => {
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phoneNumber,
|
||||
countryCode,
|
||||
membershipNo,
|
||||
join,
|
||||
dateOfBirth,
|
||||
zipCode,
|
||||
} = userData
|
||||
|
||||
// set payment method to card if saved card is submitted
|
||||
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
|
||||
? data.paymentMethod
|
||||
@@ -239,41 +232,50 @@ export default function PaymentClient({
|
||||
hotelId,
|
||||
checkInDate: fromDate,
|
||||
checkOutDate: toDate,
|
||||
rooms: rooms.map((room) => ({
|
||||
rooms: rooms.map((room, idx) => ({
|
||||
adults: room.adults,
|
||||
childrenAges: room.childrenInRoom?.map((child) => ({
|
||||
age: child.age,
|
||||
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
||||
})),
|
||||
rateCode:
|
||||
(user || join || membershipNo) && room.counterRateCode
|
||||
? room.counterRateCode
|
||||
: room.rateCode,
|
||||
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
||||
(user || room.guest.join || room.guest.membershipNo) &&
|
||||
booking.rooms[idx].counterRateCode
|
||||
? booking.rooms[idx].counterRateCode
|
||||
: booking.rooms[idx].rateCode,
|
||||
roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
||||
guest: {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phoneNumber,
|
||||
countryCode,
|
||||
membershipNumber: membershipNo,
|
||||
becomeMember: join,
|
||||
dateOfBirth,
|
||||
postalCode: zipCode,
|
||||
firstName: room.guest.firstName,
|
||||
lastName: room.guest.lastName,
|
||||
email: room.guest.email,
|
||||
phoneNumber: room.guest.phoneNumber,
|
||||
countryCode: room.guest.countryCode,
|
||||
membershipNumber: room.guest.membershipNo,
|
||||
becomeMember: room.guest.join,
|
||||
dateOfBirth: room.guest.dateOfBirth,
|
||||
postalCode: room.guest.zipCode,
|
||||
},
|
||||
packages: {
|
||||
breakfast: !!(breakfast && breakfast.code),
|
||||
breakfast: !!(room.breakfast && room.breakfast.code),
|
||||
allergyFriendly:
|
||||
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ??
|
||||
false,
|
||||
room.roomFeatures?.some(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
) ?? false,
|
||||
petFriendly:
|
||||
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
|
||||
room.roomFeatures?.some(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
) ?? false,
|
||||
accessibility:
|
||||
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
|
||||
false,
|
||||
room.roomFeatures?.some(
|
||||
(feature) =>
|
||||
feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||
) ?? false,
|
||||
},
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
roomPrice,
|
||||
roomPrice: {
|
||||
memberPrice: room.roomRate.memberRate?.localPrice.pricePerStay,
|
||||
publicPrice: room.roomRate.publicRate.localPrice.pricePerStay,
|
||||
},
|
||||
})),
|
||||
payment: {
|
||||
paymentMethod,
|
||||
@@ -292,7 +294,6 @@ export default function PaymentClient({
|
||||
})
|
||||
},
|
||||
[
|
||||
userData,
|
||||
savedCreditCards,
|
||||
lang,
|
||||
initiateBooking,
|
||||
@@ -301,9 +302,7 @@ export default function PaymentClient({
|
||||
toDate,
|
||||
rooms,
|
||||
user,
|
||||
bedType,
|
||||
breakfast,
|
||||
roomPrice,
|
||||
booking,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -316,8 +315,22 @@ export default function PaymentClient({
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
const paymentGuarantee = intl.formatMessage({
|
||||
id: "Payment Guarantee",
|
||||
})
|
||||
const payment = intl.formatMessage({
|
||||
id: "Payment",
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
className={`${styles.paymentSection} ${canProceedToPayment ? "" : styles.disabled}`}
|
||||
>
|
||||
<header>
|
||||
<Title level="h2" as="h4">
|
||||
{mustBeGuaranteed ? paymentGuarantee : payment}
|
||||
</Title>
|
||||
</header>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
@@ -460,6 +473,6 @@ export default function PaymentClient({
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { PaymentProps } from "@/types/components/hotelReservation/selectRat
|
||||
|
||||
export default async function Payment({
|
||||
user,
|
||||
roomPrice,
|
||||
otherPaymentOptions,
|
||||
mustBeGuaranteed,
|
||||
supportedCards,
|
||||
@@ -18,7 +17,6 @@ export default async function Payment({
|
||||
return (
|
||||
<PaymentClient
|
||||
user={user}
|
||||
roomPrice={roomPrice}
|
||||
otherPaymentOptions={otherPaymentOptions}
|
||||
savedCreditCards={savedCreditCards}
|
||||
mustBeGuaranteed={mustBeGuaranteed}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
.paymentSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.paymentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,8 +3,14 @@ import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import {
|
||||
selectBookingProgress,
|
||||
selectNextStep,
|
||||
selectRoom,
|
||||
selectRoomStatus,
|
||||
} from "@/stores/enter-details/helpers"
|
||||
|
||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@/components/Icons"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useScrollToActiveSection from "@/hooks/booking/useScrollToActiveSection"
|
||||
@@ -19,24 +25,31 @@ export default function SectionAccordion({
|
||||
header,
|
||||
label,
|
||||
step,
|
||||
roomIndex,
|
||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||
const intl = useIntl()
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
const steps = useEnterDetailsStore((state) => state.steps)
|
||||
const roomStatus = useEnterDetailsStore((state) =>
|
||||
selectRoomStatus(state, roomIndex)
|
||||
)
|
||||
|
||||
const setStep = useEnterDetailsStore((state) => state.actions.setStep)
|
||||
const { bedType, breakfast } = useEnterDetailsStore((state) =>
|
||||
selectRoom(state, roomIndex)
|
||||
)
|
||||
const { roomStatuses, currentRoomIndex } = useEnterDetailsStore((state) =>
|
||||
selectBookingProgress(state)
|
||||
)
|
||||
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
||||
const navigate = useEnterDetailsStore((state) => state.actions.navigate)
|
||||
const { bedType, breakfast } = useEnterDetailsStore((state) => ({
|
||||
bedType: state.bedType,
|
||||
breakfast: state.breakfast,
|
||||
}))
|
||||
const isValid = roomStatus.steps[step]?.isValid ?? false
|
||||
|
||||
const [title, setTitle] = useState(label)
|
||||
|
||||
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
|
||||
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
|
||||
|
||||
useScrollToActiveSection(step, steps, currentStep === step)
|
||||
// useScrollToActiveSection(step, steps, roomStatus.currentStep === step)
|
||||
|
||||
useEffect(() => {
|
||||
if (step === StepEnum.selectBed && bedType) {
|
||||
@@ -57,11 +70,29 @@ export default function SectionAccordion({
|
||||
}, [isValid, setIsComplete])
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(currentStep === step)
|
||||
}, [currentStep, setIsOpen, step])
|
||||
setIsOpen(roomStatus.currentStep === step && currentRoomIndex === roomIndex)
|
||||
}, [currentRoomIndex, roomIndex, roomStatus.currentStep, setIsOpen, step])
|
||||
|
||||
function onModify() {
|
||||
navigate(step)
|
||||
setStep(step, roomIndex)
|
||||
}
|
||||
|
||||
function close() {
|
||||
setIsOpen(false)
|
||||
const isLastStep = step === StepEnum.details
|
||||
const hasNextRoom = roomIndex + 1 <= roomStatuses.length
|
||||
|
||||
if (!isLastStep) {
|
||||
const nextStep = selectNextStep(roomStatus)
|
||||
if (nextStep) {
|
||||
setStep(nextStep, roomIndex)
|
||||
}
|
||||
} else if (isLastStep && hasNextRoom) {
|
||||
setStep(StepEnum.selectBed, roomIndex + 1)
|
||||
} else {
|
||||
// Time for payment, collapse any open step
|
||||
setStep(null)
|
||||
}
|
||||
}
|
||||
|
||||
const textColor =
|
||||
@@ -81,7 +112,7 @@ export default function SectionAccordion({
|
||||
</div>
|
||||
<header className={styles.header}>
|
||||
<button
|
||||
onClick={onModify}
|
||||
onClick={isOpen ? close : onModify}
|
||||
disabled={!isComplete}
|
||||
className={styles.modifyButton}
|
||||
>
|
||||
@@ -97,9 +128,11 @@ export default function SectionAccordion({
|
||||
<Subtitle className={styles.selection} type="two" color={textColor}>
|
||||
{title}
|
||||
</Subtitle>
|
||||
|
||||
{isComplete && !isOpen && (
|
||||
<ChevronDownIcon className={styles.button} color="burgundy" />
|
||||
{isComplete && (
|
||||
<ChevronDownIcon
|
||||
className={`${styles.button} ${isOpen ? styles.buttonOpen : ""}`}
|
||||
color="burgundy"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ import type { SelectedRoomProps } from "@/types/components/hotelReservation/ente
|
||||
|
||||
export default function SelectedRoom({
|
||||
hotelId,
|
||||
room,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
rateDescription,
|
||||
}: SelectedRoomProps) {
|
||||
const intl = useIntl()
|
||||
@@ -50,7 +51,7 @@ export default function SelectedRoom({
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{ id: "{roomType} <rate>{rateDescription}</rate>" },
|
||||
{
|
||||
roomType: room.roomType,
|
||||
roomType: roomType,
|
||||
rateDescription,
|
||||
rate: (str) => {
|
||||
return <span className={styles.rate}>{str}</span>
|
||||
@@ -70,12 +71,9 @@ export default function SelectedRoom({
|
||||
{intl.formatMessage({ id: "Change room" })}{" "}
|
||||
</Link>
|
||||
</div>
|
||||
{room?.roomTypeCode && (
|
||||
{roomTypeCode && (
|
||||
<div className={styles.details}>
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={room.roomTypeCode}
|
||||
/>
|
||||
<ToggleSidePeek hotelId={hotelId} roomTypeCode={roomTypeCode} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,6 @@
|
||||
@media screen and (min-width: 768px) {
|
||||
.wrapper {
|
||||
gap: var(--Spacing-x3);
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
|
||||
@@ -7,54 +7,16 @@ import SidePanel from "@/components/HotelReservation/SidePanel"
|
||||
import SummaryUI from "./UI"
|
||||
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
|
||||
function storeSelector(state: DetailsState) {
|
||||
return {
|
||||
bedType: state.bedType,
|
||||
booking: state.booking,
|
||||
breakfast: state.breakfast,
|
||||
guest: state.guest,
|
||||
packages: state.packages,
|
||||
roomRate: state.roomRate,
|
||||
roomPrice: state.roomPrice,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen: state.actions.togglePriceDetailsModalOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
vat: state.vat,
|
||||
}
|
||||
}
|
||||
|
||||
export default function DesktopSummary(props: SummaryProps) {
|
||||
const {
|
||||
bedType,
|
||||
booking,
|
||||
breakfast,
|
||||
guest,
|
||||
packages,
|
||||
roomPrice,
|
||||
roomRate,
|
||||
toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen,
|
||||
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
|
||||
totalPrice,
|
||||
vat,
|
||||
} = useEnterDetailsStore(storeSelector)
|
||||
} = useEnterDetailsStore((state) => state)
|
||||
|
||||
// TODO: rooms should be part of store
|
||||
const rooms = [
|
||||
{
|
||||
adults: booking.rooms[0].adults,
|
||||
childrenInRoom: booking.rooms[0].childrenInRoom,
|
||||
bedType,
|
||||
breakfast,
|
||||
guest,
|
||||
roomRate,
|
||||
roomPrice,
|
||||
roomType: props.roomType,
|
||||
rateDetails: props.rateDetails,
|
||||
cancellationText: props.cancellationText,
|
||||
},
|
||||
]
|
||||
const rooms = useEnterDetailsStore((state) => state.rooms)
|
||||
|
||||
return (
|
||||
<SidePanel variant="summary">
|
||||
@@ -63,7 +25,6 @@ export default function DesktopSummary(props: SummaryProps) {
|
||||
rooms={rooms}
|
||||
isMember={props.isMember}
|
||||
breakfastIncluded={props.breakfastIncluded}
|
||||
packages={packages}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
|
||||
@@ -10,55 +10,23 @@ import SummaryBottomSheet from "./BottomSheet"
|
||||
import styles from "./mobile.module.css"
|
||||
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
|
||||
function storeSelector(state: DetailsState) {
|
||||
return {
|
||||
bedType: state.bedType,
|
||||
booking: state.booking,
|
||||
breakfast: state.breakfast,
|
||||
guest: state.guest,
|
||||
packages: state.packages,
|
||||
roomRate: state.roomRate,
|
||||
roomPrice: state.roomPrice,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen: state.actions.togglePriceDetailsModalOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
vat: state.vat,
|
||||
}
|
||||
}
|
||||
|
||||
export default function MobileSummary(props: SummaryProps) {
|
||||
const {
|
||||
bedType,
|
||||
booking,
|
||||
breakfast,
|
||||
guest,
|
||||
packages,
|
||||
roomPrice,
|
||||
roomRate,
|
||||
toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen,
|
||||
actions: { toggleSummaryOpen, togglePriceDetailsModalOpen },
|
||||
totalPrice,
|
||||
vat,
|
||||
} = useEnterDetailsStore(storeSelector)
|
||||
} = useEnterDetailsStore((state) => state)
|
||||
|
||||
const rooms = useEnterDetailsStore((state) => state.rooms)
|
||||
|
||||
const showPromo =
|
||||
!props.isMember &&
|
||||
rooms.length === 1 &&
|
||||
!rooms[0].guest.join &&
|
||||
!rooms[0].guest.membershipNo
|
||||
|
||||
// TODO: rooms should be part of store
|
||||
const rooms = [
|
||||
{
|
||||
adults: booking.rooms[0].adults,
|
||||
childrenInRoom: booking.rooms[0].childrenInRoom,
|
||||
bedType,
|
||||
breakfast,
|
||||
guest,
|
||||
roomRate,
|
||||
roomPrice,
|
||||
roomType: props.roomType,
|
||||
rateDetails: props.rateDetails,
|
||||
cancellationText: props.cancellationText,
|
||||
},
|
||||
]
|
||||
const showPromo = !props.isMember && !guest.join && !guest.membershipNo
|
||||
return (
|
||||
<div className={styles.mobileSummary}>
|
||||
{showPromo ? <SignupPromoMobile /> : null}
|
||||
@@ -69,7 +37,6 @@ export default function MobileSummary(props: SummaryProps) {
|
||||
rooms={rooms}
|
||||
isMember={props.isMember}
|
||||
breakfastIncluded={props.breakfastIncluded}
|
||||
packages={packages}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
|
||||
@@ -24,20 +24,19 @@ import { formatPrice } from "@/utils/numberFormatting"
|
||||
import styles from "./ui.module.css"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { SummaryUIProps } from "@/types/components/hotelReservation/summary"
|
||||
import type { DetailsProviderProps } from "@/types/providers/enter-details"
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
export default function SummaryUI({
|
||||
booking,
|
||||
rooms,
|
||||
packages,
|
||||
totalPrice,
|
||||
isMember,
|
||||
breakfastIncluded,
|
||||
vat,
|
||||
toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen,
|
||||
}: SummaryUIProps) {
|
||||
}: EnterDetailsSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
@@ -60,8 +59,8 @@ export default function SummaryUI({
|
||||
}
|
||||
}
|
||||
|
||||
function getMemberPrice(roomRate: DetailsProviderProps["roomRate"]) {
|
||||
return roomRate.memberRate
|
||||
function getMemberPrice(roomRate: RoomRate) {
|
||||
return roomRate?.memberRate
|
||||
? {
|
||||
currency: roomRate.memberRate.localPrice.currency,
|
||||
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
|
||||
@@ -74,7 +73,7 @@ export default function SummaryUI({
|
||||
rooms.length === 1 &&
|
||||
rooms
|
||||
.slice(0, 1)
|
||||
.some((r) => !isMember || !r?.guest?.join || !r?.guest?.membershipNo)
|
||||
.some((r) => !isMember || !r.guest.join || !r.guest.membershipNo)
|
||||
|
||||
const memberPrice = getMemberPrice(rooms[0].roomRate)
|
||||
|
||||
@@ -127,11 +126,8 @@ export default function SummaryUI({
|
||||
|
||||
const isFirstRoomMember = roomNumber === 1 && isMember
|
||||
const showMemberPrice =
|
||||
!!(
|
||||
isFirstRoomMember ||
|
||||
room?.guest?.join ||
|
||||
room?.guest?.membershipNo
|
||||
) && memberPrice
|
||||
!!(isFirstRoomMember || room.guest.join || room.guest.membershipNo) &&
|
||||
memberPrice
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
||||
@@ -206,20 +202,20 @@ export default function SummaryUI({
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
{packages
|
||||
? packages.map((roomPackage) => (
|
||||
<div className={styles.entry} key={roomPackage.code}>
|
||||
{room.roomFeatures
|
||||
? room.roomFeatures.map((feature) => (
|
||||
<div className={styles.entry} key={feature.code}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{roomPackage.description}
|
||||
{feature.description}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
parseInt(roomPackage.localPrice.price),
|
||||
roomPackage.localPrice.currency
|
||||
parseInt(feature.localPrice.price),
|
||||
feature.localPrice.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
ChevronDownSmallIcon,
|
||||
} from "@/components/Icons"
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
export default function Summary({
|
||||
booking,
|
||||
rooms,
|
||||
totalPrice,
|
||||
isMember,
|
||||
vat,
|
||||
toggleSummaryOpen,
|
||||
togglePriceDetailsModalOpen,
|
||||
}: SelectRateSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
function handleToggleSummary() {
|
||||
if (toggleSummaryOpen) {
|
||||
toggleSummaryOpen()
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePriceDetailsModal() {
|
||||
if (togglePriceDetailsModalOpen) {
|
||||
togglePriceDetailsModalOpen()
|
||||
}
|
||||
}
|
||||
|
||||
function getMemberPrice(roomRate: RoomRate) {
|
||||
return roomRate?.memberRate
|
||||
? {
|
||||
currency: roomRate.memberRate.localPrice.currency,
|
||||
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
|
||||
amount: roomRate.memberRate.localPrice.pricePerStay,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
const memberPrice = getMemberPrice(rooms[0].roomRate)
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header className={styles.header}>
|
||||
<Subtitle className={styles.title} type="two">
|
||||
{intl.formatMessage({ id: "Booking summary" })}
|
||||
</Subtitle>
|
||||
<Body className={styles.date} color="baseTextMediumContrast">
|
||||
{dt(booking.fromDate).locale(lang).format("ddd, D MMM")}
|
||||
<ArrowRightIcon color="peach80" height={15} width={15} />
|
||||
{dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
||||
</Body>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
className={styles.chevronButton}
|
||||
onClick={handleToggleSummary}
|
||||
>
|
||||
<ChevronDownSmallIcon height="20" width="20" />
|
||||
</Button>
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
{rooms.map((room, idx) => {
|
||||
const roomNumber = idx + 1
|
||||
const adults = room.adults
|
||||
const childrenInRoom = room.childrenInRoom
|
||||
|
||||
const childrenBeds = childrenInRoom?.reduce(
|
||||
(acc, value) => {
|
||||
const bedType = Number(value.bed)
|
||||
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
|
||||
return acc
|
||||
}
|
||||
const count = acc.get(bedType) ?? 0
|
||||
acc.set(bedType, count + 1)
|
||||
return acc
|
||||
},
|
||||
new Map<ChildBedMapEnum, number>([
|
||||
[ChildBedMapEnum.IN_CRIB, 0],
|
||||
[ChildBedMapEnum.IN_EXTRA_BED, 0],
|
||||
])
|
||||
)
|
||||
|
||||
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
|
||||
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
|
||||
|
||||
const memberPrice = getMemberPrice(room.roomRate)
|
||||
const showMemberPrice = !!(isMember && memberPrice)
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
||||
{ totalAdults: adults }
|
||||
)
|
||||
|
||||
const guestsParts = [adultsMsg]
|
||||
if (childrenInRoom?.length) {
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
id: "{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{ totalChildren: childrenInRoom.length }
|
||||
)
|
||||
guestsParts.push(childrenMsg)
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<div
|
||||
className={styles.addOns}
|
||||
data-testid={`summary-room-${roomNumber}`}
|
||||
>
|
||||
<div>
|
||||
{rooms.length > 1 ? (
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Room" })} {roomNumber}
|
||||
</Body>
|
||||
) : null}
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{guestsParts.join(", ")}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{room.cancellationText}
|
||||
</Caption>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<Caption color="burgundy" type="underline">
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Caption>
|
||||
</Button>
|
||||
}
|
||||
title={room.cancellationText}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{room.rateDetails?.map((info) => (
|
||||
<Body
|
||||
key={info}
|
||||
color="uiTextHighContrast"
|
||||
className={styles.termsText}
|
||||
>
|
||||
<CheckIcon
|
||||
color="uiSemanticSuccess"
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.termsIcon}
|
||||
></CheckIcon>
|
||||
{info}
|
||||
</Body>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
{childBedCrib ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Crib (child) × {count}" },
|
||||
{ count: childBedCrib }
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Based on availability" })}
|
||||
</Caption>
|
||||
</div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
0,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{childBedExtraBed ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Extra bed (child) × {count}" },
|
||||
{
|
||||
count: childBedExtraBed,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
0,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
<div className={styles.total}>
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<PriceDetailsModal
|
||||
fromDate={booking.fromDate}
|
||||
toDate={booking.toDate}
|
||||
rooms={rooms.map((r) => ({
|
||||
adults: r.adults,
|
||||
childrenInRoom: r.childrenInRoom,
|
||||
roomPrice: r.roomPrice,
|
||||
roomType: r.roomType,
|
||||
}))}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
toggleModal={handleTogglePriceDetailsModal}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Body textTransform="bold" data-testid="total-price">
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency
|
||||
)}
|
||||
</Body>
|
||||
{totalPrice.requested && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Approx. {value}" },
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
totalPrice.requested.price,
|
||||
totalPrice.requested.currency
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||
</div>
|
||||
{!isMember && memberPrice ? (
|
||||
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -3,12 +3,13 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
|
||||
|
||||
import SummaryUI from "@/components/HotelReservation/EnterDetails/Summary/UI"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import Summary from "./Summary"
|
||||
|
||||
import styles from "./mobileSummary.module.css"
|
||||
|
||||
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||
@@ -55,9 +56,6 @@ export default function MobileSummary({
|
||||
},
|
||||
currency: room.public.localPrice.currency,
|
||||
},
|
||||
bedType: undefined,
|
||||
breakfast: undefined,
|
||||
guest: undefined,
|
||||
roomRate: {
|
||||
...room.public,
|
||||
memberRate: room.member,
|
||||
@@ -99,14 +97,12 @@ export default function MobileSummary({
|
||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.summaryAccordion}>
|
||||
<SummaryUI
|
||||
<Summary
|
||||
booking={booking}
|
||||
rooms={rooms}
|
||||
isMember={isUserLoggedIn}
|
||||
packages={undefined}
|
||||
totalPrice={totalPriceToShow}
|
||||
vat={vat}
|
||||
breakfastIncluded={false}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
togglePriceDetailsModalOpen={togglePriceDetailsModalOpen}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
.summary {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-areas: "title button" "date button";
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.chevronButton {
|
||||
grid-area: button;
|
||||
justify-self: end;
|
||||
align-items: center;
|
||||
margin-right: calc(0px - var(--Spacing-x2));
|
||||
}
|
||||
|
||||
.date {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: flex-start;
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.addOns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rateDetailsPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry > :last-child {
|
||||
justify-items: flex-end;
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bottomDivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
width: 560px;
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin-top: var(--Spacing-x3);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
.terms .termsIcon {
|
||||
margin-right: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.bottomDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary .header .chevronButton {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import {
|
||||
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[],
|
||||
|
||||
@@ -178,7 +178,7 @@ export default function Rooms({
|
||||
e.preventDefault()
|
||||
|
||||
window.history.pushState(null, "", `${pathname}?${queryParams.toString()}`)
|
||||
router.push(`select-bed?${queryParams}`)
|
||||
router.push(`details?${queryParams}`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -270,7 +270,7 @@ export default function Rooms({
|
||||
{rateSummary && (
|
||||
<form
|
||||
method="GET"
|
||||
action={`select-bed?${searchParams}`}
|
||||
action={`details?${searchParams}`}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<RateSummary
|
||||
|
||||
Reference in New Issue
Block a user