feat: make steps of enter details flow dynamic depending on data
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
export default function LoadingHotelHeader() {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
.header {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.descriptionContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.address {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.dividerContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header {
|
||||
padding: var(--Spacing-x4) 0;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x6);
|
||||
margin: 0 auto;
|
||||
/* simulates padding on viewport smaller than --max-width-navigation */
|
||||
width: min(
|
||||
calc(100dvw - (var(--Spacing-x2) * 2)),
|
||||
var(--max-width-navigation)
|
||||
);
|
||||
}
|
||||
|
||||
.titleContainer > h1 {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dividerContainer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.address {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./page.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)
|
||||
}
|
||||
|
||||
const intl = await getIntl()
|
||||
const hotel = hotelData.data.attributes
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h3" level="h1">
|
||||
{hotel.name}
|
||||
</Title>
|
||||
<address className={styles.address}>
|
||||
<Caption color="textMediumContrast">
|
||||
{hotel.address.streetAddress}, {hotel.address.city}
|
||||
</Caption>
|
||||
<div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<Caption color="textMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Distance in km to city centre" },
|
||||
{ number: hotel.location.distanceToCentre }
|
||||
)}
|
||||
</Caption>
|
||||
</address>
|
||||
</div>
|
||||
<div className={styles.dividerContainer}>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
<Body color="baseTextHighContrast">
|
||||
{hotel.hotelContent.texts.descriptions.short}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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 =
|
||||
user && availability.memberRate
|
||||
? {
|
||||
local: {
|
||||
price: availability.memberRate.localPrice.pricePerStay,
|
||||
currency: availability.memberRate.localPrice.currency,
|
||||
},
|
||||
euro: availability.memberRate.requestedPrice
|
||||
? {
|
||||
price: availability.memberRate.requestedPrice.pricePerStay,
|
||||
currency: availability.memberRate.requestedPrice.currency,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: {
|
||||
local: {
|
||||
price: availability.publicRate.localPrice.pricePerStay,
|
||||
currency: availability.publicRate.localPrice.currency,
|
||||
},
|
||||
euro: availability.publicRate.requestedPrice
|
||||
? {
|
||||
price: availability.publicRate.requestedPrice.pricePerStay,
|
||||
currency: availability.publicRate.requestedPrice.currency,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.mobileSummary}>
|
||||
<SummaryBottomSheet>
|
||||
<div className={styles.summary}>
|
||||
<Summary
|
||||
showMemberPrice={!!(user && availability.memberRate)}
|
||||
room={{
|
||||
roomType: availability.selectedRoom.roomType,
|
||||
localPrice: prices.local,
|
||||
euroPrice: prices.euro,
|
||||
adults,
|
||||
children,
|
||||
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,
|
||||
localPrice: prices.local,
|
||||
euroPrice: prices.euro,
|
||||
adults,
|
||||
children,
|
||||
cancellationText: availability.cancellationText,
|
||||
packages,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.shadow} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {
|
||||
getCreditCardsSafely,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
export function preload() {
|
||||
void getProfileSafely()
|
||||
void getCreditCardsSafely()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
import DetailsProvider from "@/providers/DetailsProvider"
|
||||
|
||||
import { preload } from "./_preload"
|
||||
|
||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
export default async function StepLayout({
|
||||
children,
|
||||
hotelHeader,
|
||||
params,
|
||||
summary,
|
||||
}: React.PropsWithChildren<
|
||||
LayoutArgs<LangParams> & {
|
||||
hotelHeader: React.ReactNode
|
||||
summary: React.ReactNode
|
||||
}
|
||||
>) {
|
||||
setLang(params.lang)
|
||||
preload()
|
||||
|
||||
const user = await getProfileSafely()
|
||||
|
||||
return (
|
||||
<DetailsProvider isMember={!!user}>
|
||||
<main className="enter-details-layout__layout">
|
||||
{hotelHeader}
|
||||
<div className={"enter-details-layout__container"}>
|
||||
<div className={"enter-details-layout__content"}>{children}</div>
|
||||
<aside className={"enter-details-layout__summaryContainer"}>
|
||||
{summary}
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</DetailsProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import "./enterDetailsLayout.css"
|
||||
|
||||
import { notFound, redirect, RedirectType } from "next/navigation"
|
||||
|
||||
import {
|
||||
getBreakfastPackages,
|
||||
getCreditCardsSafely,
|
||||
getHotelData,
|
||||
getProfileSafely,
|
||||
getSelectedRoomAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
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 Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
||||
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
|
||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||
import {
|
||||
generateChildrenString,
|
||||
getQueryParamsForEnterDetails,
|
||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import { getIntl } from "@/i18n"
|
||||
import StepsProvider from "@/providers/StepsProvider"
|
||||
|
||||
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
function isValidStep(step: string): step is StepEnum {
|
||||
return Object.values(StepEnum).includes(step as StepEnum)
|
||||
}
|
||||
|
||||
export default async function StepPage({
|
||||
params: { lang },
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) {
|
||||
const intl = await getIntl()
|
||||
const selectRoomParams = new URLSearchParams(searchParams)
|
||||
const {
|
||||
hotel: hotelId,
|
||||
rooms,
|
||||
fromDate,
|
||||
toDate,
|
||||
} = getQueryParamsForEnterDetails(selectRoomParams)
|
||||
|
||||
const {
|
||||
adults,
|
||||
children,
|
||||
roomTypeCode,
|
||||
rateCode,
|
||||
packages: packageCodes,
|
||||
} = rooms[0] // TODO: Handle multiple rooms
|
||||
|
||||
const childrenAsString = children && generateChildrenString(children)
|
||||
|
||||
const breakfastInput = { adults, fromDate, hotelId, toDate }
|
||||
void getBreakfastPackages(breakfastInput)
|
||||
void getSelectedRoomAvailability({
|
||||
hotelId,
|
||||
adults,
|
||||
children: childrenAsString,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
packageCodes,
|
||||
})
|
||||
|
||||
const roomAvailability = await getSelectedRoomAvailability({
|
||||
hotelId,
|
||||
adults,
|
||||
children: childrenAsString,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
packageCodes,
|
||||
})
|
||||
const hotelData = await getHotelData({
|
||||
hotelId,
|
||||
language: lang,
|
||||
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
|
||||
})
|
||||
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
||||
const user = await getProfileSafely()
|
||||
const savedCreditCards = await getCreditCardsSafely()
|
||||
|
||||
if (!isValidStep(searchParams.step) || !hotelData || !roomAvailability) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const mustBeGuaranteed = roomAvailability?.mustBeGuaranteed ?? false
|
||||
|
||||
const paymentGuarantee = intl.formatMessage({
|
||||
id: "Payment Guarantee",
|
||||
})
|
||||
const payment = intl.formatMessage({
|
||||
id: "Payment",
|
||||
})
|
||||
const guaranteeWithCard = intl.formatMessage({
|
||||
id: "Guarantee booking with credit card",
|
||||
})
|
||||
const selectPaymentMethod = intl.formatMessage({
|
||||
id: "Select payment method",
|
||||
})
|
||||
|
||||
const roomPrice = {
|
||||
memberPrice: roomAvailability.memberRate?.localPrice.pricePerStay,
|
||||
publicPrice: roomAvailability.publicRate!.localPrice.pricePerStay,
|
||||
}
|
||||
|
||||
return (
|
||||
<StepsProvider
|
||||
bedTypes={roomAvailability.bedTypes}
|
||||
breakfastPackages={breakfastPackages}
|
||||
isMember={!!user}
|
||||
step={searchParams.step}
|
||||
>
|
||||
<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}
|
||||
|
||||
{breakfastPackages?.length ? (
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Food options" })}
|
||||
step={StepEnum.breakfast}
|
||||
label={intl.formatMessage({ id: "Select breakfast options" })}
|
||||
>
|
||||
<Breakfast packages={breakfastPackages} />
|
||||
</SectionAccordion>
|
||||
) : null}
|
||||
|
||||
<SectionAccordion
|
||||
header={intl.formatMessage({ id: "Details" })}
|
||||
step={StepEnum.details}
|
||||
label={intl.formatMessage({ id: "Enter your details" })}
|
||||
>
|
||||
<Details user={user} />
|
||||
</SectionAccordion>
|
||||
|
||||
<SectionAccordion
|
||||
header={mustBeGuaranteed ? paymentGuarantee : payment}
|
||||
step={StepEnum.payment}
|
||||
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
|
||||
>
|
||||
<Payment
|
||||
roomPrice={roomPrice}
|
||||
otherPaymentOptions={
|
||||
hotelData.data.attributes.merchantInformationData
|
||||
.alternatePaymentOptions
|
||||
}
|
||||
savedCreditCards={savedCreditCards}
|
||||
mustBeGuaranteed={mustBeGuaranteed}
|
||||
/>
|
||||
</SectionAccordion>
|
||||
</section>
|
||||
</StepsProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user