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