feat: make steps of enter details flow dynamic depending on data

This commit is contained in:
Simon Emanuelsson
2024-11-18 09:13:23 +01:00
parent 3c4907efce
commit 94f693c4f0
62 changed files with 959 additions and 659 deletions

View File

@@ -1 +0,0 @@
export { default } from "../page"

View File

@@ -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<LangParams, { hotel: string }>) {
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 <HotelSelectionHeader hotel={hotel.data.attributes} />
}

View File

@@ -86,7 +86,7 @@ export default async function SelectHotelPage({
<Link <Link
className={styles.link} className={styles.link}
color="burgundy" color="burgundy"
href={selectHotelMap[params.lang]} href={selectHotelMap(params.lang)}
keepSearchParams keepSearchParams
> >
<div className={styles.mapContainer}> <div className={styles.mapContainer}>

View File

@@ -1,9 +1,9 @@
.hotelSelectionHeader { .header {
background-color: var(--Base-Surface-Subtle-Normal); background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x3) var(--Spacing-x2); padding: var(--Spacing-x3) var(--Spacing-x2);
} }
.hotelSelectionHeaderWrapper { .wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--Spacing-x3); gap: var(--Spacing-x3);
@@ -35,11 +35,11 @@
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.hotelSelectionHeader { .header {
padding: var(--Spacing-x4) 0; padding: var(--Spacing-x4) 0;
} }
.hotelSelectionHeaderWrapper { .wrapper {
flex-direction: row; flex-direction: row;
gap: var(--Spacing-x6); gap: var(--Spacing-x6);
margin: 0 auto; margin: 0 auto;

View File

@@ -1,23 +1,38 @@
"use client" import { redirect } from "next/navigation"
import { useIntl } from "react-intl"
import { getHotelData } from "@/lib/trpc/memoizedRequests"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title" 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({ export default async function HotelHeader({
hotel, params,
}: HotelSelectionHeaderProps) { searchParams,
const intl = useIntl() }: PageArgs<LangParams, { hotel: string }>) {
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 ( return (
<header className={styles.hotelSelectionHeader}> <header className={styles.header}>
<div className={styles.hotelSelectionHeaderWrapper}> <div className={styles.wrapper}>
<div className={styles.titleContainer}> <div className={styles.titleContainer}>
<Title as="h3" level="h1"> <Title as="h3" level="h1">
{hotel.name} {hotel.name}

View File

@@ -61,7 +61,7 @@ export default async function SummaryPage({
if (!availability || !availability.selectedRoom) { if (!availability || !availability.selectedRoom) {
console.error("No hotel or availability data", availability) console.error("No hotel or availability data", availability)
// TODO: handle this case // TODO: handle this case
redirect(selectRate[params.lang]) redirect(selectRate(params.lang))
} }
const prices = const prices =

View File

@@ -1,20 +1,19 @@
import { getProfileSafely } from "@/lib/trpc/memoizedRequests" import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import DetailsProvider from "@/providers/DetailsProvider"
import { preload } from "./_preload" import { preload } from "./_preload"
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
import type { LangParams, LayoutArgs } from "@/types/params" import type { LangParams, LayoutArgs } from "@/types/params"
export default async function StepLayout({ export default async function StepLayout({
summary,
children, children,
hotelHeader, hotelHeader,
params, params,
summary,
}: React.PropsWithChildren< }: React.PropsWithChildren<
LayoutArgs<LangParams & { step: StepEnum }> & { LayoutArgs<LangParams> & {
hotelHeader: React.ReactNode hotelHeader: React.ReactNode
summary: React.ReactNode summary: React.ReactNode
} }
@@ -25,7 +24,7 @@ export default async function StepLayout({
const user = await getProfileSafely() const user = await getProfileSafely()
return ( return (
<EnterDetailsProvider step={params.step} isMember={!!user}> <DetailsProvider isMember={!!user}>
<main className="enter-details-layout__layout"> <main className="enter-details-layout__layout">
{hotelHeader} {hotelHeader}
<div className={"enter-details-layout__container"}> <div className={"enter-details-layout__container"}>
@@ -35,6 +34,6 @@ export default async function StepLayout({
</aside> </aside>
</div> </div>
</main> </main>
</EnterDetailsProvider> </DetailsProvider>
) )
} }

View File

@@ -1,6 +1,6 @@
import "./enterDetailsLayout.css" import "./enterDetailsLayout.css"
import { notFound } from "next/navigation" import { notFound, redirect, RedirectType } from "next/navigation"
import { import {
getBreakfastPackages, getBreakfastPackages,
@@ -22,9 +22,10 @@ import {
getQueryParamsForEnterDetails, getQueryParamsForEnterDetails,
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils" } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { getIntl } from "@/i18n" 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 { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { StepEnum } from "@/types/enums/step"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
function isValidStep(step: string): step is StepEnum { function isValidStep(step: string): step is StepEnum {
@@ -32,11 +33,9 @@ function isValidStep(step: string): step is StepEnum {
} }
export default async function StepPage({ export default async function StepPage({
params, params: { lang },
searchParams, searchParams,
}: PageArgs<LangParams & { step: StepEnum }, SelectRateSearchParams>) { }: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) {
const { lang } = params
const intl = await getIntl() const intl = await getIntl()
const selectRoomParams = new URLSearchParams(searchParams) const selectRoomParams = new URLSearchParams(searchParams)
const { const {
@@ -88,7 +87,7 @@ export default async function StepPage({
const user = await getProfileSafely() const user = await getProfileSafely()
const savedCreditCards = await getCreditCardsSafely() const savedCreditCards = await getCreditCardsSafely()
if (!isValidStep(params.step) || !hotelData || !roomAvailability) { if (!isValidStep(searchParams.step) || !hotelData || !roomAvailability) {
return notFound() return notFound()
} }
@@ -113,54 +112,65 @@ export default async function StepPage({
} }
return ( return (
<section> <StepsProvider
<HistoryStateManager /> bedTypes={roomAvailability.bedTypes}
<SelectedRoom breakfastPackages={breakfastPackages}
hotelId={hotelId} isMember={!!user}
room={roomAvailability.selectedRoom} step={searchParams.step}
rateDescription={roomAvailability.cancellationText} >
/> <section>
<HistoryStateManager />
{/* TODO: How to handle no beds found? */} <SelectedRoom
{roomAvailability.bedTypes ? ( hotelId={hotelId}
<SectionAccordion room={roomAvailability.selectedRoom}
header="Select bed" rateDescription={roomAvailability.cancellationText}
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}
/> />
</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>
) )
} }

View File

@@ -26,7 +26,7 @@ export async function GET(
const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER) const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER)
if (status === "success" && confirmationNumber) { if (status === "success" && confirmationNumber) {
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation[lang]}`) const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation(lang)}`)
confirmationUrl.searchParams.set( confirmationUrl.searchParams.set(
BOOKING_CONFIRMATION_NUMBER, BOOKING_CONFIRMATION_NUMBER,
confirmationNumber confirmationNumber
@@ -36,7 +36,7 @@ export async function GET(
return NextResponse.redirect(confirmationUrl) return NextResponse.redirect(confirmationUrl)
} }
const returnUrl = new URL(`${publicURL}/${payment[lang]}`) const returnUrl = new URL(`${publicURL}/${payment(lang)}`)
returnUrl.search = queryParams.toString() returnUrl.search = queryParams.toString()
if (confirmationNumber) { if (confirmationNumber) {

View File

@@ -35,7 +35,7 @@ export default function Form({
const locationData: Location = JSON.parse(decodeURIComponent(data.location)) const locationData: Location = JSON.parse(decodeURIComponent(data.location))
const bookingFlowPage = const bookingFlowPage =
locationData.type == "cities" ? selectHotel[lang] : selectRate[lang] locationData.type == "cities" ? selectHotel(lang) : selectRate(lang)
const bookingWidgetParams = new URLSearchParams(data.date) const bookingWidgetParams = new URLSearchParams(data.date)
if (locationData.type == "cities") if (locationData.type == "cities")

View File

@@ -1,7 +1,6 @@
.form { .form {
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
padding-bottom: var(--Spacing-x3);
width: min(600px, 100%); width: min(600px, 100%);
} }

View File

@@ -4,7 +4,8 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect } from "react" import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form" 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 { KingBedIcon } from "@/components/Icons"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -19,22 +20,18 @@ import type {
} from "@/types/components/hotelReservation/enterDetails/bedType" } from "@/types/components/hotelReservation/enterDetails/bedType"
export default function BedType({ bedTypes }: BedTypeProps) { 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>({ const methods = useForm<BedTypeFormSchema>({
defaultValues: bedType?.roomTypeCode defaultValues: bedType ? { bedType } : undefined,
? {
bedType: bedType.roomTypeCode,
}
: undefined,
criteriaMode: "all", criteriaMode: "all",
mode: "all", mode: "all",
resolver: zodResolver(bedTypeFormSchema), resolver: zodResolver(bedTypeFormSchema),
reValidateMode: "onChange", reValidateMode: "onChange",
}) })
const completeStep = useEnterDetailsStore((state) => state.completeStep)
const onSubmit = useCallback( const onSubmit = useCallback(
(bedTypeRoomCode: BedTypeFormSchema) => { (bedTypeRoomCode: BedTypeFormSchema) => {
const matchingRoom = bedTypes.find( const matchingRoom = bedTypes.find(
@@ -45,10 +42,11 @@ export default function BedType({ bedTypes }: BedTypeProps) {
description: matchingRoom.description, description: matchingRoom.description,
roomTypeCode: matchingRoom.value, roomTypeCode: matchingRoom.value,
} }
completeStep({ bedType }) updateBedType(bedType)
completeStep()
} }
}, },
[completeStep, bedTypes] [bedTypes, completeStep, updateBedType]
) )
useEffect(() => { useEffect(() => {

View File

@@ -2,6 +2,5 @@
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
padding-bottom: var(--Spacing-x3);
width: min(600px, 100%); width: min(600px, 100%);
} }

View File

@@ -5,7 +5,8 @@ import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form" import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" 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 { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -23,34 +24,37 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Breakfast({ packages }: BreakfastProps) { export default function Breakfast({ packages }: BreakfastProps) {
const intl = useIntl() 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>({ const methods = useForm<BreakfastFormSchema>({
defaultValues, defaultValues: breakfast ? { breakfast } : undefined,
criteriaMode: "all", criteriaMode: "all",
mode: "all", mode: "all",
resolver: zodResolver(breakfastFormSchema), resolver: zodResolver(breakfastFormSchema),
reValidateMode: "onChange", reValidateMode: "onChange",
}) })
const completeStep = useEnterDetailsStore((state) => state.completeStep)
const onSubmit = useCallback( const onSubmit = useCallback(
(values: BreakfastFormSchema) => { (values: BreakfastFormSchema) => {
const pkg = packages?.find((p) => p.code === values.breakfast) const pkg = packages?.find((p) => p.code === values.breakfast)
if (pkg) { if (pkg) {
completeStep({ breakfast: pkg }) updateBreakfast(pkg)
} else { } else {
completeStep({ breakfast: BreakfastPackageEnum.NO_BREAKFAST }) updateBreakfast(false)
} }
completeStep()
}, },
[completeStep, packages] [completeStep, packages, updateBreakfast]
) )
useEffect(() => { useEffect(() => {
@@ -61,10 +65,6 @@ export default function Breakfast({ packages }: BreakfastProps) {
return () => subscription.unsubscribe() return () => subscription.unsubscribe()
}, [methods, onSubmit]) }, [methods, onSubmit])
if (!packages) {
return null
}
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}> <form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
@@ -100,7 +100,6 @@ export default function Breakfast({ packages }: BreakfastProps) {
/> />
))} ))}
<RadioCard <RadioCard
id={BreakfastPackageEnum.NO_BREAKFAST}
name="breakfast" name="breakfast"
subtitle={intl.formatMessage( subtitle={intl.formatMessage(
{ id: "{amount} {currency}" }, { 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.", id: "You can always change your mind later and add breakfast at the hotel.",
})} })}
title={intl.formatMessage({ id: "No breakfast" })} title={intl.formatMessage({ id: "No breakfast" })}
value={BreakfastPackageEnum.NO_BREAKFAST} value="false"
/> />
</form> </form>
</FormProvider> </FormProvider>

View File

@@ -2,14 +2,10 @@ import { z } from "zod"
import { breakfastPackageSchema } from "@/server/routers/hotels/output" import { breakfastPackageSchema } from "@/server/routers/hotels/output"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export const breakfastStoreSchema = z.object({ export const breakfastStoreSchema = z.object({
breakfast: breakfastPackageSchema.or( breakfast: breakfastPackageSchema.or(z.literal(false)),
z.literal(BreakfastPackageEnum.NO_BREAKFAST)
),
}) })
export const breakfastFormSchema = z.object({ export const breakfastFormSchema = z.object({
breakfast: z.string().or(z.literal(BreakfastPackageEnum.NO_BREAKFAST)), breakfast: z.string().or(z.literal("false")),
}) })

View File

@@ -1,7 +1,6 @@
.form { .form {
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
padding: var(--Spacing-x3) 0px;
} }
.container { .container {

View File

@@ -1,9 +1,11 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback } from "react"
import { FormProvider, useForm } from "react-hook-form" import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" 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 Button from "@/components/TempDesignSystem/Button"
import CountrySelect from "@/components/TempDesignSystem/Form/Country" import CountrySelect from "@/components/TempDesignSystem/Form/Country"
@@ -24,19 +26,22 @@ import type {
const formID = "enter-details" const formID = "enter-details"
export default function Details({ user }: DetailsProps) { export default function Details({ user }: DetailsProps) {
const intl = useIntl() const intl = useIntl()
const initialData = useEnterDetailsStore((state) => ({ const initialData = useDetailsStore((state) => ({
countryCode: state.userData.countryCode, countryCode: state.data.countryCode,
email: state.userData.email, email: state.data.email,
firstName: state.userData.firstName, firstName: state.data.firstName,
lastName: state.userData.lastName, lastName: state.data.lastName,
phoneNumber: state.userData.phoneNumber, phoneNumber: state.data.phoneNumber,
join: state.userData.join, join: state.data.join,
dateOfBirth: state.userData.dateOfBirth, dateOfBirth: state.data.dateOfBirth,
zipCode: state.userData.zipCode, zipCode: state.data.zipCode,
termsAccepted: state.userData.termsAccepted, termsAccepted: state.data.termsAccepted,
membershipNo: state.userData.membershipNo, membershipNo: state.data.membershipNo,
})) }))
const updateDetails = useDetailsStore((state) => state.actions.updateDetails)
const completeStep = useStepsStore((state) => state.completeStep)
const methods = useForm<DetailsSchema>({ const methods = useForm<DetailsSchema>({
defaultValues: { defaultValues: {
countryCode: user?.address?.countryCode ?? initialData.countryCode, countryCode: user?.address?.countryCode ?? initialData.countryCode,
@@ -56,14 +61,20 @@ export default function Details({ user }: DetailsProps) {
reValidateMode: "onChange", reValidateMode: "onChange",
}) })
const completeStep = useEnterDetailsStore((state) => state.completeStep) const onSubmit = useCallback(
(values: DetailsSchema) => {
updateDetails(values)
completeStep()
},
[completeStep, updateDetails]
)
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form <form
className={styles.form} className={styles.form}
id={formID} id={formID}
onSubmit={methods.handleSubmit(completeStep)} onSubmit={methods.handleSubmit(onSubmit)}
> >
{user ? null : <Signup name="join" />} {user ? null : <Signup name="join" />}
<Footnote <Footnote
@@ -107,7 +118,7 @@ export default function Details({ user }: DetailsProps) {
readOnly={!!user} readOnly={!!user}
registerOptions={{ required: true }} registerOptions={{ required: true }}
/> />
{user ? null : ( {user || methods.watch("join") ? null : (
<Input <Input
className={styles.membershipNo} className={styles.membershipNo}
label={intl.formatMessage({ id: "Membership no" })} label={intl.formatMessage({ id: "Membership no" })}
@@ -119,7 +130,6 @@ export default function Details({ user }: DetailsProps) {
<footer className={styles.footer}> <footer className={styles.footer}>
<Button <Button
disabled={!methods.formState.isValid} disabled={!methods.formState.isValid}
form={formID}
intent="secondary" intent="secondary"
size="small" size="small"
theme="base" theme="base"

View File

@@ -2,11 +2,11 @@
import { useCallback, useEffect } from "react" import { useCallback, useEffect } from "react"
import { useEnterDetailsStore } from "@/stores/enter-details" import { useStepsStore } from "@/stores/steps"
export default function HistoryStateManager() { export default function HistoryStateManager() {
const setCurrentStep = useEnterDetailsStore((state) => state.setCurrentStep) const setCurrentStep = useStepsStore((state) => state.setStep)
const currentStep = useEnterDetailsStore((state) => state.currentStep) const currentStep = useStepsStore((state) => state.currentStep)
const handleBackButton = useCallback( const handleBackButton = useCallback(
(event: PopStateEvent) => { (event: PopStateEvent) => {

View File

@@ -18,7 +18,7 @@ import {
} from "@/constants/currentWebHrefs" } from "@/constants/currentWebHrefs"
import { env } from "@/env/client" import { env } from "@/env/client"
import { trpc } from "@/lib/trpc/client" import { trpc } from "@/lib/trpc/client"
import { useEnterDetailsStore } from "@/stores/enter-details" import { useDetailsStore } from "@/stores/details"
import LoadingSpinner from "@/components/LoadingSpinner" import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button" 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 { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section" import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
const maxRetries = 4 const maxRetries = 4
const retryInterval = 2000 const retryInterval = 2000
@@ -61,12 +60,9 @@ export default function Payment({
const lang = useLang() const lang = useLang()
const intl = useIntl() const intl = useIntl()
const queryParams = useSearchParams() const queryParams = useSearchParams()
const { userData, roomData, setIsSubmittingDisabled } = useEnterDetailsStore( const { booking, ...userData } = useDetailsStore((state) => state.data)
(state) => ({ const setIsSubmittingDisabled = useDetailsStore(
userData: state.userData, (state) => state.actions.setIsSubmittingDisabled
roomData: state.roomData,
setIsSubmittingDisabled: state.setIsSubmittingDisabled,
})
) )
const { const {
@@ -82,7 +78,7 @@ export default function Payment({
dateOfBirth, dateOfBirth,
zipCode, zipCode,
} = userData } = userData
const { toDate, fromDate, rooms: rooms, hotel } = roomData const { toDate, fromDate, rooms, hotel } = booking
const [confirmationNumber, setConfirmationNumber] = useState<string>("") const [confirmationNumber, setConfirmationNumber] = useState<string>("")
const [availablePaymentOptions, setAvailablePaymentOptions] = const [availablePaymentOptions, setAvailablePaymentOptions] =
@@ -204,7 +200,7 @@ export default function Payment({
postalCode: zipCode, postalCode: zipCode,
}, },
packages: { packages: {
breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST, breakfast: !!(breakfast && breakfast.code),
allergyFriendly: allergyFriendly:
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false, room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
petFriendly: petFriendly:

View File

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

View File

@@ -2,7 +2,8 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useIntl } from "react-intl" 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 { CheckIcon, ChevronDownIcon } from "@/components/Icons"
import Footnote from "@/components/TempDesignSystem/Text/Footnote" 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 styles from "./sectionAccordion.module.css"
import { import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step"
StepEnum,
StepStoreKeys,
} from "@/types/components/hotelReservation/enterDetails/step"
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion" import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { StepEnum } from "@/types/enums/step"
export default function SectionAccordion({ export default function SectionAccordion({
header, header,
@@ -24,12 +22,12 @@ export default function SectionAccordion({
children, children,
}: React.PropsWithChildren<SectionAccordionProps>) { }: React.PropsWithChildren<SectionAccordionProps>) {
const intl = useIntl() const intl = useIntl()
const currentStep = useEnterDetailsStore((state) => state.currentStep) const currentStep = useStepsStore((state) => state.currentStep)
const [isComplete, setIsComplete] = useState(false) const [isComplete, setIsComplete] = useState(false)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const isValid = useEnterDetailsStore((state) => state.isValid[step]) const isValid = useDetailsStore((state) => state.isValid[step])
const navigate = useEnterDetailsStore((state) => state.navigate) const navigate = useStepsStore((state) => state.navigate)
const stepData = useEnterDetailsStore((state) => state.userData) const stepData = useDetailsStore((state) => state.data)
const stepStoreKey = StepStoreKeys[step] const stepStoreKey = StepStoreKeys[step]
const [title, setTitle] = useState(label) const [title, setTitle] = useState(label)
@@ -39,9 +37,12 @@ export default function SectionAccordion({
value && setTitle(value.description) value && setTitle(value.description)
} }
// If breakfast step, check if an option has been selected // 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 const value = stepData.breakfast
if (value === BreakfastPackageEnum.NO_BREAKFAST) { if (value === false) {
setTitle(intl.formatMessage({ id: "No breakfast" })) setTitle(intl.formatMessage({ id: "No breakfast" }))
} else { } else {
setTitle(intl.formatMessage({ id: "Breakfast buffet" })) setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
@@ -94,7 +95,9 @@ export default function SectionAccordion({
)} )}
</button> </button>
</header> </header>
<div className={styles.content}>{children}</div> <div className={styles.content}>
<div className={styles.contentWrapper}>{children}</div>
</div>
</div> </div>
</section> </section>
) )

View File

@@ -31,7 +31,6 @@
.main { .main {
display: grid; display: grid;
gap: var(--Spacing-x3);
width: 100%; width: 100%;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
padding-bottom: var(--Spacing-x3); padding-bottom: var(--Spacing-x3);
@@ -80,6 +79,10 @@
overflow: hidden; overflow: hidden;
} }
.contentWrapper {
padding-top: var(--Spacing-x3);
}
@media screen and (min-width: 1367px) { @media screen and (min-width: 1367px) {
.wrapper { .wrapper {
gap: var(--Spacing-x3); gap: var(--Spacing-x3);

View File

@@ -2,12 +2,13 @@
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details" import { selectRate } from "@/constants/routes/hotelReservation"
import { CheckIcon, EditIcon } from "@/components/Icons" import { CheckIcon, EditIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import ToggleSidePeek from "./ToggleSidePeek" import ToggleSidePeek from "./ToggleSidePeek"
@@ -21,8 +22,7 @@ export default function SelectedRoom({
rateDescription, rateDescription,
}: SelectedRoomProps) { }: SelectedRoomProps) {
const intl = useIntl() const intl = useIntl()
const lang = useLang()
const selectRateUrl = useEnterDetailsStore((state) => state.selectRateUrl)
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
@@ -53,7 +53,8 @@ export default function SelectedRoom({
<Link <Link
className={styles.button} className={styles.button}
color="burgundy" color="burgundy"
href={selectRateUrl} href={selectRate(lang)}
keepSearchParams
size="small" size="small"
variant="icon" variant="icon"
> >

View File

@@ -3,7 +3,7 @@
import { PropsWithChildren } from "react" import { PropsWithChildren } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details" import { useDetailsStore } from "@/stores/details"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
@@ -17,9 +17,9 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) {
const intl = useIntl() const intl = useIntl()
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } = const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
useEnterDetailsStore((state) => ({ useDetailsStore((state) => ({
isSummaryOpen: state.isSummaryOpen, isSummaryOpen: state.isSummaryOpen,
toggleSummaryOpen: state.toggleSummaryOpen, toggleSummaryOpen: state.actions.toggleSummaryOpen,
totalPrice: state.totalPrice, totalPrice: state.totalPrice,
isSubmittingDisabled: state.isSubmittingDisabled, isSubmittingDisabled: state.isSubmittingDisabled,
})) }))

View File

@@ -5,7 +5,7 @@ import { ChevronDown } from "react-feather"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import { EnterDetailsState, useEnterDetailsStore } from "@/stores/enter-details" import { useDetailsStore } from "@/stores/details"
import { ArrowRightIcon } from "@/components/Icons" import { ArrowRightIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
@@ -18,45 +18,39 @@ import useLang from "@/hooks/useLang"
import styles from "./summary.module.css" import styles from "./summary.module.css"
import { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData" import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
import { BreakfastPackageEnum } from "@/types/enums/breakfast" import type { DetailsState } from "@/types/stores/details"
function storeSelector(state: EnterDetailsState) { function storeSelector(state: DetailsState) {
return { return {
fromDate: state.roomData.fromDate, fromDate: state.data.booking.fromDate,
toDate: state.roomData.toDate, toDate: state.data.booking.toDate,
bedType: state.userData.bedType, bedType: state.data.bedType,
breakfast: state.userData.breakfast, breakfast: state.data.breakfast,
toggleSummaryOpen: state.toggleSummaryOpen, toggleSummaryOpen: state.actions.toggleSummaryOpen,
setTotalPrice: state.setTotalPrice, setTotalPrice: state.actions.setTotalPrice,
totalPrice: state.totalPrice, totalPrice: state.totalPrice,
} }
} }
export default function Summary({ export default function Summary({ showMemberPrice, room }: SummaryProps) {
showMemberPrice,
room,
}: {
showMemberPrice: boolean
room: RoomsData
}) {
const [chosenBed, setChosenBed] = useState<BedTypeSchema>() const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
const [chosenBreakfast, setChosenBreakfast] = useState< const [chosenBreakfast, setChosenBreakfast] = useState<
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST BreakfastPackage | false
>() >()
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
const { const {
fromDate,
toDate,
bedType, bedType,
breakfast, breakfast,
fromDate,
setTotalPrice, setTotalPrice,
totalPrice, toDate,
toggleSummaryOpen, toggleSummaryOpen,
} = useEnterDetailsStore(storeSelector) totalPrice,
} = useDetailsStore(storeSelector)
const diff = dt(toDate).diff(fromDate, "days") const diff = dt(toDate).diff(fromDate, "days")
@@ -88,36 +82,39 @@ export default function Summary({
setChosenBed(bedType) setChosenBed(bedType)
setChosenBreakfast(breakfast) setChosenBreakfast(breakfast)
if (breakfast && breakfast !== BreakfastPackageEnum.NO_BREAKFAST) { if (breakfast || breakfast === false) {
setTotalPrice({ setChosenBreakfast(breakfast)
local: { if (breakfast === false) {
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice), setTotalPrice({
currency: room.localPrice.currency, local: {
}, price: roomsPriceLocal,
euro: currency: room.localPrice.currency,
room.euroPrice && roomsPriceEuro },
? { 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: price:
roomsPriceEuro + roomsPriceEuro +
parseInt(breakfast.requestedPrice.totalPrice), parseInt(breakfast.requestedPrice.totalPrice),
currency: room.euroPrice.currency, currency: room.euroPrice.currency,
} }
: undefined, : undefined,
}) })
} else { }
setTotalPrice({
local: {
price: roomsPriceLocal,
currency: room.localPrice.currency,
},
euro:
room.euroPrice && roomsPriceEuro
? {
price: roomsPriceEuro,
currency: room.euroPrice.currency,
}
: undefined,
})
} }
}, [ }, [
bedType, bedType,
@@ -187,24 +184,24 @@ export default function Summary({
</div> </div>
{room.packages {room.packages
? room.packages.map((roomPackage) => ( ? room.packages.map((roomPackage) => (
<div className={styles.entry} key={roomPackage.code}> <div className={styles.entry} key={roomPackage.code}>
<div> <div>
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">
{roomPackage.description} {roomPackage.description}
</Body> </Body>
</div>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: roomPackage.localPrice.price,
currency: roomPackage.localPrice.currency,
}
)}
</Caption>
</div> </div>
))
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: roomPackage.localPrice.price,
currency: roomPackage.localPrice.currency,
}
)}
</Caption>
</div>
))
: null} : null}
{chosenBed ? ( {chosenBed ? (
<div className={styles.entry}> <div className={styles.entry}>
@@ -224,37 +221,36 @@ export default function Summary({
</div> </div>
) : null} ) : null}
{chosenBreakfast ? ( {chosenBreakfast === false ? (
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? ( <div className={styles.entry}>
<div className={styles.entry}> <Body color="uiTextHighContrast">
<Body color="uiTextHighContrast"> {intl.formatMessage({ id: "No breakfast" })}
{intl.formatMessage({ id: "No breakfast" })} </Body>
</Body> <Caption color="uiTextMediumContrast">
<Caption color="uiTextHighContrast"> {intl.formatMessage(
{intl.formatMessage( { id: "{amount} {currency}" },
{ id: "{amount} {currency}" }, { amount: "0", currency: room.localPrice.currency }
{ amount: "0", currency: room.localPrice.currency } )}
)} </Caption>
</Caption> </div>
</div> ) : chosenBreakfast?.code ? (
) : ( <div className={styles.entry}>
<div className={styles.entry}> <Body color="uiTextHighContrast">
<Body color="uiTextHighContrast"> {intl.formatMessage({ id: "Breakfast buffet" })}
{intl.formatMessage({ id: "Breakfast buffet" })} </Body>
</Body> <Caption color="uiTextMediumContrast">
<Caption color="uiTextHighContrast"> {intl.formatMessage(
{intl.formatMessage( { id: "{amount} {currency}" },
{ id: "{amount} {currency}" }, {
{ amount: chosenBreakfast.localPrice.totalPrice,
amount: chosenBreakfast.localPrice.totalPrice, currency: chosenBreakfast.localPrice.currency,
currency: chosenBreakfast.localPrice.currency, }
} )}
)} </Caption>
</Caption> </div>
</div> ) : null
) }
) : null} </div >
</div>
<Divider color="primaryLightSubtle" /> <Divider color="primaryLightSubtle" />
<div className={styles.total}> <div className={styles.total}>
<div className={styles.entry}> <div className={styles.entry}>
@@ -295,6 +291,6 @@ export default function Summary({
</div> </div>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" /> <Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div> </div>
</section> </section >
) )
} }

View File

@@ -39,7 +39,7 @@ export default function HotelPriceList({
className={styles.button} className={styles.button}
> >
<Link <Link
href={`${selectRate[lang]}?hotel=${hotelId}`} href={`${selectRate(lang)}?hotel=${hotelId}`}
color="none" color="none"
keepSearchParams keepSearchParams
> >

View File

@@ -93,7 +93,7 @@ export default function HotelCard({
</address> </address>
<Link <Link
className={styles.addressMobile} className={styles.addressMobile}
href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`} href={`${selectHotelMap(lang)}?selectedHotel=${hotelData.name}`}
keepSearchParams keepSearchParams
> >
<Caption color="baseTextMediumContrast" type="underline"> <Caption color="baseTextMediumContrast" type="underline">

View File

@@ -104,7 +104,7 @@ export default function HotelCardDialog({
<Button asChild theme="base" size="small" className={styles.button}> <Button asChild theme="base" size="small" className={styles.button}>
<Link <Link
href={`${selectRate[lang]}?hotel=${data.operaId}`} href={`${selectRate(lang)}?hotel=${data.operaId}`}
color="none" color="none"
keepSearchParams keepSearchParams
> >

View File

@@ -27,7 +27,7 @@ export default function MobileMapButtonContainer({
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
<Button asChild variant="icon" intent="secondary" size="small"> <Button asChild variant="icon" intent="secondary" size="small">
<Link <Link
href={`${selectHotelMap[lang]}`} href={selectHotelMap(lang)}
keepSearchParams keepSearchParams
color="burgundy" color="burgundy"
> >

View File

@@ -71,7 +71,7 @@ export default function SelectHotelMap({
} }
function handlePageRedirect() { function handlePageRedirect() {
router.push(`${selectHotel[lang]}?${searchParams.toString()}`) router.push(`${selectHotel(lang)}?${searchParams.toString()}`)
} }
const closeButton = ( const closeButton = (

View File

@@ -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
}

View File

@@ -15,6 +15,7 @@ export default function Card({
iconHeight = 32, iconHeight = 32,
iconWidth = 32, iconWidth = 32,
declined = false, declined = false,
defaultChecked,
highlightSubtitle = false, highlightSubtitle = false,
id, id,
list, list,
@@ -45,6 +46,7 @@ export default function Card({
<input <input
{...register(name)} {...register(name)}
aria-hidden aria-hidden
defaultChecked={defaultChecked}
id={id || name} id={id || name}
hidden hidden
type={type} type={type}

View File

@@ -12,6 +12,7 @@ import {
import Label from "@/components/TempDesignSystem/Form/Label" import Label from "@/components/TempDesignSystem/Form/Label"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import useSetOverflowVisibleOnRA from "@/hooks/useSetOverflowVisibleOnRA"
import SelectChevron from "../Form/SelectChevron" import SelectChevron from "../Form/SelectChevron"
@@ -39,6 +40,7 @@ export default function Select({
discreet = false, discreet = false,
}: SelectProps) { }: SelectProps) {
const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined) const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined)
const setOverflowVisible = useSetOverflowVisibleOnRA()
function setRef(node: SelectPortalContainerArgs) { function setRef(node: SelectPortalContainerArgs) {
if (node) { if (node) {
@@ -60,6 +62,7 @@ export default function Select({
onSelectionChange={handleOnSelect} onSelectionChange={handleOnSelect}
placeholder={placeholder} placeholder={placeholder}
selectedKey={value as Key} selectedKey={value as Key}
onOpenChange={setOverflowVisible}
> >
<Body asChild fontOnly> <Body asChild fontOnly>
<Button className={styles.input} data-testid={name}> <Button className={styles.input} data-testid={name}>

View File

@@ -1,97 +1,59 @@
/** @type {import('@/types/routes').LangRoute} */ /**
export const hotelReservation = { * @typedef {import('@/constants/languages').Lang} Lang
en: "/en/hotelreservation", */
sv: "/sv/hotelreservation",
no: "/no/hotelreservation", /**
fi: "/fi/hotelreservation", * @param {Lang} lang
da: "/da/hotelreservation", */
de: "/de/hotelreservation", function base(lang) {
return `/${lang}/hotelreservation`
} }
export const selectHotel = { /**
en: `${hotelReservation.en}/select-hotel`, * @param {Lang} lang
sv: `${hotelReservation.sv}/select-hotel`, */
no: `${hotelReservation.no}/select-hotel`, export function bookingConfirmation(lang) {
fi: `${hotelReservation.fi}/select-hotel`, return `${base(lang)}/booking-confirmation`
da: `${hotelReservation.da}/select-hotel`,
de: `${hotelReservation.de}/select-hotel`,
} }
export const selectRate = { /**
en: `${hotelReservation.en}/select-rate`, * @param {Lang} lang
sv: `${hotelReservation.sv}/select-rate`, */
no: `${hotelReservation.no}/select-rate`, export function details(lang) {
fi: `${hotelReservation.fi}/select-rate`, return `${base(lang)}/details`
da: `${hotelReservation.da}/select-rate`,
de: `${hotelReservation.de}/select-rate`,
} }
// TODO: Translate paths /**
export const selectBed = { * @param {Lang} lang
en: `${hotelReservation.en}/select-bed`, */
sv: `${hotelReservation.sv}/select-bed`, export function payment(lang) {
no: `${hotelReservation.no}/select-bed`, return `${base(lang)}/payment`
fi: `${hotelReservation.fi}/select-bed`,
da: `${hotelReservation.da}/select-bed`,
de: `${hotelReservation.de}/select-bed`,
} }
// TODO: Translate paths /**
export const breakfast = { * @param {Lang} lang
en: `${hotelReservation.en}/breakfast`, */
sv: `${hotelReservation.sv}/breakfast`, export function selectBed(lang) {
no: `${hotelReservation.no}/breakfast`, return `${base(lang)}/select-bed`
fi: `${hotelReservation.fi}/breakfast`,
da: `${hotelReservation.da}/breakfast`,
de: `${hotelReservation.de}/breakfast`,
} }
// TODO: Translate paths /**
export const details = { * @param {Lang} lang
en: `${hotelReservation.en}/details`, */
sv: `${hotelReservation.sv}/details`, export function selectHotel(lang) {
no: `${hotelReservation.no}/details`, return `${base(lang)}/select-hotel`
fi: `${hotelReservation.fi}/details`,
da: `${hotelReservation.da}/details`,
de: `${hotelReservation.de}/details`,
} }
// TODO: Translate paths /**
export const payment = { * @param {Lang} lang
en: `${hotelReservation.en}/payment`, */
sv: `${hotelReservation.sv}/payment`, export function selectHotelMap(lang) {
no: `${hotelReservation.no}/payment`, return `${base(lang)}/map`
fi: `${hotelReservation.fi}/payment`,
da: `${hotelReservation.da}/payment`,
de: `${hotelReservation.de}/payment`,
} }
export const selectHotelMap = { /**
en: `${selectHotel.en}/map`, * @param {Lang} lang
sv: `${selectHotel.sv}/map`, */
no: `${selectHotel.no}/map`, export function selectRate(lang) {
fi: `${selectHotel.fi}/map`, return `${base(lang)}/select-rate`
da: `${selectHotel.da}/map`,
de: `${selectHotel.de}/map`,
} }
/** @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),
]

5
contexts/Details.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createContext } from "react"
import type { DetailsStore } from "@/types/contexts/details"
export const DetailsContext = createContext<DetailsStore | null>(null)

5
contexts/Steps.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createContext } from "react"
import type { StepsStore } from "@/types/contexts/steps"
export const StepsContext = createContext<StepsStore | null>(null)

View File

@@ -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
}

View File

@@ -1,7 +1,5 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { bookingFlow } from "@/constants/routes/hotelReservation"
import { getDefaultRequestHeaders } from "./utils" import { getDefaultRequestHeaders } from "./utils"
import type { NextMiddleware } from "next/server" import type { NextMiddleware } from "next/server"
@@ -18,5 +16,7 @@ export const middleware: NextMiddleware = async (request) => {
} }
export const matcher: MiddlewareMatcher = (request) => { export const matcher: MiddlewareMatcher = (request) => {
return bookingFlow.includes(request.nextUrl.pathname) return !!request.nextUrl.pathname.match(
/^\/(da|de|en|fi|no|sv)\/(hotelreservation)/
)
} }

View File

@@ -277,6 +277,11 @@ const nextConfig = {
source: `${myPages.sv}/:path*`, source: `${myPages.sv}/:path*`,
destination: `/sv/my-pages/:path*`, destination: `/sv/my-pages/:path*`,
}, },
{
source:
"/:lang/hotelreservation/:step(breakfast|details|payment|select-bed)",
destination: "/:lang/hotelreservation/step?step=:step",
},
], ],
} }
}, },

View File

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

View File

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

View File

@@ -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)

195
stores/details.ts Normal file
View File

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

View File

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

159
stores/steps.ts Normal file
View File

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

View File

@@ -17,5 +17,5 @@ export interface BreakfastPackage
extends z.output<typeof breakfastPackageSchema> {} extends z.output<typeof breakfastPackageSchema> {}
export interface BreakfastProps { export interface BreakfastProps {
packages: BreakfastPackages | null packages: BreakfastPackages
} }

View File

@@ -1,9 +1,4 @@
export enum StepEnum { import { StepEnum } from "@/types/enums/step"
selectBed = "select-bed",
breakfast = "breakfast",
details = "details",
payment = "payment",
}
export const StepStoreKeys: Record<StepEnum, "bedType" | "breakfast" | null> = { export const StepStoreKeys: Record<StepEnum, "bedType" | "breakfast" | null> = {
"select-bed": "bedType", "select-bed": "bedType",

View File

@@ -1,3 +0,0 @@
import { StepEnum } from "./step"
export type EnterDetailsProviderProps = { step: StepEnum; isMember: boolean }

View File

@@ -0,0 +1,6 @@
import type { RoomsData } from "./bookingData"
export interface SummaryProps {
showMemberPrice: boolean
room: RoomsData
}

View File

@@ -1,4 +1,4 @@
import { StepEnum } from "../enterDetails/step" import { StepEnum } from "@/types/enums/step"
export interface SectionAccordionProps { export interface SectionAccordionProps {
header: string header: string

View File

@@ -0,0 +1,3 @@
import { createDetailsStore } from "@/stores/details"
export type DetailsStore = ReturnType<typeof createDetailsStore>

3
types/contexts/steps.ts Normal file
View File

@@ -0,0 +1,3 @@
import { createStepsStore } from "@/stores/steps"
export type StepsStore = ReturnType<typeof createStepsStore>

View File

@@ -1,5 +1,4 @@
export enum BreakfastPackageEnum { export enum BreakfastPackageEnum {
FREE_MEMBER_BREAKFAST = "BRF0", FREE_MEMBER_BREAKFAST = "BRF0",
REGULAR_BREAKFAST = "BRF1", REGULAR_BREAKFAST = "BRF1",
NO_BREAKFAST = "NO_BREAKFAST",
} }

6
types/enums/step.ts Normal file
View File

@@ -0,0 +1,6 @@
export enum StepEnum {
selectBed = "select-bed",
breakfast = "breakfast",
details = "details",
payment = "payment",
}

View File

@@ -0,0 +1,3 @@
export interface DetailsProviderProps extends React.PropsWithChildren {
isMember: boolean
}

10
types/providers/steps.ts Normal file
View File

@@ -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
}

40
types/stores/details.ts Normal file
View File

@@ -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
}

10
types/stores/steps.ts Normal file
View File

@@ -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[]
}