Files
web/packages/booking-flow/lib/stores/enter-details/index.ts
Linus Flood e54310b00f Merged in chore/export-name-from-file (pull request #3439)
chore(storagecleaner): import from file to avoid huge footprints on every page

* chore(storagecleaner): import from file to avoid huge footprints on every page


Approved-by: Anton Gunnarsson
2026-01-14 12:55:06 +00:00

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