Merged in feat/enter-details-multiroom (pull request #1280)
feat(SW-1259): Enter details multiroom * refactor: remove per-step URLs * WIP: map multiroom data * fix: lint errors in details page * fix: made useEnterDetailsStore tests pass * fix: WIP refactor enter details store * fix: WIP enter details store update * fix: added room index to select correct room * fix: added logic for navigating between steps and rooms * fix: update summary to work with store changes * fix: added room and total price calculation * fix: removed unused code and added test for breakfast included * refactor: move store selectors into helpers * refactor: session storage state for multiroom booking * feat: update enter details accordion navigation * fix: added room index to each form component so they select correct room * fix: added unique id to input to handle case when multiple inputs have same name * fix: update payment step with store changes * fix: rebase issues * fix: now you should only be able to go to a step if previous room is completed * refactor: cleanup * fix: if no availability just skip that room for now * fix: add select-rate Summary and adjust typings Approved-by: Arvid Norlin
This commit is contained in:
committed by
Arvid Norlin
parent
f43ee4a0e6
commit
b394d54c3f
@@ -5,13 +5,14 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import type {
|
||||
DetailsSchema,
|
||||
RoomPrice,
|
||||
RoomRate,
|
||||
SignedInDetailsSchema,
|
||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import { PackageTypeEnum } from "@/types/enums/packages"
|
||||
import type { RoomPrice, RoomRate } from "@/types/stores/enter-details"
|
||||
|
||||
export const booking: SelectRateSearchParams = {
|
||||
city: "Stockholm",
|
||||
@@ -27,6 +28,14 @@ export const booking: SelectRateSearchParams = {
|
||||
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
||||
packages: [RoomPackageCodeEnum.PET_ROOM],
|
||||
},
|
||||
{
|
||||
adults: 2,
|
||||
roomTypeCode: "",
|
||||
rateCode: "",
|
||||
counterRateCode: "",
|
||||
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
||||
packages: [RoomPackageCodeEnum.PET_ROOM],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/constants/booking"
|
||||
import {
|
||||
bookingConfirmation,
|
||||
payment,
|
||||
details,
|
||||
} from "@/constants/routes/hotelReservation"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
@@ -38,7 +38,7 @@ export default async function PaymentCallbackPage({
|
||||
redirect(confirmationUrl)
|
||||
}
|
||||
|
||||
const returnUrl = payment(lang)
|
||||
const returnUrl = details(lang)
|
||||
const searchObject = new URLSearchParams()
|
||||
|
||||
let errorMessage = undefined
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
.content {
|
||||
width: var(--max-width-page);
|
||||
margin: var(--Spacing-x3) auto 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.header {
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.summary {
|
||||
@@ -0,0 +1,259 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import {
|
||||
getBreakfastPackages,
|
||||
getHotel,
|
||||
getPackages,
|
||||
getProfileSafely,
|
||||
getSelectedRoomAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
||||
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
||||
import Details from "@/components/HotelReservation/EnterDetails/Details"
|
||||
import HotelHeader from "@/components/HotelReservation/EnterDetails/Header"
|
||||
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
||||
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
|
||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
|
||||
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
|
||||
import { generateChildrenString } from "@/components/HotelReservation/utils"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
|
||||
import { convertSearchParamsToObj } from "@/utils/url"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import { type SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
import type { Packages } from "@/types/requests/packages"
|
||||
|
||||
export interface RoomData {
|
||||
bedTypes?: BedTypeSelection[]
|
||||
mustBeGuaranteed?: boolean
|
||||
breakfastIncluded?: boolean
|
||||
packages: Packages | null
|
||||
cancellationText: string
|
||||
rateDetails: string[]
|
||||
roomType: string
|
||||
roomRate: RoomRate
|
||||
}
|
||||
|
||||
export default async function DetailsPage({
|
||||
params: { lang },
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, SelectRateSearchParams>) {
|
||||
setLang(lang)
|
||||
|
||||
const intl = await getIntl()
|
||||
const selectRoomParams = new URLSearchParams(searchParams)
|
||||
const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
|
||||
|
||||
void getProfileSafely()
|
||||
|
||||
const breakfastInput = {
|
||||
adults: 1,
|
||||
fromDate: booking.fromDate,
|
||||
hotelId: booking.hotelId,
|
||||
toDate: booking.toDate,
|
||||
}
|
||||
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
||||
const roomsData: RoomData[] = []
|
||||
|
||||
for (let room of booking.rooms) {
|
||||
const childrenAsString =
|
||||
room.childrenInRoom && generateChildrenString(room.childrenInRoom)
|
||||
|
||||
const selectedRoomAvailabilityInput = {
|
||||
adults: room.adults,
|
||||
children: childrenAsString,
|
||||
hotelId: booking.hotelId,
|
||||
packageCodes: room.packages,
|
||||
rateCode: room.rateCode,
|
||||
roomStayStartDate: booking.fromDate,
|
||||
roomStayEndDate: booking.toDate,
|
||||
roomTypeCode: room.roomTypeCode,
|
||||
}
|
||||
|
||||
const packages = room.packages
|
||||
? await getPackages({
|
||||
adults: room.adults,
|
||||
children: room.childrenInRoom?.length,
|
||||
endDate: booking.toDate,
|
||||
hotelId: booking.hotelId,
|
||||
packageCodes: room.packages,
|
||||
startDate: booking.fromDate,
|
||||
})
|
||||
: null
|
||||
|
||||
const roomAvailability = await getSelectedRoomAvailability(
|
||||
selectedRoomAvailabilityInput //
|
||||
)
|
||||
|
||||
if (!roomAvailability) {
|
||||
continue // TODO: handle no room availability
|
||||
}
|
||||
|
||||
roomsData.push({
|
||||
bedTypes: roomAvailability.bedTypes,
|
||||
packages,
|
||||
mustBeGuaranteed: roomAvailability.mustBeGuaranteed,
|
||||
breakfastIncluded: roomAvailability.breakfastIncluded,
|
||||
cancellationText: roomAvailability.cancellationText,
|
||||
rateDetails: roomAvailability.rateDetails ?? [],
|
||||
roomType: roomAvailability.selectedRoom.roomType,
|
||||
roomRate: {
|
||||
memberRate: roomAvailability?.memberRate,
|
||||
publicRate: roomAvailability.publicRate,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const isCardOnlyPayment = roomsData.some((room) => room?.mustBeGuaranteed)
|
||||
const hotelData = await getHotel({
|
||||
hotelId: booking.hotelId,
|
||||
isCardOnlyPayment,
|
||||
language: lang,
|
||||
})
|
||||
const user = await getProfileSafely()
|
||||
// const userTrackingData = await getUserTracking()
|
||||
|
||||
if (!hotelData || !roomsData) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
// const arrivalDate = new Date(booking.fromDate)
|
||||
// const departureDate = new Date(booking.toDate)
|
||||
const hotelAttributes = hotelData.hotel
|
||||
|
||||
// TODO: add tracking
|
||||
// const initialHotelsTrackingData: TrackingSDKHotelInfo = {
|
||||
// searchTerm: searchParams.city,
|
||||
// arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
|
||||
// departureDate: format(departureDate, "yyyy-MM-dd"),
|
||||
// noOfAdults: adults,
|
||||
// noOfChildren: childrenInRoom?.length,
|
||||
// ageOfChildren: childrenInRoom?.map((c) => c.age).join(","),
|
||||
// childBedPreference: childrenInRoom
|
||||
// ?.map((c) => ChildBedMapEnum[c.bed])
|
||||
// .join("|"),
|
||||
// noOfRooms: 1, // // TODO: Handle multiple rooms
|
||||
// duration: differenceInCalendarDays(departureDate, arrivalDate),
|
||||
// leadTime: differenceInCalendarDays(arrivalDate, new Date()),
|
||||
// searchType: "hotel",
|
||||
// bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
|
||||
// country: hotelAttributes?.address.country,
|
||||
// hotelID: hotelAttributes?.operaId,
|
||||
// region: hotelAttributes?.address.city,
|
||||
// }
|
||||
|
||||
const showBreakfastStep = Boolean(
|
||||
breakfastPackages?.length && !roomsData[0].breakfastIncluded
|
||||
)
|
||||
|
||||
return (
|
||||
<EnterDetailsProvider
|
||||
booking={booking}
|
||||
showBreakfastStep={showBreakfastStep}
|
||||
roomsData={roomsData}
|
||||
searchParamsStr={selectRoomParams.toString()}
|
||||
user={user}
|
||||
vat={hotelAttributes.vat}
|
||||
>
|
||||
<main>
|
||||
<HotelHeader hotelData={hotelData} />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
{roomsData.map((room, idx) => (
|
||||
<section key={idx}>
|
||||
{roomsData.length > 1 && (
|
||||
<header className={styles.header}>
|
||||
<Title level="h2" as="h4">
|
||||
{intl.formatMessage({ id: "Room" })} {idx + 1}
|
||||
</Title>
|
||||
</header>
|
||||
)}
|
||||
<SelectedRoom
|
||||
hotelId={booking.hotelId}
|
||||
roomType={room.roomType}
|
||||
roomTypeCode={booking.rooms[idx].roomTypeCode}
|
||||
rateDescription={room.cancellationText}
|
||||
/>
|
||||
|
||||
{room.bedTypes ? (
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Select bed" })}
|
||||
label={intl.formatMessage({ id: "Request bedtype" })}
|
||||
step={StepEnum.selectBed}
|
||||
roomIndex={idx}
|
||||
>
|
||||
<BedType bedTypes={room.bedTypes} roomIndex={idx} />
|
||||
</SectionAccordion>
|
||||
) : null}
|
||||
|
||||
{showBreakfastStep ? (
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Food options" })}
|
||||
label={intl.formatMessage({
|
||||
id: "Select breakfast options",
|
||||
})}
|
||||
step={StepEnum.breakfast}
|
||||
roomIndex={idx}
|
||||
>
|
||||
<Breakfast packages={breakfastPackages!} roomIndex={idx} />
|
||||
</SectionAccordion>
|
||||
) : null}
|
||||
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Details" })}
|
||||
step={StepEnum.details}
|
||||
label={intl.formatMessage({ id: "Enter your details" })}
|
||||
roomIndex={idx}
|
||||
>
|
||||
<Details
|
||||
user={idx === 0 ? user : null}
|
||||
memberPrice={{
|
||||
currency:
|
||||
room?.roomRate.memberRate?.localPrice.currency ?? "", // TODO: how to handle undefined,
|
||||
price:
|
||||
room?.roomRate.memberRate?.localPrice.pricePerNight ??
|
||||
0, // TODO: how to handle undefined,
|
||||
}}
|
||||
roomIndex={idx}
|
||||
/>
|
||||
</SectionAccordion>
|
||||
</section>
|
||||
))}
|
||||
<Suspense>
|
||||
<Payment
|
||||
user={user}
|
||||
otherPaymentOptions={
|
||||
hotelAttributes.merchantInformationData
|
||||
.alternatePaymentOptions
|
||||
}
|
||||
supportedCards={hotelAttributes.merchantInformationData.cards}
|
||||
mustBeGuaranteed={isCardOnlyPayment}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<aside className={styles.summary}>
|
||||
<MobileSummary
|
||||
isMember={!!user}
|
||||
breakfastIncluded={roomsData[0].breakfastIncluded ?? false}
|
||||
/>
|
||||
<DesktopSummary
|
||||
isMember={!!user}
|
||||
breakfastIncluded={roomsData[0].breakfastIncluded ?? false}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</EnterDetailsProvider>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { usePathname } from "next/navigation"
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { selectRoom } from "@/stores/enter-details/helpers"
|
||||
|
||||
import { useSessionId } from "@/hooks/useSessionId"
|
||||
import { createSDKPageObject, trackPageView } from "@/utils/tracking"
|
||||
@@ -36,8 +37,11 @@ export default function EnterDetailsTracking(props: Props) {
|
||||
cancellationRule,
|
||||
} = props
|
||||
|
||||
const { bedType, breakfast, totalPrice, roomPrice, roomRate, packages } =
|
||||
useEnterDetailsStore((state) => state)
|
||||
const { bedType, breakfast, roomPrice, roomRate, roomFeatures } =
|
||||
useEnterDetailsStore(selectRoom)
|
||||
|
||||
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
|
||||
|
||||
const pathName = usePathname()
|
||||
const sessionId = useSessionId()
|
||||
|
||||
@@ -128,7 +132,7 @@ export default function EnterDetailsTracking(props: Props) {
|
||||
revenueCurrencyCode: totalPrice.local?.currency,
|
||||
breakfastOption: breakfast ? "breakfast buffet" : "no breakfast",
|
||||
totalPrice: totalPrice.local?.price,
|
||||
specialRoomType: getSpecialRoomType(packages),
|
||||
specialRoomType: getSpecialRoomType(roomFeatures),
|
||||
roomTypeName: selectedRoom.roomType,
|
||||
bedType: bedType?.description,
|
||||
roomTypeCode: bedType?.roomTypeCode,
|
||||
@@ -148,7 +152,7 @@ export default function EnterDetailsTracking(props: Props) {
|
||||
totalPrice,
|
||||
roomPrice,
|
||||
roomRate,
|
||||
packages,
|
||||
roomFeatures,
|
||||
initialHotelsTrackingData,
|
||||
cancellationRule,
|
||||
selectedRoom.roomType,
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import {
|
||||
getBreakfastPackages,
|
||||
getHotel,
|
||||
getPackages,
|
||||
getProfileSafely,
|
||||
getSelectedRoomAvailability,
|
||||
getUserTracking,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
||||
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
||||
import Details from "@/components/HotelReservation/EnterDetails/Details"
|
||||
import HotelHeader from "@/components/HotelReservation/EnterDetails/Header"
|
||||
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
|
||||
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
||||
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
|
||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
|
||||
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
|
||||
import { generateChildrenString } from "@/components/HotelReservation/utils"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
|
||||
import { convertSearchParamsToObj } from "@/utils/url"
|
||||
|
||||
import EnterDetailsTracking from "./enterDetailsTracking"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import { type SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { type TrackingSDKHotelInfo } from "@/types/components/tracking"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
function isValidStep(step: string): step is StepEnum {
|
||||
return Object.values(StepEnum).includes(step as StepEnum)
|
||||
}
|
||||
|
||||
export default async function StepPage({
|
||||
params: { lang },
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) {
|
||||
if (!isValidStep(searchParams.step)) {
|
||||
return notFound()
|
||||
}
|
||||
setLang(lang)
|
||||
|
||||
const intl = await getIntl()
|
||||
const selectRoomParams = new URLSearchParams(searchParams)
|
||||
// Deleting step to avoid double searchparams after rewrite
|
||||
selectRoomParams.delete("step")
|
||||
const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
|
||||
const {
|
||||
hotelId,
|
||||
rooms: [
|
||||
{
|
||||
adults,
|
||||
childrenInRoom,
|
||||
roomTypeCode,
|
||||
rateCode,
|
||||
packages: packageCodes,
|
||||
},
|
||||
], // TODO: Handle multiple rooms
|
||||
fromDate,
|
||||
toDate,
|
||||
} = booking
|
||||
|
||||
const childrenAsString =
|
||||
childrenInRoom && generateChildrenString(childrenInRoom)
|
||||
const breakfastInput = { adults, fromDate, hotelId, toDate }
|
||||
const selectedRoomAvailabilityInput = {
|
||||
adults,
|
||||
children: childrenAsString,
|
||||
hotelId,
|
||||
packageCodes,
|
||||
rateCode,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
roomTypeCode,
|
||||
}
|
||||
|
||||
void getProfileSafely()
|
||||
void getBreakfastPackages(breakfastInput)
|
||||
void getSelectedRoomAvailability(selectedRoomAvailabilityInput)
|
||||
if (packageCodes?.length) {
|
||||
void getPackages({
|
||||
adults,
|
||||
children: childrenInRoom?.length,
|
||||
endDate: toDate,
|
||||
hotelId,
|
||||
packageCodes,
|
||||
startDate: fromDate,
|
||||
})
|
||||
}
|
||||
|
||||
const packages = packageCodes
|
||||
? await getPackages({
|
||||
adults,
|
||||
children: childrenInRoom?.length,
|
||||
endDate: toDate,
|
||||
hotelId,
|
||||
packageCodes,
|
||||
startDate: fromDate,
|
||||
})
|
||||
: null
|
||||
|
||||
const roomAvailability = await getSelectedRoomAvailability(
|
||||
selectedRoomAvailabilityInput
|
||||
)
|
||||
const hotelData = await getHotel({
|
||||
hotelId,
|
||||
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
|
||||
language: lang,
|
||||
})
|
||||
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
||||
const user = await getProfileSafely()
|
||||
const userTrackingData = await getUserTracking()
|
||||
|
||||
if (!hotelData || !roomAvailability) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const { mustBeGuaranteed, breakfastIncluded } = roomAvailability
|
||||
|
||||
const paymentGuarantee = intl.formatMessage({
|
||||
id: "Payment Guarantee",
|
||||
})
|
||||
const payment = intl.formatMessage({
|
||||
id: "Payment",
|
||||
})
|
||||
const guaranteeWithCard = intl.formatMessage({
|
||||
id: "Guarantee booking with credit card",
|
||||
})
|
||||
const selectPaymentMethod = intl.formatMessage({
|
||||
id: "Select payment method",
|
||||
})
|
||||
|
||||
const roomPrice = {
|
||||
memberPrice: roomAvailability.memberRate?.localPrice.pricePerStay,
|
||||
publicPrice: roomAvailability.publicRate!.localPrice.pricePerStay,
|
||||
}
|
||||
|
||||
const memberPrice = roomAvailability.memberRate
|
||||
? {
|
||||
price: roomAvailability.memberRate.localPrice.pricePerStay,
|
||||
currency: roomAvailability.memberRate.localPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const arrivalDate = new Date(fromDate)
|
||||
const departureDate = new Date(toDate)
|
||||
const hotelAttributes = hotelData?.hotel
|
||||
|
||||
const initialHotelsTrackingData: TrackingSDKHotelInfo = {
|
||||
searchTerm: searchParams.city,
|
||||
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
|
||||
departureDate: format(departureDate, "yyyy-MM-dd"),
|
||||
noOfAdults: adults,
|
||||
noOfChildren: childrenInRoom?.length,
|
||||
ageOfChildren: childrenInRoom?.map((c) => c.age).join(","),
|
||||
childBedPreference: childrenInRoom
|
||||
?.map((c) => ChildBedMapEnum[c.bed])
|
||||
.join("|"),
|
||||
noOfRooms: 1, // // TODO: Handle multiple rooms
|
||||
duration: differenceInCalendarDays(departureDate, arrivalDate),
|
||||
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
|
||||
searchType: "hotel",
|
||||
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
|
||||
country: hotelAttributes?.address.country,
|
||||
hotelID: hotelAttributes?.operaId,
|
||||
region: hotelAttributes?.address.city,
|
||||
}
|
||||
|
||||
const summary = {
|
||||
cancellationText: roomAvailability.cancellationText,
|
||||
isMember: !!user,
|
||||
rateDetails: roomAvailability.rateDetails,
|
||||
roomType: roomAvailability.selectedRoom.roomType,
|
||||
breakfastIncluded,
|
||||
}
|
||||
|
||||
const showBreakfastStep = Boolean(
|
||||
breakfastPackages?.length && !breakfastIncluded
|
||||
)
|
||||
|
||||
return (
|
||||
<EnterDetailsProvider
|
||||
bedTypes={roomAvailability.bedTypes}
|
||||
booking={booking}
|
||||
showBreakfastStep={showBreakfastStep}
|
||||
packages={packages}
|
||||
roomRate={{
|
||||
memberRate: roomAvailability.memberRate,
|
||||
publicRate: roomAvailability.publicRate,
|
||||
}}
|
||||
searchParamsStr={selectRoomParams.toString()}
|
||||
step={searchParams.step}
|
||||
user={user}
|
||||
vat={hotelAttributes.vat}
|
||||
>
|
||||
<main>
|
||||
<HotelHeader hotelData={hotelData} />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<section>
|
||||
<HistoryStateManager />
|
||||
<SelectedRoom
|
||||
hotelId={hotelId}
|
||||
room={roomAvailability.selectedRoom}
|
||||
rateDescription={roomAvailability.cancellationText}
|
||||
/>
|
||||
|
||||
{/* TODO: How to handle no beds found? */}
|
||||
{roomAvailability.bedTypes ? (
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Bed type" })}
|
||||
label={intl.formatMessage({ id: "Request bedtype" })}
|
||||
step={StepEnum.selectBed}
|
||||
>
|
||||
<BedType bedTypes={roomAvailability.bedTypes} />
|
||||
</SectionAccordion>
|
||||
) : null}
|
||||
|
||||
{showBreakfastStep ? (
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Food options" })}
|
||||
label={intl.formatMessage({ id: "Select breakfast options" })}
|
||||
step={StepEnum.breakfast}
|
||||
>
|
||||
<Breakfast packages={breakfastPackages!} />
|
||||
</SectionAccordion>
|
||||
) : null}
|
||||
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Details" })}
|
||||
step={StepEnum.details}
|
||||
label={intl.formatMessage({ id: "Enter your details" })}
|
||||
>
|
||||
<Details user={user} memberPrice={memberPrice} />
|
||||
</SectionAccordion>
|
||||
|
||||
<SectionAccordion
|
||||
header={mustBeGuaranteed ? paymentGuarantee : payment}
|
||||
step={StepEnum.payment}
|
||||
label={
|
||||
mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod
|
||||
}
|
||||
>
|
||||
<Suspense>
|
||||
<Payment
|
||||
user={user}
|
||||
roomPrice={roomPrice}
|
||||
otherPaymentOptions={
|
||||
hotelData.hotel.merchantInformationData
|
||||
.alternatePaymentOptions
|
||||
}
|
||||
supportedCards={
|
||||
hotelData.hotel.merchantInformationData.cards
|
||||
}
|
||||
mustBeGuaranteed={mustBeGuaranteed}
|
||||
/>
|
||||
</Suspense>
|
||||
</SectionAccordion>
|
||||
</section>
|
||||
</div>
|
||||
<aside className={styles.summary}>
|
||||
<MobileSummary {...summary} />
|
||||
<DesktopSummary {...summary} />
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<EnterDetailsTracking
|
||||
initialHotelsTrackingData={initialHotelsTrackingData}
|
||||
userTrackingData={userTrackingData}
|
||||
selectedRoom={roomAvailability.selectedRoom}
|
||||
cancellationRule={roomAvailability.cancellationText}
|
||||
lang={lang}
|
||||
/>
|
||||
</EnterDetailsProvider>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ExtraBedTypeEnum,
|
||||
} 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ForwardedRef, forwardRef } from "react"
|
||||
import { type ForwardedRef, forwardRef, useId } from "react"
|
||||
import { Input as AriaInput, Label as AriaLabel } from "react-aria-components"
|
||||
|
||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||
@@ -12,10 +12,13 @@ const AriaInputWithLabel = forwardRef(function AriaInputWithLabelComponent(
|
||||
{ label, ...props }: AriaInputWithLabelProps,
|
||||
ref: ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
const uniqueId = useId()
|
||||
const inputId = `${uniqueId}-${props.name}`
|
||||
|
||||
return (
|
||||
<AriaLabel className={styles.container} htmlFor={props.name}>
|
||||
<AriaLabel className={styles.container} htmlFor={inputId}>
|
||||
<Body asChild fontOnly>
|
||||
<AriaInput {...props} className={styles.input} ref={ref} />
|
||||
<AriaInput {...props} className={styles.input} ref={ref} id={inputId} />
|
||||
</Body>
|
||||
<Label required={!!props.required}>{label}</Label>
|
||||
</AriaLabel>
|
||||
|
||||
@@ -23,20 +23,6 @@ export function details(lang) {
|
||||
return `${hotelreservation(lang)}/details`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Lang} lang
|
||||
*/
|
||||
export function payment(lang) {
|
||||
return `${hotelreservation(lang)}/payment`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Lang} lang
|
||||
*/
|
||||
export function selectBed(lang) {
|
||||
return `${hotelreservation(lang)}/select-bed`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Lang} lang
|
||||
*/
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
"Contact our memberservice": "Contact our memberservice",
|
||||
"Contact us": "Contact us",
|
||||
"Continue": "Continue",
|
||||
"Continue to room {nextRoomNumber}": "Continue to room {nextRoomNumber}",
|
||||
"Copied to clipboard": "Copied to clipboard",
|
||||
"Copy promotion code": "Copy promotion code",
|
||||
"Could not find requested resource": "Could not find requested resource",
|
||||
|
||||
@@ -82,8 +82,7 @@ const nextConfig = {
|
||||
// ----------------------------------------
|
||||
// hotel (hotelId) param missing
|
||||
// ----------------------------------------
|
||||
source:
|
||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
||||
source: "/:lang/hotelreservation/details",
|
||||
destination: "/:lang/hotelreservation/select-rate",
|
||||
missing: [
|
||||
{
|
||||
@@ -98,8 +97,7 @@ const nextConfig = {
|
||||
// ----------------------------------------
|
||||
// hotel (hotelId) param has to be an integer
|
||||
// ----------------------------------------
|
||||
source:
|
||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
||||
source: "/:lang/hotelreservation/details",
|
||||
destination: "/:lang/hotelreservation/select-rate",
|
||||
missing: [
|
||||
{
|
||||
@@ -114,8 +112,7 @@ const nextConfig = {
|
||||
// ----------------------------------------
|
||||
// fromdate param missing
|
||||
// ----------------------------------------
|
||||
source:
|
||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
||||
source: "/:lang/hotelreservation/details",
|
||||
destination: "/:lang/hotelreservation/select-rate",
|
||||
missing: [
|
||||
{
|
||||
@@ -130,8 +127,7 @@ const nextConfig = {
|
||||
// ----------------------------------------
|
||||
// fromdate param has to be a date
|
||||
// ----------------------------------------
|
||||
source:
|
||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
||||
source: "/:lang/hotelreservation/details",
|
||||
destination: "/:lang/hotelreservation/select-rate",
|
||||
missing: [
|
||||
{
|
||||
@@ -146,8 +142,7 @@ const nextConfig = {
|
||||
// ----------------------------------------
|
||||
// todate param missing
|
||||
// ----------------------------------------
|
||||
source:
|
||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
||||
source: "/:lang/hotelreservation/details",
|
||||
destination: "/:lang/hotelreservation/select-rate",
|
||||
missing: [
|
||||
{
|
||||
@@ -162,8 +157,7 @@ const nextConfig = {
|
||||
// ----------------------------------------
|
||||
// todate param has to be a date
|
||||
// ----------------------------------------
|
||||
source:
|
||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
||||
source: "/:lang/hotelreservation/details",
|
||||
destination: "/:lang/hotelreservation/select-rate",
|
||||
missing: [
|
||||
{
|
||||
@@ -178,8 +172,7 @@ const nextConfig = {
|
||||
// ----------------------------------------
|
||||
// room[0].adults param missing
|
||||
// ----------------------------------------
|
||||
source:
|
||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
||||
source: "/:lang/hotelreservation/details",
|
||||
destination: "/:lang/hotelreservation/select-rate",
|
||||
missing: [
|
||||
{
|
||||
@@ -194,8 +187,7 @@ const nextConfig = {
|
||||
// ----------------------------------------
|
||||
// room[0].adults param has to be an integer
|
||||
// ----------------------------------------
|
||||
source:
|
||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
||||
source: "/:lang/hotelreservation/details",
|
||||
destination: "/:lang/hotelreservation/select-rate",
|
||||
missing: [
|
||||
{
|
||||
@@ -210,8 +202,7 @@ const nextConfig = {
|
||||
// ----------------------------------------
|
||||
// room[0].ratecode param missing
|
||||
// ----------------------------------------
|
||||
source:
|
||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
||||
source: "/:lang/hotelreservation/details",
|
||||
destination: "/:lang/hotelreservation/select-rate",
|
||||
missing: [
|
||||
{
|
||||
@@ -226,8 +217,7 @@ const nextConfig = {
|
||||
// ----------------------------------------
|
||||
// room[0].roomtype param missing
|
||||
// ----------------------------------------
|
||||
source:
|
||||
"/:lang/hotelreservation/(select-bed|breakfast|details|payment)",
|
||||
source: "/:lang/hotelreservation/details",
|
||||
destination: "/:lang/hotelreservation/select-rate",
|
||||
missing: [
|
||||
{
|
||||
@@ -278,11 +268,6 @@ const nextConfig = {
|
||||
source: `${myPages.sv}/:path*`,
|
||||
destination: `/sv/my-pages/:path*`,
|
||||
},
|
||||
{
|
||||
source:
|
||||
"/:lang/hotelreservation/:step(breakfast|details|payment|select-bed)",
|
||||
destination: "/:lang/hotelreservation/step?step=:step",
|
||||
},
|
||||
{
|
||||
source: "/:lang/hotelreservation/payment-callback/:status",
|
||||
destination:
|
||||
|
||||
@@ -3,136 +3,74 @@ import { useEffect, useRef } from "react"
|
||||
|
||||
import { createDetailsStore } from "@/stores/enter-details"
|
||||
import {
|
||||
calcTotalMemberPrice,
|
||||
calcTotalPublicPrice,
|
||||
navigate,
|
||||
writeToSessionStorage,
|
||||
checkIsSameRoom,
|
||||
clearSessionStorage,
|
||||
readFromSessionStorage,
|
||||
} from "@/stores/enter-details/helpers"
|
||||
|
||||
import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema"
|
||||
import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema"
|
||||
import {
|
||||
guestDetailsSchema,
|
||||
signedInDetailsSchema,
|
||||
} from "@/components/HotelReservation/EnterDetails/Details/schema"
|
||||
import { DetailsContext } from "@/contexts/Details"
|
||||
|
||||
import type { DetailsStore } from "@/types/contexts/enter-details"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { DetailsProviderProps } from "@/types/providers/enter-details"
|
||||
import type { DetailsState, InitialState } from "@/types/stores/enter-details"
|
||||
import type { InitialState } from "@/types/stores/enter-details"
|
||||
|
||||
export default function EnterDetailsProvider({
|
||||
bedTypes,
|
||||
booking,
|
||||
showBreakfastStep,
|
||||
children,
|
||||
packages,
|
||||
roomRate,
|
||||
roomsData,
|
||||
searchParamsStr,
|
||||
step,
|
||||
user,
|
||||
vat,
|
||||
}: DetailsProviderProps) {
|
||||
const storeRef = useRef<DetailsStore>()
|
||||
|
||||
if (!storeRef.current) {
|
||||
const initialData: InitialState = { booking, packages, roomRate, vat }
|
||||
if (bedTypes.length === 1) {
|
||||
initialData.bedType = {
|
||||
description: bedTypes[0].description,
|
||||
roomTypeCode: bedTypes[0].value,
|
||||
}
|
||||
const initialData: InitialState = {
|
||||
booking,
|
||||
rooms: roomsData
|
||||
.filter((r) => r.bedTypes?.length) // TODO: how to handle room without bedtypes?
|
||||
.map((room) => ({
|
||||
roomFeatures: room.packages,
|
||||
roomRate: room.roomRate,
|
||||
roomType: room.roomType,
|
||||
cancellationText: room.cancellationText,
|
||||
rateDetails: room.rateDetails,
|
||||
bedType:
|
||||
room.bedTypes?.length === 1
|
||||
? {
|
||||
roomTypeCode: room.bedTypes[0].value,
|
||||
description: room.bedTypes[0].description,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
vat,
|
||||
}
|
||||
|
||||
if (!showBreakfastStep) {
|
||||
initialData.breakfast = false
|
||||
}
|
||||
|
||||
storeRef.current = createDetailsStore(
|
||||
initialData,
|
||||
step,
|
||||
searchParamsStr,
|
||||
user
|
||||
)
|
||||
storeRef.current = createDetailsStore(initialData, searchParamsStr, user)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (storeRef.current) {
|
||||
storeRef.current.setState((state) => {
|
||||
const newState: DetailsState = { ...state }
|
||||
|
||||
newState.bedType = state.formValues.bedType
|
||||
newState.breakfast = state.formValues.breakfast
|
||||
|
||||
if (state.formValues.guest && !user) {
|
||||
newState.guest = state.formValues.guest
|
||||
}
|
||||
|
||||
if (
|
||||
(newState.guest!.join || newState.guest!.membershipNo || user) &&
|
||||
state.roomRate.memberRate
|
||||
) {
|
||||
const memberPrice = calcTotalMemberPrice(newState)
|
||||
newState.roomPrice = memberPrice.roomPrice
|
||||
newState.totalPrice = memberPrice.totalPrice
|
||||
} else {
|
||||
const publicPrice = calcTotalPublicPrice(newState)
|
||||
newState.roomPrice = publicPrice.roomPrice
|
||||
newState.totalPrice = publicPrice.totalPrice
|
||||
}
|
||||
|
||||
const isValid = { ...newState.isValid }
|
||||
|
||||
const validateBooking = state.formValues
|
||||
const validPaths = [StepEnum.selectBed]
|
||||
const validatedBedType = bedTypeSchema.safeParse(validateBooking)
|
||||
if (validatedBedType.success) {
|
||||
isValid[StepEnum.selectBed] = true
|
||||
validPaths.push(state.steps[1])
|
||||
}
|
||||
|
||||
const validatedBreakfast =
|
||||
breakfastStoreSchema.safeParse(validateBooking)
|
||||
if (validatedBreakfast.success) {
|
||||
isValid[StepEnum.breakfast] = true
|
||||
validPaths.push(StepEnum.details)
|
||||
}
|
||||
|
||||
const detailsSchema = user ? signedInDetailsSchema : guestDetailsSchema
|
||||
const validatedDetails = detailsSchema.safeParse(validateBooking.guest)
|
||||
/**
|
||||
* Need to add the breakfast check here too since
|
||||
* when a member comes into the flow, their data is
|
||||
* already added and valid, and thus to avoid showing a
|
||||
* step the user hasn't been on yet as complete
|
||||
*/
|
||||
if (isValid.breakfast && validatedDetails.success) {
|
||||
isValid[StepEnum.details] = true
|
||||
validPaths.push(StepEnum.payment)
|
||||
}
|
||||
|
||||
if (!validPaths.includes(newState.currentStep!)) {
|
||||
newState.currentStep = validPaths.at(-1)!
|
||||
}
|
||||
|
||||
if (step !== newState.currentStep) {
|
||||
const stateCurrentStep = newState.currentStep!
|
||||
setTimeout(() => {
|
||||
navigate(stateCurrentStep, searchParamsStr)
|
||||
})
|
||||
}
|
||||
|
||||
writeToSessionStorage({
|
||||
bedType: newState.bedType,
|
||||
booking: newState.booking,
|
||||
breakfast: newState.breakfast,
|
||||
guest: newState.guest,
|
||||
})
|
||||
|
||||
return { ...newState, isValid }
|
||||
})
|
||||
const storedValues = readFromSessionStorage()
|
||||
if (!storedValues) {
|
||||
return
|
||||
}
|
||||
}, [searchParamsStr, step, user])
|
||||
const isSameRoom = checkIsSameRoom(storedValues.booking, booking)
|
||||
if (!isSameRoom) {
|
||||
clearSessionStorage()
|
||||
return
|
||||
}
|
||||
|
||||
const state = storeRef.current?.getState()
|
||||
storeRef.current?.setState({
|
||||
...state,
|
||||
rooms: storedValues.rooms,
|
||||
bookingProgress: storedValues.bookingProgress,
|
||||
})
|
||||
}, [booking])
|
||||
|
||||
return (
|
||||
<DetailsContext.Provider value={storeRef.current}>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import deepmerge from "deepmerge"
|
||||
import isEqual from "fast-deep-equal"
|
||||
|
||||
import { arrayMerge } from "@/utils/merge"
|
||||
|
||||
import { detailsStorageName } from "."
|
||||
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { StepEnum } from "@/types/enums/step"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type {
|
||||
DetailsState,
|
||||
PersistedState,
|
||||
PersistedStatePart,
|
||||
RoomRate,
|
||||
RoomState,
|
||||
RoomStatus,
|
||||
} from "@/types/stores/enter-details"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
@@ -27,10 +26,6 @@ export function extractGuestFromUser(user: NonNullable<SafeUser>) {
|
||||
}
|
||||
}
|
||||
|
||||
export function navigate(step: StepEnum, searchParams: string) {
|
||||
window.history.pushState({ step }, "", `${step}?${searchParams}`)
|
||||
}
|
||||
|
||||
export function checkIsSameRoom(
|
||||
prev: SelectRateSearchParams,
|
||||
next: SelectRateSearchParams
|
||||
@@ -84,7 +79,7 @@ export function subtract(...nums: (number | string | undefined)[]) {
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function getInitialRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
||||
export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
||||
if (isMember && roomRate.memberRate) {
|
||||
return {
|
||||
perNight: {
|
||||
@@ -134,158 +129,234 @@ export function getInitialRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getInitialTotalPrice(roomRate: RoomRate, isMember: boolean) {
|
||||
if (isMember && roomRate.memberRate) {
|
||||
return {
|
||||
requested: roomRate.memberRate.requestedPrice && {
|
||||
currency: roomRate.memberRate.requestedPrice.currency,
|
||||
price: roomRate.memberRate.requestedPrice.pricePerStay,
|
||||
},
|
||||
type TotalPrice = {
|
||||
requested: { currency: string; price: number } | undefined
|
||||
local: { currency: string; price: number }
|
||||
}
|
||||
|
||||
export function getTotalPrice(roomRates: RoomRate[], isMember: boolean) {
|
||||
return roomRates.reduce<TotalPrice>(
|
||||
(total, roomRate, idx) => {
|
||||
const isFirstRoom = idx === 0
|
||||
const rate =
|
||||
isFirstRoom && isMember && roomRate.memberRate
|
||||
? roomRate.memberRate
|
||||
: roomRate.publicRate
|
||||
|
||||
return {
|
||||
requested: rate.requestedPrice
|
||||
? {
|
||||
currency: rate.requestedPrice.currency,
|
||||
price: add(
|
||||
total.requested?.price ?? 0,
|
||||
rate.requestedPrice.pricePerStay
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
local: {
|
||||
currency: rate.localPrice.currency,
|
||||
price: add(total.local.price ?? 0, rate.localPrice.pricePerStay),
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
requested: undefined,
|
||||
local: {
|
||||
currency: roomRate.memberRate.localPrice.currency,
|
||||
price: roomRate.memberRate.localPrice.pricePerStay,
|
||||
currency: roomRates[0].publicRate.localPrice.currency,
|
||||
price: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requested: roomRate.publicRate.requestedPrice && {
|
||||
currency: roomRate.publicRate.requestedPrice.currency,
|
||||
price: roomRate.publicRate.requestedPrice.pricePerStay,
|
||||
},
|
||||
local: {
|
||||
currency: roomRate.publicRate.localPrice.currency,
|
||||
price: roomRate.publicRate.localPrice.pricePerStay,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function calcTotalMemberPrice(state: DetailsState) {
|
||||
if (!state.roomRate.memberRate) {
|
||||
return {
|
||||
roomPrice: state.roomPrice,
|
||||
totalPrice: state.totalPrice,
|
||||
}
|
||||
}
|
||||
|
||||
return calcTotalPrice({
|
||||
breakfast: state.breakfast,
|
||||
packages: state.packages,
|
||||
roomPrice: state.roomPrice,
|
||||
totalPrice: state.totalPrice,
|
||||
...state.roomRate.memberRate,
|
||||
})
|
||||
}
|
||||
|
||||
export function calcTotalPublicPrice(state: DetailsState) {
|
||||
return calcTotalPrice({
|
||||
breakfast: state.breakfast,
|
||||
packages: state.packages,
|
||||
roomPrice: state.roomPrice,
|
||||
totalPrice: state.totalPrice,
|
||||
...state.roomRate.publicRate,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function calcTotalPrice(
|
||||
state: Pick<
|
||||
DetailsState,
|
||||
"breakfast" | "packages" | "roomPrice" | "totalPrice"
|
||||
> &
|
||||
DetailsState["roomRate"]["publicRate"]
|
||||
rooms: RoomState[],
|
||||
totalPrice: Price,
|
||||
isMember: boolean
|
||||
) {
|
||||
// state is sometimes read-only, thus we
|
||||
// need to create a deep copy of the values
|
||||
const roomAndTotalPrice = {
|
||||
roomPrice: {
|
||||
perNight: {
|
||||
local: { ...state.roomPrice.perNight.local },
|
||||
requested: state.roomPrice.perNight.requested
|
||||
? { ...state.roomPrice.perNight.requested }
|
||||
: state.roomPrice.perNight.requested,
|
||||
},
|
||||
perStay: {
|
||||
local: { ...state.roomPrice.perStay.local },
|
||||
requested: state.roomPrice.perStay.requested
|
||||
? { ...state.roomPrice.perStay.requested }
|
||||
: state.roomPrice.perStay.requested,
|
||||
},
|
||||
},
|
||||
totalPrice: {
|
||||
local: { ...state.totalPrice.local },
|
||||
requested: state.totalPrice.requested
|
||||
? { ...state.totalPrice.requested }
|
||||
: state.totalPrice.requested,
|
||||
},
|
||||
}
|
||||
if (state.requestedPrice?.pricePerStay) {
|
||||
roomAndTotalPrice.roomPrice.perStay.requested = {
|
||||
currency: state.requestedPrice.currency,
|
||||
price: state.requestedPrice.pricePerStay,
|
||||
}
|
||||
return rooms.reduce<Price>(
|
||||
(acc, room, index) => {
|
||||
const isFirstRoomAndMember = index === 0 && isMember
|
||||
const join = Boolean(room.guest.join || room.guest.membershipNo)
|
||||
|
||||
let totalPriceRequested = state.requestedPrice.pricePerStay
|
||||
if (state.breakfast) {
|
||||
totalPriceRequested = add(
|
||||
totalPriceRequested,
|
||||
state.breakfast.requestedPrice.totalPrice
|
||||
const roomPrice = getRoomPrice(
|
||||
room.roomRate,
|
||||
isFirstRoomAndMember || join
|
||||
)
|
||||
}
|
||||
|
||||
if (state.packages) {
|
||||
totalPriceRequested = state.packages.reduce((total, pkg) => {
|
||||
const breakfastRequestedPrice = room.breakfast
|
||||
? room.breakfast.requestedPrice?.totalPrice ?? 0
|
||||
: 0
|
||||
const breakfastLocalPrice = room.breakfast
|
||||
? room.breakfast.localPrice?.totalPrice ?? 0
|
||||
: 0
|
||||
|
||||
const roomFeaturesTotal = room.roomFeatures?.reduce((total, pkg) => {
|
||||
if (pkg.requestedPrice.totalPrice) {
|
||||
total = add(total, pkg.requestedPrice.totalPrice)
|
||||
}
|
||||
return total
|
||||
}, totalPriceRequested)
|
||||
}
|
||||
}, 0)
|
||||
|
||||
roomAndTotalPrice.totalPrice.requested = {
|
||||
currency: state.requestedPrice.currency,
|
||||
price: totalPriceRequested,
|
||||
}
|
||||
}
|
||||
|
||||
const roomPriceLocal = state.localPrice
|
||||
roomAndTotalPrice.roomPrice.perStay.local = {
|
||||
currency: roomPriceLocal.currency,
|
||||
price: roomPriceLocal.pricePerStay,
|
||||
}
|
||||
|
||||
let totalPriceLocal = roomPriceLocal.pricePerStay
|
||||
if (state.breakfast) {
|
||||
totalPriceLocal = add(
|
||||
totalPriceLocal,
|
||||
state.breakfast.localPrice.totalPrice
|
||||
)
|
||||
}
|
||||
|
||||
if (state.packages) {
|
||||
totalPriceLocal = state.packages.reduce((total, pkg) => {
|
||||
if (pkg.localPrice.totalPrice) {
|
||||
total = add(total, pkg.localPrice.totalPrice)
|
||||
const result: Price = {
|
||||
requested: roomPrice.perStay.requested
|
||||
? {
|
||||
currency: roomPrice.perStay.requested.currency,
|
||||
price: add(
|
||||
acc.requested?.price ?? 0,
|
||||
roomPrice.perStay.requested.price,
|
||||
breakfastRequestedPrice
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
local: {
|
||||
currency: roomPrice.perStay.local.currency,
|
||||
price: add(
|
||||
acc.local.price,
|
||||
roomPrice.perStay.local.price,
|
||||
breakfastLocalPrice,
|
||||
roomFeaturesTotal
|
||||
),
|
||||
},
|
||||
}
|
||||
return total
|
||||
}, totalPriceLocal)
|
||||
}
|
||||
roomAndTotalPrice.totalPrice.local = {
|
||||
currency: roomPriceLocal.currency,
|
||||
price: totalPriceLocal,
|
||||
}
|
||||
|
||||
return roomAndTotalPrice
|
||||
return result
|
||||
},
|
||||
{
|
||||
requested: undefined,
|
||||
local: { currency: totalPrice.local.currency, price: 0 },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function writeToSessionStorage(part: PersistedStatePart) {
|
||||
const unparsedData = sessionStorage.getItem(detailsStorageName)
|
||||
if (unparsedData) {
|
||||
const data: PersistedState = JSON.parse(unparsedData)
|
||||
// @ts-expect-error - deepmerge is not to happy with
|
||||
// the part type
|
||||
const updated = deepmerge(data, part, { arrayMerge })
|
||||
sessionStorage.setItem(detailsStorageName, JSON.stringify(updated))
|
||||
} else {
|
||||
sessionStorage.setItem(detailsStorageName, JSON.stringify(part))
|
||||
export const selectRoomStatus = (state: DetailsState, index?: number) =>
|
||||
state.bookingProgress.roomStatuses[
|
||||
index ?? state.bookingProgress.currentRoomIndex
|
||||
]
|
||||
|
||||
export const selectRoom = (state: DetailsState, index?: number) =>
|
||||
state.rooms[index ?? state.bookingProgress.currentRoomIndex]
|
||||
|
||||
export const selectRoomSteps = (state: DetailsState, index?: number) =>
|
||||
state.bookingProgress.roomStatuses[
|
||||
index ?? state.bookingProgress.currentRoomIndex
|
||||
].steps
|
||||
|
||||
export const selectNextStep = (roomStatus: RoomStatus) => {
|
||||
if (!roomStatus.currentStep) {
|
||||
throw new Error("getNextStep: currentStep is undefined")
|
||||
}
|
||||
|
||||
if (!roomStatus.steps[roomStatus.currentStep]?.isValid) {
|
||||
return roomStatus.currentStep
|
||||
}
|
||||
|
||||
const stepsArray = Object.values(roomStatus.steps)
|
||||
const currentIndex = stepsArray.findIndex(
|
||||
(step) => step?.step === roomStatus.currentStep
|
||||
)
|
||||
if (currentIndex === stepsArray.length - 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextInvalidStep = stepsArray
|
||||
.slice(currentIndex + 1)
|
||||
.find((step) => !step.isValid)
|
||||
|
||||
return nextInvalidStep?.step ?? null
|
||||
}
|
||||
|
||||
export const selectBookingProgress = (state: DetailsState) =>
|
||||
state.bookingProgress
|
||||
|
||||
export const checkBookingProgress = (state: DetailsState) => {
|
||||
return state.bookingProgress.roomStatuses.every((r) => r.isComplete)
|
||||
}
|
||||
|
||||
export const checkRoomProgress = (state: DetailsState) => {
|
||||
const steps = selectRoomSteps(state)
|
||||
return Object.values(steps)
|
||||
.filter(Boolean)
|
||||
.every((step) => step.isValid)
|
||||
}
|
||||
|
||||
export function handleStepProgression(state: DetailsState) {
|
||||
const isAllRoomsCompleted = checkBookingProgress(state)
|
||||
if (isAllRoomsCompleted) {
|
||||
const roomStatus = selectRoomStatus(state)
|
||||
roomStatus.currentStep = null
|
||||
state.bookingProgress.canProceedToPayment = true
|
||||
return
|
||||
}
|
||||
|
||||
const roomStatus = selectRoomStatus(state)
|
||||
if (roomStatus.isComplete) {
|
||||
const nextRoomIndex = state.bookingProgress.currentRoomIndex + 1
|
||||
|
||||
roomStatus.lastCompletedStep = roomStatus.currentStep ?? undefined
|
||||
roomStatus.currentStep = null
|
||||
const nextRoomStatus = selectRoomStatus(state, nextRoomIndex)
|
||||
nextRoomStatus.currentStep =
|
||||
Object.values(nextRoomStatus.steps).find((step) => !step.isValid)?.step ??
|
||||
StepEnum.selectBed
|
||||
|
||||
const nextStep = selectNextStep(nextRoomStatus)
|
||||
nextRoomStatus.currentStep = nextStep
|
||||
state.bookingProgress.currentRoomIndex = nextRoomIndex
|
||||
return
|
||||
}
|
||||
|
||||
const nextStep = selectNextStep(roomStatus)
|
||||
if (nextStep && roomStatus.currentStep) {
|
||||
roomStatus.lastCompletedStep = roomStatus.currentStep
|
||||
roomStatus.currentStep = nextStep
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function readFromSessionStorage(): PersistedState | undefined {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const storedData = sessionStorage.getItem(detailsStorageName)
|
||||
if (!storedData) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsedData = JSON.parse(storedData) as PersistedState
|
||||
|
||||
if (
|
||||
!parsedData.booking ||
|
||||
!parsedData.rooms ||
|
||||
!parsedData.bookingProgress
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsedData
|
||||
} catch (error) {
|
||||
console.error("Error reading from session storage:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function writeToSessionStorage(state: PersistedState) {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(detailsStorageName, JSON.stringify(state))
|
||||
} catch (error) {
|
||||
console.error("Error writing to session storage:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSessionStorage() {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
sessionStorage.removeItem(detailsStorageName)
|
||||
}
|
||||
|
||||
@@ -7,22 +7,23 @@ import { DetailsContext } from "@/contexts/Details"
|
||||
|
||||
import {
|
||||
add,
|
||||
calcTotalMemberPrice,
|
||||
calcTotalPublicPrice,
|
||||
checkIsSameRoom,
|
||||
calcTotalPrice,
|
||||
checkRoomProgress,
|
||||
extractGuestFromUser,
|
||||
getInitialRoomPrice,
|
||||
getInitialTotalPrice,
|
||||
navigate,
|
||||
getRoomPrice,
|
||||
getTotalPrice,
|
||||
handleStepProgression,
|
||||
selectRoom,
|
||||
selectRoomStatus,
|
||||
writeToSessionStorage,
|
||||
} from "./helpers"
|
||||
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type {
|
||||
DetailsState,
|
||||
FormValues,
|
||||
InitialState,
|
||||
PersistedState,
|
||||
RoomState,
|
||||
RoomStatus,
|
||||
} from "@/types/stores/enter-details"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
@@ -38,109 +39,128 @@ const defaultGuestState = {
|
||||
zipCode: "",
|
||||
}
|
||||
|
||||
export const detailsStorageName = "details-storage"
|
||||
export const detailsStorageName = "rooms-details-storage"
|
||||
|
||||
export function createDetailsStore(
|
||||
initialState: InitialState,
|
||||
currentStep: StepEnum,
|
||||
searchParams: string,
|
||||
user: SafeUser
|
||||
) {
|
||||
const isMember = !!user
|
||||
|
||||
const formValues: FormValues = {
|
||||
bedType: initialState.bedType,
|
||||
booking: initialState.booking,
|
||||
/** TODO: Needs adjustment when breakfast included in rate is added */
|
||||
breakfast:
|
||||
initialState.breakfast === false ? initialState.breakfast : undefined,
|
||||
guest: isMember
|
||||
? deepmerge(defaultGuestState, extractGuestFromUser(user))
|
||||
: defaultGuestState,
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
const unparsedStorage = sessionStorage.getItem(detailsStorageName)
|
||||
if (unparsedStorage) {
|
||||
const detailsStorage: PersistedState = JSON.parse(unparsedStorage)
|
||||
const isSameRoom = detailsStorage.booking
|
||||
? checkIsSameRoom(initialState.booking, detailsStorage.booking)
|
||||
: false
|
||||
if (isSameRoom) {
|
||||
formValues.bedType = detailsStorage.bedType
|
||||
formValues.breakfast = detailsStorage.breakfast
|
||||
}
|
||||
if (!isMember) {
|
||||
formValues.guest = detailsStorage.guest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let steps = [
|
||||
StepEnum.selectBed,
|
||||
StepEnum.breakfast,
|
||||
StepEnum.details,
|
||||
StepEnum.payment,
|
||||
]
|
||||
/**
|
||||
* TODO:
|
||||
* - when included in rate, can packages still be received?
|
||||
* - no hotels yet with breakfast included in the rate so
|
||||
* impossible to build for atm.
|
||||
*
|
||||
* checking against initialState since that means the
|
||||
* hotel doesn't offer breakfast
|
||||
*
|
||||
* matching breakfast first so the steps array is altered
|
||||
* before the bedTypes possible step altering
|
||||
*/
|
||||
if (initialState.breakfast === false) {
|
||||
steps = steps.filter((step) => step !== StepEnum.breakfast)
|
||||
if (currentStep === StepEnum.breakfast) {
|
||||
currentStep = steps[1]
|
||||
}
|
||||
}
|
||||
|
||||
if (initialState.bedType && currentStep === StepEnum.selectBed) {
|
||||
currentStep = steps[1]
|
||||
}
|
||||
|
||||
const initialRoomPrice = getInitialRoomPrice(initialState.roomRate, isMember)
|
||||
const initialTotalPrice = getInitialTotalPrice(
|
||||
initialState.roomRate,
|
||||
const initialTotalPrice = getTotalPrice(
|
||||
initialState.rooms.map((r) => r.roomRate),
|
||||
isMember
|
||||
)
|
||||
|
||||
if (initialState.packages) {
|
||||
initialState.packages.forEach((pkg) => {
|
||||
if (initialTotalPrice.requested) {
|
||||
initialTotalPrice.requested.price = add(
|
||||
initialTotalPrice.requested.price,
|
||||
pkg.requestedPrice.totalPrice
|
||||
initialState.rooms.forEach((room) => {
|
||||
if (room.roomFeatures) {
|
||||
room.roomFeatures.forEach((pkg) => {
|
||||
if (initialTotalPrice.requested) {
|
||||
initialTotalPrice.requested.price = add(
|
||||
initialTotalPrice.requested.price,
|
||||
pkg.requestedPrice.totalPrice
|
||||
)
|
||||
}
|
||||
initialTotalPrice.local.price = add(
|
||||
initialTotalPrice.local.price,
|
||||
pkg.localPrice.totalPrice
|
||||
)
|
||||
}
|
||||
initialTotalPrice.local.price = add(
|
||||
initialTotalPrice.local.price,
|
||||
pkg.localPrice.totalPrice
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const rooms: RoomState[] = initialState.rooms.map((room, idx) => {
|
||||
return {
|
||||
...room,
|
||||
adults: initialState.booking.rooms[idx].adults,
|
||||
childrenInRoom: initialState.booking.rooms[idx].childrenInRoom,
|
||||
bedType: room.bedType,
|
||||
breakfast:
|
||||
initialState.breakfast === false ? initialState.breakfast : undefined,
|
||||
guest: isMember
|
||||
? deepmerge(defaultGuestState, extractGuestFromUser(user))
|
||||
: defaultGuestState,
|
||||
roomPrice: getRoomPrice(room.roomRate, isMember && idx === 0),
|
||||
}
|
||||
})
|
||||
|
||||
const roomStatuses: RoomStatus[] = initialState.rooms.map((room, idx) => {
|
||||
const steps: RoomStatus["steps"] = {
|
||||
[StepEnum.selectBed]: {
|
||||
step: StepEnum.selectBed,
|
||||
isValid: !!room.bedType,
|
||||
},
|
||||
[StepEnum.breakfast]: {
|
||||
step: StepEnum.breakfast,
|
||||
isValid: false,
|
||||
},
|
||||
[StepEnum.details]: {
|
||||
step: StepEnum.details,
|
||||
isValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
if (initialState.breakfast === false) {
|
||||
delete steps[StepEnum.breakfast]
|
||||
}
|
||||
|
||||
const currentStep =
|
||||
idx === 0
|
||||
? Object.values(steps).find((step) => !step.isValid)?.step ??
|
||||
StepEnum.selectBed
|
||||
: null
|
||||
|
||||
return {
|
||||
isComplete: false,
|
||||
currentStep: currentStep,
|
||||
lastCompletedStep: undefined,
|
||||
steps,
|
||||
}
|
||||
})
|
||||
|
||||
return create<DetailsState>()((set, get) => ({
|
||||
searchParamString: searchParams,
|
||||
booking: initialState.booking,
|
||||
breakfast:
|
||||
initialState.breakfast === false ? initialState.breakfast : undefined,
|
||||
isSubmittingDisabled: false,
|
||||
isSummaryOpen: false,
|
||||
isPriceDetailsModalOpen: false,
|
||||
totalPrice: initialTotalPrice,
|
||||
vat: initialState.vat,
|
||||
rooms,
|
||||
bookingProgress: {
|
||||
currentRoomIndex: 0,
|
||||
roomStatuses,
|
||||
canProceedToPayment: false,
|
||||
},
|
||||
|
||||
return create<DetailsState>()((set) => ({
|
||||
actions: {
|
||||
completeStep() {
|
||||
setStep(step: StepEnum | null, roomIndex?: number) {
|
||||
if (!step) {
|
||||
return
|
||||
}
|
||||
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
||||
const nextStep = state.steps[currentStepIndex + 1]
|
||||
state.currentStep = nextStep
|
||||
navigate(nextStep, state.searchParamString)
|
||||
})
|
||||
)
|
||||
},
|
||||
navigate(step: StepEnum) {
|
||||
return set(
|
||||
produce((state) => {
|
||||
state.currentStep = step
|
||||
navigate(step, state.searchParamString)
|
||||
const currentRoomIndex =
|
||||
roomIndex ?? state.bookingProgress.currentRoomIndex
|
||||
|
||||
const arePreviousRoomsCompleted = state.bookingProgress.roomStatuses
|
||||
.slice(0, currentRoomIndex)
|
||||
.every((room) => room.isComplete)
|
||||
|
||||
const roomStatus = selectRoomStatus(state, roomIndex)
|
||||
const roomStep = roomStatus.steps[step]
|
||||
|
||||
if (arePreviousRoomsCompleted && roomStep?.isValid) {
|
||||
roomStatus.currentStep = step
|
||||
|
||||
if (roomIndex !== undefined) {
|
||||
state.bookingProgress.currentRoomIndex = roomIndex
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
@@ -151,13 +171,6 @@ export function createDetailsStore(
|
||||
})
|
||||
)
|
||||
},
|
||||
setStep(step: StepEnum) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.currentStep = step
|
||||
})
|
||||
)
|
||||
},
|
||||
setTotalPrice(totalPrice) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
@@ -183,29 +196,39 @@ export function createDetailsStore(
|
||||
updateBedType(bedType) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.isValid["select-bed"] = true
|
||||
state.bedType = bedType
|
||||
const roomStatus = selectRoomStatus(state)
|
||||
roomStatus.steps[StepEnum.selectBed].isValid = true
|
||||
|
||||
writeToSessionStorage({ bedType })
|
||||
const room = selectRoom(state)
|
||||
room.bedType = bedType
|
||||
|
||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
||||
const nextStep = state.steps[currentStepIndex + 1]
|
||||
state.currentStep = nextStep
|
||||
navigate(nextStep, state.searchParamString)
|
||||
handleStepProgression(state)
|
||||
|
||||
writeToSessionStorage({
|
||||
booking: state.booking,
|
||||
rooms: state.rooms,
|
||||
bookingProgress: state.bookingProgress,
|
||||
})
|
||||
})
|
||||
)
|
||||
},
|
||||
updateBreakfast(breakfast) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.isValid.breakfast = true
|
||||
const roomStatus = selectRoomStatus(state)
|
||||
if (roomStatus.steps[StepEnum.breakfast]) {
|
||||
roomStatus.steps[StepEnum.breakfast].isValid = true
|
||||
}
|
||||
|
||||
const stateTotalRequestedPrice =
|
||||
state.totalPrice.requested?.price || 0
|
||||
|
||||
const stateTotalLocalPrice = state.totalPrice.local.price
|
||||
|
||||
const addToTotalPrice =
|
||||
(state.breakfast === undefined || state.breakfast === false) &&
|
||||
!!breakfast
|
||||
|
||||
const subtractFromTotalPrice =
|
||||
(state.breakfast === undefined || state.breakfast) &&
|
||||
breakfast === false
|
||||
@@ -267,50 +290,64 @@ export function createDetailsStore(
|
||||
}
|
||||
}
|
||||
|
||||
state.breakfast = breakfast
|
||||
writeToSessionStorage({ breakfast })
|
||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
||||
const nextStep = state.steps[currentStepIndex + 1]
|
||||
state.currentStep = nextStep
|
||||
navigate(nextStep, state.searchParamString)
|
||||
const room = selectRoom(state)
|
||||
room.breakfast = breakfast
|
||||
|
||||
handleStepProgression(state)
|
||||
|
||||
writeToSessionStorage({
|
||||
booking: state.booking,
|
||||
rooms: state.rooms,
|
||||
bookingProgress: state.bookingProgress,
|
||||
})
|
||||
})
|
||||
)
|
||||
},
|
||||
updateDetails(data) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.isValid.details = true
|
||||
const roomStatus = selectRoomStatus(state)
|
||||
roomStatus.steps[StepEnum.details].isValid = true
|
||||
|
||||
const room = selectRoom(state)
|
||||
room.guest.countryCode = data.countryCode
|
||||
room.guest.dateOfBirth = data.dateOfBirth
|
||||
room.guest.email = data.email
|
||||
room.guest.firstName = data.firstName
|
||||
room.guest.join = data.join
|
||||
room.guest.lastName = data.lastName
|
||||
|
||||
state.guest.countryCode = data.countryCode
|
||||
state.guest.dateOfBirth = data.dateOfBirth
|
||||
state.guest.email = data.email
|
||||
state.guest.firstName = data.firstName
|
||||
state.guest.join = data.join
|
||||
state.guest.lastName = data.lastName
|
||||
if (data.join) {
|
||||
state.guest.membershipNo = undefined
|
||||
room.guest.membershipNo = undefined
|
||||
} else {
|
||||
state.guest.membershipNo = data.membershipNo
|
||||
room.guest.membershipNo = data.membershipNo
|
||||
}
|
||||
state.guest.phoneNumber = data.phoneNumber
|
||||
state.guest.zipCode = data.zipCode
|
||||
room.guest.phoneNumber = data.phoneNumber
|
||||
room.guest.zipCode = data.zipCode
|
||||
|
||||
if (data.join || data.membershipNo || isMember) {
|
||||
const memberPrice = calcTotalMemberPrice(state)
|
||||
state.roomPrice = memberPrice.roomPrice
|
||||
state.totalPrice = memberPrice.totalPrice
|
||||
} else {
|
||||
const publicPrice = calcTotalPublicPrice(state)
|
||||
state.roomPrice = publicPrice.roomPrice
|
||||
state.totalPrice = publicPrice.totalPrice
|
||||
room.roomPrice = getRoomPrice(
|
||||
room.roomRate,
|
||||
Boolean(data.join || data.membershipNo || isMember)
|
||||
)
|
||||
|
||||
state.totalPrice = calcTotalPrice(
|
||||
state.rooms,
|
||||
state.totalPrice,
|
||||
isMember
|
||||
)
|
||||
|
||||
const isAllStepsCompleted = checkRoomProgress(state)
|
||||
if (isAllStepsCompleted) {
|
||||
roomStatus.isComplete = true
|
||||
}
|
||||
|
||||
writeToSessionStorage({ guest: data })
|
||||
handleStepProgression(state)
|
||||
|
||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
||||
const nextStep = state.steps[currentStepIndex + 1]
|
||||
state.currentStep = nextStep
|
||||
navigate(nextStep, state.searchParamString)
|
||||
writeToSessionStorage({
|
||||
booking: state.booking,
|
||||
rooms: state.rooms,
|
||||
bookingProgress: state.bookingProgress,
|
||||
})
|
||||
})
|
||||
)
|
||||
},
|
||||
@@ -322,31 +359,6 @@ export function createDetailsStore(
|
||||
)
|
||||
},
|
||||
},
|
||||
searchParamString: searchParams,
|
||||
bedType: initialState.bedType ?? undefined,
|
||||
booking: initialState.booking,
|
||||
breakfast:
|
||||
initialState.breakfast === false ? initialState.breakfast : undefined,
|
||||
currentStep,
|
||||
formValues,
|
||||
guest: isMember
|
||||
? deepmerge(defaultGuestState, extractGuestFromUser(user))
|
||||
: defaultGuestState,
|
||||
isSubmittingDisabled: false,
|
||||
isSummaryOpen: false,
|
||||
isPriceDetailsModalOpen: false,
|
||||
isValid: {
|
||||
[StepEnum.selectBed]: false,
|
||||
[StepEnum.breakfast]: false,
|
||||
[StepEnum.details]: false,
|
||||
[StepEnum.payment]: false,
|
||||
},
|
||||
packages: initialState.packages,
|
||||
roomPrice: initialRoomPrice,
|
||||
roomRate: initialState.roomRate,
|
||||
steps,
|
||||
totalPrice: initialTotalPrice,
|
||||
vat: initialState.vat,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
bedType,
|
||||
booking,
|
||||
breakfastPackage,
|
||||
guestDetailsMember,
|
||||
guestDetailsNonMember,
|
||||
roomPrice,
|
||||
roomRate,
|
||||
} from "@/__mocks__/hotelReservation"
|
||||
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
|
||||
|
||||
import { selectRoom, selectRoomStatus } from "./helpers"
|
||||
import { detailsStorageName, useEnterDetailsStore } from "."
|
||||
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
@@ -31,22 +34,60 @@ jest.mock("@/lib/api", () => ({
|
||||
fetchRetry: jest.fn((fn) => fn),
|
||||
}))
|
||||
|
||||
function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<EnterDetailsProvider
|
||||
bedTypes={[bedType.king, bedType.queen]}
|
||||
booking={booking}
|
||||
showBreakfastStep={true}
|
||||
packages={null}
|
||||
roomRate={roomRate}
|
||||
searchParamsStr=""
|
||||
step={StepEnum.selectBed}
|
||||
user={null}
|
||||
vat={0}
|
||||
>
|
||||
{children}
|
||||
</EnterDetailsProvider>
|
||||
)
|
||||
interface CreateWrapperParams {
|
||||
showBreakfastStep?: boolean
|
||||
breakfastIncluded?: boolean
|
||||
mustBeGuaranteed?: boolean
|
||||
onlyOneBedType?: boolean
|
||||
}
|
||||
|
||||
function createWrapper(params: Partial<CreateWrapperParams> = {}) {
|
||||
const {
|
||||
showBreakfastStep = true,
|
||||
breakfastIncluded = false,
|
||||
mustBeGuaranteed = false,
|
||||
onlyOneBedType = false,
|
||||
} = params
|
||||
|
||||
return function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<EnterDetailsProvider
|
||||
booking={booking}
|
||||
showBreakfastStep={showBreakfastStep}
|
||||
roomsData={[
|
||||
{
|
||||
bedTypes: onlyOneBedType
|
||||
? [bedType.king]
|
||||
: [bedType.king, bedType.queen],
|
||||
packages: null,
|
||||
mustBeGuaranteed,
|
||||
breakfastIncluded,
|
||||
cancellationText: "",
|
||||
rateDetails: [],
|
||||
roomType: "Standard",
|
||||
roomRate: roomRate,
|
||||
},
|
||||
{
|
||||
bedTypes: onlyOneBedType
|
||||
? [bedType.king]
|
||||
: [bedType.king, bedType.queen],
|
||||
packages: null,
|
||||
mustBeGuaranteed,
|
||||
breakfastIncluded,
|
||||
cancellationText: "",
|
||||
rateDetails: [],
|
||||
roomType: "Standard",
|
||||
roomRate: roomRate,
|
||||
},
|
||||
]}
|
||||
searchParamsStr=""
|
||||
user={null}
|
||||
vat={0}
|
||||
>
|
||||
{children}
|
||||
</EnterDetailsProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describe("Enter Details Store", () => {
|
||||
@@ -58,27 +99,84 @@ describe("Enter Details Store", () => {
|
||||
const { result } = renderHook(
|
||||
() => useEnterDetailsStore((state) => state),
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
wrapper: createWrapper(),
|
||||
}
|
||||
)
|
||||
const state = result.current
|
||||
|
||||
expect(state.currentStep).toBe(StepEnum.selectBed)
|
||||
expect(state.booking).toEqual(booking)
|
||||
expect(state.bedType).toEqual(undefined)
|
||||
expect(state.breakfast).toEqual(undefined)
|
||||
expect(Object.values(state.guest).every((value) => value === ""))
|
||||
|
||||
// room 1
|
||||
const room1Status = selectRoomStatus(result.current, 0)
|
||||
const room1 = selectRoom(result.current, 0)
|
||||
|
||||
expect(room1Status.currentStep).toBe(StepEnum.selectBed)
|
||||
|
||||
expect(room1.roomPrice.perNight.local.price).toEqual(
|
||||
roomRate.publicRate.localPrice.pricePerNight
|
||||
)
|
||||
expect(room1.bedType).toEqual(undefined)
|
||||
expect(Object.values(room1.guest).every((value) => value === ""))
|
||||
|
||||
// room 2
|
||||
const room2Status = selectRoomStatus(result.current, 1)
|
||||
const room2 = selectRoom(result.current, 1)
|
||||
|
||||
expect(room2Status.currentStep).toBe(null)
|
||||
expect(room2.roomPrice.perNight.local.price).toEqual(
|
||||
room2.roomRate.publicRate.localPrice.pricePerNight
|
||||
)
|
||||
expect(room2.bedType).toEqual(undefined)
|
||||
expect(Object.values(room2.guest).every((value) => value === ""))
|
||||
})
|
||||
|
||||
test("initialize with correct values from sessionStorage", async () => {
|
||||
test("initialize with correct values from session storage", () => {
|
||||
const storage: PersistedState = {
|
||||
bedType: {
|
||||
roomTypeCode: bedType.queen.value,
|
||||
description: bedType.queen.description,
|
||||
booking: booking,
|
||||
bookingProgress: {
|
||||
currentRoomIndex: 0,
|
||||
canProceedToPayment: true,
|
||||
roomStatuses: [
|
||||
{
|
||||
isComplete: false,
|
||||
currentStep: StepEnum.selectBed,
|
||||
lastCompletedStep: undefined,
|
||||
steps: {
|
||||
[StepEnum.selectBed]: {
|
||||
step: StepEnum.selectBed,
|
||||
isValid: true,
|
||||
},
|
||||
[StepEnum.breakfast]: {
|
||||
step: StepEnum.breakfast,
|
||||
isValid: true,
|
||||
},
|
||||
[StepEnum.details]: {
|
||||
step: StepEnum.details,
|
||||
isValid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
breakfast: breakfastPackage,
|
||||
booking,
|
||||
guest: guestDetailsNonMember,
|
||||
rooms: [
|
||||
{
|
||||
roomFeatures: null,
|
||||
roomRate: roomRate,
|
||||
roomType: "Classic Double",
|
||||
cancellationText: "Non-refundable",
|
||||
rateDetails: [],
|
||||
bedType: {
|
||||
roomTypeCode: bedType.king.value,
|
||||
description: bedType.king.description,
|
||||
},
|
||||
adults: 1,
|
||||
childrenInRoom: [],
|
||||
breakfast: breakfastPackage,
|
||||
guest: guestDetailsNonMember,
|
||||
roomPrice: roomPrice,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage))
|
||||
@@ -86,26 +184,57 @@ describe("Enter Details Store", () => {
|
||||
const { result } = renderHook(
|
||||
() => useEnterDetailsStore((state) => state),
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
wrapper: createWrapper(),
|
||||
}
|
||||
)
|
||||
const state = result.current
|
||||
|
||||
expect(state.bedType).toEqual(storage.bedType)
|
||||
expect(state.guest).toEqual(storage.guest)
|
||||
expect(state.booking).toEqual(storage.booking)
|
||||
expect(state.breakfast).toEqual(storage.breakfast)
|
||||
expect(result.current.booking).toEqual(storage.booking)
|
||||
expect(result.current.rooms[0]).toEqual(storage.rooms[0])
|
||||
expect(result.current.bookingProgress).toEqual(storage.bookingProgress)
|
||||
})
|
||||
|
||||
test("add bedtype and proceed to next step", async () => {
|
||||
const { result } = renderHook(
|
||||
() => useEnterDetailsStore((state) => state),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
}
|
||||
)
|
||||
|
||||
let roomStatus = selectRoomStatus(result.current)
|
||||
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
|
||||
|
||||
const selectedBedType = {
|
||||
roomTypeCode: bedType.king.value,
|
||||
description: bedType.king.description,
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateBedType(selectedBedType)
|
||||
})
|
||||
|
||||
roomStatus = selectRoomStatus(result.current)
|
||||
const room = selectRoom(result.current)
|
||||
|
||||
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
|
||||
expect(room.bedType).toEqual(selectedBedType)
|
||||
|
||||
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
|
||||
})
|
||||
|
||||
test("complete step and navigate to next step", async () => {
|
||||
const { result } = renderHook(
|
||||
() => useEnterDetailsStore((state) => state),
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
wrapper: createWrapper(),
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.current.currentStep).toEqual(StepEnum.selectBed)
|
||||
// Room 1
|
||||
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
|
||||
|
||||
let roomStatus = selectRoomStatus(result.current)
|
||||
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateBedType({
|
||||
@@ -114,24 +243,221 @@ describe("Enter Details Store", () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.isValid[StepEnum.selectBed]).toEqual(true)
|
||||
expect(result.current.currentStep).toEqual(StepEnum.breakfast)
|
||||
expect(window.location.pathname.slice(1)).toBe(StepEnum.breakfast)
|
||||
roomStatus = selectRoomStatus(result.current)
|
||||
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
|
||||
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateBreakfast(breakfastPackage)
|
||||
})
|
||||
|
||||
expect(result.current.isValid[StepEnum.breakfast]).toEqual(true)
|
||||
expect(result.current.currentStep).toEqual(StepEnum.details)
|
||||
expect(window.location.pathname.slice(1)).toBe(StepEnum.details)
|
||||
roomStatus = selectRoomStatus(result.current)
|
||||
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toEqual(true)
|
||||
expect(roomStatus.currentStep).toEqual(StepEnum.details)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateDetails(guestDetailsNonMember)
|
||||
})
|
||||
|
||||
expect(result.current.isValid[StepEnum.details]).toEqual(true)
|
||||
expect(result.current.currentStep).toEqual(StepEnum.payment)
|
||||
expect(window.location.pathname.slice(1)).toBe(StepEnum.payment)
|
||||
expect(result.current.bookingProgress.canProceedToPayment).toBe(false)
|
||||
|
||||
// Room 2
|
||||
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
|
||||
|
||||
roomStatus = selectRoomStatus(result.current)
|
||||
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
|
||||
|
||||
await act(async () => {
|
||||
const selectedBedType = {
|
||||
roomTypeCode: bedType.king.value,
|
||||
description: bedType.king.description,
|
||||
}
|
||||
result.current.actions.updateBedType(selectedBedType)
|
||||
})
|
||||
|
||||
roomStatus = selectRoomStatus(result.current)
|
||||
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
|
||||
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateBreakfast(breakfastPackage)
|
||||
})
|
||||
|
||||
roomStatus = selectRoomStatus(result.current)
|
||||
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toEqual(true)
|
||||
expect(roomStatus.currentStep).toEqual(StepEnum.details)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateDetails(guestDetailsNonMember)
|
||||
})
|
||||
|
||||
expect(result.current.bookingProgress.canProceedToPayment).toBe(true)
|
||||
})
|
||||
|
||||
test("all steps needs to be completed before going to next room", async () => {
|
||||
const { result } = renderHook(
|
||||
() => useEnterDetailsStore((state) => state),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
}
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateDetails(guestDetailsNonMember)
|
||||
})
|
||||
|
||||
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.setStep(StepEnum.breakfast, 1)
|
||||
})
|
||||
|
||||
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
|
||||
})
|
||||
|
||||
test("can go back and modify room 1 after completion", async () => {
|
||||
const { result } = renderHook(
|
||||
() => useEnterDetailsStore((state) => state),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
}
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateBedType({
|
||||
roomTypeCode: bedType.king.value,
|
||||
description: bedType.king.description,
|
||||
})
|
||||
result.current.actions.updateBreakfast(breakfastPackage)
|
||||
result.current.actions.updateDetails(guestDetailsNonMember)
|
||||
})
|
||||
|
||||
// now we are at room 2
|
||||
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.setStep(StepEnum.breakfast, 0) // click "modify"
|
||||
})
|
||||
|
||||
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateBreakfast(breakfastPackage)
|
||||
})
|
||||
|
||||
// going back to room 2
|
||||
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
|
||||
})
|
||||
|
||||
test("total price should be set properly", async () => {
|
||||
const { result } = renderHook(
|
||||
() => useEnterDetailsStore((state) => state),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
}
|
||||
)
|
||||
|
||||
const publicRate = roomRate.publicRate.localPrice.pricePerStay
|
||||
const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0
|
||||
|
||||
const initialTotalPrice = publicRate * result.current.rooms.length
|
||||
expect(result.current.totalPrice.local.price).toEqual(initialTotalPrice)
|
||||
|
||||
// room 1
|
||||
await act(async () => {
|
||||
result.current.actions.updateBedType({
|
||||
roomTypeCode: bedType.king.value,
|
||||
description: bedType.king.description,
|
||||
})
|
||||
result.current.actions.updateBreakfast(breakfastPackage)
|
||||
})
|
||||
|
||||
let expectedTotalPrice =
|
||||
initialTotalPrice + Number(breakfastPackage.localPrice.price)
|
||||
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateDetails(guestDetailsMember)
|
||||
})
|
||||
|
||||
expectedTotalPrice =
|
||||
memberRate + publicRate + Number(breakfastPackage.localPrice.price)
|
||||
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
|
||||
|
||||
// room 2
|
||||
await act(async () => {
|
||||
result.current.actions.updateBedType({
|
||||
roomTypeCode: bedType.king.value,
|
||||
description: bedType.king.description,
|
||||
})
|
||||
result.current.actions.updateBreakfast(breakfastPackage)
|
||||
})
|
||||
|
||||
expectedTotalPrice =
|
||||
memberRate + publicRate + Number(breakfastPackage.localPrice.price) * 2
|
||||
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateDetails(guestDetailsNonMember)
|
||||
})
|
||||
|
||||
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
|
||||
})
|
||||
|
||||
test("room price should be set properly", async () => {
|
||||
const { result } = renderHook(
|
||||
() => useEnterDetailsStore((state) => state),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
}
|
||||
)
|
||||
|
||||
const publicRate = roomRate.publicRate.localPrice.pricePerStay
|
||||
const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0
|
||||
|
||||
let room1 = selectRoom(result.current, 0)
|
||||
expect(room1.roomPrice.perStay.local.price).toEqual(publicRate)
|
||||
|
||||
let room2 = selectRoom(result.current, 0)
|
||||
expect(room2.roomPrice.perStay.local.price).toEqual(publicRate)
|
||||
|
||||
await act(async () => {
|
||||
result.current.actions.updateDetails(guestDetailsMember)
|
||||
})
|
||||
|
||||
room1 = selectRoom(result.current, 0)
|
||||
expect(room1.roomPrice.perStay.local.price).toEqual(memberRate)
|
||||
})
|
||||
|
||||
test("breakfast step should be hidden when breakfast is included", async () => {
|
||||
const { result } = renderHook(
|
||||
() => useEnterDetailsStore((state) => state),
|
||||
{
|
||||
wrapper: createWrapper({ showBreakfastStep: false }),
|
||||
}
|
||||
)
|
||||
|
||||
const room1Status = selectRoomStatus(result.current, 0)
|
||||
expect(Object.keys(room1Status.steps)).not.toContain(StepEnum.breakfast)
|
||||
|
||||
const room2Status = selectRoomStatus(result.current, 1)
|
||||
expect(Object.keys(room2Status.steps)).not.toContain(StepEnum.breakfast)
|
||||
})
|
||||
|
||||
test("select bed step should be skipped when there is only one bedtype", async () => {
|
||||
const { result } = renderHook(
|
||||
() => useEnterDetailsStore((state) => state),
|
||||
{
|
||||
wrapper: createWrapper({ onlyOneBedType: true }),
|
||||
}
|
||||
)
|
||||
|
||||
const room1Status = selectRoomStatus(result.current, 0)
|
||||
expect(room1Status.steps[StepEnum.selectBed].isValid).toEqual(true)
|
||||
expect(room1Status.currentStep).toEqual(StepEnum.breakfast)
|
||||
|
||||
const room2Status = selectRoomStatus(result.current, 1)
|
||||
expect(room2Status.steps[StepEnum.selectBed].isValid).toEqual(true)
|
||||
expect(room2Status.currentStep).toEqual(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { z } from "zod"
|
||||
|
||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
import type {
|
||||
guestDetailsSchema,
|
||||
signedInDetailsSchema,
|
||||
} from "@/components/HotelReservation/EnterDetails/Details/schema"
|
||||
import type { Price } from "../price"
|
||||
|
||||
export type DetailsSchema = z.output<typeof guestDetailsSchema>
|
||||
export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>
|
||||
|
||||
export interface RoomPrice {
|
||||
perNight: Price
|
||||
perStay: Price
|
||||
}
|
||||
|
||||
type MemberPrice = {
|
||||
currency: string
|
||||
price: number
|
||||
@@ -23,3 +30,8 @@ export type JoinScandicFriendsCardProps = {
|
||||
name: string
|
||||
memberPrice?: MemberPrice
|
||||
}
|
||||
|
||||
export type RoomRate = {
|
||||
publicRate: Product["productType"]["public"]
|
||||
memberRate?: Product["productType"]["member"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailabil
|
||||
|
||||
export interface SelectedRoomProps {
|
||||
hotelId: string
|
||||
roomType: string
|
||||
roomTypeCode: string
|
||||
rateDescription: string
|
||||
room: RoomConfiguration
|
||||
}
|
||||
|
||||
9
types/components/hotelReservation/price.ts
Normal file
9
types/components/hotelReservation/price.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
interface TPrice {
|
||||
currency: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export interface Price {
|
||||
requested: TPrice | undefined
|
||||
local: TPrice
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Price } from "@/types/stores/enter-details"
|
||||
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { Price } from "../price"
|
||||
import type { RoomPackages } from "./roomFilter"
|
||||
import type { SelectRateSearchParams } from "./selectRate"
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ export interface DetailsProps extends SectionProps {}
|
||||
|
||||
export interface PaymentProps {
|
||||
user: SafeUser
|
||||
roomPrice: { publicPrice: number; memberPrice: number | undefined }
|
||||
otherPaymentOptions: PaymentMethodEnum[]
|
||||
mustBeGuaranteed: boolean
|
||||
supportedCards: PaymentMethodEnum[]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { StepEnum } from "@/types/enums/step"
|
||||
|
||||
export interface SectionAccordionProps {
|
||||
header: string
|
||||
label: string
|
||||
step: StepEnum
|
||||
roomIndex: number
|
||||
}
|
||||
|
||||
@@ -1,50 +1,46 @@
|
||||
import type { DetailsProviderProps } from "@/types/providers/enter-details"
|
||||
import type { Packages } from "@/types/requests/packages"
|
||||
import type {
|
||||
DetailsState,
|
||||
Price,
|
||||
RoomPrice,
|
||||
} from "@/types/stores/enter-details"
|
||||
import type { RoomAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { BedTypeSchema } from "./enterDetails/bedType"
|
||||
import type { BreakfastPackage } from "./enterDetails/breakfast"
|
||||
import type { DetailsSchema } from "./enterDetails/details"
|
||||
import type { RoomState } from "@/types/stores/enter-details"
|
||||
import type { RoomPrice, RoomRate } from "./enterDetails/details"
|
||||
import type { Price } from "./price"
|
||||
import type { Child, SelectRateSearchParams } from "./selectRate/selectRate"
|
||||
|
||||
export type RoomsData = Pick<DetailsState, "roomPrice"> &
|
||||
Pick<RoomAvailability, "cancellationText" | "rateDetails"> &
|
||||
Pick<RoomAvailability["selectedRoom"], "roomType"> & {
|
||||
adults: number
|
||||
children?: Child[]
|
||||
packages: Packages | null
|
||||
}
|
||||
export type RoomsData = {
|
||||
rateDetails: string[] | undefined
|
||||
roomType: string
|
||||
cancellationText: string
|
||||
roomPrice: RoomPrice
|
||||
adults: number
|
||||
children?: Child[]
|
||||
packages: Packages | null
|
||||
}
|
||||
|
||||
export interface SummaryProps
|
||||
extends Pick<RoomAvailability, "cancellationText" | "rateDetails">,
|
||||
Pick<RoomAvailability["selectedRoom"], "roomType"> {
|
||||
export interface SummaryProps {
|
||||
isMember: boolean
|
||||
breakfastIncluded: boolean
|
||||
}
|
||||
|
||||
export interface SummaryUIProps {
|
||||
booking: SelectRateSearchParams
|
||||
isMember: boolean
|
||||
totalPrice: Price
|
||||
toggleSummaryOpen: () => void
|
||||
togglePriceDetailsModalOpen: () => void
|
||||
vat: number
|
||||
}
|
||||
|
||||
export interface EnterDetailsSummaryProps extends SummaryUIProps {
|
||||
breakfastIncluded: boolean
|
||||
rooms: RoomState[]
|
||||
}
|
||||
|
||||
export interface SelectRateSummaryProps extends SummaryUIProps {
|
||||
rooms: {
|
||||
adults: number
|
||||
childrenInRoom: Child[] | undefined
|
||||
bedType: BedTypeSchema | undefined
|
||||
breakfast: BreakfastPackage | false | undefined
|
||||
guest: DetailsSchema | undefined
|
||||
roomRate: DetailsProviderProps["roomRate"]
|
||||
roomPrice: RoomPrice
|
||||
roomType: string
|
||||
roomPrice: RoomPrice
|
||||
roomRate: RoomRate
|
||||
rateDetails: string[] | undefined
|
||||
cancellationText: string
|
||||
}[]
|
||||
isMember: boolean
|
||||
breakfastIncluded: boolean
|
||||
packages: Packages | null
|
||||
totalPrice: Price
|
||||
vat: number
|
||||
toggleSummaryOpen: () => void
|
||||
togglePriceDetailsModalOpen: () => void
|
||||
}
|
||||
|
||||
@@ -2,5 +2,4 @@ export enum StepEnum {
|
||||
selectBed = "select-bed",
|
||||
breakfast = "breakfast",
|
||||
details = "details",
|
||||
payment = "payment",
|
||||
}
|
||||
|
||||
@@ -2,17 +2,15 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
|
||||
import type { StepEnum } from "@/types/enums/step"
|
||||
import type { RoomAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
import type { RoomData } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page"
|
||||
import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate"
|
||||
import type { Packages } from "../requests/packages"
|
||||
|
||||
export interface DetailsProviderProps extends React.PropsWithChildren {
|
||||
booking: SelectRateSearchParams
|
||||
bedTypes: BedTypeSelection[]
|
||||
showBreakfastStep: boolean
|
||||
packages: Packages | null
|
||||
roomRate: Pick<RoomAvailability, "memberRate" | "publicRate">
|
||||
roomsData: RoomData[]
|
||||
searchParamsStr: string
|
||||
step: StepEnum
|
||||
user: SafeUser
|
||||
vat: number
|
||||
}
|
||||
|
||||
@@ -2,41 +2,47 @@ import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDet
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import type {
|
||||
DetailsSchema,
|
||||
RoomPrice,
|
||||
RoomRate,
|
||||
SignedInDetailsSchema,
|
||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { StepEnum } from "@/types/enums/step"
|
||||
import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate"
|
||||
import type { DetailsProviderProps } from "../providers/enter-details"
|
||||
import type { Price } from "../components/hotelReservation/price"
|
||||
import type {
|
||||
Child,
|
||||
SelectRateSearchParams,
|
||||
} from "../components/hotelReservation/selectRate/selectRate"
|
||||
import type { Packages } from "../requests/packages"
|
||||
|
||||
interface TPrice {
|
||||
currency: string
|
||||
price: number
|
||||
export interface InitialRoomData {
|
||||
roomRate: RoomRate
|
||||
roomType: string
|
||||
rateDetails: string[] | undefined
|
||||
cancellationText: string
|
||||
roomFeatures: Packages | null
|
||||
bedType?: BedTypeSchema // used when there is only one bedtype to preselect it
|
||||
}
|
||||
|
||||
export interface RoomPrice {
|
||||
perNight: Price
|
||||
perStay: Price
|
||||
}
|
||||
|
||||
export interface Price {
|
||||
requested: TPrice | undefined
|
||||
local: TPrice
|
||||
}
|
||||
|
||||
export interface FormValues {
|
||||
export interface RoomState extends InitialRoomData {
|
||||
adults: number
|
||||
childrenInRoom: Child[] | undefined
|
||||
bedType: BedTypeSchema | undefined
|
||||
booking: SelectRateSearchParams
|
||||
breakfast: BreakfastPackage | false | undefined
|
||||
guest: DetailsSchema | SignedInDetailsSchema
|
||||
roomPrice: RoomPrice
|
||||
}
|
||||
|
||||
export type InitialState = {
|
||||
booking: SelectRateSearchParams
|
||||
vat: number
|
||||
rooms: InitialRoomData[]
|
||||
breakfast?: false
|
||||
}
|
||||
|
||||
export interface DetailsState {
|
||||
actions: {
|
||||
completeStep: () => void
|
||||
navigate: (step: StepEnum) => void
|
||||
setStep: (step: StepEnum | null, roomIndex?: number) => void
|
||||
setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void
|
||||
setStep: (step: StepEnum) => void
|
||||
setTotalPrice: (totalPrice: Price) => void
|
||||
toggleSummaryOpen: () => void
|
||||
togglePriceDetailsModalOpen: () => void
|
||||
@@ -45,40 +51,42 @@ export interface DetailsState {
|
||||
updateDetails: (data: DetailsSchema) => void
|
||||
updateSeachParamString: (searchParamString: string) => void
|
||||
}
|
||||
bedType: BedTypeSchema | undefined
|
||||
booking: SelectRateSearchParams
|
||||
breakfast: BreakfastPackage | false | undefined
|
||||
currentStep: StepEnum
|
||||
formValues: FormValues
|
||||
guest: DetailsSchema
|
||||
isSubmittingDisabled: boolean
|
||||
isSummaryOpen: boolean
|
||||
isPriceDetailsModalOpen: boolean
|
||||
isValid: Record<StepEnum, boolean>
|
||||
packages: Packages | null
|
||||
roomRate: DetailsProviderProps["roomRate"]
|
||||
roomPrice: RoomPrice
|
||||
steps: StepEnum[]
|
||||
rooms: RoomState[]
|
||||
totalPrice: Price
|
||||
searchParamString: string
|
||||
vat: number
|
||||
bookingProgress: BookingProgress
|
||||
}
|
||||
|
||||
export type InitialState = Pick<DetailsState, "booking" | "packages"> &
|
||||
Pick<DetailsProviderProps, "roomRate" | "vat"> & {
|
||||
bedType?: BedTypeSchema
|
||||
breakfast?: false
|
||||
export type PersistedState = {
|
||||
booking: SelectRateSearchParams
|
||||
bookingProgress: BookingProgress
|
||||
rooms: RoomState[]
|
||||
}
|
||||
|
||||
export type RoomStep = {
|
||||
step: StepEnum
|
||||
isValid: boolean
|
||||
}
|
||||
|
||||
export type RoomStatus = {
|
||||
isComplete: boolean
|
||||
currentStep: StepEnum | null
|
||||
lastCompletedStep: StepEnum | undefined
|
||||
steps: {
|
||||
[StepEnum.selectBed]: RoomStep
|
||||
[StepEnum.breakfast]?: RoomStep
|
||||
[StepEnum.details]: RoomStep
|
||||
}
|
||||
}
|
||||
|
||||
export type RoomRate = DetailsProviderProps["roomRate"]
|
||||
|
||||
export type PersistedState = Pick<
|
||||
DetailsState,
|
||||
"bedType" | "booking" | "breakfast" | "guest"
|
||||
>
|
||||
|
||||
export type PersistedStatePart =
|
||||
| Pick<DetailsState, "bedType">
|
||||
| Pick<DetailsState, "booking">
|
||||
| Pick<DetailsState, "breakfast">
|
||||
| Pick<DetailsState, "guest">
|
||||
export type BookingProgress = {
|
||||
currentRoomIndex: number
|
||||
roomStatuses: RoomStatus[]
|
||||
canProceedToPayment: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user