Merged in SW-3459-setup-booking-confirmation-page-in-sas (pull request #2794)

Setup booking-confirmation page in SAS

* Setup booking-confirmation page in SAS
move booking-confirmation tracking to booking-flow

* remove unused param

* Add test cards to documentation

* Fix payment callback page to use correct status


Approved-by: Anton Gunnarsson
Approved-by: Hrishikesh Vaipurkar
This commit is contained in:
Joakim Jäderberg
2025-09-11 12:19:26 +00:00
parent 6b01dd9a8f
commit 4893eb8b25
15 changed files with 355 additions and 32 deletions

View File

@@ -48,4 +48,5 @@ For more details see the respective apps and packages' README files.
## More documentation
- [Icons](./docs/icons.md)
- [Payment](./docs/payment.md)
- [Translations (i18n)](./docs/translations.md)

View File

@@ -1,4 +1,22 @@
export default async function BookingConfirmationPage() {
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
return <div>booking-confirmation</div>
import { BookingConfirmationPage as BookingConfirmationPagePrimitive } from "@scandic-hotels/booking-flow/pages/BookingConfirmationPage"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import type { LangParams, PageArgs } from "@/types/params"
export default async function BookingConfirmationPage(
props: PageArgs<LangParams>
) {
const searchParams = await props.searchParams
const lang = await getLang()
const intl = await getIntl()
return (
<BookingConfirmationPagePrimitive
intl={intl}
lang={lang}
searchParams={searchParams}
/>
)
}

View File

@@ -1,9 +1,13 @@
import { PaymentCallbackPage as PaymentCallbackPagePrimitive } from "@scandic-hotels/booking-flow/pages/PaymentCallbackPage"
import { logger } from "@scandic-hotels/common/logger"
import type { PaymentCallbackStatusEnum } from "@scandic-hotels/common/constants/paymentCallbackStatusEnum"
import type { LangParams, PageArgs } from "@/types/params"
export default async function PaymentCallbackPage(props: PageArgs<LangParams>) {
export default async function PaymentCallbackPage(
props: PageArgs<LangParams & { status: string }>
) {
const searchParams = await props.searchParams
const params = await props.params
logger.debug(`[payment-callback] callback started`)
@@ -21,6 +25,7 @@ export default async function PaymentCallbackPage(props: PageArgs<LangParams>) {
lang={lang}
userAccessToken={userAccessToken}
searchParams={searchParams}
status={params.status as PaymentCallbackStatusEnum}
/>
)
}

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "PORT=3001 NEXT_PUBLIC_PORT=3001 next dev",
"dev": "NODE_OPTIONS=--openssl-legacy-provider PORT=3001 NEXT_PUBLIC_PORT=3001 next dev",
"build": "next build",
"start": "node .next/standalone/server.js",
"lint": "next lint --max-warnings 0 && tsc --noEmit",

View File

@@ -1,6 +1,5 @@
import { BookingConfirmationPage as BookingConfirmationPagePrimitive } from "@scandic-hotels/booking-flow/pages/BookingConfirmationPage"
import Tracking from "@/components/HotelReservation/BookingConfirmation/Tracking"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
@@ -18,12 +17,6 @@ export default async function BookingConfirmationPage(
intl={intl}
lang={lang}
searchParams={searchParams}
renderTracking={(props) => (
<Tracking
bookingConfirmation={props.bookingConfirmation}
refId={props.refId}
/>
)}
/>
)
}

View File

@@ -33,6 +33,8 @@ export default async function PaymentCallbackPage(
lang={lang}
userAccessToken={userAccessToken}
searchParams={searchParams}
// TODO refactor this route to get this from params instead of rewriting in next.config
status={searchParams.status as PaymentCallbackStatusEnum}
/>
)
}

13
docs/payment.md Normal file
View File

@@ -0,0 +1,13 @@
# Payment
## Testing
| Type | Card number | CVV |
| ---- | --------------------- | --- |
| Visa | `4111 1111 1111 1111` | 123 |
| Visa | `4242 4242 4242 4242` | 123 |
- Expiration date needs to be in the future
- For OTP
- ✅ 4000 for success
- ❌ 4001 for failure

View File

@@ -0,0 +1,83 @@
"use client"
import { useEffect, useState } from "react"
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { clearPaymentInfoSessionStorage } from "../../../components/EnterDetails/Payment/helpers"
import useLang from "../../../hooks/useLang"
import { useSearchHistory } from "../../../hooks/useSearchHistory"
import { useBookingConfirmationStore } from "../../../stores/booking-confirmation"
import { getTracking } from "./tracking"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { Room } from "../../../types/stores/booking-confirmation"
export default function BookingConfirmationTracking({
bookingConfirmation,
refId,
}: {
bookingConfirmation: BookingConfirmation
refId: string
}) {
const lang = useLang()
const bookingRooms = useBookingConfirmationStore((state) => state.rooms)
const [loadedBookingConfirmationRefId] = useState(() => {
if (typeof window !== "undefined") {
return sessionStorage.getItem("loadedBookingConfirmationRefId")
}
return null
})
useEffect(() => {
sessionStorage.setItem("loadedBookingConfirmationRefId", refId)
}, [refId])
const searchHistory = useSearchHistory()
const searchTerm = searchHistory.searchHistory[0]?.name
let trackingData = null
if (bookingRooms.every(Boolean)) {
const rooms = bookingRooms.filter((room): room is Room => !!room)
trackingData = getTracking(
lang,
bookingConfirmation.booking,
bookingConfirmation.hotel,
rooms,
searchTerm
)
}
useEffect(() => {
if (trackingData?.paymentInfo) {
clearPaymentInfoSessionStorage()
}
}, [trackingData])
if (!trackingData) {
return null
}
const { hotelsTrackingData, pageTrackingData, paymentInfo, ancillaries } =
trackingData
return (
<TrackingSDK
pageData={pageTrackingData}
hotelInfo={
loadedBookingConfirmationRefId === refId
? undefined
: hotelsTrackingData
}
paymentInfo={
loadedBookingConfirmationRefId === refId ? undefined : paymentInfo
}
ancillaries={
loadedBookingConfirmationRefId === refId ? undefined : ancillaries
}
/>
)
}

View File

@@ -0,0 +1,215 @@
import { createHash } from "crypto"
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { CancellationRuleEnum } from "@scandic-hotels/common/constants/booking"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { RateEnum } from "@scandic-hotels/common/constants/rate"
import {
TrackingChannelEnum,
type TrackingSDKAncillaries,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
type TrackingSDKPaymentInfo,
} from "@scandic-hotels/tracking/types"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import { readPaymentInfoFromSessionStorage } from "../../../components/EnterDetails/Payment/helpers"
import { invertedBedTypeMap } from "../../../utils/SelectRate"
import { getSpecialRoomType } from "../../../utils/specialRoomType"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { RateDefinition } from "@scandic-hotels/trpc/types/roomAvailability"
import type { Room } from "../../../types/stores/booking-confirmation"
function getRate(cancellationRule: RateDefinition["cancellationRule"] | null) {
switch (cancellationRule) {
case "CancellableBefore6PM":
return RateEnum.flex
case "Changeable":
return RateEnum.change
case "NotCancellable":
return RateEnum.save
default:
return ""
}
}
function mapAncillaryPackage(
ancillaryPackage: BookingConfirmation["booking"]["packages"][number],
operaId: string
) {
const isPoints = ancillaryPackage.currency === CurrencyEnum.POINTS
return {
hotelid: operaId,
productCategory: "", // TODO: Add category
productId: ancillaryPackage.code,
productName: ancillaryPackage.description,
productPoints: isPoints ? ancillaryPackage.totalPrice : 0,
productPrice: isPoints ? 0 : ancillaryPackage.totalPrice,
productType:
ancillaryPackage.code === BreakfastPackageEnum.REGULAR_BREAKFAST
? "food"
: "room preference",
productUnits: ancillaryPackage.totalUnit,
productDeliveryTime: "",
}
}
export function getTracking(
lang: Lang,
booking: BookingConfirmation["booking"],
hotel: BookingConfirmation["hotel"],
rooms: Room[],
searchTerm?: string
) {
const arrivalDate = new Date(booking.checkInDate)
const departureDate = new Date(booking.checkOutDate)
const paymentInfoSessionData = readPaymentInfoFromSessionStorage()
const pageTrackingData: TrackingSDKPageData = {
channel: TrackingChannelEnum.hotelreservation,
domainLanguage: lang,
pageId: "booking-confirmation",
pageName: `hotelreservation|confirmation`,
pageType: "confirmation",
siteSections: `hotelreservation|confirmation`,
siteVersion: "new-web",
}
const noOfAdults = rooms.map((r) => r.adults).join(",")
const noOfChildren = rooms.map((r) => r.childrenAges?.length ?? 0).join(",")
const noOfRooms = rooms.length
const isFlexBooking =
booking.rateDefinition.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM
const isGuaranteedFlexBooking = booking.guaranteeInfo && isFlexBooking
const ancillaries: TrackingSDKAncillaries = rooms
.flatMap((r) => r.packages)
.filter(
(p) =>
p.code === RoomPackageCodeEnum.PET_ROOM ||
p.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
.map((pkg) => mapAncillaryPackage(pkg, hotel.operaId))
const hotelsTrackingData: TrackingSDKHotelInfo = {
ageOfChildren: rooms.map((r) => r.childrenAges?.join(",") ?? "").join("|"),
analyticsRateCode: rooms
.map((r) => getRate(r.rateDefinition.cancellationRule))
.join("|"),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
bedType: rooms
.map((r) => r.bedType)
.join(",")
.toLowerCase(),
bnr: rooms.map((r) => r.confirmationNumber).join(","),
bookingCode: rooms.map((room) => room.bookingCode ?? "n/a").join(", "),
bookingCodeAvailability: booking.bookingCode
? rooms.map((room) => (room.bookingCode ? "true" : "false")).join(", ")
: undefined,
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
breakfastOption: rooms
.map((r) => {
if (r.breakfastIncluded || r.breakfast) {
return "breakfast buffet"
}
return "no breakfast"
})
.join(","),
childBedPreference: rooms
.map(
(r) =>
r.childBedPreferences
.map((cbp) =>
Array(cbp.quantity).fill(invertedBedTypeMap[cbp.bedType])
)
.join(",") ?? ""
)
.join("|"),
country: hotel?.address.country,
departureDate: format(departureDate, "yyyy-MM-dd"),
duration: differenceInCalendarDays(departureDate, arrivalDate),
hotelID: hotel.operaId,
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
noOfAdults,
noOfChildren,
noOfRooms,
rateCode: rooms.map((r) => r.rateDefinition.rateCode).join(","),
rateCodeCancellationRule: rooms
.map((r) => r.rateDefinition.cancellationRule)
.join(",")
.toLowerCase(),
rateCodeName: rooms.map(constructRateCodeName).join(","),
rateCodeType: rooms.map((r) => r.rateCodeType?.toLowerCase()).join(","),
region: hotel?.address.city,
revenueCurrencyCode: [...new Set(rooms.map((r) => r.currencyCode))].join(
","
),
rewardNight: booking.roomPoints > 0 ? "yes" : "no",
rewardNightAvailability: booking.roomPoints > 0 ? "true" : "false",
points: booking.roomPoints > 0 ? booking.roomPoints : undefined,
roomPrice: rooms.reduce((total, room) => total + room.roomPrice, 0),
roomTypeCode: rooms.map((r) => r.roomTypeCode ?? "").join(","),
searchTerm,
searchType: "hotel",
specialRoomType: rooms
.map((room) => getSpecialRoomType(room.packages))
.join(","),
totalPrice: rooms.reduce((total, room) => total + room.totalPrice, 0),
lateArrivalGuarantee: booking.rateDefinition.mustBeGuaranteed
? "mandatory"
: isFlexBooking
? booking.guaranteeInfo
? "yes"
: "no"
: "na",
guaranteedProduct: isGuaranteedFlexBooking ? "room" : "na",
emailId: getSHAHash(booking.guest.email),
mobileNumber: getSHAHash(booking.guest.phoneNumber),
}
const paymentInfo: TrackingSDKPaymentInfo = {
paymentStatus: isGuaranteedFlexBooking
? "glacardsaveconfirmed"
: "confirmed",
type:
booking.guaranteeInfo?.cardType ?? paymentInfoSessionData?.paymentMethod,
}
return {
hotelsTrackingData,
pageTrackingData,
paymentInfo,
ancillaries,
}
}
function constructRateCodeName(room: Room) {
if (room.cheques) {
return "corporate cheque"
} else if (room.vouchers) {
return "voucher"
} else if (room.roomPoints) {
return "redemption"
}
const rate = getRate(room.rateDefinition.cancellationRule)
const bookingCodeStr = room.bookingCode ? room.bookingCode.toUpperCase() : ""
const breakfastIncludedStr = room.breakfastIncluded
? "incl. breakfast"
: "excl. breakfast"
return [bookingCodeStr, rate, breakfastIncludedStr]
.filter(Boolean)
.join(" - ")
}
function getSHAHash(key: string) {
return createHash("sha256").update(key).digest("hex")
}

View File

@@ -15,6 +15,7 @@ import { PaymentDetails } from "./PaymentDetails"
import { Promos } from "./Promos"
import { Receipt } from "./Receipt"
import { Rooms } from "./Rooms"
import BookingConfirmationTracking from "./Tracking"
import { mapRoomState } from "./utils"
import styles from "./bookingConfirmation.module.css"
@@ -26,17 +27,12 @@ type BookingConfirmationProps = {
intl: IntlShape
refId: string
membershipFailedError: boolean
renderTracking: (trackingProps: {
bookingConfirmation: BookingConfirmation
refId: string
}) => React.ReactNode
}
export async function BookingConfirmation({
intl,
refId,
membershipFailedError,
renderTracking,
}: BookingConfirmationProps) {
const bookingConfirmation = await getBookingConfirmation(refId)
@@ -112,7 +108,10 @@ export async function BookingConfirmation({
</aside>
</Confirmation>
{renderTracking({ bookingConfirmation, refId })}
<BookingConfirmationTracking
bookingConfirmation={bookingConfirmation}
refId={refId}
/>
</BookingConfirmationProvider>
)
}

View File

@@ -8,7 +8,6 @@ import { getBookingConfirmation } from "../trpc/memoizedRequests/getBookingConfi
import { MEMBERSHIP_FAILED_ERROR } from "../types/membershipFailedError"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { BookingConfirmation as BookingConfirmationType } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { IntlShape } from "react-intl"
import type { NextSearchParams } from "../types"
@@ -17,15 +16,10 @@ export async function BookingConfirmationPage({
intl,
lang,
searchParams,
renderTracking,
}: {
intl: IntlShape
lang: Lang
searchParams: NextSearchParams
renderTracking: (trackingProps: {
bookingConfirmation: BookingConfirmationType
refId: string
}) => React.ReactNode
}) {
const refId = searchParams.RefId?.toString()
@@ -56,7 +50,6 @@ export async function BookingConfirmationPage({
intl={intl}
refId={refId}
membershipFailedError={membershipFailedError}
renderTracking={renderTracking}
/>
)
}

View File

@@ -23,19 +23,20 @@ type PaymentCallbackPageProps = {
lang: Lang
searchParams: NextSearchParams
userAccessToken: string | null
status: PaymentCallbackStatusEnum
}
export async function PaymentCallbackPage({
lang,
userAccessToken,
searchParams,
status,
}: PaymentCallbackPageProps) {
const { status, confirmationNumber } = searchParams
const { confirmationNumber } = searchParams
if (
!status ||
!confirmationNumber ||
typeof confirmationNumber !== "string" ||
typeof status !== "string"
typeof confirmationNumber !== "string"
) {
logger.error(
`[payment-callback] missing status or confirmationNumber in search params`
@@ -143,8 +144,7 @@ export async function PaymentCallbackPage({
<HandleErrorCallback
returnUrl={returnUrl.toString()}
searchObject={searchObject}
// TODO we should parse instead of cast
status={status as PaymentCallbackStatusEnum}
status={status}
errorMessage={errorMessage}
/>
)

View File

@@ -65,6 +65,7 @@
"./stores/enter-details": "./lib/stores/enter-details/index.ts",
"./stores/enter-details/types": "./lib/stores/enter-details/types.ts",
"./stores/hotels-map": "./lib/stores/hotels-map.ts",
"./stores/booking-confirmation": "./lib/stores/booking-confirmation/index.ts",
"./types/components/bookingConfirmation/bookingConfirmation": "./lib/types/components/bookingConfirmation/bookingConfirmation.ts",
"./types/components/findMyBooking/additionalInfoCookieValue": "./lib/types/components/findMyBooking/additionalInfoCookieValue.ts",
"./types/components/promo/promoProps": "./lib/types/components/promo/promoProps.ts",