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:
Simon.Emanuelsson
2024-11-18 15:33:58 +00:00
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
className={styles.link}
color="burgundy"
href={selectHotelMap[params.lang]}
href={selectHotelMap(params.lang)}
keepSearchParams
>
<div className={styles.mapContainer}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,7 +71,7 @@ export default function SelectHotelMap({
}
function handlePageRedirect() {
router.push(`${selectHotel[lang]}?${searchParams.toString()}`)
router.push(`${selectHotel(lang)}?${searchParams.toString()}`)
}
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,
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}

View File

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

View File

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

View File

@@ -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",
},
],
}
},

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> {}
export interface BreakfastProps {
packages: BreakfastPackages | null
packages: BreakfastPackages
}

View File

@@ -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",

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 {
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 {
FREE_MEMBER_BREAKFAST = "BRF0",
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[]
}