feat: Refactor enter details price calculation * Refactor getTotalPrice and child functions * Move price calculations from helper file to specific file Approved-by: Linus Flood
461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
import deepmerge from "deepmerge"
|
|
import { produce } from "immer"
|
|
import { useContext } from "react"
|
|
import { create, useStore } from "zustand"
|
|
|
|
import { dt } from "@scandic-hotels/common/dt"
|
|
import { getDefaultCountryFromLang } from "@scandic-hotels/common/utils/phone"
|
|
|
|
import { EnterDetailsContext } from "../../contexts/EnterDetails/EnterDetailsContext"
|
|
import { EnterDetailsStepEnum } from "./enterDetailsStep"
|
|
import {
|
|
checkRoomProgress,
|
|
extractGuestFromUser,
|
|
writeToSessionStorage,
|
|
} from "./helpers"
|
|
import { getRoomPrice, getTotalPrice } from "./priceCalculations"
|
|
|
|
import type { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
|
import type { Lang } from "@scandic-hotels/common/constants/language"
|
|
import type { BreakfastPackages } from "@scandic-hotels/trpc/routers/hotels/output"
|
|
import type { User } from "@scandic-hotels/trpc/types/user"
|
|
|
|
import type { DetailsState, InitialState, RoomState } from "./types"
|
|
|
|
const defaultGuestState = {
|
|
countryCode: "",
|
|
dateOfBirth: "",
|
|
email: "",
|
|
firstName: "",
|
|
join: false,
|
|
lastName: "",
|
|
membershipNo: "",
|
|
phoneNumber: "",
|
|
phoneNumberCC: "",
|
|
zipCode: "",
|
|
}
|
|
|
|
export const detailsStorageName = "rooms-details-storage"
|
|
|
|
export type EnterDetailsStore = ReturnType<typeof createDetailsStore>
|
|
export function createDetailsStore(
|
|
initialState: InitialState,
|
|
searchParams: string,
|
|
user: User | null,
|
|
breakfastPackages: BreakfastPackages,
|
|
lang: Lang,
|
|
pointsCurrency?: CurrencyEnum
|
|
) {
|
|
const isMember = !!user
|
|
const nights = dt(initialState.booking.toDate).diff(
|
|
initialState.booking.fromDate,
|
|
"days"
|
|
)
|
|
|
|
const initialRooms = initialState.rooms.map((room, idx) => {
|
|
return {
|
|
...room,
|
|
adults: initialState.booking.rooms[idx].adults,
|
|
childrenInRoom: initialState.booking.rooms[idx].childrenInRoom,
|
|
bedType: room.bedType,
|
|
breakfast:
|
|
!breakfastPackages.length || room.breakfastIncluded
|
|
? (false as const)
|
|
: undefined,
|
|
guest:
|
|
isMember && idx === 0
|
|
? deepmerge(defaultGuestState, extractGuestFromUser(user))
|
|
: {
|
|
...defaultGuestState,
|
|
phoneNumberCC: getDefaultCountryFromLang(lang),
|
|
},
|
|
roomPrice: getRoomPrice(
|
|
room.roomRate,
|
|
isMember && idx === 0,
|
|
pointsCurrency
|
|
),
|
|
specialRequest: {
|
|
comment: "",
|
|
},
|
|
}
|
|
})
|
|
|
|
const initialTotalPrice = getTotalPrice(
|
|
initialRooms,
|
|
isMember,
|
|
nights,
|
|
pointsCurrency
|
|
)
|
|
|
|
const availableBeds = initialState.rooms.reduce<
|
|
DetailsState["availableBeds"]
|
|
>((total, room) => {
|
|
for (const bed of room.bedTypes) {
|
|
if (!total[bed.value]) {
|
|
total[bed.value] = bed.roomsLeft
|
|
}
|
|
}
|
|
return total
|
|
}, {})
|
|
|
|
return create<DetailsState>()((set, get) => ({
|
|
availableBeds,
|
|
booking: initialState.booking,
|
|
roomCategories: initialState.roomCategories,
|
|
breakfastPackages,
|
|
canProceedToPayment: false,
|
|
isSubmitting: false,
|
|
isSummaryOpen: false,
|
|
hotelOffersBreakfast: initialState.hotelOffersBreakfast,
|
|
lastRoom: initialState.booking.rooms.length - 1,
|
|
rooms: initialRooms.map((room, idx) => {
|
|
const steps: RoomState["steps"] = {
|
|
[EnterDetailsStepEnum.selectBed]: {
|
|
step: EnterDetailsStepEnum.selectBed,
|
|
isValid: !!room.bedType,
|
|
},
|
|
[EnterDetailsStepEnum.breakfast]: {
|
|
step: EnterDetailsStepEnum.breakfast,
|
|
isValid: false,
|
|
},
|
|
[EnterDetailsStepEnum.details]: {
|
|
step: EnterDetailsStepEnum.details,
|
|
isValid: isMember && idx === 0,
|
|
},
|
|
}
|
|
|
|
if (room.breakfastIncluded || !breakfastPackages.length) {
|
|
delete steps[EnterDetailsStepEnum.breakfast]
|
|
}
|
|
|
|
return {
|
|
actions: {
|
|
setIncomplete() {
|
|
return set(
|
|
produce((state: DetailsState) => {
|
|
state.rooms[idx].isComplete = false
|
|
})
|
|
)
|
|
},
|
|
updateBedType(bedType) {
|
|
return set(
|
|
produce((state: DetailsState) => {
|
|
const currentlySelectedBed =
|
|
state.rooms[idx].room.bedType?.roomTypeCode
|
|
if (currentlySelectedBed) {
|
|
state.availableBeds[currentlySelectedBed] =
|
|
state.availableBeds[currentlySelectedBed] + 1
|
|
}
|
|
state.availableBeds[bedType.roomTypeCode] =
|
|
state.availableBeds[bedType.roomTypeCode] - 1
|
|
|
|
state.rooms[idx].steps[EnterDetailsStepEnum.selectBed].isValid =
|
|
true
|
|
state.rooms[idx].room.bedType = bedType
|
|
|
|
const isAllStepsCompleted = checkRoomProgress(
|
|
state.rooms[idx].steps
|
|
)
|
|
if (isAllStepsCompleted) {
|
|
state.rooms[idx].isComplete = true
|
|
}
|
|
|
|
writeToSessionStorage({
|
|
booking: state.booking,
|
|
rooms: state.rooms,
|
|
})
|
|
})
|
|
)
|
|
},
|
|
updateBreakfast(breakfast) {
|
|
return set(
|
|
produce((state: DetailsState) => {
|
|
const currentRoom = state.rooms[idx]
|
|
if (currentRoom.steps[EnterDetailsStepEnum.breakfast]) {
|
|
currentRoom.steps[EnterDetailsStepEnum.breakfast].isValid =
|
|
true
|
|
}
|
|
|
|
currentRoom.room.breakfast = breakfast
|
|
|
|
const nights = dt(state.booking.toDate).diff(
|
|
state.booking.fromDate,
|
|
"days"
|
|
)
|
|
|
|
state.totalPrice = getTotalPrice(
|
|
state.rooms.map((r) => r.room),
|
|
isMember,
|
|
nights,
|
|
pointsCurrency
|
|
)
|
|
|
|
const isAllStepsCompleted = checkRoomProgress(
|
|
state.rooms[idx].steps
|
|
)
|
|
if (isAllStepsCompleted) {
|
|
state.rooms[idx].isComplete = true
|
|
}
|
|
|
|
writeToSessionStorage({
|
|
booking: state.booking,
|
|
rooms: state.rooms,
|
|
})
|
|
})
|
|
)
|
|
},
|
|
updatePriceForMembershipNo(membershipNo, isValid) {
|
|
return set(
|
|
produce((state: DetailsState) => {
|
|
const currentRoom = state.rooms[idx].room
|
|
|
|
currentRoom.guest.membershipNo = isValid ? membershipNo : ""
|
|
|
|
const isValidMembershipNo = isValid && !!membershipNo
|
|
|
|
if (isValidMembershipNo) {
|
|
currentRoom.guest.join = false
|
|
}
|
|
currentRoom.roomPrice = getRoomPrice(
|
|
currentRoom.roomRate,
|
|
isValidMembershipNo || currentRoom.guest.join,
|
|
pointsCurrency
|
|
)
|
|
|
|
const nights = dt(state.booking.toDate).diff(
|
|
state.booking.fromDate,
|
|
"days"
|
|
)
|
|
|
|
state.totalPrice = getTotalPrice(
|
|
state.rooms.map((r) => r.room),
|
|
isMember,
|
|
nights,
|
|
pointsCurrency
|
|
)
|
|
|
|
writeToSessionStorage({
|
|
booking: state.booking,
|
|
rooms: state.rooms,
|
|
})
|
|
})
|
|
)
|
|
},
|
|
updateJoin(join) {
|
|
return set(
|
|
produce((state: DetailsState) => {
|
|
const currentRoom = state.rooms[idx].room
|
|
|
|
currentRoom.guest.join = join
|
|
|
|
if (join) {
|
|
currentRoom.guest.membershipNo = ""
|
|
}
|
|
|
|
currentRoom.roomPrice = getRoomPrice(
|
|
currentRoom.roomRate,
|
|
join || !!currentRoom.guest.membershipNo,
|
|
pointsCurrency
|
|
)
|
|
|
|
const nights = dt(state.booking.toDate).diff(
|
|
state.booking.fromDate,
|
|
"days"
|
|
)
|
|
|
|
state.totalPrice = getTotalPrice(
|
|
state.rooms.map((r) => r.room),
|
|
isMember,
|
|
nights,
|
|
pointsCurrency
|
|
)
|
|
|
|
writeToSessionStorage({
|
|
booking: state.booking,
|
|
rooms: state.rooms,
|
|
})
|
|
})
|
|
)
|
|
},
|
|
updatePartialGuestData(data) {
|
|
return set(
|
|
produce((state: DetailsState) => {
|
|
const currentRoom = state.rooms[idx].room
|
|
|
|
//Update only the parts that are relevant for cross-validation
|
|
if (data.firstName !== undefined)
|
|
currentRoom.guest.firstName = data.firstName
|
|
if (data.lastName !== undefined)
|
|
currentRoom.guest.lastName = data.lastName
|
|
if (data.membershipNo !== undefined)
|
|
currentRoom.guest.membershipNo = data.membershipNo
|
|
})
|
|
)
|
|
},
|
|
updateDetails(data) {
|
|
return set(
|
|
produce((state: DetailsState) => {
|
|
state.rooms[idx].steps[EnterDetailsStepEnum.details].isValid =
|
|
true
|
|
const currentRoom = state.rooms[idx].room
|
|
|
|
currentRoom.guest.countryCode = data.countryCode
|
|
currentRoom.guest.email = data.email
|
|
currentRoom.guest.firstName = data.firstName
|
|
currentRoom.guest.join = data.join
|
|
currentRoom.guest.lastName = data.lastName
|
|
currentRoom.guest.phoneNumber = data.phoneNumber
|
|
currentRoom.guest.phoneNumberCC = data.phoneNumberCC
|
|
|
|
if (data.specialRequest?.comment) {
|
|
currentRoom.specialRequest.comment =
|
|
data.specialRequest.comment
|
|
}
|
|
|
|
if (data.join) {
|
|
currentRoom.guest.membershipNo = undefined
|
|
} else {
|
|
currentRoom.guest.membershipNo = data.membershipNo
|
|
}
|
|
|
|
// Only valid for room 1
|
|
if (idx === 0 && data.join && !isMember) {
|
|
if ("dateOfBirth" in currentRoom.guest) {
|
|
currentRoom.guest.dateOfBirth = data.dateOfBirth
|
|
}
|
|
if ("zipCode" in currentRoom.guest) {
|
|
currentRoom.guest.zipCode = data.zipCode
|
|
}
|
|
}
|
|
|
|
const isMemberAndRoomOne = idx === 0 && isMember
|
|
|
|
currentRoom.roomPrice = getRoomPrice(
|
|
currentRoom.roomRate,
|
|
Boolean(data.join || data.membershipNo || isMemberAndRoomOne),
|
|
pointsCurrency
|
|
)
|
|
|
|
const nights = dt(state.booking.toDate).diff(
|
|
state.booking.fromDate,
|
|
"days"
|
|
)
|
|
|
|
state.totalPrice = getTotalPrice(
|
|
state.rooms.map((r) => r.room),
|
|
isMember,
|
|
nights,
|
|
pointsCurrency
|
|
)
|
|
|
|
const isAllStepsCompleted = checkRoomProgress(
|
|
state.rooms[idx].steps
|
|
)
|
|
if (isAllStepsCompleted) {
|
|
state.rooms[idx].isComplete = true
|
|
}
|
|
|
|
writeToSessionStorage({
|
|
booking: state.booking,
|
|
rooms: state.rooms,
|
|
})
|
|
})
|
|
)
|
|
},
|
|
},
|
|
room,
|
|
isComplete: false,
|
|
steps,
|
|
}
|
|
}),
|
|
searchParamString: searchParams,
|
|
totalPrice: initialTotalPrice,
|
|
vat: initialState.vat,
|
|
hotelName: initialState.hotelName,
|
|
defaultCurrency: initialTotalPrice.local.currency,
|
|
preSubmitCallbacks: {},
|
|
|
|
actions: {
|
|
setIsSubmitting(isSubmitting) {
|
|
return set(
|
|
produce((state: DetailsState) => {
|
|
state.isSubmitting = isSubmitting
|
|
})
|
|
)
|
|
},
|
|
toggleSummaryOpen() {
|
|
return set(
|
|
produce((state: DetailsState) => {
|
|
state.isSummaryOpen = !state.isSummaryOpen
|
|
})
|
|
)
|
|
},
|
|
updateSeachParamString(searchParamString) {
|
|
return set(
|
|
produce((state: DetailsState) => {
|
|
state.searchParamString = searchParamString
|
|
})
|
|
)
|
|
},
|
|
addPreSubmitCallback(name, callback) {
|
|
return set(
|
|
produce((state: DetailsState) => {
|
|
state.preSubmitCallbacks[name] = callback
|
|
})
|
|
)
|
|
},
|
|
|
|
async runPreSubmitCallbacks(): Promise<HTMLElement | undefined> {
|
|
const callbacks = get().preSubmitCallbacks
|
|
const stepOrder = ["bedType", "breakfast", "details"]
|
|
|
|
const sortedKeys = Object.keys(callbacks).sort((a, b) => {
|
|
const [aIdx, aStep] = a.split("-")
|
|
const [bIdx, bStep] = b.split("-")
|
|
if (aIdx !== bIdx) return Number(aIdx) - Number(bIdx)
|
|
return stepOrder.indexOf(aStep) - stepOrder.indexOf(bStep)
|
|
})
|
|
|
|
const roomsMap = new Map<string, string[]>()
|
|
for (const key of sortedKeys) {
|
|
const [roomIdx] = key.split("-")
|
|
if (!roomsMap.has(roomIdx)) {
|
|
roomsMap.set(roomIdx, [])
|
|
}
|
|
roomsMap.get(roomIdx)?.push(key)
|
|
}
|
|
|
|
let firstInvalidElement: HTMLElement | undefined = undefined
|
|
|
|
for (const roomIdx of Array.from(roomsMap.keys()).sort(
|
|
(a, b) => Number(a) - Number(b)
|
|
)) {
|
|
const roomKeys = roomsMap.get(roomIdx)!
|
|
const invalidElementsInRoom: HTMLElement[] = []
|
|
|
|
for (const key of roomKeys) {
|
|
const el = await callbacks[key]()
|
|
if (el) invalidElementsInRoom.push(el)
|
|
}
|
|
|
|
if (!firstInvalidElement && invalidElementsInRoom.length > 0) {
|
|
firstInvalidElement = invalidElementsInRoom.at(0)
|
|
}
|
|
}
|
|
|
|
return firstInvalidElement
|
|
},
|
|
},
|
|
}))
|
|
}
|
|
|
|
export function useEnterDetailsStore<T>(selector: (store: DetailsState) => T) {
|
|
const store = useContext(EnterDetailsContext)
|
|
|
|
if (!store) {
|
|
throw new Error("useEnterDetailsStore must be used within DetailsProvider")
|
|
}
|
|
|
|
return useStore(store, selector)
|
|
}
|