Merge remote-tracking branch 'origin' into feature/tracking

This commit is contained in:
Linus Flood
2024-12-05 15:12:09 +01:00
114 changed files with 1388 additions and 653 deletions

View File

@@ -0,0 +1,61 @@
"use client"
import { createEvent } from "ics"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { CalendarAddIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import useLang from "@/hooks/useLang"
import type { AddToCalendarProps } from "@/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar"
export default function AddToCalendar({
checkInDate,
event,
hotelName,
}: AddToCalendarProps) {
const intl = useIntl()
const lang = useLang()
async function downloadBooking() {
const d = dt(checkInDate).locale(lang).format("YYYY-MM-DD")
const filename = `${hotelName.toLowerCase().split(" ").join("_")}-${d}.ics`
const file: Blob = await new Promise((resolve, reject) => {
createEvent(event, (error, value) => {
if (error) {
reject(error)
}
resolve(new File([value], filename, { type: "text/calendar" }))
})
})
const url = URL.createObjectURL(file)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
}
return (
<Button
intent="text"
onPress={downloadBooking}
size="small"
theme="base"
variant="icon"
wrapping
>
<CalendarAddIcon />
{intl.formatMessage({ id: "Add to calendar" })}
</Button>
)
}

View File

@@ -0,0 +1,31 @@
"use client"
import { useIntl } from "react-intl"
import { useReactToPrint } from "react-to-print"
import { DownloadIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import type { DownloadInvoiceProps } from "@/types/components/hotelReservation/bookingConfirmation/actions/downloadInvoice"
export default function DownloadInvoice({ mainRef }: DownloadInvoiceProps) {
const intl = useIntl()
const reactToPrintFn = useReactToPrint({ contentRef: mainRef })
function downloadBooking() {
reactToPrintFn()
}
return (
<Button
intent="text"
onPress={downloadBooking}
size="small"
theme="base"
variant="icon"
wrapping
>
<DownloadIcon />
{intl.formatMessage({ id: "Download invoice" })}
</Button>
)
}

View File

@@ -0,0 +1,15 @@
"use client"
import { useIntl } from "react-intl"
import { EditIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
export default function ManageBooking() {
const intl = useIntl()
return (
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
<EditIcon />
{intl.formatMessage({ id: "Manage booking" })}
</Button>
)
}

View File

@@ -1,15 +0,0 @@
.actions {
border-radius: var(--Corner-radius-Medium);
display: grid;
grid-area: actions;
justify-content: flex-start;
}
@media screen and (min-width: 768px) {
.actions {
gap: var(--Spacing-x3);
grid-auto-columns: auto;
grid-auto-flow: column;
grid-template-columns: auto;
}
}

View File

@@ -0,0 +1,15 @@
import { dt } from "@/lib/dt"
import type { DateTime } from "ics"
export function generateDateTime(d: Date): DateTime {
const _d = dt(d).utc()
return [
_d.year(),
// Need to add +1 since month is 0 based
_d.month() + 1,
_d.date(),
_d.hour(),
_d.minute(),
]
}

View File

@@ -1,25 +0,0 @@
import { CalendarAddIcon, DownloadIcon, EditIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
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>
<CalendarAddIcon />
{intl.formatMessage({ id: "Add to calendar" })}
</Button>
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
<EditIcon />
{intl.formatMessage({ id: "Manage booking" })}
</Button>
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
<DownloadIcon />
{intl.formatMessage({ id: "Download invoice" })}
</Button>
</div>
)
}

View File

@@ -17,6 +17,22 @@
max-width: 720px;
}
.actions {
border-radius: var(--Corner-radius-Medium);
display: grid;
grid-area: actions;
justify-content: flex-start;
}
@media screen and (min-width: 768px) {
.actions {
gap: var(--Spacing-x3);
grid-auto-columns: auto;
grid-auto-flow: column;
grid-template-columns: auto;
}
}
@media screen and (min-width: 1367px) {
.header {
padding-bottom: var(--Spacing-x4);

View File

@@ -1,21 +1,27 @@
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
"use client"
import { useIntl } from "react-intl"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import Actions from "./Actions"
import AddToCalendar from "./Actions/AddToCalendar"
import DownloadInvoice from "./Actions/DownloadInvoice"
import { generateDateTime } from "./Actions/helpers"
import ManageBooking from "./Actions/ManageBooking"
import styles from "./header.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import type { EventAttributes } from "ics"
export default async function Header({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const { hotel } = await getBookingConfirmation(confirmationNumber)
import type { BookingConfirmationHeaderProps } from "@/types/components/hotelReservation/bookingConfirmation/header"
export default function Header({
booking,
hotel,
mainRef,
}: BookingConfirmationHeaderProps) {
const intl = useIntl()
const text = intl.formatMessage<React.ReactNode>(
{ id: "booking.confirmation.text" },
@@ -28,6 +34,25 @@ export default async function Header({
}
)
const event: EventAttributes = {
busyStatus: "FREE",
categories: ["booking", "hotel", "stay"],
created: generateDateTime(booking.createDateTime),
description: hotel.hotelContent.texts.descriptions.medium,
end: generateDateTime(booking.checkOutDate),
endInputType: "utc",
geo: {
lat: hotel.location.latitude,
lon: hotel.location.longitude,
},
location: `${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city} ${hotel.address.country}`,
start: generateDateTime(booking.checkInDate),
startInputType: "utc",
status: "CONFIRMED",
title: hotel.name,
url: hotel.contactInformation.websiteUrl,
}
return (
<header className={styles.header}>
<hgroup className={styles.hgroup}>
@@ -39,7 +64,15 @@ export default async function Header({
</Title>
</hgroup>
<Body className={styles.body}>{text}</Body>
<Actions />
<div className={styles.actions}>
<AddToCalendar
checkInDate={booking.checkInDate}
event={event}
hotelName={hotel.name}
/>
<ManageBooking />
<DownloadInvoice mainRef={mainRef} />
</div>
</header>
)
}

View File

@@ -1,20 +1,19 @@
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
"use client"
import { useIntl } from "react-intl"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { Toast } from "@/components/TempDesignSystem/Toasts"
import { getIntl } from "@/i18n"
import styles from "./hotelDetails.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import type { BookingConfirmationHotelDetailsProps } from "@/types/components/hotelReservation/bookingConfirmation/hotelDetails"
export default async function HotelDetails({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const { hotel } = await getBookingConfirmation(confirmationNumber)
export default function HotelDetails({
hotel,
}: BookingConfirmationHotelDetailsProps) {
const intl = useIntl()
return (
<div className={styles.container}>
<div className={styles.details}>

View File

@@ -1,23 +1,23 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import { CreditCardAddIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import useLang from "@/hooks/useLang"
import styles from "./paymentDetails.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import type { BookingConfirmationPaymentDetailsProps } from "@/types/components/hotelReservation/bookingConfirmation/paymentDetails"
export default async function PaymentDetails({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const lang = getLang()
const { booking } = await getBookingConfirmation(confirmationNumber)
export default function PaymentDetails({
booking,
}: BookingConfirmationPaymentDetailsProps) {
const intl = useIntl()
const lang = useLang()
return (
<div className={styles.details}>
<Subtitle color="uiTextHighContrast" type="two">

View File

@@ -1,11 +1,12 @@
import { getIntl } from "@/i18n"
"use client"
import { useIntl } from "react-intl"
import Promo from "./Promo"
import styles from "./promos.module.css"
export default async function Promos() {
const intl = await getIntl()
export default function Promos() {
const intl = useIntl()
return (
<div className={styles.promos}>
<Promo

View File

@@ -1,6 +1,6 @@
"use client"
import { notFound } from "next/navigation"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import { useIntl } from "react-intl"
import { ChevronRightSmallIcon, InfoCircleIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
@@ -9,21 +9,20 @@ 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 { getIntl } from "@/i18n"
import { getBookedHotelRoom } from "@/utils/getBookedHotelRoom"
import styles from "./receipt.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import { BookingConfirmationReceiptProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default async function Receipt({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
const roomAndBed = getBookedHotelRoom(hotel, booking.roomTypeCode ?? "")
if (!roomAndBed) {
export default function Receipt({
booking,
hotel,
room,
}: BookingConfirmationReceiptProps) {
const intl = useIntl()
if (!room) {
return notFound()
}
@@ -38,7 +37,7 @@ export default async function Receipt({
<Subtitle type="two">{intl.formatMessage({ id: "Summary" })}</Subtitle>
<article className={styles.room}>
<header className={styles.roomHeader}>
<Body color="uiTextHighContrast">{roomAndBed.name}</Body>
<Body color="uiTextHighContrast">{room.name}</Body>
{booking.rateDefinition.isMemberRate ? (
<div className={styles.memberPrice}>
<Body color="uiTextPlaceholder">
@@ -82,9 +81,7 @@ export default async function Receipt({
</Link>
</header>
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{roomAndBed.bedType.description}
</Body>
<Body color="uiTextHighContrast">{room.bedType.description}</Body>
<Body color="uiTextHighContrast">
{intl.formatNumber(0, {
currency: booking.currencyCode,

View File

@@ -1,3 +1,6 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import {
@@ -10,16 +13,15 @@ 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 { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import useLang from "@/hooks/useLang"
import styles from "./room.module.css"
import type { RoomProps } from "@/types/components/hotelReservation/bookingConfirmation/room"
import type { RoomProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms/room"
export default async function Room({ booking, img, roomName }: RoomProps) {
const intl = await getIntl()
const lang = getLang()
export default function Room({ booking, img, roomName }: RoomProps) {
const intl = useIntl()
const lang = useLang()
const fromDate = dt(booking.checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang)

View File

@@ -1,30 +1,23 @@
"use client"
import { notFound } from "next/navigation"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import { getBookedHotelRoom } from "@/utils/getBookedHotelRoom"
import Room from "./Room"
import styles from "./rooms.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import type { BookingConfirmationRoomsProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms"
export default async function Rooms({
confirmationNumber,
}: BookingConfirmationProps) {
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
const roomAndBed = getBookedHotelRoom(hotel, booking.roomTypeCode ?? "")
if (!roomAndBed) {
export default function Rooms({
booking,
room,
}: BookingConfirmationRoomsProps) {
if (!room) {
return notFound()
}
return (
<section className={styles.rooms}>
<Room
booking={booking}
img={roomAndBed.images[0]}
roomName={roomAndBed.name}
/>
<Room booking={booking} img={room.images[0]} roomName={room.name} />
</section>
)
}

View File

@@ -0,0 +1,42 @@
.main {
background-color: var(--Base-Surface-Primary-light-Normal);
display: grid;
gap: var(--Spacing-x5);
grid-template-areas: "header" "booking";
margin: 0 auto;
min-height: 100dvh;
padding-top: var(--Spacing-x5);
width: var(--max-width-page);
}
.booking {
display: flex;
flex-direction: column;
gap: var(--Spacing-x5);
grid-area: booking;
padding-bottom: var(--Spacing-x9);
}
.aside {
display: none;
}
@media screen and (min-width: 1367px) {
.main {
grid-template-areas:
"header receipt"
"booking receipt";
grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr;
padding-top: var(--Spacing-x9);
}
.mobileReceipt {
display: none;
}
.aside {
display: grid;
grid-area: receipt;
}
}

View File

@@ -0,0 +1,57 @@
"use client"
import { use, useRef } from "react"
import Header from "@/components/HotelReservation/BookingConfirmation/Header"
import HotelDetails from "@/components/HotelReservation/BookingConfirmation/HotelDetails"
import PaymentDetails from "@/components/HotelReservation/BookingConfirmation/PaymentDetails"
import Promos from "@/components/HotelReservation/BookingConfirmation/Promos"
import Receipt from "@/components/HotelReservation/BookingConfirmation/Receipt"
import Rooms from "@/components/HotelReservation/BookingConfirmation/Rooms"
import SidePanel from "@/components/HotelReservation/SidePanel"
import Divider from "@/components/TempDesignSystem/Divider"
import styles from "./confirmation.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default function BookingConfirmation({
bookingConfirmationPromise,
}: BookingConfirmationProps) {
const bookingConfirmation = use(bookingConfirmationPromise)
const mainRef = useRef<HTMLElement | null>(null)
return (
<main className={styles.main} ref={mainRef}>
<Header
booking={bookingConfirmation.booking}
hotel={bookingConfirmation.hotel}
mainRef={mainRef}
/>
<div className={styles.booking}>
<Rooms
booking={bookingConfirmation.booking}
room={bookingConfirmation.room}
/>
<PaymentDetails booking={bookingConfirmation.booking} />
<Divider color="primaryLightSubtle" />
<HotelDetails hotel={bookingConfirmation.hotel} />
<Promos />
<div className={styles.mobileReceipt}>
<Receipt
booking={bookingConfirmation.booking}
hotel={bookingConfirmation.hotel}
room={bookingConfirmation.room}
/>
</div>
</div>
<aside className={styles.aside}>
<SidePanel variant="receipt">
<Receipt
booking={bookingConfirmation.booking}
hotel={bookingConfirmation.hotel}
room={bookingConfirmation.room}
/>
</SidePanel>
</aside>
</main>
)
}

View File

@@ -4,6 +4,7 @@
grid-template-rows: auto;
gap: var(--Spacing-x2);
font-family: var(--typography-Body-Regular-fontFamily);
margin-bottom: var(--Spacing-x3);
}
.address,
@@ -20,6 +21,7 @@
list-style-type: none;
display: flex;
flex-direction: column;
min-width: 0;
}
.soMeIcons {
@@ -28,6 +30,19 @@
}
.ecoLabel {
width: 38px;
height: auto;
}
.ecoLabel img {
width: 100%;
height: auto;
flex-shrink: 0;
grid-column: 1 / 3;
grid-row: 4 / 4;
}
.ecoContainer {
display: flex;
align-items: center;
column-gap: var(--Spacing-x-one-and-half);
@@ -38,10 +53,6 @@
margin-bottom: var(--Spacing-x1);
}
.ecoLabel img {
flex-shrink: 0;
}
.ecoLabelText {
display: flex;
color: var(--UI-Text-Medium-contrast);
@@ -49,8 +60,8 @@
justify-content: center;
}
.googleMaps {
text-decoration: none;
.link {
text-decoration: underline;
font-family: var(--typography-Body-Regular-fontFamily);
color: var(--Base-Text-Medium-contrast);
color: var(--Base-Text-High-contrast);
}

View File

@@ -24,31 +24,27 @@ export default function Contact({ hotel }: ContactProps) {
<Body textTransform="bold">
{intl.formatMessage({ id: "Address" })}
</Body>
<Body>
{`${hotel.address.streetAddress}, ${hotel.address.city}`}
</Body>
<Body>{`${hotel.address.streetAddress}, `}</Body>
<Body>{hotel.address.city}</Body>
</li>
<li>
<Body textTransform="bold">
{intl.formatMessage({ id: "Driving directions" })}
</Body>
<a
<Link
href={`https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`}
className={styles.googleMaps}
target="_blank"
>
Google Maps
</a>
<span className={styles.link}>Google Maps</span>
</Link>
</li>
<li>
<Body textTransform="bold">
{intl.formatMessage({ id: "Contact us" })}
</Body>
<Link
href={`tel:${hotel.contactInformation.phoneNumber}`}
color="peach80"
>
{hotel.contactInformation.phoneNumber}
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
<span className={styles.link}>
{hotel.contactInformation.phoneNumber}
</span>
</Link>
</li>
<li>
@@ -76,23 +72,24 @@ export default function Contact({ hotel }: ContactProps) {
<Body textTransform="bold">
{intl.formatMessage({ id: "Email" })}
</Body>
<Link
href={`mailto:${hotel.contactInformation.email}`}
color="peach80"
>
{hotel.contactInformation.email}
<Link href={`mailto:${hotel.contactInformation.email}`}>
<span className={styles.link}>
{hotel.contactInformation.email}
</span>
</Link>
</li>
</ul>
</address>
{hotel.hotelFacts.ecoLabels?.nordicEcoLabel ? (
<div className={styles.ecoLabel}>
<Image
height={38}
width={43}
alt={intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
/>
<div className={styles.ecoContainer}>
<div className={styles.ecoLabel}>
<Image
height={38}
width={38}
alt={intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
/>
</div>
<div className={styles.ecoLabelText}>
<span>{intl.formatMessage({ id: "Nordic Swan Ecolabel" })}</span>
<span>

View File

@@ -207,6 +207,7 @@ export default function PaymentClient({
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
initiateBooking.mutate({
language: lang,
hotelId: hotel,
checkInDate: fromDate,
checkOutDate: toDate,

View File

@@ -227,11 +227,11 @@ export default function SummaryUI({
style: "currency",
})}
</Body>
{totalPrice.euro && (
{totalPrice.requested && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{intl.formatNumber(totalPrice.euro.price, {
currency: CurrencyEnum.EUR,
{intl.formatNumber(totalPrice.requested.price, {
currency: totalPrice.requested.currency,
style: "currency",
})}
</Caption>

View File

@@ -1,7 +1,7 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect, useMemo } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
@@ -27,7 +27,8 @@ export default function RoomFilter({
onFilter,
filterOptions,
}: RoomFilterProps) {
const isAboveMobile = useMediaQuery("(min-width: 768px)")
const isTabletAndUp = useMediaQuery("(min-width: 768px)")
const [isAboveMobile, setIsAboveMobile] = useState(false)
const initialFilterValues = useMemo(
() =>
@@ -71,6 +72,10 @@ export default function RoomFilter({
return () => subscription.unsubscribe()
}, [handleSubmit, watch, submitFilter])
useEffect(() => {
setIsAboveMobile(isTabletAndUp)
}, [isTabletAndUp])
return (
<div className={styles.container}>
<div className={styles.infoDesktop}>

View File

@@ -18,9 +18,7 @@ export default function FlexibilityOption({
name,
paymentTerm,
priceInformation,
roomType,
roomTypeCode,
features,
petRoomPackage,
handleSelectRate,
}: FlexibilityOptionProps) {
@@ -45,10 +43,22 @@ export default function FlexibilityOption({
const { public: publicPrice, member: memberPrice } = product.productType
function onChange() {
handleSelectRate({
publicRateCode: publicPrice.rateCode,
roomTypeCode: roomTypeCode,
const onClick: React.MouseEventHandler<HTMLInputElement> = (e) => {
handleSelectRate((prev) => {
if (
prev &&
prev.publicRateCode === publicPrice.rateCode &&
prev.roomTypeCode === roomTypeCode
) {
if (e.currentTarget?.checked) e.currentTarget.checked = false
return undefined
} else
return {
publicRateCode: publicPrice.rateCode,
roomTypeCode: roomTypeCode,
name: name,
paymentTerm: paymentTerm,
}
})
}
@@ -58,7 +68,7 @@ export default function FlexibilityOption({
type="radio"
name="rateCode"
value={publicPrice?.rateCode}
onChange={onChange}
onClick={onClick}
/>
<div className={styles.card}>
<div className={styles.header}>

View File

@@ -34,6 +34,7 @@ export default function RateSummary({
features,
roomType,
priceName,
priceTerm,
} = rateSummary
const priceToShow = isUserLoggedIn && member ? member : publicRate
@@ -80,87 +81,93 @@ export default function RateSummary({
</Footnote>
</div>
)}
<div className={styles.summaryText}>
<Subtitle color="uiTextHighContrast">{roomType}</Subtitle>
<Body color="uiTextMediumContrast">{priceName}</Body>
</div>
<div className={styles.summaryPriceContainer}>
{showMemberDiscountBanner && (
<div className={styles.memberDiscountBannerDesktop}>
<Footnote color="burgundy">
{intl.formatMessage<React.ReactNode>(
{
id: "To get the member price <span>{amount} {currency}</span>, log in or join when completing the booking.",
},
{
span: (str) => (
<Caption color="red" type="bold" asChild>
<span>{str}</span>
</Caption>
),
amount: member.localPrice.pricePerStay,
currency: member.localPrice.currency,
}
)}
</Footnote>
</div>
)}
<div className={styles.summaryPriceTextDesktop}>
<Body>
{intl.formatMessage<React.ReactNode>(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</Body>
<Caption color="uiTextMediumContrast">{summaryPriceTex}</Caption>
<div className={styles.content}>
<div className={styles.summaryText}>
<Subtitle color="uiTextHighContrast">{roomType}</Subtitle>
<Body color="uiTextMediumContrast">{`${priceName}, ${priceTerm}`}</Body>
</div>
<div className={styles.summaryPrice}>
<div className={styles.summaryPriceTextDesktop}>
<Subtitle
color={isUserLoggedIn ? "red" : "uiTextHighContrast"}
textAlign="right"
>
{priceToShow?.localPrice.pricePerStay}{" "}
{priceToShow?.localPrice.currency}
</Subtitle>
<Body color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{priceToShow?.requestedPrice?.pricePerStay}{" "}
{priceToShow?.requestedPrice?.currency}
</Body>
</div>
<div className={styles.summaryPriceTextMobile}>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Total price" })}
</Caption>
<Subtitle color={isUserLoggedIn ? "red" : "uiTextHighContrast"}>
{priceToShow?.localPrice.pricePerStay}{" "}
{priceToShow?.localPrice.currency}
</Subtitle>
<Footnote
color="uiTextMediumContrast"
className={styles.summaryPriceTextMobile}
>
{summaryPriceTex}
</Footnote>
</div>
{isPetRoomSelected && (
<div className={styles.petInfo}>
<Body
color="uiTextHighContrast"
textTransform="bold"
textAlign="right"
>
+ {petRoomPrice} {petRoomCurrency}
</Body>
<Body color="uiTextMediumContrast" textAlign="right">
{intl.formatMessage({ id: "Pet charge" })}
</Body>
<div className={styles.summaryPriceContainer}>
{showMemberDiscountBanner && (
<div className={styles.memberDiscountBannerDesktop}>
<Footnote color="burgundy">
{intl.formatMessage<React.ReactNode>(
{
id: "To get the member price <span>{amount} {currency}</span>, log in or join when completing the booking.",
},
{
span: (str) => (
<Caption color="red" type="bold" asChild>
<span>{str}</span>
</Caption>
),
amount: member.localPrice.pricePerStay,
currency: member.localPrice.currency,
}
)}
</Footnote>
</div>
)}
<Button type="submit" theme="base" className={styles.continueButton}>
{intl.formatMessage({ id: "Continue" })}
</Button>
<div className={styles.summaryPriceTextDesktop}>
<Body>
{intl.formatMessage<React.ReactNode>(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</Body>
<Caption color="uiTextMediumContrast">{summaryPriceTex}</Caption>
</div>
<div className={styles.summaryPrice}>
<div className={styles.summaryPriceTextDesktop}>
<Subtitle
color={isUserLoggedIn ? "red" : "uiTextHighContrast"}
textAlign="right"
>
{priceToShow?.localPrice.pricePerStay}{" "}
{priceToShow?.localPrice.currency}
</Subtitle>
<Body color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{priceToShow?.requestedPrice?.pricePerStay}{" "}
{priceToShow?.requestedPrice?.currency}
</Body>
</div>
<div className={styles.summaryPriceTextMobile}>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Total price" })}
</Caption>
<Subtitle color={isUserLoggedIn ? "red" : "uiTextHighContrast"}>
{priceToShow?.localPrice.pricePerStay}{" "}
{priceToShow?.localPrice.currency}
</Subtitle>
<Footnote
color="uiTextMediumContrast"
className={styles.summaryPriceTextMobile}
>
{summaryPriceTex}
</Footnote>
</div>
{isPetRoomSelected && (
<div className={styles.petInfo}>
<Body
color="uiTextHighContrast"
textTransform="bold"
textAlign="right"
>
+ {petRoomPrice} {petRoomCurrency}
</Body>
<Body color="uiTextMediumContrast" textAlign="right">
{intl.formatMessage({ id: "Pet charge" })}
</Body>
</div>
)}
<Button
type="submit"
theme="base"
className={styles.continueButton}
>
{intl.formatMessage({ id: "Continue" })}
</Button>
</div>
</div>
</div>
</div>

View File

@@ -6,12 +6,19 @@
right: 0;
background-color: var(--Base-Surface-Primary-light-Normal);
padding: 0 0 var(--Spacing-x5);
align-items: center;
border-top: 1px solid var(--Base-Border-Subtle);
transition: bottom 300ms ease-in-out;
}
.content {
width: 100%;
max-width: var(--max-width-navigation);
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--Base-Border-Subtle);
transition: bottom 300ms ease-in-out;
}
.summary[data-visible="true"] {
@@ -80,7 +87,9 @@
@media (min-width: 768px) {
.summary {
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x5);
padding: var(--Spacing-x3) var(--Spacing-x7) var(--Spacing-x5);
}
.content {
flex-direction: row;
}
.petInfo,
@@ -102,5 +111,6 @@
.summaryPriceContainer {
width: auto;
padding: 0;
align-items: center;
}
}

View File

@@ -117,14 +117,15 @@ export default function RoomCard({
<div>
<div className={styles.imageContainer}>
<div className={styles.chipContainer}>
{roomConfiguration.roomsLeft < 5 && (
<span className={styles.chip}>
<Footnote
color="burgundy"
textTransform="uppercase"
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
</span>
)}
{roomConfiguration.roomsLeft > 0 &&
roomConfiguration.roomsLeft < 5 && (
<span className={styles.chip}>
<Footnote
color="burgundy"
textTransform="uppercase"
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
</span>
)}
{roomConfiguration.features
.filter((feature) => selectedPackages.includes(feature.code))
.map((feature) => (
@@ -209,9 +210,7 @@ export default function RoomCard({
product={findProductForRate(rate)}
priceInformation={getRateDefinitionForRate(rate)?.generalTerms}
handleSelectRate={handleSelectRate}
roomType={roomConfiguration.roomType}
roomTypeCode={roomConfiguration.roomTypeCode}
features={roomConfiguration.features}
petRoomPackage={petRoomPackage}
/>
))}

View File

@@ -14,7 +14,10 @@ import {
type RoomPackageCodes,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
import type {
Rate,
RateCode,
} from "@/types/components/hotelReservation/selectRate/selectRate"
import type { RoomConfiguration } from "@/server/routers/hotels/output"
export default function Rooms({
@@ -25,9 +28,9 @@ export default function Rooms({
}: SelectRateProps) {
const visibleRooms: RoomConfiguration[] =
filterDuplicateRoomTypesByLowestPrice(roomsAvailability.roomConfigurations)
const [selectedRate, setSelectedRate] = useState<
{ publicRateCode: string; roomTypeCode: string } | undefined
>(undefined)
const [selectedRate, setSelectedRate] = useState<RateCode | undefined>(
undefined
)
const [selectedPackages, setSelectedPackages] = useState<RoomPackageCodes[]>(
[]
)
@@ -115,17 +118,30 @@ export default function Rooms({
)
)?.features
const roomType = roomCategories.find((roomCategory) =>
roomCategory.roomTypes.some(
(roomType) => roomType.code === room.roomTypeCode
)
)
const rateSummary: Rate = {
features: petRoomPackage && features ? features : [],
priceName: room.roomType,
priceName: selectedRate?.name,
priceTerm: selectedRate?.paymentTerm,
public: product.productType.public,
member: product.productType.member,
roomType: room.roomType,
roomType: roomType?.name || room.roomType,
roomTypeCode: room.roomTypeCode,
}
return rateSummary
}, [filteredRooms, availablePackages, selectedPackages, selectedRate])
}, [
filteredRooms,
availablePackages,
selectedPackages,
selectedRate,
roomCategories,
])
useEffect(() => {
if (rateSummary) return