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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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