Files
web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx

434 lines
14 KiB
TypeScript

"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 {
BookingStatusEnum,
PAYMENT_METHOD_TITLES,
PaymentMethodEnum,
} from "@/constants/booking"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { 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 { 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 { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
import PriceChangeDialog from "../PriceChangeDialog"
import GuaranteeDetails from "./GuaranteeDetails"
import PaymentOption from "./PaymentOption"
import { PaymentFormData, paymentSchema } from "./schema"
import styles from "./payment.module.css"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { PaymentClientProps } from "@/types/components/hotelReservation/selectRate/section"
const maxRetries = 4
const retryInterval = 2000
export const formId = "submit-booking"
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
}
export default function PaymentClient({
user,
roomPrice,
otherPaymentOptions,
savedCreditCards,
mustBeGuaranteed,
}: PaymentClientProps) {
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const searchParams = useSearchParams()
const totalPrice = useEnterDetailsStore((state) => state.totalPrice)
const { bedType, booking, breakfast } = useEnterDetailsStore((state) => ({
bedType: state.bedType,
booking: state.booking,
breakfast: state.breakfast,
}))
const userData = useEnterDetailsStore((state) => state.guest)
const setIsSubmittingDisabled = useEnterDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
)
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
useState(false)
const availablePaymentOptions =
useAvailablePaymentOptions(otherPaymentOptions)
const [priceChangeData, setPriceChangeData] = useState<{
oldPrice: number
newPrice: number
} | null>()
const { toDate, fromDate, rooms, hotel } = booking
usePaymentFailedToast()
const methods = useForm<PaymentFormData>({
defaultValues: {
paymentMethod: savedCreditCards?.length
? savedCreditCards[0].id
: PaymentMethodEnum.card,
smsConfirmation: false,
termsAndConditions: false,
},
mode: "all",
reValidateMode: "onChange",
resolver: zodResolver(paymentSchema),
})
const initiateBooking = trpc.booking.create.useMutation({
onSuccess: (result) => {
if (result?.confirmationNumber) {
setConfirmationNumber(result.confirmationNumber)
if (result.metadata?.priceChangedMetadata) {
setPriceChangeData({
oldPrice: roomPrice.publicPrice,
newPrice: result.metadata.priceChangedMetadata.totalPrice,
})
} else {
setIsPollingForBookingStatus(true)
}
} else {
toast.error(
intl.formatMessage({
id: "payment.error.failed",
})
)
}
},
onError: (error) => {
console.error("Error", error)
toast.error(
intl.formatMessage({
id: "payment.error.failed",
})
)
},
})
const priceChange = trpc.booking.priceChange.useMutation({
onSuccess: (result) => {
if (result?.confirmationNumber) {
setIsPollingForBookingStatus(true)
} else {
toast.error(intl.formatMessage({ id: "payment.error.failed" }))
}
setPriceChangeData(null)
},
onError: (error) => {
console.error("Error", error)
setPriceChangeData(null)
toast.error(intl.formatMessage({ id: "payment.error.failed" }))
},
})
const bookingStatus = useHandleBookingStatus({
confirmationNumber,
expectedStatus: BookingStatusEnum.BookingCompleted,
maxRetries,
retryInterval,
enabled: isPollingForBookingStatus,
})
useEffect(() => {
if (bookingStatus?.data?.paymentUrl) {
router.push(bookingStatus.data.paymentUrl)
} else if (bookingStatus.isTimeout) {
toast.error(
intl.formatMessage({
id: "payment.error.failed",
})
)
}
}, [bookingStatus, router, intl])
useEffect(() => {
setIsSubmittingDisabled(
!methods.formState.isValid || methods.formState.isSubmitting
)
}, [
methods.formState.isValid,
methods.formState.isSubmitting,
setIsSubmittingDisabled,
])
const handleSubmit = useCallback(
(data: PaymentFormData) => {
const {
firstName,
lastName,
email,
phoneNumber,
countryCode,
membershipNo,
join,
dateOfBirth,
zipCode,
} = userData
// set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod
: PaymentMethodEnum.card
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`
initiateBooking.mutate({
language: lang,
hotelId: hotel,
checkInDate: fromDate,
checkOutDate: toDate,
rooms: rooms.map((room) => ({
adults: room.adults,
childrenAges: room.children?.map((child) => ({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
rateCode:
user || join || membershipNo ? room.counterRateCode : room.rateCode,
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: {
firstName,
lastName,
email,
phoneNumber,
countryCode,
membershipNumber: membershipNo,
becomeMember: join,
dateOfBirth,
postalCode: zipCode,
},
packages: {
breakfast: !!(breakfast && breakfast.code),
allergyFriendly:
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ??
false,
petFriendly:
room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false,
accessibility:
room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ??
false,
},
smsConfirmationRequested: data.smsConfirmation,
roomPrice,
})),
payment: {
paymentMethod,
card: savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
},
})
},
[
userData,
savedCreditCards,
lang,
initiateBooking,
hotel,
fromDate,
toDate,
rooms,
user,
bedType,
breakfast,
roomPrice,
]
)
if (
initiateBooking.isPending ||
(isPollingForBookingStatus && !bookingStatus.data?.paymentUrl)
) {
return <LoadingSpinner />
}
const guaranteeing = intl.formatMessage({ id: "guaranteeing" })
const paying = intl.formatMessage({ id: "paying" })
const paymentVerb = mustBeGuaranteed ? guaranteeing : paying
return (
<>
<FormProvider {...methods}>
<form
className={styles.paymentContainer}
onSubmit={methods.handleSubmit(handleSubmit)}
id={formId}
>
{mustBeGuaranteed ? (
<section className={styles.section}>
<Body>
{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.",
})}
</Body>
<GuaranteeDetails />
</section>
) : null}
{savedCreditCards?.length ? (
<section className={styles.section}>
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "MY SAVED CARDS" })}
</Body>
<div className={styles.paymentOptionContainer}>
{savedCreditCards?.map((savedCreditCard) => (
<PaymentOption
key={savedCreditCard.id}
name="paymentMethod"
value={savedCreditCard.id}
label={
PAYMENT_METHOD_TITLES[
savedCreditCard.cardType as PaymentMethodEnum
]
}
cardNumber={savedCreditCard.truncatedNumber}
hotelId={hotel}
/>
))}
</div>
</section>
) : null}
<section className={styles.section}>
{savedCreditCards?.length ? (
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
</Body>
) : null}
<div className={styles.paymentOptionContainer}>
<PaymentOption
name="paymentMethod"
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
hotelId={hotel}
/>
{availablePaymentOptions.map((paymentMethod) => (
<PaymentOption
key={paymentMethod}
name="paymentMethod"
value={paymentMethod}
label={
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
}
hotelId={hotel}
/>
))}
</div>
</section>
<section className={styles.section}>
<Caption>
{intl.formatMessage<React.ReactNode>(
{
id: "booking.terms",
},
{
paymentVerb,
termsLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
>
{str}
</Link>
),
privacyLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"
>
{str}
</Link>
),
}
)}
</Caption>
<Checkbox name="termsAndConditions">
<Caption>
{intl.formatMessage({
id: "I accept the terms and conditions",
})}
</Caption>
</Checkbox>
<Checkbox name="smsConfirmation">
<Caption>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</Caption>
</Checkbox>
</section>
<div className={styles.submitButton}>
<Button
type="submit"
disabled={
!methods.formState.isValid || methods.formState.isSubmitting
}
>
{intl.formatMessage({ id: "Complete booking" })}
</Button>
</div>
</form>
</FormProvider>
{priceChangeData ? (
<PriceChangeDialog
isOpen={!!priceChangeData}
oldPrice={priceChangeData.oldPrice}
newPrice={priceChangeData.newPrice}
currency={totalPrice.local.currency}
onCancel={() => {
const allSearchParams = searchParams.size
? `?${searchParams.toString()}`
: ""
router.push(`${selectRate(lang)}${allSearchParams}`)
}}
onAccept={() => priceChange.mutate({ confirmationNumber })}
/>
) : null}
</>
)
}