"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 { Label } from "react-aria-components" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod" import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation" import { Button } from "@scandic-hotels/design-system/Button" import { Typography } from "@scandic-hotels/design-system/Typography" 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 { PAYMENT_METHOD_TITLES } from "@/constants/booking" import { bookingConfirmation } from "@/constants/routes/hotelReservation" import { env } from "@/env/client" import { trpc } from "@/lib/trpc/client" import { useEnterDetailsStore } from "@/stores/enter-details" import PaymentOption from "@/components/HotelReservation/PaymentOption" import Checkbox from "@/components/TempDesignSystem/Form/Checkbox" import Body from "@/components/TempDesignSystem/Text/Body" import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions" import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus" import useLang from "@/hooks/useLang" import useStickyPosition from "@/hooks/useStickyPosition" import { formatPhoneNumber } from "@/utils/phone" import { trackPaymentEvent } from "@/utils/tracking" import { trackEvent } from "@/utils/tracking/base" import { trackGlaSaveCardAttempt } from "@/utils/tracking/myStay" import ConfirmBooking, { ConfirmBookingRedemption } from "../Confirm" import PriceChangeDialog from "../PriceChangeDialog" import { writeGlaToSessionStorage } from "./PaymentCallback/helpers" import BookingAlert from "./BookingAlert" import GuaranteeDetails from "./GuaranteeDetails" import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum, writePaymentInfoToSessionStorage, } from "./helpers" import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown" import PaymentOptionsGroup from "./PaymentOptionsGroup" import { type PaymentFormData, paymentSchema } from "./schema" import TermsAndConditions from "./TermsAndConditions" import styles from "./payment.module.css" import type { PaymentClientProps, PriceChangeData, } from "@/types/components/hotelReservation/enterDetails/payment" const maxRetries = 15 const retryInterval = 2000 export const formId = "submit-booking" export default function PaymentClient({ otherPaymentOptions, savedCreditCards, isUserLoggedIn, }: PaymentClientProps) { const router = useRouter() const lang = useLang() const intl = useIntl() const pathname = usePathname() const searchParams = useSearchParams() const { getTopOffset } = useStickyPosition({}) const [showBookingAlert, setShowBookingAlert] = useState(false) const { booking, rooms, totalPrice, isSubmitting, preSubmitCallbacks, setIsSubmitting, } = useEnterDetailsStore((state) => ({ booking: state.booking, rooms: state.rooms, totalPrice: state.totalPrice, preSubmitCallbacks: state.preSubmitCallbacks, isSubmitting: state.isSubmitting, setIsSubmitting: state.actions.setIsSubmitting, })) 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 [refId, setRefId] = useState("") const [isPollingForBookingStatus, setIsPollingForBookingStatus] = useState(false) const availablePaymentOptions = useAvailablePaymentOptions(otherPaymentOptions) 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 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) { // 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 ) setPriceChangeData(priceChangeData) } else { setIsPollingForBookingStatus(true) } } else { handlePaymentError("No confirmation number") } }, onError: (error) => { console.error("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) => { console.error("Error", error) setPriceChangeData(null) handlePaymentError(error.message) }, }) const bookingStatus = useHandleBookingStatus({ refId, expectedStatuses: [BookingStatusEnum.BookingCompleted], maxRetries, retryInterval, enabled: isPollingForBookingStatus, }) const handlePaymentError = useCallback( (errorMessage: string) => { setShowBookingAlert(true) setIsSubmitting(false) 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", }) } }, [ methods, savedCreditCards, hotelId, bookingMustBeGuaranteed, hasOnlyFlexRates, setIsSubmitting, ] ) 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] // 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 getPaymentMethod = useCallback( (paymentMethod: string | null | undefined): PaymentMethodEnum => { if (hasFlexRates) { return PaymentMethodEnum.card } return paymentMethod && isPaymentMethodEnum(paymentMethod) ? paymentMethod : PaymentMethodEnum.card }, [hasFlexRates] ) const handleSubmit = useCallback( (data: PaymentFormData) => { setIsSubmitting(true) Object.values(preSubmitCallbacks).forEach((callback) => { callback() }) const firstIncompleteRoomIndex = rooms.findIndex( (room) => !room.isComplete ) // If any room is not complete/valid, scroll to it if (firstIncompleteRoomIndex !== -1) { const roomElement = document.getElementById( `room-${firstIncompleteRoomIndex + 1}` ) if (!roomElement) { setIsSubmitting(false) return } const roomElementTop = roomElement.getBoundingClientRect().top + window.scrollY window.scrollTo({ top: roomElementTop - getTopOffset() - 20, behavior: "smooth", }) setIsSubmitting(false) return } const paymentMethod = getPaymentMethod(data.paymentMethod) 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`, } : undefined const paymentMethodType = savedCreditCard ? savedCreditCard.type : paymentMethod if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) { const lateArrivalGuarantee = guarantee ? "yes" : "mandatory" writeGlaToSessionStorage( lateArrivalGuarantee, hotelId, paymentMethodType, !!savedCreditCard ) trackGlaSaveCardAttempt(hotelId, savedCreditCard, lateArrivalGuarantee) } else if (!hasOnlyFlexRates) { trackPaymentEvent({ event: "paymentAttemptStart", hotelId, method: paymentMethodType, isSavedCreditCard: !!savedCreditCard, smsEnable: data.smsConfirmation, status: "attempt", }) } writePaymentInfoToSessionStorage(paymentMethodType, !!savedCreditCard) const payload = { checkInDate: fromDate, checkOutDate: toDate, hotelId, language: lang, payment, rooms: rooms.map(({ room }, idx) => { const isMainRoom = idx === 0 let rateCode = "" if (isMainRoom && isUserLoggedIn) { 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 ) return { adults: room.adults, bookingCode: room.roomRate.bookingCode, childrenAges: room.childrenInRoom?.map((child) => ({ age: child.age, bedType: bedTypeMap[parseInt(child.bed.toString())], })), 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, // Only allowed for room one ...(idx === 0 && { dateOfBirth: "dateOfBirth" in room.guest && room.guest.dateOfBirth ? room.guest.dateOfBirth : undefined, postalCode: "zipCode" in room.guest && room.guest.zipCode ? room.guest.zipCode : undefined, }), }, 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, }, 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) }, [ savedCreditCards, lang, initiateBooking, hotelId, fromDate, toDate, rooms, booking.rooms, getPaymentMethod, hasOnlyFlexRates, bookingMustBeGuaranteed, preSubmitCallbacks, isUserLoggedIn, getTopOffset, setIsSubmitting, ] ) const finalStep = intl.formatMessage({ defaultMessage: "Final step" }) const selectPayment = intl.formatMessage({ defaultMessage: "Select payment method", }) return (
{hasOnlyFlexRates ? finalStep : selectPayment}
{booking.searchType === SEARCH_TYPE_REDEMPTION ? ( ) : hasOnlyFlexRates && !bookingMustBeGuaranteed ? ( ) : ( <> {hasOnlyFlexRates && bookingMustBeGuaranteed ? (
{intl.formatMessage({ defaultMessage: "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.", })}
) : null} {hasMixedRates ? ( {intl.formatMessage({ defaultMessage: "As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.", })} ) : null}
{savedCreditCards?.length ? ( <> {intl.formatMessage({ defaultMessage: "MY SAVED CARDS", })} {savedCreditCards.map((savedCreditCard) => ( ))} {intl.formatMessage({ defaultMessage: "OTHER PAYMENT METHODS", })} ) : null} {!hasMixedRates && availablePaymentOptions.map((paymentMethod) => ( ))} {hasMixedRates ? ( ) : null}
{intl.formatMessage({ defaultMessage: "I would like to get my booking confirmation via sms", })}
)}
{priceChangeData ? ( { const allSearchParams = searchParams.size ? `?${searchParams.toString()}` : "" router.push(`${selectRate(lang)}${allSearchParams}`) }} onAccept={() => priceChange.mutate({ refId })} /> ) : null}
) }