Files
web/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx
Matilda Landström d23137a69d Merged in fix(BOOK-659)-multiroom-name-check (pull request #3385)
fix(BOOK-659): use unique ids for multiroom booking input fields

* fix(BOOK-659): use unique ids for multiroom booking input fields


Approved-by: Bianca Widstam
Approved-by: Linus Flood
2026-01-05 11:20:20 +00:00

630 lines
20 KiB
TypeScript

"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { cx } from "class-variance-authority"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import {
bookingConfirmation,
selectRate,
} from "@scandic-hotels/common/constants/routes/hotelReservation"
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
import { logger } from "@scandic-hotels/common/logger"
import { formatPhoneNumber } from "@scandic-hotels/common/utils/phone"
import { Button } from "@scandic-hotels/design-system/Button"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackEvent } from "@scandic-hotels/tracking/base"
import {
trackGlaSaveCardAttempt,
trackPaymentEvent,
} from "@scandic-hotels/tracking/payment"
import { trpc } from "@scandic-hotels/trpc/client"
import { bedTypeMap } from "@scandic-hotels/trpc/constants/bedTypeMap"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { BookingStatusEnum } from "@scandic-hotels/trpc/enums/bookingStatus"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import { useBookingFlowConfig } from "../../../bookingFlowConfig/bookingFlowConfigContext"
import { useBookingFlowContext } from "../../../hooks/useBookingFlowContext"
import { clearBookingWidgetState } from "../../../hooks/useBookingWidgetState"
import { useHandleBookingStatus } from "../../../hooks/useHandleBookingStatus"
import useLang from "../../../hooks/useLang"
import { useEnterDetailsStore } from "../../../stores/enter-details"
import ConfirmBooking from "../Confirm"
import PriceChangeDialog from "../PriceChangeDialog"
import { writeGlaToSessionStorage } from "./PaymentCallback/helpers"
import BookingAlert from "./BookingAlert"
import { GuaranteeInfo } from "./GuaranteeInfo"
import {
getPaymentData,
getPaymentMethod,
hasFlexibleRate,
hasPrepaidRate,
mustGuaranteeBooking,
writePaymentInfoToSessionStorage,
} from "./helpers"
import { type PaymentFormData, paymentSchema } from "./schema"
import { getPaymentHeadingConfig } from "./utils"
import styles from "./payment.module.css"
import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema"
import type { CreditCard } from "@scandic-hotels/trpc/types/user"
import type { PriceChangeData } from "../PriceChangeData"
const maxRetries = 15
const retryInterval = 2000
type PaymentClientProps = {
otherPaymentOptions: PaymentMethodEnum[]
savedCreditCards: CreditCard[] | null
}
export const formId = "submit-booking"
export default function PaymentClient({
otherPaymentOptions,
savedCreditCards,
}: PaymentClientProps) {
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const pathname = usePathname()
const searchParams = useSearchParams()
const { getTopOffset } = useStickyPosition({})
const { user, isLoggedIn } = useBookingFlowContext()
const { redemptionType } = useBookingFlowConfig()
const [refId, setRefId] = useState("")
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
useState(false)
const [priceChangeData, setPriceChangeData] =
useState<PriceChangeData | null>(null)
const [showBookingAlert, setShowBookingAlert] = useState(false)
const {
booking,
rooms,
totalPrice,
isSubmitting,
setIsSubmitting,
runPreSubmitCallbacks,
} = useEnterDetailsStore((state) => ({
booking: state.booking,
rooms: state.rooms,
totalPrice: state.totalPrice,
isSubmitting: state.isSubmitting,
setIsSubmitting: state.actions.setIsSubmitting,
runPreSubmitCallbacks: state.actions.runPreSubmitCallbacks,
}))
const bookingMustBeGuaranteed = mustGuaranteeBooking({
isUserLoggedIn: isLoggedIn,
booking,
rooms,
})
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
}
const { booking } = result
const mainRoom = booking.rooms[0]
if (booking.reservationStatus == BookingStatusEnum.BookingCompleted) {
clearBookingWidgetState()
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
// eslint-disable-next-line react-hooks/immutability
document.cookie = `bcsig=${result.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}`
router.push(confirmationUrl)
return
}
setRefId(mainRoom.refId)
const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata)
if (hasPriceChange) {
const priceChangeData = booking.rooms
.map((room) => room.priceChangedMetadata || null)
.filter(isNotNull)
setPriceChangeData(priceChangeData)
} else {
setIsPollingForBookingStatus(true)
}
} else {
handlePaymentError("No confirmation number")
}
},
onError: (error) => {
logger.error("Booking 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) => {
logger.error("Price change error", error)
setPriceChangeData(null)
handlePaymentError(error.message)
},
})
const { toDate, fromDate, hotelId } = booking
const trackPaymentError = useCallback(
(errorMessage: string) => {
const currentPaymentMethod = methods.getValues("paymentMethod")
const smsEnable = methods.getValues("smsConfirmation")
const guarantee = methods.getValues("guarantee")
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === currentPaymentMethod
)
const isSavedCreditCard = !!savedCreditCard
if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) {
const lateArrivalGuarantee = guarantee ? "yes" : "mandatory"
trackEvent({
event: "glaCardSaveFailed",
hotelInfo: {
hotelId,
lateArrivalGuarantee,
guaranteedProduct: "room",
},
paymentInfo: {
isSavedCreditCard,
hotelId,
status: "glacardsavefailed",
type: savedCreditCard ? savedCreditCard.type : currentPaymentMethod,
},
})
} else {
trackPaymentEvent({
event: "paymentFail",
hotelId,
method: savedCreditCard ? savedCreditCard.type : currentPaymentMethod,
isSavedCreditCard,
smsEnable,
errorMessage,
status: "failed",
})
}
},
[
bookingMustBeGuaranteed,
hasOnlyFlexRates,
hotelId,
methods,
savedCreditCards,
]
)
const handlePaymentError = useCallback(
(errorMessage: string) => {
setShowBookingAlert(true)
setIsSubmitting(false)
trackPaymentError(errorMessage)
},
[setIsSubmitting, trackPaymentError]
)
useBookingStatusRedirect({
refId,
enabled: isPollingForBookingStatus,
onError: handlePaymentError,
})
const scrollToInvalidField = useCallback(async (): Promise<boolean> => {
// If any room is not complete/valid, scroll to the first invalid field, this is needed as rooms and other fields are in separate forms
const invalidField = await runPreSubmitCallbacks()
const errorNames = Object.keys(methods.formState.errors)
const firstIncompleteRoomIndex = rooms.findIndex((room) => !room.isComplete)
if (invalidField) {
scrollToElement(invalidField, getTopOffset())
} else if (errorNames.length > 0) {
const firstErrorEl = document.querySelector(`[name="${errorNames[0]}"]`)
if (firstErrorEl) {
scrollToElement(firstErrorEl as HTMLElement, getTopOffset())
}
}
return firstIncompleteRoomIndex !== -1
}, [runPreSubmitCallbacks, rooms, methods.formState.errors, getTopOffset])
const isRedemptionBooking = booking.searchType === SEARCH_TYPE_REDEMPTION
const handleSubmit = useCallback(
async (data: PaymentFormData) => {
setIsSubmitting(true)
const isRoomInvalid = await scrollToInvalidField()
if (isRoomInvalid) {
setIsSubmitting(false)
return
}
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
const guarantee = data.guarantee
const paymentMethod = getPaymentMethod({
paymentMethod: data.paymentMethod,
hasFlexRates,
isRedemptionBooking,
redemptionType,
})
const payment = getPaymentData({
guarantee,
bookingMustBeGuaranteed,
hasOnlyFlexRates,
paymentMethod,
savedCreditCard,
isRedemptionBooking,
lang,
})
const paymentMethodType = savedCreditCard
? savedCreditCard.type
: paymentMethod
trackPaymentEvents({
isSavedCreditCard: !!savedCreditCard,
paymentMethodType,
guarantee,
smsEnable: data.smsConfirmation,
bookingMustBeGuaranteed,
hasOnlyFlexRates,
hotelId,
})
const payload: CreateBookingInput = {
checkInDate: fromDate,
checkOutDate: toDate,
hotelId,
language: lang,
payment: payment ?? undefined,
rooms: rooms.map(
({ room }, idx): CreateBookingInput["rooms"][number] => {
const isMainRoom = idx === 0
let rateCode = ""
if (isMainRoom && isLoggedIn) {
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
}
const phoneNumber = formatPhoneNumber(
room.guest.phoneNumber,
room.guest.phoneNumberCC
)
const guest: CreateBookingInput["rooms"][number]["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,
partnerLoyaltyNumber: null,
}
if (isMainRoom) {
// Only valid for main room
guest.partnerLoyaltyNumber =
user?.data?.partnerLoyaltyNumber || null
guest.dateOfBirth =
"dateOfBirth" in room.guest && room.guest.dateOfBirth
? room.guest.dateOfBirth
: undefined
guest.postalCode =
"zipCode" in room.guest && room.guest.zipCode
? room.guest.zipCode
: undefined
}
const packages: CreateBookingInput["rooms"][number]["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,
}
return {
adults: room.adults,
bookingCode: room.roomRate.bookingCode,
childrenAges: room.childrenInRoom?.map((child) => ({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
guest,
packages,
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)
},
[
setIsSubmitting,
scrollToInvalidField,
savedCreditCards,
hasFlexRates,
redemptionType,
bookingMustBeGuaranteed,
hasOnlyFlexRates,
lang,
isRedemptionBooking,
hotelId,
fromDate,
toDate,
rooms,
initiateBooking,
isLoggedIn,
booking.rooms,
user?.data?.partnerLoyaltyNumber,
]
)
const handleInvalidSubmit = async () => {
const valid = await methods.trigger()
if (!valid) {
await scrollToInvalidField()
}
}
const { preHeading, heading, subHeading, showLearnMore } =
getPaymentHeadingConfig(intl, bookingMustBeGuaranteed, hasOnlyFlexRates)
return (
<section
className={cx(styles.paymentSection, {
[styles.isSubmitting]: isSubmitting,
})}
>
<header className={styles.header}>
<div>
{preHeading ? (
<Typography variant="Title/Overline/sm">
<p>{preHeading}</p>
</Typography>
) : null}
<Typography variant="Title/Subtitle/md">
<h2>{heading}</h2>
</Typography>
{subHeading ? (
<Typography variant="Body/Paragraph/mdBold">
<p>{subHeading}</p>
</Typography>
) : null}
</div>
{showLearnMore ? <GuaranteeInfo /> : null}
</header>
<BookingAlert isVisible={showBookingAlert} />
<FormProvider {...methods}>
<form
className={styles.paymentForm}
onSubmit={methods.handleSubmit(handleSubmit, handleInvalidSubmit)}
id={formId}
>
<ConfirmBooking
savedCreditCards={savedCreditCards}
otherPaymentOptions={otherPaymentOptions}
hasOnlyFlexRates={hasOnlyFlexRates}
hasMixedRates={hasMixedRates}
isRedemptionBooking={isRedemptionBooking}
bookingMustBeGuaranteed={bookingMustBeGuaranteed}
/>
<Button
className={styles.submitButton}
type="submit"
isDisabled={isSubmitting}
isPending={isSubmitting}
size="md"
>
{intl.formatMessage({
id: "enterDetails.completeBooking",
defaultMessage: "Complete booking",
})}
</Button>
</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({ refId })}
/>
) : null}
</section>
)
}
const scrollToElement = (el: HTMLElement, offset: number) => {
const top = el.getBoundingClientRect().top + window.scrollY - offset - 20
window.scrollTo({ top, behavior: "smooth" })
const input = el.querySelector<HTMLElement>("input")
input?.focus({ preventScroll: true })
}
function useBookingStatusRedirect({
refId,
enabled,
onError,
}: {
refId: string
enabled: boolean
onError: (errorMessage: string) => void
}) {
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const bookingStatus = useHandleBookingStatus({
refId,
expectedStatuses: [BookingStatusEnum.BookingCompleted],
maxRetries,
retryInterval,
enabled,
})
useEffect(() => {
if (bookingStatus?.data?.booking.paymentUrl) {
router.push(bookingStatus.data.booking.paymentUrl)
return
}
if (
bookingStatus?.data?.booking.reservationStatus ===
BookingStatusEnum.BookingCompleted
) {
const mainRoom = bookingStatus.data.booking.rooms[0]
clearBookingWidgetState()
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
document.cookie = `bcsig=${bookingStatus.data.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}`
router.push(confirmationUrl)
return
}
if (bookingStatus.isTimeout) {
onError("Timeout")
}
}, [bookingStatus.data, bookingStatus.isTimeout, router, intl, lang, onError])
}
function isNotNull<T>(value: T | null): value is T {
return value !== null
}
function trackPaymentEvents(data: {
isSavedCreditCard: boolean
paymentMethodType: string
guarantee: boolean
smsEnable: boolean
bookingMustBeGuaranteed: boolean
hasOnlyFlexRates: boolean
hotelId: string
}) {
const {
isSavedCreditCard,
paymentMethodType,
guarantee,
smsEnable,
bookingMustBeGuaranteed,
hasOnlyFlexRates,
hotelId,
} = data
if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) {
const lateArrivalGuarantee = guarantee ? "yes" : "mandatory"
writeGlaToSessionStorage(
lateArrivalGuarantee,
hotelId,
paymentMethodType,
isSavedCreditCard
)
trackGlaSaveCardAttempt({
hotelId,
hasSavedCreditCard: isSavedCreditCard,
creditCardType: isSavedCreditCard ? paymentMethodType : undefined,
lateArrivalGuarantee,
})
} else if (!hasOnlyFlexRates) {
trackPaymentEvent({
event: "paymentAttemptStart",
hotelId,
method: paymentMethodType,
isSavedCreditCard,
smsEnable,
status: "attempt",
})
}
writePaymentInfoToSessionStorage(paymentMethodType, isSavedCreditCard)
}