fix(SW-2501): validation trigger * fix(SW-2501): validation trigger On enter details, when submitting we want to trigger the validation for the details forms for each room. This will display error messages for the form fields with errors if they are not already displayed, so the user knows which fields has errors. Approved-by: Tobias Johansson
637 lines
20 KiB
TypeScript
637 lines
20 KiB
TypeScript
"use client"
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod"
|
|
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 { Typography } from "@scandic-hotels/design-system/Typography"
|
|
|
|
import {
|
|
BOOKING_CONFIRMATION_NUMBER,
|
|
BookingStatusEnum,
|
|
PAYMENT_METHOD_TITLES,
|
|
PaymentMethodEnum,
|
|
REDEMPTION,
|
|
} from "@/constants/booking"
|
|
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 PaymentOption from "@/components/HotelReservation/PaymentOption"
|
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
|
import Button from "@/components/TempDesignSystem/Button"
|
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
|
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
|
|
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
|
import useLang from "@/hooks/useLang"
|
|
import useStickyPosition from "@/hooks/useStickyPosition"
|
|
import { trackPaymentEvent } from "@/utils/tracking"
|
|
import { trackEvent } from "@/utils/tracking/base"
|
|
import { trackGlaSaveCardAttempt } from "@/utils/tracking/myStay"
|
|
|
|
import { bedTypeMap } from "../../utils"
|
|
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 } 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"
|
|
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,
|
|
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, preSubmitCallbacks } =
|
|
useEnterDetailsStore((state) => ({
|
|
booking: state.booking,
|
|
rooms: state.rooms,
|
|
totalPrice: state.totalPrice,
|
|
preSubmitCallbacks: state.preSubmitCallbacks,
|
|
}))
|
|
|
|
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 setIsSubmittingDisabled = useEnterDetailsStore(
|
|
(state) => state.actions.setIsSubmittingDisabled
|
|
)
|
|
|
|
const [bookingNumber, setBookingNumber] = useState<string>("")
|
|
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
|
|
useState(false)
|
|
|
|
const availablePaymentOptions =
|
|
useAvailablePaymentOptions(otherPaymentOptions)
|
|
const [priceChangeData, setPriceChangeData] =
|
|
useState<PriceChangeData | null>(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<PaymentFormData>({
|
|
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
|
|
}
|
|
|
|
if (result.reservationStatus == BookingStatusEnum.BookingCompleted) {
|
|
const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${result.id}`
|
|
router.push(confirmationUrl)
|
|
return
|
|
}
|
|
|
|
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,
|
|
expectedStatuses: [BookingStatusEnum.BookingCompleted],
|
|
maxRetries,
|
|
retryInterval,
|
|
enabled: isPollingForBookingStatus,
|
|
})
|
|
|
|
const handlePaymentError = useCallback(
|
|
(errorMessage: string) => {
|
|
setShowBookingAlert(true)
|
|
|
|
const currentPaymentMethod = methods.getValues("paymentMethod")
|
|
const smsEnable = methods.getValues("smsConfirmation")
|
|
const guarantee = methods.getValues("guarantee")
|
|
const isSavedCreditCard = savedCreditCards?.some(
|
|
(card) => card.id === currentPaymentMethod
|
|
)
|
|
|
|
if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) {
|
|
const lateArrivalGuarantee = guarantee ? "yes" : "mandatory"
|
|
trackEvent({
|
|
event: "glaCardSaveFailed",
|
|
hotelInfo: {
|
|
hotelId,
|
|
lateArrivalGuarantee,
|
|
guaranteedProduct: "room",
|
|
},
|
|
paymentInfo: {
|
|
isSavedCreditCard,
|
|
hotelId,
|
|
status: "glacardsavefailed",
|
|
},
|
|
})
|
|
} else {
|
|
trackPaymentEvent({
|
|
event: "paymentFail",
|
|
hotelId,
|
|
method: currentPaymentMethod,
|
|
isSavedCreditCard,
|
|
smsEnable,
|
|
errorMessage,
|
|
status: "failed",
|
|
})
|
|
}
|
|
},
|
|
[
|
|
methods,
|
|
savedCreditCards,
|
|
hotelId,
|
|
bookingMustBeGuaranteed,
|
|
hasOnlyFlexRates,
|
|
]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (bookingStatus?.data?.paymentUrl) {
|
|
router.push(bookingStatus.data.paymentUrl)
|
|
} else if (
|
|
bookingStatus?.data?.reservationStatus ===
|
|
BookingStatusEnum.BookingCompleted
|
|
) {
|
|
const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${bookingStatus?.data?.id}`
|
|
router.push(confirmationUrl)
|
|
} else if (bookingStatus.isTimeout) {
|
|
handlePaymentError("Timeout")
|
|
}
|
|
}, [bookingStatus, router, intl, lang, handlePaymentError])
|
|
|
|
useEffect(() => {
|
|
setIsSubmittingDisabled(
|
|
!methods.formState.isValid || methods.formState.isSubmitting
|
|
)
|
|
}, [
|
|
methods.formState.isValid,
|
|
methods.formState.isSubmitting,
|
|
setIsSubmittingDisabled,
|
|
])
|
|
|
|
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) => {
|
|
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) {
|
|
return
|
|
}
|
|
const roomElementTop =
|
|
roomElement.getBoundingClientRect().top + window.scrollY
|
|
|
|
window.scrollTo({
|
|
top: roomElementTop - getTopOffset() - 20,
|
|
behavior: "smooth",
|
|
})
|
|
|
|
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
|
|
|
|
if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) {
|
|
const lateArrivalGuarantee = guarantee ? "yes" : "mandatory"
|
|
writeGlaToSessionStorage(lateArrivalGuarantee, hotelId)
|
|
trackGlaSaveCardAttempt(hotelId, savedCreditCard, lateArrivalGuarantee)
|
|
} else if (!hasOnlyFlexRates) {
|
|
trackPaymentEvent({
|
|
event: "paymentAttemptStart",
|
|
hotelId,
|
|
method: savedCreditCard ? savedCreditCard.type : paymentMethod,
|
|
isSavedCreditCard: !!savedCreditCard,
|
|
smsEnable: data.smsConfirmation,
|
|
status: "attempt",
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
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: 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,
|
|
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,
|
|
]
|
|
)
|
|
|
|
if (
|
|
initiateBooking.isPending ||
|
|
(isPollingForBookingStatus &&
|
|
!bookingStatus.data?.paymentUrl &&
|
|
!bookingStatus.isTimeout)
|
|
) {
|
|
return <LoadingSpinner />
|
|
}
|
|
|
|
const paymentGuarantee = intl.formatMessage({
|
|
defaultMessage: "Payment Guarantee",
|
|
})
|
|
const payment = intl.formatMessage({
|
|
defaultMessage: "Payment",
|
|
})
|
|
const confirm = intl.formatMessage({
|
|
defaultMessage: "Confirm booking",
|
|
})
|
|
|
|
return (
|
|
<section className={styles.paymentSection}>
|
|
<header>
|
|
<Title level="h2" as="h4">
|
|
{hasOnlyFlexRates && bookingMustBeGuaranteed
|
|
? paymentGuarantee
|
|
: hasOnlyFlexRates
|
|
? confirm
|
|
: payment}
|
|
</Title>
|
|
<BookingAlert isVisible={showBookingAlert} />
|
|
</header>
|
|
<FormProvider {...methods}>
|
|
<form
|
|
className={styles.paymentContainer}
|
|
onSubmit={methods.handleSubmit(handleSubmit)}
|
|
id={formId}
|
|
>
|
|
{booking.searchType === REDEMPTION ? (
|
|
<ConfirmBookingRedemption />
|
|
) : hasOnlyFlexRates && !bookingMustBeGuaranteed ? (
|
|
<ConfirmBooking savedCreditCards={savedCreditCards} />
|
|
) : (
|
|
<>
|
|
{hasOnlyFlexRates && bookingMustBeGuaranteed ? (
|
|
<section className={styles.section}>
|
|
<Body>
|
|
{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.",
|
|
})}
|
|
</Body>
|
|
<GuaranteeDetails />
|
|
</section>
|
|
) : null}
|
|
|
|
{hasMixedRates ? (
|
|
<Body>
|
|
{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.",
|
|
})}
|
|
</Body>
|
|
) : null}
|
|
|
|
<section className={styles.section}>
|
|
<PaymentOptionsGroup
|
|
name="paymentMethod"
|
|
className={styles.paymentOptionContainer}
|
|
>
|
|
<Label className="sr-only">
|
|
{intl.formatMessage({
|
|
defaultMessage: "Payment methods",
|
|
})}
|
|
</Label>
|
|
|
|
{savedCreditCards?.length ? (
|
|
<>
|
|
<Typography variant="Title/Overline/sm">
|
|
<span>
|
|
{intl.formatMessage({
|
|
defaultMessage: "MY SAVED CARDS",
|
|
})}
|
|
</span>
|
|
</Typography>
|
|
|
|
{savedCreditCards.map((savedCreditCard) => (
|
|
<PaymentOption
|
|
key={savedCreditCard.id}
|
|
value={savedCreditCard.id}
|
|
label={
|
|
PAYMENT_METHOD_TITLES[
|
|
savedCreditCard.cardType as PaymentMethodEnum
|
|
]
|
|
}
|
|
cardNumber={savedCreditCard.truncatedNumber}
|
|
/>
|
|
))}
|
|
|
|
<Typography variant="Title/Overline/sm">
|
|
<span>
|
|
{intl.formatMessage({
|
|
defaultMessage: "OTHER PAYMENT METHODS",
|
|
})}
|
|
</span>
|
|
</Typography>
|
|
</>
|
|
) : null}
|
|
<PaymentOption
|
|
value={PaymentMethodEnum.card}
|
|
label={intl.formatMessage({
|
|
defaultMessage: "Credit card",
|
|
})}
|
|
/>
|
|
{!hasMixedRates &&
|
|
availablePaymentOptions.map((paymentMethod) => (
|
|
<PaymentOption
|
|
key={paymentMethod}
|
|
value={paymentMethod}
|
|
label={
|
|
PAYMENT_METHOD_TITLES[
|
|
paymentMethod as PaymentMethodEnum
|
|
]
|
|
}
|
|
/>
|
|
))}
|
|
</PaymentOptionsGroup>
|
|
{hasMixedRates ? (
|
|
<MixedRatePaymentBreakdown
|
|
rooms={rooms}
|
|
currency={totalPrice.local.currency}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className={styles.section}>
|
|
<TermsAndConditions isFlexBookingTerms={hasOnlyFlexRates} />
|
|
</section>
|
|
</>
|
|
)}
|
|
<div className={styles.submitButton}>
|
|
<Button
|
|
intent="primary"
|
|
theme="base"
|
|
size="small"
|
|
type="submit"
|
|
disabled={methods.formState.isSubmitting}
|
|
>
|
|
{intl.formatMessage({
|
|
defaultMessage: "Complete booking",
|
|
})}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</FormProvider>
|
|
{priceChangeData ? (
|
|
<PriceChangeDialog
|
|
isOpen={!!priceChangeData}
|
|
priceChangeData={priceChangeData}
|
|
prevTotalPrice={totalPrice.local.price}
|
|
currency={totalPrice.local.currency}
|
|
onCancel={() => {
|
|
const allSearchParams = searchParams.size
|
|
? `?${searchParams.toString()}`
|
|
: ""
|
|
router.push(`${selectRate(lang)}${allSearchParams}`)
|
|
}}
|
|
onAccept={() =>
|
|
priceChange.mutate({ confirmationNumber: bookingNumber })
|
|
}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
)
|
|
}
|