import deepmerge from "deepmerge" import { produce } from "immer" import { useContext } from "react" import { create, useStore } from "zustand" import { createJSONStorage, persist } from "zustand/middleware" import { DetailsContext } from "@/contexts/Details" import { arrayMerge } from "@/utils/merge" import { add, calcTotalMemberPrice, checkIsSameBooking, extractGuestFromUser, getHydratedMemberPrice, getInitialRoomPrice, getInitialTotalPrice, langToCurrency, navigate, persistedStateSchema, validateSteps, } from "./helpers" import { CurrencyEnum } from "@/types/enums/currency" import { StepEnum } from "@/types/enums/step" import type { DetailsState, FormValues, InitialState, PersistedState, } from "@/types/stores/enter-details" import type { SafeUser } from "@/types/user" const defaultGuestState = { countryCode: "", dateOfBirth: "", email: "", firstName: "", join: false, lastName: "", membershipNo: "", phoneNumber: "", zipCode: "", } export const detailsStorageName = "details-storage" export function createDetailsStore( initialState: InitialState, currentStep: StepEnum, searchParams: string, user: SafeUser ) { const isMember = !!user const isBrowser = typeof window !== "undefined" // Spread is done on purpose since we want // a copy of initialState and not alter the // original const formValues: FormValues = { bedType: initialState.bedType, booking: initialState.booking, breakfast: undefined, guest: isMember ? deepmerge(defaultGuestState, extractGuestFromUser(user)) : defaultGuestState, } if (isBrowser) { /** * We need to initialize the store from sessionStorage ourselves * since `persist` does it first after render and therefore * we cannot use the data as `defaultValues` for our forms. * RHF caches defaultValues on mount. */ const detailsStorageUnparsed = sessionStorage.getItem(detailsStorageName) if (detailsStorageUnparsed) { const detailsStorage: Record<"state", FormValues> = JSON.parse( detailsStorageUnparsed ) const isSameBooking = checkIsSameBooking( detailsStorage.state.booking, initialState.booking ) if (isSameBooking) { if (!initialState.bedType && detailsStorage.state.bedType) { formValues.bedType = detailsStorage.state.bedType } if ("breakfast" in detailsStorage.state) { formValues.breakfast = detailsStorage.state.breakfast } if ("guest" in detailsStorage.state) { if (!user) { formValues.guest = deepmerge( defaultGuestState, detailsStorage.state.guest, { arrayMerge } ) } } } } } const initialRoomPrice = getInitialRoomPrice(initialState.roomRate, isMember) const initialTotalPrice = getInitialTotalPrice( initialState.roomRate, isMember ) if (initialState.packages) { initialState.packages.forEach((pkg) => { initialTotalPrice.euro.price = add( initialTotalPrice.euro.price, pkg.requestedPrice.totalPrice ) initialTotalPrice.local.price = add( initialTotalPrice.local.price, pkg.localPrice.totalPrice ) }) } return create()( persist( (set) => ({ actions: { completeStep() { return set( produce((state: DetailsState) => { const currentStepIndex = state.steps.indexOf(state.currentStep) const nextStep = state.steps[currentStepIndex + 1] state.currentStep = nextStep navigate(nextStep, searchParams) }) ) }, navigate(step: StepEnum) { return set( produce((state) => { state.currentStep = step navigate(step, searchParams) }) ) }, setIsSubmittingDisabled(isSubmittingDisabled) { return set( produce((state: DetailsState) => { state.isSubmittingDisabled = isSubmittingDisabled }) ) }, setStep(step: StepEnum) { return set( produce((state: DetailsState) => { state.currentStep = step }) ) }, setTotalPrice(totalPrice) { return set( produce((state: DetailsState) => { state.totalPrice.euro = totalPrice.euro state.totalPrice.local = totalPrice.local }) ) }, toggleSummaryOpen() { return set( produce((state: DetailsState) => { state.isSummaryOpen = !state.isSummaryOpen }) ) }, updateBedType(bedType) { return set( produce((state: DetailsState) => { state.isValid["select-bed"] = true state.bedType = bedType const currentStepIndex = state.steps.indexOf(state.currentStep) const nextStep = state.steps[currentStepIndex + 1] state.currentStep = nextStep navigate(nextStep, searchParams) }) ) }, updateBreakfast(breakfast) { return set( produce((state: DetailsState) => { state.isValid.breakfast = true const stateTotalEuroPrice = state.totalPrice.euro?.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 if (addToTotalPrice) { const breakfastTotalEuroPrice = parseInt( breakfast.requestedPrice.totalPrice ) const breakfastTotalPrice = parseInt( breakfast.localPrice.totalPrice ) state.totalPrice = { euro: { currency: CurrencyEnum.EUR, price: stateTotalEuroPrice + breakfastTotalEuroPrice, }, local: { currency: breakfast.localPrice.currency, price: stateTotalLocalPrice + breakfastTotalPrice, }, } } if (subtractFromTotalPrice) { let currency = state.totalPrice.local.currency ?? langToCurrency() let currentBreakfastTotalPrice = 0 let currentBreakfastTotalEuroPrice = 0 if (state.breakfast) { currentBreakfastTotalPrice = parseInt( state.breakfast.localPrice.totalPrice ) currentBreakfastTotalEuroPrice = parseInt( state.breakfast.requestedPrice.totalPrice ) currency = state.breakfast.localPrice.currency } let euroPrice = stateTotalEuroPrice - currentBreakfastTotalEuroPrice if (euroPrice < 0) { euroPrice = 0 } let localPrice = stateTotalLocalPrice - currentBreakfastTotalPrice if (localPrice < 0) { localPrice = 0 } state.totalPrice = { euro: { currency: CurrencyEnum.EUR, price: euroPrice, }, local: { currency, price: localPrice, }, } } state.breakfast = breakfast const currentStepIndex = state.steps.indexOf(state.currentStep) const nextStep = state.steps[currentStepIndex + 1] state.currentStep = nextStep navigate(nextStep, searchParams) }) ) }, updateDetails(data) { return set( produce((state: DetailsState) => { state.isValid.details = true 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 } else { state.guest.membershipNo = data.membershipNo } state.guest.phoneNumber = data.phoneNumber state.guest.zipCode = data.zipCode if (data.join || data.membershipNo || isMember) { const memberPrice = calcTotalMemberPrice(state) state.roomPrice = memberPrice.roomPrice state.totalPrice = memberPrice.totalPrice } const currentStepIndex = state.steps.indexOf(state.currentStep) const nextStep = state.steps[currentStepIndex + 1] state.currentStep = nextStep navigate(nextStep, searchParams) }) ) }, }, bedType: initialState.bedType ?? undefined, booking: initialState.booking, breakfast: undefined, currentStep, formValues, guest: isMember ? deepmerge(defaultGuestState, extractGuestFromUser(user)) : defaultGuestState, isSubmittingDisabled: false, isSummaryOpen: false, isValid: { [StepEnum.selectBed]: false, [StepEnum.breakfast]: false, [StepEnum.details]: false, [StepEnum.payment]: false, }, packages: initialState.packages, roomPrice: initialRoomPrice, roomRate: initialState.roomRate, steps: [ StepEnum.selectBed, StepEnum.breakfast, StepEnum.details, StepEnum.payment, ], totalPrice: initialTotalPrice, }), { name: detailsStorageName, merge(_persistedState, currentState) { const parsedPersistedState = persistedStateSchema.safeParse(_persistedState) let persistedState if (parsedPersistedState.success) { if (parsedPersistedState.data) { persistedState = parsedPersistedState.data as PersistedState } } if (!persistedState) { persistedState = currentState as DetailsState } if ( currentState.guest.join || !!currentState.guest.membershipNo || isMember ) { if (currentState.roomRate.memberRate) { const memberPrice = getHydratedMemberPrice( currentState.roomRate.memberRate, currentState.breakfast, currentState.packages ) currentState.roomPrice = memberPrice.roomPrice currentState.totalPrice = memberPrice.totalPrice } } const isSameBooking = checkIsSameBooking( persistedState.booking, currentState.booking ) let mergedState if (isSameBooking) { mergedState = deepmerge( currentState, persistedState, { arrayMerge } ) } else { mergedState = deepmerge( persistedState, currentState, { arrayMerge } ) } /** * 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) { mergedState.steps = mergedState.steps.filter( (step) => step === StepEnum.breakfast ) if (mergedState.currentStep === StepEnum.breakfast) { mergedState.currentStep = mergedState.steps[1] } } if (initialState.bedType) { if (mergedState.currentStep === StepEnum.selectBed) { mergedState.currentStep = mergedState.steps[1] } } const validPaths = validateSteps(mergedState, isMember) if (!validPaths.includes(mergedState.currentStep)) { mergedState.currentStep = validPaths.at(-1)! } if (currentStep !== mergedState.currentStep) { setTimeout(() => { navigate(mergedState.currentStep, searchParams) }) } return mergedState }, partialize(state) { return { bedType: state.bedType, booking: state.booking, breakfast: state.breakfast, guest: state.guest, totalPrice: state.totalPrice, } }, storage: createJSONStorage(() => sessionStorage), } ) ) } export function useEnterDetailsStore(selector: (store: DetailsState) => T) { const store = useContext(DetailsContext) if (!store) { throw new Error("useEnterDetailsStore must be used within DetailsProvider") } return useStore(store, selector) }