feat: merge stores, fix auto navigation, split summary

This commit is contained in:
Simon Emanuelsson
2024-11-12 15:30:59 +01:00
parent a69d14ff61
commit ccb15593ea
82 changed files with 2149 additions and 1842 deletions

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function LoadingHotelHeader() {
return <LoadingSpinner />
}

View File

@@ -1,5 +0,0 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function LoadingSummaryHeader() {
return <LoadingSpinner />
}

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
import {
getCreditCardsSafely,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
export function preload() {
void getProfileSafely()
void getCreditCardsSafely()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import styles from "./room.module.css"
export default function Room() {
return <article className={styles.room}></article>
}

View File

@@ -0,0 +1,5 @@
import styles from "./rooms.module.css"
export default function Rooms() {
return <section className={styles.rooms}></section>
}

View File

@@ -0,0 +1,6 @@
.rooms {
display: flex;
flex-direction: column;
gap: var(--Spacing-x9);
grid-area: booking;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
</>
)
}

View File

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

View File

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

View File

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

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

View 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;
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View 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()

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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"]

View File

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

View File

@@ -0,0 +1,5 @@
import type { RouterOutput } from "@/lib/trpc/client"
export type RoomAvailability = NonNullable<
RouterOutput["hotel"]["availability"]["room"]
>