feat: merge stores, fix auto navigation, split summary
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import BookingConfirmation from "@/components/HotelReservation/BookingConfirmation"
|
||||
import Header from "@/components/HotelReservation/BookingConfirmation/Header"
|
||||
import Rooms from "@/components/HotelReservation/BookingConfirmation/Rooms"
|
||||
import Summary from "@/components/HotelReservation/BookingConfirmation/Summary"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
@@ -12,11 +14,12 @@ export default async function BookingConfirmationPage({
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, { confirmationNumber: string }>) {
|
||||
setLang(params.lang)
|
||||
const confirmationNumber = searchParams.confirmationNumber
|
||||
void getBookingConfirmation(confirmationNumber)
|
||||
void getBookingConfirmation(searchParams.confirmationNumber)
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<BookingConfirmation confirmationNumber={confirmationNumber} />
|
||||
</main>
|
||||
<div className={styles.main}>
|
||||
<Header confirmationNumber={searchParams.confirmationNumber} />
|
||||
<Rooms />
|
||||
<Summary />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1 +1,21 @@
|
||||
export { default } from "../page"
|
||||
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import SidePeek from "@/components/HotelReservation/SidePeek"
|
||||
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function HotelSidePeek({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, { hotel: string }>) {
|
||||
if (!searchParams.hotel) {
|
||||
return <SidePeek hotel={null} />
|
||||
}
|
||||
|
||||
const hotel = await getHotelData({
|
||||
hotelId: searchParams.hotel,
|
||||
language: params.lang,
|
||||
})
|
||||
|
||||
return <SidePeek hotel={hotel} />
|
||||
}
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import SidePeek from "@/components/HotelReservation/SidePeek"
|
||||
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function HotelSidePeek({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, { hotel: string }>) {
|
||||
const search = new URLSearchParams(searchParams)
|
||||
const { hotel: hotelId } = getQueryParamsForEnterDetails(search)
|
||||
|
||||
if (!hotelId) {
|
||||
return <SidePeek hotel={null} />
|
||||
}
|
||||
|
||||
const hotel = await getHotelData({
|
||||
hotelId: hotelId,
|
||||
language: params.lang,
|
||||
})
|
||||
|
||||
return <SidePeek hotel={hotel} />
|
||||
export default function HotelSidePeekSlot() {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
export default function LoadingHotelHeader() {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
export default function LoadingSummaryHeader() {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
.mobileSummary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktopSummary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary {
|
||||
background-color: var(--Main-Grey-White);
|
||||
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-bottom: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.mobileSummary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktopSummary {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: sticky;
|
||||
top: calc(
|
||||
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
|
||||
var(--Spacing-x-half)
|
||||
);
|
||||
z-index: 10;
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
}
|
||||
|
||||
.shadow {
|
||||
display: block;
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-left-width: 1px;
|
||||
border-right-width: 1px;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.hider {
|
||||
display: block;
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
position: sticky;
|
||||
top: calc(var(--booking-widget-desktop-height) - 6px);
|
||||
margin-top: var(--Spacing-x4);
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
import {
|
||||
getPackages,
|
||||
getProfileSafely,
|
||||
getSelectedRoomAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
|
||||
import { SummaryBottomSheet } from "@/components/HotelReservation/EnterDetails/Summary/BottomSheet"
|
||||
import {
|
||||
generateChildrenString,
|
||||
getQueryParamsForEnterDetails,
|
||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { LangParams, PageArgs, SearchParams } from "@/types/params"
|
||||
|
||||
export default async function SummaryPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, SearchParams<SelectRateSearchParams>>) {
|
||||
const selectRoomParams = new URLSearchParams(searchParams)
|
||||
const { hotel, rooms, fromDate, toDate } =
|
||||
getQueryParamsForEnterDetails(selectRoomParams)
|
||||
|
||||
const {
|
||||
adults,
|
||||
children,
|
||||
roomTypeCode,
|
||||
rateCode,
|
||||
packages: packageCodes,
|
||||
} = rooms[0] // TODO: Handle multiple rooms
|
||||
|
||||
const availability = await getSelectedRoomAvailability({
|
||||
hotelId: hotel,
|
||||
adults,
|
||||
children: children ? generateChildrenString(children) : undefined,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
packageCodes,
|
||||
})
|
||||
const user = await getProfileSafely()
|
||||
|
||||
const packages = packageCodes
|
||||
? await getPackages({
|
||||
hotelId: hotel,
|
||||
startDate: fromDate,
|
||||
endDate: toDate,
|
||||
adults,
|
||||
children: children?.length,
|
||||
packageCodes,
|
||||
})
|
||||
: null
|
||||
|
||||
if (!availability || !availability.selectedRoom) {
|
||||
console.error("No hotel or availability data", availability)
|
||||
// TODO: handle this case
|
||||
redirect(selectRate(params.lang))
|
||||
}
|
||||
|
||||
const prices = {
|
||||
public: {
|
||||
local: {
|
||||
amount: availability.publicRate.localPrice.pricePerStay,
|
||||
currency: availability.publicRate.localPrice.currency,
|
||||
},
|
||||
euro: availability.publicRate?.requestedPrice
|
||||
? {
|
||||
amount: availability.publicRate?.requestedPrice.pricePerStay,
|
||||
currency: availability.publicRate?.requestedPrice.currency,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
member: availability.memberRate
|
||||
? {
|
||||
local: {
|
||||
amount: availability.memberRate.localPrice.pricePerStay,
|
||||
currency: availability.memberRate.localPrice.currency,
|
||||
},
|
||||
euro: availability.memberRate.requestedPrice
|
||||
? {
|
||||
amount: availability.memberRate.requestedPrice.pricePerStay,
|
||||
currency: availability.memberRate.requestedPrice.currency,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.mobileSummary}>
|
||||
<SummaryBottomSheet>
|
||||
<div className={styles.summary}>
|
||||
<Summary
|
||||
showMemberPrice={!!(user && availability.memberRate)}
|
||||
room={{
|
||||
roomType: availability.selectedRoom.roomType,
|
||||
prices,
|
||||
adults,
|
||||
children,
|
||||
rateDetails: availability.rateDetails,
|
||||
cancellationText: availability.cancellationText,
|
||||
packages,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SummaryBottomSheet>
|
||||
</div>
|
||||
<div className={styles.desktopSummary}>
|
||||
<div className={styles.hider} />
|
||||
<div className={styles.summary}>
|
||||
<Summary
|
||||
showMemberPrice={!!(user && availability.memberRate)}
|
||||
room={{
|
||||
roomType: availability.selectedRoom.roomType,
|
||||
prices,
|
||||
adults,
|
||||
children,
|
||||
rateDetails: availability.rateDetails,
|
||||
cancellationText: availability.cancellationText,
|
||||
packages,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.shadow} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import {
|
||||
getCreditCardsSafely,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
export function preload() {
|
||||
void getProfileSafely()
|
||||
void getCreditCardsSafely()
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Due to css import issues with parallel routes we are forced to
|
||||
* use a regular css file and import it in the page.tsx
|
||||
* This is addressed in Next 15: https://github.com/vercel/next.js/pull/66300
|
||||
*/
|
||||
|
||||
.enter-details-layout {
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
}
|
||||
|
||||
.enter-details-layout__container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3) var(--Spacing-x9);
|
||||
/* simulates padding on viewport smaller than --max-width-navigation */
|
||||
}
|
||||
|
||||
.enter-details-layout__content {
|
||||
margin: var(--Spacing-x3) var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.enter-details-layout__summaryContainer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.enter-details-layout__container {
|
||||
grid-template-columns: 1fr 340px;
|
||||
grid-template-rows: auto 1fr;
|
||||
margin: var(--Spacing-x5) auto 0;
|
||||
width: min(
|
||||
calc(100dvw - (var(--Spacing-x2) * 2)),
|
||||
var(--max-width-navigation)
|
||||
);
|
||||
}
|
||||
|
||||
.enter-details-layout__summaryContainer {
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-column: 2/3;
|
||||
grid-row: 1/-1;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
import DetailsProvider from "@/providers/DetailsProvider"
|
||||
|
||||
import { preload } from "./_preload"
|
||||
|
||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
export default async function StepLayout({
|
||||
children,
|
||||
hotelHeader,
|
||||
params,
|
||||
summary,
|
||||
}: React.PropsWithChildren<
|
||||
LayoutArgs<LangParams> & {
|
||||
hotelHeader: React.ReactNode
|
||||
summary: React.ReactNode
|
||||
}
|
||||
>) {
|
||||
setLang(params.lang)
|
||||
preload()
|
||||
|
||||
const user = await getProfileSafely()
|
||||
|
||||
return (
|
||||
<DetailsProvider isMember={!!user}>
|
||||
<main className="enter-details-layout__layout">
|
||||
{hotelHeader}
|
||||
<div className={"enter-details-layout__container"}>
|
||||
<div className={"enter-details-layout__content"}>{children}</div>
|
||||
<aside className={"enter-details-layout__summaryContainer"}>
|
||||
{summary}
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</DetailsProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3) var(--Spacing-x9);
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: var(--Spacing-x3) var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr 340px;
|
||||
grid-template-rows: auto 1fr;
|
||||
margin: var(--Spacing-x5) auto 0;
|
||||
/* simulates padding on viewport smaller than --max-width-navigation */
|
||||
width: min(
|
||||
calc(100dvw - (var(--Spacing-x2) * 2)),
|
||||
var(--max-width-navigation)
|
||||
);
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-column: 2/3;
|
||||
grid-row: 1/-1;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import "./enterDetailsLayout.css"
|
||||
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
@@ -7,6 +5,7 @@ import {
|
||||
getBreakfastPackages,
|
||||
getCreditCardsSafely,
|
||||
getHotelData,
|
||||
getPackages,
|
||||
getProfileSafely,
|
||||
getSelectedRoomAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
@@ -15,15 +14,20 @@ import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
||||
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
||||
import Details from "@/components/HotelReservation/EnterDetails/Details"
|
||||
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
|
||||
import HotelHeader from "@/components/HotelReservation/EnterDetails/HotelHeader"
|
||||
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
||||
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
|
||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
|
||||
import {
|
||||
generateChildrenString,
|
||||
getQueryParamsForEnterDetails,
|
||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import { getIntl } from "@/i18n"
|
||||
import StepsProvider from "@/providers/StepsProvider"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
@@ -37,60 +41,78 @@ export default async function StepPage({
|
||||
params: { lang },
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) {
|
||||
if (!isValidStep(searchParams.step)) {
|
||||
return notFound()
|
||||
}
|
||||
setLang(lang)
|
||||
|
||||
const intl = await getIntl()
|
||||
const selectRoomParams = new URLSearchParams(searchParams)
|
||||
// Deleting step to avoid double searchparams after rewrite
|
||||
selectRoomParams.delete("step")
|
||||
const searchParamsString = selectRoomParams.toString()
|
||||
const booking = getQueryParamsForEnterDetails(selectRoomParams)
|
||||
|
||||
const {
|
||||
hotel: hotelId,
|
||||
rooms,
|
||||
rooms: [
|
||||
{ adults, children, roomTypeCode, rateCode, packages: packageCodes },
|
||||
], // TODO: Handle multiple rooms
|
||||
fromDate,
|
||||
toDate,
|
||||
} = getQueryParamsForEnterDetails(selectRoomParams)
|
||||
|
||||
const {
|
||||
adults,
|
||||
children,
|
||||
roomTypeCode,
|
||||
rateCode,
|
||||
packages: packageCodes,
|
||||
} = rooms[0] // TODO: Handle multiple rooms
|
||||
} = booking
|
||||
|
||||
const childrenAsString = children && generateChildrenString(children)
|
||||
|
||||
const breakfastInput = { adults, fromDate, hotelId, toDate }
|
||||
void getBreakfastPackages(breakfastInput)
|
||||
void getSelectedRoomAvailability({
|
||||
hotelId,
|
||||
const selectedRoomAvailabilityInput = {
|
||||
adults,
|
||||
children: childrenAsString,
|
||||
hotelId,
|
||||
packageCodes,
|
||||
rateCode,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
packageCodes,
|
||||
})
|
||||
}
|
||||
|
||||
const roomAvailability = await getSelectedRoomAvailability({
|
||||
hotelId,
|
||||
adults,
|
||||
children: childrenAsString,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
packageCodes,
|
||||
})
|
||||
void getProfileSafely()
|
||||
void getCreditCardsSafely()
|
||||
void getBreakfastPackages(breakfastInput)
|
||||
void getSelectedRoomAvailability(selectedRoomAvailabilityInput)
|
||||
if (packageCodes?.length) {
|
||||
void getPackages({
|
||||
adults,
|
||||
children: children?.length,
|
||||
endDate: toDate,
|
||||
hotelId,
|
||||
packageCodes,
|
||||
startDate: fromDate,
|
||||
})
|
||||
}
|
||||
|
||||
const packages = packageCodes
|
||||
? await getPackages({
|
||||
adults,
|
||||
children: children?.length,
|
||||
endDate: toDate,
|
||||
hotelId,
|
||||
packageCodes,
|
||||
startDate: fromDate,
|
||||
})
|
||||
: null
|
||||
|
||||
const roomAvailability = await getSelectedRoomAvailability(
|
||||
selectedRoomAvailabilityInput
|
||||
)
|
||||
const hotelData = await getHotelData({
|
||||
hotelId,
|
||||
language: lang,
|
||||
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
|
||||
language: lang,
|
||||
})
|
||||
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
||||
const user = await getProfileSafely()
|
||||
const savedCreditCards = await getCreditCardsSafely()
|
||||
|
||||
if (!isValidStep(searchParams.step) || !hotelData || !roomAvailability) {
|
||||
if (!hotelData || !roomAvailability) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
@@ -116,75 +138,102 @@ export default async function StepPage({
|
||||
|
||||
const memberPrice = roomAvailability.memberRate
|
||||
? {
|
||||
price: roomAvailability.memberRate.localPrice.pricePerStay,
|
||||
currency: roomAvailability.memberRate.localPrice.currency,
|
||||
}
|
||||
price: roomAvailability.memberRate.localPrice.pricePerStay,
|
||||
currency: roomAvailability.memberRate.localPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<StepsProvider
|
||||
<EnterDetailsProvider
|
||||
bedTypes={roomAvailability.bedTypes}
|
||||
booking={booking}
|
||||
breakfastPackages={breakfastPackages}
|
||||
isMember={!!user}
|
||||
searchParams={searchParamsString}
|
||||
packages={packages}
|
||||
roomRate={{
|
||||
memberRate: roomAvailability.memberRate,
|
||||
publicRate: roomAvailability.publicRate,
|
||||
}}
|
||||
searchParamsStr={selectRoomParams.toString()}
|
||||
step={searchParams.step}
|
||||
user={user}
|
||||
>
|
||||
<section>
|
||||
<HistoryStateManager />
|
||||
<SelectedRoom
|
||||
hotelId={hotelId}
|
||||
room={roomAvailability.selectedRoom}
|
||||
rateDescription={roomAvailability.cancellationText}
|
||||
/>
|
||||
<main>
|
||||
<HotelHeader hotel={hotelData.data.attributes} />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<section>
|
||||
<HistoryStateManager />
|
||||
<SelectedRoom
|
||||
hotelId={hotelId}
|
||||
room={roomAvailability.selectedRoom}
|
||||
rateDescription={roomAvailability.cancellationText}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
{/* TODO: How to handle no beds found? */}
|
||||
{roomAvailability.bedTypes ? (
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Select bed" })}
|
||||
label={intl.formatMessage({ id: "Request bedtype" })}
|
||||
step={StepEnum.selectBed}
|
||||
>
|
||||
<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}
|
||||
{breakfastPackages?.length ? (
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Food options" })}
|
||||
label={intl.formatMessage({ id: "Select breakfast options" })}
|
||||
step={StepEnum.breakfast}
|
||||
>
|
||||
<Breakfast packages={breakfastPackages} />
|
||||
</SectionAccordion>
|
||||
) : null}
|
||||
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Details" })}
|
||||
step={StepEnum.details}
|
||||
label={intl.formatMessage({ id: "Enter your details" })}
|
||||
>
|
||||
<Details user={user} memberPrice={memberPrice} />
|
||||
</SectionAccordion>
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Details" })}
|
||||
step={StepEnum.details}
|
||||
label={intl.formatMessage({ id: "Enter your details" })}
|
||||
>
|
||||
<Details user={user} memberPrice={memberPrice} />
|
||||
</SectionAccordion>
|
||||
|
||||
<SectionAccordion
|
||||
header={mustBeGuaranteed ? paymentGuarantee : payment}
|
||||
step={StepEnum.payment}
|
||||
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
|
||||
>
|
||||
<Suspense>
|
||||
<Payment
|
||||
user={user}
|
||||
roomPrice={roomPrice}
|
||||
otherPaymentOptions={
|
||||
hotelData.data.attributes.merchantInformationData
|
||||
.alternatePaymentOptions
|
||||
}
|
||||
savedCreditCards={savedCreditCards}
|
||||
mustBeGuaranteed={mustBeGuaranteed}
|
||||
<SectionAccordion
|
||||
header={mustBeGuaranteed ? paymentGuarantee : payment}
|
||||
step={StepEnum.payment}
|
||||
label={
|
||||
mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod
|
||||
}
|
||||
>
|
||||
<Suspense>
|
||||
<Payment
|
||||
user={user}
|
||||
roomPrice={roomPrice}
|
||||
otherPaymentOptions={
|
||||
hotelData.data.attributes.merchantInformationData
|
||||
.alternatePaymentOptions
|
||||
}
|
||||
savedCreditCards={savedCreditCards}
|
||||
mustBeGuaranteed={mustBeGuaranteed}
|
||||
/>
|
||||
</Suspense>
|
||||
</SectionAccordion>
|
||||
</section>
|
||||
</div>
|
||||
<aside className={styles.summary}>
|
||||
<Summary
|
||||
adults={adults}
|
||||
fromDate={fromDate}
|
||||
hotelId={hotelId}
|
||||
kids={children}
|
||||
packageCodes={packageCodes}
|
||||
rateCode={rateCode}
|
||||
roomTypeCode={roomTypeCode}
|
||||
toDate={toDate}
|
||||
/>
|
||||
</Suspense>
|
||||
</SectionAccordion>
|
||||
</section>
|
||||
</StepsProvider>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</EnterDetailsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
content: "·";
|
||||
margin-left: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
&::after {
|
||||
content: "";
|
||||
@@ -56,12 +57,14 @@
|
||||
.details {
|
||||
padding: var(--Spacing-x6) var(--Spacing-x5) var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.bottomContainer {
|
||||
border-top: 1px solid var(--Base-Text-Medium-contrast);
|
||||
padding-top: var(--Spacing-x2);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navigationContainer {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
.mainNavigationItem {
|
||||
padding: var(--Spacing-x3) 0;
|
||||
border-bottom: 1px solid var(--Base-Border-Normal);
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
.actions {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: grid;
|
||||
grid-area: actions;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.actions {
|
||||
& > button[class*="btn"][class*="icon"][class*="small"] {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: 0;
|
||||
justify-content: space-between;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
& > svg {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.actions {
|
||||
gap: var(--Spacing-x1);
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.actions {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: grid;
|
||||
grid-area: actions;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.actions {
|
||||
gap: var(--Spacing-x3);
|
||||
grid-auto-columns: auto;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
CalendarIcon,
|
||||
ContractIcon,
|
||||
DownloadIcon,
|
||||
PrinterIcon,
|
||||
} from "@/components/Icons"
|
||||
import { CalendarAddIcon, DownloadIcon, EditIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./actions.module.css"
|
||||
@@ -15,20 +9,13 @@ export default async function Actions() {
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<CalendarIcon />
|
||||
<CalendarAddIcon />
|
||||
{intl.formatMessage({ id: "Add to calendar" })}
|
||||
</Button>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<ContractIcon />
|
||||
{intl.formatMessage({ id: "View terms" })}
|
||||
<EditIcon />
|
||||
{intl.formatMessage({ id: "Manage booking" })}
|
||||
</Button>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<PrinterIcon />
|
||||
{intl.formatMessage({ id: "Print confirmation" })}
|
||||
</Button>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<DownloadIcon />
|
||||
{intl.formatMessage({ id: "Download invoice" })}
|
||||
@@ -1,12 +1,12 @@
|
||||
.header,
|
||||
.hgroup {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
gap: var(--Spacing-x3);
|
||||
gap: var(--Spacing-x2);
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
.hgroup {
|
||||
@@ -14,5 +14,5 @@
|
||||
}
|
||||
|
||||
.body {
|
||||
max-width: 560px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import Actions from "./Actions"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
@@ -30,31 +31,15 @@ export default async function Header({
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<hgroup className={styles.hgroup}>
|
||||
<BiroScript color="red" tilted="small" type="two">
|
||||
{intl.formatMessage({ id: "See you soon!" })}
|
||||
</BiroScript>
|
||||
<Title
|
||||
as="h4"
|
||||
color="red"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
type="h2"
|
||||
>
|
||||
<Title as="h2" color="red" textTransform="uppercase" type="h2">
|
||||
{intl.formatMessage({ id: "booking.confirmation.title" })}
|
||||
</Title>
|
||||
<Title
|
||||
as="h4"
|
||||
color="burgundy"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
type="h1"
|
||||
>
|
||||
<Title as="h2" color="burgundy" textTransform="uppercase" type="h1">
|
||||
{hotel.name}
|
||||
</Title>
|
||||
</hgroup>
|
||||
<Body className={styles.body} textAlign="center">
|
||||
{text}
|
||||
</Body>
|
||||
<Body className={styles.body}>{text}</Body>
|
||||
<Actions />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import styles from "./room.module.css"
|
||||
|
||||
export default function Room() {
|
||||
return <article className={styles.room}></article>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function Rooms() {
|
||||
return <section className={styles.rooms}></section>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.rooms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x9);
|
||||
grid-area: booking;
|
||||
}
|
||||
@@ -1,154 +1,5 @@
|
||||
import { profile } from "@/constants/routes/myPages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getBookingConfirmation,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { CreditCardAddIcon, EditIcon, PersonIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default async function Summary({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
const user = await getProfileSafely()
|
||||
const { firstName, lastName } = booking.guest
|
||||
const membershipNumber = user?.membership?.membershipNumber
|
||||
const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff(
|
||||
dt(booking.checkInDate.setHours(0, 0, 0)),
|
||||
"days"
|
||||
)
|
||||
|
||||
const breakfastPackage = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
return (
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Guest" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{`${firstName} ${lastName}`}</Body>
|
||||
{membershipNumber ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "membership.no" },
|
||||
{ membershipNumber }
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
|
||||
<Body color="uiTextHighContrast">{booking.guest.phoneNumber}</Body>
|
||||
</div>
|
||||
{user ? (
|
||||
<Link className={styles.link} href={profile[lang]} variant="icon">
|
||||
<PersonIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Go to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "guest.paid" },
|
||||
{
|
||||
amount: intl.formatNumber(booking.totalPrice),
|
||||
currency: booking.currencyCode,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">Date information N/A</Body>
|
||||
<Body color="uiTextHighContrast">Card information N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
{user ? (
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<CreditCardAddIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Save card to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Booking" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })}
|
||||
,{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: booking.adults }
|
||||
)}
|
||||
</Body>
|
||||
{breakfastPackage ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast added" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">Bedtype N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<EditIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Manage booking" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Hotel" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{hotel.name}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hotel.contactInformation.phoneNumber}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast" className={styles.latLong}>
|
||||
{`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.hotelLinks}>
|
||||
<Link color="peach80" href={hotel.contactInformation.websiteUrl}>
|
||||
{hotel.contactInformation.websiteUrl}
|
||||
</Link>
|
||||
<Link
|
||||
color="peach80"
|
||||
href={`mailto:${hotel.contactInformation.email}`}
|
||||
>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
export default function Summary() {
|
||||
return <aside className={styles.summary}>SUMMARY</aside>
|
||||
}
|
||||
|
||||
@@ -1,31 +1,4 @@
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container,
|
||||
.textContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.container .textContainer .latLong {
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.hotelLinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary .container .link {
|
||||
gap: var(--Spacing-x1);
|
||||
background-color: hotpink;
|
||||
grid-area: summary;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { profile } from "@/constants/routes/myPages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getBookingConfirmation,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { CreditCardAddIcon, EditIcon, PersonIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default async function Summary({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
const user = await getProfileSafely()
|
||||
const { firstName, lastName } = booking.guest
|
||||
const membershipNumber = user?.membership?.membershipNumber
|
||||
const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff(
|
||||
dt(booking.checkInDate.setHours(0, 0, 0)),
|
||||
"days"
|
||||
)
|
||||
|
||||
const breakfastPackage = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
return (
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Guest" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{`${firstName} ${lastName}`}</Body>
|
||||
{membershipNumber ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "membership.no" },
|
||||
{ membershipNumber }
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
|
||||
<Body color="uiTextHighContrast">{booking.guest.phoneNumber}</Body>
|
||||
</div>
|
||||
{user ? (
|
||||
<Link className={styles.link} href={profile[lang]} variant="icon">
|
||||
<PersonIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Go to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "guest.paid" },
|
||||
{
|
||||
amount: intl.formatNumber(booking.totalPrice),
|
||||
currency: booking.currencyCode,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">Date information N/A</Body>
|
||||
<Body color="uiTextHighContrast">Card information N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
{user ? (
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<CreditCardAddIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Save card to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Booking" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })}
|
||||
,{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: booking.adults }
|
||||
)}
|
||||
</Body>
|
||||
{breakfastPackage ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast added" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">Bedtype N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<EditIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Manage booking" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Hotel" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{hotel.name}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hotel.contactInformation.phoneNumber}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast" className={styles.latLong}>
|
||||
{`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.hotelLinks}>
|
||||
<Link color="peach80" href={hotel.contactInformation.websiteUrl}>
|
||||
{hotel.contactInformation.websiteUrl}
|
||||
</Link>
|
||||
<Link
|
||||
color="peach80"
|
||||
href={`mailto:${hotel.contactInformation.email}`}
|
||||
>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container,
|
||||
.textContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.container .textContainer .latLong {
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.hotelLinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary .container .link {
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x9);
|
||||
}
|
||||
|
||||
.booking {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
grid-template-areas:
|
||||
"image"
|
||||
"details"
|
||||
"actions";
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.booking {
|
||||
grid-template-areas:
|
||||
"details image"
|
||||
"actions actions";
|
||||
grid-template-columns: 1fr minmax(256px, min(256px, 100%));
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import Actions from "./Actions"
|
||||
import Details from "./Details"
|
||||
import Header from "./Header"
|
||||
import HotelImage from "./HotelImage"
|
||||
import Summary from "./Summary"
|
||||
import TotalPrice from "./TotalPrice"
|
||||
|
||||
import styles from "./bookingConfirmation.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default function BookingConfirmation({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
return (
|
||||
<>
|
||||
<Header confirmationNumber={confirmationNumber} />
|
||||
<section className={styles.section}>
|
||||
<div className={styles.booking}>
|
||||
<Details confirmationNumber={confirmationNumber} />
|
||||
<HotelImage confirmationNumber={confirmationNumber} />
|
||||
<Actions />
|
||||
</div>
|
||||
{/* Supposed Ancillaries */}
|
||||
<Summary confirmationNumber={confirmationNumber} />
|
||||
<TotalPrice confirmationNumber={confirmationNumber} />
|
||||
{/* Supposed Info Card - Where should it come from?? */}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { KingBedIcon } from "@/components/Icons"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
@@ -20,9 +19,15 @@ import type {
|
||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
|
||||
export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
const bedType = useDetailsStore((state) => state.data.bedType?.roomTypeCode)
|
||||
const completeStep = useStepsStore((state) => state.completeStep)
|
||||
const updateBedType = useDetailsStore((state) => state.actions.updateBedType)
|
||||
const bedType = useEnterDetailsStore(
|
||||
(state) => state.formValues?.bedType?.roomTypeCode
|
||||
)
|
||||
const completeStep = useEnterDetailsStore(
|
||||
(state) => state.actions.completeStep
|
||||
)
|
||||
const updateBedType = useEnterDetailsStore(
|
||||
(state) => state.actions.updateBedType
|
||||
)
|
||||
|
||||
const methods = useForm<BedTypeFormSchema>({
|
||||
defaultValues: bedType ? { bedType } : undefined,
|
||||
@@ -43,10 +48,9 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
roomTypeCode: matchingRoom.value,
|
||||
}
|
||||
updateBedType(bedType)
|
||||
completeStep()
|
||||
}
|
||||
},
|
||||
[bedTypes, completeStep, updateBedType]
|
||||
[bedTypes, updateBedType]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useCallback, useEffect } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
@@ -24,20 +23,30 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
export default function Breakfast({ packages }: BreakfastProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const breakfast = useDetailsStore(({ data }) =>
|
||||
data.breakfast
|
||||
? data.breakfast.code
|
||||
: data.breakfast === false
|
||||
const formValuesBreakfast = useEnterDetailsStore(({ formValues }) =>
|
||||
formValues?.breakfast
|
||||
? formValues.breakfast.code
|
||||
: formValues?.breakfast === false
|
||||
? "false"
|
||||
: data.breakfast
|
||||
: undefined
|
||||
)
|
||||
const updateBreakfast = useDetailsStore(
|
||||
const breakfast = useEnterDetailsStore((state) =>
|
||||
state.breakfast
|
||||
? state.breakfast.code
|
||||
: state.breakfast === false
|
||||
? "false"
|
||||
: undefined
|
||||
)
|
||||
const completeStep = useEnterDetailsStore(
|
||||
(state) => state.actions.completeStep
|
||||
)
|
||||
const updateBreakfast = useEnterDetailsStore(
|
||||
(state) => state.actions.updateBreakfast
|
||||
)
|
||||
const completeStep = useStepsStore((state) => state.completeStep)
|
||||
|
||||
const methods = useForm<BreakfastFormSchema>({
|
||||
defaultValues: breakfast ? { breakfast } : undefined,
|
||||
defaultValues: formValuesBreakfast
|
||||
? { breakfast: formValuesBreakfast }
|
||||
: undefined,
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(breakfastFormSchema),
|
||||
@@ -52,9 +61,8 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
} else {
|
||||
updateBreakfast(false)
|
||||
}
|
||||
completeStep()
|
||||
},
|
||||
[completeStep, packages, updateBreakfast]
|
||||
[packages, updateBreakfast]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./joinScandicFriendsCard.module.css"
|
||||
|
||||
import { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
export default function JoinScandicFriendsCard({
|
||||
name,
|
||||
@@ -65,7 +65,6 @@ export default function JoinScandicFriendsCard({
|
||||
position="enter details"
|
||||
trackingId="join-scandic-friends-enter-details"
|
||||
variant="breadcrumb"
|
||||
target="_blank"
|
||||
>
|
||||
{intl.formatMessage({ id: "Log in" })}
|
||||
</LoginButton>
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useCallback } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
@@ -27,20 +26,11 @@ import type {
|
||||
const formID = "enter-details"
|
||||
export default function Details({ user, memberPrice }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
const initialData = useDetailsStore((state) => ({
|
||||
countryCode: state.data.countryCode,
|
||||
email: state.data.email,
|
||||
firstName: state.data.firstName,
|
||||
lastName: state.data.lastName,
|
||||
phoneNumber: state.data.phoneNumber,
|
||||
join: state.data.join,
|
||||
dateOfBirth: state.data.dateOfBirth,
|
||||
zipCode: state.data.zipCode,
|
||||
membershipNo: state.data.membershipNo,
|
||||
}))
|
||||
const initialData = useEnterDetailsStore((state) => state.formValues.guest)
|
||||
|
||||
const updateDetails = useDetailsStore((state) => state.actions.updateDetails)
|
||||
const completeStep = useStepsStore((state) => state.completeStep)
|
||||
const updateDetails = useEnterDetailsStore(
|
||||
(state) => state.actions.updateDetails
|
||||
)
|
||||
|
||||
const methods = useForm<DetailsSchema>({
|
||||
defaultValues: {
|
||||
@@ -63,9 +53,8 @@ export default function Details({ user, memberPrice }: DetailsProps) {
|
||||
const onSubmit = useCallback(
|
||||
(values: DetailsSchema) => {
|
||||
updateDetails(values)
|
||||
completeStep()
|
||||
},
|
||||
[completeStep, updateDetails]
|
||||
[updateDetails]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -38,7 +38,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
join: z.literal<boolean>(true),
|
||||
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
||||
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
|
||||
membershipNo: z.string().optional(),
|
||||
membershipNo: z.string().default(""),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -50,13 +50,16 @@ export const guestDetailsSchema = z.discriminatedUnion("join", [
|
||||
// For signed in users we accept partial or invalid data. Users cannot
|
||||
// change their info in this flow, so we don't want to validate it.
|
||||
export const signedInDetailsSchema = z.object({
|
||||
countryCode: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
phoneNumber: z.string().optional(),
|
||||
countryCode: z.string().default(""),
|
||||
email: z.string().default(""),
|
||||
firstName: z.string().default(""),
|
||||
lastName: z.string().default(""),
|
||||
membershipNo: z.string().default(""),
|
||||
phoneNumber: z.string().default(""),
|
||||
join: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.transform((_) => false),
|
||||
dateOfBirth: z.string().default(""),
|
||||
zipCode: z.string().default(""),
|
||||
})
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
export default function HistoryStateManager() {
|
||||
const setCurrentStep = useStepsStore((state) => state.setStep)
|
||||
const currentStep = useStepsStore((state) => state.currentStep)
|
||||
const setCurrentStep = useEnterDetailsStore((state) => state.actions.setStep)
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
|
||||
const handleBackButton = useCallback(
|
||||
(event: PopStateEvent) => {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -9,28 +5,12 @@ import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
import getSingleDecimal from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
import styles from "./header.module.css"
|
||||
|
||||
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 hotelData = await getHotelData({
|
||||
hotelId: searchParams.hotel,
|
||||
language: params.lang,
|
||||
})
|
||||
if (!hotelData?.data) {
|
||||
redirect(home)
|
||||
}
|
||||
import type { HotelHeaderProps } from "@/types/components/hotelReservation/enterDetails/hotelHeader"
|
||||
|
||||
export default async function HotelHeader({ hotel }: HotelHeaderProps) {
|
||||
const intl = await getIntl()
|
||||
const hotel = hotelData.data.attributes
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.wrapper}>
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { detailsStorageName } from "@/stores/details"
|
||||
import { detailsStorageName } from "@/stores/enter-details"
|
||||
|
||||
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import { DetailsState } from "@/types/stores/details"
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
|
||||
export default function PaymentCallback({
|
||||
returnUrl,
|
||||
@@ -25,10 +25,10 @@ export default function PaymentCallback({
|
||||
if (bookingData) {
|
||||
const detailsStorage: Record<
|
||||
"state",
|
||||
Pick<DetailsState, "data">
|
||||
Pick<DetailsState, "booking">
|
||||
> = JSON.parse(bookingData)
|
||||
const searchParams = createQueryParamsForEnterDetails(
|
||||
detailsStorage.state.data.booking,
|
||||
detailsStorage.state.booking,
|
||||
searchObject
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
@@ -41,7 +41,7 @@ import { PaymentFormData, paymentSchema } from "./schema"
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
import type { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
const maxRetries = 4
|
||||
const retryInterval = 2000
|
||||
@@ -63,9 +63,14 @@ export default function Payment({
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const searchParams = useSearchParams()
|
||||
const { booking, ...userData } = useDetailsStore((state) => state.data)
|
||||
const totalPrice = useDetailsStore((state) => state.totalPrice)
|
||||
const setIsSubmittingDisabled = useDetailsStore(
|
||||
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
|
||||
const { bedType, booking, breakfast } = useEnterDetailsStore((state) => ({
|
||||
bedType: state.bedType,
|
||||
booking: state.booking,
|
||||
breakfast: state.breakfast,
|
||||
}))
|
||||
const userData = useEnterDetailsStore((state) => state.guest)
|
||||
const setIsSubmittingDisabled = useEnterDetailsStore(
|
||||
(state) => state.actions.setIsSubmittingDisabled
|
||||
)
|
||||
|
||||
@@ -181,8 +186,6 @@ export default function Payment({
|
||||
email,
|
||||
phoneNumber,
|
||||
countryCode,
|
||||
breakfast,
|
||||
bedType,
|
||||
membershipNo,
|
||||
join,
|
||||
dateOfBirth,
|
||||
@@ -243,10 +246,10 @@ export default function Payment({
|
||||
paymentMethod,
|
||||
card: savedCreditCard
|
||||
? {
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
success: `${paymentRedirectUrl}/success`,
|
||||
@@ -256,6 +259,8 @@ export default function Payment({
|
||||
})
|
||||
},
|
||||
[
|
||||
breakfast,
|
||||
bedType,
|
||||
userData,
|
||||
booking,
|
||||
roomPrice,
|
||||
@@ -308,7 +313,7 @@ export default function Payment({
|
||||
value={savedCreditCard.id}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[
|
||||
savedCreditCard.cardType as PaymentMethodEnum
|
||||
savedCreditCard.cardType as PaymentMethodEnum
|
||||
]
|
||||
}
|
||||
cardNumber={savedCreditCard.truncatedNumber}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
@@ -11,53 +10,50 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./sectionAccordion.module.css"
|
||||
|
||||
import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step"
|
||||
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||
import type { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
|
||||
export default function SectionAccordion({
|
||||
children,
|
||||
header,
|
||||
label,
|
||||
step,
|
||||
children,
|
||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||
const intl = useIntl()
|
||||
const currentStep = useStepsStore((state) => state.currentStep)
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const isValid = useDetailsStore((state) => state.isValid[step])
|
||||
const navigate = useStepsStore((state) => state.navigate)
|
||||
const stepData = useDetailsStore((state) => state.data)
|
||||
const stepStoreKey = StepStoreKeys[step]
|
||||
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
||||
const navigate = useEnterDetailsStore((state) => state.actions.navigate)
|
||||
const { bedType, breakfast } = useEnterDetailsStore((state) => ({
|
||||
bedType: state.bedType,
|
||||
breakfast: state.breakfast,
|
||||
}))
|
||||
const [title, setTitle] = useState(label)
|
||||
|
||||
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
|
||||
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
|
||||
useEffect(() => {
|
||||
if (step === StepEnum.selectBed) {
|
||||
const value = stepData.bedType
|
||||
value && setTitle(value.description)
|
||||
if (step === StepEnum.selectBed && bedType) {
|
||||
setTitle(bedType.description)
|
||||
}
|
||||
// If breakfast step, check if an option has been selected
|
||||
if (
|
||||
step === StepEnum.breakfast &&
|
||||
(stepData.breakfast || stepData.breakfast === false)
|
||||
) {
|
||||
const value = stepData.breakfast
|
||||
if (value === false) {
|
||||
setTitle(intl.formatMessage({ id: "No breakfast" }))
|
||||
if (step === StepEnum.breakfast && breakfast !== undefined) {
|
||||
if (breakfast === false) {
|
||||
setTitle(noBreakfastTitle)
|
||||
} else {
|
||||
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
|
||||
setTitle(breakfastTitle)
|
||||
}
|
||||
}
|
||||
}, [stepData, stepStoreKey, step, intl])
|
||||
}, [bedType, breakfast, setTitle, step, breakfastTitle, noBreakfastTitle])
|
||||
|
||||
useEffect(() => {
|
||||
// We need to set the state on mount because of hydration errors
|
||||
setIsComplete(isValid)
|
||||
}, [isValid])
|
||||
}, [isValid, setIsComplete])
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(currentStep === step)
|
||||
}, [currentStep, step])
|
||||
}, [currentStep, setIsOpen, step])
|
||||
|
||||
function onModify() {
|
||||
navigate(step)
|
||||
|
||||
@@ -8,7 +8,7 @@ import ChevronRight from "@/components/Icons/ChevronRight"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({
|
||||
hotelId,
|
||||
|
||||
@@ -14,7 +14,7 @@ import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./selectedRoom.module.css"
|
||||
|
||||
import { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room"
|
||||
import type { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room"
|
||||
|
||||
export default function SelectedRoom({
|
||||
hotelId,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { hotelreservation } from "@/constants/routes/hotelReservation"
|
||||
import { detailsStorageName } from "@/stores/details"
|
||||
import { detailsStorageName } from "@/stores/enter-details"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
|
||||
95
components/HotelReservation/EnterDetails/Summary/Client.tsx
Normal file
95
components/HotelReservation/EnterDetails/Summary/Client.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import Summary from "@/components/HotelReservation/Summary"
|
||||
import { SummaryBottomSheet } from "@/components/HotelReservation/Summary/BottomSheet"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import type { ClientSummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
|
||||
function storeSelector(state: DetailsState) {
|
||||
return {
|
||||
bedType: state.bedType,
|
||||
breakfast: state.breakfast,
|
||||
fromDate: state.booking.fromDate,
|
||||
join: state.guest.join,
|
||||
membershipNo: state.guest.membershipNo,
|
||||
packages: state.packages,
|
||||
roomRate: state.roomRate,
|
||||
roomPrice: state.roomPrice,
|
||||
toDate: state.booking.toDate,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
}
|
||||
}
|
||||
|
||||
export default function ClientSummary({
|
||||
adults,
|
||||
cancellationText,
|
||||
isMember,
|
||||
kids,
|
||||
memberRate,
|
||||
rateDetails,
|
||||
roomType,
|
||||
}: ClientSummaryProps) {
|
||||
const {
|
||||
bedType,
|
||||
breakfast,
|
||||
fromDate,
|
||||
join,
|
||||
membershipNo,
|
||||
packages,
|
||||
roomPrice,
|
||||
toDate,
|
||||
totalPrice,
|
||||
} = useEnterDetailsStore(storeSelector)
|
||||
|
||||
const showMemberPrice = !!(isMember && memberRate) || join || !!membershipNo
|
||||
const room = {
|
||||
adults,
|
||||
cancellationText,
|
||||
children: kids,
|
||||
packages,
|
||||
rateDetails,
|
||||
roomPrice,
|
||||
roomType,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.mobileSummary}>
|
||||
<SummaryBottomSheet>
|
||||
<div className={styles.summary}>
|
||||
<Summary
|
||||
bedType={bedType}
|
||||
breakfast={breakfast}
|
||||
fromDate={fromDate}
|
||||
showMemberPrice={showMemberPrice}
|
||||
room={room}
|
||||
toDate={toDate}
|
||||
totalPrice={totalPrice}
|
||||
/>
|
||||
</div>
|
||||
</SummaryBottomSheet>
|
||||
</div>
|
||||
<div className={styles.desktopSummary}>
|
||||
<div className={styles.hider} />
|
||||
<div className={styles.summary}>
|
||||
<Summary
|
||||
bedType={bedType}
|
||||
breakfast={breakfast}
|
||||
fromDate={fromDate}
|
||||
showMemberPrice={showMemberPrice}
|
||||
room={room}
|
||||
toDate={toDate}
|
||||
totalPrice={totalPrice}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.shadow} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,325 +1,57 @@
|
||||
"use client"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { ChevronDown } from "react-feather"
|
||||
import { useIntl } from "react-intl"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
import {
|
||||
getProfileSafely,
|
||||
getSelectedRoomAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { generateChildrenString } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Popover from "@/components/TempDesignSystem/Popover"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import ClientSummary from "./Client"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
import type { SummaryPageProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
|
||||
import type { DetailsState } from "@/types/stores/details"
|
||||
export default async function Summary({
|
||||
adults,
|
||||
fromDate,
|
||||
hotelId,
|
||||
kids,
|
||||
packageCodes,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
toDate,
|
||||
}: SummaryPageProps) {
|
||||
const lang = getLang()
|
||||
|
||||
function storeSelector(state: DetailsState) {
|
||||
return {
|
||||
fromDate: state.data.booking.fromDate,
|
||||
toDate: state.data.booking.toDate,
|
||||
bedType: state.data.bedType,
|
||||
breakfast: state.data.breakfast,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
setTotalPrice: state.actions.setTotalPrice,
|
||||
totalPrice: state.totalPrice,
|
||||
join: state.data.join,
|
||||
membershipNo: state.data.membershipNo,
|
||||
const availability = await getSelectedRoomAvailability({
|
||||
adults,
|
||||
children: kids ? generateChildrenString(kids) : undefined,
|
||||
hotelId,
|
||||
packageCodes,
|
||||
rateCode,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
roomTypeCode,
|
||||
})
|
||||
const user = await getProfileSafely()
|
||||
|
||||
if (!availability || !availability.selectedRoom) {
|
||||
console.error("No hotel or availability data", availability)
|
||||
// TODO: handle this case
|
||||
redirect(selectRate(lang))
|
||||
}
|
||||
}
|
||||
|
||||
export default function Summary({ showMemberPrice, room }: SummaryProps) {
|
||||
const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
|
||||
const [chosenBreakfast, setChosenBreakfast] = useState<
|
||||
BreakfastPackage | false
|
||||
>()
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const {
|
||||
bedType,
|
||||
breakfast,
|
||||
fromDate,
|
||||
setTotalPrice,
|
||||
toDate,
|
||||
toggleSummaryOpen,
|
||||
totalPrice,
|
||||
join,
|
||||
membershipNo,
|
||||
} = useDetailsStore(storeSelector)
|
||||
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
const color = useRef<"uiTextHighContrast" | "red">("uiTextHighContrast")
|
||||
const [price, setPrice] = useState(room.prices.public)
|
||||
|
||||
const additionalPackageCost = room.packages?.reduce(
|
||||
(acc, curr) => {
|
||||
acc.local = acc.local + parseInt(curr.localPrice.totalPrice)
|
||||
acc.euro = acc.euro + parseInt(curr.requestedPrice.totalPrice)
|
||||
return acc
|
||||
},
|
||||
{ local: 0, euro: 0 }
|
||||
) || { local: 0, euro: 0 }
|
||||
|
||||
const roomsPriceLocal = price.local.amount + additionalPackageCost.local
|
||||
const roomsPriceEuro = price.euro
|
||||
? price.euro.amount + additionalPackageCost.euro
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (showMemberPrice || join || membershipNo) {
|
||||
color.current = "red"
|
||||
if (room.prices.member) {
|
||||
setPrice(room.prices.member)
|
||||
}
|
||||
} else {
|
||||
color.current = "uiTextHighContrast"
|
||||
setPrice(room.prices.public)
|
||||
}
|
||||
}, [showMemberPrice, join, membershipNo, room.prices])
|
||||
|
||||
useEffect(() => {
|
||||
setChosenBed(bedType)
|
||||
|
||||
if (breakfast || breakfast === false) {
|
||||
setChosenBreakfast(breakfast)
|
||||
if (breakfast === false) {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
amount: roomsPriceLocal,
|
||||
currency: price.local.currency,
|
||||
},
|
||||
euro:
|
||||
price.euro && roomsPriceEuro
|
||||
? {
|
||||
amount: roomsPriceEuro,
|
||||
currency: price.euro.currency,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
} else {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
amount: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
||||
currency: price.local.currency,
|
||||
},
|
||||
euro:
|
||||
price.euro && roomsPriceEuro
|
||||
? {
|
||||
amount:
|
||||
roomsPriceEuro +
|
||||
parseInt(breakfast.requestedPrice.totalPrice),
|
||||
currency: price.euro.currency,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [
|
||||
bedType,
|
||||
breakfast,
|
||||
roomsPriceLocal,
|
||||
price.local.currency,
|
||||
price.euro,
|
||||
roomsPriceEuro,
|
||||
setTotalPrice,
|
||||
])
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header className={styles.header}>
|
||||
<Subtitle className={styles.title} type="two">
|
||||
{intl.formatMessage({ id: "Summary" })}
|
||||
</Subtitle>
|
||||
<Body className={styles.date} color="baseTextMediumContrast">
|
||||
{dt(fromDate).locale(lang).format("ddd, D MMM")}
|
||||
<ArrowRightIcon color="peach80" height={15} width={15} />
|
||||
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
||||
</Body>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
className={styles.chevronButton}
|
||||
onClick={toggleSummaryOpen}
|
||||
>
|
||||
<ChevronDown height="20" width="20" />
|
||||
</Button>
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.addOns}>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Caption color={color.current}>
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(price.local.amount),
|
||||
currency: price.local.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: room.adults }
|
||||
)}
|
||||
</Caption>
|
||||
{room.children?.length ? (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren: room.children.length }
|
||||
)}
|
||||
</Caption>
|
||||
) : null}
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{room.cancellationText}
|
||||
</Caption>
|
||||
<Popover
|
||||
placement="bottom left"
|
||||
triggerContent={
|
||||
<Caption color="burgundy" type="underline">
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Caption>
|
||||
}
|
||||
>
|
||||
<aside className={styles.rateDetailsPopover}>
|
||||
<header>
|
||||
<Caption type="bold">{room.cancellationText}</Caption>
|
||||
</header>
|
||||
{room.rateDetails?.map((detail, idx) => (
|
||||
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
|
||||
))}
|
||||
</aside>
|
||||
</Popover>
|
||||
</div>
|
||||
{room.packages
|
||||
? room.packages.map((roomPackage) => (
|
||||
<div className={styles.entry} key={roomPackage.code}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{roomPackage.description}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: roomPackage.localPrice.price,
|
||||
currency: roomPackage.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
{chosenBed ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">{chosenBed.description}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Based on availability" })}
|
||||
</Caption>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: price.local.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{chosenBreakfast === false ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: price.local.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : chosenBreakfast?.code ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: chosenBreakfast.localPrice.totalPrice,
|
||||
currency: chosenBreakfast.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.total}>
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<Link color="burgundy" href="#" variant="underscored" size="small">
|
||||
{intl.formatMessage({ id: "Price details" })}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
{totalPrice.local.amount > 0 && (
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.local.amount),
|
||||
currency: totalPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
)}
|
||||
{totalPrice.euro && totalPrice.euro.amount > 0 && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.euro.amount),
|
||||
currency: totalPrice.euro.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||
</div>
|
||||
</section>
|
||||
<ClientSummary
|
||||
adults={adults}
|
||||
cancellationText={availability.cancellationText}
|
||||
isMember={!!user}
|
||||
kids={kids}
|
||||
memberRate={availability.memberRate}
|
||||
rateDetails={availability.rateDetails}
|
||||
roomType={availability.selectedRoom.roomType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,83 +1,68 @@
|
||||
.mobileSummary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktopSummary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3);
|
||||
height: 100%;
|
||||
background-color: var(--Main-Grey-White);
|
||||
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-bottom: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-areas: "title button" "date button";
|
||||
.hider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.chevronButton {
|
||||
grid-area: button;
|
||||
justify-self: end;
|
||||
align-items: center;
|
||||
margin-right: calc(0px - var(--Spacing-x2));
|
||||
}
|
||||
|
||||
.date {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: flex-start;
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.addOns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.rateDetailsPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry > :last-child {
|
||||
justify-items: flex-end;
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bottomDivider {
|
||||
.shadow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.bottomDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chevronButton {
|
||||
.mobileSummary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktopSummary {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: sticky;
|
||||
top: calc(
|
||||
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
|
||||
var(--Spacing-x-half)
|
||||
);
|
||||
z-index: 9;
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
}
|
||||
|
||||
.shadow {
|
||||
display: block;
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-left-width: 1px;
|
||||
border-right-width: 1px;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.hider {
|
||||
display: block;
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
position: sticky;
|
||||
top: calc(var(--booking-widget-desktop-height) - 6px);
|
||||
margin-top: var(--Spacing-x4);
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,20 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { formId } from "@/components/HotelReservation/EnterDetails/Payment"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import { formId } from "../../Payment"
|
||||
|
||||
import styles from "./bottomSheet.module.css"
|
||||
|
||||
export function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
|
||||
useDetailsStore((state) => ({
|
||||
useEnterDetailsStore((state) => ({
|
||||
isSummaryOpen: state.isSummaryOpen,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
@@ -38,7 +37,7 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.local.amount),
|
||||
amount: intl.formatNumber(totalPrice.local.price),
|
||||
currency: totalPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
234
components/HotelReservation/Summary/index.tsx
Normal file
234
components/HotelReservation/Summary/index.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { ArrowRightIcon, ChevronDownSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Popover from "@/components/TempDesignSystem/Popover"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
|
||||
export default function Summary({
|
||||
bedType,
|
||||
breakfast,
|
||||
fromDate,
|
||||
showMemberPrice,
|
||||
room,
|
||||
toDate,
|
||||
toggleSummaryOpen,
|
||||
totalPrice,
|
||||
}: SummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
function handleToggleSummary() {
|
||||
if (toggleSummaryOpen) {
|
||||
toggleSummaryOpen()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header className={styles.header}>
|
||||
<Subtitle className={styles.title} type="two">
|
||||
{intl.formatMessage({ id: "Summary" })}
|
||||
</Subtitle>
|
||||
<Body className={styles.date} color="baseTextMediumContrast">
|
||||
{dt(fromDate).locale(lang).format("ddd, D MMM")}
|
||||
<ArrowRightIcon color="peach80" height={15} width={15} />
|
||||
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
||||
</Body>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
className={styles.chevronButton}
|
||||
onClick={handleToggleSummary}
|
||||
>
|
||||
<ChevronDownSmallIcon height="20" width="20" />
|
||||
</Button>
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.addOns}>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Caption color={showMemberPrice ? "red" : "uiTextHighContrast"}>
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(room.roomPrice.local.price),
|
||||
currency: room.roomPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: room.adults }
|
||||
)}
|
||||
</Caption>
|
||||
{room.children?.length ? (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren: room.children.length }
|
||||
)}
|
||||
</Caption>
|
||||
) : null}
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{room.cancellationText}
|
||||
</Caption>
|
||||
<Popover
|
||||
placement="bottom left"
|
||||
triggerContent={
|
||||
<Caption color="burgundy" type="underline">
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Caption>
|
||||
}
|
||||
>
|
||||
<aside className={styles.rateDetailsPopover}>
|
||||
<header>
|
||||
<Caption type="bold">{room.cancellationText}</Caption>
|
||||
</header>
|
||||
{room.rateDetails?.map((detail, idx) => (
|
||||
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
|
||||
))}
|
||||
</aside>
|
||||
</Popover>
|
||||
</div>
|
||||
{room.packages
|
||||
? room.packages.map((roomPackage) => (
|
||||
<div className={styles.entry} key={roomPackage.code}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{roomPackage.description}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: roomPackage.localPrice.price,
|
||||
currency: roomPackage.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
{bedType ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">{bedType.description}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Based on availability" })}
|
||||
</Caption>
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.roomPrice.local.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{breakfast === false ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.roomPrice.local.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
{breakfast ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: breakfast.localPrice.totalPrice,
|
||||
currency: breakfast.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.total}>
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{ id: "<b>Total price</b> (incl VAT)" },
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<Link color="burgundy" href="" variant="underscored" size="small">
|
||||
{intl.formatMessage({ id: "Price details" })}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.local.price, {
|
||||
currency: totalPrice.local.currency,
|
||||
style: "currency",
|
||||
}),
|
||||
currency: totalPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
{totalPrice.euro && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.euro.price, {
|
||||
currency: CurrencyEnum.EUR,
|
||||
style: "currency",
|
||||
}),
|
||||
currency: totalPrice.euro.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
83
components/HotelReservation/Summary/summary.module.css
Normal file
83
components/HotelReservation/Summary/summary.module.css
Normal file
@@ -0,0 +1,83 @@
|
||||
.summary {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-areas: "title button" "date button";
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.chevronButton {
|
||||
grid-area: button;
|
||||
justify-self: end;
|
||||
align-items: center;
|
||||
margin-right: calc(0px - var(--Spacing-x2));
|
||||
}
|
||||
|
||||
.date {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: flex-start;
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.addOns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.rateDetailsPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry > :last-child {
|
||||
justify-items: flex-end;
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bottomDivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.bottomDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary .header .chevronButton {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
31
components/Icons/CalendarAdd.tsx
Normal file
31
components/Icons/CalendarAdd.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function CalendarAddIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M4.25 6.81254H15.75V5.06254H4.25V6.81254ZM4.25 18.125C3.82031 18.125 3.45247 17.972 3.14648 17.6661C2.84049 17.3601 2.6875 16.9922 2.6875 16.5625V5.06254C2.6875 4.63285 2.84049 4.26501 3.14648 3.95902C3.45247 3.65303 3.82031 3.50004 4.25 3.50004H5.1875V2.61462C5.1875 2.39935 5.26389 2.21532 5.41667 2.06254C5.56944 1.90976 5.75347 1.83337 5.96875 1.83337C6.18403 1.83337 6.36806 1.90976 6.52083 2.06254C6.67361 2.21532 6.75 2.39935 6.75 2.61462V3.50004H13.25V2.61462C13.25 2.39935 13.3264 2.21532 13.4792 2.06254C13.6319 1.90976 13.816 1.83337 14.0313 1.83337C14.2465 1.83337 14.4306 1.90976 14.5833 2.06254C14.7361 2.21532 14.8125 2.39935 14.8125 2.61462V3.50004H15.75C16.1797 3.50004 16.5475 3.65303 16.8535 3.95902C17.1595 4.26501 17.3125 4.63285 17.3125 5.06254V11.3917C17.0643 11.2875 16.8095 11.2007 16.5482 11.1313C16.2869 11.0618 16.0208 11.0132 15.75 10.9855V8.37504H4.25V16.5625H11.4896C11.5806 16.849 11.6878 17.1224 11.8113 17.3829C11.9348 17.6433 12.081 17.8907 12.25 18.125H4.25Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
<path
|
||||
d="M13.3333 16.5813H15.0521V18.3C15.0521 18.5153 15.1285 18.6993 15.2812 18.8521C15.434 19.0049 15.6181 19.0813 15.8333 19.0813C16.0486 19.0813 16.2326 19.0049 16.3854 18.8521C16.5382 18.6993 16.6146 18.5153 16.6146 18.3V16.5813H18.3333C18.5486 16.5813 18.7326 16.5049 18.8854 16.3521C19.0382 16.1993 19.1146 16.0153 19.1146 15.8C19.1146 15.5848 19.0382 15.4007 18.8854 15.248C18.7326 15.0952 18.5486 15.0188 18.3333 15.0188H16.6146V13.3C16.6146 13.0848 16.5382 12.9007 16.3854 12.748C16.2326 12.5952 16.0486 12.5188 15.8333 12.5188C15.6181 12.5188 15.434 12.5952 15.2812 12.748C15.1285 12.9007 15.0521 13.0848 15.0521 13.3V15.0188H13.3333C13.1181 15.0188 12.934 15.0952 12.7812 15.248C12.6285 15.4007 12.5521 15.5848 12.5521 15.8C12.5521 16.0153 12.6285 16.1993 12.7812 16.3521C12.934 16.5049 13.1181 16.5813 13.3333 16.5813Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export { default as BreakfastIcon } from "./Breakfast"
|
||||
export { default as BusinessIcon } from "./Business"
|
||||
export { default as CableIcon } from "./Cable"
|
||||
export { default as CalendarIcon } from "./Calendar"
|
||||
export { default as CalendarAddIcon } from "./CalendarAdd"
|
||||
export { default as CameraIcon } from "./Camera"
|
||||
export { default as CellphoneIcon } from "./Cellphone"
|
||||
export { default as ChairIcon } from "./Chair"
|
||||
|
||||
@@ -15,7 +15,6 @@ export default function Card({
|
||||
iconHeight = 32,
|
||||
iconWidth = 32,
|
||||
declined = false,
|
||||
defaultChecked,
|
||||
highlightSubtitle = false,
|
||||
id,
|
||||
list,
|
||||
@@ -58,7 +57,6 @@ export default function Card({
|
||||
<input
|
||||
{...register(name)}
|
||||
aria-hidden
|
||||
defaultChecked={defaultChecked}
|
||||
id={id || name}
|
||||
hidden
|
||||
type={type}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext } from "react"
|
||||
|
||||
import type { DetailsStore } from "@/types/contexts/details"
|
||||
import type { DetailsStore } from "@/types/contexts/enter-details"
|
||||
|
||||
export const DetailsContext = createContext<DetailsStore | null>(null)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createContext } from "react"
|
||||
|
||||
import type { StepsStore } from "@/types/contexts/steps"
|
||||
|
||||
export const StepsContext = createContext<StepsStore | null>(null)
|
||||
@@ -466,8 +466,8 @@
|
||||
"booking.basedOnAvailability": "Based on availability",
|
||||
"booking.bedOptions": "Bed options",
|
||||
"booking.children": "{totalChildren, plural, one {# child} other {# children}}",
|
||||
"booking.confirmation.text": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please <emailLink>email us.</emailLink>",
|
||||
"booking.confirmation.title": "Your booking is confirmed",
|
||||
"booking.confirmation.text": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please <emailLink>contact us.</emailLink>",
|
||||
"booking.confirmation.title": "Booking confirmation",
|
||||
"booking.guests": "Max {nrOfGuests, plural, one {# guest} other {# guests}}",
|
||||
"booking.nights": "{totalNights, plural, one {# night} other {# nights}}",
|
||||
"booking.rooms": "{totalRooms, plural, one {# room} other {# rooms}}",
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
import {
|
||||
GetRoomsAvailabilityInput,
|
||||
GetSelectedRoomAvailabilityInput,
|
||||
HotelDataInput,
|
||||
} from "@/server/routers/hotels/input"
|
||||
|
||||
import { cache } from "@/utils/cache"
|
||||
|
||||
@@ -13,6 +8,11 @@ import type {
|
||||
BreackfastPackagesInput,
|
||||
PackagesInput,
|
||||
} from "@/types/requests/packages"
|
||||
import type {
|
||||
GetRoomsAvailabilityInput,
|
||||
GetSelectedRoomAvailabilityInput,
|
||||
HotelDataInput,
|
||||
} from "@/server/routers/hotels/input"
|
||||
|
||||
export const getLocations = cache(async function getMemoizedLocations() {
|
||||
return serverClient().hotel.locations.get()
|
||||
@@ -60,21 +60,21 @@ export const getUserTracking = cache(async function getMemoizedUserTracking() {
|
||||
return serverClient().user.tracking()
|
||||
})
|
||||
|
||||
export const getHotelData = cache(function getMemoizedHotelData(
|
||||
props: HotelDataInput
|
||||
export const getHotelData = cache(async function getMemoizedHotelData(
|
||||
input: HotelDataInput
|
||||
) {
|
||||
return serverClient().hotel.hotelData.get(props)
|
||||
return serverClient().hotel.hotelData.get(input)
|
||||
})
|
||||
|
||||
export const getHotelPage = cache(async function getMemoizedHotelPage() {
|
||||
return serverClient().contentstack.hotelPage.get()
|
||||
})
|
||||
|
||||
export const getRoomsAvailability = cache(function getMemoizedRoomAvailability(
|
||||
args: GetRoomsAvailabilityInput
|
||||
) {
|
||||
return serverClient().hotel.availability.rooms(args)
|
||||
})
|
||||
export const getRoomsAvailability = cache(
|
||||
async function getMemoizedRoomAvailability(input: GetRoomsAvailabilityInput) {
|
||||
return serverClient().hotel.availability.rooms(input)
|
||||
}
|
||||
)
|
||||
|
||||
export const getSelectedRoomAvailability = cache(
|
||||
function getMemoizedSelectedRoomAvailability(
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -3450,7 +3450,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -3466,7 +3465,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -3482,7 +3480,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3498,7 +3495,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3514,7 +3510,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3530,7 +3525,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3546,7 +3540,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -3562,7 +3555,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -3578,7 +3570,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -20392,4 +20383,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
50
providers/EnterDetailsProvider.tsx
Normal file
50
providers/EnterDetailsProvider.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
import { useRef } from "react"
|
||||
|
||||
import { createDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { DetailsContext } from "@/contexts/Details"
|
||||
|
||||
import type { DetailsStore } from "@/types/contexts/enter-details"
|
||||
import type { DetailsProviderProps } from "@/types/providers/enter-details"
|
||||
import type { InitialState } from "@/types/stores/enter-details"
|
||||
|
||||
export default function EnterDetailsProvider({
|
||||
bedTypes,
|
||||
booking,
|
||||
breakfastPackages,
|
||||
children,
|
||||
packages,
|
||||
roomRate,
|
||||
searchParamsStr,
|
||||
step,
|
||||
user,
|
||||
}: DetailsProviderProps) {
|
||||
const storeRef = useRef<DetailsStore>()
|
||||
|
||||
if (!storeRef.current) {
|
||||
const initialData: InitialState = { booking, packages, roomRate }
|
||||
if (bedTypes.length === 1) {
|
||||
initialData.bedType = {
|
||||
description: bedTypes[0].description,
|
||||
roomTypeCode: bedTypes[0].value,
|
||||
}
|
||||
}
|
||||
if (!breakfastPackages?.length) {
|
||||
initialData.breakfast = false
|
||||
}
|
||||
|
||||
storeRef.current = createDetailsStore(
|
||||
initialData,
|
||||
step,
|
||||
searchParamsStr,
|
||||
user
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailsContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</DetailsContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client"
|
||||
import { useRouter } from "next/navigation"
|
||||
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,
|
||||
searchParams,
|
||||
step,
|
||||
}: StepsProviderProps) {
|
||||
const storeRef = useRef<StepsStore>()
|
||||
const updateBedType = useDetailsStore((state) => state.actions.updateBedType)
|
||||
const updateBreakfast = useDetailsStore(
|
||||
(state) => state.actions.updateBreakfast
|
||||
)
|
||||
const router = useRouter()
|
||||
|
||||
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,
|
||||
searchParams,
|
||||
router.push
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<StepsContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</StepsContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -54,19 +54,20 @@ export const createBookingSchema = z
|
||||
|
||||
// QUERY
|
||||
const extraBedTypesSchema = z.object({
|
||||
quantity: z.number(),
|
||||
bedType: z.nativeEnum(ChildBedTypeEnum),
|
||||
quantity: z.number().int(),
|
||||
})
|
||||
|
||||
const guestSchema = z.object({
|
||||
email: z.string().email().nullable().default(""),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
firstName: z.string().nullable().default(""),
|
||||
lastName: z.string().nullable().default(""),
|
||||
membershipNumber: z.string().nullable().default(""),
|
||||
phoneNumber: phoneValidator().nullable().default(""),
|
||||
})
|
||||
|
||||
const packageSchema = z.object({
|
||||
code: z.string().default(""),
|
||||
code: z.string().nullable().default(""),
|
||||
currency: z.nativeEnum(CurrencyEnum),
|
||||
quantity: z.number().int(),
|
||||
totalPrice: z.number(),
|
||||
@@ -74,35 +75,37 @@ const packageSchema = z.object({
|
||||
unitPrice: z.number(),
|
||||
})
|
||||
|
||||
const rateDefinitionSchema = z.object({
|
||||
breakfastIncluded: z.boolean().default(false),
|
||||
cancellationRule: z.string().nullable().default(""),
|
||||
cancellationText: z.string().nullable().default(""),
|
||||
generalTerms: z.array(z.string()).default([]),
|
||||
isMemberRate: z.boolean().default(false),
|
||||
mustBeGuaranteed: z.boolean().default(false),
|
||||
rateCode: z.string().nullable().default(""),
|
||||
title: z.string().nullable().default(""),
|
||||
})
|
||||
|
||||
export const bookingConfirmationSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
attributes: z.object({
|
||||
adults: z.number(),
|
||||
adults: z.number().int(),
|
||||
checkInDate: z.date({ coerce: true }),
|
||||
checkOutDate: z.date({ coerce: true }),
|
||||
createDateTime: z.date({ coerce: true }),
|
||||
childrenAges: z.array(z.number()),
|
||||
childrenAges: z.array(z.number().int()).default([]),
|
||||
extraBedTypes: z.array(extraBedTypesSchema).default([]),
|
||||
computedReservationStatus: z.string(),
|
||||
confirmationNumber: z.string(),
|
||||
computedReservationStatus: z.string().nullable().default(""),
|
||||
confirmationNumber: z.string().nullable().default(""),
|
||||
currencyCode: z.nativeEnum(CurrencyEnum),
|
||||
guest: guestSchema,
|
||||
hotelId: z.string(),
|
||||
packages: z.array(packageSchema),
|
||||
rateDefinition: z.object({
|
||||
rateCode: z.string(),
|
||||
title: z.string().nullable(),
|
||||
breakfastIncluded: z.boolean(),
|
||||
isMemberRate: z.boolean(),
|
||||
generalTerms: z.array(z.string()).optional(),
|
||||
cancellationRule: z.string().optional(),
|
||||
cancellationText: z.string().optional(),
|
||||
mustBeGuaranteed: z.boolean(),
|
||||
}),
|
||||
reservationStatus: z.string(),
|
||||
roomPrice: z.number().int(),
|
||||
roomTypeCode: z.string(),
|
||||
packages: z.array(packageSchema).default([]),
|
||||
rateDefinition: rateDefinitionSchema,
|
||||
reservationStatus: z.string().nullable().default(""),
|
||||
roomPrice: z.number(),
|
||||
roomTypeCode: z.string().nullable().default(""),
|
||||
totalPrice: z.number(),
|
||||
totalPriceExVat: z.number(),
|
||||
vatAmount: z.number(),
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
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 { arrayMerge } from "@/utils/merge"
|
||||
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { DetailsState, InitialState } from "@/types/stores/details"
|
||||
|
||||
export const detailsStorageName = "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(detailsStorageName)
|
||||
if (detailsStorageUnparsed) {
|
||||
const detailsStorage: Record<
|
||||
"state",
|
||||
Pick<DetailsState, "data">
|
||||
> = JSON.parse(detailsStorageUnparsed)
|
||||
initialState = merge(detailsStorage.state.data, initialState, {
|
||||
arrayMerge,
|
||||
})
|
||||
}
|
||||
}
|
||||
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.zipCode = data.zipCode
|
||||
})
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
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: "", amount: 0 },
|
||||
local: { currency: "", amount: 0 },
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: detailsStorageName,
|
||||
onRehydrateStorage(prevState) {
|
||||
return function (state) {
|
||||
if (state) {
|
||||
const validatedBedType = bedTypeSchema.safeParse(state.data)
|
||||
if (validatedBedType.success !== state.isValid["select-bed"]) {
|
||||
state.isValid["select-bed"] = validatedBedType.success
|
||||
}
|
||||
|
||||
const validatedBreakfast = breakfastStoreSchema.safeParse(
|
||||
state.data
|
||||
)
|
||||
if (validatedBreakfast.success !== state.isValid.breakfast) {
|
||||
state.isValid.breakfast = validatedBreakfast.success
|
||||
}
|
||||
|
||||
const detailsSchema = isMember
|
||||
? signedInDetailsSchema
|
||||
: guestDetailsSchema
|
||||
const validatedDetails = detailsSchema.safeParse(state.data)
|
||||
if (validatedDetails.success !== state.isValid.details) {
|
||||
state.isValid.details = validatedDetails.success
|
||||
}
|
||||
|
||||
const mergedState = merge(state.data, prevState.data, {
|
||||
arrayMerge,
|
||||
})
|
||||
state.data = mergedState
|
||||
}
|
||||
}
|
||||
},
|
||||
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)
|
||||
}
|
||||
354
stores/enter-details/helpers.ts
Normal file
354
stores/enter-details/helpers.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { breakfastPackageSchema } from "@/server/routers/hotels/output"
|
||||
|
||||
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 { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import type { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { DetailsState, RoomRate } from "@/types/stores/enter-details"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
export function langToCurrency() {
|
||||
const lang = getLang()
|
||||
switch (lang) {
|
||||
case Lang.da:
|
||||
return CurrencyEnum.DKK
|
||||
case Lang.de:
|
||||
case Lang.en:
|
||||
case Lang.fi:
|
||||
return CurrencyEnum.EUR
|
||||
case Lang.no:
|
||||
return CurrencyEnum.NOK
|
||||
case Lang.sv:
|
||||
return CurrencyEnum.SEK
|
||||
default:
|
||||
throw new Error(`Unexpected lang: ${lang}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function extractGuestFromUser(user: NonNullable<SafeUser>) {
|
||||
return {
|
||||
countryCode: user.address.countryCode?.toString(),
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
join: false,
|
||||
membershipNo: user.membership?.membershipNumber,
|
||||
phoneNumber: user.phoneNumber ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
export function navigate(step: StepEnum, searchParams: string) {
|
||||
window.history.pushState({ step }, "", `${step}?${searchParams}`)
|
||||
}
|
||||
|
||||
export function checkIsSameBooking(prev: BookingData, next: BookingData) {
|
||||
return (
|
||||
prev.fromDate === next.fromDate ||
|
||||
prev.toDate === next.toDate ||
|
||||
prev.hotel === next.hotel ||
|
||||
prev.rooms[0].adults === next.rooms[0].adults ||
|
||||
prev.rooms[0].children === next.rooms[0].children ||
|
||||
prev.rooms[0].roomTypeCode === next.rooms[0].roomTypeCode
|
||||
)
|
||||
}
|
||||
|
||||
export function validateSteps(currentState: DetailsState, isMember: boolean) {
|
||||
const validPaths = [StepEnum.selectBed]
|
||||
const validatedBedType = bedTypeSchema.safeParse(currentState)
|
||||
if (validatedBedType.success) {
|
||||
currentState.isValid["select-bed"] = true
|
||||
validPaths.push(currentState.steps[1])
|
||||
}
|
||||
|
||||
const validatedBreakfast = breakfastStoreSchema.safeParse(currentState)
|
||||
if (validatedBreakfast.success) {
|
||||
currentState.isValid.breakfast = true
|
||||
validPaths.push(StepEnum.details)
|
||||
}
|
||||
|
||||
const detailsSchema = isMember ? signedInDetailsSchema : guestDetailsSchema
|
||||
const validatedDetails = detailsSchema.safeParse(currentState.guest)
|
||||
// Need to add the breakfast check here too since
|
||||
// when a member comes into the flow, their data is
|
||||
// already added and valid, and thus to avoid showing a
|
||||
// step the user hasn't been on yet as complete
|
||||
if (currentState.isValid.breakfast && validatedDetails.success) {
|
||||
currentState.isValid.details = true
|
||||
validPaths.push(StepEnum.payment)
|
||||
}
|
||||
|
||||
return validPaths
|
||||
}
|
||||
|
||||
export function add(...nums: (number | string | undefined)[]) {
|
||||
return nums.reduce((total: number, num) => {
|
||||
if (typeof num === "undefined") {
|
||||
num = 0
|
||||
}
|
||||
total = total + parseInt(`${num}`)
|
||||
return total
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function subtract(...nums: (number | string | undefined)[]) {
|
||||
return nums.reduce((total: number, num, idx) => {
|
||||
if (typeof num === "undefined") {
|
||||
num = 0
|
||||
}
|
||||
if (idx === 0) {
|
||||
return parseInt(`${num}`)
|
||||
}
|
||||
total = total - parseInt(`${num}`)
|
||||
if (total < 0) {
|
||||
return 0
|
||||
}
|
||||
return total
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function getInitialRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
||||
if (isMember && roomRate.memberRate) {
|
||||
return {
|
||||
euro: {
|
||||
currency: CurrencyEnum.EUR,
|
||||
price: roomRate.memberRate.requestedPrice?.pricePerStay ?? 0,
|
||||
},
|
||||
local: {
|
||||
currency: roomRate.memberRate.localPrice.currency,
|
||||
price: roomRate.memberRate.localPrice.pricePerStay,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
euro: {
|
||||
currency: CurrencyEnum.EUR,
|
||||
price: roomRate.publicRate.requestedPrice?.pricePerStay ?? 0,
|
||||
},
|
||||
local: {
|
||||
currency: roomRate.publicRate.localPrice.currency,
|
||||
price: roomRate.publicRate.localPrice.pricePerStay,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getInitialTotalPrice(roomRate: RoomRate, isMember: boolean) {
|
||||
if (isMember && roomRate.memberRate) {
|
||||
return {
|
||||
euro: {
|
||||
currency: CurrencyEnum.EUR,
|
||||
price: roomRate.memberRate.requestedPrice?.pricePerStay ?? 0,
|
||||
},
|
||||
local: {
|
||||
currency: roomRate.memberRate.localPrice.currency,
|
||||
price: roomRate.memberRate.localPrice.pricePerStay,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
euro: {
|
||||
currency: CurrencyEnum.EUR,
|
||||
price: roomRate.publicRate.requestedPrice?.pricePerStay ?? 0,
|
||||
},
|
||||
local: {
|
||||
currency: roomRate.publicRate.localPrice.currency,
|
||||
price: roomRate.publicRate.localPrice.pricePerStay,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function calcTotalMemberPrice(state: DetailsState) {
|
||||
if (!state.roomRate.memberRate) {
|
||||
return {
|
||||
roomPrice: state.roomPrice,
|
||||
totalPrice: state.totalPrice,
|
||||
}
|
||||
}
|
||||
|
||||
const roomAndTotalPrice = {
|
||||
roomPrice: state.roomPrice,
|
||||
totalPrice: state.totalPrice,
|
||||
}
|
||||
if (state.roomRate.memberRate.requestedPrice?.pricePerStay) {
|
||||
roomAndTotalPrice.roomPrice.euro = {
|
||||
currency: CurrencyEnum.EUR,
|
||||
price: state.roomRate.memberRate.requestedPrice.pricePerStay,
|
||||
}
|
||||
|
||||
let totalPriceEuro = state.roomRate.memberRate.requestedPrice.pricePerStay
|
||||
if (state.breakfast) {
|
||||
totalPriceEuro = add(
|
||||
totalPriceEuro,
|
||||
state.breakfast.requestedPrice.totalPrice
|
||||
)
|
||||
}
|
||||
|
||||
if (state.packages) {
|
||||
totalPriceEuro = state.packages.reduce((total, pkg) => {
|
||||
if (pkg.requestedPrice.totalPrice) {
|
||||
total = add(total, pkg.requestedPrice.totalPrice)
|
||||
}
|
||||
return total
|
||||
}, totalPriceEuro)
|
||||
}
|
||||
|
||||
roomAndTotalPrice.totalPrice.euro = {
|
||||
currency: CurrencyEnum.EUR,
|
||||
price: totalPriceEuro,
|
||||
}
|
||||
}
|
||||
|
||||
const roomPriceLocal = state.roomRate.memberRate.localPrice
|
||||
roomAndTotalPrice.roomPrice.local = {
|
||||
currency: roomPriceLocal.currency,
|
||||
price: roomPriceLocal.pricePerStay,
|
||||
}
|
||||
|
||||
let totalPriceLocal = roomPriceLocal.pricePerStay
|
||||
if (state.breakfast) {
|
||||
totalPriceLocal = add(
|
||||
totalPriceLocal,
|
||||
state.breakfast.localPrice.totalPrice
|
||||
)
|
||||
}
|
||||
|
||||
if (state.packages) {
|
||||
totalPriceLocal = state.packages.reduce((total, pkg) => {
|
||||
if (pkg.localPrice.totalPrice) {
|
||||
total = add(total, pkg.localPrice.totalPrice)
|
||||
}
|
||||
return total
|
||||
}, totalPriceLocal)
|
||||
}
|
||||
roomAndTotalPrice.totalPrice.local = {
|
||||
currency: roomPriceLocal.currency,
|
||||
price: totalPriceLocal,
|
||||
}
|
||||
|
||||
return roomAndTotalPrice
|
||||
}
|
||||
|
||||
export function getHydratedMemberPrice(
|
||||
memberRate: NonNullable<RoomRate["memberRate"]>,
|
||||
breakfast: DetailsState["breakfast"],
|
||||
packages: DetailsState["packages"]
|
||||
) {
|
||||
const memberPrice = {
|
||||
euro: {
|
||||
currency: CurrencyEnum.EUR,
|
||||
price: memberRate.requestedPrice?.pricePerStay ?? 0,
|
||||
},
|
||||
local: {
|
||||
currency: memberRate.localPrice.currency,
|
||||
price: memberRate.localPrice.pricePerStay,
|
||||
},
|
||||
}
|
||||
|
||||
if (breakfast) {
|
||||
memberPrice.euro.price = add(
|
||||
memberPrice.euro.price,
|
||||
breakfast.requestedPrice.totalPrice
|
||||
)
|
||||
memberPrice.local.price = add(
|
||||
memberPrice.local.price,
|
||||
breakfast.localPrice.totalPrice
|
||||
)
|
||||
}
|
||||
|
||||
if (packages) {
|
||||
packages.forEach((pkg) => {
|
||||
memberPrice.euro.price = add(
|
||||
memberPrice.euro.price,
|
||||
pkg.requestedPrice.totalPrice
|
||||
)
|
||||
memberPrice.local.price = add(
|
||||
memberPrice.local.price,
|
||||
pkg.localPrice.totalPrice
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
roomPrice: {
|
||||
euro: {
|
||||
currency: CurrencyEnum.EUR,
|
||||
price: memberRate.requestedPrice?.pricePerStay ?? 0,
|
||||
},
|
||||
local: {
|
||||
currency: memberRate.localPrice.currency,
|
||||
price: memberRate.localPrice.pricePerStay,
|
||||
},
|
||||
},
|
||||
totalPrice: memberPrice,
|
||||
}
|
||||
}
|
||||
|
||||
export const persistedStateSchema = z
|
||||
.object({
|
||||
bedType: z
|
||||
.object({
|
||||
description: z.string(),
|
||||
roomTypeCode: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
booking: z.object({
|
||||
hotel: z.string(),
|
||||
fromDate: z.string(),
|
||||
toDate: z.string(),
|
||||
rooms: z.array(
|
||||
z.object({
|
||||
adults: z.number().int(),
|
||||
counterRateCode: z.string(),
|
||||
rateCode: z.string(),
|
||||
roomTypeCode: z.string(),
|
||||
children: z
|
||||
.array(
|
||||
z.object({
|
||||
age: z.number().int(),
|
||||
bed: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
packages: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
breakfast: breakfastPackageSchema.or(z.literal(false)).optional(),
|
||||
guest: z.object({
|
||||
countryCode: z.string().default(""),
|
||||
email: z.string().default(""),
|
||||
firstName: z.string().default(""),
|
||||
lastName: z.string().default(""),
|
||||
membershipNo: z.string().default(""),
|
||||
phoneNumber: z.string().default(""),
|
||||
join: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.transform((_) => false),
|
||||
dateOfBirth: z.string().default(""),
|
||||
zipCode: z.string().default(""),
|
||||
}),
|
||||
totalPrice: z.object({
|
||||
euro: z.object({
|
||||
currency: z.literal(CurrencyEnum.EUR),
|
||||
price: z.number().int(),
|
||||
}),
|
||||
local: z.object({
|
||||
currency: z.nativeEnum(CurrencyEnum),
|
||||
price: z.number().int(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.optional()
|
||||
448
stores/enter-details/index.ts
Normal file
448
stores/enter-details/index.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import deepmerge from "deepmerge"
|
||||
import { produce } from "immer"
|
||||
import { useContext } from "react"
|
||||
import { create, useStore } from "zustand"
|
||||
import { createJSONStorage, persist } from "zustand/middleware"
|
||||
|
||||
import { DetailsContext } from "@/contexts/Details"
|
||||
import { arrayMerge } from "@/utils/merge"
|
||||
|
||||
import {
|
||||
add,
|
||||
calcTotalMemberPrice,
|
||||
checkIsSameBooking,
|
||||
extractGuestFromUser,
|
||||
getHydratedMemberPrice,
|
||||
getInitialRoomPrice,
|
||||
getInitialTotalPrice,
|
||||
langToCurrency,
|
||||
navigate,
|
||||
persistedStateSchema,
|
||||
validateSteps,
|
||||
} from "./helpers"
|
||||
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type {
|
||||
DetailsState,
|
||||
FormValues,
|
||||
InitialState,
|
||||
PersistedState,
|
||||
} from "@/types/stores/enter-details"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
const defaultGuestState = {
|
||||
countryCode: "",
|
||||
dateOfBirth: "",
|
||||
email: "",
|
||||
firstName: "",
|
||||
join: false,
|
||||
lastName: "",
|
||||
membershipNo: "",
|
||||
phoneNumber: "",
|
||||
zipCode: "",
|
||||
}
|
||||
|
||||
export const detailsStorageName = "details-storage"
|
||||
export function createDetailsStore(
|
||||
initialState: InitialState,
|
||||
currentStep: StepEnum,
|
||||
searchParams: string,
|
||||
user: SafeUser
|
||||
) {
|
||||
const isMember = !!user
|
||||
const isBrowser = typeof window !== "undefined"
|
||||
|
||||
// Spread is done on purpose since we want
|
||||
// a copy of initialState and not alter the
|
||||
// original
|
||||
const formValues: FormValues = {
|
||||
bedType: initialState.bedType,
|
||||
booking: initialState.booking,
|
||||
breakfast: undefined,
|
||||
guest: isMember
|
||||
? deepmerge(defaultGuestState, extractGuestFromUser(user))
|
||||
: defaultGuestState,
|
||||
}
|
||||
if (isBrowser) {
|
||||
/**
|
||||
* 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(detailsStorageName)
|
||||
if (detailsStorageUnparsed) {
|
||||
const detailsStorage: Record<"state", FormValues> = JSON.parse(
|
||||
detailsStorageUnparsed
|
||||
)
|
||||
|
||||
const isSameBooking = checkIsSameBooking(
|
||||
detailsStorage.state.booking,
|
||||
initialState.booking
|
||||
)
|
||||
|
||||
if (isSameBooking) {
|
||||
if (!initialState.bedType && detailsStorage.state.bedType) {
|
||||
formValues.bedType = detailsStorage.state.bedType
|
||||
}
|
||||
|
||||
if ("breakfast" in detailsStorage.state) {
|
||||
formValues.breakfast = detailsStorage.state.breakfast
|
||||
}
|
||||
|
||||
if ("guest" in detailsStorage.state) {
|
||||
if (!user) {
|
||||
formValues.guest = deepmerge(
|
||||
defaultGuestState,
|
||||
detailsStorage.state.guest,
|
||||
{ arrayMerge }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const initialRoomPrice = getInitialRoomPrice(initialState.roomRate, isMember)
|
||||
const initialTotalPrice = getInitialTotalPrice(
|
||||
initialState.roomRate,
|
||||
isMember
|
||||
)
|
||||
|
||||
if (initialState.packages) {
|
||||
initialState.packages.forEach((pkg) => {
|
||||
initialTotalPrice.euro.price = add(
|
||||
initialTotalPrice.euro.price,
|
||||
pkg.requestedPrice.totalPrice
|
||||
)
|
||||
initialTotalPrice.local.price = add(
|
||||
initialTotalPrice.local.price,
|
||||
pkg.localPrice.totalPrice
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return create<DetailsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
actions: {
|
||||
completeStep() {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
||||
const nextStep = state.steps[currentStepIndex + 1]
|
||||
state.currentStep = nextStep
|
||||
navigate(nextStep, searchParams)
|
||||
})
|
||||
)
|
||||
},
|
||||
navigate(step: StepEnum) {
|
||||
return set(
|
||||
produce((state) => {
|
||||
state.currentStep = step
|
||||
navigate(step, searchParams)
|
||||
})
|
||||
)
|
||||
},
|
||||
setIsSubmittingDisabled(isSubmittingDisabled) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.isSubmittingDisabled = isSubmittingDisabled
|
||||
})
|
||||
)
|
||||
},
|
||||
setStep(step: StepEnum) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.currentStep = step
|
||||
})
|
||||
)
|
||||
},
|
||||
setTotalPrice(totalPrice) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.totalPrice.euro = totalPrice.euro
|
||||
state.totalPrice.local = totalPrice.local
|
||||
})
|
||||
)
|
||||
},
|
||||
toggleSummaryOpen() {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.isSummaryOpen = !state.isSummaryOpen
|
||||
})
|
||||
)
|
||||
},
|
||||
updateBedType(bedType) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.isValid["select-bed"] = true
|
||||
state.bedType = bedType
|
||||
|
||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
||||
const nextStep = state.steps[currentStepIndex + 1]
|
||||
state.currentStep = nextStep
|
||||
navigate(nextStep, searchParams)
|
||||
})
|
||||
)
|
||||
},
|
||||
updateBreakfast(breakfast) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.isValid.breakfast = true
|
||||
const stateTotalEuroPrice = state.totalPrice.euro?.price || 0
|
||||
const stateTotalLocalPrice = state.totalPrice.local.price
|
||||
|
||||
const addToTotalPrice =
|
||||
(state.breakfast === undefined ||
|
||||
state.breakfast === false) &&
|
||||
!!breakfast
|
||||
const subtractFromTotalPrice =
|
||||
(state.breakfast === undefined || state.breakfast) &&
|
||||
breakfast === false
|
||||
|
||||
if (addToTotalPrice) {
|
||||
const breakfastTotalEuroPrice = parseInt(
|
||||
breakfast.requestedPrice.totalPrice
|
||||
)
|
||||
const breakfastTotalPrice = parseInt(
|
||||
breakfast.localPrice.totalPrice
|
||||
)
|
||||
|
||||
state.totalPrice = {
|
||||
euro: {
|
||||
currency: CurrencyEnum.EUR,
|
||||
price: stateTotalEuroPrice + breakfastTotalEuroPrice,
|
||||
},
|
||||
local: {
|
||||
currency: breakfast.localPrice.currency,
|
||||
price: stateTotalLocalPrice + breakfastTotalPrice,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (subtractFromTotalPrice) {
|
||||
let currency =
|
||||
state.totalPrice.local.currency ?? langToCurrency()
|
||||
let currentBreakfastTotalPrice = 0
|
||||
let currentBreakfastTotalEuroPrice = 0
|
||||
if (state.breakfast) {
|
||||
currentBreakfastTotalPrice = parseInt(
|
||||
state.breakfast.localPrice.totalPrice
|
||||
)
|
||||
currentBreakfastTotalEuroPrice = parseInt(
|
||||
state.breakfast.requestedPrice.totalPrice
|
||||
)
|
||||
currency = state.breakfast.localPrice.currency
|
||||
}
|
||||
|
||||
let euroPrice =
|
||||
stateTotalEuroPrice - currentBreakfastTotalEuroPrice
|
||||
if (euroPrice < 0) {
|
||||
euroPrice = 0
|
||||
}
|
||||
let localPrice =
|
||||
stateTotalLocalPrice - currentBreakfastTotalPrice
|
||||
if (localPrice < 0) {
|
||||
localPrice = 0
|
||||
}
|
||||
|
||||
state.totalPrice = {
|
||||
euro: {
|
||||
currency: CurrencyEnum.EUR,
|
||||
price: euroPrice,
|
||||
},
|
||||
local: {
|
||||
currency,
|
||||
price: localPrice,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
state.breakfast = breakfast
|
||||
|
||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
||||
const nextStep = state.steps[currentStepIndex + 1]
|
||||
state.currentStep = nextStep
|
||||
navigate(nextStep, searchParams)
|
||||
})
|
||||
)
|
||||
},
|
||||
updateDetails(data) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.isValid.details = true
|
||||
|
||||
state.guest.countryCode = data.countryCode
|
||||
state.guest.dateOfBirth = data.dateOfBirth
|
||||
state.guest.email = data.email
|
||||
state.guest.firstName = data.firstName
|
||||
state.guest.join = data.join
|
||||
state.guest.lastName = data.lastName
|
||||
if (data.join) {
|
||||
state.guest.membershipNo = undefined
|
||||
} else {
|
||||
state.guest.membershipNo = data.membershipNo
|
||||
}
|
||||
state.guest.phoneNumber = data.phoneNumber
|
||||
state.guest.zipCode = data.zipCode
|
||||
|
||||
if (data.join || data.membershipNo || isMember) {
|
||||
const memberPrice = calcTotalMemberPrice(state)
|
||||
state.roomPrice = memberPrice.roomPrice
|
||||
state.totalPrice = memberPrice.totalPrice
|
||||
}
|
||||
|
||||
const currentStepIndex = state.steps.indexOf(state.currentStep)
|
||||
const nextStep = state.steps[currentStepIndex + 1]
|
||||
state.currentStep = nextStep
|
||||
navigate(nextStep, searchParams)
|
||||
})
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
bedType: initialState.bedType ?? undefined,
|
||||
booking: initialState.booking,
|
||||
breakfast: undefined,
|
||||
currentStep,
|
||||
formValues,
|
||||
guest: isMember
|
||||
? deepmerge(defaultGuestState, extractGuestFromUser(user))
|
||||
: defaultGuestState,
|
||||
isSubmittingDisabled: false,
|
||||
isSummaryOpen: false,
|
||||
isValid: {
|
||||
[StepEnum.selectBed]: false,
|
||||
[StepEnum.breakfast]: false,
|
||||
[StepEnum.details]: false,
|
||||
[StepEnum.payment]: false,
|
||||
},
|
||||
packages: initialState.packages,
|
||||
roomPrice: initialRoomPrice,
|
||||
roomRate: initialState.roomRate,
|
||||
steps: [
|
||||
StepEnum.selectBed,
|
||||
StepEnum.breakfast,
|
||||
StepEnum.details,
|
||||
StepEnum.payment,
|
||||
],
|
||||
totalPrice: initialTotalPrice,
|
||||
}),
|
||||
{
|
||||
name: detailsStorageName,
|
||||
merge(_persistedState, currentState) {
|
||||
const parsedPersistedState =
|
||||
persistedStateSchema.safeParse(_persistedState)
|
||||
let persistedState
|
||||
if (parsedPersistedState.success) {
|
||||
if (parsedPersistedState.data) {
|
||||
persistedState = parsedPersistedState.data as PersistedState
|
||||
}
|
||||
}
|
||||
|
||||
if (!persistedState) {
|
||||
persistedState = currentState as DetailsState
|
||||
}
|
||||
|
||||
if (
|
||||
currentState.guest.join ||
|
||||
!!currentState.guest.membershipNo ||
|
||||
isMember
|
||||
) {
|
||||
if (currentState.roomRate.memberRate) {
|
||||
const memberPrice = getHydratedMemberPrice(
|
||||
currentState.roomRate.memberRate,
|
||||
currentState.breakfast,
|
||||
currentState.packages
|
||||
)
|
||||
|
||||
currentState.roomPrice = memberPrice.roomPrice
|
||||
currentState.totalPrice = memberPrice.totalPrice
|
||||
}
|
||||
}
|
||||
|
||||
const isSameBooking = checkIsSameBooking(
|
||||
persistedState.booking,
|
||||
currentState.booking
|
||||
)
|
||||
|
||||
let mergedState
|
||||
if (isSameBooking) {
|
||||
mergedState = deepmerge<DetailsState>(
|
||||
currentState,
|
||||
persistedState,
|
||||
{ arrayMerge }
|
||||
)
|
||||
} else {
|
||||
mergedState = deepmerge<DetailsState>(
|
||||
persistedState,
|
||||
currentState,
|
||||
{ arrayMerge }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* checking against initialState since that means the
|
||||
* hotel doesn't offer breakfast
|
||||
*
|
||||
* matching breakfast first so the steps array is altered
|
||||
* before the bedTypes possible step altering
|
||||
*/
|
||||
if (initialState.breakfast === false) {
|
||||
mergedState.steps = mergedState.steps.filter(
|
||||
(step) => step === StepEnum.breakfast
|
||||
)
|
||||
if (mergedState.currentStep === StepEnum.breakfast) {
|
||||
mergedState.currentStep = mergedState.steps[1]
|
||||
}
|
||||
}
|
||||
|
||||
if (initialState.bedType) {
|
||||
if (mergedState.currentStep === StepEnum.selectBed) {
|
||||
mergedState.currentStep = mergedState.steps[1]
|
||||
}
|
||||
}
|
||||
|
||||
const validPaths = validateSteps(mergedState, isMember)
|
||||
if (!validPaths.includes(mergedState.currentStep)) {
|
||||
mergedState.currentStep = validPaths.at(-1)!
|
||||
}
|
||||
|
||||
if (currentStep !== mergedState.currentStep) {
|
||||
setTimeout(() => {
|
||||
navigate(mergedState.currentStep, searchParams)
|
||||
})
|
||||
}
|
||||
return mergedState
|
||||
},
|
||||
partialize(state) {
|
||||
return {
|
||||
bedType: state.bedType,
|
||||
booking: state.booking,
|
||||
breakfast: state.breakfast,
|
||||
guest: state.guest,
|
||||
totalPrice: state.totalPrice,
|
||||
}
|
||||
},
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function useEnterDetailsStore<T>(selector: (store: DetailsState) => T) {
|
||||
const store = useContext(DetailsContext)
|
||||
|
||||
if (!store) {
|
||||
throw new Error("useEnterDetailsStore must be used within DetailsProvider")
|
||||
}
|
||||
|
||||
return useStore(store, selector)
|
||||
}
|
||||
156
stores/steps.ts
156
stores/steps.ts
@@ -1,156 +0,0 @@
|
||||
"use client"
|
||||
import merge from "deepmerge"
|
||||
import { produce } from "immer"
|
||||
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
|
||||
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 { detailsStorageName as detailsStorageName } from "./details"
|
||||
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { DetailsState } from "@/types/stores/details"
|
||||
import type { StepState } from "@/types/stores/steps"
|
||||
|
||||
export function createStepsStore(
|
||||
currentStep: StepEnum,
|
||||
isMember: boolean,
|
||||
noBedChoices: boolean,
|
||||
noBreakfast: boolean,
|
||||
searchParams: string,
|
||||
push: AppRouterInstance["push"]
|
||||
) {
|
||||
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(`${currentStep}?${searchParams}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (noBedChoices) {
|
||||
if (currentStep === StepEnum.selectBed) {
|
||||
currentStep = steps[1]
|
||||
push(`${currentStep}?${searchParams}`)
|
||||
}
|
||||
}
|
||||
|
||||
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(`${currentStep}?${searchParams}`)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { RoomPackageCodeEnum } from "../selectRate/roomFilter"
|
||||
import { Child } from "../selectRate/selectRate"
|
||||
|
||||
import { Packages } from "@/types/requests/packages"
|
||||
import type { RoomPackageCodeEnum } from "../selectRate/roomFilter"
|
||||
import type { Child } from "../selectRate/selectRate"
|
||||
|
||||
interface Room {
|
||||
adults: number
|
||||
@@ -17,29 +15,3 @@ export interface BookingData {
|
||||
toDate: string
|
||||
rooms: Room[]
|
||||
}
|
||||
|
||||
type Price = {
|
||||
amount: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export type RoomsData = {
|
||||
roomType: string
|
||||
prices: {
|
||||
public: {
|
||||
local: Price
|
||||
euro: Price | undefined
|
||||
}
|
||||
member:
|
||||
| {
|
||||
local: Price
|
||||
euro: Price | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
adults: number
|
||||
children?: Child[]
|
||||
rateDetails?: string[]
|
||||
cancellationText: string
|
||||
packages: Packages | null
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { guestDetailsSchema } from "@/components/HotelReservation/EnterDetails/Details/schema"
|
||||
import {
|
||||
guestDetailsSchema,
|
||||
signedInDetailsSchema,
|
||||
} from "@/components/HotelReservation/EnterDetails/Details/schema"
|
||||
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
export type DetailsSchema = z.output<typeof guestDetailsSchema>
|
||||
export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>
|
||||
|
||||
type MemberPrice = { price: number; currency: string }
|
||||
type MemberPrice = {
|
||||
currency: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export interface DetailsProps {
|
||||
user: SafeUser
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { RouterOutput } from "@/lib/trpc/client"
|
||||
|
||||
type HotelDataGet = RouterOutput["hotel"]["hotelData"]["get"]
|
||||
|
||||
export interface HotelHeaderProps {
|
||||
hotel: NonNullable<HotelDataGet>["data"]["attributes"]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
|
||||
export const StepStoreKeys: Record<StepEnum, "bedType" | "breakfast" | null> = {
|
||||
"select-bed": "bedType",
|
||||
breakfast: "breakfast",
|
||||
details: null,
|
||||
payment: null,
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { RoomsData } from "./bookingData"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { Packages } from "@/types/requests/packages"
|
||||
import type { RoomAvailability } from "@/types/trpc/routers/hotel/availability"
|
||||
|
||||
export interface SummaryProps {
|
||||
showMemberPrice: boolean
|
||||
room: RoomsData
|
||||
export interface ClientSummaryProps
|
||||
extends Pick<
|
||||
RoomAvailability,
|
||||
"cancellationText" | "memberRate" | "rateDetails"
|
||||
>,
|
||||
Pick<RoomAvailability["selectedRoom"], "roomType"> {
|
||||
adults: number
|
||||
isMember: boolean
|
||||
kids: Child[] | undefined
|
||||
}
|
||||
|
||||
39
types/components/hotelReservation/summary.ts
Normal file
39
types/components/hotelReservation/summary.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { RoomPackageCodeEnum } from "./selectRate/roomFilter"
|
||||
|
||||
import type { Packages } from "@/types/requests/packages"
|
||||
import type { DetailsState, Price } from "@/types/stores/enter-details"
|
||||
import type { RoomAvailability } from "@/types/trpc/routers/hotel/availability"
|
||||
import type { BedTypeSchema } from "./enterDetails/bedType"
|
||||
import type { BreakfastPackage } from "./enterDetails/breakfast"
|
||||
import type { Child } from "./selectRate/selectRate"
|
||||
|
||||
export type RoomsData = Pick<DetailsState, "roomPrice"> &
|
||||
Pick<RoomAvailability, "cancellationText" | "rateDetails"> &
|
||||
Pick<RoomAvailability["selectedRoom"], "roomType"> & {
|
||||
adults: number
|
||||
children?: Child[]
|
||||
packages: Packages | null
|
||||
}
|
||||
|
||||
interface SharedSummaryProps {
|
||||
fromDate: string
|
||||
toDate: string
|
||||
}
|
||||
|
||||
export interface SummaryProps extends SharedSummaryProps {
|
||||
bedType: BedTypeSchema | undefined
|
||||
breakfast: BreakfastPackage | false | undefined
|
||||
showMemberPrice: boolean
|
||||
room: RoomsData
|
||||
toggleSummaryOpen?: () => void
|
||||
totalPrice: Price
|
||||
}
|
||||
|
||||
export interface SummaryPageProps extends SharedSummaryProps {
|
||||
adults: number
|
||||
hotelId: string
|
||||
kids: Child[] | undefined
|
||||
packageCodes: RoomPackageCodeEnum[] | undefined
|
||||
rateCode: string
|
||||
roomTypeCode: string
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import { createDetailsStore } from "@/stores/details"
|
||||
import { createDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
export type DetailsStore = ReturnType<typeof createDetailsStore>
|
||||
@@ -1,3 +0,0 @@
|
||||
import { createStepsStore } from "@/stores/steps"
|
||||
|
||||
export type StepsStore = ReturnType<typeof createStepsStore>
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface DetailsProviderProps extends React.PropsWithChildren {
|
||||
isMember: boolean
|
||||
}
|
||||
18
types/providers/enter-details.ts
Normal file
18
types/providers/enter-details.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
import { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData"
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { RoomAvailability } from "@/types/trpc/routers/hotel/availability"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
import type { Packages } from "../requests/packages"
|
||||
|
||||
export interface DetailsProviderProps extends React.PropsWithChildren {
|
||||
booking: BookingData
|
||||
bedTypes: BedTypeSelection[]
|
||||
breakfastPackages: BreakfastPackage[] | null
|
||||
packages: Packages | null
|
||||
roomRate: Pick<RoomAvailability, "memberRate" | "publicRate">
|
||||
searchParamsStr: string
|
||||
step: StepEnum
|
||||
user: SafeUser
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
searchParams: string
|
||||
step: StepEnum
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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
|
||||
}
|
||||
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
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface TotalPrice {
|
||||
euro: Price | undefined
|
||||
local: Price
|
||||
}
|
||||
68
types/stores/enter-details.ts
Normal file
68
types/stores/enter-details.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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,
|
||||
SignedInDetailsSchema,
|
||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { DetailsProviderProps } from "../providers/enter-details"
|
||||
import type { Packages } from "../requests/packages"
|
||||
|
||||
interface TPrice {
|
||||
currency: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export interface Price {
|
||||
euro: TPrice | undefined
|
||||
local: TPrice
|
||||
}
|
||||
|
||||
export interface FormValues {
|
||||
bedType: BedTypeSchema | undefined
|
||||
booking: BookingData
|
||||
breakfast: BreakfastPackage | false | undefined
|
||||
guest: DetailsSchema | SignedInDetailsSchema
|
||||
}
|
||||
|
||||
export interface DetailsState {
|
||||
actions: {
|
||||
completeStep: () => void
|
||||
navigate: (step: StepEnum) => void
|
||||
setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void
|
||||
setStep: (step: StepEnum) => void
|
||||
setTotalPrice: (totalPrice: Price) => void
|
||||
toggleSummaryOpen: () => void
|
||||
updateBedType: (data: BedTypeSchema) => void
|
||||
updateBreakfast: (data: BreakfastPackage | false) => void
|
||||
updateDetails: (data: DetailsSchema) => void
|
||||
}
|
||||
bedType: BedTypeSchema | undefined
|
||||
booking: BookingData
|
||||
breakfast: BreakfastPackage | false | undefined
|
||||
currentStep: StepEnum
|
||||
formValues: FormValues
|
||||
guest: DetailsSchema
|
||||
isSubmittingDisabled: boolean
|
||||
isSummaryOpen: boolean
|
||||
isValid: Record<StepEnum, boolean>
|
||||
packages: Packages | null
|
||||
roomRate: DetailsProviderProps["roomRate"]
|
||||
roomPrice: Price
|
||||
steps: StepEnum[]
|
||||
totalPrice: Price
|
||||
}
|
||||
|
||||
export type InitialState = Pick<DetailsState, "booking" | "packages"> &
|
||||
Pick<DetailsProviderProps, "roomRate"> & {
|
||||
bedType?: BedTypeSchema
|
||||
breakfast?: false
|
||||
}
|
||||
|
||||
export type PersistedState = Pick<
|
||||
DetailsState,
|
||||
"bedType" | "booking" | "breakfast" | "guest" | "totalPrice"
|
||||
>
|
||||
|
||||
export type RoomRate = DetailsProviderProps["roomRate"]
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
|
||||
export interface StepState {
|
||||
completeStep: () => void
|
||||
navigate: (step: StepEnum) => void
|
||||
setStep: (step: StepEnum) => void
|
||||
|
||||
currentStep: StepEnum
|
||||
steps: StepEnum[]
|
||||
}
|
||||
5
types/trpc/routers/hotel/availability.ts
Normal file
5
types/trpc/routers/hotel/availability.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { RouterOutput } from "@/lib/trpc/client"
|
||||
|
||||
export type RoomAvailability = NonNullable<
|
||||
RouterOutput["hotel"]["availability"]["room"]
|
||||
>
|
||||
Reference in New Issue
Block a user