Files
web/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx
Niclas Edenvin 341be43a53 Merged in fix/sw-2501-remove-continue-button-light (pull request #1892)
fix(sw-2501): remove the continue buttons on enter details

This removes the continue buttons on the enter details page.

This is only that, not the refactoring of the whole enter details page with changing to one form etc. Since I just didn’t complete that refactor today I decided to do this light variant for now.

A quick explanation is that the continue buttons are removed and instead the form is submitted (meaning saving the form data to the store) on blur on the input elements IF the form is valid. If it’s invalid we change the isComplete flag in the store to false. This will hopefully also fix a bug where you were able to submit old data if the new data is invalid.

When clicking the submit button and a room is incomplete/invalid the browser scrolls to the first invalid room.

Approved-by: Erik Tiekstra
2025-04-30 08:56:16 +00:00

628 lines
20 KiB
TypeScript

"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { 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,
BookingErrorCodeEnum,
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 GuaranteeDetails from "./GuaranteeDetails"
import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum } from "./helpers"
import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown"
import PaymentAlert from "./PaymentAlert"
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 searchParams = useSearchParams()
const { getTopOffset } = useStickyPosition({})
const [showPaymentAlert, setShowPaymentAlert] = useState(false)
const { booking, rooms, totalPrice } = useEnterDetailsStore((state) => ({
booking: state.booking,
rooms: state.rooms,
totalPrice: state.totalPrice,
}))
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) {
if (result.cause === BookingErrorCodeEnum.AvailabilityError) {
window.location.reload() // reload to refetch room data because we dont know which room is unavailable
} else {
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) => {
setShowPaymentAlert(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) => {
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,
getPaymentMethod,
hasOnlyFlexRates,
bookingMustBeGuaranteed,
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>
<PaymentAlert isVisible={showPaymentAlert} />
</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 />
</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>
)
}