diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index fd16602d6..0b5dca2aa 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -19,9 +19,10 @@ import type { } from "@/types/components/hotelReservation/enterDetails/bedType" export default function BedType({ bedTypes }: BedTypeProps) { - const bedType = useEnterDetailsStore( + const initialBedType = useEnterDetailsStore( (state) => state.formValues?.bedType?.roomTypeCode ) + const bedType = useEnterDetailsStore((state) => state.bedType?.roomTypeCode) const completeStep = useEnterDetailsStore( (state) => state.actions.completeStep ) @@ -30,7 +31,7 @@ export default function BedType({ bedTypes }: BedTypeProps) { ) const methods = useForm({ - defaultValues: bedType ? { bedType } : undefined, + defaultValues: initialBedType ? { bedType: initialBedType } : undefined, criteriaMode: "all", mode: "all", resolver: zodResolver(bedTypeFormSchema), diff --git a/components/HotelReservation/EnterDetails/Details/index.tsx b/components/HotelReservation/EnterDetails/Details/index.tsx index 437de39b2..322535574 100644 --- a/components/HotelReservation/EnterDetails/Details/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/index.tsx @@ -27,27 +27,27 @@ const formID = "enter-details" export default function Details({ user, memberPrice }: DetailsProps) { const intl = useIntl() const initialData = useEnterDetailsStore((state) => state.formValues.guest) - + const join = useEnterDetailsStore((state) => state.guest.join) const updateDetails = useEnterDetailsStore( (state) => state.actions.updateDetails ) const methods = useForm({ - defaultValues: { - countryCode: user?.address?.countryCode ?? initialData.countryCode, - email: user?.email ?? initialData.email, - firstName: user?.firstName ?? initialData.firstName, - lastName: user?.lastName ?? initialData.lastName, - phoneNumber: user?.phoneNumber ?? initialData.phoneNumber, - join: initialData.join, - dateOfBirth: initialData.dateOfBirth, - zipCode: initialData.zipCode, - membershipNo: initialData.membershipNo, - }, criteriaMode: "all", mode: "all", resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema), reValidateMode: "onChange", + values: { + countryCode: user?.address?.countryCode ?? initialData.countryCode, + dateOfBirth: initialData.dateOfBirth, + email: user?.email ?? initialData.email, + firstName: user?.firstName ?? initialData.firstName, + join, + lastName: user?.lastName ?? initialData.lastName, + membershipNo: initialData.membershipNo, + phoneNumber: user?.phoneNumber ?? initialData.phoneNumber, + zipCode: initialData.zipCode, + }, }) const onSubmit = useCallback( diff --git a/components/HotelReservation/EnterDetails/Summary/Client.tsx b/components/HotelReservation/EnterDetails/Summary/Client.tsx index 9e1207b82..1fe83bc57 100644 --- a/components/HotelReservation/EnterDetails/Summary/Client.tsx +++ b/components/HotelReservation/EnterDetails/Summary/Client.tsx @@ -44,6 +44,7 @@ export default function ClientSummary({ packages, roomPrice, toDate, + toggleSummaryOpen, totalPrice, } = useEnterDetailsStore(storeSelector) @@ -70,6 +71,7 @@ export default function ClientSummary({ showMemberPrice={showMemberPrice} room={room} toDate={toDate} + toggleSummaryOpen={toggleSummaryOpen} totalPrice={totalPrice} /> diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 38d6050b2..ddf08fb78 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -509,6 +509,7 @@ "spendable points expiring by": "{points} spendable points expiring by {date}", "to": "to", "uppercase letter": "uppercase letter", + "{amount} {currency}": "{amount} {currency}", "{amount} out of {total}": "{amount} out of {total}", "{card} ending with {cardno}": "{card} ending with {cardno}", "{difference}{amount} {currency}": "{difference}{amount} {currency}" diff --git a/package-lock.json b/package-lock.json index 6785a5baa..2a2f3b61a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "immer": "10.1.1", "json-stable-stringify-without-jsonify": "^1.0.1", "libphonenumber-js": "^1.10.60", + "lodash.isequal": "^4.5.0", "next": "^14.2.18", "next-auth": "^5.0.0-beta.19", "react": "^18", @@ -66,6 +67,7 @@ "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", "@types/json-stable-stringify-without-jsonify": "^1.0.2", + "@types/lodash.isequal": "^4.5.8", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -6864,6 +6866,21 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true + }, + "node_modules/@types/lodash.isequal": { + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz", + "integrity": "sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "20.12.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", @@ -14984,6 +15001,11 @@ "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -20383,4 +20405,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index e3dd19c1b..b6e68c685 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "immer": "10.1.1", "json-stable-stringify-without-jsonify": "^1.0.1", "libphonenumber-js": "^1.10.60", + "lodash.isequal": "^4.5.0", "next": "^14.2.18", "next-auth": "^5.0.0-beta.19", "react": "^18", @@ -81,6 +82,7 @@ "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", "@types/json-stable-stringify-without-jsonify": "^1.0.2", + "@types/lodash.isequal": "^4.5.8", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/stores/enter-details/helpers.ts b/stores/enter-details/helpers.ts index 5958c5b45..567e96334 100644 --- a/stores/enter-details/helpers.ts +++ b/stores/enter-details/helpers.ts @@ -1,14 +1,9 @@ +import isEqual from "lodash.isequal" import { z } from "zod" import { Lang } from "@/constants/languages" import { breakfastPackageSchema } from "@/server/routers/hotels/output" -import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" -import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" -import { - guestDetailsSchema, - signedInDetailsSchema, -} from "@/components/HotelReservation/EnterDetails/Details/schema" import { getLang } from "@/i18n/serverContext" import type { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData" @@ -53,42 +48,7 @@ export function navigate(step: StepEnum, searchParams: string) { } export function checkIsSameBooking(prev: BookingData, next: BookingData) { - return ( - prev.fromDate === next.fromDate || - prev.toDate === next.toDate || - prev.hotel === next.hotel || - prev.rooms[0].adults === next.rooms[0].adults || - prev.rooms[0].children === next.rooms[0].children || - prev.rooms[0].roomTypeCode === next.rooms[0].roomTypeCode - ) -} - -export function validateSteps(currentState: DetailsState, isMember: boolean) { - const validPaths = [StepEnum.selectBed] - const validatedBedType = bedTypeSchema.safeParse(currentState) - if (validatedBedType.success) { - currentState.isValid["select-bed"] = true - validPaths.push(currentState.steps[1]) - } - - const validatedBreakfast = breakfastStoreSchema.safeParse(currentState) - if (validatedBreakfast.success) { - currentState.isValid.breakfast = true - validPaths.push(StepEnum.details) - } - - const detailsSchema = isMember ? signedInDetailsSchema : guestDetailsSchema - const validatedDetails = detailsSchema.safeParse(currentState.guest) - // Need to add the breakfast check here too since - // when a member comes into the flow, their data is - // already added and valid, and thus to avoid showing a - // step the user hasn't been on yet as complete - if (currentState.isValid.breakfast && validatedDetails.success) { - currentState.isValid.details = true - validPaths.push(StepEnum.payment) - } - - return validPaths + return isEqual(prev, next) } export function add(...nums: (number | string | undefined)[]) { @@ -177,17 +137,43 @@ export function calcTotalMemberPrice(state: DetailsState) { } } + return calcTotalPrice({ + breakfast: state.breakfast, + packages: state.packages, + roomPrice: state.roomPrice, + totalPrice: state.totalPrice, + ...state.roomRate.memberRate, + }) +} + +export function calcTotalPublicPrice(state: DetailsState) { + return calcTotalPrice({ + breakfast: state.breakfast, + packages: state.packages, + roomPrice: state.roomPrice, + totalPrice: state.totalPrice, + ...state.roomRate.publicRate, + }) +} + +export function calcTotalPrice( + state: Pick< + DetailsState, + "breakfast" | "packages" | "roomPrice" | "totalPrice" + > & + DetailsState["roomRate"]["publicRate"] +) { const roomAndTotalPrice = { roomPrice: state.roomPrice, totalPrice: state.totalPrice, } - if (state.roomRate.memberRate.requestedPrice?.pricePerStay) { + if (state.requestedPrice?.pricePerStay) { roomAndTotalPrice.roomPrice.euro = { currency: CurrencyEnum.EUR, - price: state.roomRate.memberRate.requestedPrice.pricePerStay, + price: state.requestedPrice.pricePerStay, } - let totalPriceEuro = state.roomRate.memberRate.requestedPrice.pricePerStay + let totalPriceEuro = state.requestedPrice.pricePerStay if (state.breakfast) { totalPriceEuro = add( totalPriceEuro, @@ -210,7 +196,7 @@ export function calcTotalMemberPrice(state: DetailsState) { } } - const roomPriceLocal = state.roomRate.memberRate.localPrice + const roomPriceLocal = state.localPrice roomAndTotalPrice.roomPrice.local = { currency: roomPriceLocal.currency, price: roomPriceLocal.pricePerStay, @@ -239,116 +225,3 @@ export function calcTotalMemberPrice(state: DetailsState) { return roomAndTotalPrice } - -export function getHydratedMemberPrice( - memberRate: NonNullable, - breakfast: DetailsState["breakfast"], - packages: DetailsState["packages"] -) { - const memberPrice = { - euro: { - currency: CurrencyEnum.EUR, - price: memberRate.requestedPrice?.pricePerStay ?? 0, - }, - local: { - currency: memberRate.localPrice.currency, - price: memberRate.localPrice.pricePerStay, - }, - } - - if (breakfast) { - memberPrice.euro.price = add( - memberPrice.euro.price, - breakfast.requestedPrice.totalPrice - ) - memberPrice.local.price = add( - memberPrice.local.price, - breakfast.localPrice.totalPrice - ) - } - - if (packages) { - packages.forEach((pkg) => { - memberPrice.euro.price = add( - memberPrice.euro.price, - pkg.requestedPrice.totalPrice - ) - memberPrice.local.price = add( - memberPrice.local.price, - pkg.localPrice.totalPrice - ) - }) - } - - return { - roomPrice: { - euro: { - currency: CurrencyEnum.EUR, - price: memberRate.requestedPrice?.pricePerStay ?? 0, - }, - local: { - currency: memberRate.localPrice.currency, - price: memberRate.localPrice.pricePerStay, - }, - }, - totalPrice: memberPrice, - } -} - -export const persistedStateSchema = z - .object({ - bedType: z - .object({ - description: z.string(), - roomTypeCode: z.string(), - }) - .optional(), - booking: z.object({ - hotel: z.string(), - fromDate: z.string(), - toDate: z.string(), - rooms: z.array( - z.object({ - adults: z.number().int(), - counterRateCode: z.string(), - rateCode: z.string(), - roomTypeCode: z.string(), - children: z - .array( - z.object({ - age: z.number().int(), - bed: z.string(), - }) - ) - .optional(), - packages: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(), - }) - ), - }), - breakfast: breakfastPackageSchema.or(z.literal(false)).optional(), - guest: z.object({ - countryCode: z.string().default(""), - email: z.string().default(""), - firstName: z.string().default(""), - lastName: z.string().default(""), - membershipNo: z.string().default(""), - phoneNumber: z.string().default(""), - join: z - .boolean() - .optional() - .transform((_) => false), - dateOfBirth: z.string().default(""), - zipCode: z.string().default(""), - }), - totalPrice: z.object({ - euro: z.object({ - currency: z.literal(CurrencyEnum.EUR), - price: z.number().int(), - }), - local: z.object({ - currency: z.nativeEnum(CurrencyEnum), - price: z.number().int(), - }), - }), - }) - .optional() diff --git a/stores/enter-details/index.ts b/stores/enter-details/index.ts index f40bc2c2d..a78116374 100644 --- a/stores/enter-details/index.ts +++ b/stores/enter-details/index.ts @@ -4,21 +4,25 @@ import { useContext } from "react" import { create, useStore } from "zustand" import { createJSONStorage, persist } from "zustand/middleware" +import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" +import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" +import { + guestDetailsSchema, + signedInDetailsSchema, +} from "@/components/HotelReservation/EnterDetails/Details/schema" import { DetailsContext } from "@/contexts/Details" import { arrayMerge } from "@/utils/merge" import { add, calcTotalMemberPrice, + calcTotalPublicPrice, checkIsSameBooking, extractGuestFromUser, - getHydratedMemberPrice, getInitialRoomPrice, getInitialTotalPrice, langToCurrency, navigate, - persistedStateSchema, - validateSteps, } from "./helpers" import { CurrencyEnum } from "@/types/enums/currency" @@ -27,7 +31,6 @@ import type { DetailsState, FormValues, InitialState, - PersistedState, } from "@/types/stores/enter-details" import type { SafeUser } from "@/types/user" @@ -292,6 +295,10 @@ export function createDetailsStore( const memberPrice = calcTotalMemberPrice(state) state.roomPrice = memberPrice.roomPrice state.totalPrice = memberPrice.totalPrice + } else { + const publicPrice = calcTotalPublicPrice(state) + state.roomPrice = publicPrice.roomPrice + state.totalPrice = publicPrice.totalPrice } const currentStepIndex = state.steps.indexOf(state.currentStep) @@ -332,95 +339,125 @@ export function createDetailsStore( }), { name: detailsStorageName, - merge(_persistedState, currentState) { - const parsedPersistedState = - persistedStateSchema.safeParse(_persistedState) - let persistedState - if (parsedPersistedState.success) { - if (parsedPersistedState.data) { - persistedState = parsedPersistedState.data as PersistedState + merge(persistedState, currentState) { + if ( + persistedState && + Object.prototype.hasOwnProperty.call(persistedState, "booking") + ) { + const isSameBooking = checkIsSameBooking( + // @ts-expect-error - persistedState cannot be typed + persistedState.booking, + currentState.booking + ) + if (!isSameBooking) { + return deepmerge(persistedState, currentState, { arrayMerge }) } } + return deepmerge(currentState, persistedState ?? {}, { arrayMerge }) + }, + onRehydrateStorage(initState) { + return function (state) { + if (state) { + if ( + (state.guest.join || state.guest.membershipNo || isMember) && + state.roomRate.memberRate + ) { + const memberPrice = calcTotalMemberPrice(state) - if (!persistedState) { - persistedState = currentState as DetailsState - } + state.roomPrice = memberPrice.roomPrice + state.totalPrice = memberPrice.totalPrice + } else { + const publicPrice = calcTotalPublicPrice(state) - if ( - currentState.guest.join || - !!currentState.guest.membershipNo || - isMember - ) { - if (currentState.roomRate.memberRate) { - const memberPrice = getHydratedMemberPrice( - currentState.roomRate.memberRate, - currentState.breakfast, - currentState.packages + state.roomPrice = publicPrice.roomPrice + state.totalPrice = publicPrice.totalPrice + } + + /** + * TODO: + * - when included in rate, can packages still be received? + * - no hotels yet with breakfast included in the rate so + * impossible to build for atm. + * + * checking against initialState since that means the + * hotel doesn't offer breakfast + * + * matching breakfast first so the steps array is altered + * before the bedTypes possible step altering + */ + if (initialState.breakfast === false) { + state.steps = state.steps.filter( + (step) => step === StepEnum.breakfast + ) + if (state.currentStep === StepEnum.breakfast) { + state.currentStep = state.steps[1] + } + } + + if (initialState.bedType) { + if (state.currentStep === StepEnum.selectBed) { + state.currentStep = state.steps[1] + } + } + + const isSameBooking = checkIsSameBooking( + initState.booking, + state.booking ) - currentState.roomPrice = memberPrice.roomPrice - currentState.totalPrice = memberPrice.totalPrice + const validateBooking = isSameBooking ? state : initState + + const validPaths = [StepEnum.selectBed] + const validatedBedType = bedTypeSchema.safeParse(validateBooking) + if (validatedBedType.success) { + state.isValid["select-bed"] = true + validPaths.push(state.steps[1]) + } + + const validatedBreakfast = + breakfastStoreSchema.safeParse(validateBooking) + if (validatedBreakfast.success) { + state.isValid.breakfast = true + validPaths.push(StepEnum.details) + } + + const detailsSchema = isMember + ? signedInDetailsSchema + : guestDetailsSchema + const validatedDetails = detailsSchema.safeParse( + validateBooking.guest + ) + // Need to add the breakfast check here too since + // when a member comes into the flow, their data is + // already added and valid, and thus to avoid showing a + // step the user hasn't been on yet as complete + if (state.isValid.breakfast && validatedDetails.success) { + state.isValid.details = true + validPaths.push(StepEnum.payment) + } + + if (!validPaths.includes(state.currentStep)) { + state.currentStep = validPaths.at(-1)! + } + + if (currentStep !== state.currentStep) { + const stateCurrentStep = state.currentStep + setTimeout(() => { + navigate(stateCurrentStep, searchParams) + }) + } + + if (isSameBooking) { + state = deepmerge(initState, state, { + arrayMerge, + }) + } else { + state = deepmerge(state, initState, { + arrayMerge, + }) + } } } - - const isSameBooking = checkIsSameBooking( - persistedState.booking, - currentState.booking - ) - - let mergedState - if (isSameBooking) { - mergedState = deepmerge( - currentState, - persistedState, - { arrayMerge } - ) - } else { - mergedState = deepmerge( - persistedState, - currentState, - { arrayMerge } - ) - } - - /** - * TODO: - * - when included in rate, can packages still be received? - * - no hotels yet with breakfast included in the rate so - * impossible to build for atm. - * - * checking against initialState since that means the - * hotel doesn't offer breakfast - * - * matching breakfast first so the steps array is altered - * before the bedTypes possible step altering - */ - if (initialState.breakfast === false) { - mergedState.steps = mergedState.steps.filter( - (step) => step === StepEnum.breakfast - ) - if (mergedState.currentStep === StepEnum.breakfast) { - mergedState.currentStep = mergedState.steps[1] - } - } - - if (initialState.bedType) { - if (mergedState.currentStep === StepEnum.selectBed) { - mergedState.currentStep = mergedState.steps[1] - } - } - - const validPaths = validateSteps(mergedState, isMember) - if (!validPaths.includes(mergedState.currentStep)) { - mergedState.currentStep = validPaths.at(-1)! - } - - if (currentStep !== mergedState.currentStep) { - setTimeout(() => { - navigate(mergedState.currentStep, searchParams) - }) - } - return mergedState }, partialize(state) { return { diff --git a/types/stores/enter-details.ts b/types/stores/enter-details.ts index a7943e35b..e44fd8ce1 100644 --- a/types/stores/enter-details.ts +++ b/types/stores/enter-details.ts @@ -60,9 +60,4 @@ export type InitialState = Pick & breakfast?: false } -export type PersistedState = Pick< - DetailsState, - "bedType" | "booking" | "breakfast" | "guest" | "totalPrice" -> - export type RoomRate = DetailsProviderProps["roomRate"]