Merge branch 'develop' into feature/tracking

This commit is contained in:
Linus Flood
2024-10-24 12:39:34 +02:00
221 changed files with 5789 additions and 1491 deletions

View File

@@ -0,0 +1,157 @@
.details,
.guest,
.header,
.hgroup,
.hotel,
.list,
.main,
.section,
.receipt,
.total {
display: flex;
flex-direction: column;
}
.main {
gap: var(--Spacing-x5);
margin: 0 auto;
width: min(calc(100dvw - (var(--Spacing-x3) * 2)), 708px);
}
.header,
.hgroup {
align-items: center;
}
.header {
gap: var(--Spacing-x3);
}
.hgroup {
gap: var(--Spacing-x-half);
}
.body {
max-width: 560px;
}
.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";
}
.actions,
.details {
background-color: var(--Base-Surface-Subtle-Normal);
border-radius: var(--Corner-radius-Medium);
}
.details {
gap: var(--Spacing-x3);
grid-area: details;
padding: var(--Spacing-x2);
}
.tempImage {
align-items: center;
background-color: lightgrey;
border-radius: var(--Corner-radius-Medium);
display: flex;
grid-area: image;
justify-content: center;
}
.actions {
display: grid;
grid-area: actions;
padding: var(--Spacing-x1) var(--Spacing-x2);
}
.list {
gap: var(--Spacing-x-one-and-half);
list-style: none;
margin: 0;
padding: 0;
}
.listItem {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: space-between;
}
.summary {
display: grid;
gap: var(--Spacing-x3);
}
.guest,
.hotel {
gap: var(--Spacing-x-half);
}
.receipt,
.total {
gap: var(--Spacing-x2);
}
.divider {
grid-column: 1 / -1;
}
@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;
}
}
}
.tempImage {
min-height: 250px;
}
}
@media screen and (min-width: 768px) {
.booking {
grid-template-areas:
"details image"
"actions actions";
grid-template-columns: 1fr minmax(256px, min(256px, 100%));
}
.actions {
gap: var(--Spacing-x7);
grid-template-columns: repeat(auto-fit, minmax(50px, auto));
justify-content: center;
padding: var(--Spacing-x1) var(--Spacing-x3);
}
.details {
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x2);
}
.summary {
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -0,0 +1,281 @@
import { dt } from "@/lib/dt"
import { serverClient } from "@/lib/trpc/server"
import {
CalendarIcon,
DownloadIcon,
ImageIcon,
PrinterIcon,
} from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
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 Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
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 BookingConfirmationPage({
params,
searchParams,
}: PageArgs<LangParams, { confirmationNumber: string }>) {
const confirmationNumber = searchParams.confirmationNumber
const booking = await serverClient().booking.confirmation({
confirmationNumber,
})
if (!booking) {
return null
}
const intl = await getIntl()
const text = intl.formatMessage<React.ReactNode>(
{ id: "booking.confirmation.text" },
{
emailLink: (str) => (
<Link color="burgundy" href="#" textDecoration="underline">
{str}
</Link>
),
}
)
const fromDate = dt(booking.temp.fromDate).locale(params.lang)
const toDate = dt(booking.temp.toDate).locale(params.lang)
const nights = intl.formatMessage(
{ id: "booking.nights" },
{
totalNights: dt(toDate.format("YYYY-MM-DD")).diff(
dt(fromDate.format("YYYY-MM-DD")),
"days"
),
}
)
return (
<main className={styles.main}>
<header className={styles.header}>
<hgroup className={styles.hgroup}>
<Title
as="h4"
color="red"
textAlign="center"
textTransform="regular"
type="h2"
>
{intl.formatMessage({ id: "booking.confirmation.title" })}
</Title>
<Title
as="h4"
color="burgundy"
textAlign="center"
textTransform="regular"
type="h1"
>
{booking.hotel.name}
</Title>
</hgroup>
<Body className={styles.body} textAlign="center">
{text}
</Body>
</header>
<section className={styles.section}>
<div className={styles.booking}>
<article className={styles.details}>
<header>
<Subtitle color="burgundy" type="two">
{intl.formatMessage(
{ id: "Reference #{bookingNr}" },
{ bookingNr: "A92320VV" }
)}
</Subtitle>
</header>
<ul className={styles.list}>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Check-in" })}</Body>
<Body>
{`${fromDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`}
</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Check-out" })}</Body>
<Body>
{`${toDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${toDate.format("HH:mm")}`}
</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Breakfast" })}</Body>
<Body>
{booking.temp.breakfastFrom} - {booking.temp.breakfastTo}
</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Cancellation policy" })}</Body>
<Body>
{intl.formatMessage({ id: booking.temp.cancelPolicy })}
</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>
<Body>{`${intl.formatMessage({ id: "Free until" })} ${fromDate.subtract(3, "day").format("ddd, D MMM")}`}</Body>
</li>
</ul>
</article>
<aside className={styles.tempImage}>
<ImageIcon height={80} width={80} />
</aside>
<div className={styles.actions}>
<Button
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
<CalendarIcon />
{intl.formatMessage({ id: "Add to calendar" })}
</Button>
<Button
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
<PrinterIcon />
{intl.formatMessage({ id: "Print confirmation" })}
</Button>
<Button
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
<DownloadIcon />
{intl.formatMessage({ id: "Download invoice" })}
</Button>
</div>
</div>
<div className={styles.summary}>
<div className={styles.guest}>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "Guest" })}
</Caption>
<div>
<Body color="burgundy" textTransform="bold">
{`${booking.guest.firstName} ${booking.guest.lastName}${booking.guest.memberbershipNumber ? ` (${intl.formatMessage({ id: "member no" })} ${booking.guest.memberbershipNumber})` : ""}`}
</Body>
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
<Body color="uiTextHighContrast">
{booking.guest.phoneNumber}
</Body>
</div>
</div>
<div className={styles.hotel}>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "Your hotel" })}
</Caption>
<div>
<Body color="burgundy" textTransform="bold">
{booking.hotel.name}
</Body>
<Body color="uiTextHighContrast">{booking.hotel.email}</Body>
<Body color="uiTextHighContrast">
{booking.hotel.phoneNumber}
</Body>
</div>
</div>
<Divider className={styles.divider} color="primaryLightSubtle" />
<div className={styles.receipt}>
<div>
<Body color="burgundy" textTransform="bold">
{`${booking.temp.room.type}, ${nights}`}
</Body>
<Body color="uiTextHighContrast">{booking.temp.room.price}</Body>
</div>
{booking.temp.packages.map((pkg) => (
<div key={pkg.name}>
<Body color="burgundy" textTransform="bold">
{pkg.name}
</Body>
<Body color="uiTextHighContrast">{pkg.price}</Body>
</div>
))}
</div>
<div className={styles.total}>
<div>
<Body color="burgundy" textTransform="bold">
{intl.formatMessage({ id: "VAT" })}
</Body>
<Body color="uiTextHighContrast">{booking.temp.room.vat}</Body>
</div>
<div>
<Body color="burgundy" textTransform="bold">
{intl.formatMessage({ id: "Total cost" })}
</Body>
<Body color="uiTextHighContrast">{booking.temp.total}</Body>
<Caption color="uiTextPlaceholder">
{`${intl.formatMessage({ id: "Approx." })} ${booking.temp.totalInEuro}`}
</Caption>
</div>
</div>
<Divider className={styles.divider} color="primaryLightSubtle" />
<div>
<Body color="burgundy" textTransform="bold">
{`${intl.formatMessage({ id: "Payment received" })} ${dt(booking.temp.payment).locale(params.lang).format("D MMM YYYY, h:mm z")}`}
</Body>
<Caption color="uiTextPlaceholder">
{intl.formatMessage(
{ id: "{card} ending with {cardno}" },
{
card: "Mastercard",
cardno: "2202",
}
)}
</Caption>
</div>
</div>
</section>
</main>
)
}
// const { email, hotel, stay, summary } = tempConfirmationData
// const confirmationNumber = useMemo(() => {
// if (typeof window === "undefined") return ""
// const storedConfirmationNumber = sessionStorage.getItem(
// BOOKING_CONFIRMATION_NUMBER
// )
// TODO: cleanup stored values
// sessionStorage.removeItem(BOOKING_CONFIRMATION_NUMBER)
// return storedConfirmationNumber
// }, [])
// const bookingStatus = useHandleBookingStatus(
// confirmationNumber,
// BookingStatusEnum.BookingCompleted,
// maxRetries,
// retryInterval
// )
// if (
// confirmationNumber === null ||
// bookingStatus.isError ||
// (bookingStatus.isFetched && !bookingStatus.data)
// ) {
// // TODO: handle error
// throw new Error("Error fetching booking status")
// }
// if (
// bookingStatus.data?.reservationStatus === BookingStatusEnum.BookingCompleted
// ) {
// return <LoadingSpinner />

View File

@@ -0,0 +1,5 @@
.layout {
background-color: var(--Base-Surface-Primary-light-Normal);
min-height: 100dvh;
padding: 80px 0 160px;
}

View File

@@ -0,0 +1,15 @@
import { notFound } from "next/navigation"
import { env } from "@/env/server"
import styles from "./layout.module.css"
// route groups needed as layouts have different bgc
export default function ConfirmedBookingLayout({
children,
}: React.PropsWithChildren) {
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <div className={styles.layout}>{children}</div>
}

View File

@@ -1,6 +1,10 @@
import { redirect } from "next/navigation"
import { serverClient } from "@/lib/trpc/server"
import {
getCreditCardsSafely,
getHotelData,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
@@ -14,15 +18,20 @@ import styles from "./layout.module.css"
import { StepEnum } from "@/types/components/enterDetails/step"
import type { LangParams, LayoutArgs } from "@/types/params"
function preload(id: string, lang: string) {
void getHotelData(id, lang)
void getProfileSafely()
void getCreditCardsSafely()
}
export default async function StepLayout({
children,
params,
}: React.PropsWithChildren<LayoutArgs<LangParams & { step: StepEnum }>>) {
setLang(params.lang)
const hotel = await serverClient().hotel.hotelData.get({
hotelId: "811",
language: params.lang,
})
preload("811", params.lang)
const hotel = await getHotelData("811", params.lang)
if (!hotel?.data) {
redirect(`/${params.lang}`)

View File

@@ -1,13 +1,17 @@
import { notFound } from "next/navigation"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import {
getCreditCardsSafely,
getHotelData,
getProfileSafely,
} 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 Payment from "@/components/HotelReservation/SelectRate/Payment"
import { getIntl } from "@/i18n"
import { StepEnum } from "@/types/components/enterDetails/step"
@@ -24,12 +28,9 @@ export default async function StepPage({
const intl = await getIntl()
const hotel = await serverClient().hotel.hotelData.get({
hotelId: "811",
language: lang,
})
const hotel = await getHotelData("811", lang)
const user = await getProfileSafely()
const savedCreditCards = await getCreditCardsSafely()
if (!isValidStep(step) || !hotel) {
return notFound()
@@ -37,6 +38,7 @@ export default async function StepPage({
return (
<section>
<HistoryStateManager />
<SectionAccordion
header="Select bed"
step={StepEnum.selectBed}
@@ -63,7 +65,14 @@ export default async function StepPage({
step={StepEnum.payment}
label={intl.formatMessage({ id: "Select payment method" })}
>
<Payment hotel={hotel.data.attributes} />
<Payment
hotelId={hotel.data.attributes.operaId}
otherPaymentOptions={
hotel.data.attributes.merchantInformationData
.alternatePaymentOptions
}
savedCreditCards={savedCreditCards}
/>
</SectionAccordion>
</section>
)

View File

@@ -3,7 +3,7 @@ import { env } from "@/env/server"
import {
fetchAvailableHotels,
getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/select-hotel/utils"
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
import { setLang } from "@/i18n/serverContext"

View File

@@ -1,4 +1,4 @@
import { differenceInCalendarDays, format,isWeekend } from "date-fns"
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { Lang } from "@/constants/languages"
import { selectHotelMap } from "@/constants/routes/hotelReservation"
@@ -6,7 +6,7 @@ import { selectHotelMap } from "@/constants/routes/hotelReservation"
import {
fetchAvailableHotels,
getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/select-hotel/utils"
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
import { ChevronRightIcon } from "@/components/Icons"

View File

@@ -1,7 +1,9 @@
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection"
import getHotelReservationQueryParams from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { setLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
@@ -15,6 +17,12 @@ export default async function SelectRatePage({
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
setLang(params.lang)
const selectRoomParams = new URLSearchParams(searchParams)
const selectRoomParamsObject =
getHotelReservationQueryParams(selectRoomParams)
const adults = selectRoomParamsObject.room[0].adults // TODO: Handle multiple rooms
const children = selectRoomParamsObject.room[0].child?.length // TODO: Handle multiple rooms
const [hotelData, roomConfigurations, user] = await Promise.all([
serverClient().hotel.hotelData.get({
hotelId: searchParams.hotel,
@@ -23,9 +31,10 @@ export default async function SelectRatePage({
}),
serverClient().hotel.availability.rooms({
hotelId: parseInt(searchParams.hotel, 10),
roomStayStartDate: "2024-11-02",
roomStayEndDate: "2024-11-03",
adults: 1,
roomStayStartDate: searchParams.fromDate,
roomStayEndDate: searchParams.toDate,
adults,
children,
}),
getProfileSafely(),
])
@@ -42,9 +51,8 @@ export default async function SelectRatePage({
return (
<div>
<HotelInfoCard hotelData={hotelData} />
<div className={styles.content}>
{/* TODO: Add Hotel Listing Card */}
<div>Hotel Listing Card TBI</div>
<div className={styles.main}>
<RoomSelection
roomConfigurations={roomConfigurations}

View File

@@ -1,16 +0,0 @@
.main {
display: flex;
justify-content: center;
padding: var(--Spacing-x4);
background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh;
max-width: var(--max-width);
margin: 0 auto;
}
.section {
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
width: 100%;
}

View File

@@ -1,67 +0,0 @@
"use client"
import { useMemo } from "react"
import {
BOOKING_CONFIRMATION_NUMBER,
BookingStatusEnum,
} from "@/constants/booking"
import IntroSection from "@/components/HotelReservation/BookingConfirmation/IntroSection"
import StaySection from "@/components/HotelReservation/BookingConfirmation/StaySection"
import SummarySection from "@/components/HotelReservation/BookingConfirmation/SummarySection"
import { tempConfirmationData } from "@/components/HotelReservation/BookingConfirmation/tempConfirmationData"
import LoadingSpinner from "@/components/LoadingSpinner"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import styles from "./page.module.css"
const maxRetries = 10
const retryInterval = 2000
export default function BookingConfirmationPage() {
const { email, hotel, stay, summary } = tempConfirmationData
const confirmationNumber = useMemo(() => {
if (typeof window === "undefined") return ""
const storedConfirmationNumber = sessionStorage.getItem(
BOOKING_CONFIRMATION_NUMBER
)
// TODO: cleanup stored values
// sessionStorage.removeItem(BOOKING_CONFIRMATION_NUMBER)
return storedConfirmationNumber
}, [])
const bookingStatus = useHandleBookingStatus(
confirmationNumber,
BookingStatusEnum.BookingCompleted,
maxRetries,
retryInterval
)
if (
confirmationNumber === null ||
bookingStatus.isError ||
(bookingStatus.isFetched && !bookingStatus.data)
) {
// TODO: handle error
throw new Error("Error fetching booking status")
}
if (
bookingStatus.data?.reservationStatus === BookingStatusEnum.BookingCompleted
) {
return (
<main className={styles.main}>
<section className={styles.section}>
<IntroSection email={email} />
<StaySection hotel={hotel} stay={stay} />
<SummarySection summary={summary} />
</section>
</main>
)
}
return <LoadingSpinner />
}

View File

@@ -0,0 +1 @@
export { default } from "../../page"

View File

@@ -0,0 +1,3 @@
export default function ConfirmedBookingSlot() {
return null
}

View File

@@ -0,0 +1 @@
export { default } from "../page"

View File

@@ -1,8 +1,14 @@
import { env } from "@/env/server"
import LoadingSpinner from "@/components/LoadingSpinner"
import styles from "./loading.module.css"
export default function LoadingBookingWidget() {
if (env.HIDE_FOR_NEXT_RELEASE) {
return null
}
return (
<div className={styles.container}>
<LoadingSpinner />

View File

@@ -1,8 +1,13 @@
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server"
import BookingWidget, { preload } from "@/components/BookingWidget"
export default async function BookingWidgetPage() {
if (env.HIDE_FOR_NEXT_RELEASE) {
return null
}
preload()
// Get the booking widget show/hide status based on page specific settings

View File

@@ -0,0 +1 @@
export { default } from "../page"

View File

@@ -0,0 +1 @@
export { default } from "../../page"

View File

@@ -0,0 +1 @@
export { default } from "./page"

View File

@@ -0,0 +1 @@
export { default } from "../../page"

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react"
import { env } from "@/env/server"
import SitewideAlert, { preload } from "@/components/SitewideAlert"
import { setLang } from "@/i18n/serverContext"
import type { LangParams, PageArgs } from "@/types/params"
export default function SitewideAlertPage({ params }: PageArgs<LangParams>) {
if (env.HIDE_FOR_NEXT_RELEASE) {
return null
}
setLang(params.lang)
preload()
return (
<Suspense>
<SitewideAlert />
</Suspense>
)
}

View File

@@ -25,12 +25,14 @@ export default async function RootLayout({
children,
footer,
header,
sitewidealert,
params,
}: React.PropsWithChildren<
LayoutArgs<LangParams> & {
bookingwidget: React.ReactNode
footer: React.ReactNode
header: React.ReactNode
sitewidealert: React.ReactNode
}
>) {
setLang(params.lang)
@@ -60,6 +62,7 @@ export default async function RootLayout({
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
<TrpcProvider>
<RouterTracking>
{!env.HIDE_FOR_NEXT_RELEASE && <>{sitewidealert}</>}
{header}
{!env.HIDE_FOR_NEXT_RELEASE && <>{bookingwidget}</>}
{children}