"use client" import { zodResolver } from "@hookform/resolvers/zod" import { cx } from "class-variance-authority" import { usePathname, useRouter, useSearchParams } from "next/navigation" import { useCallback, useEffect, useState } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod" import { bookingConfirmation, selectRate, } from "@scandic-hotels/common/constants/routes/hotelReservation" import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition" import { logger } from "@scandic-hotels/common/logger" import { formatPhoneNumber } from "@scandic-hotels/common/utils/phone" import { Button } from "@scandic-hotels/design-system/Button" import { Typography } from "@scandic-hotels/design-system/Typography" import { trackEvent } from "@scandic-hotels/tracking/base" import { trackGlaSaveCardAttempt, trackPaymentEvent, } from "@scandic-hotels/tracking/payment" import { trpc } from "@scandic-hotels/trpc/client" import { bedTypeMap } from "@scandic-hotels/trpc/constants/bedTypeMap" import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking" import { BookingStatusEnum } from "@scandic-hotels/trpc/enums/bookingStatus" import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter" import { env } from "../../../../env/client" import { useBookingFlowContext } from "../../../hooks/useBookingFlowContext" import { clearBookingWidgetState } from "../../../hooks/useBookingWidgetState" import { useHandleBookingStatus } from "../../../hooks/useHandleBookingStatus" import useLang from "../../../hooks/useLang" import { useEnterDetailsStore } from "../../../stores/enter-details" import ConfirmBooking from "../Confirm" import PriceChangeDialog from "../PriceChangeDialog" import { writeGlaToSessionStorage } from "./PaymentCallback/helpers" import BookingAlert from "./BookingAlert" import { GuaranteeInfo } from "./GuaranteeInfo" import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum, mustGuaranteeBooking, writePaymentInfoToSessionStorage, } from "./helpers" import { type PaymentFormData, paymentSchema } from "./schema" 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" import type { PriceChangeData } from "../PriceChangeData" const maxRetries = 15 const retryInterval = 2000 type PaymentClientProps = { otherPaymentOptions: PaymentMethodEnum[] savedCreditCards: CreditCard[] | null } export const formId = "submit-booking" export default function PaymentClient({ otherPaymentOptions, savedCreditCards, }: PaymentClientProps) { const router = useRouter() const lang = useLang() const intl = useIntl() const pathname = usePathname() const searchParams = useSearchParams() const { getTopOffset } = useStickyPosition({}) const { user, isLoggedIn } = useBookingFlowContext() const [refId, setRefId] = useState("") const [isPollingForBookingStatus, setIsPollingForBookingStatus] = useState(false) const [priceChangeData, setPriceChangeData] = useState(null) const [showBookingAlert, setShowBookingAlert] = useState(false) const { booking, rooms, totalPrice, isSubmitting, setIsSubmitting, runPreSubmitCallbacks, } = useEnterDetailsStore((state) => ({ booking: state.booking, rooms: state.rooms, totalPrice: state.totalPrice, isSubmitting: state.isSubmitting, setIsSubmitting: state.actions.setIsSubmitting, runPreSubmitCallbacks: state.actions.runPreSubmitCallbacks, })) const bookingMustBeGuaranteed = mustGuaranteeBooking({ isUserLoggedIn: isLoggedIn, booking, rooms, }) const hasPrepaidRates = rooms.some(hasPrepaidRate) const hasFlexRates = rooms.some(hasFlexibleRate) const hasOnlyFlexRates = rooms.every(hasFlexibleRate) const hasMixedRates = hasPrepaidRates && hasFlexRates const methods = useForm({ defaultValues: { paymentMethod: savedCreditCards?.length ? savedCreditCards[0].id : PaymentMethodEnum.card, smsConfirmation: false, termsAndConditions: false, guarantee: false, }, mode: "all", reValidateMode: "onChange", resolver: zodResolver(paymentSchema), }) const initiateBooking = trpc.booking.create.useMutation({ onSuccess: (result) => { if (result) { if ("error" in result) { const queryParams = new URLSearchParams(searchParams.toString()) queryParams.set("errorCode", result.cause) window.history.replaceState( {}, "", `${pathname}?${queryParams.toString()}` ) handlePaymentError(result.cause) return } const { booking } = result const mainRoom = booking.rooms[0] if (booking.reservationStatus == BookingStatusEnum.BookingCompleted) { clearBookingWidgetState() // Cookie is used by Booking Confirmation page to validate that the user came from payment callback document.cookie = `bcsig=${result.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict` const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}` router.push(confirmationUrl) return } setRefId(mainRoom.refId) const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata) if (hasPriceChange) { const priceChangeData = booking.rooms .map((room) => room.priceChangedMetadata || null) .filter(isNotNull) setPriceChangeData(priceChangeData) } else { setIsPollingForBookingStatus(true) } } else { handlePaymentError("No confirmation number") } }, onError: (error) => { logger.error("Booking error", error) handlePaymentError(error.message) }, }) const priceChange = trpc.booking.priceChange.useMutation({ onSuccess: (result) => { if (result?.id) { setIsPollingForBookingStatus(true) } else { handlePaymentError("No confirmation number") } setPriceChangeData(null) }, onError: (error) => { logger.error("Price change error", error) setPriceChangeData(null) handlePaymentError(error.message) }, }) const { toDate, fromDate, hotelId } = booking const trackPaymentError = useCallback( (errorMessage: string) => { const currentPaymentMethod = methods.getValues("paymentMethod") const smsEnable = methods.getValues("smsConfirmation") const guarantee = methods.getValues("guarantee") const savedCreditCard = savedCreditCards?.find( (card) => card.id === currentPaymentMethod ) const isSavedCreditCard = !!savedCreditCard if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) { const lateArrivalGuarantee = guarantee ? "yes" : "mandatory" trackEvent({ event: "glaCardSaveFailed", hotelInfo: { hotelId, lateArrivalGuarantee, guaranteedProduct: "room", }, paymentInfo: { isSavedCreditCard, hotelId, status: "glacardsavefailed", type: savedCreditCard ? savedCreditCard.type : currentPaymentMethod, }, }) } else { trackPaymentEvent({ event: "paymentFail", hotelId, method: savedCreditCard ? savedCreditCard.type : currentPaymentMethod, isSavedCreditCard, smsEnable, errorMessage, status: "failed", }) } }, [ bookingMustBeGuaranteed, hasOnlyFlexRates, hotelId, methods, savedCreditCards, ] ) const handlePaymentError = useCallback( (errorMessage: string) => { setShowBookingAlert(true) setIsSubmitting(false) trackPaymentError(errorMessage) }, [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 const invalidField = await runPreSubmitCallbacks() const errorNames = Object.keys(methods.formState.errors) const firstIncompleteRoomIndex = rooms.findIndex((room) => !room.isComplete) if (invalidField) { scrollToElement(invalidField, getTopOffset()) } else if (errorNames.length > 0) { const firstErrorEl = document.querySelector(`[name="${errorNames[0]}"]`) if (firstErrorEl) { scrollToElement(firstErrorEl as HTMLElement, getTopOffset()) } } return firstIncompleteRoomIndex !== -1 }, [runPreSubmitCallbacks, rooms, methods.formState.errors, getTopOffset]) const handleSubmit = useCallback( async (data: PaymentFormData) => { setIsSubmitting(true) const isRoomInvalid = await scrollToInvalidField() if (isRoomInvalid) { setIsSubmitting(false) return } const paymentMethod = getPaymentMethod(data.paymentMethod, hasFlexRates) const savedCreditCard = savedCreditCards?.find( (card) => card.id === data.paymentMethod ) const guarantee = data.guarantee const shouldUsePayment = guarantee || bookingMustBeGuaranteed || !hasOnlyFlexRates const payment = shouldUsePayment ? getPaymentData({ paymentMethod, savedCreditCard, lang }) : undefined const paymentMethodType = savedCreditCard ? savedCreditCard.type : paymentMethod trackPaymentEvents({ isSavedCreditCard: !!savedCreditCard, paymentMethodType, guarantee, smsEnable: data.smsConfirmation, bookingMustBeGuaranteed, hasOnlyFlexRates, hotelId, }) const payload: CreateBookingInput = { checkInDate: fromDate, checkOutDate: toDate, hotelId, language: lang, payment, rooms: rooms.map( ({ room }, idx): CreateBookingInput["rooms"][number] => { const isMainRoom = idx === 0 let rateCode = "" if (isMainRoom && isLoggedIn) { rateCode = booking.rooms[idx].rateCode } else if ( (room.guest.join || room.guest.membershipNo) && booking.rooms[idx].counterRateCode ) { rateCode = booking.rooms[idx].counterRateCode } else { rateCode = booking.rooms[idx].rateCode } const phoneNumber = formatPhoneNumber( room.guest.phoneNumber, room.guest.phoneNumberCC ) const guest: CreateBookingInput["rooms"][number]["guest"] = { becomeMember: room.guest.join, countryCode: room.guest.countryCode, email: room.guest.email, firstName: room.guest.firstName, lastName: room.guest.lastName, membershipNumber: room.guest.membershipNo, phoneNumber, partnerLoyaltyNumber: null, } if (isMainRoom) { // Only valid for main room guest.partnerLoyaltyNumber = user?.data?.partnerLoyaltyNumber || null guest.dateOfBirth = "dateOfBirth" in room.guest && room.guest.dateOfBirth ? room.guest.dateOfBirth : undefined guest.postalCode = "zipCode" in room.guest && room.guest.zipCode ? room.guest.zipCode : undefined } const packages: CreateBookingInput["rooms"][number]["packages"] = { accessibility: room.roomFeatures?.some( (feature) => feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM ) ?? false, allergyFriendly: room.roomFeatures?.some( (feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM ) ?? false, breakfast: !!(room.breakfast && room.breakfast.code), petFriendly: room.roomFeatures?.some( (feature) => feature.code === RoomPackageCodeEnum.PET_ROOM ) ?? false, } return { adults: room.adults, bookingCode: room.roomRate.bookingCode, childrenAges: room.childrenInRoom?.map((child) => ({ age: child.age, bedType: bedTypeMap[parseInt(child.bed.toString())], })), guest, packages, rateCode, roomPrice: { memberPrice: "member" in room.roomRate ? room.roomRate.member?.localPrice.pricePerStay : undefined, publicPrice: "public" in room.roomRate ? room.roomRate.public?.localPrice.pricePerStay : undefined, }, roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step. smsConfirmationRequested: data.smsConfirmation, specialRequest: { comment: room.specialRequest.comment ? room.specialRequest.comment : undefined, }, } } ), } initiateBooking.mutate(payload) }, [ setIsSubmitting, scrollToInvalidField, hasFlexRates, savedCreditCards, bookingMustBeGuaranteed, hasOnlyFlexRates, lang, fromDate, toDate, hotelId, rooms, initiateBooking, isLoggedIn, booking.rooms, user?.data?.partnerLoyaltyNumber, ] ) const handleInvalidSubmit = async () => { const valid = await methods.trigger() if (!valid) { await scrollToInvalidField() } } const { preHeading, heading, subHeading, showLearnMore } = getPaymentHeadingConfig(intl, bookingMustBeGuaranteed, hasOnlyFlexRates) const isRedemptionBooking = booking.searchType === SEARCH_TYPE_REDEMPTION return (
{preHeading ? (

{preHeading}

) : null}

{heading}

{subHeading ? (

{subHeading}

) : null}
{showLearnMore ? : null}
{priceChangeData ? ( { const allSearchParams = searchParams.size ? `?${searchParams.toString()}` : "" router.push(`${selectRate(lang)}${allSearchParams}`) }} onAccept={() => priceChange.mutate({ refId })} /> ) : null}
) } 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) }