diff --git a/README.md b/README.md index aee9512e4..f4adfa9f1 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Inside the `/packages` directory, you'll find our shared libraries and utilities > A note about dependencies between our apps and packages: > In general all apps are allowed to depend on any package, but packages have a few caveats: -> +> > `design-system` should never import from `booking-flow` or `trpc`. > `common` should never import from anything except `typescript-config`. @@ -48,4 +48,5 @@ For more details see the respective apps and packages' README files. ## More documentation - [Icons](./docs/icons.md) -- [Translations (i18n)](./docs/translations.md) \ No newline at end of file +- [Payment](./docs/payment.md) +- [Translations (i18n)](./docs/translations.md) diff --git a/apps/partner-sas/app/[lang]/hotelreservation/(confirmation)/booking-confirmation/page.tsx b/apps/partner-sas/app/[lang]/hotelreservation/(confirmation)/booking-confirmation/page.tsx index cef2be4c8..54f0f1650 100644 --- a/apps/partner-sas/app/[lang]/hotelreservation/(confirmation)/booking-confirmation/page.tsx +++ b/apps/partner-sas/app/[lang]/hotelreservation/(confirmation)/booking-confirmation/page.tsx @@ -1,4 +1,22 @@ -export default async function BookingConfirmationPage() { - // eslint-disable-next-line formatjs/no-literal-string-in-jsx - return
booking-confirmation
+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 +) { + const searchParams = await props.searchParams + const lang = await getLang() + const intl = await getIntl() + + return ( + + ) } diff --git a/apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/layout.module.css b/apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/[status]/layout.module.css similarity index 100% rename from apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/layout.module.css rename to apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/[status]/layout.module.css diff --git a/apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/layout.tsx b/apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/[status]/layout.tsx similarity index 100% rename from apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/layout.tsx rename to apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/[status]/layout.tsx diff --git a/apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/page.tsx b/apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/[status]/page.tsx similarity index 73% rename from apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/page.tsx rename to apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/[status]/page.tsx index c8f0a8f00..1eb193a27 100644 --- a/apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/page.tsx +++ b/apps/partner-sas/app/[lang]/hotelreservation/(payment-callback)/payment-callback/[status]/page.tsx @@ -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) { +export default async function PaymentCallbackPage( + props: PageArgs +) { 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) { lang={lang} userAccessToken={userAccessToken} searchParams={searchParams} + status={params.status as PaymentCallbackStatusEnum} /> ) } diff --git a/apps/partner-sas/package.json b/apps/partner-sas/package.json index 43e8cc90e..a3fc1afda 100644 --- a/apps/partner-sas/package.json +++ b/apps/partner-sas/package.json @@ -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", diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx index ec34c7f5d..b772d349d 100644 --- a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx @@ -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) => ( - - )} /> ) } diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx index ab4d47d21..45daba87f 100644 --- a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx @@ -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} /> ) } diff --git a/docs/payment.md b/docs/payment.md new file mode 100644 index 000000000..104b7d7fe --- /dev/null +++ b/docs/payment.md @@ -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 diff --git a/packages/booking-flow/lib/components/BookingConfirmation/Tracking/index.tsx b/packages/booking-flow/lib/components/BookingConfirmation/Tracking/index.tsx new file mode 100644 index 000000000..526fc64e4 --- /dev/null +++ b/packages/booking-flow/lib/components/BookingConfirmation/Tracking/index.tsx @@ -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 ( + + ) +} diff --git a/packages/booking-flow/lib/components/BookingConfirmation/Tracking/tracking.ts b/packages/booking-flow/lib/components/BookingConfirmation/Tracking/tracking.ts new file mode 100644 index 000000000..5175a2dd2 --- /dev/null +++ b/packages/booking-flow/lib/components/BookingConfirmation/Tracking/tracking.ts @@ -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") +} diff --git a/packages/booking-flow/lib/components/BookingConfirmation/index.tsx b/packages/booking-flow/lib/components/BookingConfirmation/index.tsx index 0ed523abd..b3834f0d3 100644 --- a/packages/booking-flow/lib/components/BookingConfirmation/index.tsx +++ b/packages/booking-flow/lib/components/BookingConfirmation/index.tsx @@ -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({ - {renderTracking({ bookingConfirmation, refId })} + ) } diff --git a/packages/booking-flow/lib/pages/BookingConfirmationPage.tsx b/packages/booking-flow/lib/pages/BookingConfirmationPage.tsx index 8bfb5d7a9..8c7ba2e2f 100644 --- a/packages/booking-flow/lib/pages/BookingConfirmationPage.tsx +++ b/packages/booking-flow/lib/pages/BookingConfirmationPage.tsx @@ -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} /> ) } diff --git a/packages/booking-flow/lib/pages/PaymentCallbackPage.tsx b/packages/booking-flow/lib/pages/PaymentCallbackPage.tsx index 709b83dc9..6f3f1eccd 100644 --- a/packages/booking-flow/lib/pages/PaymentCallbackPage.tsx +++ b/packages/booking-flow/lib/pages/PaymentCallbackPage.tsx @@ -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({ ) diff --git a/packages/booking-flow/package.json b/packages/booking-flow/package.json index c1431bdc6..e4453ff4b 100644 --- a/packages/booking-flow/package.json +++ b/packages/booking-flow/package.json @@ -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",