Files
web/apps/scandic-web/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx
Anton Gunnarsson 4e1cb01b84 Merged in chore/cleanup-after-trpc-migration (pull request #2457)
Chore/cleanup after trpc migration

* Clean up TODOs

* Rename REDEMPTION constant to SEARCH_TYPE_REDEMPTION

* Update dependencies

Remove unused deps from scandic-web
Add missing deps to trpc package

* Update self-referencing imports

* Remove unused variables from scandic-web env

* Fix missing graphql-tag package

* Actually fix

* Remove unused env var


Approved-by: Christian Andolf
Approved-by: Linus Flood
2025-06-30 12:08:19 +00:00

667 lines
22 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 { Label } from "react-aria-components"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { Button } from "@scandic-hotels/design-system/Button"
import { Typography } from "@scandic-hotels/design-system/Typography"
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 { PAYMENT_METHOD_TITLES } from "@/constants/booking"
import { bookingConfirmation } 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 Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Body from "@/components/TempDesignSystem/Text/Body"
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import useLang from "@/hooks/useLang"
import useStickyPosition from "@/hooks/useStickyPosition"
import { formatPhoneNumber } from "@/utils/phone"
import { trackPaymentEvent } from "@/utils/tracking"
import { trackEvent } from "@/utils/tracking/base"
import { trackGlaSaveCardAttempt } from "@/utils/tracking/myStay"
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,
writePaymentInfoToSessionStorage,
} 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"
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,
isSubmitting,
preSubmitCallbacks,
setIsSubmitting,
} = useEnterDetailsStore((state) => ({
booking: state.booking,
rooms: state.rooms,
totalPrice: state.totalPrice,
preSubmitCallbacks: state.preSubmitCallbacks,
isSubmitting: state.isSubmitting,
setIsSubmitting: state.actions.setIsSubmitting,
}))
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 [refId, setRefId] = useState("")
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
}
const { booking } = result
const mainRoom = booking.rooms[0]
if (booking.reservationStatus == BookingStatusEnum.BookingCompleted) {
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
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
)
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({
refId,
expectedStatuses: [BookingStatusEnum.BookingCompleted],
maxRetries,
retryInterval,
enabled: isPollingForBookingStatus,
})
const handlePaymentError = useCallback(
(errorMessage: string) => {
setShowBookingAlert(true)
setIsSubmitting(false)
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",
})
}
},
[
methods,
savedCreditCards,
hotelId,
bookingMustBeGuaranteed,
hasOnlyFlexRates,
setIsSubmitting,
]
)
useEffect(() => {
if (bookingStatus?.data?.booking.paymentUrl) {
router.push(bookingStatus.data.booking.paymentUrl)
} else if (
bookingStatus?.data?.booking.reservationStatus ===
BookingStatusEnum.BookingCompleted
) {
const mainRoom = bookingStatus.data.booking.rooms[0]
// 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)
} else if (bookingStatus.isTimeout) {
handlePaymentError("Timeout")
}
}, [
bookingStatus.data,
bookingStatus.isTimeout,
router,
intl,
lang,
handlePaymentError,
])
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) => {
setIsSubmitting(true)
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) {
setIsSubmitting(false)
return
}
const roomElementTop =
roomElement.getBoundingClientRect().top + window.scrollY
window.scrollTo({
top: roomElementTop - getTopOffset() - 20,
behavior: "smooth",
})
setIsSubmitting(false)
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
const paymentMethodType = savedCreditCard
? savedCreditCard.type
: paymentMethod
if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) {
const lateArrivalGuarantee = guarantee ? "yes" : "mandatory"
writeGlaToSessionStorage(
lateArrivalGuarantee,
hotelId,
paymentMethodType,
!!savedCreditCard
)
trackGlaSaveCardAttempt(hotelId, savedCreditCard, lateArrivalGuarantee)
} else if (!hasOnlyFlexRates) {
trackPaymentEvent({
event: "paymentAttemptStart",
hotelId,
method: paymentMethodType,
isSavedCreditCard: !!savedCreditCard,
smsEnable: data.smsConfirmation,
status: "attempt",
})
}
writePaymentInfoToSessionStorage(paymentMethodType, !!savedCreditCard)
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
}
const phoneNumber = formatPhoneNumber(
room.guest.phoneNumber,
room.guest.phoneNumberCC
)
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,
// 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,
setIsSubmitting,
]
)
const finalStep = intl.formatMessage({ defaultMessage: "Final step" })
const selectPayment = intl.formatMessage({
defaultMessage: "Select payment method",
})
return (
<section
className={cx(styles.paymentSection, {
[styles.disabled]: isSubmitting,
})}
>
<header>
<Typography variant="Title/Subtitle/md">
<span>{hasOnlyFlexRates ? finalStep : selectPayment}</span>
</Typography>
<BookingAlert isVisible={showBookingAlert} />
</header>
<FormProvider {...methods}>
<form
className={styles.paymentContainer}
onSubmit={methods.handleSubmit(handleSubmit)}
id={formId}
>
{booking.searchType === SEARCH_TYPE_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>
<div className={styles.checkboxContainer}>
<Checkbox name="smsConfirmation">
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>
{intl.formatMessage({
defaultMessage:
"I would like to get my booking confirmation via sms",
})}
</span>
</Typography>
</Checkbox>
</div>
<section className={styles.section}>
<TermsAndConditions isFlexBookingTerms={hasOnlyFlexRates} />
</section>
</>
)}
<div className={styles.submitButton}>
<Button
type="submit"
isDisabled={isSubmitting}
isPending={isSubmitting}
typography="Body/Supporting text (caption)/smBold"
>
{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({ refId })}
/>
) : null}
</section>
)
}