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
|
<Link
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
color="burgundy"
|
color="burgundy"
|
||||||
href={selectHotelMap[params.lang]}
|
href={selectHotelMap(params.lang)}
|
||||||
keepSearchParams
|
keepSearchParams
|
||||||
>
|
>
|
||||||
<div className={styles.mapContainer}>
|
<div className={styles.mapContainer}>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
.hotelSelectionHeader {
|
.header {
|
||||||
background-color: var(--Base-Surface-Subtle-Normal);
|
background-color: var(--Base-Surface-Subtle-Normal);
|
||||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hotelSelectionHeaderWrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x3);
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.hotelSelectionHeader {
|
.header {
|
||||||
padding: var(--Spacing-x4) 0;
|
padding: var(--Spacing-x4) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hotelSelectionHeaderWrapper {
|
.wrapper {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: var(--Spacing-x6);
|
gap: var(--Spacing-x6);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -1,23 +1,38 @@
|
|||||||
"use client"
|
import { redirect } from "next/navigation"
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import Divider from "@/components/TempDesignSystem/Divider"
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
import styles from "./hotelSelectionHeader.module.css"
|
import styles from "./page.module.css"
|
||||||
|
|
||||||
import { HotelSelectionHeaderProps } from "@/types/components/hotelReservation/selectRate/hotelSelectionHeader"
|
import type { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
export default function HotelSelectionHeader({
|
export default async function HotelHeader({
|
||||||
hotel,
|
params,
|
||||||
}: HotelSelectionHeaderProps) {
|
searchParams,
|
||||||
const intl = useIntl()
|
}: PageArgs<LangParams, { hotel: string }>) {
|
||||||
|
const home = `/${params.lang}`
|
||||||
|
if (!searchParams.hotel) {
|
||||||
|
redirect(home)
|
||||||
|
}
|
||||||
|
const hotelData = await getHotelData({
|
||||||
|
hotelId: searchParams.hotel,
|
||||||
|
language: params.lang,
|
||||||
|
})
|
||||||
|
if (!hotelData?.data) {
|
||||||
|
redirect(home)
|
||||||
|
}
|
||||||
|
|
||||||
|
const intl = await getIntl()
|
||||||
|
const hotel = hotelData.data.attributes
|
||||||
return (
|
return (
|
||||||
<header className={styles.hotelSelectionHeader}>
|
<header className={styles.header}>
|
||||||
<div className={styles.hotelSelectionHeaderWrapper}>
|
<div className={styles.wrapper}>
|
||||||
<div className={styles.titleContainer}>
|
<div className={styles.titleContainer}>
|
||||||
<Title as="h3" level="h1">
|
<Title as="h3" level="h1">
|
||||||
{hotel.name}
|
{hotel.name}
|
||||||
@@ -61,7 +61,7 @@ export default async function SummaryPage({
|
|||||||
if (!availability || !availability.selectedRoom) {
|
if (!availability || !availability.selectedRoom) {
|
||||||
console.error("No hotel or availability data", availability)
|
console.error("No hotel or availability data", availability)
|
||||||
// TODO: handle this case
|
// TODO: handle this case
|
||||||
redirect(selectRate[params.lang])
|
redirect(selectRate(params.lang))
|
||||||
}
|
}
|
||||||
|
|
||||||
const prices =
|
const prices =
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider"
|
|
||||||
import { setLang } from "@/i18n/serverContext"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
|
import DetailsProvider from "@/providers/DetailsProvider"
|
||||||
|
|
||||||
import { preload } from "./_preload"
|
import { preload } from "./_preload"
|
||||||
|
|
||||||
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
|
|
||||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||||
|
|
||||||
export default async function StepLayout({
|
export default async function StepLayout({
|
||||||
summary,
|
|
||||||
children,
|
children,
|
||||||
hotelHeader,
|
hotelHeader,
|
||||||
params,
|
params,
|
||||||
|
summary,
|
||||||
}: React.PropsWithChildren<
|
}: React.PropsWithChildren<
|
||||||
LayoutArgs<LangParams & { step: StepEnum }> & {
|
LayoutArgs<LangParams> & {
|
||||||
hotelHeader: React.ReactNode
|
hotelHeader: React.ReactNode
|
||||||
summary: React.ReactNode
|
summary: React.ReactNode
|
||||||
}
|
}
|
||||||
@@ -25,7 +24,7 @@ export default async function StepLayout({
|
|||||||
const user = await getProfileSafely()
|
const user = await getProfileSafely()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnterDetailsProvider step={params.step} isMember={!!user}>
|
<DetailsProvider isMember={!!user}>
|
||||||
<main className="enter-details-layout__layout">
|
<main className="enter-details-layout__layout">
|
||||||
{hotelHeader}
|
{hotelHeader}
|
||||||
<div className={"enter-details-layout__container"}>
|
<div className={"enter-details-layout__container"}>
|
||||||
@@ -35,6 +34,6 @@ export default async function StepLayout({
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</EnterDetailsProvider>
|
</DetailsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import "./enterDetailsLayout.css"
|
import "./enterDetailsLayout.css"
|
||||||
|
|
||||||
import { notFound } from "next/navigation"
|
import { notFound, redirect, RedirectType } from "next/navigation"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getBreakfastPackages,
|
getBreakfastPackages,
|
||||||
@@ -22,9 +22,10 @@ import {
|
|||||||
getQueryParamsForEnterDetails,
|
getQueryParamsForEnterDetails,
|
||||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
import StepsProvider from "@/providers/StepsProvider"
|
||||||
|
|
||||||
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
|
|
||||||
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
|
import { StepEnum } from "@/types/enums/step"
|
||||||
import type { LangParams, PageArgs } from "@/types/params"
|
import type { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
function isValidStep(step: string): step is StepEnum {
|
function isValidStep(step: string): step is StepEnum {
|
||||||
@@ -32,11 +33,9 @@ function isValidStep(step: string): step is StepEnum {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function StepPage({
|
export default async function StepPage({
|
||||||
params,
|
params: { lang },
|
||||||
searchParams,
|
searchParams,
|
||||||
}: PageArgs<LangParams & { step: StepEnum }, SelectRateSearchParams>) {
|
}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) {
|
||||||
const { lang } = params
|
|
||||||
|
|
||||||
const intl = await getIntl()
|
const intl = await getIntl()
|
||||||
const selectRoomParams = new URLSearchParams(searchParams)
|
const selectRoomParams = new URLSearchParams(searchParams)
|
||||||
const {
|
const {
|
||||||
@@ -88,7 +87,7 @@ export default async function StepPage({
|
|||||||
const user = await getProfileSafely()
|
const user = await getProfileSafely()
|
||||||
const savedCreditCards = await getCreditCardsSafely()
|
const savedCreditCards = await getCreditCardsSafely()
|
||||||
|
|
||||||
if (!isValidStep(params.step) || !hotelData || !roomAvailability) {
|
if (!isValidStep(searchParams.step) || !hotelData || !roomAvailability) {
|
||||||
return notFound()
|
return notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,54 +112,65 @@ export default async function StepPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<StepsProvider
|
||||||
<HistoryStateManager />
|
bedTypes={roomAvailability.bedTypes}
|
||||||
<SelectedRoom
|
breakfastPackages={breakfastPackages}
|
||||||
hotelId={hotelId}
|
isMember={!!user}
|
||||||
room={roomAvailability.selectedRoom}
|
step={searchParams.step}
|
||||||
rateDescription={roomAvailability.cancellationText}
|
>
|
||||||
/>
|
<section>
|
||||||
|
<HistoryStateManager />
|
||||||
{/* TODO: How to handle no beds found? */}
|
<SelectedRoom
|
||||||
{roomAvailability.bedTypes ? (
|
hotelId={hotelId}
|
||||||
<SectionAccordion
|
room={roomAvailability.selectedRoom}
|
||||||
header="Select bed"
|
rateDescription={roomAvailability.cancellationText}
|
||||||
step={StepEnum.selectBed}
|
|
||||||
label={intl.formatMessage({ id: "Request bedtype" })}
|
|
||||||
>
|
|
||||||
<BedType bedTypes={roomAvailability.bedTypes} />
|
|
||||||
</SectionAccordion>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<SectionAccordion
|
|
||||||
header={intl.formatMessage({ id: "Food options" })}
|
|
||||||
step={StepEnum.breakfast}
|
|
||||||
label={intl.formatMessage({ id: "Select breakfast options" })}
|
|
||||||
>
|
|
||||||
<Breakfast packages={breakfastPackages} />
|
|
||||||
</SectionAccordion>
|
|
||||||
<SectionAccordion
|
|
||||||
header={intl.formatMessage({ id: "Details" })}
|
|
||||||
step={StepEnum.details}
|
|
||||||
label={intl.formatMessage({ id: "Enter your details" })}
|
|
||||||
>
|
|
||||||
<Details user={user} />
|
|
||||||
</SectionAccordion>
|
|
||||||
<SectionAccordion
|
|
||||||
header={mustBeGuaranteed ? paymentGuarantee : payment}
|
|
||||||
step={StepEnum.payment}
|
|
||||||
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
|
|
||||||
>
|
|
||||||
<Payment
|
|
||||||
roomPrice={roomPrice}
|
|
||||||
otherPaymentOptions={
|
|
||||||
hotelData.data.attributes.merchantInformationData
|
|
||||||
.alternatePaymentOptions
|
|
||||||
}
|
|
||||||
savedCreditCards={savedCreditCards}
|
|
||||||
mustBeGuaranteed={mustBeGuaranteed}
|
|
||||||
/>
|
/>
|
||||||
</SectionAccordion>
|
|
||||||
</section>
|
{/* TODO: How to handle no beds found? */}
|
||||||
|
{roomAvailability.bedTypes ? (
|
||||||
|
<SectionAccordion
|
||||||
|
header={intl.formatMessage({ id: "Select bed" })}
|
||||||
|
step={StepEnum.selectBed}
|
||||||
|
label={intl.formatMessage({ id: "Request bedtype" })}
|
||||||
|
>
|
||||||
|
<BedType bedTypes={roomAvailability.bedTypes} />
|
||||||
|
</SectionAccordion>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{breakfastPackages?.length ? (
|
||||||
|
<SectionAccordion
|
||||||
|
header={intl.formatMessage({ id: "Food options" })}
|
||||||
|
step={StepEnum.breakfast}
|
||||||
|
label={intl.formatMessage({ id: "Select breakfast options" })}
|
||||||
|
>
|
||||||
|
<Breakfast packages={breakfastPackages} />
|
||||||
|
</SectionAccordion>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SectionAccordion
|
||||||
|
header={intl.formatMessage({ id: "Details" })}
|
||||||
|
step={StepEnum.details}
|
||||||
|
label={intl.formatMessage({ id: "Enter your details" })}
|
||||||
|
>
|
||||||
|
<Details user={user} />
|
||||||
|
</SectionAccordion>
|
||||||
|
|
||||||
|
<SectionAccordion
|
||||||
|
header={mustBeGuaranteed ? paymentGuarantee : payment}
|
||||||
|
step={StepEnum.payment}
|
||||||
|
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
|
||||||
|
>
|
||||||
|
<Payment
|
||||||
|
roomPrice={roomPrice}
|
||||||
|
otherPaymentOptions={
|
||||||
|
hotelData.data.attributes.merchantInformationData
|
||||||
|
.alternatePaymentOptions
|
||||||
|
}
|
||||||
|
savedCreditCards={savedCreditCards}
|
||||||
|
mustBeGuaranteed={mustBeGuaranteed}
|
||||||
|
/>
|
||||||
|
</SectionAccordion>
|
||||||
|
</section>
|
||||||
|
</StepsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ export async function GET(
|
|||||||
const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER)
|
const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER)
|
||||||
|
|
||||||
if (status === "success" && confirmationNumber) {
|
if (status === "success" && confirmationNumber) {
|
||||||
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation[lang]}`)
|
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation(lang)}`)
|
||||||
confirmationUrl.searchParams.set(
|
confirmationUrl.searchParams.set(
|
||||||
BOOKING_CONFIRMATION_NUMBER,
|
BOOKING_CONFIRMATION_NUMBER,
|
||||||
confirmationNumber
|
confirmationNumber
|
||||||
@@ -36,7 +36,7 @@ export async function GET(
|
|||||||
return NextResponse.redirect(confirmationUrl)
|
return NextResponse.redirect(confirmationUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnUrl = new URL(`${publicURL}/${payment[lang]}`)
|
const returnUrl = new URL(`${publicURL}/${payment(lang)}`)
|
||||||
returnUrl.search = queryParams.toString()
|
returnUrl.search = queryParams.toString()
|
||||||
|
|
||||||
if (confirmationNumber) {
|
if (confirmationNumber) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function Form({
|
|||||||
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
|
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
|
||||||
|
|
||||||
const bookingFlowPage =
|
const bookingFlowPage =
|
||||||
locationData.type == "cities" ? selectHotel[lang] : selectRate[lang]
|
locationData.type == "cities" ? selectHotel(lang) : selectRate(lang)
|
||||||
const bookingWidgetParams = new URLSearchParams(data.date)
|
const bookingWidgetParams = new URLSearchParams(data.date)
|
||||||
|
|
||||||
if (locationData.type == "cities")
|
if (locationData.type == "cities")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
.form {
|
.form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||||
padding-bottom: var(--Spacing-x3);
|
|
||||||
width: min(600px, 100%);
|
width: min(600px, 100%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
|||||||
import { useCallback, useEffect } from "react"
|
import { useCallback, useEffect } from "react"
|
||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
import { useStepsStore } from "@/stores/steps"
|
||||||
|
|
||||||
import { KingBedIcon } from "@/components/Icons"
|
import { KingBedIcon } from "@/components/Icons"
|
||||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||||
@@ -19,22 +20,18 @@ import type {
|
|||||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||||
|
|
||||||
export default function BedType({ bedTypes }: BedTypeProps) {
|
export default function BedType({ bedTypes }: BedTypeProps) {
|
||||||
const bedType = useEnterDetailsStore((state) => state.userData.bedType)
|
const bedType = useDetailsStore((state) => state.data.bedType?.roomTypeCode)
|
||||||
|
const completeStep = useStepsStore((state) => state.completeStep)
|
||||||
|
const updateBedType = useDetailsStore((state) => state.actions.updateBedType)
|
||||||
|
|
||||||
const methods = useForm<BedTypeFormSchema>({
|
const methods = useForm<BedTypeFormSchema>({
|
||||||
defaultValues: bedType?.roomTypeCode
|
defaultValues: bedType ? { bedType } : undefined,
|
||||||
? {
|
|
||||||
bedType: bedType.roomTypeCode,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
mode: "all",
|
mode: "all",
|
||||||
resolver: zodResolver(bedTypeFormSchema),
|
resolver: zodResolver(bedTypeFormSchema),
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
})
|
})
|
||||||
|
|
||||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
(bedTypeRoomCode: BedTypeFormSchema) => {
|
(bedTypeRoomCode: BedTypeFormSchema) => {
|
||||||
const matchingRoom = bedTypes.find(
|
const matchingRoom = bedTypes.find(
|
||||||
@@ -45,10 +42,11 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
|||||||
description: matchingRoom.description,
|
description: matchingRoom.description,
|
||||||
roomTypeCode: matchingRoom.value,
|
roomTypeCode: matchingRoom.value,
|
||||||
}
|
}
|
||||||
completeStep({ bedType })
|
updateBedType(bedType)
|
||||||
|
completeStep()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[completeStep, bedTypes]
|
[bedTypes, completeStep, updateBedType]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,6 +2,5 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||||
padding-bottom: var(--Spacing-x3);
|
|
||||||
width: min(600px, 100%);
|
width: min(600px, 100%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { useCallback, useEffect } from "react"
|
|||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
import { useStepsStore } from "@/stores/steps"
|
||||||
|
|
||||||
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
||||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||||
@@ -23,34 +24,37 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
|||||||
export default function Breakfast({ packages }: BreakfastProps) {
|
export default function Breakfast({ packages }: BreakfastProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
const breakfast = useEnterDetailsStore((state) => state.userData.breakfast)
|
const breakfast = useDetailsStore(({ data }) =>
|
||||||
|
data.breakfast
|
||||||
|
? data.breakfast.code
|
||||||
|
: data.breakfast === false
|
||||||
|
? "false"
|
||||||
|
: data.breakfast
|
||||||
|
)
|
||||||
|
const updateBreakfast = useDetailsStore(
|
||||||
|
(state) => state.actions.updateBreakfast
|
||||||
|
)
|
||||||
|
const completeStep = useStepsStore((state) => state.completeStep)
|
||||||
|
|
||||||
let defaultValues = undefined
|
|
||||||
if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) {
|
|
||||||
defaultValues = { breakfast: BreakfastPackageEnum.NO_BREAKFAST }
|
|
||||||
} else if (breakfast?.code) {
|
|
||||||
defaultValues = { breakfast: breakfast.code }
|
|
||||||
}
|
|
||||||
const methods = useForm<BreakfastFormSchema>({
|
const methods = useForm<BreakfastFormSchema>({
|
||||||
defaultValues,
|
defaultValues: breakfast ? { breakfast } : undefined,
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
mode: "all",
|
mode: "all",
|
||||||
resolver: zodResolver(breakfastFormSchema),
|
resolver: zodResolver(breakfastFormSchema),
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
})
|
})
|
||||||
|
|
||||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
(values: BreakfastFormSchema) => {
|
(values: BreakfastFormSchema) => {
|
||||||
const pkg = packages?.find((p) => p.code === values.breakfast)
|
const pkg = packages?.find((p) => p.code === values.breakfast)
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
completeStep({ breakfast: pkg })
|
updateBreakfast(pkg)
|
||||||
} else {
|
} else {
|
||||||
completeStep({ breakfast: BreakfastPackageEnum.NO_BREAKFAST })
|
updateBreakfast(false)
|
||||||
}
|
}
|
||||||
|
completeStep()
|
||||||
},
|
},
|
||||||
[completeStep, packages]
|
[completeStep, packages, updateBreakfast]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -61,10 +65,6 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
|||||||
return () => subscription.unsubscribe()
|
return () => subscription.unsubscribe()
|
||||||
}, [methods, onSubmit])
|
}, [methods, onSubmit])
|
||||||
|
|
||||||
if (!packages) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||||
@@ -100,7 +100,6 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<RadioCard
|
<RadioCard
|
||||||
id={BreakfastPackageEnum.NO_BREAKFAST}
|
|
||||||
name="breakfast"
|
name="breakfast"
|
||||||
subtitle={intl.formatMessage(
|
subtitle={intl.formatMessage(
|
||||||
{ id: "{amount} {currency}" },
|
{ id: "{amount} {currency}" },
|
||||||
@@ -113,7 +112,7 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
|||||||
id: "You can always change your mind later and add breakfast at the hotel.",
|
id: "You can always change your mind later and add breakfast at the hotel.",
|
||||||
})}
|
})}
|
||||||
title={intl.formatMessage({ id: "No breakfast" })}
|
title={intl.formatMessage({ id: "No breakfast" })}
|
||||||
value={BreakfastPackageEnum.NO_BREAKFAST}
|
value="false"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ import { z } from "zod"
|
|||||||
|
|
||||||
import { breakfastPackageSchema } from "@/server/routers/hotels/output"
|
import { breakfastPackageSchema } from "@/server/routers/hotels/output"
|
||||||
|
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
|
||||||
|
|
||||||
export const breakfastStoreSchema = z.object({
|
export const breakfastStoreSchema = z.object({
|
||||||
breakfast: breakfastPackageSchema.or(
|
breakfast: breakfastPackageSchema.or(z.literal(false)),
|
||||||
z.literal(BreakfastPackageEnum.NO_BREAKFAST)
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const breakfastFormSchema = z.object({
|
export const breakfastFormSchema = z.object({
|
||||||
breakfast: z.string().or(z.literal(BreakfastPackageEnum.NO_BREAKFAST)),
|
breakfast: z.string().or(z.literal("false")),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
.form {
|
.form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
padding: var(--Spacing-x3) 0px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useCallback } from "react"
|
||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
import { useStepsStore } from "@/stores/steps"
|
||||||
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||||
@@ -24,19 +26,22 @@ import type {
|
|||||||
const formID = "enter-details"
|
const formID = "enter-details"
|
||||||
export default function Details({ user }: DetailsProps) {
|
export default function Details({ user }: DetailsProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const initialData = useEnterDetailsStore((state) => ({
|
const initialData = useDetailsStore((state) => ({
|
||||||
countryCode: state.userData.countryCode,
|
countryCode: state.data.countryCode,
|
||||||
email: state.userData.email,
|
email: state.data.email,
|
||||||
firstName: state.userData.firstName,
|
firstName: state.data.firstName,
|
||||||
lastName: state.userData.lastName,
|
lastName: state.data.lastName,
|
||||||
phoneNumber: state.userData.phoneNumber,
|
phoneNumber: state.data.phoneNumber,
|
||||||
join: state.userData.join,
|
join: state.data.join,
|
||||||
dateOfBirth: state.userData.dateOfBirth,
|
dateOfBirth: state.data.dateOfBirth,
|
||||||
zipCode: state.userData.zipCode,
|
zipCode: state.data.zipCode,
|
||||||
termsAccepted: state.userData.termsAccepted,
|
termsAccepted: state.data.termsAccepted,
|
||||||
membershipNo: state.userData.membershipNo,
|
membershipNo: state.data.membershipNo,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const updateDetails = useDetailsStore((state) => state.actions.updateDetails)
|
||||||
|
const completeStep = useStepsStore((state) => state.completeStep)
|
||||||
|
|
||||||
const methods = useForm<DetailsSchema>({
|
const methods = useForm<DetailsSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
countryCode: user?.address?.countryCode ?? initialData.countryCode,
|
countryCode: user?.address?.countryCode ?? initialData.countryCode,
|
||||||
@@ -56,14 +61,20 @@ export default function Details({ user }: DetailsProps) {
|
|||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
})
|
})
|
||||||
|
|
||||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
const onSubmit = useCallback(
|
||||||
|
(values: DetailsSchema) => {
|
||||||
|
updateDetails(values)
|
||||||
|
completeStep()
|
||||||
|
},
|
||||||
|
[completeStep, updateDetails]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form
|
<form
|
||||||
className={styles.form}
|
className={styles.form}
|
||||||
id={formID}
|
id={formID}
|
||||||
onSubmit={methods.handleSubmit(completeStep)}
|
onSubmit={methods.handleSubmit(onSubmit)}
|
||||||
>
|
>
|
||||||
{user ? null : <Signup name="join" />}
|
{user ? null : <Signup name="join" />}
|
||||||
<Footnote
|
<Footnote
|
||||||
@@ -107,7 +118,7 @@ export default function Details({ user }: DetailsProps) {
|
|||||||
readOnly={!!user}
|
readOnly={!!user}
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
{user ? null : (
|
{user || methods.watch("join") ? null : (
|
||||||
<Input
|
<Input
|
||||||
className={styles.membershipNo}
|
className={styles.membershipNo}
|
||||||
label={intl.formatMessage({ id: "Membership no" })}
|
label={intl.formatMessage({ id: "Membership no" })}
|
||||||
@@ -119,7 +130,6 @@ export default function Details({ user }: DetailsProps) {
|
|||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<Button
|
<Button
|
||||||
disabled={!methods.formState.isValid}
|
disabled={!methods.formState.isValid}
|
||||||
form={formID}
|
|
||||||
intent="secondary"
|
intent="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
theme="base"
|
theme="base"
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect } from "react"
|
import { useCallback, useEffect } from "react"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useStepsStore } from "@/stores/steps"
|
||||||
|
|
||||||
export default function HistoryStateManager() {
|
export default function HistoryStateManager() {
|
||||||
const setCurrentStep = useEnterDetailsStore((state) => state.setCurrentStep)
|
const setCurrentStep = useStepsStore((state) => state.setStep)
|
||||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
const currentStep = useStepsStore((state) => state.currentStep)
|
||||||
|
|
||||||
const handleBackButton = useCallback(
|
const handleBackButton = useCallback(
|
||||||
(event: PopStateEvent) => {
|
(event: PopStateEvent) => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "@/constants/currentWebHrefs"
|
} from "@/constants/currentWebHrefs"
|
||||||
import { env } from "@/env/client"
|
import { env } from "@/env/client"
|
||||||
import { trpc } from "@/lib/trpc/client"
|
import { trpc } from "@/lib/trpc/client"
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
@@ -40,7 +40,6 @@ import styles from "./payment.module.css"
|
|||||||
|
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
|
||||||
|
|
||||||
const maxRetries = 4
|
const maxRetries = 4
|
||||||
const retryInterval = 2000
|
const retryInterval = 2000
|
||||||
@@ -61,12 +60,9 @@ export default function Payment({
|
|||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const queryParams = useSearchParams()
|
const queryParams = useSearchParams()
|
||||||
const { userData, roomData, setIsSubmittingDisabled } = useEnterDetailsStore(
|
const { booking, ...userData } = useDetailsStore((state) => state.data)
|
||||||
(state) => ({
|
const setIsSubmittingDisabled = useDetailsStore(
|
||||||
userData: state.userData,
|
(state) => state.actions.setIsSubmittingDisabled
|
||||||
roomData: state.roomData,
|
|
||||||
setIsSubmittingDisabled: state.setIsSubmittingDisabled,
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -82,7 +78,7 @@ export default function Payment({
|
|||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
zipCode,
|
zipCode,
|
||||||
} = userData
|
} = userData
|
||||||
const { toDate, fromDate, rooms: rooms, hotel } = roomData
|
const { toDate, fromDate, rooms, hotel } = booking
|
||||||
|
|
||||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||||
const [availablePaymentOptions, setAvailablePaymentOptions] =
|
const [availablePaymentOptions, setAvailablePaymentOptions] =
|
||||||
@@ -204,7 +200,7 @@ export default function Payment({
|
|||||||
postalCode: zipCode,
|
postalCode: zipCode,
|
||||||
},
|
},
|
||||||
packages: {
|
packages: {
|
||||||
breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST,
|
breakfast: !!(breakfast && breakfast.code),
|
||||||
allergyFriendly:
|
allergyFriendly:
|
||||||
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
|
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
|
||||||
petFriendly:
|
petFriendly:
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
import { useStepsStore } from "@/stores/steps"
|
||||||
|
|
||||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
@@ -10,12 +11,9 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
|||||||
|
|
||||||
import styles from "./sectionAccordion.module.css"
|
import styles from "./sectionAccordion.module.css"
|
||||||
|
|
||||||
import {
|
import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step"
|
||||||
StepEnum,
|
|
||||||
StepStoreKeys,
|
|
||||||
} from "@/types/components/hotelReservation/enterDetails/step"
|
|
||||||
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
import { StepEnum } from "@/types/enums/step"
|
||||||
|
|
||||||
export default function SectionAccordion({
|
export default function SectionAccordion({
|
||||||
header,
|
header,
|
||||||
@@ -24,12 +22,12 @@ export default function SectionAccordion({
|
|||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
const currentStep = useStepsStore((state) => state.currentStep)
|
||||||
const [isComplete, setIsComplete] = useState(false)
|
const [isComplete, setIsComplete] = useState(false)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
const isValid = useDetailsStore((state) => state.isValid[step])
|
||||||
const navigate = useEnterDetailsStore((state) => state.navigate)
|
const navigate = useStepsStore((state) => state.navigate)
|
||||||
const stepData = useEnterDetailsStore((state) => state.userData)
|
const stepData = useDetailsStore((state) => state.data)
|
||||||
const stepStoreKey = StepStoreKeys[step]
|
const stepStoreKey = StepStoreKeys[step]
|
||||||
const [title, setTitle] = useState(label)
|
const [title, setTitle] = useState(label)
|
||||||
|
|
||||||
@@ -39,9 +37,12 @@ export default function SectionAccordion({
|
|||||||
value && setTitle(value.description)
|
value && setTitle(value.description)
|
||||||
}
|
}
|
||||||
// If breakfast step, check if an option has been selected
|
// If breakfast step, check if an option has been selected
|
||||||
if (step === StepEnum.breakfast && stepData.breakfast) {
|
if (
|
||||||
|
step === StepEnum.breakfast &&
|
||||||
|
(stepData.breakfast || stepData.breakfast === false)
|
||||||
|
) {
|
||||||
const value = stepData.breakfast
|
const value = stepData.breakfast
|
||||||
if (value === BreakfastPackageEnum.NO_BREAKFAST) {
|
if (value === false) {
|
||||||
setTitle(intl.formatMessage({ id: "No breakfast" }))
|
setTitle(intl.formatMessage({ id: "No breakfast" }))
|
||||||
} else {
|
} else {
|
||||||
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
|
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
|
||||||
@@ -94,7 +95,9 @@ export default function SectionAccordion({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className={styles.content}>{children}</div>
|
<div className={styles.content}>
|
||||||
|
<div className={styles.contentWrapper}>{children}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
|
|
||||||
.main {
|
.main {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x3);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||||
padding-bottom: var(--Spacing-x3);
|
padding-bottom: var(--Spacing-x3);
|
||||||
@@ -80,6 +79,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contentWrapper {
|
||||||
|
padding-top: var(--Spacing-x3);
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
@media screen and (min-width: 1367px) {
|
||||||
.wrapper {
|
.wrapper {
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x3);
|
||||||
@@ -98,4 +101,4 @@
|
|||||||
content: "";
|
content: "";
|
||||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||||
|
|
||||||
import { CheckIcon, EditIcon } from "@/components/Icons"
|
import { CheckIcon, EditIcon } from "@/components/Icons"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
import ToggleSidePeek from "./ToggleSidePeek"
|
import ToggleSidePeek from "./ToggleSidePeek"
|
||||||
|
|
||||||
@@ -21,8 +22,7 @@ export default function SelectedRoom({
|
|||||||
rateDescription,
|
rateDescription,
|
||||||
}: SelectedRoomProps) {
|
}: SelectedRoomProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
const lang = useLang()
|
||||||
const selectRateUrl = useEnterDetailsStore((state) => state.selectRateUrl)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
@@ -53,7 +53,8 @@ export default function SelectedRoom({
|
|||||||
<Link
|
<Link
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
color="burgundy"
|
color="burgundy"
|
||||||
href={selectRateUrl}
|
href={selectRate(lang)}
|
||||||
|
keepSearchParams
|
||||||
size="small"
|
size="small"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { PropsWithChildren } from "react"
|
import { PropsWithChildren } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
@@ -17,9 +17,9 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) {
|
|||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
|
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
|
||||||
useEnterDetailsStore((state) => ({
|
useDetailsStore((state) => ({
|
||||||
isSummaryOpen: state.isSummaryOpen,
|
isSummaryOpen: state.isSummaryOpen,
|
||||||
toggleSummaryOpen: state.toggleSummaryOpen,
|
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||||
totalPrice: state.totalPrice,
|
totalPrice: state.totalPrice,
|
||||||
isSubmittingDisabled: state.isSubmittingDisabled,
|
isSubmittingDisabled: state.isSubmittingDisabled,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChevronDown } from "react-feather"
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { dt } from "@/lib/dt"
|
import { dt } from "@/lib/dt"
|
||||||
import { EnterDetailsState, useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
|
||||||
import { ArrowRightIcon } from "@/components/Icons"
|
import { ArrowRightIcon } from "@/components/Icons"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
@@ -18,45 +18,39 @@ import useLang from "@/hooks/useLang"
|
|||||||
|
|
||||||
import styles from "./summary.module.css"
|
import styles from "./summary.module.css"
|
||||||
|
|
||||||
import { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||||
import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData"
|
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||||
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
import type { DetailsState } from "@/types/stores/details"
|
||||||
|
|
||||||
function storeSelector(state: EnterDetailsState) {
|
function storeSelector(state: DetailsState) {
|
||||||
return {
|
return {
|
||||||
fromDate: state.roomData.fromDate,
|
fromDate: state.data.booking.fromDate,
|
||||||
toDate: state.roomData.toDate,
|
toDate: state.data.booking.toDate,
|
||||||
bedType: state.userData.bedType,
|
bedType: state.data.bedType,
|
||||||
breakfast: state.userData.breakfast,
|
breakfast: state.data.breakfast,
|
||||||
toggleSummaryOpen: state.toggleSummaryOpen,
|
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||||
setTotalPrice: state.setTotalPrice,
|
setTotalPrice: state.actions.setTotalPrice,
|
||||||
totalPrice: state.totalPrice,
|
totalPrice: state.totalPrice,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Summary({
|
export default function Summary({ showMemberPrice, room }: SummaryProps) {
|
||||||
showMemberPrice,
|
|
||||||
room,
|
|
||||||
}: {
|
|
||||||
showMemberPrice: boolean
|
|
||||||
room: RoomsData
|
|
||||||
}) {
|
|
||||||
const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
|
const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
|
||||||
const [chosenBreakfast, setChosenBreakfast] = useState<
|
const [chosenBreakfast, setChosenBreakfast] = useState<
|
||||||
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
|
BreakfastPackage | false
|
||||||
>()
|
>()
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const {
|
const {
|
||||||
fromDate,
|
|
||||||
toDate,
|
|
||||||
bedType,
|
bedType,
|
||||||
breakfast,
|
breakfast,
|
||||||
|
fromDate,
|
||||||
setTotalPrice,
|
setTotalPrice,
|
||||||
totalPrice,
|
toDate,
|
||||||
toggleSummaryOpen,
|
toggleSummaryOpen,
|
||||||
} = useEnterDetailsStore(storeSelector)
|
totalPrice,
|
||||||
|
} = useDetailsStore(storeSelector)
|
||||||
|
|
||||||
const diff = dt(toDate).diff(fromDate, "days")
|
const diff = dt(toDate).diff(fromDate, "days")
|
||||||
|
|
||||||
@@ -88,36 +82,39 @@ export default function Summary({
|
|||||||
setChosenBed(bedType)
|
setChosenBed(bedType)
|
||||||
setChosenBreakfast(breakfast)
|
setChosenBreakfast(breakfast)
|
||||||
|
|
||||||
if (breakfast && breakfast !== BreakfastPackageEnum.NO_BREAKFAST) {
|
if (breakfast || breakfast === false) {
|
||||||
setTotalPrice({
|
setChosenBreakfast(breakfast)
|
||||||
local: {
|
if (breakfast === false) {
|
||||||
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
setTotalPrice({
|
||||||
currency: room.localPrice.currency,
|
local: {
|
||||||
},
|
price: roomsPriceLocal,
|
||||||
euro:
|
currency: room.localPrice.currency,
|
||||||
room.euroPrice && roomsPriceEuro
|
},
|
||||||
? {
|
euro:
|
||||||
|
room.euroPrice && roomsPriceEuro
|
||||||
|
? {
|
||||||
|
price: roomsPriceEuro,
|
||||||
|
currency: room.euroPrice.currency,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTotalPrice({
|
||||||
|
local: {
|
||||||
|
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
||||||
|
currency: room.localPrice.currency,
|
||||||
|
},
|
||||||
|
euro:
|
||||||
|
room.euroPrice && roomsPriceEuro
|
||||||
|
? {
|
||||||
price:
|
price:
|
||||||
roomsPriceEuro +
|
roomsPriceEuro +
|
||||||
parseInt(breakfast.requestedPrice.totalPrice),
|
parseInt(breakfast.requestedPrice.totalPrice),
|
||||||
currency: room.euroPrice.currency,
|
currency: room.euroPrice.currency,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
} else {
|
}
|
||||||
setTotalPrice({
|
|
||||||
local: {
|
|
||||||
price: roomsPriceLocal,
|
|
||||||
currency: room.localPrice.currency,
|
|
||||||
},
|
|
||||||
euro:
|
|
||||||
room.euroPrice && roomsPriceEuro
|
|
||||||
? {
|
|
||||||
price: roomsPriceEuro,
|
|
||||||
currency: room.euroPrice.currency,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
bedType,
|
bedType,
|
||||||
@@ -187,24 +184,24 @@ export default function Summary({
|
|||||||
</div>
|
</div>
|
||||||
{room.packages
|
{room.packages
|
||||||
? room.packages.map((roomPackage) => (
|
? room.packages.map((roomPackage) => (
|
||||||
<div className={styles.entry} key={roomPackage.code}>
|
<div className={styles.entry} key={roomPackage.code}>
|
||||||
<div>
|
<div>
|
||||||
<Body color="uiTextHighContrast">
|
<Body color="uiTextHighContrast">
|
||||||
{roomPackage.description}
|
{roomPackage.description}
|
||||||
</Body>
|
</Body>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Caption color="uiTextHighContrast">
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "{amount} {currency}" },
|
|
||||||
{
|
|
||||||
amount: roomPackage.localPrice.price,
|
|
||||||
currency: roomPackage.localPrice.currency,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</Caption>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
|
<Caption color="uiTextHighContrast">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "{amount} {currency}" },
|
||||||
|
{
|
||||||
|
amount: roomPackage.localPrice.price,
|
||||||
|
currency: roomPackage.localPrice.currency,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</Caption>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
: null}
|
: null}
|
||||||
{chosenBed ? (
|
{chosenBed ? (
|
||||||
<div className={styles.entry}>
|
<div className={styles.entry}>
|
||||||
@@ -224,37 +221,36 @@ export default function Summary({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{chosenBreakfast ? (
|
{chosenBreakfast === false ? (
|
||||||
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
|
<div className={styles.entry}>
|
||||||
<div className={styles.entry}>
|
<Body color="uiTextHighContrast">
|
||||||
<Body color="uiTextHighContrast">
|
{intl.formatMessage({ id: "No breakfast" })}
|
||||||
{intl.formatMessage({ id: "No breakfast" })}
|
</Body>
|
||||||
</Body>
|
<Caption color="uiTextMediumContrast">
|
||||||
<Caption color="uiTextHighContrast">
|
{intl.formatMessage(
|
||||||
{intl.formatMessage(
|
{ id: "{amount} {currency}" },
|
||||||
{ id: "{amount} {currency}" },
|
{ amount: "0", currency: room.localPrice.currency }
|
||||||
{ amount: "0", currency: room.localPrice.currency }
|
)}
|
||||||
)}
|
</Caption>
|
||||||
</Caption>
|
</div>
|
||||||
</div>
|
) : chosenBreakfast?.code ? (
|
||||||
) : (
|
<div className={styles.entry}>
|
||||||
<div className={styles.entry}>
|
<Body color="uiTextHighContrast">
|
||||||
<Body color="uiTextHighContrast">
|
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
</Body>
|
||||||
</Body>
|
<Caption color="uiTextMediumContrast">
|
||||||
<Caption color="uiTextHighContrast">
|
{intl.formatMessage(
|
||||||
{intl.formatMessage(
|
{ id: "{amount} {currency}" },
|
||||||
{ id: "{amount} {currency}" },
|
{
|
||||||
{
|
amount: chosenBreakfast.localPrice.totalPrice,
|
||||||
amount: chosenBreakfast.localPrice.totalPrice,
|
currency: chosenBreakfast.localPrice.currency,
|
||||||
currency: chosenBreakfast.localPrice.currency,
|
}
|
||||||
}
|
)}
|
||||||
)}
|
</Caption>
|
||||||
</Caption>
|
</div>
|
||||||
</div>
|
) : null
|
||||||
)
|
}
|
||||||
) : null}
|
</div >
|
||||||
</div>
|
|
||||||
<Divider color="primaryLightSubtle" />
|
<Divider color="primaryLightSubtle" />
|
||||||
<div className={styles.total}>
|
<div className={styles.total}>
|
||||||
<div className={styles.entry}>
|
<div className={styles.entry}>
|
||||||
@@ -295,6 +291,6 @@ export default function Summary({
|
|||||||
</div>
|
</div>
|
||||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function HotelPriceList({
|
|||||||
className={styles.button}
|
className={styles.button}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`${selectRate[lang]}?hotel=${hotelId}`}
|
href={`${selectRate(lang)}?hotel=${hotelId}`}
|
||||||
color="none"
|
color="none"
|
||||||
keepSearchParams
|
keepSearchParams
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function HotelCard({
|
|||||||
</address>
|
</address>
|
||||||
<Link
|
<Link
|
||||||
className={styles.addressMobile}
|
className={styles.addressMobile}
|
||||||
href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`}
|
href={`${selectHotelMap(lang)}?selectedHotel=${hotelData.name}`}
|
||||||
keepSearchParams
|
keepSearchParams
|
||||||
>
|
>
|
||||||
<Caption color="baseTextMediumContrast" type="underline">
|
<Caption color="baseTextMediumContrast" type="underline">
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default function HotelCardDialog({
|
|||||||
|
|
||||||
<Button asChild theme="base" size="small" className={styles.button}>
|
<Button asChild theme="base" size="small" className={styles.button}>
|
||||||
<Link
|
<Link
|
||||||
href={`${selectRate[lang]}?hotel=${data.operaId}`}
|
href={`${selectRate(lang)}?hotel=${data.operaId}`}
|
||||||
color="none"
|
color="none"
|
||||||
keepSearchParams
|
keepSearchParams
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function MobileMapButtonContainer({
|
|||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
<Button asChild variant="icon" intent="secondary" size="small">
|
<Button asChild variant="icon" intent="secondary" size="small">
|
||||||
<Link
|
<Link
|
||||||
href={`${selectHotelMap[lang]}`}
|
href={selectHotelMap(lang)}
|
||||||
keepSearchParams
|
keepSearchParams
|
||||||
color="burgundy"
|
color="burgundy"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function SelectHotelMap({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePageRedirect() {
|
function handlePageRedirect() {
|
||||||
router.push(`${selectHotel[lang]}?${searchParams.toString()}`)
|
router.push(`${selectHotel(lang)}?${searchParams.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeButton = (
|
const closeButton = (
|
||||||
|
|||||||
@@ -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,
|
iconHeight = 32,
|
||||||
iconWidth = 32,
|
iconWidth = 32,
|
||||||
declined = false,
|
declined = false,
|
||||||
|
defaultChecked,
|
||||||
highlightSubtitle = false,
|
highlightSubtitle = false,
|
||||||
id,
|
id,
|
||||||
list,
|
list,
|
||||||
@@ -45,6 +46,7 @@ export default function Card({
|
|||||||
<input
|
<input
|
||||||
{...register(name)}
|
{...register(name)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
defaultChecked={defaultChecked}
|
||||||
id={id || name}
|
id={id || name}
|
||||||
hidden
|
hidden
|
||||||
type={type}
|
type={type}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
|
|
||||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
|
import useSetOverflowVisibleOnRA from "@/hooks/useSetOverflowVisibleOnRA"
|
||||||
|
|
||||||
import SelectChevron from "../Form/SelectChevron"
|
import SelectChevron from "../Form/SelectChevron"
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export default function Select({
|
|||||||
discreet = false,
|
discreet = false,
|
||||||
}: SelectProps) {
|
}: SelectProps) {
|
||||||
const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined)
|
const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined)
|
||||||
|
const setOverflowVisible = useSetOverflowVisibleOnRA()
|
||||||
|
|
||||||
function setRef(node: SelectPortalContainerArgs) {
|
function setRef(node: SelectPortalContainerArgs) {
|
||||||
if (node) {
|
if (node) {
|
||||||
@@ -60,6 +62,7 @@ export default function Select({
|
|||||||
onSelectionChange={handleOnSelect}
|
onSelectionChange={handleOnSelect}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
selectedKey={value as Key}
|
selectedKey={value as Key}
|
||||||
|
onOpenChange={setOverflowVisible}
|
||||||
>
|
>
|
||||||
<Body asChild fontOnly>
|
<Body asChild fontOnly>
|
||||||
<Button className={styles.input} data-testid={name}>
|
<Button className={styles.input} data-testid={name}>
|
||||||
|
|||||||
@@ -1,97 +1,59 @@
|
|||||||
/** @type {import('@/types/routes').LangRoute} */
|
/**
|
||||||
export const hotelReservation = {
|
* @typedef {import('@/constants/languages').Lang} Lang
|
||||||
en: "/en/hotelreservation",
|
*/
|
||||||
sv: "/sv/hotelreservation",
|
|
||||||
no: "/no/hotelreservation",
|
/**
|
||||||
fi: "/fi/hotelreservation",
|
* @param {Lang} lang
|
||||||
da: "/da/hotelreservation",
|
*/
|
||||||
de: "/de/hotelreservation",
|
function base(lang) {
|
||||||
|
return `/${lang}/hotelreservation`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const selectHotel = {
|
/**
|
||||||
en: `${hotelReservation.en}/select-hotel`,
|
* @param {Lang} lang
|
||||||
sv: `${hotelReservation.sv}/select-hotel`,
|
*/
|
||||||
no: `${hotelReservation.no}/select-hotel`,
|
export function bookingConfirmation(lang) {
|
||||||
fi: `${hotelReservation.fi}/select-hotel`,
|
return `${base(lang)}/booking-confirmation`
|
||||||
da: `${hotelReservation.da}/select-hotel`,
|
|
||||||
de: `${hotelReservation.de}/select-hotel`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const selectRate = {
|
/**
|
||||||
en: `${hotelReservation.en}/select-rate`,
|
* @param {Lang} lang
|
||||||
sv: `${hotelReservation.sv}/select-rate`,
|
*/
|
||||||
no: `${hotelReservation.no}/select-rate`,
|
export function details(lang) {
|
||||||
fi: `${hotelReservation.fi}/select-rate`,
|
return `${base(lang)}/details`
|
||||||
da: `${hotelReservation.da}/select-rate`,
|
|
||||||
de: `${hotelReservation.de}/select-rate`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Translate paths
|
/**
|
||||||
export const selectBed = {
|
* @param {Lang} lang
|
||||||
en: `${hotelReservation.en}/select-bed`,
|
*/
|
||||||
sv: `${hotelReservation.sv}/select-bed`,
|
export function payment(lang) {
|
||||||
no: `${hotelReservation.no}/select-bed`,
|
return `${base(lang)}/payment`
|
||||||
fi: `${hotelReservation.fi}/select-bed`,
|
|
||||||
da: `${hotelReservation.da}/select-bed`,
|
|
||||||
de: `${hotelReservation.de}/select-bed`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Translate paths
|
/**
|
||||||
export const breakfast = {
|
* @param {Lang} lang
|
||||||
en: `${hotelReservation.en}/breakfast`,
|
*/
|
||||||
sv: `${hotelReservation.sv}/breakfast`,
|
export function selectBed(lang) {
|
||||||
no: `${hotelReservation.no}/breakfast`,
|
return `${base(lang)}/select-bed`
|
||||||
fi: `${hotelReservation.fi}/breakfast`,
|
|
||||||
da: `${hotelReservation.da}/breakfast`,
|
|
||||||
de: `${hotelReservation.de}/breakfast`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Translate paths
|
/**
|
||||||
export const details = {
|
* @param {Lang} lang
|
||||||
en: `${hotelReservation.en}/details`,
|
*/
|
||||||
sv: `${hotelReservation.sv}/details`,
|
export function selectHotel(lang) {
|
||||||
no: `${hotelReservation.no}/details`,
|
return `${base(lang)}/select-hotel`
|
||||||
fi: `${hotelReservation.fi}/details`,
|
|
||||||
da: `${hotelReservation.da}/details`,
|
|
||||||
de: `${hotelReservation.de}/details`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Translate paths
|
/**
|
||||||
export const payment = {
|
* @param {Lang} lang
|
||||||
en: `${hotelReservation.en}/payment`,
|
*/
|
||||||
sv: `${hotelReservation.sv}/payment`,
|
export function selectHotelMap(lang) {
|
||||||
no: `${hotelReservation.no}/payment`,
|
return `${base(lang)}/map`
|
||||||
fi: `${hotelReservation.fi}/payment`,
|
|
||||||
da: `${hotelReservation.da}/payment`,
|
|
||||||
de: `${hotelReservation.de}/payment`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const selectHotelMap = {
|
/**
|
||||||
en: `${selectHotel.en}/map`,
|
* @param {Lang} lang
|
||||||
sv: `${selectHotel.sv}/map`,
|
*/
|
||||||
no: `${selectHotel.no}/map`,
|
export function selectRate(lang) {
|
||||||
fi: `${selectHotel.fi}/map`,
|
return `${base(lang)}/select-rate`
|
||||||
da: `${selectHotel.da}/map`,
|
|
||||||
de: `${selectHotel.de}/map`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {import('@/types/routes').LangRoute} */
|
|
||||||
export const bookingConfirmation = {
|
|
||||||
en: `${hotelReservation.en}/booking-confirmation`,
|
|
||||||
sv: `${hotelReservation.sv}/booking-confirmation`,
|
|
||||||
no: `${hotelReservation.no}/booking-confirmation`,
|
|
||||||
fi: `${hotelReservation.fi}/booking-confirmation`,
|
|
||||||
da: `${hotelReservation.da}/booking-confirmation`,
|
|
||||||
de: `${hotelReservation.de}/booking-confirmation`,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const bookingFlow = [
|
|
||||||
...Object.values(selectHotel),
|
|
||||||
...Object.values(selectBed),
|
|
||||||
...Object.values(breakfast),
|
|
||||||
...Object.values(details),
|
|
||||||
...Object.values(payment),
|
|
||||||
...Object.values(selectHotelMap),
|
|
||||||
...Object.values(bookingConfirmation),
|
|
||||||
...Object.values(selectRate),
|
|
||||||
]
|
|
||||||
|
|||||||
5
contexts/Details.ts
Normal file
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 { NextResponse } from "next/server"
|
||||||
|
|
||||||
import { bookingFlow } from "@/constants/routes/hotelReservation"
|
|
||||||
|
|
||||||
import { getDefaultRequestHeaders } from "./utils"
|
import { getDefaultRequestHeaders } from "./utils"
|
||||||
|
|
||||||
import type { NextMiddleware } from "next/server"
|
import type { NextMiddleware } from "next/server"
|
||||||
@@ -18,5 +16,7 @@ export const middleware: NextMiddleware = async (request) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const matcher: MiddlewareMatcher = (request) => {
|
export const matcher: MiddlewareMatcher = (request) => {
|
||||||
return bookingFlow.includes(request.nextUrl.pathname)
|
return !!request.nextUrl.pathname.match(
|
||||||
|
/^\/(da|de|en|fi|no|sv)\/(hotelreservation)/
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,11 @@ const nextConfig = {
|
|||||||
source: `${myPages.sv}/:path*`,
|
source: `${myPages.sv}/:path*`,
|
||||||
destination: `/sv/my-pages/:path*`,
|
destination: `/sv/my-pages/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source:
|
||||||
|
"/:lang/hotelreservation/:step(breakfast|details|payment|select-bed)",
|
||||||
|
destination: "/:lang/hotelreservation/step?step=:step",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
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> {}
|
extends z.output<typeof breakfastPackageSchema> {}
|
||||||
|
|
||||||
export interface BreakfastProps {
|
export interface BreakfastProps {
|
||||||
packages: BreakfastPackages | null
|
packages: BreakfastPackages
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
export enum StepEnum {
|
import { StepEnum } from "@/types/enums/step"
|
||||||
selectBed = "select-bed",
|
|
||||||
breakfast = "breakfast",
|
|
||||||
details = "details",
|
|
||||||
payment = "payment",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StepStoreKeys: Record<StepEnum, "bedType" | "breakfast" | null> = {
|
export const StepStoreKeys: Record<StepEnum, "bedType" | "breakfast" | null> = {
|
||||||
"select-bed": "bedType",
|
"select-bed": "bedType",
|
||||||
|
|||||||
@@ -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 {
|
export interface SectionAccordionProps {
|
||||||
header: string
|
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 {
|
export enum BreakfastPackageEnum {
|
||||||
FREE_MEMBER_BREAKFAST = "BRF0",
|
FREE_MEMBER_BREAKFAST = "BRF0",
|
||||||
REGULAR_BREAKFAST = "BRF1",
|
REGULAR_BREAKFAST = "BRF1",
|
||||||
NO_BREAKFAST = "NO_BREAKFAST",
|
|
||||||
}
|
}
|
||||||
|
|||||||
6
types/enums/step.ts
Normal file
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