Merge master
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton"
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function Tracking({
|
|||||||
getTracking(
|
getTracking(
|
||||||
lang,
|
lang,
|
||||||
bookingConfirmation.booking,
|
bookingConfirmation.booking,
|
||||||
bookingConfirmation.hotel,
|
bookingConfirmation.hotelData.hotel,
|
||||||
rooms
|
rooms
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,11 @@ export function Ancillaries({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AddedAncillaries booking={booking} ancillaries={uniqueAncillaries} />
|
<AddedAncillaries
|
||||||
|
booking={booking}
|
||||||
|
ancillaries={uniqueAncillaries}
|
||||||
|
refId={refId}
|
||||||
|
/>
|
||||||
|
|
||||||
<AncillaryFlowModalWrapper>
|
<AncillaryFlowModalWrapper>
|
||||||
<AddAncillaryFlowModal
|
<AddAncillaryFlowModal
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -40,7 +40,7 @@ export default function FinalConfirmation({
|
|||||||
defaultMessage: "We’re sorry that things didn’t work out.",
|
defaultMessage: "We’re sorry that things didn’t 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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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 ?? "",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
160
apps/scandic-web/components/Lightbox/Gallery/gallery.module.css
Normal file
160
apps/scandic-web/components/Lightbox/Gallery/gallery.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
57
apps/scandic-web/components/Lightbox/lightbox.module.css
Normal file
57
apps/scandic-web/components/Lightbox/lightbox.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
])
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
|||||||
@@ -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])]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export interface ManageBookingProps {
|
export interface ManageBookingProps {
|
||||||
bookingUrl: string
|
refId: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"> {}
|
|
||||||
|
|||||||
@@ -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[]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,7 @@ export type PriceChangeData = Array<{
|
|||||||
totalPrice: number
|
totalPrice: number
|
||||||
packagePrice?: number
|
packagePrice?: number
|
||||||
} | null>
|
} | null>
|
||||||
|
|
||||||
|
export interface TermsAndConditionsProps {
|
||||||
|
isFlexBookingTerms: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type Room = Pick<
|
|||||||
| "linkedReservations"
|
| "linkedReservations"
|
||||||
| "multiRoom"
|
| "multiRoom"
|
||||||
| "rateDefinition"
|
| "rateDefinition"
|
||||||
|
| "refId"
|
||||||
| "reservationStatus"
|
| "reservationStatus"
|
||||||
| "roomPoints"
|
| "roomPoints"
|
||||||
| "roomTypeCode"
|
| "roomTypeCode"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
21
apps/scandic-web/utils/refId.ts
Normal file
21
apps/scandic-web/utils/refId.ts
Normal 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
Reference in New Issue
Block a user