Merged in feat/SW-1652-confirmation-page (pull request #1483)

Feat/SW-1652 confirmation page

* feat(SW-1652): handle linkedReservations fetching

* fix: add missing translations

* feat: add linkedReservation retry functionality

* chore: align naming


Approved-by: Simon.Emanuelsson
This commit is contained in:
Arvid Norlin
2025-03-07 12:47:04 +00:00
parent 7fa86a2077
commit ec60e9abdd
34 changed files with 474 additions and 303 deletions

View File

@@ -19,10 +19,6 @@
gap: var(--Spacing-x-half); gap: var(--Spacing-x-half);
} }
.coordinates {
margin-top: var(--Spacing-x-half);
}
.list { .list {
padding-left: var(--Spacing-x2); padding-left: var(--Spacing-x2);
} }

View File

@@ -41,15 +41,6 @@ export default function HotelDetails({
</Link> </Link>
</Body> </Body>
</div> </div>
<Body color="uiTextPlaceholder" className={styles.coordinates}>
{intl.formatMessage(
{ id: "Long {long} ∙ Lat {lat}" },
{
lat: hotel.location.latitude,
long: hotel.location.longitude,
}
)}
</Body>
</div> </div>
<div className={styles.contact}> <div className={styles.contact}>
<Link <Link

View File

@@ -1,61 +1,49 @@
import { dt } from "@/lib/dt" "use client"
import { serverClient } from "@/lib/trpc/server"
import { useIntl } from "react-intl"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import { CreditCardAddIcon } from "@/components/Icons" import { CreditCardAddIcon } from "@/components/Icons"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { formatPrice } from "@/utils/numberFormatting" import { formatPrice } from "@/utils/numberFormatting"
import styles from "./paymentDetails.module.css" import styles from "./paymentDetails.module.css"
import type { BookingConfirmationPaymentDetailsProps } from "@/types/components/hotelReservation/bookingConfirmation/paymentDetails" export default function PaymentDetails() {
const intl = useIntl()
export default async function PaymentDetails({ const rooms = useBookingConfirmationStore((state) => state.rooms)
booking, const currencyCode = useBookingConfirmationStore(
}: BookingConfirmationPaymentDetailsProps) { (state) => state.currencyCode
const intl = await getIntl()
const lang = getLang()
const linkedReservations = await Promise.all(
// TODO: How to handle partial failure (e.g. one booking can't be fetched)? Need UX/UI
booking.linkedReservations.map(async (res) => {
const confirmation = await serverClient().booking.confirmation({
confirmationNumber: res.confirmationNumber,
})
return confirmation
})
) )
const grandTotal = linkedReservations.reduce((acc, res) => { const hasAllRoomsLoaded = rooms.every((room) => room)
return res ? acc + res.booking.totalPrice : acc const grandTotal = rooms.reduce((acc, room) => {
}, booking.totalPrice) const reservationTotalPrice = room?.totalPrice || 0
return acc + reservationTotalPrice
}, 0)
return ( return (
<div className={styles.details}> <div className={styles.details}>
<Subtitle color="uiTextHighContrast" type="two"> <Subtitle color="uiTextHighContrast" type="two">
{intl.formatMessage({ id: "Payment details" })} {intl.formatMessage({ id: "Payment details" })}
</Subtitle> </Subtitle>
<div className={styles.payment}> <div className={styles.payment}>
<Body color="uiTextHighContrast"> {hasAllRoomsLoaded ? (
{intl.formatMessage( <Body color="uiTextHighContrast">
{ id: "{amount} has been paid" }, {intl.formatMessage(
{ { id: "{amount} has been paid" },
amount: formatPrice(intl, grandTotal, booking.currencyCode), {
} amount: formatPrice(intl, grandTotal, currencyCode),
)} }
</Body> )}
<Body color="uiTextHighContrast"> </Body>
{dt(booking.createDateTime) ) : (
.locale(lang) <SkeletonShimmer width={"100%"} />
.format("ddd D MMM YYYY, hh:mm")} )}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{card} ending with {cardno}" },
{ card: "N/A", cardno: "N/A" }
)}
</Body>
</div> </div>
<Button <Button
className={styles.btn} className={styles.btn}

View File

@@ -6,6 +6,7 @@
.details { .details {
gap: var(--Spacing-x-one-and-half); gap: var(--Spacing-x-one-and-half);
max-width: fit-content;
} }
.payment { .payment {

View File

@@ -0,0 +1,17 @@
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./roomSkeletonLoader.module.css"
export default function RoomSkeletonLoader() {
return (
<div className={styles.room}>
<SkeletonShimmer />
<SkeletonShimmer width={"15%"} />
<SkeletonShimmer width={"30%"} />
<SkeletonShimmer width={"40%"} />
<SkeletonShimmer />
<SkeletonShimmer />
<SkeletonShimmer />
</div>
)
}

View File

@@ -2,6 +2,9 @@
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { CancellationRuleEnum } from "@/constants/booking"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import { CheckIcon, InfoCircleIcon } from "@/components/Icons" import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
import Modal from "@/components/Modal" import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
@@ -10,58 +13,49 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import { formatPrice } from "@/utils/numberFormatting" import { formatPrice } from "@/utils/numberFormatting"
import RoomSkeletonLoader from "./RoomSkeletonLoader"
import styles from "./room.module.css" import styles from "./room.module.css"
import type { BookingConfirmationReceiptRoomProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt" import type { BookingConfirmationReceiptRoomProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function ReceiptRoom({ export default function ReceiptRoom({
booking, roomIndex,
room,
roomNumber,
}: BookingConfirmationReceiptRoomProps) { }: BookingConfirmationReceiptRoomProps) {
const intl = useIntl() const intl = useIntl()
const room = useBookingConfirmationStore((state) => state.rooms[roomIndex])
const breakfastPkgSelected = booking.packages.find( const currencyCode = useBookingConfirmationStore(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST (state) => state.currencyCode
)
const breakfastPkgIncluded = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
) )
if (!room) {
return <RoomSkeletonLoader />
}
return ( return (
<article className={styles.room}> <article className={styles.room}>
{roomNumber !== null ? (
<Body color="uiTextHighContrast" textTransform={"bold"}>
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{ roomIndex: roomNumber }
)}
</Body>
) : null}
<header className={styles.roomHeader}> <header className={styles.roomHeader}>
<Body color="uiTextHighContrast">{room.name}</Body> <Body color="uiTextHighContrast">{room.name}</Body>
{booking.rateDefinition.isMemberRate ? ( {room.rateDefinition.isMemberRate ? (
<div className={styles.memberPrice}> <div className={styles.memberPrice}>
<Body color="red"> <Body color="red">
{formatPrice(intl, booking.roomPrice, booking.currencyCode)} {formatPrice(intl, room.roomPrice, currencyCode)}
</Body> </Body>
</div> </div>
) : ( ) : (
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">
{formatPrice(intl, booking.roomPrice, booking.currencyCode)} {formatPrice(intl, room.roomPrice, currencyCode)}
</Body> </Body>
)} )}
<Caption color="uiTextMediumContrast"> <Caption color="uiTextMediumContrast">
{intl.formatMessage( {intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" }, { id: "{totalAdults, plural, one {# adult} other {# adults}}" },
{ {
totalAdults: booking.adults, totalAdults: room.adults,
} }
)} )}
</Caption> </Caption>
<Caption color="uiTextMediumContrast"> <Caption color="uiTextMediumContrast">
{booking.rateDefinition.cancellationText} {room.rateDefinition.cancellationText}
</Caption> </Caption>
<Modal <Modal
trigger={ trigger={
@@ -78,15 +72,16 @@ export default function ReceiptRoom({
</Link> </Link>
</Button> </Button>
} }
title={booking.rateDefinition.cancellationText || ""} title={room.rateDefinition.cancellationText || ""}
subtitle={ subtitle={
booking.rateDefinition.cancellationRule == "CancellableBefore6PM" room.rateDefinition.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM
? intl.formatMessage({ id: "Pay later" }) ? intl.formatMessage({ id: "Pay later" })
: intl.formatMessage({ id: "Pay now" }) : intl.formatMessage({ id: "Pay now" })
} }
> >
<div className={styles.terms}> <div className={styles.terms}>
{booking.rateDefinition.generalTerms?.map((info) => ( {room.rateDefinition.generalTerms?.map((info) => (
<Body <Body
key={info} key={info}
color="uiTextHighContrast" color="uiTextHighContrast"
@@ -105,22 +100,22 @@ export default function ReceiptRoom({
</Modal> </Modal>
</header> </header>
<div className={styles.entry}> <div className={styles.entry}>
<Body color="uiTextHighContrast">{room.bedType.description}</Body> <Body color="uiTextHighContrast">{room.bedDescription}</Body>
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">
{formatPrice(intl, 0, booking.currencyCode)} {formatPrice(intl, 0, currencyCode)}
</Body> </Body>
</div> </div>
<div className={styles.entry}> <div className={styles.entry}>
<Body>{intl.formatMessage({ id: "Breakfast buffet" })}</Body> <Body>{intl.formatMessage({ id: "Breakfast buffet" })}</Body>
{(booking.rateDefinition.breakfastIncluded ?? breakfastPkgIncluded) ? ( {(room.rateDefinition.breakfastIncluded ?? room.breakfastIncluded) ? (
<Body color="red">{intl.formatMessage({ id: "Included" })}</Body> <Body color="red">{intl.formatMessage({ id: "Included" })}</Body>
) : null} ) : null}
{breakfastPkgSelected ? ( {room.selectedBreakfast ? (
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">
{formatPrice( {formatPrice(
intl, intl,
breakfastPkgSelected.totalPrice, room.selectedBreakfast.totalPrice,
breakfastPkgSelected.currency room.selectedBreakfast.currency
)} )}
</Body> </Body>
) : null} ) : null}

View File

@@ -0,0 +1,5 @@
.room {
display: flex;
gap: var(--Spacing-x1);
flex-direction: column;
}

View File

@@ -1,88 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { ChevronRightSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { formatPrice } from "@/utils/numberFormatting"
import Room from "../Room"
import styles from "./rooms.module.css"
import type { BookingConfirmationReceiptRoomsProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt"
export default function ReceiptRooms({
booking,
room,
linkedReservations,
}: BookingConfirmationReceiptRoomsProps) {
const intl = useIntl()
if (linkedReservations.some((reservation) => !reservation)) {
return null
}
const grandTotal = linkedReservations.reduce((acc, reservation) => {
const reservationTotalPrice = reservation?.booking.totalPrice || 0
return acc + reservationTotalPrice
}, booking.totalPrice)
return (
<>
<Room
booking={booking}
room={room}
roomNumber={linkedReservations.length ? 1 : null}
/>
{linkedReservations.map((reservation, idx) => {
if (!reservation?.room) {
return null
}
return (
<Room
key={reservation?.booking.confirmationNumber}
booking={reservation.booking}
room={reservation.room}
roomNumber={idx + 2}
/>
)
})}
<Divider color="primaryLightSubtle" />
<div className={styles.price}>
<div className={styles.entry}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Total price" })}
</Body>
<Body textTransform="bold">
{formatPrice(intl, grandTotal, booking.currencyCode)}
</Body>
</div>
<div className={styles.entry}>
<Button
className={styles.btn}
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
{intl.formatMessage({ id: "Price details" })}
<ChevronRightSmallIcon />
</Button>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "Approx. {value}" },
{
value: "N/A",
}
)}
</Caption>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,66 @@
"use client"
import { useIntl } from "react-intl"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import { ChevronRightSmallIcon } from "@/components/Icons"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./totalPrice.module.css"
export default function TotalPrice() {
const intl = useIntl()
const rooms = useBookingConfirmationStore((state) => state.rooms)
const currencyCode = useBookingConfirmationStore(
(state) => state.currencyCode
)
const hasAllRoomsLoaded = rooms.every((room) => room)
const grandTotal = rooms.reduce((acc, room) => {
const reservationTotalPrice = room?.totalPrice || 0
return acc + reservationTotalPrice
}, 0)
return (
<>
<Divider color="primaryLightSubtle" />
<div className={styles.price}>
<div className={styles.entry}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Total price" })}
</Body>
{hasAllRoomsLoaded ? (
<Body textTransform="bold">
{formatPrice(intl, grandTotal, currencyCode)}
</Body>
) : (
<SkeletonShimmer width={"25%"} />
)}
</div>
{hasAllRoomsLoaded ? (
<div className={styles.entry}>
<Button
className={styles.btn}
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
{intl.formatMessage({ id: "Price details" })}
<ChevronRightSmallIcon />
</Button>
</div>
) : (
<div className={styles.priceDetailsLoader}>
<SkeletonShimmer width={"100%"} />
</div>
)}
</div>
</>
)
}

View File

@@ -6,3 +6,7 @@
.price button.btn { .price button.btn {
padding: 0; padding: 0;
} }
.priceDetailsLoader {
padding-top: var(--Spacing-x1);
}

View File

@@ -1,46 +1,42 @@
import { notFound } from "next/navigation" "use client"
import { serverClient } from "@/lib/trpc/server" import { useIntl } from "react-intl"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import ReceiptRooms from "./Rooms" import Room from "./Room"
import TotalPrice from "./TotalPrice"
import styles from "./receipt.module.css" import styles from "./receipt.module.css"
import type { BookingConfirmationReceiptProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt" export default function Receipt() {
const intl = useIntl()
export default async function Receipt({ const rooms = useBookingConfirmationStore((state) => state.rooms)
booking,
room,
}: BookingConfirmationReceiptProps) {
if (!room) {
return notFound()
}
const intl = await getIntl()
const linkedReservations = await Promise.all(
// TODO: How to handle partial failure (e.g. one booking can't be fetched)? Need UX/UI
booking.linkedReservations.map(async (res) => {
const confirmation = await serverClient().booking.confirmation({
confirmationNumber: res.confirmationNumber,
})
return confirmation
})
)
return ( return (
<section className={styles.receipt}> <section className={styles.receipt}>
<Subtitle type="two"> <Subtitle type="two">
{intl.formatMessage({ id: "Booking summary" })} {intl.formatMessage({ id: "Booking summary" })}
</Subtitle> </Subtitle>
<ReceiptRooms
booking={booking} {rooms.map((room, idx) => (
room={room} <div key={room ? room.confirmationNumber : `loader-${idx}`}>
linkedReservations={linkedReservations} {rooms.length > 1 ? (
/> <Body color="uiTextHighContrast" textTransform={"bold"}>
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{ roomIndex: idx + 1 }
)}
</Body>
) : null}
<Room roomIndex={idx} />
</div>
))}
<TotalPrice />
</section> </section>
) )
} }

View File

@@ -0,0 +1,23 @@
"use client"
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./retry.module.css"
import type { RetryProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms/linkedReservation"
export default function Retry({ handleRefetch }: RetryProps) {
const intl = useIntl()
return (
<div className={styles.retry}>
<Body>{intl.formatMessage({ id: "Something went wrong!" })}</Body>
<Button size={"small"} onPress={handleRefetch}>
{intl.formatMessage({ id: "Try again" })}
</Button>
</div>
)
}

View File

@@ -1,22 +1,50 @@
import { serverClient } from "@/lib/trpc/server" "use client"
import { useEffect } from "react"
import { trpc } from "@/lib/trpc/client"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import useLang from "@/hooks/useLang"
import { mapRoomState } from "../../utils"
import Room from "../Room" import Room from "../Room"
import { LinkedReservationCardSkeleton } from "./LinkedReservationCardSkeleton"
import Retry from "./Retry"
export async function LinkedReservation({ import type { LinkedReservationProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms/linkedReservation"
export function LinkedReservation({
confirmationNumber, confirmationNumber,
}: { roomIndex,
confirmationNumber: string }: LinkedReservationProps) {
}) { const lang = useLang()
const confirmation = await serverClient().booking.confirmation({ const { data, refetch, isLoading } = trpc.booking.confirmation.useQuery({
confirmationNumber, confirmationNumber,
lang,
}) })
const setRoom = useBookingConfirmationStore((state) => state.actions.setRoom)
const room = confirmation?.room useEffect(() => {
const booking = confirmation?.booking if (data?.room) {
const roomData = mapRoomState(data.booking, data.room)
setRoom(roomData, roomIndex)
}
}, [data, roomIndex, setRoom])
if (!booking || !room) { if (isLoading) {
return <div>Something went wrong, try again</div> return <LinkedReservationCardSkeleton />
} }
return <Room booking={booking} img={room.images[0]} roomName={room.name} /> if (!data?.room) {
return <Retry handleRefetch={refetch} />
}
return (
<Room
img={data.room.images[0]}
booking={data.booking}
roomName={data.room.name}
/>
)
} }

View File

@@ -0,0 +1,10 @@
.retry {
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Large);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x3)
var(--Spacing-x2);
align-items: center;
}

View File

@@ -2,6 +2,7 @@
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { CancellationRuleEnum } from "@/constants/booking"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import { import {
@@ -27,6 +28,12 @@ export default function Room({ booking, img, roomName }: RoomProps) {
const guestName = `${booking.guest.firstName} ${booking.guest.lastName}` const guestName = `${booking.guest.firstName} ${booking.guest.lastName}`
const fromDate = dt(booking.checkInDate).locale(lang) const fromDate = dt(booking.checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang) const toDate = dt(booking.checkOutDate).locale(lang)
const isFlexBooking =
booking.rateDefinition.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM
const isChangeBooking =
booking.rateDefinition.cancellationRule === CancellationRuleEnum.Changeable
return ( return (
<article className={styles.room}> <article className={styles.room}>
<header className={styles.header}> <header className={styles.header}>
@@ -98,14 +105,6 @@ export default function Room({ booking, img, roomName }: RoomProps) {
)} )}
</Body> </Body>
</li> </li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Breakfast" })}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "N/A" })}
</Body>
</li>
<li className={styles.listItem}> <li className={styles.listItem}>
<Body color="uiTextPlaceholder"> <Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Cancellation policy" })} {intl.formatMessage({ id: "Cancellation policy" })}
@@ -114,14 +113,19 @@ export default function Room({ booking, img, roomName }: RoomProps) {
{booking.rateDefinition.cancellationText} {booking.rateDefinition.cancellationText}
</Body> </Body>
</li> </li>
<li className={styles.listItem}> {isFlexBooking || isChangeBooking ? (
<Body color="uiTextPlaceholder"> <li className={styles.listItem}>
{intl.formatMessage({ id: "Rebooking" })} <Body color="uiTextPlaceholder">
</Body> {intl.formatMessage({ id: "Rebooking" })}
<Body color="uiTextHighContrast"> </Body>
{intl.formatMessage({ id: "N/A" })} <Body color="uiTextHighContrast">
</Body> {intl.formatMessage(
</li> { id: "Until {time}, {date}" },
{ time: "18:00", date: fromDate.format("dddd D MMM") }
)}
</Body>
</li>
) : null}
</ul> </ul>
<div className={styles.guest}> <div className={styles.guest}>
<Body color="uiTextPlaceholder"> <Body color="uiTextPlaceholder">

View File

@@ -1,9 +1,6 @@
import { Suspense } from "react"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { LinkedReservationCardSkeleton } from "./LinkedReservation/LinkedReservationCardSkeleton"
import { LinkedReservation } from "./LinkedReservation" import { LinkedReservation } from "./LinkedReservation"
import Room from "./Room" import Room from "./Room"
@@ -17,6 +14,7 @@ export default async function Rooms({
linkedReservations, linkedReservations,
}: BookingConfirmationRoomsProps) { }: BookingConfirmationRoomsProps) {
const intl = await getIntl() const intl = await getIntl()
return ( return (
<section className={styles.rooms}> <section className={styles.rooms}>
<div className={styles.room}> <div className={styles.room}>
@@ -40,11 +38,10 @@ export default async function Rooms({
{ roomIndex: idx + 2 } { roomIndex: idx + 2 }
)} )}
</Subtitle> </Subtitle>
<Suspense fallback={<LinkedReservationCardSkeleton />}> <LinkedReservation
<LinkedReservation confirmationNumber={reservation.confirmationNumber}
confirmationNumber={reservation.confirmationNumber} roomIndex={idx + 1}
/> />
</Suspense>
</div> </div>
))} ))}
</section> </section>

View File

@@ -1,9 +1,7 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns" import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { Suspense } from "react"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import HotelDetails from "@/components/HotelReservation/BookingConfirmation/HotelDetails" import HotelDetails from "@/components/HotelReservation/BookingConfirmation/HotelDetails"
import PaymentDetails from "@/components/HotelReservation/BookingConfirmation/PaymentDetails" import PaymentDetails from "@/components/HotelReservation/BookingConfirmation/PaymentDetails"
@@ -14,10 +12,12 @@ import SidePanel from "@/components/HotelReservation/SidePanel"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
import TrackingSDK from "@/components/TrackingSDK" import TrackingSDK from "@/components/TrackingSDK"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
import BookingConfirmationProvider from "@/providers/BookingConfirmationProvider"
import { invertedBedTypeMap } from "../utils" import { invertedBedTypeMap } from "../utils"
import Alerts from "./Alerts" import Alerts from "./Alerts"
import Confirmation from "./Confirmation" import Confirmation from "./Confirmation"
import { mapRoomState } from "./utils"
import styles from "./bookingConfirmation.module.css" import styles from "./bookingConfirmation.module.css"
@@ -47,16 +47,16 @@ export default async function BookingConfirmation({
const arrivalDate = new Date(booking.checkInDate) const arrivalDate = new Date(booking.checkInDate)
const departureDate = new Date(booking.checkOutDate) const departureDate = new Date(booking.checkOutDate)
const breakfastPkgSelected = booking.packages.find( const selectedBreakfast = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST (pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
) )
const breakfastAncillary = breakfastPkgSelected && { const breakfastAncillary = selectedBreakfast && {
hotelid: hotel.operaId, hotelid: hotel.operaId,
productName: "BreakfastAdult", productName: "BreakfastAdult",
productCategory: "", // TODO: Add category productCategory: "", // TODO: Add category
productId: breakfastPkgSelected.code ?? "", productId: selectedBreakfast.code ?? "",
productPrice: +breakfastPkgSelected.unitPrice, productPrice: +selectedBreakfast.unitPrice,
productUnits: booking.adults, productUnits: booking.adults,
productPoints: 0, productPoints: 0,
productType: "food", productType: "food",
@@ -112,18 +112,15 @@ export default async function BookingConfirmation({
paymentStatus: "confirmed", paymentStatus: "confirmed",
} }
const linkedReservations = await Promise.all(
// TODO: How to handle partial failure (e.g. one booking can't be fetched)? Need UX/UI
booking.linkedReservations.map(async (res) => {
const confirmation = await serverClient().booking.confirmation({
confirmationNumber: res.confirmationNumber,
})
return confirmation
})
)
return ( return (
<> <BookingConfirmationProvider
currencyCode={booking.currencyCode}
rooms={[
mapRoomState(booking, room),
// null represents "known but not yet fetched rooms" and is used to render placeholders correctly
...Array(booking.linkedReservations.length).fill(null),
]}
>
<Confirmation booking={booking} hotel={hotel} room={room}> <Confirmation booking={booking} hotel={hotel} room={room}>
<div className={styles.booking}> <div className={styles.booking}>
<Alerts booking={booking} /> <Alerts booking={booking} />
@@ -132,12 +129,7 @@ export default async function BookingConfirmation({
mainRoom={room} mainRoom={room}
linkedReservations={booking.linkedReservations} linkedReservations={booking.linkedReservations}
/> />
<Suspense fallback={null}> <PaymentDetails />
<PaymentDetails
booking={booking}
linkedReservations={linkedReservations}
/>
</Suspense>
<Divider color="primaryLightSubtle" /> <Divider color="primaryLightSubtle" />
<HotelDetails hotel={hotel} /> <HotelDetails hotel={hotel} />
<Promos <Promos
@@ -146,16 +138,12 @@ export default async function BookingConfirmation({
lastName={booking.guest.lastName} lastName={booking.guest.lastName}
/> />
<div className={styles.mobileReceipt}> <div className={styles.mobileReceipt}>
<Suspense fallback={null}> <Receipt />
<Receipt booking={booking} hotel={hotel} room={room} />
</Suspense>
</div> </div>
</div> </div>
<aside className={styles.aside}> <aside className={styles.aside}>
<SidePanel variant="receipt"> <SidePanel variant="receipt">
<Suspense fallback={null}> <Receipt />
<Receipt booking={booking} hotel={hotel} room={room} />
</Suspense>
</SidePanel> </SidePanel>
</aside> </aside>
</Confirmation> </Confirmation>
@@ -164,6 +152,6 @@ export default async function BookingConfirmation({
hotelInfo={initialHotelsTrackingData} hotelInfo={initialHotelsTrackingData}
paymentInfo={paymentInfo} paymentInfo={paymentInfo}
/> />
</> </BookingConfirmationProvider>
) )
} }

View File

@@ -0,0 +1,29 @@
import type { BookingConfirmationRoom } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import type { BookingConfirmationSchema } from "@/types/trpc/routers/booking/confirmation"
export function mapRoomState(
booking: BookingConfirmationSchema,
room: BookingConfirmationRoom
) {
const selectedBreakfast = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
const breakfastIncluded = booking.packages.some(
(pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
)
return {
adults: booking.adults,
bedDescription: room.bedType.description,
breakfastIncluded,
children: booking.childrenAges.length,
confirmationNumber: booking.confirmationNumber,
fromDate: booking.checkInDate,
name: room.name,
rateDefinition: booking.rateDefinition,
roomPrice: booking.roomPrice,
selectedBreakfast,
toDate: booking.checkOutDate,
totalPrice: booking.totalPrice,
}
}

View File

@@ -151,3 +151,9 @@ export const BED_TYPE_ICONS: Record<
PullOutBed: ExtraPullOutBedIcon, PullOutBed: ExtraPullOutBedIcon,
Other: SingleBedIcon, Other: SingleBedIcon,
} }
export enum CancellationRuleEnum {
CancellableBefore6PM = "CancellableBefore6PM",
NonCancellable = "NonCancellable",
Changeable = "Changeable",
}

View File

@@ -0,0 +1,6 @@
import { createContext } from "react"
import type { BookingConfirmationStore } from "@/types/contexts/booking-confirmation"
export const BookingConfirmationContext =
createContext<BookingConfirmationStore | null>(null)

View File

@@ -647,6 +647,7 @@
"Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please <emailLink>contact us.</emailLink>": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please <emailLink>contact us.</emailLink>", "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please <emailLink>contact us.</emailLink>": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please <emailLink>contact us.</emailLink>",
"The code youve entered have expired. <resendOtpLink>Resend code.</resendOtpLink>": "The code youve entered have expired. <resendOtpLink>Resend code.</resendOtpLink>", "The code youve entered have expired. <resendOtpLink>Resend code.</resendOtpLink>": "The code youve entered have expired. <resendOtpLink>Resend code.</resendOtpLink>",
"The code youve entered is incorrect.": "The code youve entered is incorrect.", "The code youve entered is incorrect.": "The code youve entered is incorrect.",
"The first or last name doesn't match the membership number you provided. Your booking(s) is confirmed but to get the membership attached you'll need to present your existing membership number upon check-in. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay, or we can assist upon arrival.": "The first or last name doesn't match the membership number you provided. Your booking(s) is confirmed but to get the membership attached you'll need to present your existing membership number upon check-in. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay, or we can assist upon arrival.",
"The new price is": "The new price is", "The new price is": "The new price is",
"The price has increased": "The price has increased", "The price has increased": "The price has increased",
"The price has increased since you selected your room.": "The price has increased since you selected your room.", "The price has increased since you selected your room.": "The price has increased since you selected your room.",
@@ -671,6 +672,7 @@
"Transactions": "Transactions", "Transactions": "Transactions",
"Transportations": "Transportations", "Transportations": "Transportations",
"TripAdvisor rating": "TripAdvisor rating", "TripAdvisor rating": "TripAdvisor rating",
"Try again": "Try again",
"Tuesday": "Tuesday", "Tuesday": "Tuesday",
"Type of bed": "Type of bed", "Type of bed": "Type of bed",
"Type of room": "Type of room", "Type of room": "Type of room",

View File

@@ -0,0 +1,29 @@
"use client"
import { useRef } from "react"
import { createBookingConfirmationStore } from "@/stores/booking-confirmation"
import { BookingConfirmationContext } from "@/contexts/BookingConfirmation"
import type { BookingConfirmationStore } from "@/types/contexts/booking-confirmation"
import type { BookingConfirmationProviderProps } from "@/types/providers/booking-confirmation"
export default function BookingConfirmationProvider({
children,
currencyCode,
rooms,
}: BookingConfirmationProviderProps) {
const storeRef = useRef<BookingConfirmationStore>()
if (!storeRef.current) {
const initialData = { rooms, currencyCode }
storeRef.current = createBookingConfirmationStore(initialData)
}
return (
<BookingConfirmationContext.Provider value={storeRef.current}>
{children}
</BookingConfirmationContext.Provider>
)
}

View File

@@ -123,6 +123,7 @@ export const createRefIdInput = z.object({
// Query // Query
const confirmationNumberInput = z.object({ const confirmationNumberInput = z.object({
confirmationNumber: z.string(), confirmationNumber: z.string(),
lang: z.nativeEnum(Lang).optional(),
}) })
export const bookingConfirmationInput = confirmationNumberInput export const bookingConfirmationInput = confirmationNumberInput

View File

@@ -79,7 +79,7 @@ const guestSchema = z.object({
phoneNumber: phoneValidator().nullable().default(""), phoneNumber: phoneValidator().nullable().default(""),
}) })
const packageSchema = z export const packageSchema = z
.object({ .object({
type: z.string().nullable(), type: z.string().nullable(),
description: z.string().nullable().default(""), description: z.string().nullable().default(""),
@@ -198,7 +198,7 @@ export const bookingConfirmationSchema = z
canChangeDate: z.boolean(), canChangeDate: z.boolean(),
bookingCode: z.string().nullable(), bookingCode: z.string().nullable(),
computedReservationStatus: z.string().nullable().default(""), computedReservationStatus: z.string().nullable().default(""),
confirmationNumber: z.string().nullable().default(""), confirmationNumber: nullableStringValidator,
createDateTime: z.date({ coerce: true }), createDateTime: z.date({ coerce: true }),
currencyCode: z.string(), currencyCode: z.string(),
guest: guestSchema, guest: guestSchema,

View File

@@ -41,9 +41,14 @@ const getBookingStatusFailCounter = meter.createCounter(
export const bookingQueryRouter = router({ export const bookingQueryRouter = router({
confirmation: safeProtectedServiceProcedure confirmation: safeProtectedServiceProcedure
.input(bookingConfirmationInput) .input(bookingConfirmationInput)
.query(async function ({ ctx, input: { confirmationNumber } }) { .query(async function ({
ctx,
input: { confirmationNumber, lang: inputLang },
}) {
getBookingConfirmationCounter.add(1, { confirmationNumber }) getBookingConfirmationCounter.add(1, { confirmationNumber })
let lang = ctx.lang ?? inputLang
const token = ctx.session?.token.access_token ?? ctx.serviceToken const token = ctx.session?.token.access_token ?? ctx.serviceToken
const apiResponse = await api.get( const apiResponse = await api.get(
@@ -105,7 +110,7 @@ export const bookingQueryRouter = router({
{ {
hotelId: booking.data.hotelId, hotelId: booking.data.hotelId,
isCardOnlyPayment: false, isCardOnlyPayment: false,
language: ctx.lang, language: lang,
}, },
ctx.serviceToken ctx.serviceToken
) )

View File

@@ -0,0 +1,40 @@
import { useContext } from "react"
import { create, useStore } from "zustand"
import { BookingConfirmationContext } from "@/contexts/BookingConfirmation"
import type {
BookingConfirmationState,
InitialState,
} from "@/types/stores/booking-confirmation"
export function createBookingConfirmationStore(initialState: InitialState) {
return create<BookingConfirmationState>()((set) => ({
rooms: initialState.rooms,
currencyCode: initialState.currencyCode,
actions: {
setRoom: (room, idx) => {
set((state) => {
const rooms = [...state.rooms]
rooms[idx] = room
return { rooms }
})
},
},
}))
}
export function useBookingConfirmationStore<T>(
selector: (store: BookingConfirmationState) => T
) {
const store = useContext(BookingConfirmationContext)
if (!store) {
throw new Error(
"useBookingConfirmationStore must be used within BookingConfirmationProvider"
)
}
return useStore(store, selector)
}

View File

@@ -8,10 +8,12 @@ export interface BookingConfirmationProps {
confirmationNumber: string confirmationNumber: string
} }
export interface BookingConfirmationRoom extends Room {
bedType: Room["roomTypes"][number]
}
export interface ConfirmationProps extends BookingConfirmation { export interface ConfirmationProps extends BookingConfirmation {
room: Room & { room: BookingConfirmationRoom
bedType: Room["roomTypes"][number]
}
} }
export interface BookingConfirmationAlertsProps { export interface BookingConfirmationAlertsProps {

View File

@@ -1,6 +0,0 @@
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export interface BookingConfirmationPaymentDetailsProps
extends Pick<BookingConfirmation, "booking"> {
linkedReservations: (BookingConfirmation | null)[]
}

View File

@@ -1,22 +1,3 @@
import type { Room } from "@/types/hotel"
import type {
BookingConfirmation,
BookingConfirmationSchema,
} from "@/types/trpc/routers/booking/confirmation"
export interface BookingConfirmationReceiptProps extends BookingConfirmation {}
interface ReceiptRoom extends Room {
bedType: Room["roomTypes"][number]
}
export interface BookingConfirmationReceiptRoomsProps {
booking: BookingConfirmationSchema
room: ReceiptRoom
linkedReservations: (BookingConfirmation | null)[]
}
export interface BookingConfirmationReceiptRoomProps { export interface BookingConfirmationReceiptRoomProps {
booking: BookingConfirmationSchema roomIndex: number
room: ReceiptRoom
roomNumber: number | null
} }

View File

@@ -0,0 +1,8 @@
export interface LinkedReservationProps {
confirmationNumber: string
roomIndex: number
}
export interface RetryProps {
handleRefetch: () => void
}

View File

@@ -0,0 +1,5 @@
import type { createBookingConfirmationStore } from "@/stores/booking-confirmation"
export type BookingConfirmationStore = ReturnType<
typeof createBookingConfirmationStore
>

View File

@@ -0,0 +1,7 @@
import type { Room } from "../stores/booking-confirmation"
export interface BookingConfirmationProviderProps
extends React.PropsWithChildren {
currencyCode: string
rooms: (Room | null)[]
}

View File

@@ -0,0 +1,30 @@
import type {
BookingConfirmation,
PackageSchema,
} from "../trpc/routers/booking/confirmation"
export interface Room {
adults: number
bedDescription: string
breakfastIncluded: boolean
children?: number
confirmationNumber: string
fromDate: Date
name: string
rateDefinition: BookingConfirmation["booking"]["rateDefinition"]
roomPrice: number
selectedBreakfast?: PackageSchema
toDate: Date
totalPrice: number
}
export interface InitialState {
rooms: (Room | null)[]
currencyCode: string
}
export interface BookingConfirmationState {
rooms: (Room | null)[]
currencyCode: string
actions: { setRoom: (room: Room, idx: number) => void }
}

View File

@@ -1,11 +1,16 @@
import type { z } from "zod" import type { z } from "zod"
import type { Hotel, Room } from "@/types/hotel" import type { Hotel, Room } from "@/types/hotel"
import type { bookingConfirmationSchema } from "@/server/routers/booking/output" import type {
bookingConfirmationSchema,
packageSchema,
} from "@/server/routers/booking/output"
export interface BookingConfirmationSchema export interface BookingConfirmationSchema
extends z.output<typeof bookingConfirmationSchema> {} extends z.output<typeof bookingConfirmationSchema> {}
export interface PackageSchema extends z.output<typeof packageSchema> {}
export interface BookingConfirmation { export interface BookingConfirmation {
booking: BookingConfirmationSchema booking: BookingConfirmationSchema
hotel: Hotel hotel: Hotel