feat(SW-2116): RefId instead of confirmationNumber

This commit is contained in:
Arvid Norlin
2025-04-25 13:44:49 +02:00
committed by Michael Zetterberg
parent 7eeb0bbcac
commit 74d37dad93
61 changed files with 1032 additions and 843 deletions

View File

@@ -19,3 +19,26 @@
padding-top: var(--Spacing-x9); padding-top: var(--Spacing-x9);
} }
} }
.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) {
.mobileReceipt {
display: none;
}
.aside {
display: grid;
grid-area: receipt;
}
}

View File

@@ -1,14 +1,84 @@
import { notFound } from "next/navigation"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import BookingConfirmation from "@/components/HotelReservation/BookingConfirmation" import Alerts from "@/components/HotelReservation/BookingConfirmation/Alerts"
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 Tracking from "@/components/HotelReservation/BookingConfirmation/Tracking"
import { mapRoomState } from "@/components/HotelReservation/BookingConfirmation/utils"
import SidePanel from "@/components/HotelReservation/SidePanel"
import Divider from "@/components/TempDesignSystem/Divider"
import { getIntl } from "@/i18n"
import BookingConfirmationProvider from "@/providers/BookingConfirmationProvider"
import styles from "./page.module.css"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
export default async function BookingConfirmationPage({ export default async function BookingConfirmationPage({
params,
searchParams, searchParams,
}: PageArgs<LangParams, { confirmationNumber: string }>) { }: PageArgs<LangParams, { RefId?: string }>) {
void getBookingConfirmation(searchParams.confirmationNumber) const refId = searchParams.RefId
if (!refId) {
notFound()
}
const bookingConfirmation = await getBookingConfirmation(refId, params.lang)
if (!bookingConfirmation) {
notFound()
}
const { booking, hotelData, room } = bookingConfirmation
const { hotel } = hotelData
const intl = await getIntl()
return ( return (
<BookingConfirmation confirmationNumber={searchParams.confirmationNumber} /> <BookingConfirmationProvider
bookingCode={booking.bookingCode}
currencyCode={booking.currencyCode}
fromDate={booking.checkInDate}
toDate={booking.checkOutDate}
rooms={[
mapRoomState(booking, room, intl),
// null represents "known but not yet fetched rooms" and is used to render placeholders correctly
...Array(booking.linkedReservations.length).fill(null),
]}
vat={booking.vatPercentage}
>
<main className={styles.main}>
<Header booking={booking} hotel={hotel} refId={refId} />
<div className={styles.booking}>
<Alerts booking={booking} />
<Rooms
booking={booking}
checkInTime={hotel.hotelFacts.checkin.checkInTime}
checkOutTime={hotel.hotelFacts.checkin.checkOutTime}
mainRoom={room}
/>
<PaymentDetails />
<Divider color="primaryLightSubtle" />
<HotelDetails hotel={hotel} />
<Promos refId={refId} hotelId={hotel.operaId} />
<div className={styles.mobileReceipt}>
<Receipt />
</div>
</div>
<aside className={styles.aside}>
<SidePanel variant="receipt">
<Receipt />
</SidePanel>
</aside>
</main>
<Tracking bookingConfirmation={bookingConfirmation} />
</BookingConfirmationProvider>
) )
} }

View File

@@ -6,10 +6,12 @@ import {
} from "@/constants/booking" } from "@/constants/booking"
import { myStay } from "@/constants/routes/myStay" import { myStay } from "@/constants/routes/myStay"
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
import { createCounter } from "@/server/telemetry"
import GuaranteeCallback from "@/components/HotelReservation/MyStay/Ancillaries/GuaranteeCallback" import GuaranteeCallback from "@/components/HotelReservation/MyStay/Ancillaries/GuaranteeCallback"
import TrackGuarantee from "@/components/HotelReservation/MyStay/TrackGuarantee" import TrackGuarantee from "@/components/HotelReservation/MyStay/TrackGuarantee"
import LoadingSpinner from "@/components/LoadingSpinner" import LoadingSpinner from "@/components/LoadingSpinner"
import { setLang } from "@/i18n/serverContext"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
@@ -19,45 +21,56 @@ export default async function GuaranteePaymentCallbackPage({
}: PageArgs< }: PageArgs<
LangParams, LangParams,
{ {
status: PaymentCallbackStatusEnum status?: PaymentCallbackStatusEnum
RefId: string RefId?: string
confirmationNumber?: string confirmationNumber?: string
ancillary?: string ancillary?: string
} }
>) { >) {
console.log(`[gla-payment-callback] callback started`)
const lang = params.lang const lang = params.lang
const status = searchParams.status const status = searchParams.status
const confirmationNumber = searchParams.confirmationNumber
const refId = searchParams.RefId const refId = searchParams.RefId
if (!refId) { const confirmationNumber = searchParams.confirmationNumber
notFound()
}
const isAncillaryFlow = searchParams.ancillary const isAncillaryFlow = searchParams.ancillary
setLang(params.lang)
if (!status || !confirmationNumber || !refId) {
notFound()
}
const glaSuccessCounter = createCounter("gla", "success")
const metricsGlaSuccess = glaSuccessCounter.init({
confirmationNumber,
})
metricsGlaSuccess.start()
const myStayUrl = `${myStay[lang]}?RefId=${encodeURIComponent(refId)}` const myStayUrl = `${myStay[lang]}?RefId=${encodeURIComponent(refId)}`
const searchObject = new URLSearchParams()
if (status === PaymentCallbackStatusEnum.Success && confirmationNumber) { if (status === PaymentCallbackStatusEnum.Success && confirmationNumber) {
if (isAncillaryFlow) { if (isAncillaryFlow) {
return ( return (
<GuaranteeCallback <GuaranteeCallback
returnUrl={myStayUrl} returnUrl={myStayUrl}
refId={refId}
confirmationNumber={confirmationNumber} confirmationNumber={confirmationNumber}
lang={lang} lang={lang}
/> />
) )
} }
console.log(`[gla-payment-callback] redirecting to: ${myStayUrl}`)
return <TrackGuarantee status={status} redirectUrl={myStayUrl} /> return <TrackGuarantee status={status} redirectUrl={myStayUrl} />
} }
let errorMessage = undefined let errorMessage = undefined
if (confirmationNumber) { if (confirmationNumber) {
const searchObject = new URLSearchParams()
try { try {
const bookingStatus = await serverClient().booking.status({ const bookingStatus = await serverClient().booking.status({
confirmationNumber, refId,
}) })
const error = bookingStatus.errors.find((e) => e.errorCode) const error = bookingStatus.errors.find((e) => e.errorCode)

View File

@@ -1,93 +0,0 @@
import {
BOOKING_CONFIRMATION_NUMBER,
BookingErrorCodeEnum,
PaymentCallbackStatusEnum,
} from "@/constants/booking"
import {
bookingConfirmation,
details,
} from "@/constants/routes/hotelReservation"
import { serverClient } from "@/lib/trpc/server"
import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleErrorCallback"
import HandleSuccessCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback"
import type { LangParams, PageArgs } from "@/types/params"
export default async function PaymentCallbackPage({
params,
searchParams,
}: PageArgs<
LangParams,
{
status: PaymentCallbackStatusEnum
confirmationNumber?: string
hotel?: string
}
>) {
console.log(`[payment-callback] callback started`)
const lang = params.lang
const status = searchParams.status
const confirmationNumber = searchParams.confirmationNumber
if (status === PaymentCallbackStatusEnum.Success && confirmationNumber) {
const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${confirmationNumber}`
console.log(
`[payment-callback] rendering success callback with confirmation number: ${confirmationNumber}`
)
return (
<HandleSuccessCallback
confirmationNumber={confirmationNumber}
successRedirectUrl={confirmationUrl}
/>
)
}
const returnUrl = details(lang)
const searchObject = new URLSearchParams()
let errorMessage = undefined
if (confirmationNumber) {
try {
const bookingStatus = await serverClient().booking.status({
confirmationNumber,
})
// TODO: how to handle errors for multiple rooms?
const error = bookingStatus.errors.find((e) => e.errorCode)
errorMessage =
error?.description ??
`No error message found for booking ${confirmationNumber}, status: ${status}`
searchObject.set(
"errorCode",
error
? error.errorCode.toString()
: BookingErrorCodeEnum.TransactionFailed
)
} catch {
console.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
)
if (status === PaymentCallbackStatusEnum.Cancel) {
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionCancelled)
}
if (status === PaymentCallbackStatusEnum.Error) {
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionFailed)
errorMessage = `Failed to get booking status for ${confirmationNumber}, status: ${status}`
}
}
}
return (
<HandleErrorCallback
returnUrl={returnUrl.toString()}
searchObject={searchObject}
status={status}
errorMessage={errorMessage}
/>
)
}

View File

@@ -8,7 +8,7 @@ import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Paym
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
export default async function PaymentCallbackPage({ export default async function PaymentCallbackCancelPage({
params, params,
}: PageArgs<LangParams>) { }: PageArgs<LangParams>) {
console.log(`[payment-callback] cancel callback started`) console.log(`[payment-callback] cancel callback started`)

View File

@@ -6,10 +6,11 @@ import { details } from "@/constants/routes/hotelReservation"
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleErrorCallback" import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleErrorCallback"
import { calculateRefId } from "@/utils/refId"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
export default async function PaymentCallbackPage({ export default async function PaymentCallbackErrorPage({
params, params,
searchParams, searchParams,
}: PageArgs< }: PageArgs<
@@ -19,6 +20,7 @@ export default async function PaymentCallbackPage({
} }
>) { >) {
console.log(`[payment-callback] error callback started`) console.log(`[payment-callback] error callback started`)
const lang = params.lang const lang = params.lang
const confirmationNumber = searchParams.confirmationNumber const confirmationNumber = searchParams.confirmationNumber
@@ -28,9 +30,11 @@ export default async function PaymentCallbackPage({
let errorMessage = undefined let errorMessage = undefined
if (confirmationNumber) { if (confirmationNumber) {
const refId = calculateRefId(confirmationNumber, "")
try { try {
const bookingStatus = await serverClient().booking.status({ const bookingStatus = await serverClient().booking.confirmationError({
confirmationNumber, refId,
}) })
// TODO: how to handle errors for multiple rooms? // TODO: how to handle errors for multiple rooms?

View File

@@ -1,33 +1,44 @@
import { BOOKING_CONFIRMATION_NUMBER } from "@/constants/booking" import { notFound } from "next/navigation"
import { bookingConfirmation } from "@/constants/routes/hotelReservation" import { bookingConfirmation } from "@/constants/routes/hotelReservation"
import { createCounter } from "@/server/telemetry"
import HandleSuccessCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback" import HandleSuccessCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleSuccessCallback"
import { setLang } from "@/i18n/serverContext"
import { calculateRefId } from "@/utils/refId"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
export default async function PaymentCallbackPage({ export default async function PaymentCallbackSuccessPage({
params, params,
searchParams, searchParams,
}: PageArgs< }: PageArgs<
LangParams, LangParams,
{ {
confirmationNumber: string confirmationNumber?: string
} }
>) { >) {
console.log(`[payment-callback] success callback started`)
const lang = params.lang
const confirmationNumber = searchParams.confirmationNumber const confirmationNumber = searchParams.confirmationNumber
const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${confirmationNumber}` setLang(params.lang)
console.log(
`[payment-callback] rendering success callback with confirmation number: ${confirmationNumber}` if (!confirmationNumber) {
) notFound()
}
const paymentSuccessCounter = createCounter("payment", "success")
const metricsPaymentSuccess = paymentSuccessCounter.init({
confirmationNumber,
})
metricsPaymentSuccess.start()
const refId = calculateRefId(confirmationNumber, "")
return ( return (
<HandleSuccessCallback <HandleSuccessCallback
confirmationNumber={confirmationNumber} refId={refId}
successRedirectUrl={confirmationUrl} successRedirectUrl={bookingConfirmation(params.lang)}
/> />
) )
} }

View File

@@ -13,7 +13,6 @@ import {
getProfileSafely, getProfileSafely,
getSavedPaymentCardsSafely, getSavedPaymentCardsSafely,
} from "@/lib/trpc/memoizedRequests" } from "@/lib/trpc/memoizedRequests"
import { decrypt } from "@/server/routers/utils/encryption"
import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm" import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm"
import accessBooking, { import accessBooking, {
@@ -32,6 +31,7 @@ import Image from "@/components/Image"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import MyStayProvider from "@/providers/MyStay" import MyStayProvider from "@/providers/MyStay"
import { parseRefId } from "@/utils/refId"
import { getCurrentWebUrl } from "@/utils/url" import { getCurrentWebUrl } from "@/utils/url"
import styles from "./page.module.css" import styles from "./page.module.css"
@@ -44,29 +44,26 @@ export default async function MyStay({
searchParams, searchParams,
}: PageArgs<LangParams, { RefId?: string }>) { }: PageArgs<LangParams, { RefId?: string }>) {
setLang(params.lang) setLang(params.lang)
const refId = searchParams.RefId const refId = searchParams.RefId
if (!refId) { if (!refId) {
notFound() notFound()
} }
const value = decrypt(refId) const bookingConfirmation = await getBookingConfirmation(refId, params.lang)
if (!value) {
return notFound()
}
const [confirmationNumber, lastName] = value.split(",")
const bookingConfirmation = await getBookingConfirmation(confirmationNumber)
if (!bookingConfirmation) { if (!bookingConfirmation) {
return notFound() return notFound()
} }
const { additionalData, booking, hotel, roomCategories } = bookingConfirmation const { booking, hotelData } = bookingConfirmation
const { hotel } = hotelData
const user = await getProfileSafely() const user = await getProfileSafely()
const bv = cookies().get("bv")?.value const bv = cookies().get("bv")?.value
const intl = await getIntl() const intl = await getIntl()
const { lastName } = parseRefId(refId)
const access = accessBooking(booking.guest, lastName, user, bv) const access = accessBooking(booking.guest, lastName, user, bv)
if (access === ACCESS_GRANTED) { if (access === ACCESS_GRANTED) {
@@ -74,9 +71,7 @@ export default async function MyStay({
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD") const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD") const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
const linkedReservationsPromise = getLinkedReservations({ const linkedReservationsPromise = getLinkedReservations(refId, params.lang)
rooms: booking.linkedReservations,
})
const packagesInput = { const packagesInput = {
adults: booking.adults, adults: booking.adults,
@@ -121,7 +116,7 @@ export default async function MyStay({
const imageSrc = const imageSrc =
hotel.hotelContent.images.imageSizes.large ?? hotel.hotelContent.images.imageSizes.large ??
additionalData.gallery?.heroImages[0]?.imageSizes.large ?? hotelData.additionalData.gallery?.heroImages[0]?.imageSizes.large ??
hotel.galleryImages[0]?.imageSizes.large hotel.galleryImages[0]?.imageSizes.large
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com" const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
@@ -138,7 +133,7 @@ export default async function MyStay({
lang={params.lang} lang={params.lang}
linkedReservationsPromise={linkedReservationsPromise} linkedReservationsPromise={linkedReservationsPromise}
refId={refId} refId={refId}
roomCategories={roomCategories} roomCategories={hotelData.roomCategories}
savedCreditCards={savedCreditCards} savedCreditCards={savedCreditCards}
> >
<main className={styles.main}> <main className={styles.main}>
@@ -197,10 +192,7 @@ export default async function MyStay({
return ( return (
<main className={styles.main}> <main className={styles.main}>
<div className={styles.form}> <div className={styles.form}>
<AdditionalInfoForm <AdditionalInfoForm refId={refId} lastName={lastName} />
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
</div> </div>
</main> </main>
) )

View File

@@ -0,0 +1 @@
export { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton"

View File

@@ -1,20 +1,177 @@
import { cookies } from "next/headers"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { Suspense } from "react"
import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton" import ScandicLogoIcon from "@scandic-hotels/design-system/Icons/ScandicLogoIcon"
import { Receipt } from "@/components/HotelReservation/MyStay/Receipt" import { Typography } from "@scandic-hotels/design-system/Typography"
import { dt } from "@/lib/dt"
import {
getAncillaryPackages,
getBookingConfirmation,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm"
import accessBooking, {
ACCESS_GRANTED,
ERROR_BAD_REQUEST,
ERROR_UNAUTHORIZED,
} from "@/components/HotelReservation/MyStay/accessBooking"
import Footer from "@/components/HotelReservation/MyStay/Receipt/Footer"
import Specification from "@/components/HotelReservation/MyStay/Receipt/Specification"
import Total from "@/components/HotelReservation/MyStay/Receipt/Total"
import { getIntl } from "@/i18n"
import { parseRefId } from "@/utils/refId"
import styles from "./page.module.css"
import { CurrencyEnum } from "@/types/enums/currency"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
export default async function ReceiptPage({ export default async function ReceiptPage({
params,
searchParams, searchParams,
}: PageArgs<LangParams, { RefId?: string }>) { }: PageArgs<LangParams, { RefId?: string }>) {
if (!searchParams.RefId) { const refId = searchParams.RefId
if (!refId) {
notFound() notFound()
} }
return (
<Suspense fallback={<MyStaySkeleton />}> const { confirmationNumber, lastName } = parseRefId(refId)
<Receipt refId={searchParams.RefId} />
</Suspense> const bookingConfirmation = await getBookingConfirmation(
confirmationNumber,
params.lang
) )
if (!bookingConfirmation) {
return notFound()
}
const { booking, hotelData, room } = bookingConfirmation
const { hotel } = hotelData
const intl = await getIntl()
const user = await getProfileSafely()
const bv = cookies().get("bv")?.value
const access = accessBooking(booking.guest, lastName, user, bv)
if (access === ACCESS_GRANTED) {
const ancillaryPackages = await getAncillaryPackages({
fromDate: dt(booking.checkInDate).format("YYYY-MM-DD"),
hotelId: hotel.operaId,
toDate: dt(booking.checkOutDate).format("YYYY-MM-DD"),
})
const currency =
booking.currencyCode !== CurrencyEnum.POINTS
? booking.currencyCode
: (booking.ancillaries.find((a) => a.currency !== CurrencyEnum.POINTS)
?.currency ??
booking.packages.find((p) => p.currency !== CurrencyEnum.POINTS)
?.currency)
return (
<main className={styles.main}>
<div>
<ScandicLogoIcon width="89px" height="19px" color="Icon/Accent" />
<div className={styles.addresses}>
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div>{hotel.name}</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={`${styles.tertiary} ${styles.addressMargin}`}>
{hotel.contactInformation.email}
</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.tertiary}>
{hotel.contactInformation.phoneNumber}
</div>
</Typography>
</div>
<div className={styles.rightColumn}>
<Typography variant="Body/Supporting text (caption)/smRegular">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<div>{`${booking.guest.firstName} ${booking.guest.lastName}`}</div>
</Typography>
{booking.guest.membershipNumber && (
<Typography variant="Body/Supporting text (caption)/smRegular">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<div>{`${intl.formatMessage({
defaultMessage: "Member",
})} ${booking.guest.membershipNumber}`}</div>
</Typography>
)}
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={`${styles.tertiary} ${styles.addressMargin}`}>
{booking.guest.email}
</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.tertiary}>
{booking.guest.phoneNumber}
</div>
</Typography>
</div>
</div>
</div>
<Total booking={booking} currency={currency} />
<Specification
ancillaryPackages={ancillaryPackages}
booking={booking}
currency={currency}
/>
<hr className={styles.divider} />
<Footer booking={booking} room={room} />
</main>
)
}
if (access === ERROR_BAD_REQUEST) {
return (
<main className={styles.main}>
<div className={styles.form}>
<AdditionalInfoForm refId={refId} lastName={lastName} />
</div>
</main>
)
}
if (access === ERROR_UNAUTHORIZED) {
return (
<main className={styles.main}>
<div className={styles.logIn}>
<Typography variant="Title/md">
<h1>
{intl.formatMessage({
defaultMessage: "You need to be logged in to view your booking",
})}
</h1>
</Typography>
<Typography variant="Body/Lead text">
<p>
{intl.formatMessage({
defaultMessage:
"And you need to be logged in with the same member account that made the booking.",
})}
</p>
</Typography>
</div>
</main>
)
}
return notFound()
} }

View File

@@ -13,7 +13,6 @@ import {
getProfileSafely, getProfileSafely,
getSavedPaymentCardsSafely, getSavedPaymentCardsSafely,
} from "@/lib/trpc/memoizedRequests" } from "@/lib/trpc/memoizedRequests"
import { decrypt } from "@/server/routers/utils/encryption"
import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm" import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm"
import accessBooking, { import accessBooking, {
@@ -32,6 +31,7 @@ import Image from "@/components/Image"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import MyStayProvider from "@/providers/MyStay" import MyStayProvider from "@/providers/MyStay"
import { parseRefId } from "@/utils/refId"
import { getCurrentWebUrl } from "@/utils/url" import { getCurrentWebUrl } from "@/utils/url"
import styles from "./page.module.css" import styles from "./page.module.css"
@@ -44,29 +44,26 @@ export default async function MyStay({
searchParams, searchParams,
}: PageArgs<LangParams, { RefId?: string }>) { }: PageArgs<LangParams, { RefId?: string }>) {
setLang(params.lang) setLang(params.lang)
const refId = searchParams.RefId const refId = searchParams.RefId
if (!refId) { if (!refId) {
notFound() notFound()
} }
const value = decrypt(refId) const bookingConfirmation = await getBookingConfirmation(refId, params.lang)
if (!value) {
return notFound()
}
const [confirmationNumber, lastName] = value.split(",")
const bookingConfirmation = await getBookingConfirmation(confirmationNumber)
if (!bookingConfirmation) { if (!bookingConfirmation) {
return notFound() return notFound()
} }
const { additionalData, booking, hotel, roomCategories } = bookingConfirmation const { booking, hotelData } = bookingConfirmation
const { hotel } = hotelData
const user = await getProfileSafely() const user = await getProfileSafely()
const bv = cookies().get("bv")?.value const bv = cookies().get("bv")?.value
const intl = await getIntl() const intl = await getIntl()
const { lastName } = parseRefId(refId)
const access = accessBooking(booking.guest, lastName, user, bv) const access = accessBooking(booking.guest, lastName, user, bv)
if (access === ACCESS_GRANTED) { if (access === ACCESS_GRANTED) {
@@ -74,9 +71,7 @@ export default async function MyStay({
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD") const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD") const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
const linkedReservationsPromise = getLinkedReservations({ const linkedReservationsPromise = getLinkedReservations(refId, params.lang)
rooms: booking.linkedReservations,
})
const packagesInput = { const packagesInput = {
adults: booking.adults, adults: booking.adults,
@@ -98,9 +93,9 @@ export default async function MyStay({
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST (pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
) )
const breakfastIncluded = booking.rateDefinition.breakfastIncluded const breakfastIncluded = booking.rateDefinition.breakfastIncluded
const alreadyHasABreakfastSelection = const shouldFetchBreakfastPackages =
!hasBreakfastPackage && !breakfastIncluded !hasBreakfastPackage && !breakfastIncluded
if (alreadyHasABreakfastSelection) { if (shouldFetchBreakfastPackages) {
void getPackages(packagesInput) void getPackages(packagesInput)
} }
void getSavedPaymentCardsSafely(savedPaymentCardsInput) void getSavedPaymentCardsSafely(savedPaymentCardsInput)
@@ -112,7 +107,7 @@ export default async function MyStay({
}) })
let breakfastPackages = null let breakfastPackages = null
if (alreadyHasABreakfastSelection) { if (shouldFetchBreakfastPackages) {
breakfastPackages = await getPackages(packagesInput) breakfastPackages = await getPackages(packagesInput)
} }
const savedCreditCards = await getSavedPaymentCardsSafely( const savedCreditCards = await getSavedPaymentCardsSafely(
@@ -121,7 +116,7 @@ export default async function MyStay({
const imageSrc = const imageSrc =
hotel.hotelContent.images.imageSizes.large ?? hotel.hotelContent.images.imageSizes.large ??
additionalData.gallery?.heroImages[0]?.imageSizes.large ?? hotelData.additionalData.gallery?.heroImages[0]?.imageSizes.large ??
hotel.galleryImages[0]?.imageSizes.large hotel.galleryImages[0]?.imageSizes.large
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com" const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
@@ -138,7 +133,7 @@ export default async function MyStay({
lang={params.lang} lang={params.lang}
linkedReservationsPromise={linkedReservationsPromise} linkedReservationsPromise={linkedReservationsPromise}
refId={refId} refId={refId}
roomCategories={roomCategories} roomCategories={hotelData.roomCategories}
savedCreditCards={savedCreditCards} savedCreditCards={savedCreditCards}
> >
<main className={styles.main}> <main className={styles.main}>
@@ -197,10 +192,7 @@ export default async function MyStay({
return ( return (
<main className={styles.main}> <main className={styles.main}>
<div className={styles.form}> <div className={styles.form}>
<AdditionalInfoForm <AdditionalInfoForm refId={refId} lastName={lastName} />
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
</div> </div>
</main> </main>
) )

View File

@@ -1,4 +1,5 @@
"use client" "use client"
import { createEvent } from "ics" import { createEvent } from "ics"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"

View File

@@ -1,24 +0,0 @@
"use client"
import { useRef } from "react"
import Header from "@/components/HotelReservation/BookingConfirmation/Header"
import styles from "./confirmation.module.css"
import type { ConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default function Confirmation({
booking,
hotel,
children,
refId,
}: React.PropsWithChildren<ConfirmationProps>) {
const mainRef = useRef<HTMLElement | null>(null)
return (
<main className={styles.main} ref={mainRef}>
<Header booking={booking} hotel={hotel} mainRef={mainRef} refId={refId} />
{children}
</main>
)
}

View File

@@ -1,15 +1,22 @@
"use client" "use client"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { myStay } from "@/constants/routes/myStay"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import type { ManageBookingProps } from "@/types/components/hotelReservation/bookingConfirmation/actions/manageBooking" import type { ManageBookingProps } from "@/types/components/hotelReservation/bookingConfirmation/actions/manageBooking"
export default function ManageBooking({ bookingUrl }: ManageBookingProps) { export default function ManageBooking({ refId }: ManageBookingProps) {
const intl = useIntl() const intl = useIntl()
const lang = useLang()
const bookingUrl = `${myStay[lang]}?RefId=${refId}`
return ( return (
<Button <Button

View File

@@ -1,15 +1,11 @@
"use client" "use client"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { myStay } from "@/constants/routes/myStay"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import useLang from "@/hooks/useLang"
import AddToCalendar from "../../AddToCalendar" import AddToCalendar from "../../AddToCalendar"
import AddToCalendarButton from "./Actions/AddToCalendarButton" import AddToCalendarButton from "./Actions/AddToCalendarButton"
// import DownloadInvoice from "./Actions/DownloadInvoice"
import { generateDateTime } from "./Actions/helpers" import { generateDateTime } from "./Actions/helpers"
import ManageBooking from "./Actions/ManageBooking" import ManageBooking from "./Actions/ManageBooking"
@@ -22,11 +18,9 @@ import type { BookingConfirmationHeaderProps } from "@/types/components/hotelRes
export default function Header({ export default function Header({
booking, booking,
hotel, hotel,
// mainRef,
refId, refId,
}: BookingConfirmationHeaderProps) { }: BookingConfirmationHeaderProps) {
const intl = useIntl() const intl = useIntl()
const lang = useLang()
const text = intl.formatMessage({ const text = intl.formatMessage({
defaultMessage: defaultMessage:
@@ -52,8 +46,6 @@ export default function Header({
url: hotel.contactInformation.websiteUrl, url: hotel.contactInformation.websiteUrl,
} }
const bookingUrlPath = `${myStay[lang]}?RefId=${refId}`
return ( return (
<header className={styles.header}> <header className={styles.header}>
<hgroup className={styles.hgroup}> <hgroup className={styles.hgroup}>
@@ -74,9 +66,7 @@ export default function Header({
hotelName={hotel.name} hotelName={hotel.name}
renderButton={(onPress) => <AddToCalendarButton onPress={onPress} />} renderButton={(onPress) => <AddToCalendarButton onPress={onPress} />}
/> />
<ManageBooking bookingUrl={bookingUrlPath} /> <ManageBooking refId={refId} />
{/* Download Invoice will be added later (currently available on My Stay) */}
{/* <DownloadInvoice mainRef={mainRef} /> */}
</div> </div>
</header> </header>
) )

View File

@@ -1,9 +1,8 @@
"use client" "use client"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { homeHrefs } from "@/constants/homeHrefs" import { myStay } from "@/constants/routes/myStay"
import { myBooking } from "@/constants/myBooking"
import { env } from "@/env/client"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
@@ -13,22 +12,17 @@ import styles from "./promos.module.css"
import type { PromosProps } from "@/types/components/hotelReservation/bookingConfirmation/promos" import type { PromosProps } from "@/types/components/hotelReservation/bookingConfirmation/promos"
export default function Promos({ export default function Promos({ refId, hotelId }: PromosProps) {
confirmationNumber,
hotelId,
lastName,
}: PromosProps) {
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
const homeUrl = homeHrefs[env.NEXT_PUBLIC_NODE_ENV][lang]
const myBookingUrl = myBooking[env.NEXT_PUBLIC_NODE_ENV][lang]
return ( return (
<div className={styles.promos}> <div className={styles.promos}>
<Promo <Promo
buttonText={intl.formatMessage({ buttonText={intl.formatMessage({
defaultMessage: "View and buy add-ons", defaultMessage: "View and buy add-ons",
})} })}
href={`${myBookingUrl}?bookingId=${confirmationNumber}&lastName=${lastName}`} href={`${myStay[lang]}?RefId=${refId}`}
text={intl.formatMessage({ text={intl.formatMessage({
defaultMessage: defaultMessage:
"Discover the little extra touches to make your upcoming stay even more unforgettable.", "Discover the little extra touches to make your upcoming stay even more unforgettable.",
@@ -41,7 +35,7 @@ export default function Promos({
buttonText={intl.formatMessage({ buttonText={intl.formatMessage({
defaultMessage: "Book another stay", defaultMessage: "Book another stay",
})} })}
href={`${homeUrl}?hotel=${hotelId}`} href={`/${lang}?hotel=${hotelId}`}
text={intl.formatMessage({ text={intl.formatMessage({
defaultMessage: defaultMessage:
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.", "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",

View File

@@ -20,14 +20,16 @@ import { CurrencyEnum } from "@/types/enums/currency"
export function LinkedReservation({ export function LinkedReservation({
checkInTime, checkInTime,
checkOutTime, checkOutTime,
confirmationNumber, refId,
roomIndex, roomIndex,
}: LinkedReservationProps) { }: LinkedReservationProps) {
const lang = useLang() const lang = useLang()
const { data, refetch, isLoading } = trpc.booking.get.useQuery({
confirmationNumber, const { data, refetch, isLoading } = trpc.booking.confirmation.useQuery({
refId,
lang, lang,
}) })
const { const {
setRoom, setRoom,
setFormattedTotalCost, setFormattedTotalCost,
@@ -41,6 +43,7 @@ export function LinkedReservation({
totalBookingPrice: state.totalBookingPrice, totalBookingPrice: state.totalBookingPrice,
totalBookingCheques: state.totalBookingCheques, totalBookingCheques: state.totalBookingCheques,
})) }))
const intl = useIntl() const intl = useIntl()
useEffect(() => { useEffect(() => {
@@ -78,7 +81,9 @@ export function LinkedReservation({
if (!data?.room) { if (!data?.room) {
return <Retry handleRefetch={refetch} /> return <Retry handleRefetch={refetch} />
} }
const { booking, room } = data const { booking, room } = data
return ( return (
<Room <Room
checkInDate={booking.checkInDate} checkInDate={booking.checkInDate}
@@ -86,8 +91,8 @@ export function LinkedReservation({
checkInTime={checkInTime} checkInTime={checkInTime}
checkOutTime={checkOutTime} checkOutTime={checkOutTime}
confirmationNumber={booking.confirmationNumber} confirmationNumber={booking.confirmationNumber}
guest={booking.guest}
guaranteeInfo={booking.guaranteeInfo} guaranteeInfo={booking.guaranteeInfo}
guest={booking.guest}
img={room.images[0]} img={room.images[0]}
rateDefinition={booking.rateDefinition} rateDefinition={booking.rateDefinition}
roomName={room.name} roomName={room.name}

View File

@@ -9,30 +9,35 @@ import styles from "./rooms.module.css"
import type { BookingConfirmationRoomsProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms" import type { BookingConfirmationRoomsProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms"
async function RoomTitle({ nr }: { nr: number }) {
const intl = await getIntl()
return (
<Typography variant="Title/Subtitle/md">
<h2 className={styles.roomTitle}>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: nr }
)}
</h2>
</Typography>
)
}
export default async function Rooms({ export default async function Rooms({
booking, booking,
checkInTime, checkInTime,
checkOutTime, checkOutTime,
mainRoom, mainRoom,
linkedReservations,
}: BookingConfirmationRoomsProps) { }: BookingConfirmationRoomsProps) {
const intl = await getIntl() const { linkedReservations } = booking
return ( return (
<section className={styles.rooms}> <section className={styles.rooms}>
<div className={styles.room}> <div className={styles.room}>
{linkedReservations.length ? ( {linkedReservations.length ? <RoomTitle nr={1} /> : null}
<Typography variant="Title/Subtitle/md">
<h2 className={styles.roomTitle}>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: 1 }
)}
</h2>
</Typography>
) : null}
<Room <Room
checkInDate={booking.checkInDate} checkInDate={booking.checkInDate}
checkOutDate={booking.checkOutDate} checkOutDate={booking.checkOutDate}
@@ -49,20 +54,11 @@ export default async function Rooms({
{linkedReservations.map((reservation, idx) => ( {linkedReservations.map((reservation, idx) => (
<div className={styles.room} key={reservation.confirmationNumber}> <div className={styles.room} key={reservation.confirmationNumber}>
<Typography variant="Title/Subtitle/md"> <RoomTitle nr={idx + 2} />
<h2 className={styles.roomTitle}>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: idx + 2 }
)}
</h2>
</Typography>
<LinkedReservation <LinkedReservation
checkInTime={checkInTime} checkInTime={checkInTime}
checkOutTime={checkOutTime} checkOutTime={checkOutTime}
confirmationNumber={reservation.confirmationNumber} refId={reservation.refId}
roomIndex={idx + 1} roomIndex={idx + 1}
/> />
</div> </div>

View File

@@ -27,7 +27,7 @@ export default function Tracking({
getTracking( getTracking(
lang, lang,
bookingConfirmation.booking, bookingConfirmation.booking,
bookingConfirmation.hotel, bookingConfirmation.hotelData.hotel,
rooms rooms
) )

View File

@@ -67,7 +67,7 @@ function mapAncillaryPackage(
export function getTracking( export function getTracking(
lang: Lang, lang: Lang,
booking: BookingConfirmation["booking"], booking: BookingConfirmation["booking"],
hotel: BookingConfirmation["hotel"], hotel: BookingConfirmation["hotelData"]["hotel"],
rooms: Room[] rooms: Room[]
) { ) {
const arrivalDate = new Date(booking.checkInDate) const arrivalDate = new Date(booking.checkInDate)

View File

@@ -1,22 +0,0 @@
.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) {
.mobileReceipt {
display: none;
}
.aside {
display: grid;
grid-area: receipt;
}
}

View File

@@ -1,83 +0,0 @@
import { notFound } from "next/navigation"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
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 { getIntl } from "@/i18n"
import BookingConfirmationProvider from "@/providers/BookingConfirmationProvider"
import { encrypt } from "@/utils/encryption"
import Alerts from "./Alerts"
import Confirmation from "./Confirmation"
import Tracking from "./Tracking"
import { mapRoomState } from "./utils"
import styles from "./bookingConfirmation.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default async function BookingConfirmation({
confirmationNumber,
}: BookingConfirmationProps) {
const bookingConfirmation = await getBookingConfirmation(confirmationNumber)
if (!bookingConfirmation) {
return notFound()
}
const { booking, hotel, room } = bookingConfirmation
if (!room) {
return notFound()
}
const refId = encrypt(
`${booking.confirmationNumber},${booking.guest.lastName}`
)
const intl = await getIntl()
return (
<BookingConfirmationProvider
bookingCode={booking.bookingCode}
currencyCode={booking.currencyCode}
fromDate={booking.checkInDate}
toDate={booking.checkOutDate}
rooms={[
mapRoomState(booking, room, intl),
// null represents "known but not yet fetched rooms" and is used to render placeholders correctly
...Array(booking.linkedReservations.length).fill(null),
]}
vat={booking.vatPercentage}
>
<Confirmation booking={booking} hotel={hotel} room={room} refId={refId}>
<div className={styles.booking}>
<Alerts booking={booking} />
<Rooms
booking={booking}
checkInTime={hotel.hotelFacts.checkin.checkInTime}
checkOutTime={hotel.hotelFacts.checkin.checkOutTime}
mainRoom={room}
linkedReservations={booking.linkedReservations}
/>
<PaymentDetails />
<Divider color="primaryLightSubtle" />
<HotelDetails hotel={hotel} />
<Promos refId={refId} hotelId={hotel.operaId} />
<div className={styles.mobileReceipt}>
<Receipt />
</div>
</div>
<aside className={styles.aside}>
<SidePanel variant="receipt">
<Receipt />
</SidePanel>
</aside>
</Confirmation>
<Tracking bookingConfirmation={bookingConfirmation} />
</BookingConfirmationProvider>
)
}

View File

@@ -5,10 +5,10 @@ import type { IntlShape } from "react-intl"
import type { BookingConfirmationRoom } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" import type { BookingConfirmationRoom } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { CurrencyEnum } from "@/types/enums/currency" import { CurrencyEnum } from "@/types/enums/currency"
import type { BookingConfirmationSchema } from "@/types/trpc/routers/booking/confirmation" import type { BookingSchema } from "@/types/trpc/routers/booking/confirmation"
export function mapRoomState( export function mapRoomState(
booking: BookingConfirmationSchema, booking: BookingSchema,
room: BookingConfirmationRoom, room: BookingConfirmationRoom,
intl: IntlShape intl: IntlShape
) { ) {

View File

@@ -18,12 +18,12 @@ const validBookingStatuses = [
] ]
interface HandleStatusPollingProps { interface HandleStatusPollingProps {
confirmationNumber: string refId: string
successRedirectUrl: string successRedirectUrl: string
} }
export default function HandleSuccessCallback({ export default function HandleSuccessCallback({
confirmationNumber, refId,
successRedirectUrl, successRedirectUrl,
}: HandleStatusPollingProps) { }: HandleStatusPollingProps) {
const router = useRouter() const router = useRouter()
@@ -33,7 +33,7 @@ export default function HandleSuccessCallback({
error, error,
isTimeout, isTimeout,
} = useHandleBookingStatus({ } = useHandleBookingStatus({
confirmationNumber, refId,
expectedStatuses: validBookingStatuses, expectedStatuses: validBookingStatuses,
maxRetries: 10, maxRetries: 10,
retryInterval: 2000, retryInterval: 2000,
@@ -70,9 +70,9 @@ export default function HandleSuccessCallback({
? `&errorCode=${membershipFailedError.errorCode}` ? `&errorCode=${membershipFailedError.errorCode}`
: "" : ""
router.replace(`${successRedirectUrl}${errorParam}`) router.replace(`${successRedirectUrl}?RefId=${refId}${errorParam}`)
} }
}, [bookingStatus, successRedirectUrl, router]) }, [bookingStatus, refId, router, successRedirectUrl])
if (isTimeout || error) { if (isTimeout || error) {
return <TimeoutSpinner /> return <TimeoutSpinner />

View File

@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { Label } from "react-aria-components" import { Label } from "react-aria-components"
import { FormProvider, useForm } from "react-hook-form" import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
@@ -10,7 +10,6 @@ import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { import {
BOOKING_CONFIRMATION_NUMBER,
BookingStatusEnum, BookingStatusEnum,
PAYMENT_METHOD_TITLES, PAYMENT_METHOD_TITLES,
PaymentMethodEnum, PaymentMethodEnum,
@@ -30,7 +29,6 @@ import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions" import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import useStickyPosition from "@/hooks/useStickyPosition" import useStickyPosition from "@/hooks/useStickyPosition"
import { trackPaymentEvent } from "@/utils/tracking" import { trackPaymentEvent } from "@/utils/tracking"
@@ -101,7 +99,7 @@ export default function PaymentClient({
(state) => state.actions.setIsSubmittingDisabled (state) => state.actions.setIsSubmittingDisabled
) )
const [bookingNumber, setBookingNumber] = useState<string>("") const [refId, setRefId] = useState<string>("")
const [isPollingForBookingStatus, setIsPollingForBookingStatus] = const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
useState(false) useState(false)
@@ -146,13 +144,14 @@ export default function PaymentClient({
return return
} }
const mainRoom = result.rooms[0]
if (result.reservationStatus == BookingStatusEnum.BookingCompleted) { if (result.reservationStatus == BookingStatusEnum.BookingCompleted) {
const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${result.id}` const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${mainRoom.refId}`
router.push(confirmationUrl) router.push(confirmationUrl)
return return
} }
setBookingNumber(result.id) setRefId(mainRoom.refId)
const hasPriceChange = result.rooms.some((r) => r.priceChangedMetadata) const hasPriceChange = result.rooms.some((r) => r.priceChangedMetadata)
if (hasPriceChange) { if (hasPriceChange) {
@@ -174,8 +173,8 @@ export default function PaymentClient({
}) })
const priceChange = trpc.booking.priceChange.useMutation({ const priceChange = trpc.booking.priceChange.useMutation({
onSuccess: (result) => { onSuccess: (confirmationNumber) => {
if (result?.id) { if (confirmationNumber) {
setIsPollingForBookingStatus(true) setIsPollingForBookingStatus(true)
} else { } else {
handlePaymentError("No confirmation number") handlePaymentError("No confirmation number")
@@ -189,13 +188,39 @@ export default function PaymentClient({
}, },
}) })
const bookingStatus = useHandleBookingStatus({ // Replaced useHandleBookingStatus with logic specifically used here, since the hook would need
confirmationNumber: bookingNumber, // to handle different parameters based on use case
expectedStatuses: [BookingStatusEnum.BookingCompleted], const retries = useRef(0)
maxRetries,
retryInterval, const bookingStatus = trpc.booking.confirmationCompleted.useQuery(
enabled: isPollingForBookingStatus, {
}) refId,
lang,
},
{
enabled: isPollingForBookingStatus,
refetchInterval: (query) => {
retries.current = query.state.dataUpdateCount
if (query.state.error || query.state.dataUpdateCount >= maxRetries) {
return false
}
if (
query.state.data?.reservationStatus ===
BookingStatusEnum.BookingCompleted
) {
return false
}
return retryInterval
},
refetchIntervalInBackground: true,
refetchOnWindowFocus: false,
refetchOnMount: false,
retry: false,
}
)
const handlePaymentError = useCallback( const handlePaymentError = useCallback(
(errorMessage: string) => { (errorMessage: string) => {
@@ -245,18 +270,12 @@ export default function PaymentClient({
) )
useEffect(() => { useEffect(() => {
if (bookingStatus?.data?.paymentUrl) { if (bookingStatus?.data?.redirectUrl) {
router.push(bookingStatus.data.paymentUrl) router.push(bookingStatus.data.redirectUrl)
} else if ( } else if (retries.current >= maxRetries) {
bookingStatus?.data?.reservationStatus ===
BookingStatusEnum.BookingCompleted
) {
const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${bookingStatus?.data?.id}`
router.push(confirmationUrl)
} else if (bookingStatus.isTimeout) {
handlePaymentError("Timeout") handlePaymentError("Timeout")
} }
}, [bookingStatus, router, intl, lang, handlePaymentError]) }, [bookingStatus, router, handlePaymentError])
useEffect(() => { useEffect(() => {
setIsSubmittingDisabled( setIsSubmittingDisabled(
@@ -458,7 +477,7 @@ export default function PaymentClient({
initiateBooking.isPending || initiateBooking.isPending ||
(isPollingForBookingStatus && (isPollingForBookingStatus &&
!bookingStatus.data?.paymentUrl && !bookingStatus.data?.paymentUrl &&
!bookingStatus.isTimeout) retries.current < maxRetries)
) { ) {
return <LoadingSpinner /> return <LoadingSpinner />
} }
@@ -620,9 +639,7 @@ export default function PaymentClient({
: "" : ""
router.push(`${selectRate(lang)}${allSearchParams}`) router.push(`${selectRate(lang)}${allSearchParams}`)
}} }}
onAccept={() => onAccept={() => priceChange.mutate({ refId })}
priceChange.mutate({ confirmationNumber: bookingNumber })
}
/> />
) : null} ) : null}
</section> </section>

View File

@@ -18,10 +18,10 @@ import {
import styles from "./findMyBooking.module.css" import styles from "./findMyBooking.module.css"
export default function AdditionalInfoForm({ export default function AdditionalInfoForm({
confirmationNumber, refId,
lastName, lastName,
}: { }: {
confirmationNumber: string refId: string
lastName: string lastName: string
}) { }) {
const router = useRouter() const router = useRouter()
@@ -37,7 +37,7 @@ export default function AdditionalInfoForm({
const values = form.getValues() const values = form.getValues()
const value = new URLSearchParams({ const value = new URLSearchParams({
...values, ...values,
confirmationNumber, RefId: refId,
lastName, lastName,
}).toString() }).toString()
document.cookie = `bv=${encodeURIComponent(value)}; Path=/; Max-Age=600; Secure; SameSite=Strict` document.cookie = `bv=${encodeURIComponent(value)}; Path=/; Max-Age=600; Secure; SameSite=Strict`

View File

@@ -145,7 +145,7 @@ export default function AddAncillaryFlowModal({
) { ) {
addAncillary.mutate( addAncillary.mutate(
{ {
confirmationNumber: booking.confirmationNumber, refId,
ancillaryComment: data.optionalText, ancillaryComment: data.optionalText,
ancillaryDeliveryTime: selectedAncillary?.requiresDeliveryTime ancillaryDeliveryTime: selectedAncillary?.requiresDeliveryTime
? data.deliveryTime ? data.deliveryTime
@@ -175,8 +175,8 @@ export default function AddAncillaryFlowModal({
) )
clearAncillarySessionData() clearAncillarySessionData()
closeModal() closeModal()
utils.booking.get.invalidate({ utils.booking.confirmation.invalidate({
confirmationNumber: booking.confirmationNumber, refId: booking.refId,
}) })
router.refresh() router.refresh()
} else { } else {
@@ -211,7 +211,7 @@ export default function AddAncillaryFlowModal({
} }
: undefined : undefined
guaranteeBooking.mutate({ guaranteeBooking.mutate({
confirmationNumber: booking.confirmationNumber, refId,
language: lang, language: lang,
...(card && { card }), ...(card && { card }),
success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}&ancillary=1`, success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}&ancillary=1`,

View File

@@ -10,12 +10,12 @@ import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
export default function RemoveButton({ export default function RemoveButton({
confirmationNumber, refId,
codes, codes,
title, title,
onSuccess, onSuccess,
}: { }: {
confirmationNumber: string refId: string
codes: string[] codes: string[]
title?: string title?: string
onSuccess: () => void onSuccess: () => void
@@ -51,7 +51,7 @@ export default function RemoveButton({
removePackage.mutate( removePackage.mutate(
{ {
language: lang, language: lang,
confirmationNumber, refId,
codes, codes,
}, },
{ {

View File

@@ -25,6 +25,7 @@ import type {
export function AddedAncillaries({ export function AddedAncillaries({
ancillaries, ancillaries,
booking, booking,
refId,
}: AddedAncillariesProps) { }: AddedAncillariesProps) {
const intl = useIntl() const intl = useIntl()
const router = useRouter() const router = useRouter()
@@ -126,7 +127,7 @@ export function AddedAncillaries({
{booking.confirmationNumber && ancillary.code ? ( {booking.confirmationNumber && ancillary.code ? (
<div className={styles.actions}> <div className={styles.actions}>
<RemoveButton <RemoveButton
confirmationNumber={booking.confirmationNumber} refId={refId}
codes={ codes={
ancillary.code === ancillary.code ===
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
@@ -192,7 +193,7 @@ export function AddedAncillaries({
booking.canModifyAncillaries ? ( booking.canModifyAncillaries ? (
<div className={styles.actions}> <div className={styles.actions}>
<RemoveButton <RemoveButton
confirmationNumber={booking.confirmationNumber} refId={refId}
codes={ codes={
ancillary.code === ancillary.code ===
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST

View File

@@ -20,10 +20,12 @@ import type { Lang } from "@/constants/languages"
export default function GuaranteeAncillaryHandler({ export default function GuaranteeAncillaryHandler({
confirmationNumber, confirmationNumber,
refId,
returnUrl, returnUrl,
lang, lang,
}: { }: {
confirmationNumber: string confirmationNumber: string
refId: string
returnUrl: string returnUrl: string
lang: Lang lang: Lang
}) { }) {
@@ -47,7 +49,7 @@ export default function GuaranteeAncillaryHandler({
addAncillary.mutate( addAncillary.mutate(
{ {
confirmationNumber, refId,
ancillaryComment: formData.optionalText, ancillaryComment: formData.optionalText,
ancillaryDeliveryTime: selectedAncillary.requiresDeliveryTime ancillaryDeliveryTime: selectedAncillary.requiresDeliveryTime
? formData.deliveryTime ? formData.deliveryTime
@@ -86,7 +88,7 @@ export default function GuaranteeAncillaryHandler({
}, },
} }
) )
}, [confirmationNumber, returnUrl, addAncillary, lang, router]) }, [confirmationNumber, refId, returnUrl, addAncillary, lang, router])
return <LoadingSpinner /> return <LoadingSpinner />
} }

View File

@@ -213,7 +213,11 @@ export function Ancillaries({
</> </>
)} )}
<AddedAncillaries booking={booking} ancillaries={uniqueAncillaries} /> <AddedAncillaries
booking={booking}
ancillaries={uniqueAncillaries}
refId={refId}
/>
<AncillaryFlowModalWrapper> <AncillaryFlowModalWrapper>
<AddAncillaryFlowModal <AddAncillaryFlowModal

View File

@@ -64,10 +64,10 @@ export default function Details({ booking, user }: DetailsProps) {
const updateGuest = trpc.booking.update.useMutation({ const updateGuest = trpc.booking.update.useMutation({
onMutate: () => setIsLoading(true), onMutate: () => setIsLoading(true),
onSuccess: (data) => { onSuccess: (refId) => {
if (data) { if (refId) {
utils.booking.get.invalidate({ utils.booking.confirmation.invalidate({
confirmationNumber: data.confirmationNumber, refId,
}) })
toast.success( toast.success(
@@ -99,7 +99,7 @@ export default function Details({ booking, user }: DetailsProps) {
async function onSubmit(data: ModifyContactSchema) { async function onSubmit(data: ModifyContactSchema) {
updateGuest.mutate({ updateGuest.mutate({
confirmationNumber: booking.confirmationNumber, refId: booking.refId,
guest: { guest: {
email: data.email, email: data.email,
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,

View File

@@ -1,4 +1,5 @@
"use client" "use client"
import { useMyStayStore } from "@/stores/my-stay" import { useMyStayStore } from "@/stores/my-stay"
import Details from "./Details" import Details from "./Details"

View File

@@ -1,169 +0,0 @@
import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import ScandicLogoIcon from "@scandic-hotels/design-system/Icons/ScandicLogoIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { dt } from "@/lib/dt"
import {
getAncillaryPackages,
getBookingConfirmation,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import { getIntl } from "@/i18n"
import { decrypt } from "@/utils/encryption"
import AdditionalInfoForm from "../../FindMyBooking/AdditionalInfoForm"
import accessBooking, {
ACCESS_GRANTED,
ERROR_BAD_REQUEST,
ERROR_UNAUTHORIZED,
} from "../accessBooking"
import Footer from "./Footer"
import Specification from "./Specification"
import Total from "./Total"
import styles from "./receipt.module.css"
import { CurrencyEnum } from "@/types/enums/currency"
export async function Receipt({ refId }: { refId: string }) {
const value = decrypt(refId)
if (!value) {
return notFound()
}
const [confirmationNumber, lastName] = value.split(",")
const bookingConfirmation = await getBookingConfirmation(confirmationNumber)
if (!bookingConfirmation) {
return notFound()
}
const { booking, hotel, room } = bookingConfirmation
const user = await getProfileSafely()
const bv = cookies().get("bv")?.value
const intl = await getIntl()
const access = accessBooking(booking.guest, lastName, user, bv)
if (access === ACCESS_GRANTED) {
const ancillaryPackages = await getAncillaryPackages({
fromDate: dt(booking.checkInDate).format("YYYY-MM-DD"),
hotelId: hotel.operaId,
toDate: dt(booking.checkOutDate).format("YYYY-MM-DD"),
})
const currency =
booking.currencyCode !== CurrencyEnum.POINTS
? booking.currencyCode
: (booking.ancillaries.find((a) => a.currency !== CurrencyEnum.POINTS)
?.currency ??
booking.packages.find((p) => p.currency !== CurrencyEnum.POINTS)
?.currency)
return (
<main className={styles.main}>
<div>
<ScandicLogoIcon width="89px" height="19px" color="Icon/Accent" />
<div className={styles.addresses}>
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div>{hotel.name}</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={`${styles.tertiary} ${styles.addressMargin}`}>
{hotel.contactInformation.email}
</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.tertiary}>
{hotel.contactInformation.phoneNumber}
</div>
</Typography>
</div>
<div className={styles.rightColumn}>
<Typography variant="Body/Supporting text (caption)/smRegular">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<div>{`${booking.guest.firstName} ${booking.guest.lastName}`}</div>
</Typography>
{booking.guest.membershipNumber && (
<Typography variant="Body/Supporting text (caption)/smRegular">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<div>{`${intl.formatMessage({
defaultMessage: "Member",
})} ${booking.guest.membershipNumber}`}</div>
</Typography>
)}
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={`${styles.tertiary} ${styles.addressMargin}`}>
{booking.guest.email}
</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.tertiary}>
{booking.guest.phoneNumber}
</div>
</Typography>
</div>
</div>
</div>
<Total booking={booking} currency={currency} />
<Specification
ancillaryPackages={ancillaryPackages}
booking={booking}
currency={currency}
/>
<hr className={styles.divider} />
<Footer booking={booking} room={room} />
</main>
)
}
if (access === ERROR_BAD_REQUEST) {
return (
<main className={styles.main}>
<div className={styles.form}>
<AdditionalInfoForm
// send refId here?
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
</div>
</main>
)
}
if (access === ERROR_UNAUTHORIZED) {
return (
<main className={styles.main}>
<div className={styles.logIn}>
<Typography variant="Title/md">
<h1>
{intl.formatMessage({
defaultMessage: "You need to be logged in to view your booking",
})}
</h1>
</Typography>
<Typography variant="Body/Lead text">
<p>
{intl.formatMessage({
defaultMessage:
"And you need to be logged in with the same member account that made the booking.",
})}
</p>
</Typography>
</div>
</main>
)
}
return notFound()
}

View File

@@ -57,7 +57,7 @@ export default function FinalConfirmation({
) )
} else { } else {
const cancelledRooms = rooms.filter((r) => const cancelledRooms = rooms.filter((r) =>
variables.confirmationNumbers.includes(r.confirmationNumber) variables.refIds.includes(r.refId)
) )
for (const cancelledRoom of cancelledRooms) { for (const cancelledRoom of cancelledRooms) {
toast.success( toast.success(
@@ -93,13 +93,16 @@ export default function FinalConfirmation({
) )
} }
utils.booking.get.invalidate({ utils.booking.confirmation.invalidate({
confirmationNumber: bookedRoom.confirmationNumber, refId: bookedRoom.refId,
})
utils.booking.linkedReservations.invalidate({
lang, lang,
rooms: bookedRoom.linkedReservations,
}) })
utils.booking.linkedReservations.invalidate({
refId: bookedRoom.refId,
lang,
})
closeModal() closeModal()
}, },
onError() { onError() {
@@ -113,13 +116,13 @@ export default function FinalConfirmation({
function cancelBooking() { function cancelBooking() {
if (Array.isArray(formRooms)) { if (Array.isArray(formRooms)) {
const confirmationNumbersToCancel = formRooms const refIdsToCancel = formRooms
.filter((r) => r.checked) .filter((r) => r.checked)
.map((r) => r.confirmationNumber) .map((r) => r.confirmationNumber)
if (confirmationNumbersToCancel.length) { if (refIdsToCancel.length) {
cancelBookingsMutation.mutate({ cancelBookingsMutation.mutate({
confirmationNumbers: confirmationNumbersToCancel, refIds: refIdsToCancel,
language: lang, lang,
}) })
} }
} else { } else {

View File

@@ -54,10 +54,10 @@ export default function Confirmation({
) )
const updateBooking = trpc.booking.update.useMutation({ const updateBooking = trpc.booking.update.useMutation({
onSuccess: (updatedBooking) => { onSuccess: (refId) => {
if (updatedBooking) { if (refId) {
utils.booking.get.invalidate({ utils.booking.confirmation.invalidate({
confirmationNumber: updatedBooking.confirmationNumber, refId,
}) })
toast.success( toast.success(
@@ -86,7 +86,7 @@ export default function Confirmation({
function handleModifyStay() { function handleModifyStay() {
updateBooking.mutate({ updateBooking.mutate({
confirmationNumber: bookedRoom.confirmationNumber, refId: bookedRoom.refId,
checkInDate, checkInDate,
checkOutDate, checkOutDate,
}) })

View File

@@ -60,7 +60,7 @@ export default function Form() {
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}` const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}`
const { guaranteeBooking, isLoading, handleGuaranteeError } = const { guaranteeBooking, isLoading, handleGuaranteeError } =
useGuaranteeBooking(confirmationNumber, false, hotelId) useGuaranteeBooking(refId, false, hotelId)
if (isLoading) { if (isLoading) {
return ( return (
@@ -85,7 +85,7 @@ export default function Form() {
: undefined : undefined
writeGlaToSessionStorage("yes", hotelId) writeGlaToSessionStorage("yes", hotelId)
guaranteeBooking.mutate({ guaranteeBooking.mutate({
confirmationNumber, refId,
language: lang, language: lang,
...(card && { card }), ...(card && { card }),
success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}`, success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}`,

View File

@@ -143,6 +143,7 @@ export function mapRoomDetails({
priceType, priceType,
rate, rate,
rateDefinition: booking.rateDefinition, rateDefinition: booking.rateDefinition,
refId: booking.refId,
reservationStatus: booking.reservationStatus, reservationStatus: booking.reservationStatus,
room, room,
roomName: room?.name ?? "", roomName: room?.name ?? "",

View File

@@ -37,8 +37,6 @@ export enum ChildBedTypeEnum {
export const REDEMPTION = "redemption" export const REDEMPTION = "redemption"
export const SEARCHTYPE = "searchtype" export const SEARCHTYPE = "searchtype"
export const BOOKING_CONFIRMATION_NUMBER = "confirmationNumber"
export const MEMBERSHIP_FAILED_ERROR = "MembershipFailedError" export const MEMBERSHIP_FAILED_ERROR = "MembershipFailedError"
export enum PaymentMethodEnum { export enum PaymentMethodEnum {

View File

@@ -13,7 +13,7 @@ const maxRetries = 15
const retryInterval = 2000 const retryInterval = 2000
export function useGuaranteeBooking( export function useGuaranteeBooking(
confirmationNumber: string, refId: string,
isAncillaryFlow = false, isAncillaryFlow = false,
hotelId: string hotelId: string
) { ) {
@@ -51,10 +51,10 @@ export function useGuaranteeBooking(
onSuccess: (result) => { onSuccess: (result) => {
if (result) { if (result) {
if (result.reservationStatus == BookingStatusEnum.BookingCompleted) { if (result.reservationStatus == BookingStatusEnum.BookingCompleted) {
utils.booking.get.invalidate({ confirmationNumber }) utils.booking.confirmation.invalidate({ refId })
} else { } else {
setIsPollingForBookingStatus(true) setIsPollingForBookingStatus(true)
utils.booking.status.invalidate({ confirmationNumber }) utils.booking.status.invalidate({ refId })
} }
} else { } else {
handleGuaranteeError() handleGuaranteeError()
@@ -66,7 +66,7 @@ export function useGuaranteeBooking(
}) })
const bookingStatus = useHandleBookingStatus({ const bookingStatus = useHandleBookingStatus({
confirmationNumber, refId,
expectedStatuses: [BookingStatusEnum.BookingCompleted], expectedStatuses: [BookingStatusEnum.BookingCompleted],
maxRetries, maxRetries,
retryInterval, retryInterval,
@@ -76,7 +76,7 @@ export function useGuaranteeBooking(
useEffect(() => { useEffect(() => {
if (bookingStatus?.data?.paymentUrl && isPollingForBookingStatus) { if (bookingStatus?.data?.paymentUrl && isPollingForBookingStatus) {
router.push(bookingStatus.data.paymentUrl) router.push(bookingStatus.data.paymentUrl)
utils.booking.get.invalidate({ confirmationNumber }) utils.booking.confirmation.invalidate({ refId })
setIsPollingForBookingStatus(false) setIsPollingForBookingStatus(false)
} else if (bookingStatus.isTimeout) { } else if (bookingStatus.isTimeout) {
handleGuaranteeError("Timeout") handleGuaranteeError("Timeout")
@@ -87,8 +87,8 @@ export function useGuaranteeBooking(
handleGuaranteeError, handleGuaranteeError,
setIsPollingForBookingStatus, setIsPollingForBookingStatus,
isPollingForBookingStatus, isPollingForBookingStatus,
confirmationNumber, refId,
utils.booking.get, utils.booking.confirmation,
]) ])
const isLoading = const isLoading =

View File

@@ -7,13 +7,13 @@ import { trpc } from "@/lib/trpc/client"
import type { BookingStatusEnum } from "@/constants/booking" import type { BookingStatusEnum } from "@/constants/booking"
export function useHandleBookingStatus({ export function useHandleBookingStatus({
confirmationNumber, refId,
expectedStatuses, expectedStatuses,
maxRetries, maxRetries,
retryInterval, retryInterval,
enabled, enabled,
}: { }: {
confirmationNumber: string | null refId: string
expectedStatuses: BookingStatusEnum[] expectedStatuses: BookingStatusEnum[]
maxRetries: number maxRetries: number
retryInterval: number retryInterval: number
@@ -22,7 +22,7 @@ export function useHandleBookingStatus({
const retries = useRef(0) const retries = useRef(0)
const query = trpc.booking.status.useQuery( const query = trpc.booking.status.useQuery(
{ confirmationNumber: confirmationNumber ?? "" }, { refId },
{ {
enabled, enabled,
refetchInterval: (query) => { refetchInterval: (query) => {

View File

@@ -1,3 +1,4 @@
import { getHotel as _getHotel } from "@/server/routers/hotels/utils"
import { isDefined } from "@/server/utils" import { isDefined } from "@/server/utils"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
@@ -17,7 +18,6 @@ import type {
HotelInput, HotelInput,
} from "@/types/trpc/routers/hotel/hotel" } from "@/types/trpc/routers/hotel/hotel"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
import type { LinkedReservationsInput } from "@/server/routers/booking/input"
import type { GetHotelsByCSFilterInput } from "@/server/routers/hotels/input" import type { GetHotelsByCSFilterInput } from "@/server/routers/hotels/input"
import type { GetSavedPaymentCardsInput } from "@/server/routers/user/input" import type { GetSavedPaymentCardsInput } from "@/server/routers/user/input"
@@ -136,14 +136,20 @@ export const getPackages = cache(async function getMemoizedPackages(
}) })
export const getBookingConfirmation = cache( export const getBookingConfirmation = cache(
async function getMemoizedBookingConfirmation(confirmationNumber: string) { async function getMemoizedBookingConfirmation(refId: string, lang: Lang) {
return serverClient().booking.get({ confirmationNumber }) return serverClient().booking.confirmation({
refId,
lang,
})
} }
) )
export const getLinkedReservations = cache( export const getLinkedReservations = cache(
async function getMemoizedLinkedReservations(input: LinkedReservationsInput) { async function getMemoizedLinkedReservations(refId: string, lang: Lang) {
return serverClient().booking.linkedReservations(input) return serverClient().booking.linkedReservations({
refId,
lang,
})
} }
) )

View File

@@ -1,9 +1,10 @@
"use client" "use client"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { use, useRef } from "react" import { use, useRef } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client" import { type RouterOutput, trpc } from "@/lib/trpc/client"
import { createMyStayStore } from "@/stores/my-stay" import { createMyStayStore } from "@/stores/my-stay"
import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton" import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton"
@@ -12,10 +13,7 @@ import { MyStayContext } from "@/contexts/MyStay"
import type { Packages } from "@/types/components/myPages/myStay/ancillaries" import type { Packages } from "@/types/components/myPages/myStay/ancillaries"
import type { MyStayStore } from "@/types/contexts/my-stay" import type { MyStayStore } from "@/types/contexts/my-stay"
import type { RoomCategories } from "@/types/hotel" import type { RoomCategories } from "@/types/hotel"
import type { import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
BookingConfirmation,
BookingConfirmationSchema,
} from "@/types/trpc/routers/booking/confirmation"
import type { CreditCard } from "@/types/user" import type { CreditCard } from "@/types/user"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
@@ -23,7 +21,9 @@ interface MyStayProviderProps {
bookingConfirmation: BookingConfirmation bookingConfirmation: BookingConfirmation
breakfastPackages: Packages | null breakfastPackages: Packages | null
lang: Lang lang: Lang
linkedReservationsPromise: Promise<BookingConfirmationSchema[]> linkedReservationsPromise: Promise<
RouterOutput["booking"]["linkedReservations"]
>
refId: string refId: string
roomCategories: RoomCategories roomCategories: RoomCategories
savedCreditCards: CreditCard[] | null savedCreditCards: CreditCard[] | null
@@ -39,13 +39,14 @@ export default function MyStayProvider({
roomCategories, roomCategories,
savedCreditCards, savedCreditCards,
}: React.PropsWithChildren<MyStayProviderProps>) { }: React.PropsWithChildren<MyStayProviderProps>) {
const storeRef = useRef<MyStayStore>()
const intl = useIntl() const intl = useIntl()
const storeRef = useRef<MyStayStore>()
const { data, error, isFetching, isFetchedAfterMount } = const { data, error, isFetching, isFetchedAfterMount } =
trpc.booking.get.useQuery( trpc.booking.confirmation.useQuery(
{ {
confirmationNumber: bookingConfirmation.booking.confirmationNumber, refId,
lang, lang,
}, },
{ {
@@ -68,7 +69,7 @@ export default function MyStayProvider({
} = trpc.booking.linkedReservations.useQuery( } = trpc.booking.linkedReservations.useQuery(
{ {
lang, lang,
rooms: bookingConfirmation.booking.linkedReservations, refId,
}, },
{ {
initialData: linkedReservationsResponses, initialData: linkedReservationsResponses,
@@ -85,15 +86,16 @@ export default function MyStayProvider({
return notFound() return notFound()
} }
const rooms = [data.booking, ...linkedReservations] const rooms = [data.booking].concat(linkedReservations ?? [])
const hasInvalidatedQueryAndRefetched = const hasInvalidatedQueryAndRefetched =
(isFetchedAfterMount && data) || (isFetchedAfterMount && data) ||
(linkedReservationsIsFetchedAfterMount && linkedReservations) (linkedReservationsIsFetchedAfterMount && linkedReservations)
if (!storeRef.current || hasInvalidatedQueryAndRefetched) { if (!storeRef.current || hasInvalidatedQueryAndRefetched) {
storeRef.current = createMyStayStore({ storeRef.current = createMyStayStore({
breakfastPackages, breakfastPackages,
hotel: bookingConfirmation.hotel, hotel: bookingConfirmation.hotelData.hotel,
intl, intl,
refId, refId,
roomCategories, roomCategories,

View File

@@ -103,7 +103,7 @@ export const createBookingInput = z.object({
}) })
export const addPackageInput = z.object({ export const addPackageInput = z.object({
confirmationNumber: z.string(), refId: z.string(),
ancillaryComment: z.string(), ancillaryComment: z.string(),
ancillaryDeliveryTime: z.string().nullish(), ancillaryDeliveryTime: z.string().nullish(),
packages: z.array( packages: z.array(
@@ -117,22 +117,22 @@ export const addPackageInput = z.object({
}) })
export const removePackageInput = z.object({ export const removePackageInput = z.object({
confirmationNumber: z.string(), refId: z.string(),
codes: z.array(z.string()), codes: z.array(z.string()),
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
}) })
export const priceChangeInput = z.object({ export const priceChangeInput = z.object({
confirmationNumber: z.string(), refId: z.string(),
}) })
export const cancelBookingsInput = z.object({ export const cancelBookingsInput = z.object({
confirmationNumbers: z.array(z.string()), refIds: z.array(z.string()),
language: z.nativeEnum(Lang), lang: z.nativeEnum(Lang),
}) })
export const guaranteeBookingInput = z.object({ export const guaranteeBookingInput = z.object({
confirmationNumber: z.string(), refId: z.string(),
card: z card: z
.object({ .object({
alias: z.string(), alias: z.string(),
@@ -156,7 +156,7 @@ export const createRefIdInput = z.object({
}) })
export const updateBookingInput = z.object({ export const updateBookingInput = z.object({
confirmationNumber: z.string(), refId: z.string(),
checkInDate: z.string().optional(), checkInDate: z.string().optional(),
checkOutDate: z.string().optional(), checkOutDate: z.string().optional(),
guest: z guest: z
@@ -168,22 +168,27 @@ export const updateBookingInput = z.object({
.optional(), .optional(),
}) })
// Query export const bookingConfirmationInput = z.object({
const confirmationNumberInput = z.object({ refId: z.string(),
confirmationNumber: z.string(),
lang: z.nativeEnum(Lang).optional(), lang: z.nativeEnum(Lang).optional(),
}) })
export const getBookingInput = confirmationNumberInput
export const getLinkedReservationsInput = z.object({ export const getLinkedReservationsInput = z.object({
refId: z.string(),
lang: z.nativeEnum(Lang).optional(), lang: z.nativeEnum(Lang).optional(),
rooms: z.array(
z.object({
confirmationNumber: z.string(),
})
),
}) })
export type LinkedReservationsInput = z.input<typeof getLinkedReservationsInput> export type LinkedReservationsInput = z.input<typeof getLinkedReservationsInput>
export const getBookingStatusInput = confirmationNumberInput export const getBookingStatusInput = z.object({
refId: z.string(),
})
export const getBookingConfirmationErrorInput = z.object({
refId: z.string(),
})
export const getConfirmationCompletedInput = z.object({
refId: z.string(),
lang: z.nativeEnum(Lang),
})

View File

@@ -1,8 +1,11 @@
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { getMembershipNumber } from "@/server/routers/user/utils" import { getMembershipNumber } from "@/server/routers/user/utils"
import { createCounter } from "@/server/telemetry" import { createCounter } from "@/server/telemetry"
import { getUserOrServiceToken } from "@/server/tokenManager"
import { router, safeProtectedServiceProcedure } from "@/server/trpc" import { router, safeProtectedServiceProcedure } from "@/server/trpc"
import { parseRefId } from "@/utils/refId"
import { import {
addPackageInput, addPackageInput,
cancelBookingsInput, cancelBookingsInput,
@@ -12,7 +15,7 @@ import {
removePackageInput, removePackageInput,
updateBookingInput, updateBookingInput,
} from "./input" } from "./input"
import { bookingConfirmationSchema, createBookingSchema } from "./output" import { bookingSchema, createBookingSchema } from "./output"
import { cancelBooking } from "./utils" import { cancelBooking } from "./utils"
export const bookingMutationRouter = router({ export const bookingMutationRouter = router({
@@ -73,8 +76,17 @@ export const bookingMutationRouter = router({
}), }),
priceChange: safeProtectedServiceProcedure priceChange: safeProtectedServiceProcedure
.input(priceChangeInput) .input(priceChangeInput)
.use(async ({ input, next }) => {
const { confirmationNumber } = parseRefId(input.refId)
return next({
ctx: {
confirmationNumber,
},
})
})
.mutation(async function ({ ctx, input }) { .mutation(async function ({ ctx, input }) {
const { confirmationNumber } = input const { confirmationNumber } = ctx
const priceChangeCounter = createCounter("trpc.booking", "price-change") const priceChangeCounter = createCounter("trpc.booking", "price-change")
const metricsPriceChange = priceChangeCounter.init({ confirmationNumber }) const metricsPriceChange = priceChangeCounter.init({ confirmationNumber })
@@ -109,17 +121,29 @@ export const bookingMutationRouter = router({
metricsPriceChange.success() metricsPriceChange.success()
return verifiedData.data return verifiedData.data.id
}), }),
cancel: safeProtectedServiceProcedure cancel: safeProtectedServiceProcedure
.input(cancelBookingsInput) .input(cancelBookingsInput)
.use(async ({ input, next }) => {
const confirmationNumbers = input.refIds.map((refId) => {
const { confirmationNumber } = parseRefId(refId)
return confirmationNumber
})
return next({
ctx: {
confirmationNumbers,
},
})
})
.mutation(async function ({ ctx, input }) { .mutation(async function ({ ctx, input }) {
const token = ctx.session?.token.access_token ?? ctx.serviceToken const { confirmationNumbers } = ctx
const { confirmationNumbers, language } = input const { lang } = input
const responses = await Promise.allSettled( const responses = await Promise.allSettled(
confirmationNumbers.map((confirmationNumber) => confirmationNumbers.map((confirmationNumber) =>
cancelBooking(confirmationNumber, language, token) cancelBooking(confirmationNumber, lang)
) )
) )
@@ -144,10 +168,19 @@ export const bookingMutationRouter = router({
}), }),
packages: safeProtectedServiceProcedure packages: safeProtectedServiceProcedure
.input(addPackageInput) .input(addPackageInput)
.use(async ({ input, next }) => {
const { confirmationNumber } = parseRefId(input.refId)
return next({
ctx: {
confirmationNumber,
},
})
})
.mutation(async function ({ ctx, input }) { .mutation(async function ({ ctx, input }) {
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
const { confirmationNumber, ...body } = input const { refId, ...body } = input
const { confirmationNumber } = ctx
const addPackageCounter = createCounter("trpc.booking", "package.add") const addPackageCounter = createCounter("trpc.booking", "package.add")
const metricsAddPackage = addPackageCounter.init({ confirmationNumber }) const metricsAddPackage = addPackageCounter.init({ confirmationNumber })
@@ -183,10 +216,19 @@ export const bookingMutationRouter = router({
}), }),
guarantee: safeProtectedServiceProcedure guarantee: safeProtectedServiceProcedure
.input(guaranteeBookingInput) .input(guaranteeBookingInput)
.use(async ({ input, next }) => {
const { confirmationNumber } = parseRefId(input.refId)
return next({
ctx: {
confirmationNumber,
},
})
})
.mutation(async function ({ ctx, input }) { .mutation(async function ({ ctx, input }) {
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
const { confirmationNumber, language, ...body } = input const { refId, language, ...body } = input
const { confirmationNumber } = ctx
const guaranteeBookingCounter = createCounter("trpc.booking", "guarantee") const guaranteeBookingCounter = createCounter("trpc.booking", "guarantee")
const metricsGuaranteeBooking = guaranteeBookingCounter.init({ const metricsGuaranteeBooking = guaranteeBookingCounter.init({
confirmationNumber, confirmationNumber,
@@ -225,10 +267,16 @@ export const bookingMutationRouter = router({
}), }),
update: safeProtectedServiceProcedure update: safeProtectedServiceProcedure
.input(updateBookingInput) .input(updateBookingInput)
.use(async ({ input, next }) => {
const { confirmationNumber } = parseRefId(input.refId)
return next({
ctx: {
confirmationNumber,
},
})
})
.mutation(async function ({ ctx, input }) { .mutation(async function ({ ctx, input }) {
const accessToken = ctx.session?.token.access_token || ctx.serviceToken const { confirmationNumber } = ctx
const { confirmationNumber, ...body } = input
const updateBookingCounter = createCounter("trpc.booking", "update") const updateBookingCounter = createCounter("trpc.booking", "update")
const metricsUpdateBooking = updateBookingCounter.init({ const metricsUpdateBooking = updateBookingCounter.init({
confirmationNumber, confirmationNumber,
@@ -236,12 +284,17 @@ export const bookingMutationRouter = router({
metricsUpdateBooking.start() metricsUpdateBooking.start()
const token = getUserOrServiceToken()
const apiResponse = await api.put( const apiResponse = await api.put(
api.endpoints.v1.Booking.booking(confirmationNumber), api.endpoints.v1.Booking.booking(confirmationNumber),
{ {
body, body: {
checkInDate: input.checkInDate,
checkOutDate: input.checkOutDate,
guest: input.guest,
},
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${token}`,
}, },
} }
) )
@@ -253,7 +306,7 @@ export const bookingMutationRouter = router({
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const verifiedData = bookingConfirmationSchema.safeParse(apiJson) const verifiedData = bookingSchema.safeParse(apiJson)
if (!verifiedData.success) { if (!verifiedData.success) {
metricsUpdateBooking.validationError(verifiedData.error) metricsUpdateBooking.validationError(verifiedData.error)
return null return null
@@ -261,14 +314,23 @@ export const bookingMutationRouter = router({
metricsUpdateBooking.success() metricsUpdateBooking.success()
return verifiedData.data return verifiedData.data.refId
}), }),
removePackage: safeProtectedServiceProcedure removePackage: safeProtectedServiceProcedure
.input(removePackageInput) .input(removePackageInput)
.use(async ({ input, next }) => {
const { confirmationNumber } = parseRefId(input.refId)
return next({
ctx: {
confirmationNumber,
},
})
})
.mutation(async function ({ ctx, input }) { .mutation(async function ({ ctx, input }) {
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
const { confirmationNumber, codes, language } = input const { codes, language } = input
const { confirmationNumber } = ctx
const removePackageCounter = createCounter( const removePackageCounter = createCounter(
"trpc.booking", "trpc.booking",
"package.remove" "package.remove"

View File

@@ -2,6 +2,7 @@ import { z } from "zod"
import { BookingStatusEnum, ChildBedTypeEnum } from "@/constants/booking" import { BookingStatusEnum, ChildBedTypeEnum } from "@/constants/booking"
import { calculateRefId } from "@/utils/refId"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator" import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import { nullableIntValidator } from "@/utils/zod/numberValidator" import { nullableIntValidator } from "@/utils/zod/numberValidator"
import { import {
@@ -78,7 +79,13 @@ export const createBookingSchema = z
type: d.data.type, type: d.data.type,
reservationStatus: d.data.attributes.reservationStatus, reservationStatus: d.data.attributes.reservationStatus,
paymentUrl: d.data.attributes.paymentUrl, paymentUrl: d.data.attributes.paymentUrl,
rooms: d.data.attributes.rooms, rooms: d.data.attributes.rooms.map((room) => {
const lastName = d.data.attributes.guest?.lastName || ""
return {
...room,
refId: calculateRefId(room.confirmationNumber, lastName),
}
}),
errors: d.data.attributes.errors, errors: d.data.attributes.errors,
guest: d.data.attributes.guest, guest: d.data.attributes.guest,
})) }))
@@ -195,7 +202,7 @@ const linksSchema = z.object({
.nullable(), .nullable(),
}) })
export const bookingConfirmationSchema = z export const bookingSchema = z
.object({ .object({
data: z.object({ data: z.object({
attributes: z.object({ attributes: z.object({
@@ -248,6 +255,19 @@ export const bookingConfirmationSchema = z
}) })
.transform(({ data }) => ({ .transform(({ data }) => ({
...data.attributes, ...data.attributes,
refId: calculateRefId(
data.attributes.confirmationNumber,
data.attributes.guest.lastName
),
linkedReservations: data.attributes.linkedReservations.map(
(linkedReservation) => {
const lastName = data.attributes.guest.lastName
return {
...linkedReservation,
refId: calculateRefId(linkedReservation.confirmationNumber, lastName),
}
}
),
packages: data.attributes.packages.filter((p) => p.type !== "Ancillary"), packages: data.attributes.packages.filter((p) => p.type !== "Ancillary"),
ancillaries: data.attributes.packages.filter((p) => p.type === "Ancillary"), ancillaries: data.attributes.packages.filter((p) => p.type === "Ancillary"),
extraBedTypes: data.attributes.childBedPreferences, extraBedTypes: data.attributes.childBedPreferences,

View File

@@ -1,3 +1,5 @@
import { BookingStatusEnum } from "@/constants/booking"
import { bookingConfirmation } from "@/constants/routes/hotelReservation"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc" import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc"
import { createCounter } from "@/server/telemetry" import { createCounter } from "@/server/telemetry"
@@ -6,39 +8,46 @@ import {
safeProtectedServiceProcedure, safeProtectedServiceProcedure,
serviceProcedure, serviceProcedure,
} from "@/server/trpc" } from "@/server/trpc"
import { getBookedHotelRoom } from "@/stores/my-stay"
import { calculateRefId } from "@/utils/refId" import { calculateRefId, parseRefId } from "@/utils/refId"
import { getHotel } from "../hotels/utils" import { getHotel } from "../hotels/utils"
import { import {
bookingConfirmationInput,
createRefIdInput, createRefIdInput,
getBookingInput, getBookingConfirmationErrorInput,
getBookingStatusInput, getBookingStatusInput,
getConfirmationCompletedInput,
getLinkedReservationsInput, getLinkedReservationsInput,
} from "./input" } from "./input"
import { createBookingSchema } from "./output" import { createBookingSchema } from "./output"
import { getBookedHotelRoom, getBooking } from "./utils" import { getBooking, getLinkedReservations } from "./utils"
import type { BookingSchema } from "@/types/trpc/routers/booking/confirmation"
export const bookingQueryRouter = router({ export const bookingQueryRouter = router({
get: safeProtectedServiceProcedure confirmation: safeProtectedServiceProcedure
.input(getBookingInput) .input(bookingConfirmationInput)
.use(async ({ ctx, input, next }) => { .use(async ({ ctx, input, next }) => {
const lang = input.lang ?? ctx.lang const lang = input.lang ?? ctx.lang
const token = ctx.session?.token.access_token ?? ctx.serviceToken const { confirmationNumber } = parseRefId(input.refId)
return next({ return next({
ctx: { ctx: {
lang, lang,
token, confirmationNumber,
}, },
}) })
}) })
.query(async function ({ ctx, input: { confirmationNumber } }) { .query(async function ({
ctx: { confirmationNumber, lang, serviceToken },
}) {
const getBookingCounter = createCounter("trpc.booking", "get") const getBookingCounter = createCounter("trpc.booking", "get")
const metricsGetBooking = getBookingCounter.init({ confirmationNumber }) const metricsGetBooking = getBookingCounter.init({ confirmationNumber })
metricsGetBooking.start() metricsGetBooking.start()
const booking = await getBooking(confirmationNumber, ctx.lang, ctx.token) const booking = await getBooking(confirmationNumber, lang)
if (!booking) { if (!booking) {
metricsGetBooking.dataError( metricsGetBooking.dataError(
@@ -52,9 +61,9 @@ export const bookingQueryRouter = router({
{ {
hotelId: booking.hotelId, hotelId: booking.hotelId,
isCardOnlyPayment: false, isCardOnlyPayment: false,
language: ctx.lang, language: lang,
}, },
ctx.serviceToken serviceToken
) )
if (!hotelData) { if (!hotelData) {
@@ -68,104 +77,243 @@ export const bookingQueryRouter = router({
throw serverErrorByStatus(404) throw serverErrorByStatus(404)
} }
const room = getBookedHotelRoom(
hotelData.roomCategories,
booking.roomTypeCode
)
if (!room) {
metricsGetBooking.dataError(
`Failed to extract booked room ${booking.roomTypeCode} from room categories for ${booking.hotelId}`,
{
roomTypeCode: booking.roomTypeCode,
hotelId: booking.hotelId,
}
)
throw serverErrorByStatus(404)
}
metricsGetBooking.success() metricsGetBooking.success()
return { return {
...hotelData, hotelData,
booking, booking,
room: getBookedHotelRoom( room,
hotelData.roomCategories,
booking.roomTypeCode
),
} }
}), }),
linkedReservations: safeProtectedServiceProcedure linkedReservations: safeProtectedServiceProcedure
.input(getLinkedReservationsInput) .input(getLinkedReservationsInput)
.use(async ({ ctx, input, next }) => { .use(async ({ ctx, input, next }) => {
const lang = input.lang ?? ctx.lang const lang = input.lang ?? ctx.lang
const token = ctx.session?.token.access_token ?? ctx.serviceToken const { confirmationNumber } = parseRefId(input.refId)
return next({ return next({
ctx: { ctx: {
lang, lang,
token, confirmationNumber,
}, },
}) })
}) })
.query(async function ({ ctx, input: { rooms } }) { .query(async function ({ ctx: { confirmationNumber, lang } }) {
const getLinkedReservationsCounter = createCounter( const linkedReservationsCounter = createCounter(
"trpc.booking", "trpc.booking",
"linkedReservations" "linkedReservations"
) )
const metricsGetLinkedReservations = getLinkedReservationsCounter.init({ const metricsLinkedReservations = linkedReservationsCounter.init({
confirmationNumbers: rooms, confirmationNumber,
}) })
metricsGetLinkedReservations.start() metricsLinkedReservations.start()
const linkedReservationsResult = await Promise.allSettled( const linkedReservations = await getLinkedReservations(
rooms.map((room) => confirmationNumber,
getBooking(room.confirmationNumber, ctx.lang, ctx.token) lang
)
) )
const linkedReservations = []
for (const booking of linkedReservationsResult) { if (!linkedReservations) {
if (booking.status === "fulfilled") { metricsLinkedReservations.noDataError()
if (booking.value) { return null
linkedReservations.push(booking.value) }
} else {
metricsGetLinkedReservations.dataError( const validLinkedReservations = linkedReservations.reduce<
`Unexpected value for linked reservation` BookingSchema[]
) >((acc, linkedReservation) => {
} if ("error" in linkedReservation) {
} else { metricsLinkedReservations.dataError(
metricsGetLinkedReservations.dataError( `Failed to get linked reservations ${linkedReservation.confirmationNumber}`,
`Failed to get linked reservation` {
linkedReservationConfirmationNumber:
linkedReservation.confirmationNumber,
}
) )
return acc
} }
}
metricsGetLinkedReservations.success() acc.push(linkedReservation)
return acc
}, [])
return linkedReservations metricsLinkedReservations.success()
return validLinkedReservations
}), }),
status: serviceProcedure.input(getBookingStatusInput).query(async function ({ status: serviceProcedure
ctx, .input(getBookingStatusInput)
input, .use(async ({ input, next }) => {
}) { const { confirmationNumber } = parseRefId(input.refId)
const { confirmationNumber } = input
const getBookingStatusCounter = createCounter("trpc.booking", "status") return next({
const metricsGetBookingStatus = getBookingStatusCounter.init({ ctx: {
confirmationNumber, confirmationNumber,
})
metricsGetBookingStatus.start()
const apiResponse = await api.get(
api.endpoints.v1.Booking.status(confirmationNumber),
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
}, },
})
})
.query(async function ({ ctx: { confirmationNumber, serviceToken } }) {
const getBookingStatusCounter = createCounter("trpc.booking", "status")
const metricsGetBookingStatus = getBookingStatusCounter.init({
confirmationNumber,
})
metricsGetBookingStatus.start()
const apiResponse = await api.get(
api.endpoints.v1.Booking.status(confirmationNumber),
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
}
)
if (!apiResponse.ok) {
await metricsGetBookingStatus.httpError(apiResponse)
throw serverErrorByStatus(apiResponse.status, apiResponse)
} }
)
if (!apiResponse.ok) { const apiJson = await apiResponse.json()
await metricsGetBookingStatus.httpError(apiResponse) const verifiedData = createBookingSchema.safeParse(apiJson)
throw serverErrorByStatus(apiResponse.status, apiResponse) if (!verifiedData.success) {
} metricsGetBookingStatus.validationError(verifiedData.error)
throw badRequestError()
}
const apiJson = await apiResponse.json() metricsGetBookingStatus.success()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetBookingStatus.validationError(verifiedData.error)
throw badRequestError()
}
metricsGetBookingStatus.success() return verifiedData.data
}),
confirmationCompleted: serviceProcedure
.input(getConfirmationCompletedInput)
.use(async ({ input, next }) => {
const { confirmationNumber } = parseRefId(input.refId)
return next({
ctx: {
confirmationNumber,
},
})
})
.query(async function ({ ctx, input }) {
const { confirmationNumber } = ctx
const confirmationCompletedCounter = createCounter(
"trpc.booking",
"confirmationCompleted"
)
const metricsConfirmationCompleted = confirmationCompletedCounter.init({
confirmationNumber,
})
metricsConfirmationCompleted.start()
const apiResponse = await api.get(
api.endpoints.v1.Booking.status(confirmationNumber),
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
}
)
if (!apiResponse.ok) {
await metricsConfirmationCompleted.httpError(apiResponse)
throw serverErrorByStatus(apiResponse.status, apiResponse)
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsConfirmationCompleted.validationError(verifiedData.error)
throw badRequestError()
}
const confirmationUrl =
verifiedData.data.reservationStatus ===
BookingStatusEnum.BookingCompleted
? `${bookingConfirmation(input.lang)}?RefId=${verifiedData.data.rooms[0].refId}`
: ""
const result = {
...verifiedData.data,
redirectUrl: verifiedData.data.paymentUrl || confirmationUrl,
}
metricsConfirmationCompleted.success()
return result
}),
confirmationError: serviceProcedure
.input(getBookingConfirmationErrorInput)
.use(async ({ input, next }) => {
const { confirmationNumber } = parseRefId(input.refId)
return next({
ctx: {
confirmationNumber,
},
})
})
.query(async function ({ ctx }) {
const { confirmationNumber } = ctx
const confirmationErrorCounter = createCounter(
"trpc.booking",
"confirmationError"
)
const metricsConfirmationError = confirmationErrorCounter.init({
confirmationNumber,
})
metricsConfirmationError.start()
const apiResponse = await api.get(
api.endpoints.v1.Booking.status(confirmationNumber),
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
}
)
if (!apiResponse.ok) {
await metricsConfirmationError.httpError(apiResponse)
throw serverErrorByStatus(apiResponse.status, apiResponse)
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsConfirmationError.validationError(verifiedData.error)
throw badRequestError()
}
metricsConfirmationError.success()
return verifiedData.data
}),
return verifiedData.data
}),
createRefId: serviceProcedure createRefId: serviceProcedure
.input(createRefIdInput) .input(createRefIdInput)
.mutation(async function ({ input }) { .mutation(async function ({ input }) {

View File

@@ -1,101 +1,134 @@
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc" import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc"
import { createCounter } from "@/server/telemetry" import { createCounter } from "@/server/telemetry"
import { getUserOrServiceToken } from "@/server/tokenManager"
import { toApiLang } from "@/server/utils" import { toApiLang } from "@/server/utils"
import { bookingConfirmationSchema, createBookingSchema } from "./output" import { getCacheClient } from "@/services/dataCache"
import type { Room } from "@/types/hotel" import { bookingSchema, createBookingSchema } from "./output"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { BookingSchema } from "@/types/trpc/routers/booking/confirmation"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
export function getBookedHotelRoom( export async function getBooking(confirmationNumber: string, lang: Lang) {
rooms: Room[] | undefined,
roomTypeCode: BookingConfirmation["booking"]["roomTypeCode"]
) {
if (!rooms?.length || !roomTypeCode) {
return null
}
const room = rooms?.find((r) => {
return r.roomTypes.find((roomType) => roomType.code === roomTypeCode)
})
if (!room) {
return null
}
const bedType = room.roomTypes.find(
(roomType) => roomType.code === roomTypeCode
)
if (!bedType) {
return null
}
return {
...room,
bedType,
}
}
export async function getBooking(
confirmationNumber: string,
lang: Lang,
token: string
) {
const getBookingCounter = createCounter("booking", "get") const getBookingCounter = createCounter("booking", "get")
const metricsGetBooking = getBookingCounter.init({ confirmationNumber }) const metricsGetBooking = getBookingCounter.init({ confirmationNumber })
metricsGetBooking.start() metricsGetBooking.start()
const apiResponse = await api.get( const cacheKey = `${lang}:booking:${confirmationNumber}`
api.endpoints.v1.Booking.booking(confirmationNumber), const cache = await getCacheClient()
{
headers: { const result: BookingSchema | null = await cache.cacheOrGet(
Authorization: `Bearer ${token}`, cacheKey,
}, async () => {
const token = getUserOrServiceToken()
const apiResponse = await api.get(
api.endpoints.v1.Booking.booking(confirmationNumber),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
{ language: toApiLang(lang) }
)
if (!apiResponse.ok) {
await metricsGetBooking.httpError(apiResponse)
// If the booking is not found, return null.
// This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them.
if (apiResponse.status === 400) {
return null
}
throw serverErrorByStatus(apiResponse.status, apiResponse)
}
const apiJson = await apiResponse.json()
const booking = bookingSchema.safeParse(apiJson)
if (!booking.success) {
metricsGetBooking.validationError(booking.error)
throw badRequestError()
}
return booking.data
}, },
{ language: toApiLang(lang) } "1h"
) )
if (!apiResponse.ok) {
await metricsGetBooking.httpError(apiResponse)
// If the booking is not found, return null.
// This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them.
if (apiResponse.status === 400) {
return null
}
throw serverErrorByStatus(apiResponse.status, apiResponse)
}
const apiJson = await apiResponse.json()
const booking = bookingConfirmationSchema.safeParse(apiJson)
if (!booking.success) {
metricsGetBooking.validationError(booking.error)
throw badRequestError()
}
metricsGetBooking.success() metricsGetBooking.success()
return booking.data return result
} }
export async function cancelBooking( export async function getBookings(confirmationNumbers: string[], lang: Lang) {
const results = await Promise.allSettled(
confirmationNumbers.map((confirmationNumber) => {
return getBooking(confirmationNumber, lang)
})
)
return results.map((result) => {
if (result.status === "fulfilled" && result.value) {
return result.value
}
return null
})
}
export async function getLinkedReservations(
confirmationNumber: string, confirmationNumber: string,
language: Lang, lang: Lang
token: string
) { ) {
const booking = await getBooking(confirmationNumber, lang)
if (!booking) {
return null
}
if (booking.linkedReservations.length > 0) {
const confirmationNumbers = booking.linkedReservations.map(
(linkedReservation) => {
return linkedReservation.confirmationNumber
}
)
const bookings = await getBookings(confirmationNumbers, lang)
const linkedReservations = bookings.map((booking, i) => {
if (booking === null) {
return {
confirmationNumber: confirmationNumbers[i],
error: true,
} as const
}
return booking
})
return linkedReservations
}
return []
}
export async function cancelBooking(confirmationNumber: string, lang: Lang) {
const cancelBookingCounter = createCounter("booking", "cancel") const cancelBookingCounter = createCounter("booking", "cancel")
const metricsCancelBooking = cancelBookingCounter.init({ const metricsCancelBooking = cancelBookingCounter.init({
confirmationNumber, confirmationNumber,
language, lang,
}) })
metricsCancelBooking.start() metricsCancelBooking.start()
const token = getUserOrServiceToken()
const headers = { const headers = {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
} }
const booking = await getBooking(confirmationNumber, language, token) const booking = await getBooking(confirmationNumber, lang)
if (!booking) { if (!booking) {
metricsCancelBooking.noDataError({ confirmationNumber }) metricsCancelBooking.noDataError({ confirmationNumber })
return null return null
@@ -107,7 +140,7 @@ export async function cancelBooking(
headers, headers,
body: { firstName, lastName, email }, body: { firstName, lastName, email },
}, },
{ language: toApiLang(language) } { language: toApiLang(lang) }
) )
if (!apiResponse.ok) { if (!apiResponse.ok) {

View File

@@ -3,7 +3,9 @@ import { trace, type Tracer } from "@opentelemetry/api"
import { env } from "@/env/server" import { env } from "@/env/server"
import { createCounter } from "@/server/telemetry" import { createCounter } from "@/server/telemetry"
import { auth } from "@/auth"
import { getCacheClient } from "@/services/dataCache" import { getCacheClient } from "@/services/dataCache"
import { isValidSession } from "@/utils/session"
import type { ServiceTokenResponse } from "@/types/tokens" import type { ServiceTokenResponse } from "@/types/tokens"
@@ -117,3 +119,12 @@ async function fetchServiceToken(scopes: string[]) {
function getServiceTokenCacheKey(scopes: string[]): string { function getServiceTokenCacheKey(scopes: string[]): string {
return `serviceToken:${scopes.join(",")}` return `serviceToken:${scopes.join(",")}`
} }
export async function getUserOrServiceToken() {
const serviceToken = await getServiceToken()
const session = await auth()
return isValidSession(session)
? session.token.access_token
: serviceToken.access_token
}

View File

@@ -3,8 +3,6 @@ import { produce } from "immer"
import { useContext } from "react" import { useContext } from "react"
import { create, useStore } from "zustand" import { create, useStore } from "zustand"
import { getBookedHotelRoom } from "@/server/routers/booking/utils"
import { mapRoomDetails } from "@/components/HotelReservation/MyStay/utils/mapRoomDetails" import { mapRoomDetails } from "@/components/HotelReservation/MyStay/utils/mapRoomDetails"
import { MyStayContext } from "@/contexts/MyStay" import { MyStayContext } from "@/contexts/MyStay"
@@ -14,7 +12,34 @@ import {
isAllRoomsCancelled, isAllRoomsCancelled,
} from "./helpers" } from "./helpers"
import type { Room } from "@/types/hotel"
import type { InitialState, MyStayState } from "@/types/stores/my-stay" import type { InitialState, MyStayState } from "@/types/stores/my-stay"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export function getBookedHotelRoom(
rooms: Room[] | undefined,
roomTypeCode: BookingConfirmation["booking"]["roomTypeCode"]
) {
if (!rooms?.length || !roomTypeCode) {
return null
}
const room = rooms?.find((r) => {
return r.roomTypes.find((roomType) => roomType.code === roomTypeCode)
})
if (!room) {
return null
}
const bedType = room.roomTypes.find(
(roomType) => roomType.code === roomTypeCode
)
if (!bedType) {
return null
}
return {
...room,
bedType,
}
}
export function createMyStayStore({ export function createMyStayStore({
breakfastPackages, breakfastPackages,

View File

@@ -1,12 +1,8 @@
import type { EventAttributes } from "ics" import type { EventAttributes } from "ics"
import type { RouterOutput } from "@/lib/trpc/client"
export interface AddToCalendarProps { export interface AddToCalendarProps {
checkInDate: NonNullable< checkInDate: Date
RouterOutput["booking"]["get"]
>["booking"]["checkInDate"]
event: EventAttributes event: EventAttributes
hotelName: NonNullable<RouterOutput["booking"]["get"]>["hotel"]["name"] hotelName: string
renderButton: (onPress: () => Promise<void>) => React.ReactNode renderButton: (onPress: () => Promise<void>) => React.ReactNode
} }

View File

@@ -1,3 +1,3 @@
export interface ManageBookingProps { export interface ManageBookingProps {
bookingUrl: string refId: string
} }

View File

@@ -1,23 +1,19 @@
import type { Room } from "@/types/hotel" import type { Room } from "@/types/hotel"
import type { import type {
BookingConfirmation, BookingConfirmation,
BookingConfirmationSchema, BookingSchema,
} from "@/types/trpc/routers/booking/confirmation" } from "@/types/trpc/routers/booking/confirmation"
export interface BookingConfirmationProps {
confirmationNumber: string
}
export interface BookingConfirmationRoom extends Room { export interface BookingConfirmationRoom extends Room {
bedType: Room["roomTypes"][number] bedType: Room["roomTypes"][number]
} }
export interface ConfirmationProps export interface ConfirmationProps
extends Pick<BookingConfirmation, "booking" | "hotel"> { extends Pick<BookingConfirmation, "booking" | "hotelData"> {
room: BookingConfirmationRoom room: BookingConfirmationRoom
refId: string refId: string
} }
export interface BookingConfirmationAlertsProps { export interface BookingConfirmationAlertsProps {
booking: BookingConfirmationSchema booking: BookingSchema
} }

View File

@@ -1,9 +1,7 @@
import type { MutableRefObject } from "react"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export interface BookingConfirmationHeaderProps export interface BookingConfirmationHeaderProps {
extends Pick<BookingConfirmation, "booking" | "hotel"> { booking: BookingConfirmation["booking"]
mainRef: MutableRefObject<HTMLElement | null> hotel: BookingConfirmation["hotelData"]["hotel"]
refId: string refId: string
} }

View File

@@ -1,5 +1,5 @@
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export interface BookingConfirmationHotelDetailsProps { export interface BookingConfirmationHotelDetailsProps {
hotel: BookingConfirmation["hotel"] hotel: BookingConfirmation["hotelData"]["hotel"]
} }

View File

@@ -1,11 +1,5 @@
import type { z } from "zod"
import type { Room } from "@/types/hotel" import type { Room } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { linkedReservationSchema } from "@/server/routers/booking/output"
export interface LinkedReservationSchema
extends z.output<typeof linkedReservationSchema> {}
export interface BookingConfirmationRoomsProps export interface BookingConfirmationRoomsProps
extends Pick<BookingConfirmation, "booking"> { extends Pick<BookingConfirmation, "booking"> {
@@ -14,5 +8,4 @@ export interface BookingConfirmationRoomsProps
} }
checkInTime: string checkInTime: string
checkOutTime: string checkOutTime: string
linkedReservations: LinkedReservationSchema[]
} }

View File

@@ -1,7 +1,7 @@
export interface LinkedReservationProps { export interface LinkedReservationProps {
checkInTime: string checkInTime: string
checkOutTime: string checkOutTime: string
confirmationNumber: string refId: string
roomIndex: number roomIndex: number
} }

View File

@@ -23,6 +23,7 @@ export interface AncillariesProps extends Pick<BookingConfirmation, "booking"> {
export interface AddedAncillariesProps { export interface AddedAncillariesProps {
ancillaries: Ancillary["ancillaryContent"][number][] | null ancillaries: Ancillary["ancillaryContent"][number][] | null
booking: BookingConfirmation["booking"] booking: BookingConfirmation["booking"]
refId: string
} }
export interface AncillaryProps { export interface AncillaryProps {

View File

@@ -30,6 +30,7 @@ export type Room = Pick<
| "linkedReservations" | "linkedReservations"
| "multiRoom" | "multiRoom"
| "rateDefinition" | "rateDefinition"
| "refId"
| "reservationStatus" | "reservationStatus"
| "roomPoints" | "roomPoints"
| "roomTypeCode" | "roomTypeCode"

View File

@@ -2,20 +2,20 @@ import type { z } from "zod"
import type { HotelData, Room } from "@/types/hotel" import type { HotelData, Room } from "@/types/hotel"
import type { import type {
bookingConfirmationSchema, bookingSchema,
packageSchema, packageSchema,
} from "@/server/routers/booking/output" } from "@/server/routers/booking/output"
export interface BookingConfirmationSchema export interface BookingSchema extends z.output<typeof bookingSchema> {}
extends z.output<typeof bookingConfirmationSchema> {}
export interface PackageSchema extends z.output<typeof packageSchema> {} export interface PackageSchema extends z.output<typeof packageSchema> {}
export interface BookingConfirmation extends HotelData { export interface BookingConfirmationRoom extends Room {
booking: BookingConfirmationSchema bedType: Room["roomTypes"][number]
room: }
| (Room & {
bedType: Room["roomTypes"][number] export interface BookingConfirmation {
}) booking: BookingSchema
| null hotelData: HotelData
room: BookingConfirmationRoom
} }