Merged in feat/SW-784 (pull request #914)
feat: make steps of enter details flow dynamic depending on data Approved-by: Arvid Norlin
This commit is contained in:
@@ -1 +0,0 @@
|
||||
export { default } from "../page"
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export default async function SelectHotelPage({
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="burgundy"
|
||||
href={selectHotelMap[params.lang]}
|
||||
href={selectHotelMap(params.lang)}
|
||||
keepSearchParams
|
||||
>
|
||||
<div className={styles.mapContainer}>
|
||||
|
||||
@@ -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;
|
||||
@@ -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<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 (
|
||||
<header className={styles.hotelSelectionHeader}>
|
||||
<div className={styles.hotelSelectionHeaderWrapper}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h3" level="h1">
|
||||
{hotel.name}
|
||||
@@ -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 =
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")),
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3) 0px;
|
||||
}
|
||||
|
||||
.container {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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 >
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function SelectHotelMap({
|
||||
}
|
||||
|
||||
function handlePageRedirect() {
|
||||
router.push(`${selectHotel[lang]}?${searchParams.toString()}`)
|
||||
router.push(`${selectHotel(lang)}?${searchParams.toString()}`)
|
||||
}
|
||||
|
||||
const closeButton = (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
5
contexts/Details.ts
Normal file
5
contexts/Details.ts
Normal 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
5
contexts/Steps.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createContext } from "react"
|
||||
|
||||
import type { StepsStore } from "@/types/contexts/steps"
|
||||
|
||||
export const StepsContext = createContext<StepsStore | null>(null)
|
||||
11
hooks/useSetOverflowVisibleOnRA.ts
Normal file
11
hooks/useSetOverflowVisibleOnRA.ts
Normal 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
|
||||
}
|
||||
@@ -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)/
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
30
providers/DetailsProvider.tsx
Normal file
30
providers/DetailsProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
providers/StepsProvider.tsx
Normal file
53
providers/StepsProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
server/routers/hotels/schemas/packages.ts
Normal file
62
server/routers/hotels/schemas/packages.ts
Normal 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
195
stores/details.ts
Normal 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)
|
||||
}
|
||||
@@ -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
159
stores/steps.ts
Normal 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)
|
||||
}
|
||||
@@ -17,5 +17,5 @@ export interface BreakfastPackage
|
||||
extends z.output<typeof breakfastPackageSchema> {}
|
||||
|
||||
export interface BreakfastProps {
|
||||
packages: BreakfastPackages | null
|
||||
packages: BreakfastPackages
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { StepEnum } from "./step"
|
||||
|
||||
export type EnterDetailsProviderProps = { step: StepEnum; isMember: boolean }
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { RoomsData } from "./bookingData"
|
||||
|
||||
export interface SummaryProps {
|
||||
showMemberPrice: boolean
|
||||
room: RoomsData
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { StepEnum } from "../enterDetails/step"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
|
||||
export interface SectionAccordionProps {
|
||||
header: string
|
||||
|
||||
3
types/contexts/details.ts
Normal file
3
types/contexts/details.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createDetailsStore } from "@/stores/details"
|
||||
|
||||
export type DetailsStore = ReturnType<typeof createDetailsStore>
|
||||
3
types/contexts/steps.ts
Normal file
3
types/contexts/steps.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createStepsStore } from "@/stores/steps"
|
||||
|
||||
export type StepsStore = ReturnType<typeof createStepsStore>
|
||||
@@ -1,5 +1,4 @@
|
||||
export enum BreakfastPackageEnum {
|
||||
FREE_MEMBER_BREAKFAST = "BRF0",
|
||||
REGULAR_BREAKFAST = "BRF1",
|
||||
NO_BREAKFAST = "NO_BREAKFAST",
|
||||
}
|
||||
|
||||
6
types/enums/step.ts
Normal file
6
types/enums/step.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum StepEnum {
|
||||
selectBed = "select-bed",
|
||||
breakfast = "breakfast",
|
||||
details = "details",
|
||||
payment = "payment",
|
||||
}
|
||||
3
types/providers/details.ts
Normal file
3
types/providers/details.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface DetailsProviderProps extends React.PropsWithChildren {
|
||||
isMember: boolean
|
||||
}
|
||||
10
types/providers/steps.ts
Normal file
10
types/providers/steps.ts
Normal 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
40
types/stores/details.ts
Normal 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
10
types/stores/steps.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user