"use client" import { zodResolver } from "@hookform/resolvers/zod" import { useRouter, useSearchParams } from "next/navigation" import { useCallback, useEffect, useState } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" import { BOOKING_CONFIRMATION_NUMBER, BookingErrorCodeEnum, BookingStatusEnum, PAYMENT_METHOD_TITLES, PaymentMethodEnum, } from "@/constants/booking" import { bookingTermsAndConditions, privacyPolicy, } from "@/constants/currentWebHrefs" import { bookingConfirmation, selectRate, } from "@/constants/routes/hotelReservation" import { env } from "@/env/client" import { trpc } from "@/lib/trpc/client" import { useEnterDetailsStore } from "@/stores/enter-details" import LoadingSpinner from "@/components/LoadingSpinner" import Button from "@/components/TempDesignSystem/Button" import Checkbox from "@/components/TempDesignSystem/Form/Checkbox" import Link from "@/components/TempDesignSystem/Link" import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import Title from "@/components/TempDesignSystem/Text/Title" import { toast } from "@/components/TempDesignSystem/Toasts" import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions" import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus" import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast" import useLang from "@/hooks/useLang" import { trackPaymentEvent } from "@/utils/tracking" import { bedTypeMap } from "../../utils" import ConfirmBooking from "../Confirm" import PriceChangeDialog from "../PriceChangeDialog" import GuaranteeDetails from "./GuaranteeDetails" import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum } from "./helpers" import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown" import MySavedCards from "./MySavedCards" import PaymentOption from "./PaymentOption" import { type PaymentFormData, paymentSchema } from "./schema" import styles from "./payment.module.css" import type { PaymentClientProps, PriceChangeData, } from "@/types/components/hotelReservation/enterDetails/payment" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" const maxRetries = 15 const retryInterval = 2000 export const formId = "submit-booking" export default function PaymentClient({ otherPaymentOptions, savedCreditCards, mustBeGuaranteed, memberMustBeGuaranteed, isFlexRate, }: PaymentClientProps) { const router = useRouter() const lang = useLang() const intl = useIntl() const searchParams = useSearchParams() const bookingCode = searchParams.get("bookingCode") const { booking, canProceedToPayment, rooms, totalPrice } = useEnterDetailsStore((state) => ({ booking: state.booking, canProceedToPayment: state.canProceedToPayment, rooms: state.rooms, totalPrice: state.totalPrice, })) const bookingMustBeGuaranteed = rooms.some( ({ room }, idx) => (room.guest.join || room.guest.membershipNo) && booking.rooms[idx].counterRateCode ) ? memberMustBeGuaranteed : mustBeGuaranteed const setIsSubmittingDisabled = useEnterDetailsStore( (state) => state.actions.setIsSubmittingDisabled ) const [bookingNumber, setBookingNumber] = 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 hasMixedRates = hasPrepaidRates && hasFlexRates usePaymentFailedToast() 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) { if (result.cause === BookingErrorCodeEnum.AvailabilityError) { window.location.reload() // reload to refetch room data because we dont know which room is unavailable } else { handlePaymentError(result.cause) } return } if (result.reservationStatus == BookingStatusEnum.BookingCompleted) { const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${result.id}` router.push(confirmationUrl) } setBookingNumber(result.id) const hasPriceChange = result.rooms.some((r) => r.priceChangedMetadata) if (hasPriceChange) { const priceChangeData = result.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({ confirmationNumber: bookingNumber, expectedStatus: BookingStatusEnum.BookingCompleted, maxRetries, retryInterval, enabled: isPollingForBookingStatus, }) const handlePaymentError = useCallback( (errorMessage: string) => { toast.error( intl.formatMessage({ id: "We had an issue processing your booking. Please try again. No charges have been made.", }) ) const currentPaymentMethod = methods.getValues("paymentMethod") const smsEnable = methods.getValues("smsConfirmation") const isSavedCreditCard = savedCreditCards?.some( (card) => card.id === currentPaymentMethod ) trackPaymentEvent({ event: "paymentFail", hotelId, method: currentPaymentMethod, isSavedCreditCard, smsEnable, errorMessage, status: "failed", }) }, [intl, methods, savedCreditCards, hotelId] ) useEffect(() => { if (bookingStatus?.data?.paymentUrl) { router.push(bookingStatus.data.paymentUrl) } else if (bookingStatus.isTimeout) { handlePaymentError("Timeout") } }, [bookingStatus, router, intl, handlePaymentError]) useEffect(() => { setIsSubmittingDisabled( !methods.formState.isValid || methods.formState.isSubmitting ) }, [ methods.formState.isValid, methods.formState.isSubmitting, setIsSubmittingDisabled, ]) const getPaymentMethod = ( isFlexRate: boolean, paymentMethod: string | null | undefined ): PaymentMethodEnum => { if (isFlexRate) { return PaymentMethodEnum.card } return paymentMethod && isPaymentMethodEnum(paymentMethod) ? paymentMethod : PaymentMethodEnum.card } const handleSubmit = useCallback( (data: PaymentFormData) => { const paymentMethod = getPaymentMethod(isFlexRate, 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 = !isFlexRate || guarantee const payment = shouldUsePayment ? { paymentMethod: paymentMethod, ...useSavedCard, success: `${paymentRedirectUrl}/success`, error: `${paymentRedirectUrl}/error`, cancel: `${paymentRedirectUrl}/cancel`, } : undefined trackPaymentEvent({ event: "paymentAttemptStart", hotelId, method: paymentMethod, isSavedCreditCard: !!savedCreditCard, smsEnable: data.smsConfirmation, status: "attempt", }) initiateBooking.mutate({ checkInDate: fromDate, checkOutDate: toDate, hotelId, language: lang, payment, rooms: rooms.map(({ room }, idx) => ({ adults: room.adults, 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: room.guest.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: (room.guest.join || room.guest.membershipNo) && booking.rooms[idx].counterRateCode ? booking.rooms[idx].counterRateCode : booking.rooms[idx].rateCode, roomPrice: { memberPrice: room.roomRate.memberRate?.localPrice.pricePerStay, publicPrice: room.roomRate.publicRate?.localPrice.pricePerStay, }, bookingCode, 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, }, })), }) }, [ savedCreditCards, lang, initiateBooking, hotelId, fromDate, toDate, rooms, booking, isFlexRate, bookingCode, ] ) if ( initiateBooking.isPending || (isPollingForBookingStatus && !bookingStatus.data?.paymentUrl && !bookingStatus.isTimeout) ) { return } const paymentGuarantee = intl.formatMessage({ id: "Payment Guarantee", }) const payment = intl.formatMessage({ id: "Payment", }) const confirm = intl.formatMessage({ id: "Confirm booking", }) return (
{bookingMustBeGuaranteed ? paymentGuarantee : isFlexRate ? confirm : payment}
{isFlexRate && !bookingMustBeGuaranteed ? ( ) : ( <> {bookingMustBeGuaranteed ? (
{intl.formatMessage({ id: "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({ id: "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 ? (
) : null}
{savedCreditCards?.length ? ( {intl.formatMessage({ id: "OTHER PAYMENT METHODS" })} ) : null}
{availablePaymentOptions.map((paymentMethod) => ( ))}
{hasMixedRates ? ( ) : null}
{intl.formatMessage( { id: "By paying with any of the payment methods available, I accept the terms for this booking and the general Terms & Conditions, and understand that Scandic will process my personal data for this booking in accordance with Scandic's Privacy policy. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.", }, { termsAndConditionsLink: (str) => ( {str} ), privacyPolicyLink: (str) => ( {str} ), } )} {intl.formatMessage({ id: "I accept the terms and conditions", })} {intl.formatMessage({ id: "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({ confirmationNumber: bookingNumber }) } /> ) : null}
) }