479 lines
16 KiB
TypeScript
479 lines
16 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 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 PriceChangeDialog from "../PriceChangeDialog"
|
|
import GuaranteeDetails from "./GuaranteeDetails"
|
|
import PaymentOption from "./PaymentOption"
|
|
import { type PaymentFormData, paymentSchema } from "./schema"
|
|
|
|
import styles from "./payment.module.css"
|
|
|
|
import type { PaymentClientProps } 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"
|
|
|
|
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
|
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
|
|
}
|
|
|
|
export default function PaymentClient({
|
|
user,
|
|
otherPaymentOptions,
|
|
savedCreditCards,
|
|
mustBeGuaranteed,
|
|
}: PaymentClientProps) {
|
|
const router = useRouter()
|
|
const lang = useLang()
|
|
const intl = useIntl()
|
|
const searchParams = useSearchParams()
|
|
|
|
const { totalPrice, booking, rooms, bookingProgress } = useEnterDetailsStore(
|
|
(state) => {
|
|
return {
|
|
totalPrice: state.totalPrice,
|
|
booking: state.booking,
|
|
rooms: state.rooms,
|
|
bookingProgress: state.bookingProgress,
|
|
}
|
|
}
|
|
)
|
|
const canProceedToPayment = bookingProgress.canProceedToPayment
|
|
|
|
const setIsSubmittingDisabled = useEnterDetailsStore(
|
|
(state) => state.actions.setIsSubmittingDisabled
|
|
)
|
|
|
|
const [bookingNumber, setBookingNumber] = useState<string>("")
|
|
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
|
|
useState(false)
|
|
|
|
const availablePaymentOptions =
|
|
useAvailablePaymentOptions(otherPaymentOptions)
|
|
const [priceChangeData, setPriceChangeData] = useState<{
|
|
oldPrice: number
|
|
newPrice: number
|
|
} | null>()
|
|
|
|
const { toDate, fromDate, hotelId } = 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) {
|
|
setBookingNumber(result.id)
|
|
|
|
const priceChange = result.rooms.find(
|
|
(r) => r.priceChangedMetadata
|
|
)?.priceChangedMetadata
|
|
|
|
if (priceChange) {
|
|
setPriceChangeData({
|
|
oldPrice: rooms[0].roomPrice.perStay.local.price,
|
|
newPrice: priceChange.totalPrice,
|
|
})
|
|
} 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 handleSubmit = useCallback(
|
|
(data: PaymentFormData) => {
|
|
// 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`
|
|
|
|
trackPaymentEvent({
|
|
event: "paymentAttemptStart",
|
|
hotelId,
|
|
method: paymentMethod,
|
|
isSavedCreditCard: !!savedCreditCard,
|
|
smsEnable: data.smsConfirmation,
|
|
status: "attempt",
|
|
})
|
|
|
|
initiateBooking.mutate({
|
|
language: lang,
|
|
hotelId,
|
|
checkInDate: fromDate,
|
|
checkOutDate: toDate,
|
|
rooms: rooms.map((room, idx) => ({
|
|
adults: room.adults,
|
|
childrenAges: room.childrenInRoom?.map((child) => ({
|
|
age: child.age,
|
|
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
|
})),
|
|
rateCode:
|
|
(user || room.guest.join || room.guest.membershipNo) &&
|
|
booking.rooms[idx].counterRateCode
|
|
? booking.rooms[idx].counterRateCode
|
|
: booking.rooms[idx].rateCode,
|
|
roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
|
guest: {
|
|
firstName: room.guest.firstName,
|
|
lastName: room.guest.lastName,
|
|
email: room.guest.email,
|
|
phoneNumber: room.guest.phoneNumber,
|
|
countryCode: room.guest.countryCode,
|
|
membershipNumber: room.guest.membershipNo,
|
|
becomeMember: room.guest.join,
|
|
dateOfBirth: room.guest.dateOfBirth,
|
|
postalCode: room.guest.zipCode,
|
|
},
|
|
packages: {
|
|
breakfast: !!(room.breakfast && room.breakfast.code),
|
|
allergyFriendly:
|
|
room.roomFeatures?.some(
|
|
(feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
|
) ?? false,
|
|
petFriendly:
|
|
room.roomFeatures?.some(
|
|
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
|
) ?? false,
|
|
accessibility:
|
|
room.roomFeatures?.some(
|
|
(feature) =>
|
|
feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
|
) ?? false,
|
|
},
|
|
smsConfirmationRequested: data.smsConfirmation,
|
|
roomPrice: {
|
|
memberPrice: room.roomRate.memberRate?.localPrice.pricePerStay,
|
|
publicPrice: room.roomRate.publicRate.localPrice.pricePerStay,
|
|
},
|
|
})),
|
|
payment: {
|
|
paymentMethod,
|
|
card: savedCreditCard
|
|
? {
|
|
alias: savedCreditCard.alias,
|
|
expiryDate: savedCreditCard.expirationDate,
|
|
cardType: savedCreditCard.cardType,
|
|
}
|
|
: undefined,
|
|
|
|
success: `${paymentRedirectUrl}/success`,
|
|
error: `${paymentRedirectUrl}/error`,
|
|
cancel: `${paymentRedirectUrl}/cancel`,
|
|
},
|
|
})
|
|
},
|
|
[
|
|
savedCreditCards,
|
|
lang,
|
|
initiateBooking,
|
|
hotelId,
|
|
fromDate,
|
|
toDate,
|
|
rooms,
|
|
user,
|
|
booking,
|
|
]
|
|
)
|
|
|
|
if (
|
|
initiateBooking.isPending ||
|
|
(isPollingForBookingStatus &&
|
|
!bookingStatus.data?.paymentUrl &&
|
|
!bookingStatus.isTimeout)
|
|
) {
|
|
return <LoadingSpinner />
|
|
}
|
|
|
|
const paymentGuarantee = intl.formatMessage({
|
|
id: "Payment Guarantee",
|
|
})
|
|
const payment = intl.formatMessage({
|
|
id: "Payment",
|
|
})
|
|
|
|
return (
|
|
<section
|
|
className={`${styles.paymentSection} ${canProceedToPayment ? "" : styles.disabled}`}
|
|
>
|
|
<header>
|
|
<Title level="h2" as="h4">
|
|
{mustBeGuaranteed ? paymentGuarantee : payment}
|
|
</Title>
|
|
</header>
|
|
<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={hotelId}
|
|
/>
|
|
))}
|
|
</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={hotelId}
|
|
/>
|
|
{availablePaymentOptions.map((paymentMethod) => (
|
|
<PaymentOption
|
|
key={paymentMethod}
|
|
name="paymentMethod"
|
|
value={paymentMethod}
|
|
label={
|
|
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
|
|
}
|
|
hotelId={hotelId}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
<section className={styles.section}>
|
|
<Caption>
|
|
{intl.formatMessage<React.ReactNode>(
|
|
{
|
|
id: "By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.",
|
|
},
|
|
{
|
|
termsAndConditionsLink: (str) => (
|
|
<Link
|
|
className={styles.link}
|
|
variant="underscored"
|
|
href={bookingTermsAndConditions[lang]}
|
|
target="_blank"
|
|
>
|
|
{str}
|
|
</Link>
|
|
),
|
|
privacyPolicyLink: (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
|
|
intent="primary"
|
|
theme="base"
|
|
size="small"
|
|
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: bookingNumber })
|
|
}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
)
|
|
}
|