Merged in monorepo-step-1 (pull request #1080)

Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

View File

@@ -0,0 +1,13 @@
import { create } from "zustand"
interface BookingCodeFilterState {
activeCodeFilter: string
setFilter: (filter: string) => void
}
export const useBookingCodeFilterStore = create<BookingCodeFilterState>(
(set) => ({
activeCodeFilter: "discounted",
setFilter: (filter) => set({ activeCodeFilter: filter }),
})
)

View File

@@ -0,0 +1,392 @@
import isEqual from "fast-deep-equal"
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 { StepEnum } from "@/types/enums/step"
import type {
DetailsState,
PersistedState,
RoomState,
RoomStatus,
RoomStep,
} from "@/types/stores/enter-details"
import type { SafeUser } from "@/types/user"
export function extractGuestFromUser(user: NonNullable<SafeUser>) {
return {
countryCode: user.address.countryCode?.toString(),
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
join: false,
membershipNo: user.membership?.membershipNumber,
phoneNumber: user.phoneNumber ?? "",
}
}
export function checkIsSameBedTypes(
storedBedTypes: string,
bedTypesData: string
) {
return storedBedTypes === bedTypesData
}
export function checkIsSameBooking(
prev: SelectRateSearchParams,
next: SelectRateSearchParams
) {
const { rooms: prevRooms, ...prevBooking } = prev
const prevRoomsWithoutRateCodes = prevRooms.map(
({ rateCode, counterRateCode, roomTypeCode, ...room }) => room
)
const { rooms: nextRooms, ...nextBooking } = next
const nextRoomsWithoutRateCodes = nextRooms.map(
({ rateCode, counterRateCode, roomTypeCode, ...room }) => room
)
return isEqual(
{
...prevBooking,
rooms: prevRoomsWithoutRateCodes,
},
{
...nextBooking,
rooms: nextRoomsWithoutRateCodes,
}
)
}
export function add(...nums: (number | string | undefined)[]) {
return nums.reduce((total: number, num) => {
if (typeof num === "undefined") {
num = 0
}
total = total + parseInt(`${num}`)
return total
}, 0)
}
export function subtract(...nums: (number | string | undefined)[]) {
return nums.reduce((total: number, num, idx) => {
if (typeof num === "undefined") {
num = 0
}
if (idx === 0) {
return parseInt(`${num}`)
}
total = total - parseInt(`${num}`)
if (total < 0) {
return 0
}
return total
}, 0)
}
export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
if (isMember && roomRate.memberRate) {
return {
perNight: {
requested: roomRate.memberRate.requestedPrice && {
currency: roomRate.memberRate.requestedPrice.currency,
price: roomRate.memberRate.requestedPrice.pricePerNight,
},
local: {
currency: roomRate.memberRate.localPrice.currency,
price: roomRate.memberRate.localPrice.pricePerNight,
},
},
perStay: {
requested: roomRate.memberRate.requestedPrice && {
currency: roomRate.memberRate.requestedPrice.currency,
price: roomRate.memberRate.requestedPrice.pricePerStay,
},
local: {
currency: roomRate.memberRate.localPrice.currency,
price: roomRate.memberRate.localPrice.pricePerStay,
},
},
}
}
return {
perNight: {
requested: roomRate.publicRate.requestedPrice && {
currency: roomRate.publicRate.requestedPrice.currency,
price: roomRate.publicRate.requestedPrice.pricePerNight,
},
local: {
currency: roomRate.publicRate.localPrice.currency,
price: roomRate.publicRate.localPrice.pricePerNight,
},
},
perStay: {
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,
},
},
}
}
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: roomRates[0].publicRate.localPrice.currency,
price: 0,
},
}
)
}
export function calcTotalPrice(
rooms: RoomState[],
totalPrice: Price,
isMember: boolean
) {
return rooms.reduce<Price>(
(acc, room, index) => {
const isFirstRoomAndMember = index === 0 && isMember
const join = Boolean(room.guest.join || room.guest.membershipNo)
const roomPrice = getRoomPrice(
room.roomRate,
isFirstRoomAndMember || join
)
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
}, 0)
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 result
},
{
requested: undefined,
local: { currency: totalPrice.local.currency, price: 0 },
}
)
}
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 selectPreviousSteps = (
state: DetailsState,
index?: number
): {
[StepEnum.selectBed]?: RoomStep
[StepEnum.breakfast]?: RoomStep
[StepEnum.details]?: RoomStep
} => {
const roomStatus =
state.bookingProgress.roomStatuses[
index ?? state.bookingProgress.currentRoomIndex
]
const stepKeys = Object.keys(roomStatus.steps)
const currentStepIndex = stepKeys.indexOf(`${roomStatus.currentStep}`)
return Object.entries(roomStatus.steps)
.slice(0, currentStepIndex)
.reduce((acc, [key, value]) => {
return { ...acc, [key]: value }
}, {})
}
export const selectNextStep = (roomStatus: RoomStatus) => {
if (roomStatus.currentStep === null) {
throw new Error("getNextStep: currentStep is null")
}
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.roomStatuses.findIndex(
(room) => !room.isComplete
)
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 !== null && roomStatus.currentStep !== null) {
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)
}

View File

@@ -0,0 +1,376 @@
import deepmerge from "deepmerge"
import { produce } from "immer"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { DetailsContext } from "@/contexts/Details"
import {
add,
calcTotalPrice,
checkRoomProgress,
extractGuestFromUser,
getRoomPrice,
getTotalPrice,
handleStepProgression,
selectPreviousSteps,
selectRoom,
selectRoomStatus,
writeToSessionStorage,
} from "./helpers"
import { StepEnum } from "@/types/enums/step"
import type {
DetailsState,
InitialState,
RoomState,
RoomStatus,
RoomStep,
} 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 = "rooms-details-storage"
export function createDetailsStore(
initialState: InitialState,
searchParams: string,
user: SafeUser
) {
const isMember = !!user
const initialTotalPrice = getTotalPrice(
initialState.rooms.map((r) => r.roomRate),
isMember
)
initialState.rooms.forEach((room) => {
if (room.roomFeatures) {
room.roomFeatures.forEach((pkg) => {
if (initialTotalPrice.requested) {
initialTotalPrice.requested.price = add(
initialTotalPrice.requested.price,
pkg.requestedPrice.totalPrice
)
}
initialTotalPrice.local.price = add(
initialTotalPrice.local.price,
pkg.localPrice.totalPrice
)
})
}
})
const rooms: RoomState[] = initialState.rooms.map((room, idx) => {
return {
...room,
adults: initialState.booking.rooms[idx].adults,
childrenInRoom: initialState.booking.rooms[idx].childrenInRoom,
bedType: room.bedType,
breakfast:
initialState.breakfast === false ? initialState.breakfast : undefined,
guest: isMember
? deepmerge(defaultGuestState, extractGuestFromUser(user))
: defaultGuestState,
roomPrice: getRoomPrice(room.roomRate, isMember && idx === 0),
}
})
const roomStatuses: RoomStatus[] = initialState.rooms.map((room, idx) => {
const steps: RoomStatus["steps"] = {
[StepEnum.selectBed]: {
step: StepEnum.selectBed,
isValid: !!room.bedType,
},
[StepEnum.breakfast]: {
step: StepEnum.breakfast,
isValid: false,
},
[StepEnum.details]: {
step: StepEnum.details,
isValid: false,
},
}
if (initialState.breakfast === false) {
delete steps[StepEnum.breakfast]
}
const currentStep =
idx === 0
? Object.values(steps).find((step) => !step.isValid)?.step ??
StepEnum.selectBed
: null
return {
isComplete: false,
currentStep: currentStep,
lastCompletedStep: undefined,
steps,
}
})
return create<DetailsState>()((set, get) => ({
searchParamString: searchParams,
booking: initialState.booking,
breakfast:
initialState.breakfast === false ? initialState.breakfast : undefined,
isSubmittingDisabled: false,
isSummaryOpen: false,
isPriceDetailsModalOpen: false,
totalPrice: initialTotalPrice,
vat: initialState.vat,
rooms,
bookingProgress: {
currentRoomIndex: 0,
roomStatuses,
canProceedToPayment: false,
},
actions: {
setStep(step: StepEnum | null, roomIndex?: number) {
if (step === null) {
return
}
return set(
produce((state: DetailsState) => {
const currentRoomIndex =
roomIndex ?? state.bookingProgress.currentRoomIndex
const previousSteps = selectPreviousSteps(state, roomIndex)
const arePreviousStepsCompleted = Object.values(
previousSteps
).every((step: RoomStep) => step.isValid)
const arePreviousRoomsCompleted = state.bookingProgress.roomStatuses
.slice(0, currentRoomIndex)
.every((room) => room.isComplete)
const roomStatus = selectRoomStatus(state, roomIndex)
if (arePreviousRoomsCompleted && arePreviousStepsCompleted) {
roomStatus.currentStep = step
if (roomIndex !== undefined) {
state.bookingProgress.currentRoomIndex = roomIndex
}
}
})
)
},
setIsSubmittingDisabled(isSubmittingDisabled) {
return set(
produce((state: DetailsState) => {
state.isSubmittingDisabled = isSubmittingDisabled
})
)
},
setTotalPrice(totalPrice) {
return set(
produce((state: DetailsState) => {
state.totalPrice.requested = totalPrice.requested
state.totalPrice.local = totalPrice.local
})
)
},
toggleSummaryOpen() {
return set(
produce((state: DetailsState) => {
state.isSummaryOpen = !state.isSummaryOpen
})
)
},
togglePriceDetailsModalOpen() {
return set(
produce((state: DetailsState) => {
state.isPriceDetailsModalOpen = !state.isPriceDetailsModalOpen
})
)
},
updateBedType(bedType) {
return set(
produce((state: DetailsState) => {
const roomStatus = selectRoomStatus(state)
roomStatus.steps[StepEnum.selectBed].isValid = true
const room = selectRoom(state)
room.bedType = bedType
handleStepProgression(state)
writeToSessionStorage({
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
})
})
)
},
updateBreakfast(breakfast) {
return set(
produce((state: DetailsState) => {
const roomStatus = selectRoomStatus(state)
if (roomStatus.steps[StepEnum.breakfast]) {
roomStatus.steps[StepEnum.breakfast].isValid = true
}
const stateTotalRequestedPrice =
state.totalPrice.requested?.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 breakfastTotalRequestedPrice = parseInt(
breakfast.requestedPrice.totalPrice
)
const breakfastTotalPrice = parseInt(
breakfast.localPrice.totalPrice
)
state.totalPrice = {
requested: state.totalPrice.requested && {
currency: state.totalPrice.requested.currency,
price:
stateTotalRequestedPrice + breakfastTotalRequestedPrice,
},
local: {
currency: breakfast.localPrice.currency,
price: stateTotalLocalPrice + breakfastTotalPrice,
},
}
}
if (subtractFromTotalPrice) {
let currency = state.totalPrice.local.currency
let currentBreakfastTotalPrice = 0
let currentBreakfastTotalRequestedPrice = 0
if (state.breakfast) {
currentBreakfastTotalPrice = parseInt(
state.breakfast.localPrice.totalPrice
)
currentBreakfastTotalRequestedPrice = parseInt(
state.breakfast.requestedPrice.totalPrice
)
currency = state.breakfast.localPrice.currency
}
let requestedPrice =
stateTotalRequestedPrice - currentBreakfastTotalRequestedPrice
if (requestedPrice < 0) {
requestedPrice = 0
}
let localPrice = stateTotalLocalPrice - currentBreakfastTotalPrice
if (localPrice < 0) {
localPrice = 0
}
state.totalPrice = {
requested: state.totalPrice.requested && {
currency: state.totalPrice.requested.currency,
price: requestedPrice,
},
local: {
currency,
price: localPrice,
},
}
}
const room = selectRoom(state)
room.breakfast = breakfast
handleStepProgression(state)
writeToSessionStorage({
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
})
})
)
},
updateDetails(data) {
return set(
produce((state: DetailsState) => {
const roomStatus = selectRoomStatus(state)
roomStatus.steps[StepEnum.details].isValid = true
const room = selectRoom(state)
room.guest.countryCode = data.countryCode
room.guest.dateOfBirth = data.dateOfBirth
room.guest.email = data.email
room.guest.firstName = data.firstName
room.guest.join = data.join
room.guest.lastName = data.lastName
if (data.join) {
room.guest.membershipNo = undefined
} else {
room.guest.membershipNo = data.membershipNo
}
room.guest.phoneNumber = data.phoneNumber
room.guest.zipCode = data.zipCode
room.roomPrice = getRoomPrice(
room.roomRate,
Boolean(data.join || data.membershipNo || isMember)
)
state.totalPrice = calcTotalPrice(
state.rooms,
state.totalPrice,
isMember
)
const isAllStepsCompleted = checkRoomProgress(state)
if (isAllStepsCompleted) {
roomStatus.isComplete = true
}
handleStepProgression(state)
writeToSessionStorage({
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
})
})
)
},
updateSeachParamString(searchParamString) {
return set(
produce((state: DetailsState) => {
state.searchParamString = searchParamString
})
)
},
},
}))
}
export function useEnterDetailsStore<T>(selector: (store: DetailsState) => T) {
const store = useContext(DetailsContext)
if (!store) {
throw new Error("useEnterDetailsStore must be used within DetailsProvider")
}
return useStore(store, selector)
}

View File

@@ -0,0 +1,633 @@
import { describe, expect, test } from "@jest/globals"
import { act, renderHook, waitFor } from "@testing-library/react"
import { type PropsWithChildren } from "react"
import { Lang } from "@/constants/languages"
import {
bedType,
booking,
breakfastPackage,
guestDetailsMember,
guestDetailsNonMember,
roomPrice,
roomRate,
} from "@/__mocks__/hotelReservation"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { selectRoom, selectRoomStatus } from "./helpers"
import { detailsStorageName, useEnterDetailsStore } from "."
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { StepEnum } from "@/types/enums/step"
import type { PersistedState } from "@/types/stores/enter-details"
jest.mock("react", () => ({
...jest.requireActual("react"),
cache: jest.fn(),
}))
jest.mock("@/server/utils", () => ({
toLang: () => Lang.en,
}))
jest.mock("@/lib/api", () => ({
fetchRetry: jest.fn((fn) => fn),
}))
interface CreateWrapperParams {
showBreakfastStep?: boolean
breakfastIncluded?: boolean
mustBeGuaranteed?: boolean
bookingParams?: SelectRateSearchParams
bedTypes?: BedTypeSelection[]
}
function createWrapper(params: Partial<CreateWrapperParams> = {}) {
const {
showBreakfastStep = true,
breakfastIncluded = false,
mustBeGuaranteed = false,
bookingParams = booking,
bedTypes = [bedType.king, bedType.queen],
} = params
return function Wrapper({ children }: PropsWithChildren) {
return (
<EnterDetailsProvider
booking={bookingParams}
showBreakfastStep={showBreakfastStep}
roomsData={[
{
bedTypes,
packages: null,
mustBeGuaranteed,
breakfastIncluded,
cancellationText: "",
rateDetails: [],
roomType: "Standard",
roomRate: roomRate,
},
{
bedTypes,
packages: null,
mustBeGuaranteed,
breakfastIncluded,
cancellationText: "",
rateDetails: [],
roomType: "Standard",
roomRate: roomRate,
},
]}
searchParamsStr=""
user={null}
vat={0}
>
{children}
</EnterDetailsProvider>
)
}
}
describe("Enter Details Store", () => {
beforeEach(() => {
window.sessionStorage.clear()
})
describe("initial state", () => {
test("initialize with correct default values", () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
const state = result.current
expect(state.booking).toEqual(booking)
expect(state.breakfast).toEqual(undefined)
// room 1
const room1Status = selectRoomStatus(result.current, 0)
const room1 = selectRoom(result.current, 0)
expect(room1Status.currentStep).toBe(StepEnum.selectBed)
expect(room1.roomPrice.perNight.local.price).toEqual(
roomRate.publicRate.localPrice.pricePerNight
)
expect(room1.bedType).toEqual(undefined)
expect(Object.values(room1.guest).every((value) => value === ""))
// room 2
const room2Status = selectRoomStatus(result.current, 1)
const room2 = selectRoom(result.current, 1)
expect(room2Status.currentStep).toBe(null)
expect(room2.roomPrice.perNight.local.price).toEqual(
room2.roomRate.publicRate.localPrice.pricePerNight
)
expect(room2.bedType).toEqual(undefined)
expect(Object.values(room2.guest).every((value) => value === ""))
})
test("initialize with correct values from session storage", () => {
const storage: PersistedState = {
booking: booking,
bookingProgress: {
currentRoomIndex: 0,
canProceedToPayment: true,
roomStatuses: [
{
isComplete: false,
currentStep: StepEnum.selectBed,
lastCompletedStep: undefined,
steps: {
[StepEnum.selectBed]: {
step: StepEnum.selectBed,
isValid: true,
},
[StepEnum.breakfast]: {
step: StepEnum.breakfast,
isValid: true,
},
[StepEnum.details]: {
step: StepEnum.details,
isValid: true,
},
},
},
],
},
rooms: [
{
roomFeatures: null,
roomRate: roomRate,
roomType: "Classic Double",
cancellationText: "Non-refundable",
rateDetails: [],
bedType: {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
},
adults: 1,
childrenInRoom: [],
breakfast: breakfastPackage,
guest: guestDetailsNonMember,
roomPrice: roomPrice,
},
],
}
window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage))
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
expect(result.current.booking).toEqual(storage.booking)
expect(result.current.rooms[0]).toEqual(storage.rooms[0])
expect(result.current.bookingProgress).toEqual(storage.bookingProgress)
})
})
test("add bedtype and proceed to next step", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
let roomStatus = selectRoomStatus(result.current)
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
await act(async () => {
result.current.actions.updateBedType(selectedBedType)
})
roomStatus = selectRoomStatus(result.current)
const room = selectRoom(result.current)
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(room.bedType).toEqual(selectedBedType)
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
})
test("complete step and navigate to next step", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
// Room 1
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
let roomStatus = selectRoomStatus(result.current)
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
await act(async () => {
result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
})
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
await act(async () => {
result.current.actions.updateBreakfast(breakfastPackage)
})
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.details)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.bookingProgress.canProceedToPayment).toBe(false)
// Room 2
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.currentStep).toEqual(StepEnum.selectBed)
await act(async () => {
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
result.current.actions.updateBedType(selectedBedType)
})
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.breakfast)
await act(async () => {
result.current.actions.updateBreakfast(breakfastPackage)
})
roomStatus = selectRoomStatus(result.current)
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toEqual(true)
expect(roomStatus.currentStep).toEqual(StepEnum.details)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.bookingProgress.canProceedToPayment).toBe(true)
})
test("all steps needs to be completed before going to next room", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
await act(async () => {
result.current.actions.setStep(StepEnum.breakfast, 1)
})
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
})
test("can go back and modify room 1 after completion", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
await act(async () => {
result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
result.current.actions.updateBreakfast(breakfastPackage)
result.current.actions.updateDetails(guestDetailsNonMember)
})
// now we are at room 2
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
await act(async () => {
result.current.actions.setStep(StepEnum.breakfast, 0) // click "modify"
})
expect(result.current.bookingProgress.currentRoomIndex).toEqual(0)
await act(async () => {
result.current.actions.updateBreakfast(breakfastPackage)
})
// going back to room 2
expect(result.current.bookingProgress.currentRoomIndex).toEqual(1)
})
test("breakfast step should be hidden when breakfast is included", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ showBreakfastStep: false }),
}
)
const room1Status = selectRoomStatus(result.current, 0)
expect(Object.keys(room1Status.steps)).not.toContain(StepEnum.breakfast)
const room2Status = selectRoomStatus(result.current, 1)
expect(Object.keys(room2Status.steps)).not.toContain(StepEnum.breakfast)
})
test("select bed step should be skipped when there is only one bedtype", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ bedTypes: [bedType.queen] }),
}
)
const room1Status = selectRoomStatus(result.current, 0)
expect(room1Status.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(room1Status.currentStep).toEqual(StepEnum.breakfast)
const room2Status = selectRoomStatus(result.current, 1)
expect(room2Status.steps[StepEnum.selectBed].isValid).toEqual(true)
expect(room2Status.currentStep).toEqual(null)
})
describe("price calculation", () => {
test("total price should be set properly", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
const publicRate = roomRate.publicRate.localPrice.pricePerStay
const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0
const initialTotalPrice = publicRate * result.current.rooms.length
expect(result.current.totalPrice.local.price).toEqual(initialTotalPrice)
// room 1
await act(async () => {
result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
result.current.actions.updateBreakfast(breakfastPackage)
})
let expectedTotalPrice =
initialTotalPrice + Number(breakfastPackage.localPrice.price)
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
await act(async () => {
result.current.actions.updateDetails(guestDetailsMember)
})
expectedTotalPrice =
memberRate + publicRate + Number(breakfastPackage.localPrice.price)
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
// room 2
await act(async () => {
result.current.actions.updateBedType({
roomTypeCode: bedType.king.value,
description: bedType.king.description,
})
result.current.actions.updateBreakfast(breakfastPackage)
})
expectedTotalPrice =
memberRate + publicRate + Number(breakfastPackage.localPrice.price) * 2
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
await act(async () => {
result.current.actions.updateDetails(guestDetailsNonMember)
})
expect(result.current.totalPrice.local.price).toEqual(expectedTotalPrice)
})
test("room price should be set properly", async () => {
const { result } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper(),
}
)
const publicRate = roomRate.publicRate.localPrice.pricePerStay
const memberRate = roomRate.memberRate?.localPrice.pricePerStay ?? 0
let room1 = selectRoom(result.current, 0)
expect(room1.roomPrice.perStay.local.price).toEqual(publicRate)
let room2 = selectRoom(result.current, 0)
expect(room2.roomPrice.perStay.local.price).toEqual(publicRate)
await act(async () => {
result.current.actions.updateDetails(guestDetailsMember)
})
room1 = selectRoom(result.current, 0)
expect(room1.roomPrice.perStay.local.price).toEqual(memberRate)
})
})
describe("change room", () => {
test("changing to room with new bedtypes requires selecting bed again", async () => {
const { result: firstRun } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ bedTypes: [bedType.king, bedType.queen] }),
}
)
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
// add bedtype
await act(async () => {
firstRun.current.actions.updateBedType(selectedBedType)
})
await act(async () => {
firstRun.current.actions.updateBreakfast(false) // 'no breakfast' selected
})
await act(async () => {
firstRun.current.actions.updateDetails(guestDetailsNonMember)
})
const updatedBooking = {
...booking,
rooms: booking.rooms.map((r) => ({
...r,
roomTypeCode: "NEW",
})),
}
// render again to change the bedtypes
const { result: secondRun } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({
bookingParams: updatedBooking,
bedTypes: [bedType.single, bedType.queen],
}),
}
)
await waitFor(() => {
const room = selectRoom(secondRun.current, 0)
const roomStatus = selectRoomStatus(secondRun.current, 0)
// bed type should be unset since the bed types have changed
expect(room.bedType).toEqual(undefined)
// bed step should be unselected
expect(roomStatus.currentStep).toBe(StepEnum.selectBed)
expect(roomStatus.steps[StepEnum.selectBed].isValid).toBe(false)
// other steps should still be selected
expect(room.breakfast).toBe(false)
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toBe(true)
expect(room.guest).toEqual(guestDetailsNonMember)
expect(roomStatus.steps[StepEnum.details].isValid).toBe(true)
})
})
test("changing to room with single bedtype option should skip step", async () => {
const { result: firstRun } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ bedTypes: [bedType.king, bedType.queen] }),
}
)
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
// add bedtype
await act(async () => {
firstRun.current.actions.updateBedType(selectedBedType)
})
await act(async () => {
firstRun.current.actions.updateBreakfast(breakfastPackage)
})
const updatedBooking = {
...booking,
rooms: booking.rooms.map((r) => ({
...r,
roomTypeCode: "NEW",
})),
}
// render again to change the bedtypes
const { result: secondRun } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({
bookingParams: updatedBooking,
bedTypes: [bedType.queen],
}),
}
)
await waitFor(() => {
const room = selectRoom(secondRun.current, 0)
const roomStatus = selectRoomStatus(secondRun.current, 0)
expect(room.bedType).toEqual({
roomTypeCode: bedType.queen.value,
description: bedType.queen.description,
})
expect(roomStatus.steps[StepEnum.selectBed].isValid).toBe(true)
expect(roomStatus.steps[StepEnum.breakfast]?.isValid).toBe(true)
expect(roomStatus.steps[StepEnum.details].isValid).toBe(false)
expect(roomStatus.currentStep).toBe(StepEnum.details)
})
})
test("if booking has changed, stored values should be discarded", async () => {
const { result: firstRun } = renderHook(
() => useEnterDetailsStore((state) => state),
{
wrapper: createWrapper({ bedTypes: [bedType.king, bedType.queen] }),
}
)
const selectedBedType = {
roomTypeCode: bedType.king.value,
description: bedType.king.description,
}
// add bedtype
await act(async () => {
firstRun.current.actions.updateBedType(selectedBedType)
})
await act(async () => {
firstRun.current.actions.updateBreakfast(breakfastPackage)
})
const updatedBooking = {
...booking,
hotelId: "0001",
fromDate: "2030-01-01",
toDate: "2030-01-02",
}
renderHook(() => useEnterDetailsStore((state) => state), {
wrapper: createWrapper({
bookingParams: updatedBooking,
bedTypes: [bedType.queen],
}),
})
await waitFor(() => {
const storageItem = window.sessionStorage.getItem(detailsStorageName)
expect(storageItem).toBe(null)
})
})
})
})

View File

@@ -0,0 +1,118 @@
import type {
CategorizedFilters,
Filter,
SortItem,
} from "@/types/components/hotelFilterAndSort"
import { SortOption } from "@/types/enums/hotelFilterAndSort"
import type { HotelDataWithUrl } from "@/types/hotel"
export const SORTING_STRATAGIES: Record<
SortOption,
(a: HotelDataWithUrl, b: HotelDataWithUrl) => number
> = {
[SortOption.Name]: function (a, b) {
return a.hotel.name.localeCompare(b.hotel.name)
},
[SortOption.TripAdvisorRating]: function (a, b) {
return (
(b.hotel.ratings?.tripAdvisor.rating ?? 0) -
(a.hotel.ratings?.tripAdvisor.rating ?? 0)
)
},
[SortOption.Distance]: function (a, b) {
return a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre
},
}
export function getFilteredHotels(
hotels: HotelDataWithUrl[],
filters: string[]
) {
if (filters.length) {
return hotels.filter(({ hotel }) =>
filters.every((filter) =>
hotel.detailedFacilities.some((facility) => facility.slug === filter)
)
)
}
return hotels
}
export function getSortedHotels(
hotels: HotelDataWithUrl[],
sortOption: SortOption
) {
return hotels.sort(SORTING_STRATAGIES[sortOption])
}
export const DEFAULT_SORT = SortOption.Distance
export function isValidSortOption(
value: string,
sortItems: SortItem[]
): value is SortOption {
return sortItems.map((item) => item.value).includes(value as SortOption)
}
const HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES = [
"Hotel surroundings",
"Hotel omgivelser",
"Hotelumgebung",
"Hotellia lähellä",
"Hotellomgivelser",
"Omgivningar",
]
const HOTEL_FACILITIES_FILTER_TYPE_NAMES = [
"Hotel facilities",
"Hotellfaciliteter",
"Hotelfaciliteter",
"Hotel faciliteter",
"Hotel-Infos",
"Hotellin palvelut",
]
export function getFiltersFromHotels(
hotels: HotelDataWithUrl[]
): CategorizedFilters {
if (hotels.length === 0) {
return { facilityFilters: [], surroundingsFilters: [] }
}
const filters = hotels.flatMap(({ hotel }) => hotel.detailedFacilities)
const sortedFilters = filters.sort((a, b) => b.sortOrder - a.sortOrder)
const uniqueFilterNames = [
...new Set(sortedFilters.map((filter) => filter.name)),
]
const filterList = uniqueFilterNames
.map((filterName) => {
const filter = filters.find((filter) => filter.name === filterName)
return filter
? {
name: filter.name,
slug: filter.slug,
filterType: filter.filter,
}
: null
})
.filter((filter): filter is Filter => !!filter)
return {
facilityFilters: filterList.filter((filter) =>
HOTEL_FACILITIES_FILTER_TYPE_NAMES.includes(filter.filterType)
),
surroundingsFilters: filterList.filter((filter) =>
HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES.includes(filter.filterType)
),
}
}
export function getBasePathNameWithoutFilters(
pathname: string,
filterSlugs: string[]
) {
const pathSegments = pathname.split("/")
const filteredSegments = pathSegments.filter(
(segment) => !filterSlugs.includes(segment)
)
return filteredSegments.join("/")
}

View File

@@ -0,0 +1,159 @@
import { produce } from "immer"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { HotelDataContext } from "@/contexts/HotelData"
import {
getBasePathNameWithoutFilters,
getFilteredHotels,
getFiltersFromHotels,
getSortedHotels,
isValidSortOption,
} from "./helper"
import type { Filter } from "@/types/components/hotelFilterAndSort"
import { SortOption } from "@/types/enums/hotelFilterAndSort"
import type { HotelDataState, InitialState } from "@/types/stores/hotel-data"
export function createHotelDataStore({
allHotels,
searchParams,
pathname,
filterFromUrl,
sortItems,
submitCallbackFn,
}: InitialState) {
const sortFromSearchParams = searchParams.get("sort")
const initialFilters = filterFromUrl ? [filterFromUrl] : []
let initialSort = SortOption.Distance
if (
sortFromSearchParams &&
isValidSortOption(sortFromSearchParams, sortItems)
) {
initialSort = sortFromSearchParams
}
const initialFilteredHotels = getFilteredHotels(allHotels, initialFilters)
const initialActiveHotels = getSortedHotels(
initialFilteredHotels,
initialSort
)
const allFilters = getFiltersFromHotels(allHotels)
const allFilterSlugs = Object.values(allFilters).flatMap((filter: Filter[]) =>
filter.map((f) => f.slug)
)
return create<HotelDataState>((set) => ({
actions: {
submitFiltersAndSort() {
return set(
produce((state: HotelDataState) => {
const sort = state.pendingSort
const filters = state.pendingFilters
const filteredHotels = getFilteredHotels(state.allHotels, filters)
const sortedHotels = getSortedHotels(filteredHotels, sort)
state.activeSort = sort
state.activeFilters = state.pendingFilters
state.activeHotels = sortedHotels
state.pendingCount = filteredHotels.length
if (submitCallbackFn) {
submitCallbackFn({
sort,
filters,
basePath: state.basePathnameWithoutFilters,
})
}
})
)
},
setPendingSort(sort) {
return set(
produce((state: HotelDataState) => {
state.pendingSort = sort
})
)
},
togglePendingFilter(filter) {
return set(
produce((state: HotelDataState) => {
const isActive = state.pendingFilters.includes(filter)
const filters = isActive
? state.pendingFilters.filter((f) => f !== filter)
: [...state.pendingFilters, filter]
const pendingHotels = getFilteredHotels(state.allHotels, filters)
state.pendingFilters = filters
state.pendingCount = pendingHotels.length
})
)
},
clearPendingFilters() {
return set(
produce((state: HotelDataState) => {
state.pendingFilters = []
state.pendingCount = state.allHotels.length
})
)
},
resetPendingValues() {
return set(
produce((state: HotelDataState) => {
state.pendingFilters = state.activeFilters
state.pendingSort = state.activeSort
state.pendingCount = state.activeHotels.length
})
)
},
loadInitialHashFilter(hash) {
return set(
produce((state: HotelDataState) => {
state.initialHashFilterLoaded = true
const filters = []
const filtersFromHash = hash.split("&").filter(Boolean) ?? []
if (filterFromUrl) {
filters.push(filterFromUrl, ...filtersFromHash)
}
const filteredHotels = getFilteredHotels(state.allHotels, filters)
const sortedHotels = getSortedHotels(
filteredHotels,
state.activeSort
)
state.activeHotels = sortedHotels
state.activeFilters = filters
state.pendingFilters = filters
state.pendingCount = filteredHotels.length
})
)
},
},
allHotels,
activeHotels: initialActiveHotels,
pendingCount: initialActiveHotels.length,
activeSort: initialSort,
pendingSort: initialSort,
activeFilters: initialFilters,
pendingFilters: initialFilters,
searchParams,
allFilters,
allFilterSlugs,
basePathnameWithoutFilters: getBasePathNameWithoutFilters(
pathname,
allFilterSlugs
),
sortItems,
initialHashFilterLoaded: false,
}))
}
export function useHotelDataStore<T>(selector: (store: HotelDataState) => T) {
const store = useContext(HotelDataContext)
if (!store) {
throw new Error("useHotelDataStore must be used within HotelDataProvider")
}
return useStore(store, selector)
}

View File

@@ -0,0 +1,26 @@
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 }),
}))

View File

@@ -0,0 +1,15 @@
import { create } from "zustand"
interface HotelPageState {
isDynamicMapOpen: boolean
openDynamicMap: () => void
closeDynamicMap: () => void
}
const useHotelPageStore = create<HotelPageState>((set) => ({
isDynamicMapOpen: false,
openDynamicMap: () => set({ isDynamicMapOpen: true }),
closeDynamicMap: () => set({ isDynamicMapOpen: false }),
}))
export default useHotelPageStore

View File

@@ -0,0 +1,15 @@
import { create } from "zustand"
interface HotelsMapState {
activeHotelCard: string | null
activeHotelPin: string | null
setActiveHotelCard: (hotelCard: string | null) => void
setActiveHotelPin: (hotelPin: string | null) => void
}
export const useHotelsMapStore = create<HotelsMapState>((set) => ({
activeHotelCard: null,
activeHotelPin: null,
setActiveHotelCard: (hotelCard) => set({ activeHotelCard: hotelCard }),
setActiveHotelPin: (hotelPin) => set({ activeHotelPin: hotelPin }),
}))

View File

@@ -0,0 +1,115 @@
import { produce } from "immer"
import { create } from "zustand"
import {
type DropdownState,
DropdownTypeEnum,
} from "@/types/components/dropdown/dropdown"
const useDropdownStore = create<DropdownState>((set, get) => ({
isHamburgerMenuOpen: false,
isMyPagesMobileMenuOpen: false,
isMyPagesMenuOpen: false,
isHeaderLanguageSwitcherOpen: false,
isHeaderLanguageSwitcherMobileOpen: false,
isFooterLanguageSwitcherOpen: false,
openMegaMenu: false,
toggleMegaMenu: (menu: string | false) =>
set(
produce((state: DropdownState) => {
if (state.openMegaMenu === menu) {
state.openMegaMenu = false
} else {
state.openMegaMenu = menu
}
state.isMyPagesMobileMenuOpen = false
state.isMyPagesMenuOpen = false
state.isHeaderLanguageSwitcherOpen = false
state.isHeaderLanguageSwitcherMobileOpen = false
state.isFooterLanguageSwitcherOpen = false
})
),
toggleDropdown: (dropdown: DropdownTypeEnum) =>
set(
produce((state: DropdownState) => {
const hamburgerShouldStayExpanded =
state.isMyPagesMenuOpen ||
state.isHeaderLanguageSwitcherMobileOpen ||
state.isFooterLanguageSwitcherOpen ||
!!state.openMegaMenu
switch (dropdown) {
case DropdownTypeEnum.HamburgerMenu:
if (state.isHamburgerMenuOpen) {
if (hamburgerShouldStayExpanded) {
state.isMyPagesMobileMenuOpen = false
state.isHeaderLanguageSwitcherMobileOpen = false
state.isFooterLanguageSwitcherOpen = false
state.openMegaMenu = false
} else {
state.isHamburgerMenuOpen = false
}
} else if (state.isMyPagesMobileMenuOpen) {
state.isMyPagesMobileMenuOpen = false
} else {
state.isHamburgerMenuOpen = true
}
break
case DropdownTypeEnum.MyPagesMobileMenu:
state.isMyPagesMobileMenuOpen = !state.isMyPagesMobileMenuOpen
state.isHamburgerMenuOpen = false
state.isMyPagesMenuOpen = false
state.isHeaderLanguageSwitcherOpen = false
state.isHeaderLanguageSwitcherMobileOpen = false
state.isFooterLanguageSwitcherOpen = false
state.openMegaMenu = false
break
case DropdownTypeEnum.MyPagesMenu:
state.isMyPagesMenuOpen = !state.isMyPagesMenuOpen
state.isHamburgerMenuOpen = false
state.isMyPagesMobileMenuOpen = false
state.isHeaderLanguageSwitcherOpen = false
state.isHeaderLanguageSwitcherMobileOpen = false
state.isFooterLanguageSwitcherOpen = false
state.openMegaMenu = false
break
case DropdownTypeEnum.HeaderLanguageSwitcher:
state.isHeaderLanguageSwitcherOpen =
!state.isHeaderLanguageSwitcherOpen
state.isHamburgerMenuOpen = false
state.isMyPagesMobileMenuOpen = false
state.isMyPagesMenuOpen = false
state.isHeaderLanguageSwitcherMobileOpen = false
state.isFooterLanguageSwitcherOpen = false
state.openMegaMenu = false
break
case DropdownTypeEnum.HeaderLanguageSwitcherMobile:
state.isHeaderLanguageSwitcherMobileOpen =
!state.isHeaderLanguageSwitcherMobileOpen
state.isMyPagesMobileMenuOpen = false
state.isMyPagesMenuOpen = false
state.isHeaderLanguageSwitcherOpen = false
state.isFooterLanguageSwitcherOpen = false
state.openMegaMenu = false
break
case DropdownTypeEnum.FooterLanguageSwitcher:
state.isFooterLanguageSwitcherOpen =
!state.isFooterLanguageSwitcherOpen
state.isHamburgerMenuOpen = false
state.isMyPagesMobileMenuOpen = false
state.isMyPagesMenuOpen = false
state.isHeaderLanguageSwitcherOpen = false
state.isHeaderLanguageSwitcherMobileOpen = false
state.openMegaMenu = false
if (state.isFooterLanguageSwitcherOpen) {
document.body.classList.add("overflow-hidden")
} else {
document.body.classList.remove("overflow-hidden")
}
break
}
})
),
}))
export default useDropdownStore

View File

@@ -0,0 +1,42 @@
import { create } from "zustand"
import type { Ancillary } from "@/types/components/myPages/myStay/ancillaries"
interface AddAncillaryState {
step: number
totalSteps: number
nextStep: () => void
prevStep: () => void
resetStore: () => void
selectedAncillary: Ancillary["ancillaryContent"][number] | null
setSelectedAncillary: (
ancillary: Ancillary["ancillaryContent"][number]
) => void
confirmationNumber: string
setConfirmationNumber: (confirmationNumber: string) => void
openedFrom: "list" | "grid" | null
setOpenedFrom: (source: "list" | "grid") => void
isGridOpen: boolean
setGridIsOpen: (isOpen: boolean) => void
}
export const useAddAncillaryStore = create<AddAncillaryState>((set) => ({
step: 1,
totalSteps: 3,
nextStep: () =>
set((state) =>
state.step < state.totalSteps ? { step: state.step + 1 } : {}
),
prevStep: () =>
set((state) => (state.step > 1 ? { step: state.step - 1 } : {})),
resetStore: () => set({ step: 1 }),
selectedAncillary: null,
setSelectedAncillary: (ancillary) => set({ selectedAncillary: ancillary }),
confirmationNumber: "",
setConfirmationNumber: (confirmationNumber) =>
set({ confirmationNumber: confirmationNumber }),
openedFrom: null,
setOpenedFrom: (source) => set({ openedFrom: source }),
isGridOpen: false,
setGridIsOpen: (isOpen) => set({ isGridOpen: isOpen }),
}))

View File

@@ -0,0 +1,17 @@
"use client"
import { create } from "zustand"
interface RouterTransitionState {
isTransitioning: boolean
startRouterTransition: () => void
stopRouterTransition: () => void
}
const useRouterTransitionStore = create<RouterTransitionState>((set) => ({
isTransitioning: false,
startRouterTransition: () => set(() => ({ isTransitioning: true })),
stopRouterTransition: () => set(() => ({ isTransitioning: false })),
}))
export default useRouterTransitionStore

View File

@@ -0,0 +1,197 @@
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import {
RoomPackageCodeEnum,
type RoomPackages,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import type {
Rate,
RateCode,
} from "@/types/components/hotelReservation/selectRate/selectRate"
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
interface CalculateRoomSummaryParams {
availablePackages: RoomPackages
getFilteredRooms: (roomIndex: number) => RoomConfiguration[]
roomCategories: Array<{ name: string; roomTypes: Array<{ code: string }> }>
selectedPackagesByRoom: Record<number, RoomPackageCodeEnum[]>
selectedRate: RateCode
roomIndex: number
}
export function calculateRoomSummary({
selectedRate,
roomIndex,
getFilteredRooms,
availablePackages,
roomCategories,
selectedPackagesByRoom,
}: CalculateRoomSummaryParams): Rate | null {
const filteredRooms = getFilteredRooms(roomIndex)
const selectedPackages = selectedPackagesByRoom[roomIndex] || []
const room = filteredRooms.find(
(room) => room.roomTypeCode === selectedRate.roomTypeCode
)
if (!room) return null
const product = room.products.find(
(product) =>
product.productType.public.rateCode === selectedRate.publicRateCode
)
if (!product) return null
const petRoomPackage = selectedPackages.includes(RoomPackageCodeEnum.PET_ROOM)
? availablePackages.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)
: undefined
const features = filteredRooms.find((room) =>
room.features.some(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
)
)?.features
const roomType = roomCategories.find((roomCategory) =>
roomCategory.roomTypes.some((type) => type.code === room.roomTypeCode)
)
return {
features: petRoomPackage && features ? features : [],
priceName: selectedRate.name,
priceTerm: selectedRate.paymentTerm,
public: product.productType.public,
member: product.productType.member,
roomType: roomType?.name ?? room.roomType,
roomTypeCode: room.roomTypeCode,
}
}
/**
* Get the lowest priced room for each room type that appears more than once.
*/
export function filterDuplicateRoomTypesByLowestPrice(
roomConfigurations: RoomConfiguration[]
): RoomConfiguration[] {
const roomTypeCount = roomConfigurations.reduce<Record<string, number>>(
(roomTypeTally, currentRoom) => {
const currentRoomType = currentRoom.roomType
const currentCount = roomTypeTally[currentRoomType] || 0
return {
...roomTypeTally,
[currentRoomType]: currentCount + 1,
}
},
{}
)
const duplicateRoomTypes = new Set(
Object.keys(roomTypeCount).filter((roomType) => roomTypeCount[roomType] > 1)
)
const roomMap = new Map()
roomConfigurations.forEach((room) => {
const { roomType, products, status } = room
if (!duplicateRoomTypes.has(roomType)) {
roomMap.set(roomType, room)
return
}
const previousRoom = roomMap.get(roomType)
// Prioritize 'Available' status
if (
status === AvailabilityEnum.Available &&
previousRoom?.status === AvailabilityEnum.NotAvailable
) {
roomMap.set(roomType, room)
return
}
if (
status === AvailabilityEnum.NotAvailable &&
previousRoom?.status === AvailabilityEnum.Available
) {
return
}
if (previousRoom) {
products.forEach((product) => {
const { productType } = product
const publicProduct = productType.public || {
requestedPrice: null,
localPrice: null,
}
const memberProduct = productType.member || {
requestedPrice: null,
localPrice: null,
}
const {
requestedPrice: publicRequestedPrice,
localPrice: publicLocalPrice,
} = publicProduct
const {
requestedPrice: memberRequestedPrice,
localPrice: memberLocalPrice,
} = memberProduct
const previousLowest = roomMap.get(roomType)
const currentRequestedPrice = Math.min(
Number(publicRequestedPrice?.pricePerNight) ?? Infinity,
Number(memberRequestedPrice?.pricePerNight) ?? Infinity
)
const currentLocalPrice = Math.min(
Number(publicLocalPrice?.pricePerNight) ?? Infinity,
Number(memberLocalPrice?.pricePerNight) ?? Infinity
)
if (
!previousLowest ||
currentRequestedPrice <
Math.min(
Number(
previousLowest.products[0].productType.public.requestedPrice
?.pricePerNight
) ?? Infinity,
Number(
previousLowest.products[0].productType.member?.requestedPrice
?.pricePerNight
) ?? Infinity
) ||
(currentRequestedPrice ===
Math.min(
Number(
previousLowest.products[0].productType.public.requestedPrice
?.pricePerNight
) ?? Infinity,
Number(
previousLowest.products[0].productType.member?.requestedPrice
?.pricePerNight
) ?? Infinity
) &&
currentLocalPrice <
Math.min(
Number(
previousLowest.products[0].productType.public.localPrice
?.pricePerNight
) ?? Infinity,
Number(
previousLowest.products[0].productType.member?.localPrice
?.pricePerNight
) ?? Infinity
))
) {
roomMap.set(roomType, room)
}
})
} else {
roomMap.set(roomType, room)
}
})
return Array.from(roomMap.values())
}

View File

@@ -0,0 +1,291 @@
import { produce } from "immer"
import { ReadonlyURLSearchParams } from "next/navigation"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { filterDuplicateRoomTypesByLowestPrice } from "@/stores/select-rate/helper"
import { RatesContext } from "@/contexts/Rates"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { InitialState, RatesState } from "@/types/stores/rates"
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
const statusLookup = {
[AvailabilityEnum.Available]: 1,
[AvailabilityEnum.NotAvailable]: 2,
}
function findSelectedRate(
rateCode: string,
roomTypeCode: string,
rooms: RoomConfiguration[]
) {
return rooms.find(
(room) =>
room.roomTypeCode === roomTypeCode &&
room.products.find(
(product) =>
product.productType.public.rateCode === rateCode ||
product.productType.member?.rateCode === rateCode
)
)
}
export function createRatesStore({
booking,
hotelType,
isUserLoggedIn,
labels,
packages,
pathname,
roomCategories,
roomsAvailability,
searchParams,
vat,
}: InitialState) {
const filterOptions = [
{
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
description: labels.accessibilityRoom,
itemCode: packages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
)?.itemCode,
},
{
code: RoomPackageCodeEnum.ALLERGY_ROOM,
description: labels.allergyRoom,
itemCode: packages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM
)?.itemCode,
},
{
code: RoomPackageCodeEnum.PET_ROOM,
description: labels.petRoom,
itemCode: packages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
)?.itemCode,
},
]
let allRooms: RoomConfiguration[] = []
if (roomsAvailability?.roomConfigurations) {
allRooms = filterDuplicateRoomTypesByLowestPrice(
roomsAvailability.roomConfigurations
).sort(
// @ts-expect-error - array indexing
(a, b) => statusLookup[a.status] - statusLookup[b.status]
)
}
const rateSummary: RatesState["rateSummary"] = []
booking.rooms.forEach((room, idx) => {
if (room.rateCode && room.roomTypeCode) {
const selectedRoom = roomsAvailability?.roomConfigurations.find(
(roomConf) =>
roomConf.roomTypeCode === room.roomTypeCode &&
roomConf.products.find(
(product) =>
product.productType.public.rateCode === room.rateCode ||
product.productType.member?.rateCode === room.rateCode
)
)
const product = selectedRoom?.products.find(
(p) =>
p.productType.public.rateCode === room.rateCode ||
p.productType.member?.rateCode === room.rateCode
)
if (selectedRoom && product) {
rateSummary[idx] = {
features: selectedRoom.features,
member: product.productType.member,
public: product.productType.public,
roomType: selectedRoom.roomType,
roomTypeCode: selectedRoom.roomTypeCode,
}
}
}
})
let activeRoom = rateSummary.length
if (searchParams.has("modifyRateIndex")) {
activeRoom = Number(searchParams.get("modifyRateIndex"))
} else if (rateSummary.length === booking.rooms.length) {
// Since all rooms has selections, all sections should be
// closed on load
activeRoom = -1
}
return create<RatesState>()((set) => ({
actions: {
closeSection(idx) {
return function () {
return set(
produce((state: RatesState) => {
if (state.rateSummary.length === state.booking.rooms.length) {
state.activeRoom = -1
} else {
state.activeRoom = idx + 1
}
})
)
}
},
modifyRate(idx) {
return function () {
return set(
produce((state: RatesState) => {
state.activeRoom = idx
})
)
}
},
selectFilter(idx) {
return function (code) {
return set(
produce((state: RatesState) => {
state.rooms[idx].selectedPackage = code
const searchParams = new URLSearchParams(state.searchParams)
if (code) {
state.rooms[idx].rooms = state.allRooms.filter((room) =>
room.features.find((feat) => feat.code === code)
)
searchParams.set(`room[${idx}].packages`, code)
if (state.rateSummary[idx]) {
state.rateSummary[idx].package = code
}
} else {
state.rooms[idx].rooms = state.allRooms
searchParams.delete(`room[${idx}].packages`)
if (state.rateSummary[idx]) {
state.rateSummary[idx].package = undefined
}
}
state.searchParams = new ReadonlyURLSearchParams(searchParams)
window.history.pushState(
{},
"",
`${state.pathname}?${searchParams}`
)
})
)
}
},
selectRate(idx) {
return function (selectedRate) {
return set(
produce((state: RatesState) => {
state.rooms[idx].selectedRate = selectedRate
state.rateSummary[idx] = {
features: selectedRate.features,
member: selectedRate.product.productType.member,
package: state.rooms[idx].selectedPackage,
public: selectedRate.product.productType.public,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
}
const roomNr = idx + 1
const isMemberRate =
isUserLoggedIn &&
roomNr === 1 &&
selectedRate.product.productType.member
const searchParams = new URLSearchParams(state.searchParams)
searchParams.set(
`room[${idx}].counterratecode`,
isMemberRate
? selectedRate.product.productType.public.rateCode
: selectedRate.product.productType.member?.rateCode ?? ""
)
searchParams.set(
`room[${idx}].ratecode`,
isMemberRate
? // already checked in isMemberRate
selectedRate.product.productType.member!.rateCode
: selectedRate.product.productType.public.rateCode
)
searchParams.set(
`room[${idx}].roomtype`,
selectedRate.roomTypeCode
)
if (state.rateSummary.length === state.booking.rooms.length) {
state.activeRoom = -1
} else {
state.activeRoom = idx + 1
}
state.searchParams = new ReadonlyURLSearchParams(searchParams)
window.history.pushState(
{},
"",
`${state.pathname}?${searchParams}`
)
})
)
}
},
},
activeRoom,
allRooms,
booking,
filterOptions,
hotelType,
packages,
pathname,
petRoomPackage: packages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
),
rateSummary,
rooms: booking.rooms.map((room) => {
const selectedRate =
findSelectedRate(room.rateCode, room.roomTypeCode, allRooms) ?? null
const product = selectedRate?.products.find(
(prd) =>
prd.productType.public.rateCode === room.rateCode ||
prd.productType.member?.rateCode === room.rateCode
)
const selectedPackage = room.packages?.[0]
return {
bookingRoom: room,
rooms: selectedPackage
? allRooms.filter((r) =>
r.features.find((f) => f.code === selectedPackage)
)
: allRooms,
selectedPackage,
selectedRate:
selectedRate && product
? {
features: selectedRate.features,
product,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
}
: null,
}
}),
roomCategories,
roomsAvailability,
searchParams,
vat,
}))
}
export function useRatesStore<T>(selector: (store: RatesState) => T) {
const store = useContext(RatesContext)
if (!store) {
throw new Error("useRatesStore must be used within RatesProvider")
}
return useStore(store, selector)
}

View File

@@ -0,0 +1,104 @@
import { create } from "zustand"
import { calculateRoomSummary } from "./helper"
import type {
RoomPackageCodeEnum,
RoomPackages,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import type {
Child,
Rate,
RateCode,
} from "@/types/components/hotelReservation/selectRate/selectRate"
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
export interface RateSummaryParams {
getFilteredRooms: (roomIndex: number) => RoomConfiguration[]
availablePackages: RoomPackages
roomCategories: Array<{ name: string; roomTypes: Array<{ code: string }> }>
selectedPackagesByRoom: Record<number, RoomPackageCodeEnum[]>
}
interface RateSelectionState {
selectedRates: (RateCode | undefined)[]
rateSummary: (Rate | null)[]
isPriceDetailsModalOpen: boolean
isSummaryOpen: boolean
guestsInRooms: { adults: number; children?: Child[] }[]
modifyRateIndex: number | null
modifyRate: (index: number) => void
closeModifyRate: () => void
selectRate: (index: number, rate: RateCode | undefined) => void
initializeRates: (count: number) => void
calculateRateSummary: ({
getFilteredRooms,
availablePackages,
roomCategories,
}: RateSummaryParams) => void
getSelectedRateSummary: () => Rate[]
togglePriceDetailsModalOpen: () => void
toggleSummaryOpen: () => void
setGuestsInRooms: (index: number, adults: number, children?: Child[]) => void
}
export const useRateSelectionStore = create<RateSelectionState>((set, get) => ({
selectedRates: [],
rateSummary: [],
isPriceDetailsModalOpen: false,
isSummaryOpen: false,
guestsInRooms: [{ adults: 1 }],
modifyRateIndex: null,
modifyRate: (index) => set({ modifyRateIndex: index }),
closeModifyRate: () => set({ modifyRateIndex: null }),
selectRate: (index, rate) => {
set((state) => {
const newRates = [...state.selectedRates]
newRates[index] = rate
return {
selectedRates: newRates,
}
})
},
initializeRates: (count) =>
set({ selectedRates: new Array(count).fill(undefined) }),
calculateRateSummary: (params) => {
const { selectedRates } = get()
const summaries = selectedRates.map((selectedRate, roomIndex) => {
if (!selectedRate) return null
return calculateRoomSummary({
selectedRate,
roomIndex,
...params,
})
})
set({ rateSummary: summaries })
},
getSelectedRateSummary: () => {
const { rateSummary } = get()
return rateSummary.filter((summary): summary is Rate => summary !== null)
},
togglePriceDetailsModalOpen: () => {
set((state) => ({
isPriceDetailsModalOpen: !state.isPriceDetailsModalOpen,
}))
},
toggleSummaryOpen: () => {
set((state) => ({
isSummaryOpen: !state.isSummaryOpen,
}))
},
setGuestsInRooms: (index, adults, children) => {
set((state) => ({
guestsInRooms: [
...state.guestsInRooms.slice(0, index),
{ adults, children },
...state.guestsInRooms.slice(index + 1),
],
}))
},
}))

View File

@@ -0,0 +1,39 @@
import { create } from "zustand"
import { trackOpenSidePeekEvent } from "@/utils/tracking"
import type { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
interface SidePeekState {
activeSidePeek: SidePeekEnum | null
hotelId: string | null
roomTypeCode: string | null
showCTA: boolean
openSidePeek: ({
key,
hotelId,
roomTypeCode,
showCTA,
}: {
key: SidePeekEnum | null
hotelId: string
roomTypeCode?: string
showCTA?: boolean
}) => void
closeSidePeek: () => void
}
const useSidePeekStore = create<SidePeekState>((set) => ({
activeSidePeek: null,
hotelId: null,
roomTypeCode: null,
showCTA: true,
openSidePeek: ({ key, hotelId, roomTypeCode, showCTA }) => {
trackOpenSidePeekEvent(key, hotelId, window.location.pathname, roomTypeCode)
set({ activeSidePeek: key, hotelId, roomTypeCode, showCTA })
},
closeSidePeek: () =>
set({ activeSidePeek: null, hotelId: null, roomTypeCode: null }),
}))
export default useSidePeekStore

View File

@@ -0,0 +1,82 @@
import { create } from "zustand"
export enum StickyElementNameEnum {
SITEWIDE_ALERT = "SITEWIDE_ALERT",
BOOKING_WIDGET = "BOOKING_WIDGET",
HOTEL_TAB_NAVIGATION = "HOTEL_TAB_NAVIGATION",
HOTEL_STATIC_MAP = "HOTEL_STATIC_MAP",
DESTINATION_SIDEBAR = "DESTINATION_SIDEBAR",
}
export interface StickyElement {
height: number
ref: React.RefObject<HTMLElement>
group: string
priority: number
name: StickyElementNameEnum
}
interface StickyStore {
stickyElements: StickyElement[]
registerSticky: (
ref: React.RefObject<HTMLElement>,
name: StickyElementNameEnum,
group: string
) => void
unregisterSticky: (ref: React.RefObject<HTMLElement>) => void
updateHeights: () => void
getAllElements: () => Array<StickyElement>
}
// Map to define priorities based on StickyElementNameEnum
const priorityMap: Record<StickyElementNameEnum, number> = {
[StickyElementNameEnum.SITEWIDE_ALERT]: 1,
[StickyElementNameEnum.BOOKING_WIDGET]: 2,
[StickyElementNameEnum.HOTEL_TAB_NAVIGATION]: 3,
[StickyElementNameEnum.HOTEL_STATIC_MAP]: 3,
[StickyElementNameEnum.DESTINATION_SIDEBAR]: 3,
}
const useStickyPositionStore = create<StickyStore>((set, get) => ({
stickyElements: [],
registerSticky: (ref, name, group) => {
const priority = priorityMap[name] || 0
set((state) => {
const newStickyElement: StickyElement = {
height: ref.current?.offsetHeight || 0,
ref,
group,
priority,
name,
}
const updatedStickyElements = [
...state.stickyElements,
newStickyElement,
].sort((a, b) => a.priority - b.priority)
return {
stickyElements: updatedStickyElements,
}
})
},
unregisterSticky: (ref) => {
set((state) => ({
stickyElements: state.stickyElements.filter((el) => el.ref !== ref),
}))
},
updateHeights: () => {
set((state) => ({
stickyElements: state.stickyElements.map((el) => ({
...el,
height: el.ref.current?.offsetHeight || el.height,
})),
}))
},
getAllElements: () => get().stickyElements,
}))
export default useStickyPositionStore

View File

@@ -0,0 +1,24 @@
"use client"
import { create } from "zustand"
interface TrackingStoreState {
hasRun: boolean
setHasRun: () => void
initialStartTime: number
setInitialPageLoadTime: (time: number) => void
getPageLoadTime: () => number
}
const useTrackingStore = create<TrackingStoreState>((set, get) => ({
hasRun: false,
initialStartTime: Date.now(),
setInitialPageLoadTime: (time) => set({ initialStartTime: time }),
setHasRun: () => set(() => ({ hasRun: true })),
getPageLoadTime: () => {
const { initialStartTime } = get()
return (Date.now() - initialStartTime) / 1000
},
}))
export default useTrackingStore