Files
web/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx
Niclas Edenvin fb44990777 Merged in fix/SW-2501-validation-trigger (pull request #1924)
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
2025-05-02 13:59:23 +00:00

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>
)
}