Merge remote-tracking branch 'origin' into feature/tracking
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
57
components/HotelReservation/BookingConfirmation/index.tsx
Normal file
57
components/HotelReservation/BookingConfirmation/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user