feat(SW-791): make confirmation page dynamic

This commit is contained in:
Simon Emanuelsson
2024-11-06 16:31:03 +01:00
parent e6a70a0a8a
commit 0897a398ee
35 changed files with 983 additions and 577 deletions
@@ -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%));
}
}
@@ -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":