diff --git a/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx b/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx index 77cf5a17a..65e6ed593 100644 --- a/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx +++ b/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx @@ -32,7 +32,6 @@ import { env } from "../../../../env/client" import { useBookingFlowContext } from "../../../hooks/useBookingFlowContext" import { clearBookingWidgetState } from "../../../hooks/useBookingWidgetState" import { useHandleBookingStatus } from "../../../hooks/useHandleBookingStatus" -import { useIsLoggedIn } from "../../../hooks/useIsLoggedIn" import useLang from "../../../hooks/useLang" import { useEnterDetailsStore } from "../../../stores/enter-details" import ConfirmBooking from "../Confirm" @@ -44,6 +43,7 @@ import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum, + mustGuaranteeBooking, writePaymentInfoToSessionStorage, } from "./helpers" import { type PaymentFormData, paymentSchema } from "./schema" @@ -51,6 +51,7 @@ import { getPaymentHeadingConfig } from "./utils" import styles from "./payment.module.css" +import type { Lang } from "@scandic-hotels/common/constants/language" import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema" import type { CreditCard } from "@scandic-hotels/trpc/types/user" @@ -74,10 +75,13 @@ export default function PaymentClient({ const intl = useIntl() const pathname = usePathname() const searchParams = useSearchParams() - const isUserLoggedIn = useIsLoggedIn() const { getTopOffset } = useStickyPosition({}) - const { user } = useBookingFlowContext() - + const { user, isLoggedIn } = useBookingFlowContext() + const [refId, setRefId] = useState("") + const [isPollingForBookingStatus, setIsPollingForBookingStatus] = + useState(false) + const [priceChangeData, setPriceChangeData] = + useState(null) const [showBookingAlert, setShowBookingAlert] = useState(false) const { @@ -96,35 +100,16 @@ export default function PaymentClient({ runPreSubmitCallbacks: state.actions.runPreSubmitCallbacks, })) - const bookingMustBeGuaranteed = rooms.some(({ room }, idx) => { - if (idx === 0 && isUserLoggedIn && room.memberMustBeGuaranteed) { - return true - } - - if ( - (room.guest.join || room.guest.membershipNo) && - booking.rooms[idx].counterRateCode - ) { - return room.memberMustBeGuaranteed - } - - return room.mustBeGuaranteed + const bookingMustBeGuaranteed = mustGuaranteeBooking({ + isUserLoggedIn: isLoggedIn, + booking, + rooms, }) - const [refId, setRefId] = useState("") - const [isPollingForBookingStatus, setIsPollingForBookingStatus] = - useState(false) - - const [priceChangeData, setPriceChangeData] = - useState(null) - - const { toDate, fromDate, hotelId } = booking - const hasPrepaidRates = rooms.some(hasPrepaidRate) const hasFlexRates = rooms.some(hasFlexibleRate) const hasOnlyFlexRates = rooms.every(hasFlexibleRate) const hasMixedRates = hasPrepaidRates && hasFlexRates - const isRedemptionBooking = booking.searchType === SEARCH_TYPE_REDEMPTION const methods = useForm({ defaultValues: { @@ -171,9 +156,9 @@ export default function PaymentClient({ const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata) if (hasPriceChange) { - const priceChangeData = booking.rooms.map( - (room) => room.priceChangedMetadata || null - ) + const priceChangeData = booking.rooms + .map((room) => room.priceChangedMetadata || null) + .filter(isNotNull) setPriceChangeData(priceChangeData) } else { setIsPollingForBookingStatus(true) @@ -204,19 +189,9 @@ export default function PaymentClient({ }, }) - const bookingStatus = useHandleBookingStatus({ - refId, - expectedStatuses: [BookingStatusEnum.BookingCompleted], - maxRetries, - retryInterval, - enabled: isPollingForBookingStatus, - }) - - const handlePaymentError = useCallback( + const { toDate, fromDate, hotelId } = booking + const trackPaymentError = useCallback( (errorMessage: string) => { - setShowBookingAlert(true) - setIsSubmitting(false) - const currentPaymentMethod = methods.getValues("paymentMethod") const smsEnable = methods.getValues("smsConfirmation") const guarantee = methods.getValues("guarantee") @@ -255,52 +230,30 @@ export default function PaymentClient({ } }, [ - methods, - savedCreditCards, - hotelId, bookingMustBeGuaranteed, hasOnlyFlexRates, - setIsSubmitting, + hotelId, + methods, + savedCreditCards, ] ) - useEffect(() => { - if (bookingStatus?.data?.booking.paymentUrl) { - router.push(bookingStatus.data.booking.paymentUrl) - } else if ( - bookingStatus?.data?.booking.reservationStatus === - BookingStatusEnum.BookingCompleted - ) { - const mainRoom = bookingStatus.data.booking.rooms[0] - clearBookingWidgetState() - // Cookie is used by Booking Confirmation page to validate that the user came from payment callback - document.cookie = `bcsig=${bookingStatus.data.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict` - const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}` - router.push(confirmationUrl) - } else if (bookingStatus.isTimeout) { - handlePaymentError("Timeout") - } - }, [ - bookingStatus.data, - bookingStatus.isTimeout, - router, - intl, - lang, - handlePaymentError, - ]) + const handlePaymentError = useCallback( + (errorMessage: string) => { + setShowBookingAlert(true) + setIsSubmitting(false) - const getPaymentMethod = useCallback( - (paymentMethod: string | null | undefined): PaymentMethodEnum => { - if (hasFlexRates) { - return PaymentMethodEnum.card - } - return paymentMethod && isPaymentMethodEnum(paymentMethod) - ? paymentMethod - : PaymentMethodEnum.card + trackPaymentError(errorMessage) }, - [hasFlexRates] + [setIsSubmitting, trackPaymentError] ) + useBookingStatusRedirect({ + refId, + enabled: isPollingForBookingStatus, + onError: handlePaymentError, + }) + const scrollToInvalidField = useCallback(async (): Promise => { // If any room is not complete/valid, scroll to the first invalid field, this is needed as rooms and other fields are in separate forms @@ -308,20 +261,12 @@ export default function PaymentClient({ const errorNames = Object.keys(methods.formState.errors) const firstIncompleteRoomIndex = rooms.findIndex((room) => !room.isComplete) - const scrollToElement = (el: HTMLElement) => { - const offset = getTopOffset() - const top = el.getBoundingClientRect().top + window.scrollY - offset - 20 - window.scrollTo({ top, behavior: "smooth" }) - const input = el.querySelector("input") - input?.focus({ preventScroll: true }) - } - if (invalidField) { - scrollToElement(invalidField) + scrollToElement(invalidField, getTopOffset()) } else if (errorNames.length > 0) { const firstErrorEl = document.querySelector(`[name="${errorNames[0]}"]`) if (firstErrorEl) { - scrollToElement(firstErrorEl as HTMLElement) + scrollToElement(firstErrorEl as HTMLElement, getTopOffset()) } } @@ -338,63 +283,32 @@ export default function PaymentClient({ return } - const paymentMethod = getPaymentMethod(data.paymentMethod) + const paymentMethod = getPaymentMethod(data.paymentMethod, hasFlexRates) const savedCreditCard = savedCreditCards?.find( (card) => card.id === data.paymentMethod ) - const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback` const guarantee = data.guarantee - const useSavedCard = savedCreditCard - ? { - card: { - alias: savedCreditCard.alias, - expiryDate: savedCreditCard.expirationDate, - cardType: savedCreditCard.cardType, - }, - } - : {} const shouldUsePayment = guarantee || bookingMustBeGuaranteed || !hasOnlyFlexRates const payment = shouldUsePayment - ? { - paymentMethod: paymentMethod, - ...useSavedCard, - success: `${paymentRedirectUrl}/success`, - error: `${paymentRedirectUrl}/error`, - cancel: `${paymentRedirectUrl}/cancel`, - } + ? getPaymentData({ paymentMethod, savedCreditCard, lang }) : undefined + const paymentMethodType = savedCreditCard ? savedCreditCard.type : paymentMethod - if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) { - const lateArrivalGuarantee = guarantee ? "yes" : "mandatory" - writeGlaToSessionStorage( - lateArrivalGuarantee, - hotelId, - paymentMethodType, - !!savedCreditCard - ) - trackGlaSaveCardAttempt({ - hotelId, - hasSavedCreditCard: !!savedCreditCard, - creditCardType: savedCreditCard?.cardType, - lateArrivalGuarantee, - }) - } else if (!hasOnlyFlexRates) { - trackPaymentEvent({ - event: "paymentAttemptStart", - hotelId, - method: paymentMethodType, - isSavedCreditCard: !!savedCreditCard, - smsEnable: data.smsConfirmation, - status: "attempt", - }) - } - writePaymentInfoToSessionStorage(paymentMethodType, !!savedCreditCard) + trackPaymentEvents({ + isSavedCreditCard: !!savedCreditCard, + paymentMethodType, + guarantee, + smsEnable: data.smsConfirmation, + bookingMustBeGuaranteed, + hasOnlyFlexRates, + hotelId, + }) const payload: CreateBookingInput = { checkInDate: fromDate, @@ -406,7 +320,7 @@ export default function PaymentClient({ ({ room }, idx): CreateBookingInput["rooms"][number] => { const isMainRoom = idx === 0 let rateCode = "" - if (isMainRoom && isUserLoggedIn) { + if (isMainRoom && isLoggedIn) { rateCode = booking.rooms[idx].rateCode } else if ( (room.guest.join || room.guest.membershipNo) && @@ -500,17 +414,17 @@ export default function PaymentClient({ [ setIsSubmitting, scrollToInvalidField, - getPaymentMethod, + hasFlexRates, savedCreditCards, - lang, bookingMustBeGuaranteed, hasOnlyFlexRates, + lang, fromDate, toDate, hotelId, rooms, initiateBooking, - isUserLoggedIn, + isLoggedIn, booking.rooms, user?.data?.partnerLoyaltyNumber, ] @@ -526,6 +440,7 @@ export default function PaymentClient({ const { preHeading, heading, subHeading, showLearnMore } = getPaymentHeadingConfig(intl, bookingMustBeGuaranteed, hasOnlyFlexRates) + const isRedemptionBooking = booking.searchType === SEARCH_TYPE_REDEMPTION return (
) } + +const scrollToElement = (el: HTMLElement, offset: number) => { + const top = el.getBoundingClientRect().top + window.scrollY - offset - 20 + window.scrollTo({ top, behavior: "smooth" }) + const input = el.querySelector("input") + input?.focus({ preventScroll: true }) +} + +const getPaymentMethod = ( + paymentMethod: string | null | undefined, + hasFlexRates: boolean +): PaymentMethodEnum => { + if (hasFlexRates) { + return PaymentMethodEnum.card + } + return paymentMethod && isPaymentMethodEnum(paymentMethod) + ? paymentMethod + : PaymentMethodEnum.card +} + +function createPaymentCallbackUrl(lang: Lang) { + return `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback` +} + +function useBookingStatusRedirect({ + refId, + enabled, + onError, +}: { + refId: string + enabled: boolean + onError: (errorMessage: string) => void +}) { + const router = useRouter() + const lang = useLang() + const intl = useIntl() + + const bookingStatus = useHandleBookingStatus({ + refId, + expectedStatuses: [BookingStatusEnum.BookingCompleted], + maxRetries, + retryInterval, + enabled, + }) + + useEffect(() => { + if (bookingStatus?.data?.booking.paymentUrl) { + router.push(bookingStatus.data.booking.paymentUrl) + return + } + + if ( + bookingStatus?.data?.booking.reservationStatus === + BookingStatusEnum.BookingCompleted + ) { + const mainRoom = bookingStatus.data.booking.rooms[0] + clearBookingWidgetState() + // Cookie is used by Booking Confirmation page to validate that the user came from payment callback + document.cookie = `bcsig=${bookingStatus.data.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict` + const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}` + router.push(confirmationUrl) + return + } + + if (bookingStatus.isTimeout) { + onError("Timeout") + } + }, [bookingStatus.data, bookingStatus.isTimeout, router, intl, lang, onError]) +} + +function getPaymentData({ + paymentMethod, + savedCreditCard, + lang, +}: { + paymentMethod: PaymentMethodEnum + savedCreditCard?: CreditCard + lang: Lang +}) { + const paymentRedirectUrl = createPaymentCallbackUrl(lang) + + return { + paymentMethod: paymentMethod, + success: `${paymentRedirectUrl}/success`, + error: `${paymentRedirectUrl}/error`, + cancel: `${paymentRedirectUrl}/cancel`, + card: savedCreditCard + ? { + alias: savedCreditCard.alias, + expiryDate: savedCreditCard.expirationDate, + cardType: savedCreditCard.cardType, + } + : undefined, + } +} + +function isNotNull(value: T | null): value is T { + return value !== null +} + +function trackPaymentEvents(data: { + isSavedCreditCard: boolean + paymentMethodType: string + guarantee: boolean + smsEnable: boolean + bookingMustBeGuaranteed: boolean + hasOnlyFlexRates: boolean + hotelId: string +}) { + const { + isSavedCreditCard, + paymentMethodType, + guarantee, + smsEnable, + bookingMustBeGuaranteed, + hasOnlyFlexRates, + hotelId, + } = data + + if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) { + const lateArrivalGuarantee = guarantee ? "yes" : "mandatory" + writeGlaToSessionStorage( + lateArrivalGuarantee, + hotelId, + paymentMethodType, + isSavedCreditCard + ) + trackGlaSaveCardAttempt({ + hotelId, + hasSavedCreditCard: isSavedCreditCard, + creditCardType: isSavedCreditCard ? paymentMethodType : undefined, + lateArrivalGuarantee, + }) + } else if (!hasOnlyFlexRates) { + trackPaymentEvent({ + event: "paymentAttemptStart", + hotelId, + method: paymentMethodType, + isSavedCreditCard, + smsEnable, + status: "attempt", + }) + } + writePaymentInfoToSessionStorage(paymentMethodType, isSavedCreditCard) +} diff --git a/packages/booking-flow/lib/components/EnterDetails/Payment/helpers.test.ts b/packages/booking-flow/lib/components/EnterDetails/Payment/helpers.test.ts new file mode 100644 index 000000000..cdf2a7d93 --- /dev/null +++ b/packages/booking-flow/lib/components/EnterDetails/Payment/helpers.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest" + +import { mustGuaranteeBooking } from "./helpers" + +const buildRoom = ( + overrides: Partial<{ + memberMustBeGuaranteed: boolean + mustBeGuaranteed: boolean + guest: { join: boolean; membershipNo?: string } + }> = {} +) => ({ + room: { + memberMustBeGuaranteed: false, + mustBeGuaranteed: false, + guest: { join: false, membershipNo: undefined }, + ...overrides, + }, +}) + +describe("mustGuaranteeBooking", () => { + it("returns true when the first room requires a member guarantee for a logged-in user", () => { + const rooms = [ + buildRoom({ + memberMustBeGuaranteed: true, + mustBeGuaranteed: false, + }), + ] + + const booking = { rooms: [{}] } + + expect( + mustGuaranteeBooking({ + isUserLoggedIn: true, + booking, + rooms, + }) + ).toBe(true) + }) + + it("returns memberMustBeGuaranteed when guest has membership details and counter rate code", () => { + const rooms = [ + buildRoom(), + buildRoom({ + memberMustBeGuaranteed: true, + guest: { join: true }, + }), + ] + + const booking = { rooms: [{}, { counterRateCode: "COUNTER" }] } + + expect( + mustGuaranteeBooking({ + isUserLoggedIn: false, + booking, + rooms, + }) + ).toBe(true) + }) + + it("returns false when member condition is not met despite counter rate code", () => { + const rooms = [ + buildRoom(), + buildRoom({ + memberMustBeGuaranteed: false, + guest: { join: true }, + }), + ] + + const booking = { rooms: [{}, { counterRateCode: "COUNTER" }] } + + expect( + mustGuaranteeBooking({ + isUserLoggedIn: false, + booking, + rooms, + }) + ).toBe(false) + }) + + it("falls back to mustBeGuaranteed when no member-specific rules apply", () => { + const rooms = [ + buildRoom({ + memberMustBeGuaranteed: false, + mustBeGuaranteed: true, + }), + ] + + const booking = { rooms: [{}] } + + expect( + mustGuaranteeBooking({ + isUserLoggedIn: false, + booking, + rooms, + }) + ).toBe(true) + }) +}) diff --git a/packages/booking-flow/lib/components/EnterDetails/Payment/helpers.ts b/packages/booking-flow/lib/components/EnterDetails/Payment/helpers.ts index dad18eb4a..808b7641f 100644 --- a/packages/booking-flow/lib/components/EnterDetails/Payment/helpers.ts +++ b/packages/booking-flow/lib/components/EnterDetails/Payment/helpers.ts @@ -89,3 +89,37 @@ export function writePaymentInfoToSessionStorage( export function clearPaymentInfoSessionStorage() { sessionStorage.removeItem(paymentInfoStorageName) } + +export function mustGuaranteeBooking({ + isUserLoggedIn, + booking, + rooms, +}: { + isUserLoggedIn: boolean + booking: { rooms: { counterRateCode?: string }[] } + rooms: { + room: { + memberMustBeGuaranteed?: boolean + mustBeGuaranteed: boolean + guest: { + join: boolean + membershipNo?: string + } + } + }[] +}) { + return rooms.some(({ room }, idx) => { + if (idx === 0 && isUserLoggedIn && room.memberMustBeGuaranteed) { + return true + } + + if ( + (room.guest.join || room.guest.membershipNo) && + booking.rooms[idx].counterRateCode + ) { + return room.memberMustBeGuaranteed + } + + return room.mustBeGuaranteed + }) +} diff --git a/packages/booking-flow/lib/components/EnterDetails/PriceChangeData.ts b/packages/booking-flow/lib/components/EnterDetails/PriceChangeData.ts index 9c39c2e56..03e3675ec 100644 --- a/packages/booking-flow/lib/components/EnterDetails/PriceChangeData.ts +++ b/packages/booking-flow/lib/components/EnterDetails/PriceChangeData.ts @@ -2,4 +2,4 @@ export type PriceChangeData = Array<{ roomPrice: number totalPrice: number packagePrice?: number -} | null> +}>