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

feat(SW-1259): Enter details multiroom

* refactor: remove per-step URLs

* WIP: map multiroom data

* fix: lint errors in details page

* fix: made useEnterDetailsStore tests pass

* fix: WIP refactor enter details store

* fix: WIP enter details store update

* fix: added room index to select correct room

* fix: added logic for navigating between steps and rooms

* fix: update summary to work with store changes

* fix: added room and total price calculation

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

* refactor: move store selectors into helpers

* refactor: session storage state for multiroom booking

* feat: update enter details accordion navigation

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

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

* fix: update payment step with store changes

* fix: rebase issues

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

* refactor: cleanup

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

* fix: add select-rate Summary and adjust typings


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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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