Merge master

This commit is contained in:
Linus Flood
2025-05-02 14:01:50 +02:00
116 changed files with 2531 additions and 1682 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

@@ -0,0 +1,29 @@
import {
BookingErrorCodeEnum,
PaymentCallbackStatusEnum,
} from "@/constants/booking"
import { details } from "@/constants/routes/hotelReservation"
import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleErrorCallback"
import type { LangParams, PageArgs } from "@/types/params"
export default async function PaymentCallbackCancelPage({
params,
}: PageArgs<LangParams>) {
console.log(`[payment-callback] cancel callback started`)
const lang = params.lang
const returnUrl = details(lang)
const searchObject = new URLSearchParams()
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionCancelled)
return (
<HandleErrorCallback
returnUrl={returnUrl.toString()}
searchObject={searchObject}
status={PaymentCallbackStatusEnum.Cancel}
/>
)
}

View File

@@ -0,0 +1,71 @@
import {
BookingErrorCodeEnum,
PaymentCallbackStatusEnum,
} from "@/constants/booking"
import { details } from "@/constants/routes/hotelReservation"
import { serverClient } from "@/lib/trpc/server"
import HandleErrorCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/HandleErrorCallback"
import { calculateRefId } from "@/utils/refId"
import type { LangParams, PageArgs } from "@/types/params"
export default async function PaymentCallbackErrorPage({
params,
searchParams,
}: PageArgs<
LangParams,
{
confirmationNumber?: string
}
>) {
console.log(`[payment-callback] error callback started`)
const lang = params.lang
const confirmationNumber = searchParams.confirmationNumber
const returnUrl = details(lang)
const searchObject = new URLSearchParams()
let errorMessage = undefined
if (confirmationNumber) {
const refId = calculateRefId(confirmationNumber, "")
try {
const bookingStatus = await serverClient().booking.confirmationError({
refId,
})
// 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}`
searchObject.set(
"errorCode",
error
? error.errorCode.toString()
: BookingErrorCodeEnum.TransactionFailed
)
} catch {
console.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}`
)
searchObject.set("errorCode", BookingErrorCodeEnum.TransactionFailed)
errorMessage = `Failed to get booking status for ${confirmationNumber}`
}
}
return (
<HandleErrorCallback
returnUrl={returnUrl.toString()}
searchObject={searchObject}
status={PaymentCallbackStatusEnum.Error}
errorMessage={errorMessage}
/>
)
}

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

@@ -0,0 +1,44 @@
import { notFound } from "next/navigation"
import { bookingConfirmation } from "@/constants/routes/hotelReservation"
import { createCounter } from "@/server/telemetry"
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"
export default async function PaymentCallbackSuccessPage({
params,
searchParams,
}: PageArgs<
LangParams,
{
confirmationNumber?: string
}
>) {
const confirmationNumber = searchParams.confirmationNumber
setLang(params.lang)
if (!confirmationNumber) {
notFound()
}
const paymentSuccessCounter = createCounter("payment", "success")
const metricsPaymentSuccess = paymentSuccessCounter.init({
confirmationNumber,
})
metricsPaymentSuccess.start()
const refId = calculateRefId(confirmationNumber, "")
return (
<HandleSuccessCallback
refId={refId}
successRedirectUrl={bookingConfirmation(params.lang)}
/>
)
}

View File

@@ -1,6 +1,7 @@
import { notFound, redirect } from "next/navigation" import { notFound, redirect } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { BookingErrorCodeEnum } from "@/constants/booking"
import { selectRate } from "@/constants/routes/hotelReservation" import { selectRate } from "@/constants/routes/hotelReservation"
import { import {
getBreakfastPackages, getBreakfastPackages,
@@ -16,8 +17,6 @@ import RoomOne from "@/components/HotelReservation/EnterDetails/Room/One"
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop" import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile" import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
import EnterDetailsTrackingWrapper from "@/components/HotelReservation/EnterDetails/Tracking" import EnterDetailsTrackingWrapper from "@/components/HotelReservation/EnterDetails/Tracking"
import Alert from "@/components/TempDesignSystem/Alert"
import { getIntl } from "@/i18n"
import RoomProvider from "@/providers/Details/RoomProvider" import RoomProvider from "@/providers/Details/RoomProvider"
import EnterDetailsProvider from "@/providers/EnterDetailsProvider" import EnterDetailsProvider from "@/providers/EnterDetailsProvider"
import { convertSearchParamsToObj } from "@/utils/url" import { convertSearchParamsToObj } from "@/utils/url"
@@ -25,7 +24,6 @@ import { convertSearchParamsToObj } from "@/utils/url"
import styles from "./page.module.css" import styles from "./page.module.css"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { AlertTypeEnum } from "@/types/enums/alert"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
import type { Room } from "@/types/providers/details/room" import type { Room } from "@/types/providers/details/room"
@@ -71,6 +69,7 @@ export default async function DetailsPage({
// (possibly also add an error case to url?) // (possibly also add an error case to url?)
// ------------------------------------------------------- // -------------------------------------------------------
// redirect back to select-rate if availability call fails // redirect back to select-rate if availability call fails
selectRoomParams.set("errorCode", BookingErrorCodeEnum.AvailabilityError)
redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`) redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`)
} }
@@ -94,12 +93,9 @@ export default async function DetailsPage({
hotel.merchantInformationData.alternatePaymentOptions = [] hotel.merchantInformationData.alternatePaymentOptions = []
} }
const intl = await getIntl()
const firstRoom = rooms[0] const firstRoom = rooms[0]
const multirooms = rooms.slice(1) const multirooms = rooms.slice(1)
const isRoomNotAvailable = rooms.some((room) => !room.isAvailable)
return ( return (
<EnterDetailsProvider <EnterDetailsProvider
booking={booking} booking={booking}
@@ -112,26 +108,6 @@ export default async function DetailsPage({
<main> <main>
<HotelHeader hotelData={hotelData} /> <HotelHeader hotelData={hotelData} />
<div className={styles.container}> <div className={styles.container}>
{isRoomNotAvailable && (
<Alert
type={AlertTypeEnum.Alarm}
variant="inline"
heading={intl.formatMessage({
defaultMessage: "Room sold out",
})}
text={intl.formatMessage({
defaultMessage:
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.",
})}
link={{
title: intl.formatMessage({
defaultMessage: "Change room",
}),
url: `${selectRate(lang)}?${selectRoomParams.toString()}`,
keepSearchParams: true,
}}
/>
)}
<div className={styles.content}> <div className={styles.content}>
<RoomProvider idx={0} room={firstRoom}> <RoomProvider idx={0} room={firstRoom}>
<RoomOne user={user} /> <RoomOne user={user} />

View File

@@ -14,7 +14,6 @@ import {
getProfileSafely, getProfileSafely,
getSavedPaymentCardsSafely, getSavedPaymentCardsSafely,
} from "@/lib/trpc/memoizedRequests" } from "@/lib/trpc/memoizedRequests"
import { decrypt } from "@/server/routers/utils/encryption"
import { auth } from "@/auth" import { auth } from "@/auth"
import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm" import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm"
@@ -34,6 +33,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 { isValidSession } from "@/utils/session" import { isValidSession } from "@/utils/session"
import { getCurrentWebUrl } from "@/utils/url" import { getCurrentWebUrl } from "@/utils/url"
@@ -47,25 +47,20 @@ 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)
if (!value) {
return notFound()
}
const session = await auth() const session = await auth()
const isLoggedIn = isValidSession(session) const isLoggedIn = isValidSession(session)
const { confirmationNumber, lastName } = parseRefId(refId)
const [confirmationNumber, lastName] = value.split(",")
const bv = cookies().get("bv")?.value const bv = cookies().get("bv")?.value
let bookingConfirmation let bookingConfirmation
if (isLoggedIn) { if (isLoggedIn) {
bookingConfirmation = await getBookingConfirmation(confirmationNumber) bookingConfirmation = await getBookingConfirmation(refId, params.lang)
} else if (bv) { } else if (bv) {
const params = new URLSearchParams(bv) const params = new URLSearchParams(bv)
const firstName = params.get("firstName") const firstName = params.get("firstName")
@@ -79,27 +74,18 @@ export default async function MyStay({
email email
) )
} else { } else {
return ( return <RenderAdditionalInfoForm refId={refId} lastName={lastName} />
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
} }
} else { } else {
return ( return <RenderAdditionalInfoForm refId={refId} lastName={lastName} />
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
} }
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()
@@ -112,9 +98,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,
@@ -159,7 +143,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"
@@ -176,7 +160,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}>
@@ -235,10 +219,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>
) )
@@ -272,19 +253,16 @@ export default async function MyStay({
} }
function RenderAdditionalInfoForm({ function RenderAdditionalInfoForm({
confirmationNumber, refId,
lastName, lastName,
}: { }: {
confirmationNumber: string refId: string
lastName: string lastName: string
}) { }) {
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,215 @@
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 {
findBooking,
getAncillaryPackages,
getBookingConfirmation,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import { auth } from "@/auth"
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 { isValidSession } from "@/utils/session"
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()
} }
const session = await auth()
const isLoggedIn = isValidSession(session)
const { confirmationNumber, lastName } = parseRefId(refId)
const bv = cookies().get("bv")?.value
let bookingConfirmation
if (isLoggedIn) {
bookingConfirmation = await getBookingConfirmation(refId, params.lang)
} else if (bv) {
const params = new URLSearchParams(bv)
const firstName = params.get("firstName")
const email = params.get("email")
if (firstName && email) {
bookingConfirmation = await findBooking(
confirmationNumber,
lastName,
firstName,
email
)
} else {
return <RenderAdditionalInfoForm refId={refId} lastName={lastName} />
}
} else {
return <RenderAdditionalInfoForm refId={refId} lastName={lastName} />
}
if (!bookingConfirmation) {
return notFound()
}
const { booking, hotelData, room } = bookingConfirmation
const { hotel } = hotelData
const intl = await getIntl()
const user = await getProfileSafely()
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()
}
function RenderAdditionalInfoForm({
refId,
lastName,
}: {
refId: string
lastName: string
}) {
return ( return (
<Suspense fallback={<MyStaySkeleton />}> <main className={styles.main}>
<Receipt refId={searchParams.RefId} /> <div className={styles.form}>
</Suspense> <AdditionalInfoForm refId={refId} lastName={lastName} />
</div>
</main>
) )
} }

View File

@@ -14,7 +14,6 @@ import {
getProfileSafely, getProfileSafely,
getSavedPaymentCardsSafely, getSavedPaymentCardsSafely,
} from "@/lib/trpc/memoizedRequests" } from "@/lib/trpc/memoizedRequests"
import { decrypt } from "@/server/routers/utils/encryption"
import { auth } from "@/auth" import { auth } from "@/auth"
import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm" import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm"
@@ -34,6 +33,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 { isValidSession } from "@/utils/session" import { isValidSession } from "@/utils/session"
import { getCurrentWebUrl } from "@/utils/url" import { getCurrentWebUrl } from "@/utils/url"
@@ -47,24 +47,21 @@ 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)
if (!value) {
return notFound()
}
const session = await auth() const session = await auth()
const isLoggedIn = isValidSession(session) const isLoggedIn = isValidSession(session)
const { confirmationNumber, lastName } = parseRefId(refId)
const [confirmationNumber, lastName] = value.split(",")
const bv = cookies().get("bv")?.value const bv = cookies().get("bv")?.value
let bookingConfirmation let bookingConfirmation
if (isLoggedIn) { if (isLoggedIn) {
bookingConfirmation = await getBookingConfirmation(confirmationNumber) bookingConfirmation = await getBookingConfirmation(refId, params.lang)
} else if (bv) { } else if (bv) {
const params = new URLSearchParams(bv) const params = new URLSearchParams(bv)
const firstName = params.get("firstName") const firstName = params.get("firstName")
@@ -78,26 +75,17 @@ export default async function MyStay({
email email
) )
} else { } else {
return ( return <RenderAdditionalInfoForm refId={refId} lastName={lastName} />
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
} }
} else { } else {
return ( return <RenderAdditionalInfoForm refId={refId} lastName={lastName} />
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
} }
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 intl = await getIntl() const intl = await getIntl()
@@ -109,9 +97,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,
@@ -133,9 +119,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)
@@ -147,7 +133,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(
@@ -156,7 +142,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"
@@ -173,7 +159,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}>
@@ -232,10 +218,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>
) )
@@ -269,19 +252,16 @@ export default async function MyStay({
} }
function RenderAdditionalInfoForm({ function RenderAdditionalInfoForm({
confirmationNumber, refId,
lastName, lastName,
}: { }: {
confirmationNumber: string refId: string
lastName: string lastName: string
}) { }) {
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,7 +1,7 @@
"use client" "use client"
import { useCallback, useEffect, useRef } from "react" import { useCallback, useEffect, useRef } from "react"
import { Button as AriaButton } from "react-aria-components" import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import HotelLogoIcon from "@scandic-hotels/design-system/Icons/HotelLogoIcon" import HotelLogoIcon from "@scandic-hotels/design-system/Icons/HotelLogoIcon"
@@ -94,12 +94,12 @@ export default function HotelListItem(data: DestinationPagesHotelData) {
<Typography variant="Body/Supporting text (caption)/smRegular"> <Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.captions}> <div className={styles.captions}>
<Typography variant="Link/sm"> <Typography variant="Link/sm">
<AriaButton <ButtonRAC
className={styles.addressButton} className={styles.addressButton}
onPress={() => setActiveMarker(hotel.id)} onPress={() => setActiveMarker(hotel.id)}
> >
{address} {address}
</AriaButton> </ButtonRAC>
</Typography> </Typography>
<Divider variant="vertical" color="beige" /> <Divider variant="vertical" color="beige" />
<p> <p>

View File

@@ -2,7 +2,7 @@
import { useState } from "react" import { useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button" import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -48,9 +48,9 @@ export default function HotelMapCard({
return ( return (
<article className={className}> <article className={className}>
<div className={styles.wrapper}> <div className={styles.wrapper}>
<Button <IconButton
size="Medium" theme="Black"
variant="Icon" style="Muted"
className={styles.closeButton} className={styles.closeButton}
onPress={handleClose} onPress={handleClose}
aria-label={intl.formatMessage({ aria-label={intl.formatMessage({
@@ -63,7 +63,7 @@ export default function HotelMapCard({
className={styles.closeIcon} className={styles.closeIcon}
color="CurrentColor" color="CurrentColor"
/> />
</Button> </IconButton>
{image ? ( {image ? (
<DialogImage <DialogImage
image={image.src} image={image.src}

View File

@@ -3,7 +3,7 @@
import { useMap } from "@vis.gl/react-google-maps" import { useMap } from "@vis.gl/react-google-maps"
import { cx } from "class-variance-authority" import { cx } from "class-variance-authority"
import { useState } from "react" import { useState } from "react"
import { Button as AriaButton } from "react-aria-components" import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -136,12 +136,12 @@ export default function Sidebar({
}`} }`}
> >
<Typography variant="Body/Paragraph/mdBold"> <Typography variant="Body/Paragraph/mdBold">
<AriaButton <ButtonRAC
className={styles.sidebarToggle} className={styles.sidebarToggle}
onPress={toggleFullScreenSidebar} onPress={toggleFullScreenSidebar}
> >
{isFullScreenSidebar ? viewAsMapMsg : viewAsListMsg} {isFullScreenSidebar ? viewAsMapMsg : viewAsListMsg}
</AriaButton> </ButtonRAC>
</Typography> </Typography>
<div className={styles.sidebarContent}> <div className={styles.sidebarContent}>
<Typography variant="Title/sm"> <Typography variant="Title/sm">
@@ -168,7 +168,7 @@ export default function Sidebar({
{pois.map((poi) => ( {pois.map((poi) => (
<li key={poi.name} className={styles.poiItem}> <li key={poi.name} className={styles.poiItem}>
<Typography variant="Body/Paragraph/mdRegular"> <Typography variant="Body/Paragraph/mdRegular">
<AriaButton <ButtonRAC
className={cx(styles.poiButton, { className={cx(styles.poiButton, {
[styles.active]: activePoi === poi.name, [styles.active]: activePoi === poi.name,
})} })}
@@ -188,7 +188,7 @@ export default function Sidebar({
} }
)} )}
</span> </span>
</AriaButton> </ButtonRAC>
</Typography> </Typography>
</li> </li>
))} ))}

View File

@@ -1,11 +1,13 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import Image from "@/components/Image" import Image from "@/components/Image"
import Lightbox from "@/components/Lightbox/" import Lightbox from "@/components/Lightbox/"
import Button from "@/components/TempDesignSystem/Button"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery" import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import styles from "./previewImages.module.css" import styles from "./previewImages.module.css"
@@ -17,31 +19,52 @@ export default function PreviewImages({
hotelName, hotelName,
}: PreviewImagesProps) { }: PreviewImagesProps) {
const intl = useIntl() const intl = useIntl()
const [lightboxIsOpen, setLightboxIsOpen] = useState(false) const [lightboxState, setLightboxState] = useState({
activeIndex: 0,
isOpen: false,
})
const lightboxImages = mapApiImagesToGalleryImages(images) const lightboxImages = mapApiImagesToGalleryImages(images)
return ( return (
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
{images.slice(0, 3).map((image, index) => ( {lightboxImages.slice(0, 3).map((image, index) => (
<Image <ButtonRAC
key={index} key={image.src}
src={image.imageSizes.medium} className={styles.imageButton}
alt={image.metaData.altText} aria-label={intl.formatMessage({
title={image.metaData.title} defaultMessage: "See all photos",
width={index === 0 ? 752 : 292} })}
height={index === 0 ? 540 : 266} onPress={() =>
onClick={() => setLightboxIsOpen(true)} setLightboxState({
className={styles.image} activeIndex: index,
/> isOpen: true,
})
}
>
<Image
src={image.src}
alt={image.alt}
title={image.caption || ""}
width={index === 0 ? 752 : 292}
height={index === 0 ? 540 : 266}
className={styles.image}
/>
</ButtonRAC>
))} ))}
{images.length > 1 && ( {images.length > 1 && (
<> <>
<Button <Button
theme="base" variant="Primary"
intent="inverted" color="Inverted"
size="small" size="Small"
onClick={() => setLightboxIsOpen(true)} onPress={() =>
setLightboxState({
activeIndex: 0,
isOpen: true,
})
}
typography="Body/Supporting text (caption)/smBold"
className={styles.seeAllButton} className={styles.seeAllButton}
> >
{intl.formatMessage({ {intl.formatMessage({
@@ -56,8 +79,9 @@ export default function PreviewImages({
}, },
{ title: hotelName } { title: hotelName }
)} )}
isOpen={lightboxIsOpen} isOpen={lightboxState.isOpen}
onClose={() => setLightboxIsOpen(false)} activeIndex={lightboxState.activeIndex}
onClose={() => setLightboxState({ activeIndex: 0, isOpen: false })}
/> />
</> </>
)} )}

View File

@@ -3,18 +3,25 @@
gap: 8px; gap: 8px;
position: relative; position: relative;
width: 100%; width: 100%;
padding: 0 var(--Spacing-x2);
z-index: 0; z-index: 0;
max-width: var(--max-width-page);
margin: 0 auto;
}
.imageButton {
padding: 0;
border-width: 0;
background-color: transparent;
cursor: pointer;
border-radius: var(--Corner-radius-Small);
overflow: hidden;
} }
.image { .image {
object-fit: cover; object-fit: cover;
border-radius: var(--Corner-radius-Small);
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 30vh; max-height: 30vh;
cursor: pointer;
max-width: var(--max-width-page);
margin: 0 auto; margin: 0 auto;
} }

View File

@@ -104,25 +104,32 @@ export default async function RoomSidePeek({
</Typography> </Typography>
<ul className={styles.facilityList}> <ul className={styles.facilityList}>
{room.roomFacilities {room.roomFacilities
.filter((facility) => !!facility.isUniqueSellingPoint)
.sort((a, b) => a.sortOrder - b.sortOrder) .sort((a, b) => a.sortOrder - b.sortOrder)
.map((facility) => { .map((facility) => {
const Icon = ( const facilityName = facility.availableInAllRooms
<FacilityIcon ? facility.name
name={facility.icon} : intl.formatMessage(
size={24} {
color="Icon/Default" defaultMessage: "{facility} (available in some rooms)",
/> },
) {
facility: facility.name,
}
)
return ( return (
<li className={styles.listItem} key={facility.name}> <li className={styles.listItem} key={facility.name}>
{Icon && Icon} <FacilityIcon
name={facility.icon}
size={24}
color="Icon/Default"
/>
<Typography <Typography
variant="Body/Paragraph/mdRegular" variant="Body/Paragraph/mdRegular"
className={cx(styles.iconText, { className={styles.iconText}
[styles.noIcon]: !Icon,
})}
> >
<span>{facility.name}</span> <span>{facilityName}</span>
</Typography> </Typography>
</li> </li>
) )

View File

@@ -45,10 +45,6 @@
justify-content: flex-start; justify-content: flex-start;
} }
.noIcon {
margin-left: var(--Spacing-x4);
}
.buttonContainer { .buttonContainer {
background-color: var(--Base-Background-Primary-Normal); background-color: var(--Base-Background-Primary-Normal);
border-top: 1px solid var(--Base-Border-Subtle); border-top: 1px solid var(--Base-Border-Subtle);

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(() => {
@@ -79,13 +82,20 @@ export function LinkedReservation({
return <Retry handleRefetch={refetch} /> return <Retry handleRefetch={refetch} />
} }
const { booking, room } = data
return ( return (
<Room <Room
booking={data.booking} checkInDate={booking.checkInDate}
checkOutDate={booking.checkOutDate}
checkInTime={checkInTime} checkInTime={checkInTime}
checkOutTime={checkOutTime} checkOutTime={checkOutTime}
img={data.room.images[0]} confirmationNumber={booking.confirmationNumber}
roomName={data.room.name} guaranteeInfo={booking.guaranteeInfo}
guest={booking.guest}
img={room.images[0]}
rateDefinition={booking.rateDefinition}
roomName={room.name}
/> />
) )
} }

View File

@@ -20,24 +20,28 @@ import styles from "./room.module.css"
import type { RoomProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms/room" import type { RoomProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms/room"
export default function Room({ export default function Room({
booking, checkInDate,
checkOutDate,
checkInTime, checkInTime,
checkOutTime, checkOutTime,
confirmationNumber,
guaranteeInfo,
guest,
img, img,
rateDefinition,
roomName, roomName,
}: RoomProps) { }: RoomProps) {
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
const guestName = `${booking.guest.firstName} ${booking.guest.lastName}` const guestName = `${guest.firstName} ${guest.lastName}`
const fromDate = dt(booking.checkInDate).locale(lang) const fromDate = dt(checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang) const toDate = dt(checkOutDate).locale(lang)
const isFlexBooking = const isFlexBooking =
booking.rateDefinition.cancellationRule === rateDefinition.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM CancellationRuleEnum.CancellableBefore6PM
const isChangeBooking = const isChangeBooking =
booking.rateDefinition.cancellationRule === CancellationRuleEnum.Changeable rateDefinition.cancellationRule === CancellationRuleEnum.Changeable
return ( return (
<article className={styles.room}> <article className={styles.room}>
<header className={styles.header}> <header className={styles.header}>
@@ -47,11 +51,11 @@ export default function Room({
{ {
defaultMessage: "Booking number {value}", defaultMessage: "Booking number {value}",
}, },
{ value: booking.confirmationNumber } { value: confirmationNumber }
)} )}
</h2> </h2>
</Typography> </Typography>
{booking.rateDefinition.isMemberRate ? ( {rateDefinition.isMemberRate ? (
<div className={styles.benefits}> <div className={styles.benefits}>
<> <>
<MaterialIcon <MaterialIcon
@@ -67,7 +71,7 @@ export default function Room({
</> </>
</div> </div>
) : null} ) : null}
{booking.guaranteeInfo && ( {guaranteeInfo && (
<div className={styles.benefits}> <div className={styles.benefits}>
<MaterialIcon <MaterialIcon
icon="check_circle" icon="check_circle"
@@ -168,7 +172,7 @@ export default function Room({
})} })}
</Body> </Body>
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">
{booking.rateDefinition.cancellationText} {rateDefinition.cancellationText}
</Body> </Body>
</li> </li>
{isFlexBooking || isChangeBooking ? ( {isFlexBooking || isChangeBooking ? (
@@ -196,25 +200,23 @@ export default function Room({
})} })}
</Body> </Body>
<Body color="uiTextHighContrast">{guestName}</Body> <Body color="uiTextHighContrast">{guestName}</Body>
{booking.guest.membershipNumber ? ( {guest.membershipNumber ? (
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">
{intl.formatMessage( {intl.formatMessage(
{ {
defaultMessage: "Friend no. {value}", defaultMessage: "Friend no. {value}",
}, },
{ {
value: booking.guest.membershipNumber, value: guest.membershipNumber,
} }
)} )}
</Body> </Body>
) : null} ) : null}
{booking.guest.phoneNumber ? ( {guest.phoneNumber ? (
<Body color="uiTextHighContrast"> <Body color="uiTextHighContrast">{guest.phoneNumber}</Body>
{booking.guest.phoneNumber}
</Body>
) : null} ) : null}
{booking.guest.email ? ( {guest.email ? (
<Body color="uiTextHighContrast">{booking.guest.email}</Body> <Body color="uiTextHighContrast">{guest.email}</Body>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@@ -9,55 +9,56 @@ 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
booking={booking} checkInDate={booking.checkInDate}
checkOutDate={booking.checkOutDate}
checkInTime={checkInTime} checkInTime={checkInTime}
checkOutTime={checkOutTime} checkOutTime={checkOutTime}
confirmationNumber={booking.confirmationNumber}
guaranteeInfo={booking.guaranteeInfo}
guest={booking.guest}
img={mainRoom.images[0]} img={mainRoom.images[0]}
rateDefinition={booking.rateDefinition}
roomName={mainRoom.name} roomName={mainRoom.name}
/> />
</div> </div>
{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,87 +0,0 @@
import { notFound } from "next/navigation"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import { encrypt } from "@/server/routers/utils/encryption"
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 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
confirmationNumber={booking.confirmationNumber}
hotelId={hotel.operaId}
lastName={booking.guest.lastName}
/>
<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

@@ -135,7 +135,7 @@ export default function ConfirmBooking({
) : null} ) : null}
</div> </div>
<div className={styles.checkboxContainer}> <div className={styles.checkboxContainer}>
<TermsAndConditions /> <TermsAndConditions isFlexBookingTerms />
</div> </div>
</div> </div>
) )
@@ -156,7 +156,7 @@ export function ConfirmBookingRedemption() {
</Typography> </Typography>
</div> </div>
<div className={styles.checkboxContainer}> <div className={styles.checkboxContainer}>
<TermsAndConditions /> <TermsAndConditions isFlexBookingTerms />
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,31 @@
"use client"
import { useEffect } from "react"
import { useFormContext } from "react-hook-form"
export default function AutoFillDetector() {
const {
formState: { dirtyFields, isDirty, touchedFields },
trigger,
watch,
} = useFormContext()
useEffect(() => {
const dirtyFieldKeys = Object.keys(dirtyFields)
const touchedFieldKeys = Object.keys(touchedFields)
const hasDirtyUnTouchedFields = dirtyFieldKeys.some(
(field) => !touchedFieldKeys.includes(field)
)
const subscription = watch((_, field) => {
if (!field.type) {
if (isDirty && hasDirtyUnTouchedFields) {
trigger(field.name)
trigger("countryCode")
}
}
})
return () => subscription.unsubscribe()
}, [dirtyFields, isDirty, touchedFields, trigger, watch])
return null
}

View File

@@ -11,6 +11,7 @@ import Phone from "@/components/TempDesignSystem/Form/Phone"
import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import { useRoomContext } from "@/contexts/Details/Room" import { useRoomContext } from "@/contexts/Details/Room"
import AutoFillDetector from "./AutoFillDetector"
import JoinScandicFriendsCard from "./JoinScandicFriendsCard" import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
import { guestDetailsSchema, signedInDetailsSchema } from "./schema" import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
import Signup from "./Signup" import Signup from "./Signup"
@@ -150,6 +151,7 @@ export default function Details({ user }: DetailsProps) {
registerOptions={{ required: true, onBlur: updateDetailsStore }} registerOptions={{ required: true, onBlur: updateDetailsStore }}
/> />
</div> </div>
<AutoFillDetector />
</form> </form>
</FormProvider> </FormProvider>
) )

View File

@@ -1,15 +1,18 @@
"use client" "use client"
import { usePathname, useSearchParams } from "next/navigation" import { usePathname, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { BookingErrorCodeEnum } from "@/constants/booking" import { BookingErrorCodeEnum } from "@/constants/booking"
import { selectRate } from "@/constants/routes/hotelReservation"
import { useEnterDetailsStore } from "@/stores/enter-details" import { useEnterDetailsStore } from "@/stores/enter-details"
import Alert from "@/components/TempDesignSystem/Alert" import Alert from "@/components/TempDesignSystem/Alert"
import useLang from "@/hooks/useLang"
import useStickyPosition from "@/hooks/useStickyPosition"
import styles from "./paymentAlert.module.css" import styles from "./bookingAlert.module.css"
import { AlertTypeEnum } from "@/types/enums/alert" import { AlertTypeEnum } from "@/types/enums/alert"
@@ -18,6 +21,7 @@ function useBookingErrorAlert() {
(state) => state.actions.updateSeachParamString (state) => state.actions.updateSeachParamString
) )
const intl = useIntl() const intl = useIntl()
const lang = useLang()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const pathname = usePathname() const pathname = usePathname()
@@ -30,12 +34,19 @@ function useBookingErrorAlert() {
const [showAlert, setShowAlert] = useState(!!errorCode) const [showAlert, setShowAlert] = useState(!!errorCode)
const selectRateReturnUrl = getSelectRateReturnUrl()
function getErrorMessage(errorCode: string | null) { function getErrorMessage(errorCode: string | null) {
switch (errorCode) { switch (errorCode) {
case BookingErrorCodeEnum.TransactionCancelled: case BookingErrorCodeEnum.TransactionCancelled:
return intl.formatMessage({ return intl.formatMessage({
defaultMessage: "You have now cancelled your payment.", defaultMessage: "You have now cancelled your payment.",
}) })
case BookingErrorCodeEnum.AvailabilityError:
return intl.formatMessage({
defaultMessage:
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.",
})
default: default:
return intl.formatMessage({ return intl.formatMessage({
defaultMessage: defaultMessage:
@@ -53,16 +64,42 @@ function useBookingErrorAlert() {
window.history.replaceState({}, "", `${pathname}?${queryParams.toString()}`) window.history.replaceState({}, "", `${pathname}?${queryParams.toString()}`)
} }
return { showAlert, errorMessage, severityLevel, discardAlert, setShowAlert } function getSelectRateReturnUrl() {
const queryParams = new URLSearchParams(searchParams.toString())
queryParams.delete("errorCode")
return `${selectRate(lang)}?${queryParams.toString()}`
}
return {
showAlert,
errorCode,
errorMessage,
severityLevel,
discardAlert,
setShowAlert,
selectRateReturnUrl,
}
} }
interface PaymentAlertProps { interface BookingAlertProps {
isVisible?: boolean isVisible?: boolean
} }
export default function PaymentAlert({ isVisible = false }: PaymentAlertProps) { export default function BookingAlert({ isVisible = false }: BookingAlertProps) {
const { showAlert, errorMessage, severityLevel, discardAlert, setShowAlert } = const intl = useIntl()
useBookingErrorAlert()
const {
showAlert,
errorCode,
errorMessage,
severityLevel,
discardAlert,
setShowAlert,
selectRateReturnUrl,
} = useBookingErrorAlert()
const ref = useRef<HTMLDivElement>(null)
const { getTopOffset } = useStickyPosition()
useEffect(() => { useEffect(() => {
if (isVisible) { if (isVisible) {
@@ -70,15 +107,39 @@ export default function PaymentAlert({ isVisible = false }: PaymentAlertProps) {
} }
}, [isVisible, setShowAlert]) }, [isVisible, setShowAlert])
useEffect(() => {
const el = ref.current
if (showAlert && el) {
window.scrollTo({
top: el.offsetTop - getTopOffset(),
behavior: "smooth",
})
}
}, [showAlert, getTopOffset])
if (!showAlert) return null if (!showAlert) return null
const isAvailabilityError =
errorCode === BookingErrorCodeEnum.AvailabilityError
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper} ref={ref}>
<Alert <Alert
type={severityLevel} type={severityLevel}
variant="inline" variant="inline"
text={errorMessage} text={errorMessage}
close={discardAlert} close={discardAlert}
link={
isAvailabilityError
? {
title: intl.formatMessage({
defaultMessage: "Change room",
}),
url: selectRateReturnUrl,
}
: undefined
}
/> />
</div> </div>
) )

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

@@ -1,8 +1,8 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { 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,8 +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,
BookingErrorCodeEnum,
BookingStatusEnum, BookingStatusEnum,
PAYMENT_METHOD_TITLES, PAYMENT_METHOD_TITLES,
PaymentMethodEnum, PaymentMethodEnum,
@@ -31,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"
@@ -42,10 +39,10 @@ import { bedTypeMap } from "../../utils"
import ConfirmBooking, { ConfirmBookingRedemption } from "../Confirm" import ConfirmBooking, { ConfirmBookingRedemption } from "../Confirm"
import PriceChangeDialog from "../PriceChangeDialog" import PriceChangeDialog from "../PriceChangeDialog"
import { writeGlaToSessionStorage } from "./PaymentCallback/helpers" import { writeGlaToSessionStorage } from "./PaymentCallback/helpers"
import BookingAlert from "./BookingAlert"
import GuaranteeDetails from "./GuaranteeDetails" import GuaranteeDetails from "./GuaranteeDetails"
import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum } from "./helpers" import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum } from "./helpers"
import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown" import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown"
import PaymentAlert from "./PaymentAlert"
import PaymentOptionsGroup from "./PaymentOptionsGroup" import PaymentOptionsGroup from "./PaymentOptionsGroup"
import { type PaymentFormData, paymentSchema } from "./schema" import { type PaymentFormData, paymentSchema } from "./schema"
import TermsAndConditions from "./TermsAndConditions" import TermsAndConditions from "./TermsAndConditions"
@@ -71,10 +68,11 @@ export default function PaymentClient({
const router = useRouter() const router = useRouter()
const lang = useLang() const lang = useLang()
const intl = useIntl() const intl = useIntl()
const pathname = usePathname()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { getTopOffset } = useStickyPosition({}) const { getTopOffset } = useStickyPosition({})
const [showPaymentAlert, setShowPaymentAlert] = useState(false) const [showBookingAlert, setShowBookingAlert] = useState(false)
const { booking, rooms, totalPrice } = useEnterDetailsStore((state) => ({ const { booking, rooms, totalPrice } = useEnterDetailsStore((state) => ({
booking: state.booking, booking: state.booking,
@@ -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)
@@ -135,21 +133,25 @@ export default function PaymentClient({
onSuccess: (result) => { onSuccess: (result) => {
if (result) { if (result) {
if ("error" in result) { if ("error" in result) {
if (result.cause === BookingErrorCodeEnum.AvailabilityError) { const queryParams = new URLSearchParams(searchParams.toString())
window.location.reload() // reload to refetch room data because we dont know which room is unavailable queryParams.set("errorCode", result.cause)
} else { window.history.replaceState(
handlePaymentError(result.cause) {},
} "",
`${pathname}?${queryParams.toString()}`
)
handlePaymentError(result.cause)
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) {
@@ -171,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")
@@ -186,17 +188,43 @@ 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) => {
setShowPaymentAlert(true) setShowBookingAlert(true)
const currentPaymentMethod = methods.getValues("paymentMethod") const currentPaymentMethod = methods.getValues("paymentMethod")
const smsEnable = methods.getValues("smsConfirmation") const smsEnable = methods.getValues("smsConfirmation")
@@ -242,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(
@@ -455,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 />
} }
@@ -480,7 +502,7 @@ export default function PaymentClient({
? confirm ? confirm
: payment} : payment}
</Title> </Title>
<PaymentAlert isVisible={showPaymentAlert} /> <BookingAlert isVisible={showBookingAlert} />
</header> </header>
<FormProvider {...methods}> <FormProvider {...methods}>
<form <form
@@ -586,7 +608,7 @@ export default function PaymentClient({
</section> </section>
<section className={styles.section}> <section className={styles.section}>
<TermsAndConditions /> <TermsAndConditions isFlexBookingTerms={hasOnlyFlexRates} />
</section> </section>
</> </>
)} )}
@@ -617,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

@@ -12,45 +12,82 @@ import useLang from "@/hooks/useLang"
import styles from "../payment.module.css" import styles from "../payment.module.css"
export default function TermsAndConditions() { import type { TermsAndConditionsProps } from "@/types/components/hotelReservation/enterDetails/payment"
export default function TermsAndConditions({
isFlexBookingTerms,
}: TermsAndConditionsProps) {
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
return ( return (
<> <>
<Caption> <Caption>
{intl.formatMessage( {isFlexBookingTerms
{ ? intl.formatMessage(
defaultMessage: {
"I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic requires a valid payment card during my visit in case anything is left unpaid.", defaultMessage:
}, "I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>.",
{ },
termsAndConditionsLink: (str) => ( {
<Link termsAndConditionsLink: (str) => (
className={styles.link} <Link
variant="underscored" className={styles.link}
href={bookingTermsAndConditions[lang]} variant="underscored"
target="_blank" href={bookingTermsAndConditions[lang]}
weight="bold" target="_blank"
size="small" weight="bold"
> size="small"
{str} >
</Link> {str}
), </Link>
privacyPolicyLink: (str) => ( ),
<Link privacyPolicyLink: (str) => (
className={styles.link} <Link
variant="underscored" className={styles.link}
href={privacyPolicy[lang]} variant="underscored"
target="_blank" href={privacyPolicy[lang]}
weight="bold" target="_blank"
size="small" weight="bold"
> size="small"
{str} >
</Link> {str}
), </Link>
} ),
)} }
)
: intl.formatMessage(
{
defaultMessage:
"By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic requires a valid payment card during my visit in case anything is left unpaid.",
},
{
termsAndConditionsLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
weight="bold"
size="small"
>
{str}
</Link>
),
privacyPolicyLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"
weight="bold"
size="small"
>
{str}
</Link>
),
}
)}
</Caption> </Caption>
<Checkbox name="termsAndConditions"> <Checkbox name="termsAndConditions">
<Caption> <Caption>

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { useSearchParams } from "next/navigation"
import { type PropsWithChildren, useEffect, useRef } from "react" import { type PropsWithChildren, useEffect, useRef } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
@@ -16,6 +17,8 @@ import styles from "./bottomSheet.module.css"
export default function SummaryBottomSheet({ children }: PropsWithChildren) { export default function SummaryBottomSheet({ children }: PropsWithChildren) {
const intl = useIntl() const intl = useIntl()
const scrollY = useRef(0) const scrollY = useRef(0)
const searchParams = useSearchParams()
const errorCode = searchParams.get("errorCode")
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } = const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
useEnterDetailsStore((state) => ({ useEnterDetailsStore((state) => ({
@@ -33,18 +36,21 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
} else { } else {
document.body.style.position = "" document.body.style.position = ""
document.body.style.top = "" document.body.style.top = ""
window.scrollTo({
top: scrollY.current, if (!errorCode) {
left: 0, window.scrollTo({
behavior: "instant", top: scrollY.current,
}) left: 0,
behavior: "instant",
})
}
} }
return () => { return () => {
document.body.style.position = "" document.body.style.position = ""
document.body.style.top = "" document.body.style.top = ""
} }
}, [isSummaryOpen]) }, [isSummaryOpen, errorCode])
return ( return (
<div className={styles.wrapper} data-open={isSummaryOpen}> <div className={styles.wrapper} data-open={isSummaryOpen}>

View File

@@ -4,6 +4,7 @@ import { Fragment } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button" import { Button } from "@scandic-hotels/design-system/Button"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon" import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -115,17 +116,18 @@ export default function SummaryUI({
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nightsMsg}) {dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nightsMsg})
</Body> </Body>
<Button <IconButton
onPress={handleToggleSummary} onPress={handleToggleSummary}
className={styles.chevronButton} className={styles.chevronButton}
variant="Icon" theme="Black"
style="Muted"
> >
<MaterialIcon <MaterialIcon
icon="keyboard_arrow_down" icon="keyboard_arrow_down"
size={20} size={20}
color="CurrentColor" color="CurrentColor"
/> />
</Button> </IconButton>
</header> </header>
<Divider color="primaryLightSubtle" /> <Divider color="primaryLightSubtle" />
{rooms.map(({ room }, idx) => { {rooms.map(({ room }, idx) => {

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,223 +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 {
findBooking,
getAncillaryPackages,
getBookingConfirmation,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import { decrypt } from "@/server/routers/utils/encryption"
import { auth } from "@/auth"
import { getIntl } from "@/i18n"
import { isValidSession } from "@/utils/session"
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 session = await auth()
const isLoggedIn = isValidSession(session)
const [confirmationNumber, lastName] = value.split(",")
const bv = cookies().get("bv")?.value
let bookingConfirmation
if (isLoggedIn) {
bookingConfirmation = await getBookingConfirmation(confirmationNumber)
} else if (bv) {
const params = new URLSearchParams(bv)
const firstName = params.get("firstName")
const email = params.get("email")
if (firstName && email) {
bookingConfirmation = await findBooking(
confirmationNumber,
lastName,
firstName,
email
)
} else {
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
}
} else {
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
}
if (!bookingConfirmation) {
return notFound()
}
const { booking, hotel, room } = bookingConfirmation
const user = await getProfileSafely()
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
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()
}
function RenderAdditionalInfoForm({
confirmationNumber,
lastName,
}: {
confirmationNumber: string
lastName: string
}) {
return (
<main className={styles.main}>
<div className={styles.form}>
<AdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
</div>
</main>
)
}

View File

@@ -40,7 +40,7 @@ export default function FinalConfirmation({
defaultMessage: "Were sorry that things didnt work out.", defaultMessage: "Were sorry that things didnt work out.",
}) })
const cancelBookingsMutation = trpc.booking.cancelMany.useMutation({ const cancelBookingsMutation = trpc.booking.cancel.useMutation({
onSuccess(data, variables) { onSuccess(data, variables) {
const allCancellationsWentThrough = data.every((cancelled) => cancelled) const allCancellationsWentThrough = data.every((cancelled) => cancelled)
if (allCancellationsWentThrough) { if (allCancellationsWentThrough) {
@@ -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)}`,
@@ -105,7 +105,7 @@ export default function Form() {
const guaranteeMsg = intl.formatMessage( const guaranteeMsg = intl.formatMessage(
{ {
defaultMessage: defaultMessage:
"By guaranteeing with any of the payment methods available, I accept the terms for this stay and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandic's Privacy Policy</privacyPolicyLink>. I accept Scandic requiring a valid credit card during my visit in case anything is left unpaid.", "I accept the terms for this stay and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandic's Privacy Policy</privacyPolicyLink>. ",
}, },
{ {
termsAndConditionsLink: (str) => ( termsAndConditionsLink: (str) => (

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

@@ -0,0 +1,38 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import { BookingErrorCodeEnum } from "@/constants/booking"
import { toast } from "@/components/TempDesignSystem/Toasts"
export default function AvailabilityError() {
const intl = useIntl()
const pathname = usePathname()
const searchParams = useSearchParams()
const errorCode = searchParams.get("errorCode")
const hasAvailabilityError =
errorCode === BookingErrorCodeEnum.AvailabilityError
const errorMessage = intl.formatMessage({
defaultMessage:
"Unfortunately, one of the rooms you selected is sold out. Please choose another room to proceed.",
})
useEffect(() => {
if (!hasAvailabilityError) {
return
}
toast.error(errorMessage)
const newParams = new URLSearchParams(searchParams.toString())
newParams.delete("errorCode")
window.history.replaceState({}, "", `${pathname}?${newParams.toString()}`)
}, [errorMessage, hasAvailabilityError, pathname, searchParams])
return null
}

View File

@@ -3,6 +3,7 @@ import { Fragment } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button" import { Button } from "@scandic-hotels/design-system/Button"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
@@ -88,13 +89,13 @@ export default function Summary({
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights}) {dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights})
</Body> </Body>
<Button onPress={toggleSummaryOpen} variant="Icon"> <IconButton onPress={toggleSummaryOpen} theme="Black" style="Muted">
<MaterialIcon <MaterialIcon
icon="keyboard_arrow_down" icon="keyboard_arrow_down"
size={20} size={20}
color="CurrentColor" color="CurrentColor"
/> />
</Button> </IconButton>
</header> </header>
<Divider color="primaryLightSubtle" /> <Divider color="primaryLightSubtle" />
{rooms.map((room, idx) => { {rooms.map((room, idx) => {

View File

@@ -7,8 +7,8 @@ import {
} from "react-aria-components" } from "react-aria-components"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton" import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -39,9 +39,13 @@ export default function RoomPackageFilterModal() {
{intl.formatMessage({ defaultMessage: "Special needs" })} {intl.formatMessage({ defaultMessage: "Special needs" })}
</h3> </h3>
</Typography> </Typography>
<Button variant="Icon" onPress={() => setIsOpen(false)}> <IconButton
theme="Black"
style="Muted"
onPress={() => setIsOpen(false)}
>
<MaterialIcon icon="close" size={24} color="CurrentColor" /> <MaterialIcon icon="close" size={24} color="CurrentColor" />
</Button> </IconButton>
</div> </div>
<Form close={() => setIsOpen(false)} /> <Form close={() => setIsOpen(false)} />
</Dialog> </Dialog>

View File

@@ -1,5 +1,5 @@
"use client" "use client"
import { Button as AriaButton } from "react-aria-components" import { Button as ButtonRAC } from "react-aria-components"
import { useMediaQuery } from "usehooks-ts" import { useMediaQuery } from "usehooks-ts"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
@@ -69,12 +69,12 @@ export default function RoomPackageFilter() {
color="CurrentColor" color="CurrentColor"
/> />
{pkg.description} {pkg.description}
<AriaButton <ButtonRAC
onPress={() => deleteSelectedPackage(pkg.code)} onPress={() => deleteSelectedPackage(pkg.code)}
className={styles.removeButton} className={styles.removeButton}
> >
<MaterialIcon icon="close" size={16} color="CurrentColor" /> <MaterialIcon icon="close" size={16} color="CurrentColor" />
</AriaButton> </ButtonRAC>
</span> </span>
</Typography> </Typography>
))} ))}

View File

@@ -12,6 +12,7 @@ import { setLang } from "@/i18n/serverContext"
import { getHotelSearchDetails } from "@/utils/hotelSearchDetails" import { getHotelSearchDetails } from "@/utils/hotelSearchDetails"
import { convertSearchParamsToObj } from "@/utils/url" import { convertSearchParamsToObj } from "@/utils/url"
import AvailabilityError from "./AvailabilityError"
import { getValidDates } from "./getValidDates" import { getValidDates } from "./getValidDates"
import { getTracking } from "./tracking" import { getTracking } from "./tracking"
@@ -90,6 +91,8 @@ export default async function SelectRatePage({
hotelInfo={hotelsTrackingData} hotelInfo={hotelsTrackingData}
/> />
</Suspense> </Suspense>
<AvailabilityError />
</> </>
) )
} }

View File

@@ -0,0 +1,126 @@
.fullViewContainer {
background-color: var(--UI-Text-High-contrast);
height: 100%;
padding: var(--Spacing-x3) var(--Spacing-x2);
position: relative;
align-items: center;
justify-items: center;
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-columns: 1fr;
place-content: center;
gap: var(--Spacing-x5);
}
.closeButton {
position: absolute;
top: var(--Space-x2);
right: var(--Space-x2);
z-index: 1;
}
.header {
display: flex;
justify-content: center;
width: 100%;
}
.imageCount {
background-color: var(--Overlay-90);
padding: var(--Space-x025) var(--Space-x05);
border-radius: var(--Corner-radius-Small);
color: var(--Text-Inverted);
}
.imageContainer {
position: relative;
width: 100%;
height: 100%;
max-height: 25rem;
margin-bottom: var(--Spacing-x5);
}
.imageWrapper {
position: absolute;
width: 100%;
height: 100%;
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
.footer {
color: var(--Text-Inverted);
position: absolute;
bottom: calc(-1 * var(--Spacing-x5));
}
@media screen and (max-width: 767px) {
.navigationButton {
display: none;
}
}
@media screen and (min-width: 768px) and (max-width: 1366px) {
.fullViewContainer {
padding: var(--Spacing-x5);
}
.imageContainer {
height: 100%;
max-height: 560px;
}
}
@media screen and (min-width: 768px) {
.closeButton {
position: fixed;
top: var(--Spacing-x-one-and-half);
right: var(--Spacing-x-half);
}
.fullViewContainer {
margin-top: 0;
padding: var(--Spacing-x5);
grid-template-rows: auto 1fr auto;
width: 100%;
height: 100%;
}
.imageContainer {
width: 70%;
max-width: 1454px;
max-height: 700px;
}
.navigationButton {
position: absolute;
top: 50%;
transform: translateY(-50%);
background-color: var(--Component-Button-Inverted-Fill-Default);
color: var(--Component-Button-Inverted-On-fill-Default);
border-radius: var(--Corner-radius-rounded);
padding: 10px;
cursor: pointer;
border-width: 0;
display: flex;
z-index: 1;
box-shadow: 0px 0px 8px 1px #0000001a;
&:hover {
background-color: var(--Component-Button-Inverted-Fill-Hover);
color: var(--Component-Button-Inverted-On-fill-Hover);
}
}
.fullViewNextButton {
right: var(--Spacing-x5);
}
.fullViewPrevButton {
left: var(--Spacing-x5);
}
}

View File

@@ -2,15 +2,15 @@
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import { useState } from "react" import { useState } from "react"
import { useIntl } from "react-intl"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import Image from "@/components/Image" import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./Lightbox.module.css" import styles from "./fullView.module.css"
import type { FullViewProps } from "@/types/components/lightbox/lightbox" import type { FullViewProps } from "@/types/components/lightbox/lightbox"
@@ -23,6 +23,7 @@ export default function FullView({
totalImages, totalImages,
hideLabel, hideLabel,
}: FullViewProps) { }: FullViewProps) {
const intl = useIntl()
const [animateLeft, setAnimateLeft] = useState(true) const [animateLeft, setAnimateLeft] = useState(true)
function handleSwipe(offset: number) { function handleSwipe(offset: number) {
@@ -54,29 +55,26 @@ export default function FullView({
return ( return (
<div className={styles.fullViewContainer}> <div className={styles.fullViewContainer}>
<Button <IconButton
intent="text" theme="Inverted"
size="small" style="Muted"
variant="icon" className={styles.closeButton}
className={styles.fullViewCloseButton} onPress={onClose}
onClick={onClose} aria-label={intl.formatMessage({
defaultMessage: "Close",
})}
> >
<MaterialIcon <MaterialIcon icon="close" color="CurrentColor" size={24} />
icon="close" </IconButton>
size={32} <div className={styles.header}>
className={styles.fullViewCloseIcon} <Typography variant="Tag/sm">
color="Icon/Inverted" <span className={styles.imageCount}>
/>
</Button>
<div className={styles.fullViewHeader}>
<span className={styles.imagePosition}>
<Caption color="white">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${currentIndex + 1} / ${totalImages}`} {`${currentIndex + 1} / ${totalImages}`}
</Caption> </span>
</span> </Typography>
</div> </div>
<div className={styles.fullViewImageContainer}> <div className={styles.imageContainer}>
<AnimatePresence initial={false} custom={animateLeft}> <AnimatePresence initial={false} custom={animateLeft}>
<motion.div <motion.div
key={image.src} key={image.src}
@@ -86,7 +84,7 @@ export default function FullView({
animate="animate" animate="animate"
exit="exit" exit="exit"
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className={styles.fullViewImage} className={styles.imageWrapper}
drag="x" drag="x"
onDragEnd={(_e, info) => handleSwipe(info.offset.x)} onDragEnd={(_e, info) => handleSwipe(info.offset.x)}
> >
@@ -95,14 +93,14 @@ export default function FullView({
fill fill
sizes="(min-width: 1500px) 1500px, 100vw" sizes="(min-width: 1500px) 1500px, 100vw"
src={image.src} src={image.src}
style={{ objectFit: "cover" }} className={styles.image}
/> />
<div className={styles.fullViewFooter}> {image.caption && !hideLabel ? (
{image.caption && !hideLabel && ( <Typography variant="Body/Paragraph/mdRegular">
<Body color="white">{image.caption}</Body> <p className={styles.footer}>{image.caption}</p>
)} </Typography>
</div> ) : null}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>
@@ -112,8 +110,8 @@ export default function FullView({
onClick={handlePrev} onClick={handlePrev}
> >
<MaterialIcon <MaterialIcon
icon="arrow_forward" icon="arrow_back"
color="Icon/Interactive/Default" color="CurrentColor"
className={styles.leftTransformIcon} className={styles.leftTransformIcon}
/> />
</motion.button> </motion.button>
@@ -121,7 +119,7 @@ export default function FullView({
className={`${styles.navigationButton} ${styles.fullViewNextButton}`} className={`${styles.navigationButton} ${styles.fullViewNextButton}`}
onClick={handleNext} onClick={handleNext}
> >
<MaterialIcon icon="arrow_forward" color="Icon/Interactive/Default" /> <MaterialIcon icon="arrow_forward" color="CurrentColor" />
</motion.button> </motion.button>
</div> </div>
) )

View File

@@ -0,0 +1,160 @@
.galleryContainer {
display: grid;
gap: var(--Space-x2);
padding: var(--Space-x2);
height: 100%;
overflow-y: auto;
background-color: var(--Base-Background-Primary-Normal);
}
.mobileGallery {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--Space-x1);
padding-bottom: var(--Space-x3);
}
.thumbnailContainer {
position: relative;
height: 242px;
}
.fullWidthImage {
grid-column: 1 / -1;
height: 240px;
}
.imageButton {
position: relative;
height: 100%;
width: 100%;
padding: 0;
border-width: 0;
background-color: transparent;
cursor: pointer;
border-radius: var(--Corner-radius-Medium);
overflow: hidden;
z-index: 0;
&:focus-visible {
outline-offset: -2px; /* Adjust the outline offset as wrappers uses overflow-hidden */
}
}
.image {
transition: opacity 0.3s ease-in-out;
object-fit: cover;
z-index: -1;
}
@media screen and (max-width: 767px) {
.desktopCloseIcon,
.desktopGallery {
display: none;
}
.closeButton {
justify-self: start;
}
}
@media screen and (min-width: 768px) {
.mobileGallery,
.mobileCloseIcon {
display: none;
}
.galleryContainer {
padding: var(--Spacing-x5) var(--Spacing-x6);
}
.closeButton {
position: absolute;
top: var(--Space-x2);
right: var(--Space-x2);
z-index: 1;
}
.desktopGallery {
display: grid;
grid-template-rows: 28px 1fr 7.8125rem;
row-gap: var(--Spacing-x-one-and-half);
background-color: var(--Base-Background-Primary-Normal);
height: 100%;
position: relative;
overflow: hidden;
}
.galleryHeader {
display: flex;
align-items: center;
}
.imageCaption {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x-half) var(--Spacing-x1);
border-radius: var(--Corner-radius-Small);
color: var(--Text-Secondary);
}
.mainImageWrapper {
position: relative;
width: 100%;
}
.mainImageContainer {
width: 100%;
height: 100%;
will-change: transform;
position: absolute;
}
.desktopThumbnailGrid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--Spacing-x1);
max-height: 7.8125rem;
overflow: hidden;
}
.thumbnailContainer {
height: 125px;
}
.fullWidthImage {
grid-column: auto;
height: auto;
}
.thumbnailContainer img {
border-radius: var(--Corner-radius-Small);
}
.navigationButton {
position: absolute;
top: 50%;
transform: translateY(-50%);
background-color: var(--Component-Button-Inverted-Fill-Default);
color: var(--Component-Button-Inverted-On-fill-Default);
border-radius: var(--Corner-radius-rounded);
padding: 10px;
cursor: pointer;
border-width: 0;
display: flex;
z-index: 1;
box-shadow: 0px 0px 8px 1px #0000001a;
&:hover {
background-color: var(--Component-Button-Inverted-Fill-Hover);
color: var(--Component-Button-Inverted-On-fill-Hover);
}
}
.galleryPrevButton {
left: var(--Spacing-x2);
}
.galleryNextButton {
right: var(--Spacing-x2);
}
}

View File

@@ -1,15 +1,16 @@
"use client" "use client"
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import { useState } from "react" import { useState } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import Image from "@/components/Image" import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./Lightbox.module.css" import styles from "./gallery.module.css"
import type { GalleryProps } from "@/types/components/lightbox/lightbox" import type { GalleryProps } from "@/types/components/lightbox/lightbox"
@@ -61,36 +62,38 @@ export default function Gallery({
return ( return (
<div className={styles.galleryContainer}> <div className={styles.galleryContainer}>
<Button <IconButton
intent="text" theme="Black"
size="small" style="Muted"
className={styles.closeButton} className={styles.closeButton}
onClick={onClose} onPress={onClose}
aria-label={intl.formatMessage({ aria-label={intl.formatMessage({
defaultMessage: "Close", defaultMessage: "Close",
})} })}
> >
<MaterialIcon <MaterialIcon
icon="chevron_left" icon="chevron_left"
color="Icon/Intense" color="CurrentColor"
size={32} size={24}
className={styles.mobileCloseIcon} className={styles.mobileCloseIcon}
/> />
<MaterialIcon <MaterialIcon
icon="close" icon="close"
size={32} color="CurrentColor"
size={24}
className={styles.desktopCloseIcon} className={styles.desktopCloseIcon}
/> />
</Button> </IconButton>
{/* Desktop Gallery */} {/* Desktop Gallery */}
<div className={styles.desktopGallery}> <div className={styles.desktopGallery}>
<div className={styles.galleryHeader}> <Typography variant="Body/Supporting text (caption)/smRegular">
{mainImage.caption && !hideLabel && ( <p className={styles.galleryHeader}>
<div className={styles.imageCaption}> {mainImage.caption && !hideLabel && (
<Caption color="textMediumContrast">{mainImage.caption}</Caption> <span className={styles.imageCaption}>{mainImage.caption}</span>
</div> )}
)} </p>
</div> </Typography>
<div className={styles.mainImageWrapper}> <div className={styles.mainImageWrapper}>
<AnimatePresence initial={false} custom={animateLeft}> <AnimatePresence initial={false} custom={animateLeft}>
<motion.div <motion.div
@@ -103,34 +106,34 @@ export default function Gallery({
exit="exit" exit="exit"
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
> >
<Image <ButtonRAC
src={mainImage.src} onPress={onImageClick}
alt={mainImage.alt} className={styles.imageButton}
fill aria-label={intl.formatMessage({
sizes="(min-width: 1000px) 1000px, 100vw" defaultMessage: "Open image",
className={styles.image} })}
onClick={onImageClick} >
/> <Image
src={mainImage.src}
alt={mainImage.alt}
fill
sizes="(min-width: 1000px) 1000px, 100vw"
className={styles.image}
/>
</ButtonRAC>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
<motion.button <motion.button
className={`${styles.navigationButton} ${styles.galleryPrevButton}`} className={`${styles.navigationButton} ${styles.galleryPrevButton}`}
onClick={handlePrev} onClick={handlePrev}
> >
<MaterialIcon <MaterialIcon icon="arrow_back" color="CurrentColor" />
icon="arrow_forward"
color="Icon/Interactive/Default"
className={styles.leftTransformIcon}
/>
</motion.button> </motion.button>
<motion.button <motion.button
className={`${styles.navigationButton} ${styles.galleryNextButton}`} className={`${styles.navigationButton} ${styles.galleryNextButton}`}
onClick={handleNext} onClick={handleNext}
> >
<MaterialIcon <MaterialIcon icon="arrow_forward" color="CurrentColor" />
icon="arrow_forward"
color="Icon/Interactive/Default"
/>
</motion.button> </motion.button>
</div> </div>
<div className={styles.desktopThumbnailGrid}> <div className={styles.desktopThumbnailGrid}>
@@ -139,19 +142,26 @@ export default function Gallery({
<motion.div <motion.div
key={image.smallSrc || image.src} key={image.smallSrc || image.src}
className={styles.thumbnailContainer} className={styles.thumbnailContainer}
onClick={() => onSelectImage(image)}
initial={{ opacity: 0, x: 50 }} initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }} exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.2, delay: index * 0.05 }} transition={{ duration: 0.2, delay: index * 0.05 }}
> >
<Image <ButtonRAC
src={image.smallSrc || image.src} className={styles.imageButton}
alt={image.alt} onPress={() => onSelectImage(image)}
fill aria-label={intl.formatMessage({
sizes="200px" defaultMessage: "Open image",
className={styles.image} })}
/> >
<Image
src={image.smallSrc || image.src}
alt={image.alt}
fill
sizes="200px"
className={styles.image}
/>
</ButtonRAC>
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
@@ -160,31 +170,32 @@ export default function Gallery({
{/* Mobile Gallery */} {/* Mobile Gallery */}
<div className={styles.mobileGallery}> <div className={styles.mobileGallery}>
<div className={styles.mobileGalleryContent}> {images.map((image, index) => (
<div className={styles.thumbnailGrid}> <motion.div
{images.map((image, index) => ( key={image.smallSrc || image.src}
<motion.div className={`${styles.thumbnailContainer} ${index % 3 === 0 ? styles.fullWidthImage : ""}`}
key={image.smallSrc || image.src} initial={{ opacity: 0, y: 20 }}
className={`${styles.thumbnailContainer} ${index % 3 === 0 ? styles.fullWidthImage : ""}`} animate={{ opacity: 1, y: 0 }}
onClick={() => { transition={{ duration: 0.3, delay: index * 0.05 }}
onSelectImage(image) >
onImageClick() <ButtonRAC
}} className={styles.imageButton}
initial={{ opacity: 0, y: 20 }} aria-label={intl.formatMessage({ defaultMessage: "Open image" })}
animate={{ opacity: 1, y: 0 }} onPress={() => {
transition={{ duration: 0.3, delay: index * 0.05 }} onSelectImage(image)
> onImageClick()
<Image }}
src={image.smallSrc || image.src} >
alt={image.alt} <Image
fill src={image.smallSrc || image.src}
sizes="100vw" alt={image.alt}
className={styles.image} fill
/> sizes="100vw"
</motion.div> className={styles.image}
))} />
</div> </ButtonRAC>
</div> </motion.div>
))}
</div> </div>
</div> </div>
) )

View File

@@ -1,348 +0,0 @@
@keyframes darken-background {
from {
background-color: rgba(0, 0, 0, 0);
}
to {
background-color: rgba(0, 0, 0, 0.5);
}
}
.mobileGallery {
height: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.closeButton {
justify-content: flex-start;
width: fit-content;
}
.closeButton .desktopCloseIcon {
display: none;
}
.mobileGalleryContent {
display: block;
}
.fullViewCloseButton {
position: absolute;
top: var(--Spacing-x-one-and-half);
right: var(--Spacing-x-half);
z-index: 1;
}
.fullViewCloseButton:hover .fullViewCloseIcon {
background-color: var(--UI-Text-Medium-contrast);
border-radius: 50%;
}
.leftTransformIcon {
transform: scaleX(-1);
}
.content {
width: 100%;
height: 100%;
border-radius: 0;
position: fixed;
top: 50%;
left: 50%;
z-index: var(--lightbox-z-index);
}
.overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: var(--lightbox-z-index);
}
.overlay[data-entering] {
animation: darken-background 0.2s;
}
.overlay[data-exiting] {
animation: darken-background 0.2s reverse;
}
.galleryContainer {
background-color: var(--Base-Background-Primary-Normal);
padding: var(--Spacing-x2);
height: 100%;
display: flex;
flex-direction: column;
position: relative;
overflow-y: auto;
}
.galleryHeader {
display: flex;
justify-content: space-between;
align-items: center;
height: 1.71875rem;
}
.desktopGallery,
.desktopThumbnailGrid,
.navigationButton {
display: none;
}
.imageCaption {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x-half) var(--Spacing-x1);
border-radius: var(--Corner-radius-Small);
}
.mainImageWrapper {
position: relative;
width: 100%;
}
.mainImageContainer {
width: 100%;
height: 100%;
will-change: transform;
overflow: hidden;
position: absolute;
}
.mainImageContainer img,
.thumbnailContainer img {
border-radius: var(--Corner-radius-Small);
cursor: pointer;
transition: opacity 0.3s ease-in-out;
}
.thumbnailGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--Spacing-x1);
max-height: none;
padding: var(--Spacing-x3) 0;
}
.thumbnailContainer {
position: relative;
height: 242px;
}
.fullWidthImage {
grid-column: 1 / -1;
height: 240px;
}
.thumbnailContainer img {
border-radius: var(--Corner-radius-Medium);
}
.fullViewContainer {
background-color: var(--UI-Text-High-contrast);
height: 100%;
padding: var(--Spacing-x2);
position: relative;
align-items: center;
justify-items: center;
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-columns: 1fr;
place-content: center;
gap: var(--Spacing-x5);
}
.fullViewHeader {
display: flex;
justify-content: center;
width: 100%;
}
.fullViewImageContainer {
position: relative;
width: 100%;
height: 100%;
max-height: 25rem;
margin-bottom: var(--Spacing-x5);
}
.fullViewImage {
position: absolute;
width: 100%;
height: 100%;
border-radius: var(--Corner-radius-Medium);
}
.fullViewImageContainer img {
border-radius: var(--Corner-radius-Medium);
width: 100%;
height: 100%;
}
.fullViewFooter {
position: absolute;
bottom: calc(-1 * var(--Spacing-x5));
}
.imagePosition {
background-color: var(--UI-Grey-90);
padding: var(--Spacing-x-quarter) var(--Spacing-x-half);
border-radius: var(--Corner-radius-Small);
}
.portraitImage {
max-width: 548px;
}
.image {
object-fit: cover;
}
@media (min-width: 768px) and (max-width: 1366px) {
.fullViewContainer {
padding: var(--Spacing-x5);
}
.fullViewImageContainer {
height: 100%;
max-height: 35rem;
}
}
@media (min-width: 768px) {
.mobileGallery,
.thumbnailGrid {
display: none;
}
.content {
position: fixed;
top: 50%;
left: 50%;
overflow: hidden;
}
.content:not(.fullViewContent) {
border-radius: var(--Corner-radius-Large);
}
.galleryContent {
width: 1090px;
width: min(var(--max-width-page), 1090px);
height: min(725px, 85dvh);
}
.fullViewContent {
width: 100vw;
height: 100vh;
}
.galleryContainer {
padding: var(--Spacing-x5) var(--Spacing-x6);
}
.desktopGallery {
display: grid;
grid-template-rows: 1.71875rem 1fr 7.8125rem;
row-gap: var(--Spacing-x-one-and-half);
background-color: var(--Base-Background-Primary-Normal);
height: 100%;
position: relative;
overflow: hidden;
}
.closeButton {
display: block;
position: absolute;
top: var(--Spacing-x-one-and-half);
right: var(--Spacing-x1);
z-index: 1;
}
.closeButton .mobileCloseIcon {
display: none;
}
.closeButton .desktopCloseIcon {
display: block;
}
.closeButton:hover .desktopCloseIcon {
background-color: var(--Base-Surface-Primary-light-Hover-alt);
border-radius: 50%;
}
.desktopThumbnailGrid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--Spacing-x1);
max-height: 7.8125rem;
overflow: hidden;
}
.thumbnailContainer {
height: 125px;
}
.fullViewCloseButton {
position: fixed;
top: var(--Spacing-x-one-and-half);
right: var(--Spacing-x-half);
}
.fullWidthImage {
grid-column: auto;
height: auto;
}
.thumbnailContainer img {
border-radius: var(--Corner-radius-Small);
}
.fullViewContainer {
margin-top: 0;
padding: var(--Spacing-x5);
grid-template-rows: auto 1fr auto;
width: 100%;
height: 100%;
}
.fullViewImageContainer {
width: 70%;
max-width: 90.875rem;
max-height: 43.75rem;
}
.navigationButton {
position: absolute;
top: 50%;
transform: translateY(-50%);
background-color: var(--Base-Button-Inverted-Fill-Normal);
border-radius: 50%;
padding: var(--Spacing-x1);
cursor: pointer;
border: none;
display: flex;
z-index: 1;
}
.galleryPrevButton {
left: var(--Spacing-x2);
}
.galleryNextButton {
right: var(--Spacing-x2);
}
.fullViewNextButton {
right: var(--Spacing-x5);
}
.fullViewPrevButton {
left: var(--Spacing-x5);
}
.fullViewFooter {
text-align: left;
}
}

View File

@@ -6,7 +6,7 @@ import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import FullView from "./FullView" import FullView from "./FullView"
import Gallery from "./Gallery" import Gallery from "./Gallery"
import styles from "./Lightbox.module.css" import styles from "./lightbox.module.css"
import type { LightboxProps } from "@/types/components/lightbox/lightbox" import type { LightboxProps } from "@/types/components/lightbox/lightbox"

View File

@@ -0,0 +1,57 @@
.overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: var(--lightbox-z-index);
&[data-entering] {
animation: darken-background 0.2s;
}
&[data-exiting] {
animation: darken-background 0.2s reverse;
}
}
.content {
width: 100%;
height: 100%;
border-radius: 0;
position: fixed;
top: 50%;
left: 50%;
z-index: var(--lightbox-z-index);
}
@media screen and (min-width: 768px) {
.content {
position: fixed;
top: 50%;
left: 50%;
overflow: hidden;
&:not(.fullViewContent) {
border-radius: var(--Corner-radius-Large);
}
&.fullViewContent {
width: 100vw;
height: 100vh;
}
&.galleryContent {
width: min(var(--max-width-page), 1090px);
height: min(725px, 85dvh);
}
}
}
@keyframes darken-background {
from {
background-color: rgba(0, 0, 0, 0);
}
to {
background-color: rgba(0, 0, 0, 0.5);
}
}

View File

@@ -19,7 +19,8 @@ export default function RoomDetails({
}: RoomDetailsProps) { }: RoomDetailsProps) {
const intl = useIntl() const intl = useIntl()
const sortedFacilities = roomFacilities const filteredSortedFacilities = roomFacilities
.filter((facility) => !!facility.isUniqueSellingPoint)
.sort((a, b) => a.sortOrder - b.sortOrder) .sort((a, b) => a.sortOrder - b.sortOrder)
.map((facility) => { .map((facility) => {
const Icon = <FacilityIcon name={facility.icon} color="Icon/Default" /> const Icon = <FacilityIcon name={facility.icon} color="Icon/Default" />
@@ -45,14 +46,28 @@ export default function RoomDetails({
</p> </p>
</Typography> </Typography>
<ul className={styles.facilityList}> <ul className={styles.facilityList}>
{sortedFacilities.map(({ name, Icon }) => ( {filteredSortedFacilities.map(
<li key={name}> ({ name, Icon, availableInAllRooms }) => (
{Icon && Icon} <li key={name}>
<Typography variant="Body/Paragraph/mdRegular"> {Icon}
<span className={styles.listText}>{name}</span> <Typography variant="Body/Paragraph/mdRegular">
</Typography> <span className={styles.listText}>
</li> {availableInAllRooms
))} ? name
: intl.formatMessage(
{
defaultMessage:
"{facility} (available in some rooms)",
},
{
facility: name,
}
)}
</span>
</Typography>
</li>
)
)}
</ul> </ul>
</div> </div>
<div className={styles.listContainer}> <div className={styles.listContainer}>

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

@@ -24,7 +24,7 @@ let resizeObserver: ResizeObserver | null = null
* This hook registers an element as sticky, calculates its top offset based on * This hook registers an element as sticky, calculates its top offset based on
* other registered sticky elements, and updates the element's position dynamically. * other registered sticky elements, and updates the element's position dynamically.
* *
* @param {UseStickyPositionProps} props - The properties for configuring the hook. * @param {UseStickyPositionProps} [props] - The properties for configuring the hook.
* @param {React.RefObject<HTMLElement>} [props.ref] - A reference to the HTML element that should be sticky. Is optional to allow for other components to only get the height of the sticky elements. * @param {React.RefObject<HTMLElement>} [props.ref] - A reference to the HTML element that should be sticky. Is optional to allow for other components to only get the height of the sticky elements.
* @param {StickyElementNameEnum} [props.name] - A unique name for the sticky element, used for tracking. * @param {StickyElementNameEnum} [props.name] - A unique name for the sticky element, used for tracking.
* @param {string} [props.group] - An optional group identifier to make multiple elements share the same top offset. Defaults to the name if not provided. * @param {string} [props.group] - An optional group identifier to make multiple elements share the same top offset. Defaults to the name if not provided.
@@ -37,7 +37,7 @@ export default function useStickyPosition({
ref, ref,
name, name,
group, group,
}: UseStickyPositionProps) { }: UseStickyPositionProps = {}) {
const { const {
registerSticky, registerSticky,
unregisterSticky, unregisterSticky,

View File

@@ -100,14 +100,19 @@ export async function put(
export async function remove( export async function remove(
endpoint: Endpoint | `${Endpoint}/${string}`, endpoint: Endpoint | `${Endpoint}/${string}`,
options: RequestOptionsWithOutBody, options: RequestOptionsWithJSONBody,
params = {} params = {}
) { ) {
const { body, ...requestOptions } = options
const url = new URL(env.API_BASEURL) const url = new URL(env.API_BASEURL)
url.pathname = endpoint url.pathname = endpoint
url.search = new URLSearchParams(params).toString() url.search = new URLSearchParams(params).toString()
return wrappedFetch( return wrappedFetch(
url, url,
merge.all([defaultOptions, { method: "DELETE" }, options]) merge.all([
defaultOptions,
{ body: JSON.stringify(body), method: "DELETE" },
requestOptions,
])
) )
} }

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,8 +136,11 @@ 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,
})
} }
) )
@@ -156,8 +159,11 @@ export const findBooking = cache(async function getMemoizedFindBooking(
}) })
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

@@ -285,11 +285,6 @@ const nextConfig = {
source: `${myPages.sv}/:path*`, source: `${myPages.sv}/:path*`,
destination: `/sv/my-pages/:path*`, destination: `/sv/my-pages/:path*`,
}, },
{
source: "/:lang/hotelreservation/payment-callback/:status",
destination:
"/:lang/hotelreservation/payment-callback?status=:status",
},
// Find my booking // Find my booking
{ {
source: findMyBooking.en, source: findMyBooking.en,

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,27 +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 cancelBookingInput = z.object({ export const cancelBookingsInput = z.object({
confirmationNumber: z.string(), refIds: z.array(z.string()),
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), lang: z.nativeEnum(Lang),
})
export const cancelManyBookingsInput = z.object({
confirmationNumbers: z.array(z.string()),
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
}) })
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(),
@@ -161,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
@@ -173,20 +168,14 @@ 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 const findBookingInput = z.object({ export const findBookingInput = z.object({
@@ -199,4 +188,15 @@ export const findBookingInput = z.object({
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,19 +1,21 @@
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,
cancelBookingInput, cancelBookingsInput,
cancelManyBookingsInput,
createBookingInput, createBookingInput,
guaranteeBookingInput, guaranteeBookingInput,
priceChangeInput, priceChangeInput,
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({
@@ -74,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 })
@@ -110,24 +121,29 @@ export const bookingMutationRouter = router({
metricsPriceChange.success() metricsPriceChange.success()
return verifiedData.data return verifiedData.data.id
}), }),
cancel: safeProtectedServiceProcedure cancel: safeProtectedServiceProcedure
.input(cancelBookingInput) .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 { confirmationNumber, language } = input const { lang } = input
return await cancelBooking(confirmationNumber, language, token)
}),
cancelMany: safeProtectedServiceProcedure
.input(cancelManyBookingsInput)
.mutation(async function ({ ctx, input }) {
const token = ctx.session?.token.access_token ?? ctx.serviceToken
const { confirmationNumbers, language } = input
const responses = await Promise.allSettled( const responses = await Promise.allSettled(
confirmationNumbers.map((confirmationNumber) => confirmationNumbers.map((confirmationNumber) =>
cancelBooking(confirmationNumber, language, token) cancelBooking(confirmationNumber, lang)
) )
) )
@@ -152,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 })
@@ -191,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,
@@ -233,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,
@@ -244,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}`,
}, },
} }
) )
@@ -261,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
@@ -269,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"
@@ -297,7 +351,7 @@ export const bookingMutationRouter = router({
api.endpoints.v1.Booking.packages(confirmationNumber), api.endpoints.v1.Booking.packages(confirmationNumber),
{ {
headers, headers,
} as RequestInit, },
[["language", language], ...codes.map((code) => ["codes", code])] [["language", language], ...codes.map((code) => ["codes", code])]
) )

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,47 @@ import {
safeProtectedServiceProcedure, safeProtectedServiceProcedure,
serviceProcedure, serviceProcedure,
} from "@/server/trpc" } from "@/server/trpc"
import { getBookedHotelRoom } from "@/stores/my-stay"
import { calculateRefId, parseRefId } from "@/utils/refId"
import { getHotel } from "../hotels/utils" import { getHotel } from "../hotels/utils"
import { encrypt } from "../utils/encryption"
import { import {
bookingConfirmationInput,
createRefIdInput, createRefIdInput,
findBookingInput, findBookingInput,
getBookingInput, getBookingConfirmationErrorInput,
getBookingStatusInput, getBookingStatusInput,
getConfirmationCompletedInput,
getLinkedReservationsInput, getLinkedReservationsInput,
} from "./input" } from "./input"
import { createBookingSchema } from "./output" import { createBookingSchema } from "./output"
import { findBooking, getBookedHotelRoom, getBooking } from "./utils" import { findBooking, 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 +62,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,15 +78,29 @@ 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
),
} }
}), }),
findBooking: safeProtectedServiceProcedure findBooking: safeProtectedServiceProcedure
@@ -128,109 +152,248 @@ export const bookingQueryRouter = router({
throw serverErrorByStatus(404) throw serverErrorByStatus(404)
} }
const room = getBookedHotelRoom(
hotelData.roomCategories,
booking.roomTypeCode
)
if (!room) {
metricsFindBooking.dataError(
`Failed to extract booked room ${booking.roomTypeCode} from room categories for ${booking.hotelId}`,
{
roomTypeCode: booking.roomTypeCode,
hotelId: booking.hotelId,
}
)
throw serverErrorByStatus(404)
}
metricsFindBooking.success() metricsFindBooking.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 }) {
const { confirmationNumber, lastName } = input const { confirmationNumber, lastName } = input
const encryptedRefId = encrypt(`${confirmationNumber},${lastName}`) const encryptedRefId = calculateRefId(confirmationNumber, lastName)
if (!encryptedRefId) { if (!encryptedRefId) {
throw serverErrorByStatus(422, "Was not able to encrypt ref id") throw serverErrorByStatus(422, "Was not able to encrypt ref id")

View File

@@ -1,81 +1,163 @@
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()
const result: BookingSchema | null = await cache.cacheOrGet(
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
},
"1h"
)
metricsGetBooking.success()
return result
}
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,
lang: Lang
) {
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 metricsCancelBooking = cancelBookingCounter.init({
confirmationNumber,
lang,
})
metricsCancelBooking.start()
const token = getUserOrServiceToken()
const headers = {
Authorization: `Bearer ${token}`,
}
const booking = await getBooking(confirmationNumber, lang)
if (!booking) {
metricsCancelBooking.noDataError({ confirmationNumber })
return null
}
const { firstName, lastName, email } = booking.guest
const apiResponse = await api.remove(
api.endpoints.v1.Booking.cancel(confirmationNumber),
{ {
headers: { headers,
Authorization: `Bearer ${token}`, body: { firstName, lastName, email },
},
}, },
{ language: toApiLang(lang) } { language: toApiLang(lang) }
) )
if (!apiResponse.ok) { if (!apiResponse.ok) {
await metricsGetBooking.httpError(apiResponse) await metricsCancelBooking.httpError(apiResponse)
return null
// 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 apiJson = await apiResponse.json()
const booking = bookingConfirmationSchema.safeParse(apiJson) const verifiedData = createBookingSchema.safeParse(apiJson)
if (!booking.success) { if (!verifiedData.success) {
metricsGetBooking.validationError(booking.error) metricsCancelBooking.validationError(verifiedData.error)
throw badRequestError() return null
} }
metricsGetBooking.success() metricsCancelBooking.success()
return booking.data return verifiedData.data
} }
export async function findBooking( export async function findBooking(
@@ -124,7 +206,7 @@ export async function findBooking(
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const booking = bookingConfirmationSchema.safeParse(apiJson) const booking = bookingSchema.safeParse(apiJson)
if (!booking.success) { if (!booking.success) {
metricsGetBooking.validationError(booking.error) metricsGetBooking.validationError(booking.error)
throw badRequestError() throw badRequestError()
@@ -134,52 +216,3 @@ export async function findBooking(
return booking.data return booking.data
} }
export async function cancelBooking(
confirmationNumber: string,
language: string,
token: string
) {
const cancellationReason = {
reasonCode: "WEB-CANCEL",
reason: "WEB-CANCEL",
}
const cancelBookingCounter = createCounter("booking", "cancel")
const metricsCancelBooking = cancelBookingCounter.init({
cancellationReason,
confirmationNumber,
language,
})
metricsCancelBooking.start()
const headers = {
Authorization: `Bearer ${token}`,
}
const apiResponse = await api.remove(
api.endpoints.v1.Booking.cancel(confirmationNumber),
{
headers,
body: JSON.stringify(cancellationReason),
} as RequestInit,
{ language }
)
if (!apiResponse.ok) {
await metricsCancelBooking.httpError(apiResponse)
return false
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsCancelBooking.validationError(verifiedData.error)
return null
}
metricsCancelBooking.success()
return verifiedData.data
}

View File

@@ -4,10 +4,10 @@ import { myStay } from "@/constants/routes/myStay"
import { env } from "@/env/server" import { env } from "@/env/server"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import { encrypt } from "@/server/routers/utils/encryption"
import { createCounter } from "@/server/telemetry" import { createCounter } from "@/server/telemetry"
import { cache } from "@/utils/cache" import { cache } from "@/utils/cache"
import { encrypt } from "@/utils/encryption"
import * as maskValue from "@/utils/maskValue" import * as maskValue from "@/utils/maskValue"
import { isValidSession } from "@/utils/session" import { isValidSession } from "@/utils/session"
import { getCurrentWebUrl } from "@/utils/url" import { getCurrentWebUrl } from "@/utils/url"

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,8 +1,4 @@
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" export interface PromosProps {
hotelId: string
export interface PromosProps refId: string
extends Pick< }
BookingConfirmation["booking"],
"confirmationNumber" | "hotelId"
>,
Pick<BookingConfirmation["booking"]["guest"], "lastName"> {}

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

@@ -1,9 +1,14 @@
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export interface RoomProps { export interface RoomProps {
booking: BookingConfirmation["booking"] checkInDate: BookingConfirmation["booking"]["checkInDate"]
checkOutDate: BookingConfirmation["booking"]["checkOutDate"]
checkInTime: string checkInTime: string
checkOutTime: string checkOutTime: string
confirmationNumber: string
guest: BookingConfirmation["booking"]["guest"]
guaranteeInfo: BookingConfirmation["booking"]["guaranteeInfo"]
img: NonNullable<BookingConfirmation["room"]>["images"][number] img: NonNullable<BookingConfirmation["room"]>["images"][number]
rateDefinition: BookingConfirmation["booking"]["rateDefinition"]
roomName: NonNullable<BookingConfirmation["room"]>["name"] roomName: NonNullable<BookingConfirmation["room"]>["name"]
} }

View File

@@ -17,3 +17,7 @@ export type PriceChangeData = Array<{
totalPrice: number totalPrice: number
packagePrice?: number packagePrice?: number
} | null> } | null>
export interface TermsAndConditionsProps {
isFlexBookingTerms: boolean
}

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
} }

View File

@@ -0,0 +1,21 @@
import "server-only"
import { decrypt, encrypt } from "./encryption"
export function calculateRefId(confirmationNumber: string, lastName: string) {
const encryptedRefId = encrypt(`${confirmationNumber},${lastName}`)
return encryptedRefId
}
export function parseRefId(refId: string) {
const data = decrypt(refId)
const parts = data.split(",")
if (parts.length !== 2) {
throw new Error("Invalid refId format")
}
return {
confirmationNumber: parts[0],
lastName: parts[1],
}
}

Some files were not shown because too many files have changed in this diff Show More