diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/[...paths]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/[...paths]/page.tsx deleted file mode 100644 index 03a82e5f5..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/[...paths]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../page" diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/page.tsx deleted file mode 100644 index 75101475a..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { redirect } from "next/navigation" - -import { getHotelData } from "@/lib/trpc/memoizedRequests" - -import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader" - -import type { LangParams, PageArgs } from "@/types/params" - -export default async function HotelHeader({ - params, - searchParams, -}: PageArgs) { - const home = `/${params.lang}` - if (!searchParams.hotel) { - redirect(home) - } - const hotel = await getHotelData({ - hotelId: searchParams.hotel, - language: params.lang, - }) - if (!hotel?.data) { - redirect(home) - } - return -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx index 791773f94..a12639acf 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx @@ -86,7 +86,7 @@ export default async function SelectHotelPage({
diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/loading.tsx similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/loading.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/loading.tsx diff --git a/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css similarity index 90% rename from components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css index 9eefdfb33..82d6353ac 100644 --- a/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css @@ -1,9 +1,9 @@ -.hotelSelectionHeader { +.header { background-color: var(--Base-Surface-Subtle-Normal); padding: var(--Spacing-x3) var(--Spacing-x2); } -.hotelSelectionHeaderWrapper { +.wrapper { display: flex; flex-direction: column; gap: var(--Spacing-x3); @@ -35,11 +35,11 @@ } @media (min-width: 768px) { - .hotelSelectionHeader { + .header { padding: var(--Spacing-x4) 0; } - .hotelSelectionHeaderWrapper { + .wrapper { flex-direction: row; gap: var(--Spacing-x6); margin: 0 auto; diff --git a/components/HotelReservation/HotelSelectionHeader/index.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.tsx similarity index 63% rename from components/HotelReservation/HotelSelectionHeader/index.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.tsx index c0045dff5..83412f1d1 100644 --- a/components/HotelReservation/HotelSelectionHeader/index.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.tsx @@ -1,23 +1,38 @@ -"use client" -import { useIntl } from "react-intl" +import { redirect } from "next/navigation" + +import { getHotelData } from "@/lib/trpc/memoizedRequests" import Divider from "@/components/TempDesignSystem/Divider" import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import Title from "@/components/TempDesignSystem/Text/Title" +import { getIntl } from "@/i18n" -import styles from "./hotelSelectionHeader.module.css" +import styles from "./page.module.css" -import { HotelSelectionHeaderProps } from "@/types/components/hotelReservation/selectRate/hotelSelectionHeader" +import type { LangParams, PageArgs } from "@/types/params" -export default function HotelSelectionHeader({ - hotel, -}: HotelSelectionHeaderProps) { - const intl = useIntl() +export default async function HotelHeader({ + params, + searchParams, +}: PageArgs) { + const home = `/${params.lang}` + if (!searchParams.hotel) { + redirect(home) + } + const hotelData = await getHotelData({ + hotelId: searchParams.hotel, + language: params.lang, + }) + if (!hotelData?.data) { + redirect(home) + } + const intl = await getIntl() + const hotel = hotelData.data.attributes return ( -
-
+
+
{hotel.name} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.module.css similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.module.css rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.module.css diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx similarity index 99% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx index d3228e7b7..da3554f50 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx @@ -61,7 +61,7 @@ export default async function SummaryPage({ if (!availability || !availability.selectedRoom) { console.error("No hotel or availability data", availability) // TODO: handle this case - redirect(selectRate[params.lang]) + redirect(selectRate(params.lang)) } const prices = diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/_preload.ts b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/_preload.ts similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/_preload.ts rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/_preload.ts diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/enterDetailsLayout.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsLayout.css similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/enterDetailsLayout.css rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsLayout.css diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx similarity index 73% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx index fbd462544..2bd8a5102 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx @@ -1,20 +1,19 @@ import { getProfileSafely } from "@/lib/trpc/memoizedRequests" -import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider" import { setLang } from "@/i18n/serverContext" +import DetailsProvider from "@/providers/DetailsProvider" import { preload } from "./_preload" -import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step" import type { LangParams, LayoutArgs } from "@/types/params" export default async function StepLayout({ - summary, children, hotelHeader, params, + summary, }: React.PropsWithChildren< - LayoutArgs<LangParams & { step: StepEnum }> & { + LayoutArgs<LangParams> & { hotelHeader: React.ReactNode summary: React.ReactNode } @@ -25,7 +24,7 @@ export default async function StepLayout({ const user = await getProfileSafely() return ( - <EnterDetailsProvider step={params.step} isMember={!!user}> + <DetailsProvider isMember={!!user}> <main className="enter-details-layout__layout"> {hotelHeader} <div className={"enter-details-layout__container"}> @@ -35,6 +34,6 @@ export default async function StepLayout({ </aside> </div> </main> - </EnterDetailsProvider> + </DetailsProvider> ) } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx similarity index 59% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx index ae04c61a6..648cdff93 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx @@ -1,6 +1,6 @@ import "./enterDetailsLayout.css" -import { notFound } from "next/navigation" +import { notFound, redirect, RedirectType } from "next/navigation" import { getBreakfastPackages, @@ -22,9 +22,10 @@ import { getQueryParamsForEnterDetails, } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" import { getIntl } from "@/i18n" +import StepsProvider from "@/providers/StepsProvider" -import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step" import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" +import { StepEnum } from "@/types/enums/step" import type { LangParams, PageArgs } from "@/types/params" function isValidStep(step: string): step is StepEnum { @@ -32,11 +33,9 @@ function isValidStep(step: string): step is StepEnum { } export default async function StepPage({ - params, + params: { lang }, searchParams, -}: PageArgs<LangParams & { step: StepEnum }, SelectRateSearchParams>) { - const { lang } = params - +}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) { const intl = await getIntl() const selectRoomParams = new URLSearchParams(searchParams) const { @@ -88,7 +87,7 @@ export default async function StepPage({ const user = await getProfileSafely() const savedCreditCards = await getCreditCardsSafely() - if (!isValidStep(params.step) || !hotelData || !roomAvailability) { + if (!isValidStep(searchParams.step) || !hotelData || !roomAvailability) { return notFound() } @@ -113,54 +112,65 @@ export default async function StepPage({ } return ( - <section> - <HistoryStateManager /> - <SelectedRoom - hotelId={hotelId} - room={roomAvailability.selectedRoom} - rateDescription={roomAvailability.cancellationText} - /> - - {/* TODO: How to handle no beds found? */} - {roomAvailability.bedTypes ? ( - <SectionAccordion - header="Select bed" - step={StepEnum.selectBed} - label={intl.formatMessage({ id: "Request bedtype" })} - > - <BedType bedTypes={roomAvailability.bedTypes} /> - </SectionAccordion> - ) : null} - - <SectionAccordion - header={intl.formatMessage({ id: "Food options" })} - step={StepEnum.breakfast} - label={intl.formatMessage({ id: "Select breakfast options" })} - > - <Breakfast packages={breakfastPackages} /> - </SectionAccordion> - <SectionAccordion - header={intl.formatMessage({ id: "Details" })} - step={StepEnum.details} - label={intl.formatMessage({ id: "Enter your details" })} - > - <Details user={user} /> - </SectionAccordion> - <SectionAccordion - header={mustBeGuaranteed ? paymentGuarantee : payment} - step={StepEnum.payment} - label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod} - > - <Payment - roomPrice={roomPrice} - otherPaymentOptions={ - hotelData.data.attributes.merchantInformationData - .alternatePaymentOptions - } - savedCreditCards={savedCreditCards} - mustBeGuaranteed={mustBeGuaranteed} + <StepsProvider + bedTypes={roomAvailability.bedTypes} + breakfastPackages={breakfastPackages} + isMember={!!user} + step={searchParams.step} + > + <section> + <HistoryStateManager /> + <SelectedRoom + hotelId={hotelId} + room={roomAvailability.selectedRoom} + rateDescription={roomAvailability.cancellationText} /> - </SectionAccordion> - </section> + + {/* TODO: How to handle no beds found? */} + {roomAvailability.bedTypes ? ( + <SectionAccordion + header={intl.formatMessage({ id: "Select bed" })} + step={StepEnum.selectBed} + label={intl.formatMessage({ id: "Request bedtype" })} + > + <BedType bedTypes={roomAvailability.bedTypes} /> + </SectionAccordion> + ) : null} + + {breakfastPackages?.length ? ( + <SectionAccordion + header={intl.formatMessage({ id: "Food options" })} + step={StepEnum.breakfast} + label={intl.formatMessage({ id: "Select breakfast options" })} + > + <Breakfast packages={breakfastPackages} /> + </SectionAccordion> + ) : null} + + <SectionAccordion + header={intl.formatMessage({ id: "Details" })} + step={StepEnum.details} + label={intl.formatMessage({ id: "Enter your details" })} + > + <Details user={user} /> + </SectionAccordion> + + <SectionAccordion + header={mustBeGuaranteed ? paymentGuarantee : payment} + step={StepEnum.payment} + label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod} + > + <Payment + roomPrice={roomPrice} + otherPaymentOptions={ + hotelData.data.attributes.merchantInformationData + .alternatePaymentOptions + } + savedCreditCards={savedCreditCards} + mustBeGuaranteed={mustBeGuaranteed} + /> + </SectionAccordion> + </section> + </StepsProvider> ) } diff --git a/app/api/web/payment-callback/[lang]/[status]/route.ts b/app/api/web/payment-callback/[lang]/[status]/route.ts index 624df1f52..5884d63f9 100644 --- a/app/api/web/payment-callback/[lang]/[status]/route.ts +++ b/app/api/web/payment-callback/[lang]/[status]/route.ts @@ -26,7 +26,7 @@ export async function GET( const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER) if (status === "success" && confirmationNumber) { - const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation[lang]}`) + const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation(lang)}`) confirmationUrl.searchParams.set( BOOKING_CONFIRMATION_NUMBER, confirmationNumber @@ -36,7 +36,7 @@ export async function GET( return NextResponse.redirect(confirmationUrl) } - const returnUrl = new URL(`${publicURL}/${payment[lang]}`) + const returnUrl = new URL(`${publicURL}/${payment(lang)}`) returnUrl.search = queryParams.toString() if (confirmationNumber) { diff --git a/components/Forms/BookingWidget/index.tsx b/components/Forms/BookingWidget/index.tsx index b9ea569e8..b47ae74aa 100644 --- a/components/Forms/BookingWidget/index.tsx +++ b/components/Forms/BookingWidget/index.tsx @@ -35,7 +35,7 @@ export default function Form({ const locationData: Location = JSON.parse(decodeURIComponent(data.location)) const bookingFlowPage = - locationData.type == "cities" ? selectHotel[lang] : selectRate[lang] + locationData.type == "cities" ? selectHotel(lang) : selectRate(lang) const bookingWidgetParams = new URLSearchParams(data.date) if (locationData.type == "cities") diff --git a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css index 81fd223b9..844ed4a6b 100644 --- a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css +++ b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css @@ -1,7 +1,6 @@ .form { display: grid; gap: var(--Spacing-x2); - grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); - padding-bottom: var(--Spacing-x3); + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); width: min(600px, 100%); } diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index 1bf78fee5..eeb8237a0 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -4,7 +4,8 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useCallback, useEffect } from "react" import { FormProvider, useForm } from "react-hook-form" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" +import { useStepsStore } from "@/stores/steps" import { KingBedIcon } from "@/components/Icons" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" @@ -19,22 +20,18 @@ import type { } from "@/types/components/hotelReservation/enterDetails/bedType" export default function BedType({ bedTypes }: BedTypeProps) { - const bedType = useEnterDetailsStore((state) => state.userData.bedType) + const bedType = useDetailsStore((state) => state.data.bedType?.roomTypeCode) + const completeStep = useStepsStore((state) => state.completeStep) + const updateBedType = useDetailsStore((state) => state.actions.updateBedType) const methods = useForm<BedTypeFormSchema>({ - defaultValues: bedType?.roomTypeCode - ? { - bedType: bedType.roomTypeCode, - } - : undefined, + defaultValues: bedType ? { bedType } : undefined, criteriaMode: "all", mode: "all", resolver: zodResolver(bedTypeFormSchema), reValidateMode: "onChange", }) - const completeStep = useEnterDetailsStore((state) => state.completeStep) - const onSubmit = useCallback( (bedTypeRoomCode: BedTypeFormSchema) => { const matchingRoom = bedTypes.find( @@ -45,10 +42,11 @@ export default function BedType({ bedTypes }: BedTypeProps) { description: matchingRoom.description, roomTypeCode: matchingRoom.value, } - completeStep({ bedType }) + updateBedType(bedType) + completeStep() } }, - [completeStep, bedTypes] + [bedTypes, completeStep, updateBedType] ) useEffect(() => { diff --git a/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css b/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css index 81fd223b9..f24c6ba64 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css +++ b/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css @@ -2,6 +2,5 @@ display: grid; gap: var(--Spacing-x2); grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); - padding-bottom: var(--Spacing-x3); width: min(600px, 100%); } diff --git a/components/HotelReservation/EnterDetails/Breakfast/index.tsx b/components/HotelReservation/EnterDetails/Breakfast/index.tsx index 00d5ab4cc..fdaec3a84 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/index.tsx +++ b/components/HotelReservation/EnterDetails/Breakfast/index.tsx @@ -5,7 +5,8 @@ import { useCallback, useEffect } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" +import { useStepsStore } from "@/stores/steps" import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" @@ -23,34 +24,37 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast" export default function Breakfast({ packages }: BreakfastProps) { const intl = useIntl() - const breakfast = useEnterDetailsStore((state) => state.userData.breakfast) + const breakfast = useDetailsStore(({ data }) => + data.breakfast + ? data.breakfast.code + : data.breakfast === false + ? "false" + : data.breakfast + ) + const updateBreakfast = useDetailsStore( + (state) => state.actions.updateBreakfast + ) + const completeStep = useStepsStore((state) => state.completeStep) - let defaultValues = undefined - if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) { - defaultValues = { breakfast: BreakfastPackageEnum.NO_BREAKFAST } - } else if (breakfast?.code) { - defaultValues = { breakfast: breakfast.code } - } const methods = useForm<BreakfastFormSchema>({ - defaultValues, + defaultValues: breakfast ? { breakfast } : undefined, criteriaMode: "all", mode: "all", resolver: zodResolver(breakfastFormSchema), reValidateMode: "onChange", }) - const completeStep = useEnterDetailsStore((state) => state.completeStep) - const onSubmit = useCallback( (values: BreakfastFormSchema) => { const pkg = packages?.find((p) => p.code === values.breakfast) if (pkg) { - completeStep({ breakfast: pkg }) + updateBreakfast(pkg) } else { - completeStep({ breakfast: BreakfastPackageEnum.NO_BREAKFAST }) + updateBreakfast(false) } + completeStep() }, - [completeStep, packages] + [completeStep, packages, updateBreakfast] ) useEffect(() => { @@ -61,10 +65,6 @@ export default function Breakfast({ packages }: BreakfastProps) { return () => subscription.unsubscribe() }, [methods, onSubmit]) - if (!packages) { - return null - } - return ( <FormProvider {...methods}> <form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}> @@ -100,7 +100,6 @@ export default function Breakfast({ packages }: BreakfastProps) { /> ))} <RadioCard - id={BreakfastPackageEnum.NO_BREAKFAST} name="breakfast" subtitle={intl.formatMessage( { id: "{amount} {currency}" }, @@ -113,7 +112,7 @@ export default function Breakfast({ packages }: BreakfastProps) { id: "You can always change your mind later and add breakfast at the hotel.", })} title={intl.formatMessage({ id: "No breakfast" })} - value={BreakfastPackageEnum.NO_BREAKFAST} + value="false" /> </form> </FormProvider> diff --git a/components/HotelReservation/EnterDetails/Breakfast/schema.ts b/components/HotelReservation/EnterDetails/Breakfast/schema.ts index 5f8c1f354..4766980cb 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/schema.ts +++ b/components/HotelReservation/EnterDetails/Breakfast/schema.ts @@ -2,14 +2,10 @@ import { z } from "zod" import { breakfastPackageSchema } from "@/server/routers/hotels/output" -import { BreakfastPackageEnum } from "@/types/enums/breakfast" - export const breakfastStoreSchema = z.object({ - breakfast: breakfastPackageSchema.or( - z.literal(BreakfastPackageEnum.NO_BREAKFAST) - ), + breakfast: breakfastPackageSchema.or(z.literal(false)), }) export const breakfastFormSchema = z.object({ - breakfast: z.string().or(z.literal(BreakfastPackageEnum.NO_BREAKFAST)), + breakfast: z.string().or(z.literal("false")), }) diff --git a/components/HotelReservation/EnterDetails/Details/details.module.css b/components/HotelReservation/EnterDetails/Details/details.module.css index 62a947e3e..f89dfa7cc 100644 --- a/components/HotelReservation/EnterDetails/Details/details.module.css +++ b/components/HotelReservation/EnterDetails/Details/details.module.css @@ -1,7 +1,6 @@ .form { display: grid; gap: var(--Spacing-x2); - padding: var(--Spacing-x3) 0px; } .container { diff --git a/components/HotelReservation/EnterDetails/Details/index.tsx b/components/HotelReservation/EnterDetails/Details/index.tsx index 806776ce8..dd5959c31 100644 --- a/components/HotelReservation/EnterDetails/Details/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/index.tsx @@ -1,9 +1,11 @@ "use client" import { zodResolver } from "@hookform/resolvers/zod" +import { useCallback } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" +import { useStepsStore } from "@/stores/steps" import Button from "@/components/TempDesignSystem/Button" import CountrySelect from "@/components/TempDesignSystem/Form/Country" @@ -24,19 +26,22 @@ import type { const formID = "enter-details" export default function Details({ user }: DetailsProps) { const intl = useIntl() - const initialData = useEnterDetailsStore((state) => ({ - countryCode: state.userData.countryCode, - email: state.userData.email, - firstName: state.userData.firstName, - lastName: state.userData.lastName, - phoneNumber: state.userData.phoneNumber, - join: state.userData.join, - dateOfBirth: state.userData.dateOfBirth, - zipCode: state.userData.zipCode, - termsAccepted: state.userData.termsAccepted, - membershipNo: state.userData.membershipNo, + const initialData = useDetailsStore((state) => ({ + countryCode: state.data.countryCode, + email: state.data.email, + firstName: state.data.firstName, + lastName: state.data.lastName, + phoneNumber: state.data.phoneNumber, + join: state.data.join, + dateOfBirth: state.data.dateOfBirth, + zipCode: state.data.zipCode, + termsAccepted: state.data.termsAccepted, + membershipNo: state.data.membershipNo, })) + const updateDetails = useDetailsStore((state) => state.actions.updateDetails) + const completeStep = useStepsStore((state) => state.completeStep) + const methods = useForm<DetailsSchema>({ defaultValues: { countryCode: user?.address?.countryCode ?? initialData.countryCode, @@ -56,14 +61,20 @@ export default function Details({ user }: DetailsProps) { reValidateMode: "onChange", }) - const completeStep = useEnterDetailsStore((state) => state.completeStep) + const onSubmit = useCallback( + (values: DetailsSchema) => { + updateDetails(values) + completeStep() + }, + [completeStep, updateDetails] + ) return ( <FormProvider {...methods}> <form className={styles.form} id={formID} - onSubmit={methods.handleSubmit(completeStep)} + onSubmit={methods.handleSubmit(onSubmit)} > {user ? null : <Signup name="join" />} <Footnote @@ -107,7 +118,7 @@ export default function Details({ user }: DetailsProps) { readOnly={!!user} registerOptions={{ required: true }} /> - {user ? null : ( + {user || methods.watch("join") ? null : ( <Input className={styles.membershipNo} label={intl.formatMessage({ id: "Membership no" })} @@ -119,7 +130,6 @@ export default function Details({ user }: DetailsProps) { <footer className={styles.footer}> <Button disabled={!methods.formState.isValid} - form={formID} intent="secondary" size="small" theme="base" diff --git a/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx b/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx index 0ce1b1080..dc83a8072 100644 --- a/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx +++ b/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect } from "react" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useStepsStore } from "@/stores/steps" export default function HistoryStateManager() { - const setCurrentStep = useEnterDetailsStore((state) => state.setCurrentStep) - const currentStep = useEnterDetailsStore((state) => state.currentStep) + const setCurrentStep = useStepsStore((state) => state.setStep) + const currentStep = useStepsStore((state) => state.currentStep) const handleBackButton = useCallback( (event: PopStateEvent) => { diff --git a/components/HotelReservation/EnterDetails/Payment/index.tsx b/components/HotelReservation/EnterDetails/Payment/index.tsx index ff402bf2d..a912f74b9 100644 --- a/components/HotelReservation/EnterDetails/Payment/index.tsx +++ b/components/HotelReservation/EnterDetails/Payment/index.tsx @@ -18,7 +18,7 @@ import { } from "@/constants/currentWebHrefs" import { env } from "@/env/client" import { trpc } from "@/lib/trpc/client" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" import LoadingSpinner from "@/components/LoadingSpinner" import Button from "@/components/TempDesignSystem/Button" @@ -40,7 +40,6 @@ import styles from "./payment.module.css" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section" -import { BreakfastPackageEnum } from "@/types/enums/breakfast" const maxRetries = 4 const retryInterval = 2000 @@ -61,12 +60,9 @@ export default function Payment({ const lang = useLang() const intl = useIntl() const queryParams = useSearchParams() - const { userData, roomData, setIsSubmittingDisabled } = useEnterDetailsStore( - (state) => ({ - userData: state.userData, - roomData: state.roomData, - setIsSubmittingDisabled: state.setIsSubmittingDisabled, - }) + const { booking, ...userData } = useDetailsStore((state) => state.data) + const setIsSubmittingDisabled = useDetailsStore( + (state) => state.actions.setIsSubmittingDisabled ) const { @@ -82,7 +78,7 @@ export default function Payment({ dateOfBirth, zipCode, } = userData - const { toDate, fromDate, rooms: rooms, hotel } = roomData + const { toDate, fromDate, rooms, hotel } = booking const [confirmationNumber, setConfirmationNumber] = useState<string>("") const [availablePaymentOptions, setAvailablePaymentOptions] = @@ -204,7 +200,7 @@ export default function Payment({ postalCode: zipCode, }, packages: { - breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST, + breakfast: !!(breakfast && breakfast.code), allergyFriendly: room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false, petFriendly: diff --git a/components/HotelReservation/EnterDetails/Provider/index.tsx b/components/HotelReservation/EnterDetails/Provider/index.tsx deleted file mode 100644 index 82bfdbd82..000000000 --- a/components/HotelReservation/EnterDetails/Provider/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client" -import { useSearchParams } from "next/navigation" -import { PropsWithChildren, useRef } from "react" - -import { - EnterDetailsContext, - type EnterDetailsStore, - initEditDetailsState, -} from "@/stores/enter-details" - -import { EnterDetailsProviderProps } from "@/types/components/hotelReservation/enterDetails/store" - -export default function EnterDetailsProvider({ - step, - isMember, - children, -}: PropsWithChildren<EnterDetailsProviderProps>) { - const searchParams = useSearchParams() - const initialStore = useRef<EnterDetailsStore>() - if (!initialStore.current) { - initialStore.current = initEditDetailsState(step, searchParams, isMember) - } - - return ( - <EnterDetailsContext.Provider value={initialStore.current}> - {children} - </EnterDetailsContext.Provider> - ) -} diff --git a/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx b/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx index dee985295..ce548ae74 100644 --- a/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx +++ b/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from "react" import { useIntl } from "react-intl" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" +import { useStepsStore } from "@/stores/steps" import { CheckIcon, ChevronDownIcon } from "@/components/Icons" import Footnote from "@/components/TempDesignSystem/Text/Footnote" @@ -10,12 +11,9 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import styles from "./sectionAccordion.module.css" -import { - StepEnum, - StepStoreKeys, -} from "@/types/components/hotelReservation/enterDetails/step" +import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step" import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion" -import { BreakfastPackageEnum } from "@/types/enums/breakfast" +import { StepEnum } from "@/types/enums/step" export default function SectionAccordion({ header, @@ -24,12 +22,12 @@ export default function SectionAccordion({ children, }: React.PropsWithChildren<SectionAccordionProps>) { const intl = useIntl() - const currentStep = useEnterDetailsStore((state) => state.currentStep) + const currentStep = useStepsStore((state) => state.currentStep) const [isComplete, setIsComplete] = useState(false) const [isOpen, setIsOpen] = useState(false) - const isValid = useEnterDetailsStore((state) => state.isValid[step]) - const navigate = useEnterDetailsStore((state) => state.navigate) - const stepData = useEnterDetailsStore((state) => state.userData) + const isValid = useDetailsStore((state) => state.isValid[step]) + const navigate = useStepsStore((state) => state.navigate) + const stepData = useDetailsStore((state) => state.data) const stepStoreKey = StepStoreKeys[step] const [title, setTitle] = useState(label) @@ -39,9 +37,12 @@ export default function SectionAccordion({ value && setTitle(value.description) } // If breakfast step, check if an option has been selected - if (step === StepEnum.breakfast && stepData.breakfast) { + if ( + step === StepEnum.breakfast && + (stepData.breakfast || stepData.breakfast === false) + ) { const value = stepData.breakfast - if (value === BreakfastPackageEnum.NO_BREAKFAST) { + if (value === false) { setTitle(intl.formatMessage({ id: "No breakfast" })) } else { setTitle(intl.formatMessage({ id: "Breakfast buffet" })) @@ -94,7 +95,9 @@ export default function SectionAccordion({ )} </button> </header> - <div className={styles.content}>{children}</div> + <div className={styles.content}> + <div className={styles.contentWrapper}>{children}</div> + </div> </div> </section> ) diff --git a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css index fc3de1764..ed91cb9e2 100644 --- a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css +++ b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css @@ -31,7 +31,6 @@ .main { display: grid; - gap: var(--Spacing-x3); width: 100%; border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); padding-bottom: var(--Spacing-x3); @@ -80,6 +79,10 @@ overflow: hidden; } +.contentWrapper { + padding-top: var(--Spacing-x3); +} + @media screen and (min-width: 1367px) { .wrapper { gap: var(--Spacing-x3); @@ -98,4 +101,4 @@ content: ""; border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); } -} +} \ No newline at end of file diff --git a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx index 64e9e0960..9d373f871 100644 --- a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx +++ b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx @@ -2,12 +2,13 @@ import { useIntl } from "react-intl" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { selectRate } from "@/constants/routes/hotelReservation" import { CheckIcon, EditIcon } from "@/components/Icons" import Link from "@/components/TempDesignSystem/Link" import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import useLang from "@/hooks/useLang" import ToggleSidePeek from "./ToggleSidePeek" @@ -21,8 +22,7 @@ export default function SelectedRoom({ rateDescription, }: SelectedRoomProps) { const intl = useIntl() - - const selectRateUrl = useEnterDetailsStore((state) => state.selectRateUrl) + const lang = useLang() return ( <div className={styles.wrapper}> @@ -53,7 +53,8 @@ export default function SelectedRoom({ <Link className={styles.button} color="burgundy" - href={selectRateUrl} + href={selectRate(lang)} + keepSearchParams size="small" variant="icon" > diff --git a/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx b/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx index ac7921aec..9f99a56c0 100644 --- a/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx @@ -3,7 +3,7 @@ import { PropsWithChildren } from "react" import { useIntl } from "react-intl" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" import Button from "@/components/TempDesignSystem/Button" import Caption from "@/components/TempDesignSystem/Text/Caption" @@ -17,9 +17,9 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) { const intl = useIntl() const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } = - useEnterDetailsStore((state) => ({ + useDetailsStore((state) => ({ isSummaryOpen: state.isSummaryOpen, - toggleSummaryOpen: state.toggleSummaryOpen, + toggleSummaryOpen: state.actions.toggleSummaryOpen, totalPrice: state.totalPrice, isSubmittingDisabled: state.isSubmittingDisabled, })) diff --git a/components/HotelReservation/EnterDetails/Summary/index.tsx b/components/HotelReservation/EnterDetails/Summary/index.tsx index 4b093f8ea..c447c7f75 100644 --- a/components/HotelReservation/EnterDetails/Summary/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/index.tsx @@ -5,7 +5,7 @@ import { ChevronDown } from "react-feather" import { useIntl } from "react-intl" import { dt } from "@/lib/dt" -import { EnterDetailsState, useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" import { ArrowRightIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" @@ -18,45 +18,39 @@ import useLang from "@/hooks/useLang" import styles from "./summary.module.css" -import { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" -import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData" -import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" -import { BreakfastPackageEnum } from "@/types/enums/breakfast" +import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" +import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" +import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary" +import type { DetailsState } from "@/types/stores/details" -function storeSelector(state: EnterDetailsState) { +function storeSelector(state: DetailsState) { return { - fromDate: state.roomData.fromDate, - toDate: state.roomData.toDate, - bedType: state.userData.bedType, - breakfast: state.userData.breakfast, - toggleSummaryOpen: state.toggleSummaryOpen, - setTotalPrice: state.setTotalPrice, + fromDate: state.data.booking.fromDate, + toDate: state.data.booking.toDate, + bedType: state.data.bedType, + breakfast: state.data.breakfast, + toggleSummaryOpen: state.actions.toggleSummaryOpen, + setTotalPrice: state.actions.setTotalPrice, totalPrice: state.totalPrice, } } -export default function Summary({ - showMemberPrice, - room, -}: { - showMemberPrice: boolean - room: RoomsData -}) { +export default function Summary({ showMemberPrice, room }: SummaryProps) { const [chosenBed, setChosenBed] = useState<BedTypeSchema>() const [chosenBreakfast, setChosenBreakfast] = useState< - BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST + BreakfastPackage | false >() const intl = useIntl() const lang = useLang() const { - fromDate, - toDate, bedType, breakfast, + fromDate, setTotalPrice, - totalPrice, + toDate, toggleSummaryOpen, - } = useEnterDetailsStore(storeSelector) + totalPrice, + } = useDetailsStore(storeSelector) const diff = dt(toDate).diff(fromDate, "days") @@ -88,36 +82,39 @@ export default function Summary({ setChosenBed(bedType) setChosenBreakfast(breakfast) - if (breakfast && breakfast !== BreakfastPackageEnum.NO_BREAKFAST) { - setTotalPrice({ - local: { - price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice), - currency: room.localPrice.currency, - }, - euro: - room.euroPrice && roomsPriceEuro - ? { + if (breakfast || breakfast === false) { + setChosenBreakfast(breakfast) + if (breakfast === false) { + setTotalPrice({ + local: { + price: roomsPriceLocal, + currency: room.localPrice.currency, + }, + euro: + room.euroPrice && roomsPriceEuro + ? { + price: roomsPriceEuro, + currency: room.euroPrice.currency, + } + : undefined, + }) + } else { + setTotalPrice({ + local: { + price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice), + currency: room.localPrice.currency, + }, + euro: + room.euroPrice && roomsPriceEuro + ? { price: roomsPriceEuro + parseInt(breakfast.requestedPrice.totalPrice), currency: room.euroPrice.currency, } - : undefined, - }) - } else { - setTotalPrice({ - local: { - price: roomsPriceLocal, - currency: room.localPrice.currency, - }, - euro: - room.euroPrice && roomsPriceEuro - ? { - price: roomsPriceEuro, - currency: room.euroPrice.currency, - } - : undefined, - }) + : undefined, + }) + } } }, [ bedType, @@ -187,24 +184,24 @@ export default function Summary({ </div> {room.packages ? room.packages.map((roomPackage) => ( - <div className={styles.entry} key={roomPackage.code}> - <div> - <Body color="uiTextHighContrast"> - {roomPackage.description} - </Body> - </div> - - <Caption color="uiTextHighContrast"> - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: roomPackage.localPrice.price, - currency: roomPackage.localPrice.currency, - } - )} - </Caption> + <div className={styles.entry} key={roomPackage.code}> + <div> + <Body color="uiTextHighContrast"> + {roomPackage.description} + </Body> </div> - )) + + <Caption color="uiTextHighContrast"> + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: roomPackage.localPrice.price, + currency: roomPackage.localPrice.currency, + } + )} + </Caption> + </div> + )) : null} {chosenBed ? ( <div className={styles.entry}> @@ -224,37 +221,36 @@ export default function Summary({ </div> ) : null} - {chosenBreakfast ? ( - chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? ( - <div className={styles.entry}> - <Body color="uiTextHighContrast"> - {intl.formatMessage({ id: "No breakfast" })} - </Body> - <Caption color="uiTextHighContrast"> - {intl.formatMessage( - { id: "{amount} {currency}" }, - { amount: "0", currency: room.localPrice.currency } - )} - </Caption> - </div> - ) : ( - <div className={styles.entry}> - <Body color="uiTextHighContrast"> - {intl.formatMessage({ id: "Breakfast buffet" })} - </Body> - <Caption color="uiTextHighContrast"> - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: chosenBreakfast.localPrice.totalPrice, - currency: chosenBreakfast.localPrice.currency, - } - )} - </Caption> - </div> - ) - ) : null} - </div> + {chosenBreakfast === false ? ( + <div className={styles.entry}> + <Body color="uiTextHighContrast"> + {intl.formatMessage({ id: "No breakfast" })} + </Body> + <Caption color="uiTextMediumContrast"> + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "0", currency: room.localPrice.currency } + )} + </Caption> + </div> + ) : chosenBreakfast?.code ? ( + <div className={styles.entry}> + <Body color="uiTextHighContrast"> + {intl.formatMessage({ id: "Breakfast buffet" })} + </Body> + <Caption color="uiTextMediumContrast"> + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: chosenBreakfast.localPrice.totalPrice, + currency: chosenBreakfast.localPrice.currency, + } + )} + </Caption> + </div> + ) : null + } + </div > <Divider color="primaryLightSubtle" /> <div className={styles.total}> <div className={styles.entry}> @@ -295,6 +291,6 @@ export default function Summary({ </div> <Divider className={styles.bottomDivider} color="primaryLightSubtle" /> </div> - </section> + </section > ) } diff --git a/components/HotelReservation/HotelCard/HotelPriceList/index.tsx b/components/HotelReservation/HotelCard/HotelPriceList/index.tsx index 1f55c2d8a..d98fdc260 100644 --- a/components/HotelReservation/HotelCard/HotelPriceList/index.tsx +++ b/components/HotelReservation/HotelCard/HotelPriceList/index.tsx @@ -39,7 +39,7 @@ export default function HotelPriceList({ className={styles.button} > <Link - href={`${selectRate[lang]}?hotel=${hotelId}`} + href={`${selectRate(lang)}?hotel=${hotelId}`} color="none" keepSearchParams > diff --git a/components/HotelReservation/HotelCard/index.tsx b/components/HotelReservation/HotelCard/index.tsx index feda19a05..808e9ac9f 100644 --- a/components/HotelReservation/HotelCard/index.tsx +++ b/components/HotelReservation/HotelCard/index.tsx @@ -93,7 +93,7 @@ export default function HotelCard({ </address> <Link className={styles.addressMobile} - href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`} + href={`${selectHotelMap(lang)}?selectedHotel=${hotelData.name}`} keepSearchParams > <Caption color="baseTextMediumContrast" type="underline"> diff --git a/components/HotelReservation/HotelCardDialog/index.tsx b/components/HotelReservation/HotelCardDialog/index.tsx index 16d1ce860..d444a1083 100644 --- a/components/HotelReservation/HotelCardDialog/index.tsx +++ b/components/HotelReservation/HotelCardDialog/index.tsx @@ -104,7 +104,7 @@ export default function HotelCardDialog({ <Button asChild theme="base" size="small" className={styles.button}> <Link - href={`${selectRate[lang]}?hotel=${data.operaId}`} + href={`${selectRate(lang)}?hotel=${data.operaId}`} color="none" keepSearchParams > diff --git a/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx b/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx index 558a3ef50..0b4881943 100644 --- a/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx +++ b/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx @@ -27,7 +27,7 @@ export default function MobileMapButtonContainer({ <div className={styles.buttonContainer}> <Button asChild variant="icon" intent="secondary" size="small"> <Link - href={`${selectHotelMap[lang]}`} + href={selectHotelMap(lang)} keepSearchParams color="burgundy" > diff --git a/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx b/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx index 21b804306..215c7ae66 100644 --- a/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx +++ b/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx @@ -71,7 +71,7 @@ export default function SelectHotelMap({ } function handlePageRedirect() { - router.push(`${selectHotel[lang]}?${searchParams.toString()}`) + router.push(`${selectHotel(lang)}?${searchParams.toString()}`) } const closeButton = ( diff --git a/components/HotelReservation/SelectRate/RoomSelection/utils.ts b/components/HotelReservation/SelectRate/RoomSelection/utils.ts index aa6ef2810..43af470e3 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/utils.ts +++ b/components/HotelReservation/SelectRate/RoomSelection/utils.ts @@ -53,26 +53,3 @@ export function getQueryParamsForEnterDetails( })), } } - -export function createSelectRateUrl(roomData: BookingData) { - const { hotel, fromDate, toDate } = roomData - const params = new URLSearchParams({ fromDate, toDate, hotel }) - - roomData.rooms.forEach((room, index) => { - params.set(`room[${index}].adults`, room.adults.toString()) - - if (room.children) { - room.children.forEach((child, childIndex) => { - params.set( - `room[${index}].child[${childIndex}].age`, - child.age.toString() - ) - params.set( - `room[${index}].child[${childIndex}].bed`, - child.bed.toString() - ) - }) - } - }) - return params -} diff --git a/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx index 2a3faf57b..7d6ef8105 100644 --- a/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx +++ b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx @@ -15,6 +15,7 @@ export default function Card({ iconHeight = 32, iconWidth = 32, declined = false, + defaultChecked, highlightSubtitle = false, id, list, @@ -45,6 +46,7 @@ export default function Card({ <input {...register(name)} aria-hidden + defaultChecked={defaultChecked} id={id || name} hidden type={type} diff --git a/components/TempDesignSystem/Select/index.tsx b/components/TempDesignSystem/Select/index.tsx index bce5132ab..816d8ddb9 100644 --- a/components/TempDesignSystem/Select/index.tsx +++ b/components/TempDesignSystem/Select/index.tsx @@ -12,6 +12,7 @@ import { import Label from "@/components/TempDesignSystem/Form/Label" import Body from "@/components/TempDesignSystem/Text/Body" +import useSetOverflowVisibleOnRA from "@/hooks/useSetOverflowVisibleOnRA" import SelectChevron from "../Form/SelectChevron" @@ -39,6 +40,7 @@ export default function Select({ discreet = false, }: SelectProps) { const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined) + const setOverflowVisible = useSetOverflowVisibleOnRA() function setRef(node: SelectPortalContainerArgs) { if (node) { @@ -60,6 +62,7 @@ export default function Select({ onSelectionChange={handleOnSelect} placeholder={placeholder} selectedKey={value as Key} + onOpenChange={setOverflowVisible} > <Body asChild fontOnly> <Button className={styles.input} data-testid={name}> diff --git a/constants/routes/hotelReservation.js b/constants/routes/hotelReservation.js index 94a9cef18..c7882f159 100644 --- a/constants/routes/hotelReservation.js +++ b/constants/routes/hotelReservation.js @@ -1,97 +1,59 @@ -/** @type {import('@/types/routes').LangRoute} */ -export const hotelReservation = { - en: "/en/hotelreservation", - sv: "/sv/hotelreservation", - no: "/no/hotelreservation", - fi: "/fi/hotelreservation", - da: "/da/hotelreservation", - de: "/de/hotelreservation", +/** + * @typedef {import('@/constants/languages').Lang} Lang + */ + +/** + * @param {Lang} lang + */ +function base(lang) { + return `/${lang}/hotelreservation` } -export const selectHotel = { - en: `${hotelReservation.en}/select-hotel`, - sv: `${hotelReservation.sv}/select-hotel`, - no: `${hotelReservation.no}/select-hotel`, - fi: `${hotelReservation.fi}/select-hotel`, - da: `${hotelReservation.da}/select-hotel`, - de: `${hotelReservation.de}/select-hotel`, +/** + * @param {Lang} lang + */ +export function bookingConfirmation(lang) { + return `${base(lang)}/booking-confirmation` } -export const selectRate = { - en: `${hotelReservation.en}/select-rate`, - sv: `${hotelReservation.sv}/select-rate`, - no: `${hotelReservation.no}/select-rate`, - fi: `${hotelReservation.fi}/select-rate`, - da: `${hotelReservation.da}/select-rate`, - de: `${hotelReservation.de}/select-rate`, +/** + * @param {Lang} lang + */ +export function details(lang) { + return `${base(lang)}/details` } -// TODO: Translate paths -export const selectBed = { - en: `${hotelReservation.en}/select-bed`, - sv: `${hotelReservation.sv}/select-bed`, - no: `${hotelReservation.no}/select-bed`, - fi: `${hotelReservation.fi}/select-bed`, - da: `${hotelReservation.da}/select-bed`, - de: `${hotelReservation.de}/select-bed`, +/** + * @param {Lang} lang + */ +export function payment(lang) { + return `${base(lang)}/payment` } -// TODO: Translate paths -export const breakfast = { - en: `${hotelReservation.en}/breakfast`, - sv: `${hotelReservation.sv}/breakfast`, - no: `${hotelReservation.no}/breakfast`, - fi: `${hotelReservation.fi}/breakfast`, - da: `${hotelReservation.da}/breakfast`, - de: `${hotelReservation.de}/breakfast`, +/** + * @param {Lang} lang + */ +export function selectBed(lang) { + return `${base(lang)}/select-bed` } -// TODO: Translate paths -export const details = { - en: `${hotelReservation.en}/details`, - sv: `${hotelReservation.sv}/details`, - no: `${hotelReservation.no}/details`, - fi: `${hotelReservation.fi}/details`, - da: `${hotelReservation.da}/details`, - de: `${hotelReservation.de}/details`, +/** + * @param {Lang} lang + */ +export function selectHotel(lang) { + return `${base(lang)}/select-hotel` } -// TODO: Translate paths -export const payment = { - en: `${hotelReservation.en}/payment`, - sv: `${hotelReservation.sv}/payment`, - no: `${hotelReservation.no}/payment`, - fi: `${hotelReservation.fi}/payment`, - da: `${hotelReservation.da}/payment`, - de: `${hotelReservation.de}/payment`, +/** + * @param {Lang} lang + */ +export function selectHotelMap(lang) { + return `${base(lang)}/map` } -export const selectHotelMap = { - en: `${selectHotel.en}/map`, - sv: `${selectHotel.sv}/map`, - no: `${selectHotel.no}/map`, - fi: `${selectHotel.fi}/map`, - da: `${selectHotel.da}/map`, - de: `${selectHotel.de}/map`, +/** + * @param {Lang} lang + */ +export function selectRate(lang) { + return `${base(lang)}/select-rate` } - -/** @type {import('@/types/routes').LangRoute} */ -export const bookingConfirmation = { - en: `${hotelReservation.en}/booking-confirmation`, - sv: `${hotelReservation.sv}/booking-confirmation`, - no: `${hotelReservation.no}/booking-confirmation`, - fi: `${hotelReservation.fi}/booking-confirmation`, - da: `${hotelReservation.da}/booking-confirmation`, - de: `${hotelReservation.de}/booking-confirmation`, -} - -export const bookingFlow = [ - ...Object.values(selectHotel), - ...Object.values(selectBed), - ...Object.values(breakfast), - ...Object.values(details), - ...Object.values(payment), - ...Object.values(selectHotelMap), - ...Object.values(bookingConfirmation), - ...Object.values(selectRate), -] diff --git a/contexts/Details.ts b/contexts/Details.ts new file mode 100644 index 000000000..7fb3a010a --- /dev/null +++ b/contexts/Details.ts @@ -0,0 +1,5 @@ +import { createContext } from "react" + +import type { DetailsStore } from "@/types/contexts/details" + +export const DetailsContext = createContext<DetailsStore | null>(null) diff --git a/contexts/Steps.ts b/contexts/Steps.ts new file mode 100644 index 000000000..220365fbe --- /dev/null +++ b/contexts/Steps.ts @@ -0,0 +1,5 @@ +import { createContext } from "react" + +import type { StepsStore } from "@/types/contexts/steps" + +export const StepsContext = createContext<StepsStore | null>(null) diff --git a/hooks/useSetOverflowVisibleOnRA.ts b/hooks/useSetOverflowVisibleOnRA.ts new file mode 100644 index 000000000..e9031b477 --- /dev/null +++ b/hooks/useSetOverflowVisibleOnRA.ts @@ -0,0 +1,11 @@ +export default function useSetOverflowVisibleOnRA() { + function setOverflowVisible(isOpen: boolean) { + if (isOpen) { + document.body.style.overflow = "visible" + } else { + document.body.style.overflow = "" + } + } + + return setOverflowVisible +} diff --git a/middlewares/bookingFlow.ts b/middlewares/bookingFlow.ts index 098ca9108..20e646e5a 100644 --- a/middlewares/bookingFlow.ts +++ b/middlewares/bookingFlow.ts @@ -1,7 +1,5 @@ import { NextResponse } from "next/server" -import { bookingFlow } from "@/constants/routes/hotelReservation" - import { getDefaultRequestHeaders } from "./utils" import type { NextMiddleware } from "next/server" @@ -18,5 +16,7 @@ export const middleware: NextMiddleware = async (request) => { } export const matcher: MiddlewareMatcher = (request) => { - return bookingFlow.includes(request.nextUrl.pathname) + return !!request.nextUrl.pathname.match( + /^\/(da|de|en|fi|no|sv)\/(hotelreservation)/ + ) } diff --git a/next.config.js b/next.config.js index 29012f230..222f085ac 100644 --- a/next.config.js +++ b/next.config.js @@ -277,6 +277,11 @@ const nextConfig = { source: `${myPages.sv}/:path*`, destination: `/sv/my-pages/:path*`, }, + { + source: + "/:lang/hotelreservation/:step(breakfast|details|payment|select-bed)", + destination: "/:lang/hotelreservation/step?step=:step", + }, ], } }, diff --git a/providers/DetailsProvider.tsx b/providers/DetailsProvider.tsx new file mode 100644 index 000000000..328307ee7 --- /dev/null +++ b/providers/DetailsProvider.tsx @@ -0,0 +1,30 @@ +"use client" +import { useSearchParams } from "next/navigation" +import { useRef } from "react" + +import { createDetailsStore } from "@/stores/details" + +import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" +import { DetailsContext } from "@/contexts/Details" + +import type { DetailsStore } from "@/types/contexts/details" +import type { DetailsProviderProps } from "@/types/providers/details" + +export default function DetailsProvider({ + children, + isMember, +}: DetailsProviderProps) { + const storeRef = useRef<DetailsStore>() + const searchParams = useSearchParams() + + if (!storeRef.current) { + const booking = getQueryParamsForEnterDetails(searchParams) + storeRef.current = createDetailsStore({ booking }, isMember) + } + + return ( + <DetailsContext.Provider value={storeRef.current}> + {children} + </DetailsContext.Provider> + ) +} diff --git a/providers/StepsProvider.tsx b/providers/StepsProvider.tsx new file mode 100644 index 000000000..87594be02 --- /dev/null +++ b/providers/StepsProvider.tsx @@ -0,0 +1,53 @@ +"use client" +import { useRef } from "react" + +import { useDetailsStore } from "@/stores/details" +import { createStepsStore } from "@/stores/steps" + +import { StepsContext } from "@/contexts/Steps" + +import type { StepsStore } from "@/types/contexts/steps" +import type { StepsProviderProps } from "@/types/providers/steps" + +export default function StepsProvider({ + bedTypes, + breakfastPackages, + children, + isMember, + step, +}: StepsProviderProps) { + const storeRef = useRef<StepsStore>() + const updateBedType = useDetailsStore((state) => state.actions.updateBedType) + const updateBreakfast = useDetailsStore( + (state) => state.actions.updateBreakfast + ) + + if (!storeRef.current) { + const noBedChoices = bedTypes.length === 1 + const noBreakfast = !breakfastPackages?.length + + if (noBedChoices) { + updateBedType({ + description: bedTypes[0].description, + roomTypeCode: bedTypes[0].value, + }) + } + + if (noBreakfast) { + updateBreakfast(false) + } + + storeRef.current = createStepsStore( + step, + isMember, + noBedChoices, + noBreakfast + ) + } + + return ( + <StepsContext.Provider value={storeRef.current}> + {children} + </StepsContext.Provider> + ) +} diff --git a/server/routers/hotels/schemas/packages.ts b/server/routers/hotels/schemas/packages.ts new file mode 100644 index 000000000..988cf1ac5 --- /dev/null +++ b/server/routers/hotels/schemas/packages.ts @@ -0,0 +1,62 @@ +import { z } from "zod" + +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" +import { CurrencyEnum } from "@/types/enums/currency" + +export const getRoomPackagesInputSchema = z.object({ + hotelId: z.string(), + startDate: z.string(), + endDate: z.string(), + adults: z.number(), + children: z.number().optional().default(0), + packageCodes: z.array(z.string()).optional().default([]), +}) + +export const packagePriceSchema = z + .object({ + currency: z.nativeEnum(CurrencyEnum), + price: z.string(), + totalPrice: z.string(), + }) + .optional() + .default({ + currency: CurrencyEnum.SEK, + price: "0", + totalPrice: "0", + }) // TODO: Remove optional and default when the API change has been deployed + +export const packagesSchema = z.object({ + code: z.nativeEnum(RoomPackageCodeEnum), + description: z.string(), + localPrice: packagePriceSchema, + requestedPrice: packagePriceSchema, + inventories: z.array( + z.object({ + date: z.string(), + total: z.number(), + available: z.number(), + }) + ), +}) + +export const getRoomPackagesSchema = z + .object({ + data: z.object({ + attributes: z.object({ + hotelId: z.number(), + packages: z.array(packagesSchema).default([]), + }), + relationships: z + .object({ + links: z.array( + z.object({ + url: z.string(), + type: z.string(), + }) + ), + }) + .optional(), + type: z.string(), + }), + }) + .transform((data) => data.data.attributes.packages) diff --git a/stores/details.ts b/stores/details.ts new file mode 100644 index 000000000..5d23248e5 --- /dev/null +++ b/stores/details.ts @@ -0,0 +1,195 @@ +import merge from "deepmerge" +import { produce } from "immer" +import { useContext } from "react" +import { create, useStore } from "zustand" +import { createJSONStorage, persist } from "zustand/middleware" + +import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" +import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" +import { + guestDetailsSchema, + signedInDetailsSchema, +} from "@/components/HotelReservation/EnterDetails/Details/schema" +import { DetailsContext } from "@/contexts/Details" + +import { StepEnum } from "@/types/enums/step" +import type { DetailsState, InitialState } from "@/types/stores/details" + +export const storageName = "details-storage" +export function createDetailsStore( + initialState: InitialState, + isMember: boolean +) { + if (typeof window !== "undefined") { + /** + * We need to initialize the store from sessionStorage ourselves + * since `persist` does it first after render and therefore + * we cannot use the data as `defaultValues` for our forms. + * RHF caches defaultValues on mount. + */ + const detailsStorageUnparsed = sessionStorage.getItem(storageName) + if (detailsStorageUnparsed) { + const detailsStorage: Record< + "state", + Pick<DetailsState, "data"> + > = JSON.parse(detailsStorageUnparsed) + initialState = merge(initialState, detailsStorage.state.data) + } + } + return create<DetailsState>()( + persist( + (set) => ({ + actions: { + setIsSubmittingDisabled(isSubmittingDisabled) { + return set( + produce((state: DetailsState) => { + state.isSubmittingDisabled = isSubmittingDisabled + }) + ) + }, + setTotalPrice(totalPrice) { + return set( + produce((state: DetailsState) => { + state.totalPrice = totalPrice + }) + ) + }, + toggleSummaryOpen() { + return set( + produce((state: DetailsState) => { + state.isSummaryOpen = !state.isSummaryOpen + }) + ) + }, + updateBedType(bedType) { + return set( + produce((state: DetailsState) => { + state.isValid["select-bed"] = true + state.data.bedType = bedType + }) + ) + }, + updateBreakfast(breakfast) { + return set( + produce((state: DetailsState) => { + state.isValid.breakfast = true + state.data.breakfast = breakfast + }) + ) + }, + updateDetails(data) { + return set( + produce((state: DetailsState) => { + state.isValid.details = true + + state.data.countryCode = data.countryCode + state.data.dateOfBirth = data.dateOfBirth + state.data.email = data.email + state.data.firstName = data.firstName + state.data.join = data.join + state.data.lastName = data.lastName + if (data.join) { + state.data.membershipNo = undefined + } else { + state.data.membershipNo = data.membershipNo + } + state.data.phoneNumber = data.phoneNumber + state.data.termsAccepted = data.termsAccepted + state.data.zipCode = data.zipCode + }) + ) + }, + updateValidity(property, isValid) { + return set( + produce((state: DetailsState) => { + state.isValid[property] = isValid + }) + ) + }, + }, + + data: merge( + { + bedType: undefined, + breakfast: undefined, + countryCode: "", + dateOfBirth: "", + email: "", + firstName: "", + join: false, + lastName: "", + membershipNo: "", + phoneNumber: "", + termsAccepted: false, + zipCode: "", + }, + initialState + ), + + isSubmittingDisabled: false, + isSummaryOpen: false, + isValid: { + [StepEnum.selectBed]: false, + [StepEnum.breakfast]: false, + [StepEnum.details]: false, + [StepEnum.payment]: false, + }, + + totalPrice: { + euro: { currency: "", price: 0 }, + local: { currency: "", price: 0 }, + }, + }), + { + name: storageName, + onRehydrateStorage() { + return function (state) { + if (state) { + const validatedBedType = bedTypeSchema.safeParse(state.data) + if (validatedBedType.success) { + state.actions.updateValidity(StepEnum.selectBed, true) + } else { + state.actions.updateValidity(StepEnum.selectBed, false) + } + + const validatedBreakfast = breakfastStoreSchema.safeParse( + state.data + ) + if (validatedBreakfast.success) { + state.actions.updateValidity(StepEnum.breakfast, true) + } else { + state.actions.updateValidity(StepEnum.breakfast, false) + } + + const detailsSchema = isMember + ? signedInDetailsSchema + : guestDetailsSchema + const validatedDetails = detailsSchema.safeParse(state.data) + if (validatedDetails.success) { + state.actions.updateValidity(StepEnum.details, true) + } else { + state.actions.updateValidity(StepEnum.details, false) + } + } + } + }, + partialize(state) { + return { + data: state.data, + } + }, + storage: createJSONStorage(() => sessionStorage), + } + ) + ) +} + +export function useDetailsStore<T>(selector: (store: DetailsState) => T) { + const store = useContext(DetailsContext) + + if (!store) { + throw new Error("useDetailsStore must be used within DetailsProvider") + } + + return useStore(store, selector) +} diff --git a/stores/enter-details.ts b/stores/enter-details.ts deleted file mode 100644 index b5f99cc38..000000000 --- a/stores/enter-details.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { produce } from "immer" -import { ReadonlyURLSearchParams } from "next/navigation" -import { createContext, useContext } from "react" -import { create, useStore } from "zustand" - -import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" -import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" -import { - guestDetailsSchema, - signedInDetailsSchema, -} from "@/components/HotelReservation/EnterDetails/Details/schema" -import { - createSelectRateUrl, - getQueryParamsForEnterDetails, -} from "@/components/HotelReservation/SelectRate/RoomSelection/utils" - -import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" -import { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData" -import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" -import { DetailsSchema } from "@/types/components/hotelReservation/enterDetails/details" -import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step" -import { BreakfastPackageEnum } from "@/types/enums/breakfast" - -const SESSION_STORAGE_KEY = "enterDetails" - -type TotalPrice = { - local: { price: number; currency: string } - euro?: { price: number; currency: string } -} - -export interface EnterDetailsState { - userData: { - bedType: BedTypeSchema | undefined - breakfast: BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST | undefined - } & DetailsSchema - roomData: BookingData - steps: StepEnum[] - selectRateUrl: string - currentStep: StepEnum - totalPrice: TotalPrice - isSubmittingDisabled: boolean - isSummaryOpen: boolean - isValid: Record<StepEnum, boolean> - completeStep: (updatedData: Partial<EnterDetailsState["userData"]>) => void - navigate: ( - step: StepEnum, - updatedData?: Record< - string, - string | boolean | number | BreakfastPackage | BedTypeSchema - > - ) => void - setCurrentStep: (step: StepEnum) => void - toggleSummaryOpen: () => void - setTotalPrice: (totalPrice: TotalPrice) => void - setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void -} - -export function initEditDetailsState( - currentStep: StepEnum, - searchParams: ReadonlyURLSearchParams, - isMember: boolean -) { - const isBrowser = typeof window !== "undefined" - const sessionData = isBrowser - ? sessionStorage.getItem(SESSION_STORAGE_KEY) - : null - - let roomData: BookingData - let selectRateUrl: string - if (searchParams?.size) { - const data = getQueryParamsForEnterDetails(searchParams) - roomData = data - selectRateUrl = `select-rate?${createSelectRateUrl(data)}` - } - - const defaultUserData: EnterDetailsState["userData"] = { - bedType: undefined, - breakfast: undefined, - countryCode: "", - email: "", - firstName: "", - lastName: "", - phoneNumber: "", - join: false, - zipCode: "", - dateOfBirth: undefined, - termsAccepted: false, - membershipNo: "", - } - - let inputUserData = {} - if (sessionData) { - inputUserData = JSON.parse(sessionData) - } - - const validPaths = [StepEnum.selectBed] - - let initialData: EnterDetailsState["userData"] = defaultUserData - - const isValid = { - [StepEnum.selectBed]: false, - [StepEnum.breakfast]: false, - [StepEnum.details]: false, - [StepEnum.payment]: false, - } - - const validatedBedType = bedTypeSchema.safeParse(inputUserData) - if (validatedBedType.success) { - validPaths.push(StepEnum.breakfast) - initialData = { ...initialData, ...validatedBedType.data } - isValid[StepEnum.selectBed] = true - } - const validatedBreakfast = breakfastStoreSchema.safeParse(inputUserData) - if (validatedBreakfast.success) { - validPaths.push(StepEnum.details) - initialData = { ...initialData, ...validatedBreakfast.data } - isValid[StepEnum.breakfast] = true - } - const detailsSchema = isMember ? signedInDetailsSchema : guestDetailsSchema - const validatedDetails = detailsSchema.safeParse(inputUserData) - if (validatedDetails.success) { - validPaths.push(StepEnum.payment) - initialData = { ...initialData, ...validatedDetails.data } - isValid[StepEnum.details] = true - } - - if (!validPaths.includes(currentStep)) { - currentStep = validPaths.pop()! // We will always have at least one valid path - if (isBrowser) { - window.history.pushState( - { step: currentStep }, - "", - currentStep + window.location.search - ) - } - } - - return create<EnterDetailsState>()((set, get) => ({ - userData: initialData, - roomData, - selectRateUrl, - steps: Object.values(StepEnum), - totalPrice: { - local: { price: 0, currency: "" }, - euro: { price: 0, currency: "" }, - }, - isSummaryOpen: false, - isSubmittingDisabled: false, - setCurrentStep: (step) => set({ currentStep: step }), - navigate: (step, updatedData) => - set( - produce((state) => { - const sessionStorage = window.sessionStorage - - const previousDataString = sessionStorage.getItem(SESSION_STORAGE_KEY) - - const previousData = JSON.parse(previousDataString || "{}") - - sessionStorage.setItem( - SESSION_STORAGE_KEY, - JSON.stringify({ ...previousData, ...updatedData }) - ) - - state.currentStep = step - window.history.pushState({ step }, "", step + window.location.search) - }) - ), - currentStep, - isValid, - completeStep: (updatedData) => - set( - produce((state: EnterDetailsState) => { - state.isValid[state.currentStep] = true - - const nextStep = - state.steps[state.steps.indexOf(state.currentStep) + 1] - - state.userData = { - ...state.userData, - ...updatedData, - } - state.currentStep = nextStep - get().navigate(nextStep, updatedData) - }) - ), - toggleSummaryOpen: () => set({ isSummaryOpen: !get().isSummaryOpen }), - setTotalPrice: (totalPrice) => set({ totalPrice: totalPrice }), - setIsSubmittingDisabled: (isSubmittingDisabled) => - set({ isSubmittingDisabled }), - })) -} - -export type EnterDetailsStore = ReturnType<typeof initEditDetailsState> - -export const EnterDetailsContext = createContext<EnterDetailsStore | null>(null) - -export const useEnterDetailsStore = <T>( - selector: (store: EnterDetailsState) => T -): T => { - const enterDetailsContextStore = useContext(EnterDetailsContext) - - if (!enterDetailsContextStore) { - throw new Error( - `useEnterDetailsStore must be used within EnterDetailsContextProvider` - ) - } - - return useStore(enterDetailsContextStore, selector) -} diff --git a/stores/steps.ts b/stores/steps.ts new file mode 100644 index 000000000..f1e456af2 --- /dev/null +++ b/stores/steps.ts @@ -0,0 +1,159 @@ +"use client" +import merge from "deepmerge" +import { produce } from "immer" +import { useContext } from "react" +import { create, useStore } from "zustand" + +import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" +import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" +import { + guestDetailsSchema, + signedInDetailsSchema, +} from "@/components/HotelReservation/EnterDetails/Details/schema" +import { StepsContext } from "@/contexts/Steps" + +import { storageName as detailsStorageName } from "./details" + +import { StepEnum } from "@/types/enums/step" +import type { DetailsState } from "@/types/stores/details" +import type { StepState } from "@/types/stores/steps" + +function push(data: Record<string, string>, url: string) { + if (typeof window !== "undefined") { + window.history.pushState(data, "", url + window.location.search) + } +} + +export function createStepsStore( + currentStep: StepEnum, + isMember: boolean, + noBedChoices: boolean, + noBreakfast: boolean +) { + const isBrowser = typeof window !== "undefined" + const steps = [ + StepEnum.selectBed, + StepEnum.breakfast, + StepEnum.details, + StepEnum.payment, + ] + + /** + * TODO: + * - when included in rate, can packages still be received? + * - no hotels yet with breakfast included in the rate so + * impossible to build for atm. + * + * matching breakfast first so the steps array is altered + * before the bedTypes possible step altering + */ + if (noBreakfast) { + steps.splice(1, 1) + if (currentStep === StepEnum.breakfast) { + currentStep = steps[1] + push({ step: currentStep }, currentStep) + } + } + + if (noBedChoices) { + if (currentStep === StepEnum.selectBed) { + currentStep = steps[1] + push({ step: currentStep }, currentStep) + } + } + + const detailsStorageUnparsed = isBrowser + ? sessionStorage.getItem(detailsStorageName) + : null + if (detailsStorageUnparsed) { + const detailsStorage: Record< + "state", + Pick<DetailsState, "data"> + > = JSON.parse(detailsStorageUnparsed) + + const validPaths = [StepEnum.selectBed] + + const validatedBedType = bedTypeSchema.safeParse(detailsStorage.state.data) + if (validatedBedType.success) { + validPaths.push(steps[1]) + } + + const validatedBreakfast = breakfastStoreSchema.safeParse( + detailsStorage.state.data + ) + if (validatedBreakfast.success) { + validPaths.push(StepEnum.details) + } + + const detailsSchema = isMember ? signedInDetailsSchema : guestDetailsSchema + const validatedDetails = detailsSchema.safeParse(detailsStorage.state.data) + if (validatedDetails.success) { + validPaths.push(StepEnum.payment) + } + + if (!validPaths.includes(currentStep) && isBrowser) { + // We will always have at least one valid path + currentStep = validPaths.pop()! + push({ step: currentStep }, currentStep) + } + } + + const initalData = { + currentStep, + steps, + } + + return create<StepState>()((set) => + merge( + { + currentStep: StepEnum.selectBed, + steps: [], + + completeStep() { + return set( + produce((state: StepState) => { + const currentStepIndex = state.steps.indexOf(state.currentStep) + const nextStep = state.steps[currentStepIndex + 1] + state.currentStep = nextStep + window.history.pushState( + { step: nextStep }, + "", + nextStep + window.location.search + ) + }) + ) + }, + navigate(step: StepEnum) { + return set( + produce((state) => { + state.currentStep = step + window.history.pushState( + { step }, + "", + step + window.location.search + ) + }) + ) + }, + setStep(step: StepEnum) { + return set( + produce((state: StepState) => { + state.currentStep = step + }) + ) + }, + }, + initalData + ) + ) +} + +export function useStepsStore<T>(selector: (store: StepState) => T) { + const store = useContext(StepsContext) + + if (!store) { + throw new Error(`useStepsStore must be used within StepsProvider`) + } + + return useStore(store, selector) +} diff --git a/types/components/hotelReservation/enterDetails/breakfast.ts b/types/components/hotelReservation/enterDetails/breakfast.ts index 21ba37bd0..283d85028 100644 --- a/types/components/hotelReservation/enterDetails/breakfast.ts +++ b/types/components/hotelReservation/enterDetails/breakfast.ts @@ -17,5 +17,5 @@ export interface BreakfastPackage extends z.output<typeof breakfastPackageSchema> {} export interface BreakfastProps { - packages: BreakfastPackages | null + packages: BreakfastPackages } diff --git a/types/components/hotelReservation/enterDetails/step.ts b/types/components/hotelReservation/enterDetails/step.ts index 45de5a009..8c8c967ef 100644 --- a/types/components/hotelReservation/enterDetails/step.ts +++ b/types/components/hotelReservation/enterDetails/step.ts @@ -1,9 +1,4 @@ -export enum StepEnum { - selectBed = "select-bed", - breakfast = "breakfast", - details = "details", - payment = "payment", -} +import { StepEnum } from "@/types/enums/step" export const StepStoreKeys: Record<StepEnum, "bedType" | "breakfast" | null> = { "select-bed": "bedType", diff --git a/types/components/hotelReservation/enterDetails/store.ts b/types/components/hotelReservation/enterDetails/store.ts deleted file mode 100644 index 45dbd5f75..000000000 --- a/types/components/hotelReservation/enterDetails/store.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { StepEnum } from "./step" - -export type EnterDetailsProviderProps = { step: StepEnum; isMember: boolean } diff --git a/types/components/hotelReservation/enterDetails/summary.ts b/types/components/hotelReservation/enterDetails/summary.ts new file mode 100644 index 000000000..901113414 --- /dev/null +++ b/types/components/hotelReservation/enterDetails/summary.ts @@ -0,0 +1,6 @@ +import type { RoomsData } from "./bookingData" + +export interface SummaryProps { + showMemberPrice: boolean + room: RoomsData +} diff --git a/types/components/hotelReservation/selectRate/sectionAccordion.ts b/types/components/hotelReservation/selectRate/sectionAccordion.ts index 46b194db3..c50207f3a 100644 --- a/types/components/hotelReservation/selectRate/sectionAccordion.ts +++ b/types/components/hotelReservation/selectRate/sectionAccordion.ts @@ -1,4 +1,4 @@ -import { StepEnum } from "../enterDetails/step" +import { StepEnum } from "@/types/enums/step" export interface SectionAccordionProps { header: string diff --git a/types/contexts/details.ts b/types/contexts/details.ts new file mode 100644 index 000000000..ea6b65edd --- /dev/null +++ b/types/contexts/details.ts @@ -0,0 +1,3 @@ +import { createDetailsStore } from "@/stores/details" + +export type DetailsStore = ReturnType<typeof createDetailsStore> diff --git a/types/contexts/steps.ts b/types/contexts/steps.ts new file mode 100644 index 000000000..40c3cb55e --- /dev/null +++ b/types/contexts/steps.ts @@ -0,0 +1,3 @@ +import { createStepsStore } from "@/stores/steps" + +export type StepsStore = ReturnType<typeof createStepsStore> diff --git a/types/enums/breakfast.ts b/types/enums/breakfast.ts index 81ff51a2e..723326c37 100644 --- a/types/enums/breakfast.ts +++ b/types/enums/breakfast.ts @@ -1,5 +1,4 @@ export enum BreakfastPackageEnum { FREE_MEMBER_BREAKFAST = "BRF0", REGULAR_BREAKFAST = "BRF1", - NO_BREAKFAST = "NO_BREAKFAST", } diff --git a/types/enums/step.ts b/types/enums/step.ts new file mode 100644 index 000000000..e52d3c856 --- /dev/null +++ b/types/enums/step.ts @@ -0,0 +1,6 @@ +export enum StepEnum { + selectBed = "select-bed", + breakfast = "breakfast", + details = "details", + payment = "payment", +} diff --git a/types/providers/details.ts b/types/providers/details.ts new file mode 100644 index 000000000..c58effb2c --- /dev/null +++ b/types/providers/details.ts @@ -0,0 +1,3 @@ +export interface DetailsProviderProps extends React.PropsWithChildren { + isMember: boolean +} diff --git a/types/providers/steps.ts b/types/providers/steps.ts new file mode 100644 index 000000000..8c24fdc8f --- /dev/null +++ b/types/providers/steps.ts @@ -0,0 +1,10 @@ +import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" +import { StepEnum } from "@/types/enums/step" +import type { BreakfastPackage } from "../components/hotelReservation/enterDetails/breakfast" + +export interface StepsProviderProps extends React.PropsWithChildren { + bedTypes: BedTypeSelection[] + breakfastPackages: BreakfastPackage[] | null + isMember: boolean + step: StepEnum +} diff --git a/types/stores/details.ts b/types/stores/details.ts new file mode 100644 index 000000000..ef6d101dc --- /dev/null +++ b/types/stores/details.ts @@ -0,0 +1,40 @@ +import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" +import type { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData" +import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" +import type { DetailsSchema } from "@/types/components/hotelReservation/enterDetails/details" +import { StepEnum } from "@/types/enums/step" + +export interface DetailsState { + actions: { + setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void + setTotalPrice: (totalPrice: TotalPrice) => void + toggleSummaryOpen: () => void, + updateBedType: (data: BedTypeSchema) => void + updateBreakfast: (data: BreakfastPackage | false) => void + updateDetails: (data: DetailsSchema) => void + updateValidity: (property: StepEnum, isValid: boolean) => void + } + data: DetailsSchema & { + bedType: BedTypeSchema | undefined + breakfast: BreakfastPackage | false | undefined + booking: BookingData + } + isSubmittingDisabled: boolean + isSummaryOpen: boolean + isValid: Record<StepEnum, boolean> + totalPrice: TotalPrice +} + +export interface InitialState extends Partial<DetailsState> { + booking: BookingData +} + +interface Price { + currency: string + price: number +} + +export interface TotalPrice { + euro: Price | undefined + local: Price +} \ No newline at end of file diff --git a/types/stores/steps.ts b/types/stores/steps.ts new file mode 100644 index 000000000..bfdafdae7 --- /dev/null +++ b/types/stores/steps.ts @@ -0,0 +1,10 @@ +import { StepEnum } from "@/types/enums/step" + +export interface StepState { + completeStep: () => void + navigate: (step: StepEnum) => void + setStep: (step: StepEnum) => void + + currentStep: StepEnum + steps: StepEnum[] +}