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