Merge branch 'master' into feature/tracking

This commit is contained in:
Linus Flood
2024-11-21 07:53:58 +01:00
213 changed files with 3486 additions and 1990 deletions

194
stores/details.ts Normal file
View File

@@ -0,0 +1,194 @@
import merge from "deepmerge"
import { produce } from "immer"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { createJSONStorage, persist } from "zustand/middleware"
import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema"
import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema"
import {
guestDetailsSchema,
signedInDetailsSchema,
} from "@/components/HotelReservation/EnterDetails/Details/schema"
import { DetailsContext } from "@/contexts/Details"
import { StepEnum } from "@/types/enums/step"
import type { DetailsState, InitialState } from "@/types/stores/details"
export const storageName = "details-storage"
export function createDetailsStore(
initialState: InitialState,
isMember: boolean
) {
if (typeof window !== "undefined") {
/**
* 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(storageName)
if (detailsStorageUnparsed) {
const detailsStorage: Record<
"state",
Pick<DetailsState, "data">
> = JSON.parse(detailsStorageUnparsed)
initialState = merge(initialState, detailsStorage.state.data)
}
}
return create<DetailsState>()(
persist(
(set) => ({
actions: {
setIsSubmittingDisabled(isSubmittingDisabled) {
return set(
produce((state: DetailsState) => {
state.isSubmittingDisabled = isSubmittingDisabled
})
)
},
setTotalPrice(totalPrice) {
return set(
produce((state: DetailsState) => {
state.totalPrice = totalPrice
})
)
},
toggleSummaryOpen() {
return set(
produce((state: DetailsState) => {
state.isSummaryOpen = !state.isSummaryOpen
})
)
},
updateBedType(bedType) {
return set(
produce((state: DetailsState) => {
state.isValid["select-bed"] = true
state.data.bedType = bedType
})
)
},
updateBreakfast(breakfast) {
return set(
produce((state: DetailsState) => {
state.isValid.breakfast = true
state.data.breakfast = breakfast
})
)
},
updateDetails(data) {
return set(
produce((state: DetailsState) => {
state.isValid.details = true
state.data.countryCode = data.countryCode
state.data.dateOfBirth = data.dateOfBirth
state.data.email = data.email
state.data.firstName = data.firstName
state.data.join = data.join
state.data.lastName = data.lastName
if (data.join) {
state.data.membershipNo = undefined
} else {
state.data.membershipNo = data.membershipNo
}
state.data.phoneNumber = data.phoneNumber
state.data.zipCode = data.zipCode
})
)
},
updateValidity(property, isValid) {
return set(
produce((state: DetailsState) => {
state.isValid[property] = isValid
})
)
},
},
data: merge(
{
bedType: undefined,
breakfast: undefined,
countryCode: "",
dateOfBirth: "",
email: "",
firstName: "",
join: false,
lastName: "",
membershipNo: "",
phoneNumber: "",
termsAccepted: false,
zipCode: "",
},
initialState
),
isSubmittingDisabled: false,
isSummaryOpen: false,
isValid: {
[StepEnum.selectBed]: false,
[StepEnum.breakfast]: false,
[StepEnum.details]: false,
[StepEnum.payment]: false,
},
totalPrice: {
euro: { currency: "", price: 0 },
local: { currency: "", price: 0 },
},
}),
{
name: storageName,
onRehydrateStorage() {
return function (state) {
if (state) {
const validatedBedType = bedTypeSchema.safeParse(state.data)
if (validatedBedType.success) {
state.actions.updateValidity(StepEnum.selectBed, true)
} else {
state.actions.updateValidity(StepEnum.selectBed, false)
}
const validatedBreakfast = breakfastStoreSchema.safeParse(
state.data
)
if (validatedBreakfast.success) {
state.actions.updateValidity(StepEnum.breakfast, true)
} else {
state.actions.updateValidity(StepEnum.breakfast, false)
}
const detailsSchema = isMember
? signedInDetailsSchema
: guestDetailsSchema
const validatedDetails = detailsSchema.safeParse(state.data)
if (validatedDetails.success) {
state.actions.updateValidity(StepEnum.details, true)
} else {
state.actions.updateValidity(StepEnum.details, false)
}
}
}
},
partialize(state) {
return {
data: state.data,
}
},
storage: createJSONStorage(() => sessionStorage),
}
)
)
}
export function useDetailsStore<T>(selector: (store: DetailsState) => T) {
const store = useContext(DetailsContext)
if (!store) {
throw new Error("useDetailsStore must be used within DetailsProvider")
}
return useStore(store, selector)
}

View File

@@ -1,209 +0,0 @@
import { produce } from "immer"
import { ReadonlyURLSearchParams } from "next/navigation"
import { createContext, useContext } from "react"
import { create, useStore } from "zustand"
import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema"
import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema"
import {
guestDetailsSchema,
signedInDetailsSchema,
} from "@/components/HotelReservation/EnterDetails/Details/schema"
import {
createSelectRateUrl,
getQueryParamsForEnterDetails,
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData"
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import { DetailsSchema } from "@/types/components/hotelReservation/enterDetails/details"
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
const SESSION_STORAGE_KEY = "enterDetails"
type TotalPrice = {
local: { price: number; currency: string }
euro: { price: number; currency: string }
}
export interface EnterDetailsState {
userData: {
bedType: BedTypeSchema | undefined
breakfast: BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST | undefined
} & DetailsSchema
roomData: BookingData
steps: StepEnum[]
selectRateUrl: string
currentStep: StepEnum
totalPrice: TotalPrice
isSubmittingDisabled: boolean
isSummaryOpen: boolean
isValid: Record<StepEnum, boolean>
completeStep: (updatedData: Partial<EnterDetailsState["userData"]>) => void
navigate: (
step: StepEnum,
updatedData?: Record<
string,
string | boolean | number | BreakfastPackage | BedTypeSchema
>
) => void
setCurrentStep: (step: StepEnum) => void
toggleSummaryOpen: () => void
setTotalPrice: (totalPrice: TotalPrice) => void
setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void
}
export function initEditDetailsState(
currentStep: StepEnum,
searchParams: ReadonlyURLSearchParams,
isMember: boolean
) {
const isBrowser = typeof window !== "undefined"
const sessionData = isBrowser
? sessionStorage.getItem(SESSION_STORAGE_KEY)
: null
let roomData: BookingData
let selectRateUrl: string
if (searchParams?.size) {
const data = getQueryParamsForEnterDetails(searchParams)
roomData = data
selectRateUrl = `select-rate?${createSelectRateUrl(data)}`
}
const defaultUserData: EnterDetailsState["userData"] = {
bedType: undefined,
breakfast: undefined,
countryCode: "",
email: "",
firstName: "",
lastName: "",
phoneNumber: "",
join: false,
zipCode: "",
dateOfBirth: undefined,
termsAccepted: false,
membershipNo: "",
}
let inputUserData = {}
if (sessionData) {
inputUserData = JSON.parse(sessionData)
}
const validPaths = [StepEnum.selectBed]
let initialData: EnterDetailsState["userData"] = defaultUserData
const isValid = {
[StepEnum.selectBed]: false,
[StepEnum.breakfast]: false,
[StepEnum.details]: false,
[StepEnum.payment]: false,
}
const validatedBedType = bedTypeSchema.safeParse(inputUserData)
if (validatedBedType.success) {
validPaths.push(StepEnum.breakfast)
initialData = { ...initialData, ...validatedBedType.data }
isValid[StepEnum.selectBed] = true
}
const validatedBreakfast = breakfastStoreSchema.safeParse(inputUserData)
if (validatedBreakfast.success) {
validPaths.push(StepEnum.details)
initialData = { ...initialData, ...validatedBreakfast.data }
isValid[StepEnum.breakfast] = true
}
const detailsSchema = isMember ? signedInDetailsSchema : guestDetailsSchema
const validatedDetails = detailsSchema.safeParse(inputUserData)
if (validatedDetails.success) {
validPaths.push(StepEnum.payment)
initialData = { ...initialData, ...validatedDetails.data }
isValid[StepEnum.details] = true
}
if (!validPaths.includes(currentStep)) {
currentStep = validPaths.pop()! // We will always have at least one valid path
if (isBrowser) {
window.history.pushState(
{ step: currentStep },
"",
currentStep + window.location.search
)
}
}
return create<EnterDetailsState>()((set, get) => ({
userData: initialData,
roomData,
selectRateUrl,
steps: Object.values(StepEnum),
totalPrice: {
local: { price: 0, currency: "" },
euro: { price: 0, currency: "" },
},
isSummaryOpen: false,
isSubmittingDisabled: false,
setCurrentStep: (step) => set({ currentStep: step }),
navigate: (step, updatedData) =>
set(
produce((state) => {
const sessionStorage = window.sessionStorage
const previousDataString = sessionStorage.getItem(SESSION_STORAGE_KEY)
const previousData = JSON.parse(previousDataString || "{}")
sessionStorage.setItem(
SESSION_STORAGE_KEY,
JSON.stringify({ ...previousData, ...updatedData })
)
state.currentStep = step
window.history.pushState({ step }, "", step + window.location.search)
})
),
currentStep,
isValid,
completeStep: (updatedData) =>
set(
produce((state: EnterDetailsState) => {
state.isValid[state.currentStep] = true
const nextStep =
state.steps[state.steps.indexOf(state.currentStep) + 1]
state.userData = {
...state.userData,
...updatedData,
}
state.currentStep = nextStep
get().navigate(nextStep, updatedData)
})
),
toggleSummaryOpen: () => set({ isSummaryOpen: !get().isSummaryOpen }),
setTotalPrice: (totalPrice) => set({ totalPrice: totalPrice }),
setIsSubmittingDisabled: (isSubmittingDisabled) =>
set({ isSubmittingDisabled }),
}))
}
export type EnterDetailsStore = ReturnType<typeof initEditDetailsState>
export const EnterDetailsContext = createContext<EnterDetailsStore | null>(null)
export const useEnterDetailsStore = <T>(
selector: (store: EnterDetailsState) => T
): T => {
const enterDetailsContextStore = useContext(EnterDetailsContext)
if (!enterDetailsContextStore) {
throw new Error(
`useEnterDetailsStore must be used within EnterDetailsContextProvider`
)
}
return useStore(enterDetailsContextStore, selector)
}

27
stores/hotel-filters.ts Normal file
View File

@@ -0,0 +1,27 @@
import { create } from "zustand"
interface HotelFilterState {
activeFilters: string[]
toggleFilter: (filterId: string) => void
setFilters: (filters: string[]) => void
resultCount: number
setResultCount: (count: number) => void
}
export const useHotelFilterStore = create<HotelFilterState>((set) => ({
activeFilters: [],
setFilters: (filters) => set({ activeFilters: filters }),
toggleFilter: (filterId: string) =>
set((state) => {
const isActive = state.activeFilters.includes(filterId)
const newFilters = isActive
? state.activeFilters.filter((id) => id !== filterId)
: [...state.activeFilters, filterId]
return { activeFilters: newFilters }
}),
resultCount: 0,
setResultCount: (count) => set({ resultCount: count }),
}))

156
stores/steps.ts Normal file
View File

@@ -0,0 +1,156 @@
"use client"
import merge from "deepmerge"
import { produce } from "immer"
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema"
import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema"
import {
guestDetailsSchema,
signedInDetailsSchema,
} from "@/components/HotelReservation/EnterDetails/Details/schema"
import { StepsContext } from "@/contexts/Steps"
import { storageName as detailsStorageName } from "./details"
import { StepEnum } from "@/types/enums/step"
import type { DetailsState } from "@/types/stores/details"
import type { StepState } from "@/types/stores/steps"
export function createStepsStore(
currentStep: StepEnum,
isMember: boolean,
noBedChoices: boolean,
noBreakfast: boolean,
searchParams: string,
push: AppRouterInstance["push"]
) {
const isBrowser = typeof window !== "undefined"
const 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.
*
* matching breakfast first so the steps array is altered
* before the bedTypes possible step altering
*/
if (noBreakfast) {
steps.splice(1, 1)
if (currentStep === StepEnum.breakfast) {
currentStep = steps[1]
push(`${currentStep}?${searchParams}`)
}
}
if (noBedChoices) {
if (currentStep === StepEnum.selectBed) {
currentStep = steps[1]
push(`${currentStep}?${searchParams}`)
}
}
const detailsStorageUnparsed = isBrowser
? sessionStorage.getItem(detailsStorageName)
: null
if (detailsStorageUnparsed) {
const detailsStorage: Record<
"state",
Pick<DetailsState, "data">
> = JSON.parse(detailsStorageUnparsed)
const validPaths = [StepEnum.selectBed]
const validatedBedType = bedTypeSchema.safeParse(detailsStorage.state.data)
if (validatedBedType.success) {
validPaths.push(steps[1])
}
const validatedBreakfast = breakfastStoreSchema.safeParse(
detailsStorage.state.data
)
if (validatedBreakfast.success) {
validPaths.push(StepEnum.details)
}
const detailsSchema = isMember ? signedInDetailsSchema : guestDetailsSchema
const validatedDetails = detailsSchema.safeParse(detailsStorage.state.data)
if (validatedDetails.success) {
validPaths.push(StepEnum.payment)
}
if (!validPaths.includes(currentStep) && isBrowser) {
// We will always have at least one valid path
currentStep = validPaths.pop()!
push(`${currentStep}?${searchParams}`)
}
}
const initalData = {
currentStep,
steps,
}
return create<StepState>()((set) =>
merge(
{
currentStep: StepEnum.selectBed,
steps: [],
completeStep() {
return set(
produce((state: StepState) => {
const currentStepIndex = state.steps.indexOf(state.currentStep)
const nextStep = state.steps[currentStepIndex + 1]
state.currentStep = nextStep
window.history.pushState(
{ step: nextStep },
"",
nextStep + window.location.search
)
})
)
},
navigate(step: StepEnum) {
return set(
produce((state) => {
state.currentStep = step
window.history.pushState(
{ step },
"",
step + window.location.search
)
})
)
},
setStep(step: StepEnum) {
return set(
produce((state: StepState) => {
state.currentStep = step
})
)
},
},
initalData
)
)
}
export function useStepsStore<T>(selector: (store: StepState) => T) {
const store = useContext(StepsContext)
if (!store) {
throw new Error(`useStepsStore must be used within StepsProvider`)
}
return useStore(store, selector)
}