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

feat(SW-1259): Enter details multiroom

* refactor: remove per-step URLs

* WIP: map multiroom data

* fix: lint errors in details page

* fix: made useEnterDetailsStore tests pass

* fix: WIP refactor enter details store

* fix: WIP enter details store update

* fix: added room index to select correct room

* fix: added logic for navigating between steps and rooms

* fix: update summary to work with store changes

* fix: added room and total price calculation

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

* refactor: move store selectors into helpers

* refactor: session storage state for multiroom booking

* feat: update enter details accordion navigation

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

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

* fix: update payment step with store changes

* fix: rebase issues

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

* refactor: cleanup

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

* fix: add select-rate Summary and adjust typings


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

View File

@@ -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)
}

View File

@@ -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,
}))
}

View File

@@ -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)
})
})