feat(SW-791): make confirmation page dynamic
This commit is contained in:
@@ -1,157 +1,7 @@
|
||||
.details,
|
||||
.guest,
|
||||
.header,
|
||||
.hgroup,
|
||||
.hotel,
|
||||
.list,
|
||||
.main,
|
||||
.section,
|
||||
.receipt,
|
||||
.total {
|
||||
.main {
|
||||
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;
|
||||
}
|
||||
width: min(calc(100dvw - (var(--Spacing-x3) * 2)), 948px);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
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 BookingConfirmation from "@/components/HotelReservation/BookingConfirmation"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
@@ -24,269 +11,12 @@ export default async function BookingConfirmationPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, { confirmationNumber: string }>) {
|
||||
setLang(params.lang)
|
||||
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.checkInDate).locale(params.lang)
|
||||
const toDate = dt(booking.checkOutDate).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"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
void getBookingConfirmation(confirmationNumber)
|
||||
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?.data.attributes.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: booking.confirmationNumber }
|
||||
)}
|
||||
</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?.data.attributes.name}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.hotel?.data.attributes.contactInformation.email}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.hotel?.data.attributes.contactInformation.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">
|
||||
{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(booking.totalPrice),
|
||||
currency: booking.currencyCode,
|
||||
}
|
||||
)}
|
||||
</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>
|
||||
<BookingConfirmation confirmationNumber={confirmationNumber} />
|
||||
</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 />
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
.actions {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: grid;
|
||||
grid-area: actions;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.actions {
|
||||
& > button[class*="btn"][class*="icon"][class*="small"] {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: 0;
|
||||
justify-content: space-between;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
& > svg {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.actions {
|
||||
gap: var(--Spacing-x1);
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CalendarIcon,
|
||||
ContractIcon,
|
||||
DownloadIcon,
|
||||
PrinterIcon,
|
||||
} 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"
|
||||
|
||||
export default async function Actions() {
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
|
||||
<CalendarIcon />
|
||||
{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" })}
|
||||
</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" })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.details {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
grid-area: details;
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.details {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function Details({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const { booking } = await getBookingConfirmation(confirmationNumber)
|
||||
|
||||
const fromDate = dt(booking.checkInDate).locale(lang)
|
||||
const toDate = dt(booking.checkOutDate).locale(lang)
|
||||
|
||||
return (
|
||||
<article className={styles.details}>
|
||||
<header>
|
||||
<Subtitle color="burgundy" type="two">
|
||||
{intl.formatMessage(
|
||||
{ id: "Reference #{bookingNr}" },
|
||||
{ bookingNr: booking.confirmationNumber }
|
||||
)}
|
||||
</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>N/A</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Cancellation policy" })}</Body>
|
||||
<Body>N/A</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>
|
||||
<Body>N/A</Body>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.header,
|
||||
.hgroup {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.hgroup {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.body {
|
||||
max-width: 560px;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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 styles from "./header.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function Header({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const { hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
|
||||
const text = intl.formatMessage<React.ReactNode>(
|
||||
{ id: "booking.confirmation.text" },
|
||||
{
|
||||
emailLink: (str) => (
|
||||
<Link color="burgundy" href="#" textDecoration="underline">
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
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"
|
||||
>
|
||||
{intl.formatMessage({ id: "booking.confirmation.title" })}
|
||||
</Title>
|
||||
<Title
|
||||
as="h4"
|
||||
color="burgundy"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
type="h1"
|
||||
>
|
||||
{hotel.name}
|
||||
</Title>
|
||||
</hgroup>
|
||||
<Body className={styles.body} textAlign="center">
|
||||
{text}
|
||||
</Body>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.imageContainer {
|
||||
align-items: center;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
|
||||
import styles from "./image.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function HotelImage({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const { hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
return (
|
||||
<aside className={styles.imageContainer}>
|
||||
<Image
|
||||
alt={hotel.hotelContent.images.metaData.altText}
|
||||
height={256}
|
||||
src={hotel.hotelContent.images.imageSizes.medium}
|
||||
title={hotel.hotelContent.images.metaData.title}
|
||||
width={256}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { profile } from "@/constants/routes/myPages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getBookingConfirmation,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { CreditCardAddIcon, EditIcon, PersonIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default async function Summary({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
const user = await getProfileSafely()
|
||||
const { firstName, lastName } = booking.guest
|
||||
const membershipNumber = user?.membership?.membershipNumber
|
||||
const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff(
|
||||
dt(booking.checkInDate.setHours(0, 0, 0)),
|
||||
"days"
|
||||
)
|
||||
|
||||
const breakfastPackage = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
return (
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Guest" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{`${firstName} ${lastName}`}</Body>
|
||||
{membershipNumber ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "membership.no" },
|
||||
{ membershipNumber }
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
|
||||
<Body color="uiTextHighContrast">{booking.guest.phoneNumber}</Body>
|
||||
</div>
|
||||
{user ? (
|
||||
<Link className={styles.link} href={profile[lang]} variant="icon">
|
||||
<PersonIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Go to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "guest.paid" },
|
||||
{
|
||||
amount: intl.formatNumber(booking.totalPrice),
|
||||
currency: booking.currencyCode,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">Date information N/A</Body>
|
||||
<Body color="uiTextHighContrast">Card information N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
{user ? (
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<CreditCardAddIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Save card to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Booking" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })}
|
||||
,{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: booking.adults }
|
||||
)}
|
||||
</Body>
|
||||
{breakfastPackage ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast added" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">Bedtype N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<EditIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Manage booking" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Hotel" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{hotel.name}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hotel.contactInformation.phoneNumber}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast" className={styles.latLong}>
|
||||
{`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.hotelLinks}>
|
||||
<Link color="peach80" href={hotel.contactInformation.websiteUrl}>
|
||||
{hotel.contactInformation.websiteUrl}
|
||||
</Link>
|
||||
<Link
|
||||
color="peach80"
|
||||
href={`mailto:${hotel.contactInformation.email}`}
|
||||
>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container,
|
||||
.textContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.container .textContainer .latLong {
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.hotelLinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary .container .link {
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import {
|
||||
CoffeeIcon,
|
||||
DiscountIcon,
|
||||
DoorClosedIcon,
|
||||
PriceTagIcon,
|
||||
} from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./totalPrice.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default async function TotalPrice({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const { booking } = await getBookingConfirmation(confirmationNumber)
|
||||
|
||||
const totalPrice = intl.formatNumber(booking.totalPrice, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})
|
||||
const breakfastPackage = booking.packages.find(
|
||||
(p) => p.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<hgroup>
|
||||
<Subtitle color="uiTextPlaceholder" type="two">
|
||||
{intl.formatMessage({ id: "Total price" })}
|
||||
</Subtitle>
|
||||
<Subtitle color="uiTextHighContrast" type="two">
|
||||
{totalPrice} (~ EUR)
|
||||
</Subtitle>
|
||||
</hgroup>
|
||||
<div className={styles.items}>
|
||||
<div>
|
||||
<DoorClosedIcon />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{`${intl.formatMessage({ id: "Room" })}, ${intl.formatMessage({ id: "booking.nights" }, { totalNights: 1 })}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{totalPrice}</Body>
|
||||
</div>
|
||||
<div>
|
||||
<CoffeeIcon />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Breakfast" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{breakfastPackage
|
||||
? intl.formatNumber(breakfastPackage.totalPrice, {
|
||||
currency: breakfastPackage.currency,
|
||||
style: "currency",
|
||||
})
|
||||
: intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
</div>
|
||||
<div>
|
||||
<DiscountIcon />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Member discount" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">N/A</Body>
|
||||
</div>
|
||||
<div>
|
||||
<PriceTagIcon height={20} width={20} />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Points used" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">N/A</Body>
|
||||
</div>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.items}>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Price excl VAT" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatNumber(booking.totalPriceExVat, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "VAT" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">{booking.vatPercentage}%</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "VAT amount" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatNumber(booking.vatAmount, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Price incl VAT" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatNumber(booking.totalPrice, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment method" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">N/A</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment status" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">N/A</Caption>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.container {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3) var(--Spacing-x1);
|
||||
grid-template-columns: repeat(4, minmax(100px, 1fr));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.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%));
|
||||
}
|
||||
}
|
||||
31
components/HotelReservation/BookingConfirmation/index.tsx
Normal file
31
components/HotelReservation/BookingConfirmation/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -44,7 +43,6 @@ export default function Details({ user }: DetailsProps) {
|
||||
firstName: user?.firstName ?? initialData.firstName,
|
||||
lastName: user?.lastName ?? initialData.lastName,
|
||||
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
|
||||
//@ts-expect-error: We use a literal for join to be true or false, which does not convert to a boolean
|
||||
join: initialData.join,
|
||||
dateOfBirth: initialData.dateOfBirth,
|
||||
zipCode: initialData.zipCode,
|
||||
@@ -58,14 +56,6 @@ export default function Details({ user }: DetailsProps) {
|
||||
|
||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async function (values: DetailsSchema) {
|
||||
completeStep(values)
|
||||
},
|
||||
|
||||
[completeStep]
|
||||
)
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<section className={styles.container}>
|
||||
@@ -77,7 +67,7 @@ export default function Details({ user }: DetailsProps) {
|
||||
<form
|
||||
className={styles.form}
|
||||
id={formID}
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
onSubmit={methods.handleSubmit(completeStep)}
|
||||
>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "First name" })}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const baseDetailsSchema = z.object({
|
||||
|
||||
export const notJoinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal(false),
|
||||
join: z.literal<boolean>(false),
|
||||
zipCode: z.string().optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
termsAccepted: z.boolean().default(false),
|
||||
@@ -21,10 +21,10 @@ export const notJoinDetailsSchema = baseDetailsSchema.merge(
|
||||
|
||||
export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
z.object({
|
||||
join: z.literal(true),
|
||||
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" }),
|
||||
termsAccepted: z.literal(true, {
|
||||
termsAccepted: z.literal<boolean>(true, {
|
||||
errorMap: (err, ctx) => {
|
||||
switch (err.code) {
|
||||
case "invalid_literal":
|
||||
|
||||
23
components/Icons/Coffee.tsx
Normal file
23
components/Icons/Coffee.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function CoffeeIcon({ 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.32422 17.2918C4.10894 17.2918 3.92491 17.2154 3.77214 17.0627C3.61936 16.9099 3.54297 16.7259 3.54297 16.5106C3.54297 16.2953 3.61936 16.1113 3.77214 15.9585C3.92491 15.8057 4.10894 15.7293 4.32422 15.7293H15.6784C15.8937 15.7293 16.0777 15.8057 16.2305 15.9585C16.3832 16.1113 16.4596 16.2953 16.4596 16.5106C16.4596 16.7259 16.3832 16.9099 16.2305 17.0627C16.0777 17.2154 15.8937 17.2918 15.6784 17.2918H4.32422ZM6.7513 14.1668C5.86241 14.1668 5.10547 13.8543 4.48047 13.2293C3.85547 12.6043 3.54297 11.8474 3.54297 10.9585V4.271C3.54297 3.8413 3.69596 3.47346 4.00195 3.16748C4.30793 2.86149 4.67577 2.7085 5.10547 2.7085H16.5638C16.9935 2.7085 17.3613 2.86149 17.6673 3.16748C17.9733 3.47346 18.1263 3.8413 18.1263 4.271V6.97933C18.1263 7.40902 17.9733 7.77686 17.6673 8.08285C17.3613 8.38884 16.9935 8.54183 16.5638 8.54183H14.793V10.9612C14.793 11.8483 14.478 12.6043 13.8481 13.2293C13.2181 13.8543 12.4609 14.1668 11.5763 14.1668H6.7513ZM6.7513 12.6043H11.5742C12.0256 12.6043 12.4145 12.4432 12.7409 12.1209C13.0673 11.7986 13.2305 11.4111 13.2305 10.9585V4.271H5.10547V10.9585C5.10547 11.4111 5.26662 11.7986 5.58893 12.1209C5.91125 12.4432 6.2987 12.6043 6.7513 12.6043ZM14.793 6.97933H16.5638V4.271H14.793V6.97933ZM6.7513 12.6043H5.10547H13.2305H6.7513Z"
|
||||
fill="#57514E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
27
components/Icons/Contract.tsx
Normal file
27
components/Icons/Contract.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function ContractIcon({
|
||||
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="M5.47786 18.125C4.81814 18.125 4.25391 17.8941 3.78516 17.4323C3.31641 16.9705 3.08203 16.4097 3.08203 15.75V14.7917C3.08203 14.362 3.23502 13.9941 3.54101 13.6881C3.847 13.3822 4.21484 13.2292 4.64453 13.2292H5.3737V3.4375C5.3737 3.00781 5.52669 2.63997 5.83268 2.33398C6.13866 2.02799 6.5065 1.875 6.9362 1.875H16.1029C16.5326 1.875 16.9004 2.02799 17.2064 2.33398C17.5124 2.63997 17.6654 3.00781 17.6654 3.4375V15.75C17.6654 16.4097 17.431 16.9705 16.9622 17.4323C16.4935 17.8941 15.9293 18.125 15.2695 18.125H5.47786ZM15.2695 16.5625C15.5056 16.5625 15.7036 16.4846 15.8633 16.3289C16.023 16.1732 16.1029 15.9802 16.1029 15.75V3.4375H6.9362V13.2292H12.8945C13.3242 13.2292 13.6921 13.3822 13.9981 13.6881C14.304 13.9941 14.457 14.362 14.457 14.7917V15.75C14.457 15.9802 14.5349 16.1732 14.6906 16.3289C14.8464 16.4846 15.0393 16.5625 15.2695 16.5625ZM8.65495 7.29167C8.43967 7.29167 8.25564 7.21528 8.10286 7.0625C7.95009 6.90972 7.8737 6.72569 7.8737 6.51042C7.8737 6.29514 7.95009 6.11111 8.10286 5.95833C8.25564 5.80556 8.43967 5.72917 8.65495 5.72917H14.3841C14.5994 5.72917 14.7834 5.80556 14.9362 5.95833C15.089 6.11111 15.1654 6.29514 15.1654 6.51042C15.1654 6.72569 15.089 6.90972 14.9362 7.0625C14.7834 7.21528 14.5994 7.29167 14.3841 7.29167H8.65495ZM8.65495 9.79167C8.43967 9.79167 8.25564 9.71528 8.10286 9.5625C7.95009 9.40972 7.8737 9.22569 7.8737 9.01042C7.8737 8.79514 7.95009 8.61111 8.10286 8.45833C8.25564 8.30556 8.43967 8.22917 8.65495 8.22917H14.3841C14.5994 8.22917 14.7834 8.30556 14.9362 8.45833C15.089 8.61111 15.1654 8.79514 15.1654 9.01042C15.1654 9.22569 15.089 9.40972 14.9362 9.5625C14.7834 9.71528 14.5994 9.79167 14.3841 9.79167H8.65495ZM5.47786 16.5625H12.8945V14.7917H4.64453V15.75C4.64453 15.9802 4.72439 16.1732 4.88411 16.3289C5.04384 16.4846 5.24175 16.5625 5.47786 16.5625Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
27
components/Icons/CreditCardAdd.tsx
Normal file
27
components/Icons/CreditCardAdd.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as variants from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function CreditCardAddIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = variants.iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3.4375 16.4792C3.00781 16.4792 2.63997 16.3262 2.33398 16.0202C2.02799 15.7142 1.875 15.3464 1.875 14.9167V5.0625C1.875 4.63281 2.02799 4.26497 2.33398 3.95898C2.63997 3.65299 3.00781 3.5 3.4375 3.5H16.5625C16.9922 3.5 17.36 3.65299 17.666 3.95898C17.972 4.26497 18.125 4.63281 18.125 5.0625V9.91667H3.4375V14.9167H10.8021C11.0174 14.9167 11.2014 14.9931 11.3542 15.1458C11.5069 15.2986 11.5833 15.4826 11.5833 15.6979C11.5833 15.9132 11.5069 16.0972 11.3542 16.25C11.2014 16.4028 11.0174 16.4792 10.8021 16.4792H3.4375ZM3.4375 6.77083H16.5625V5.0625H3.4375V6.77083ZM15.8854 15.7812H14.1667C13.9514 15.7812 13.7674 15.7049 13.6146 15.5521C13.4618 15.3993 13.3854 15.2153 13.3854 15C13.3854 14.7847 13.4618 14.6007 13.6146 14.4479C13.7674 14.2951 13.9514 14.2188 14.1667 14.2188H15.8854V12.5C15.8854 12.2847 15.9618 12.1007 16.1146 11.9479C16.2674 11.7951 16.4514 11.7188 16.6667 11.7188C16.8819 11.7188 17.066 11.7951 17.2188 11.9479C17.3715 12.1007 17.4479 12.2847 17.4479 12.5V14.2188H19.1667C19.3819 14.2188 19.566 14.2951 19.7188 14.4479C19.8715 14.6007 19.9479 14.7847 19.9479 15C19.9479 15.2153 19.8715 15.3993 19.7188 15.5521C19.566 15.7049 19.3819 15.7812 19.1667 15.7812H17.4479V17.5C17.4479 17.7153 17.3715 17.8993 17.2188 18.0521C17.066 18.2049 16.8819 18.2812 16.6667 18.2812C16.4514 18.2812 16.2674 18.2049 16.1146 18.0521C15.9618 17.8993 15.8854 17.7153 15.8854 17.5V15.7812Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
27
components/Icons/Discount.tsx
Normal file
27
components/Icons/Discount.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function DiscountIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M9.9987 12.0106L11.582 12.9689C11.7279 13.0592 11.872 13.054 12.0143 12.9533C12.1567 12.8526 12.207 12.7189 12.1654 12.5522L11.7487 10.746L13.1654 9.521C13.2973 9.40519 13.3355 9.26686 13.2799 9.10602C13.2244 8.94517 13.1098 8.8578 12.9362 8.84391L11.082 8.68766L10.3529 6.97933C10.2834 6.81961 10.1654 6.73975 9.9987 6.73975C9.83203 6.73975 9.71398 6.81961 9.64453 6.97933L8.91536 8.68766L7.0612 8.84391C6.88759 8.8578 6.773 8.94517 6.71745 9.10602C6.66189 9.26686 6.70009 9.40519 6.83203 9.521L8.2487 10.746L7.83203 12.5522C7.79037 12.7189 7.84071 12.8526 7.98307 12.9533C8.12543 13.054 8.26953 13.0592 8.41537 12.9689L9.9987 12.0106ZM7.2932 16.4585H5.10286C4.67317 16.4585 4.30533 16.3055 3.99934 15.9995C3.69336 15.6935 3.54036 15.3257 3.54036 14.896V12.7057L1.9362 11.0939C1.78342 10.9342 1.67057 10.7623 1.59766 10.5783C1.52474 10.3943 1.48828 10.2016 1.48828 10.0002C1.48828 9.79877 1.52474 9.60607 1.59766 9.42204C1.67057 9.23801 1.78342 9.06614 1.9362 8.90641L3.54036 7.29466V5.10433C3.54036 4.67464 3.69336 4.30679 3.99934 4.00081C4.30533 3.69482 4.67317 3.54183 5.10286 3.54183H7.2932L8.90495 1.93766C9.06467 1.78488 9.23655 1.67204 9.42057 1.59912C9.6046 1.5262 9.79731 1.48975 9.9987 1.48975C10.2001 1.48975 10.3928 1.5262 10.5768 1.59912C10.7609 1.67204 10.9327 1.78488 11.0924 1.93766L12.7042 3.54183H14.8945C15.3242 3.54183 15.6921 3.69482 15.9981 4.00081C16.304 4.30679 16.457 4.67464 16.457 5.10433V7.29466L18.0612 8.90641C18.214 9.06614 18.3268 9.23801 18.3997 9.42204C18.4727 9.60607 18.5091 9.79877 18.5091 10.0002C18.5091 10.2016 18.4727 10.3943 18.3997 10.5783C18.3268 10.7623 18.214 10.9342 18.0612 11.0939L16.457 12.7057V14.896C16.457 15.3257 16.304 15.6935 15.9981 15.9995C15.6921 16.3055 15.3242 16.4585 14.8945 16.4585H12.7042L11.0924 18.0627C10.9327 18.2154 10.7609 18.3283 10.5768 18.4012C10.3928 18.4741 10.2001 18.5106 9.9987 18.5106C9.79731 18.5106 9.6046 18.4741 9.42057 18.4012C9.23655 18.3283 9.06467 18.2154 8.90495 18.0627L7.2932 16.4585ZM7.9362 14.896L9.9987 16.9585L12.0612 14.896H14.8945V12.0627L16.957 10.0002L14.8945 7.93766V5.10433H12.0612L9.9987 3.04183L7.9362 5.10433H5.10286V7.93766L3.04036 10.0002L5.10286 12.0627V14.896H7.9362Z"
|
||||
fill="#57514E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
27
components/Icons/DoorClosed.tsx
Normal file
27
components/Icons/DoorClosed.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function DoorClosedIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3.46875 17.3125C3.25347 17.3125 3.06944 17.2361 2.91667 17.0833C2.76389 16.9306 2.6875 16.7465 2.6875 16.5312C2.6875 16.316 2.76389 16.1319 2.91667 15.9792C3.06944 15.8264 3.25347 15.75 3.46875 15.75H4.33333V4.25C4.33333 3.82031 4.48633 3.45247 4.79231 3.14648C5.0983 2.84049 5.46614 2.6875 5.89583 2.6875H14.1042C14.5339 2.6875 14.9017 2.84049 15.2077 3.14648C15.5137 3.45247 15.6667 3.82031 15.6667 4.25V15.75H16.5312C16.7465 15.75 16.9306 15.8264 17.0833 15.9792C17.2361 16.1319 17.3125 16.316 17.3125 16.5312C17.3125 16.7465 17.2361 16.9306 17.0833 17.0833C16.9306 17.2361 16.7465 17.3125 16.5312 17.3125H3.46875ZM5.89583 15.75H14.1042V4.25H5.89583V15.75ZM8.33223 10.8125C8.56213 10.8125 8.75521 10.7347 8.91146 10.5792C9.06771 10.4237 9.14583 10.231 9.14583 10.0011C9.14583 9.7712 9.06808 9.57813 8.91256 9.42188C8.75704 9.26563 8.56433 9.1875 8.33444 9.1875C8.10453 9.1875 7.91146 9.26526 7.75521 9.42077C7.59896 9.5763 7.52083 9.76901 7.52083 9.9989C7.52083 10.2288 7.59859 10.4219 7.7541 10.5781C7.90963 10.7344 8.10234 10.8125 8.33223 10.8125Z"
|
||||
fill="#57514E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -25,15 +25,20 @@ export { default as ChevronRightSmallIcon } from "./ChevronRightSmall"
|
||||
export { default as CityIcon } from "./City"
|
||||
export { default as CloseIcon } from "./Close"
|
||||
export { default as CloseLargeIcon } from "./CloseLarge"
|
||||
export { default as CoffeeIcon } from "./Coffee"
|
||||
export { default as CoffeeAltIcon } from "./CoffeeAlt"
|
||||
export { default as ConciergeIcon } from "./Concierge"
|
||||
export { default as ContractIcon } from "./Contract"
|
||||
export { default as ConvenienceStore24hIcon } from "./ConvenienceStore24h"
|
||||
export { default as CoolIcon } from "./Cool"
|
||||
export { default as CreditCard } from "./CreditCard"
|
||||
export { default as CreditCardAddIcon } from "./CreditCardAdd"
|
||||
export { default as CrossCircle } from "./CrossCircle"
|
||||
export { default as CulturalIcon } from "./Cultural"
|
||||
export { default as DeleteIcon } from "./Delete"
|
||||
export { default as DeskIcon } from "./Desk"
|
||||
export { default as DiscountIcon } from "./Discount"
|
||||
export { default as DoorClosedIcon } from "./DoorClosed"
|
||||
export { default as DoorOpenIcon } from "./DoorOpen"
|
||||
export { default as DownloadIcon } from "./Download"
|
||||
export { default as DresserIcon } from "./Dresser"
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
color: var(--UI-Text-Medium-contrast);
|
||||
}
|
||||
|
||||
.uiTextPlaceholder {
|
||||
color: var(--UI-Text-Placeholder);
|
||||
}
|
||||
|
||||
.red {
|
||||
color: var(--Scandic-Brand-Scandic-Red);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const config = {
|
||||
pale: styles.pale,
|
||||
uiTextHighContrast: styles.uiTextHighContrast,
|
||||
uiTextMediumContrast: styles.uiTextMediumContrast,
|
||||
uiTextPlaceholder: styles.uiTextPlaceholder,
|
||||
red: styles.red,
|
||||
},
|
||||
textAlign: {
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"Birth date": "Birth date",
|
||||
"Book": "Book",
|
||||
"Book reward night": "Book reward night",
|
||||
"Booking": "Booking",
|
||||
"Booking number": "Booking number",
|
||||
"Breakfast": "Breakfast",
|
||||
"Breakfast buffet": "Breakfast buffet",
|
||||
@@ -56,9 +57,12 @@
|
||||
"Business": "Business",
|
||||
"Cancel": "Cancel",
|
||||
"Cancellation policy": "Cancellation policy",
|
||||
"Change room": "Change room",
|
||||
"Check in": "Check in",
|
||||
"Check out": "Check out",
|
||||
"Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.",
|
||||
"Check-in": "Check-in",
|
||||
"Check-out": "Check-out",
|
||||
"Child age is required": "Child age is required",
|
||||
"Children": "Children",
|
||||
"Choose room": "Choose room",
|
||||
@@ -141,6 +145,7 @@
|
||||
"Go back to edit": "Go back to edit",
|
||||
"Go back to overview": "Go back to overview",
|
||||
"Go to My Benefits": "Go to My Benefits",
|
||||
"Go to profile": "Go to profile",
|
||||
"Guarantee booking with credit card": "Guarantee booking with credit card",
|
||||
"Guest": "Guest",
|
||||
"Guest information": "Guest information",
|
||||
@@ -168,6 +173,7 @@
|
||||
"Language": "Language",
|
||||
"Last name": "Last name",
|
||||
"Latest searches": "Latest searches",
|
||||
"Latitude": "Latitude {lat}",
|
||||
"Left": "left",
|
||||
"Level": "Level",
|
||||
"Level 1": "Level 1",
|
||||
@@ -184,19 +190,23 @@
|
||||
"Log in here": "Log in here",
|
||||
"Log in/Join": "Log in/Join",
|
||||
"Log out": "Log out",
|
||||
"Longitude": "Longitude {long}",
|
||||
"MY SAVED CARDS": "MY SAVED CARDS",
|
||||
"Main menu": "Main menu",
|
||||
"Manage booking": "Manage booking",
|
||||
"Manage preferences": "Manage preferences",
|
||||
"Map": "Map",
|
||||
"Map of HOTEL_NAME": "Map of {hotelName}",
|
||||
"Marketing city": "Marketing city",
|
||||
"Meetings & Conferences": "Meetings & Conferences",
|
||||
"Member discount": "Member discount",
|
||||
"Member price": "Member price",
|
||||
"Member price from": "Member price from",
|
||||
"Members": "Members",
|
||||
"Membership ID": "Membership ID",
|
||||
"Membership ID copied to clipboard": "Membership ID copied to clipboard",
|
||||
"Membership cards": "Membership cards",
|
||||
"Membership no": "Membership no",
|
||||
"Membership terms and conditions": "Membership terms and conditions",
|
||||
"Menu": "Menu",
|
||||
"Modify": "Modify",
|
||||
@@ -245,7 +255,9 @@
|
||||
"Payment": "Payment",
|
||||
"Payment Guarantee": "Payment Guarantee",
|
||||
"Payment info": "Payment info",
|
||||
"Payment method": "Payment method",
|
||||
"Payment received": "Payment received",
|
||||
"Payment status": "Payment status",
|
||||
"Pet Room": "Pet room",
|
||||
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Pet-friendly rooms have an additional fee of 20 EUR per stay",
|
||||
"Phone": "Phone",
|
||||
@@ -259,10 +271,13 @@
|
||||
"Points may take up to 10 days to be displayed.": "Points may take up to 10 days to be displayed.",
|
||||
"Points needed to level up": "Points needed to level up",
|
||||
"Points needed to stay on level": "Points needed to stay on level",
|
||||
"Points used": "Points used",
|
||||
"Practical information": "Practial information",
|
||||
"Previous": "Previous",
|
||||
"Previous victories": "Previous victories",
|
||||
"Price details": "Price details",
|
||||
"Price excl VAT": "Price excl VAT",
|
||||
"Price incl VAT": "Price incl VAT",
|
||||
"Print confirmation": "Print confirmation",
|
||||
"Proceed to login": "Proceed to login",
|
||||
"Proceed to payment method": "Proceed to payment method",
|
||||
@@ -291,6 +306,7 @@
|
||||
"Rooms & Guests": "Rooms & Guests",
|
||||
"Sauna and gym": "Sauna and gym",
|
||||
"Save": "Save",
|
||||
"Save card to profile": "Save card to profile",
|
||||
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
|
||||
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
|
||||
"Scandic's Privacy Policy.": "Scandic's Privacy Policy.",
|
||||
@@ -302,6 +318,7 @@
|
||||
"See on map": "See on map",
|
||||
"See room details": "See room details",
|
||||
"See rooms": "See rooms",
|
||||
"See you soon!": "See you soon!",
|
||||
"Select a country": "Select a country",
|
||||
"Select bed": "Select bed",
|
||||
"Select breakfast options": "Select breakfast options",
|
||||
@@ -353,9 +370,11 @@
|
||||
"Use code/voucher": "Use code/voucher",
|
||||
"User information": "User information",
|
||||
"VAT": "VAT",
|
||||
"VAT amount": "VAT amount",
|
||||
"Valid through": "Valid through",
|
||||
"View as list": "View as list",
|
||||
"View as map": "View as map",
|
||||
"View terms": "View terms",
|
||||
"View your booking": "View your booking",
|
||||
"Visiting address": "Visiting address",
|
||||
"We could not add a card right now, please try again later.": "We could not add a card right now, please try again later.",
|
||||
@@ -419,12 +438,13 @@
|
||||
"from": "from",
|
||||
"guaranteeing": "guaranteeing",
|
||||
"guest": "guest",
|
||||
"guest.paid": "{amount} {currency} has been paid",
|
||||
"guests": "guests",
|
||||
"hotelPages.rooms.roomCard.persons": "{totalOccupancy, plural, one {# person} other {# persons}}",
|
||||
"hotelPages.rooms.roomCard.seeRoomDetails": "See room details",
|
||||
"km to city center": "km to city center",
|
||||
"lowercase letter": "lowercase letter",
|
||||
"member no": "member no",
|
||||
"membership.no": "Scandic Friends No. {membershipNumber}",
|
||||
"n/a": "n/a",
|
||||
"next level:": "next level:",
|
||||
"night": "night",
|
||||
|
||||
@@ -143,3 +143,9 @@ export const getBreakfastPackages = cache(async function getMemoizedPackages(
|
||||
) {
|
||||
return serverClient().hotel.packages.breakfast(input)
|
||||
})
|
||||
|
||||
export const getBookingConfirmation = cache(
|
||||
function getMemoizedBookingConfirmation(confirmationNumber: string) {
|
||||
return serverClient().booking.confirmation({ confirmationNumber })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,6 +2,10 @@ import { z } from "zod"
|
||||
|
||||
import { ChildBedTypeEnum } from "@/constants/booking"
|
||||
|
||||
import { phoneValidator } from "@/utils/phoneValidator"
|
||||
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
|
||||
// MUTATION
|
||||
export const createBookingSchema = z
|
||||
.object({
|
||||
@@ -42,20 +46,20 @@ const extraBedTypesSchema = z.object({
|
||||
})
|
||||
|
||||
const guestSchema = z.object({
|
||||
email: z.string().email().nullable().default(""),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
email: z.string().nullable(),
|
||||
phoneNumber: z.string().nullable(),
|
||||
phoneNumber: phoneValidator().nullable().default(""),
|
||||
})
|
||||
|
||||
const packagesSchema = z.array(
|
||||
z.object({
|
||||
accessibility: z.boolean().optional(),
|
||||
allergyFriendly: z.boolean().optional(),
|
||||
breakfast: z.boolean().optional(),
|
||||
petFriendly: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
const packageSchema = z.object({
|
||||
code: z.string().default(""),
|
||||
currency: z.nativeEnum(CurrencyEnum),
|
||||
quantity: z.number().int(),
|
||||
totalPrice: z.number(),
|
||||
totalQuantity: z.number().int(),
|
||||
unitPrice: z.number(),
|
||||
})
|
||||
|
||||
export const bookingConfirmationSchema = z
|
||||
.object({
|
||||
@@ -66,17 +70,21 @@ export const bookingConfirmationSchema = z
|
||||
checkOutDate: z.date({ coerce: true }),
|
||||
createDateTime: z.date({ coerce: true }),
|
||||
childrenAges: z.array(z.number()),
|
||||
extraBedTypes: z.array(extraBedTypesSchema),
|
||||
extraBedTypes: z.array(extraBedTypesSchema).default([]),
|
||||
computedReservationStatus: z.string(),
|
||||
confirmationNumber: z.string(),
|
||||
currencyCode: z.string(),
|
||||
currencyCode: z.nativeEnum(CurrencyEnum),
|
||||
guest: guestSchema,
|
||||
hasPayRouting: z.boolean().optional(),
|
||||
hotelId: z.string(),
|
||||
packages: packagesSchema,
|
||||
packages: z.array(packageSchema),
|
||||
rateCode: z.string(),
|
||||
reservationStatus: z.string(),
|
||||
roomPrice: z.number().int(),
|
||||
roomTypeCode: z.string(),
|
||||
totalPrice: z.number(),
|
||||
totalPriceExVat: z.number(),
|
||||
vatAmount: z.number(),
|
||||
vatPercentage: z.number(),
|
||||
}),
|
||||
id: z.string(),
|
||||
type: z.literal("booking"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc"
|
||||
import { router, serviceProcedure } from "@/server/trpc"
|
||||
|
||||
@@ -87,6 +88,28 @@ export const bookingQueryRouter = router({
|
||||
ctx.serviceToken
|
||||
)
|
||||
|
||||
if (!hotelData) {
|
||||
getBookingConfirmationFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
hotelId: booking.data.hotelId,
|
||||
error_type: "http_error",
|
||||
error: "Couldn`t get hotel",
|
||||
})
|
||||
console.error(
|
||||
"api.booking.confirmation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber, hotelId: booking.data.hotelId },
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text: "Couldn`t get hotel",
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
throw serverErrorByStatus(404)
|
||||
}
|
||||
|
||||
getBookingConfirmationSuccessCounter.add(1, { confirmationNumber })
|
||||
console.info(
|
||||
"api.booking.confirmation success",
|
||||
@@ -95,44 +118,31 @@ export const bookingQueryRouter = router({
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Add hotels check in and out times to booking check in and out date
|
||||
* as that is date only (YYYY-MM-DD)
|
||||
*/
|
||||
const checkInTime =
|
||||
hotelData.data.attributes.hotelFacts.checkin.checkInTime
|
||||
const [checkInHour, checkInMinute] = checkInTime.split(":")
|
||||
const checkIn = dt(booking.data.checkInDate)
|
||||
.set("hour", Number(checkInHour))
|
||||
.set("minute", Number(checkInMinute))
|
||||
const checkOutTime =
|
||||
hotelData.data.attributes.hotelFacts.checkin.checkOutTime
|
||||
const [checkOutHour, checkOutMinute] = checkOutTime.split(":")
|
||||
const checkOut = dt(booking.data.checkOutDate)
|
||||
.set("hour", Number(checkOutHour))
|
||||
.set("minute", Number(checkOutMinute))
|
||||
|
||||
booking.data.checkInDate = checkIn.toDate()
|
||||
booking.data.checkOutDate = checkOut.toDate()
|
||||
|
||||
return {
|
||||
...booking.data,
|
||||
hotel: hotelData,
|
||||
temp: {
|
||||
breakfastFrom: "06:30",
|
||||
breakfastTo: "11:00",
|
||||
cancelPolicy: "Free rebooking",
|
||||
fromDate: "2024-10-21 14:00",
|
||||
packages: [
|
||||
{
|
||||
name: "Breakfast buffet",
|
||||
price: "150 SEK",
|
||||
},
|
||||
{
|
||||
name: "Member discount",
|
||||
price: "-297 SEK",
|
||||
},
|
||||
{
|
||||
name: "Points used / remaining",
|
||||
price: "0 / 1044",
|
||||
},
|
||||
],
|
||||
payment: "2024-08-09 1:47",
|
||||
room: {
|
||||
price: "2 589 SEK",
|
||||
type: "Cozy Cabin",
|
||||
vat: "684,79 SEK",
|
||||
},
|
||||
toDate: "2024-10-22 11:00",
|
||||
total: "2 739 SEK",
|
||||
totalInEuro: "265 EUR",
|
||||
},
|
||||
guest: {
|
||||
email: "sarah.obrian@gmail.com",
|
||||
firstName: "Sarah",
|
||||
lastName: "O'Brian",
|
||||
memberbershipNumber: "19822",
|
||||
phoneNumber: "+46702446688",
|
||||
booking: booking.data,
|
||||
hotel: {
|
||||
...hotelData.data.attributes,
|
||||
included: hotelData.included,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -381,11 +381,54 @@ const merchantInformationSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
const hotelFacilityDetailSchema = z
|
||||
.object({
|
||||
description: z.string(),
|
||||
heading: z.string(),
|
||||
})
|
||||
.optional()
|
||||
|
||||
/** Possibly more values */
|
||||
const hotelFacilityDetailsSchema = z.object({
|
||||
breakfast: hotelFacilityDetailSchema,
|
||||
checkout: hotelFacilityDetailSchema,
|
||||
gym: hotelFacilityDetailSchema,
|
||||
internet: hotelFacilityDetailSchema,
|
||||
laundry: hotelFacilityDetailSchema,
|
||||
luggage: hotelFacilityDetailSchema,
|
||||
shop: hotelFacilityDetailSchema,
|
||||
telephone: hotelFacilityDetailSchema,
|
||||
})
|
||||
|
||||
const hotelInformationSchema = z
|
||||
.object({
|
||||
description: z.string(),
|
||||
heading: z.string(),
|
||||
link: z.string().optional(),
|
||||
})
|
||||
.optional()
|
||||
|
||||
const hotelInformationsSchema = z.object({
|
||||
accessibility: hotelInformationSchema,
|
||||
safety: hotelInformationSchema,
|
||||
sustainability: hotelInformationSchema,
|
||||
})
|
||||
|
||||
const hotelFactsSchema = z.object({
|
||||
checkin: checkinSchema,
|
||||
ecoLabels: ecoLabelsSchema,
|
||||
hotelFacilityDetail: hotelFacilityDetailsSchema.default({}),
|
||||
hotelInformation: hotelInformationsSchema.default({}),
|
||||
interior: interiorSchema,
|
||||
receptionHours: receptionHoursSchema,
|
||||
yearBuilt: z.string(),
|
||||
})
|
||||
|
||||
// NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html
|
||||
export const getHotelDataSchema = z.object({
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
type: z.string(), // No enum here but the standard return appears to be "hotels".
|
||||
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
|
||||
language: z.string().transform((val) => {
|
||||
const lang = toLang(val)
|
||||
if (!lang) {
|
||||
@@ -394,44 +437,41 @@ export const getHotelDataSchema = z.object({
|
||||
return lang
|
||||
}),
|
||||
attributes: z.object({
|
||||
name: z.string(),
|
||||
operaId: z.string(),
|
||||
keywords: z.array(z.string()),
|
||||
isPublished: z.boolean(),
|
||||
accessibilityElevatorPitchText: z.string().optional(),
|
||||
address: addressSchema,
|
||||
cityId: z.string(),
|
||||
cityName: z.string(),
|
||||
ratings: ratingsSchema,
|
||||
address: addressSchema,
|
||||
conferencesAndMeetings: facilitySchema.optional(),
|
||||
contactInformation: contactInformationSchema,
|
||||
hotelFacts: z.object({
|
||||
checkin: checkinSchema,
|
||||
ecoLabels: ecoLabelsSchema,
|
||||
interior: interiorSchema,
|
||||
receptionHours: receptionHoursSchema,
|
||||
yearBuilt: z.string(),
|
||||
}),
|
||||
location: locationSchema,
|
||||
hotelContent: hotelContentSchema,
|
||||
detailedFacilities: z
|
||||
.array(detailedFacilitySchema)
|
||||
.transform((facilities) =>
|
||||
facilities.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||
),
|
||||
gallery: gallerySchema.optional(),
|
||||
healthAndWellness: facilitySchema.optional(),
|
||||
healthFacilities: z.array(healthFacilitySchema),
|
||||
hotelContent: hotelContentSchema,
|
||||
hotelFacts: hotelFactsSchema,
|
||||
hotelRoomElevatorPitchText: z.string().optional(),
|
||||
hotelType: z.string().optional(),
|
||||
isActive: z.boolean(),
|
||||
isPublished: z.boolean(),
|
||||
keywords: z.array(z.string()),
|
||||
location: locationSchema,
|
||||
merchantInformationData: merchantInformationSchema,
|
||||
rewardNight: rewardNightSchema,
|
||||
name: z.string(),
|
||||
operaId: z.string(),
|
||||
parking: z.array(parkingSchema),
|
||||
pointsOfInterest: z
|
||||
.array(pointOfInterestSchema)
|
||||
.transform((pois) => pois.sort((a, b) => a.distance - b.distance)),
|
||||
parking: z.array(parkingSchema),
|
||||
specialNeedGroups: z.array(specialNeedGroupSchema),
|
||||
ratings: ratingsSchema,
|
||||
rewardNight: rewardNightSchema,
|
||||
restaurantImages: facilitySchema.optional(),
|
||||
socialMedia: socialMediaSchema,
|
||||
specialAlerts: specialAlertsSchema,
|
||||
isActive: z.boolean(),
|
||||
conferencesAndMeetings: facilitySchema.optional(),
|
||||
healthAndWellness: facilitySchema.optional(),
|
||||
restaurantImages: facilitySchema.optional(),
|
||||
gallery: gallerySchema.optional(),
|
||||
specialNeedGroups: z.array(specialNeedGroupSchema),
|
||||
}),
|
||||
relationships: relationshipsSchema,
|
||||
}),
|
||||
@@ -631,7 +671,7 @@ export const apiCitiesByCountrySchema = z.object({
|
||||
})
|
||||
|
||||
export interface CitiesByCountry
|
||||
extends z.output<typeof apiCitiesByCountrySchema> {}
|
||||
extends z.output<typeof apiCitiesByCountrySchema> { }
|
||||
export type CitiesGroupedByCountry = Record<string, CitiesByCountry["data"]>
|
||||
|
||||
export const apiCountriesSchema = z.object({
|
||||
@@ -661,7 +701,7 @@ export const apiCountriesSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
export interface Countries extends z.output<typeof apiCountriesSchema> {}
|
||||
export interface Countries extends z.output<typeof apiCountriesSchema> { }
|
||||
|
||||
export const apiLocationCitySchema = z.object({
|
||||
attributes: z.object({
|
||||
@@ -802,10 +842,7 @@ export const breakfastPackageSchema = z.object({
|
||||
description: z.string(),
|
||||
localPrice: breakfastPackagePriceSchema,
|
||||
requestedPrice: breakfastPackagePriceSchema,
|
||||
packageType: z.enum([
|
||||
PackageTypeEnum.BreakfastAdult,
|
||||
PackageTypeEnum.BreakfastChildren,
|
||||
]),
|
||||
packageType: z.literal(PackageTypeEnum.BreakfastAdult),
|
||||
})
|
||||
|
||||
export const breakfastPackagesSchema = z
|
||||
|
||||
@@ -1062,20 +1062,10 @@ export const hotelQueryRouter = router({
|
||||
user.membership &&
|
||||
["L6", "L7"].includes(user.membership.membershipLevel)
|
||||
) {
|
||||
const originalBreakfastPackage = breakfastPackages.data.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
const freeBreakfastPackage = breakfastPackages.data.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
|
||||
)
|
||||
if (freeBreakfastPackage && freeBreakfastPackage.localPrice) {
|
||||
if (
|
||||
originalBreakfastPackage &&
|
||||
originalBreakfastPackage.localPrice
|
||||
) {
|
||||
freeBreakfastPackage.localPrice.price =
|
||||
originalBreakfastPackage.localPrice.price
|
||||
}
|
||||
if (freeBreakfastPackage?.localPrice) {
|
||||
return [freeBreakfastPackage]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export const getStaysSchema = z.object({
|
||||
relationships: z.object({
|
||||
hotel: z.object({
|
||||
links: z.object({
|
||||
related: z.string(),
|
||||
related: z.string().nullable().optional(),
|
||||
}),
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
|
||||
@@ -38,7 +38,7 @@ interface EnterDetailsState {
|
||||
step: StepEnum,
|
||||
updatedData?: Record<
|
||||
string,
|
||||
string | boolean | BreakfastPackage | BedTypeSchema
|
||||
string | boolean | number | BreakfastPackage | BedTypeSchema
|
||||
>
|
||||
) => void
|
||||
setCurrentStep: (step: StepEnum) => void
|
||||
@@ -157,7 +157,6 @@ export function initEditDetailsState(
|
||||
const nextStep =
|
||||
state.steps[state.steps.indexOf(state.currentStep) + 1]
|
||||
|
||||
// @ts-expect-error: ts has a hard time understanding that "false | true" equals "boolean"
|
||||
state.userData = {
|
||||
...state.userData,
|
||||
...updatedData,
|
||||
|
||||
@@ -1,40 +1,3 @@
|
||||
export type BookingConfirmation = {
|
||||
email: string
|
||||
hotel: {
|
||||
name: string
|
||||
address: string
|
||||
location: string
|
||||
phone: string
|
||||
image: string
|
||||
checkIn: string
|
||||
checkOut: string
|
||||
breakfast: {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
}
|
||||
stay: {
|
||||
nights: number
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
summary: {
|
||||
roomType: string
|
||||
bedType: string
|
||||
breakfast: string
|
||||
flexibility: string
|
||||
}
|
||||
}
|
||||
|
||||
export type IntroSectionProps = {
|
||||
email: BookingConfirmation["email"]
|
||||
}
|
||||
|
||||
export type StaySectionProps = {
|
||||
hotel: BookingConfirmation["hotel"]
|
||||
stay: BookingConfirmation["stay"]
|
||||
}
|
||||
|
||||
export type SummarySectionProps = {
|
||||
summary: BookingConfirmation["summary"]
|
||||
export interface BookingConfirmationProps {
|
||||
confirmationNumber: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user