Merged in chore/refactor-paymentclient (pull request #3219)

chore: Refactor PaymentClient

* Extract function mustGuaranteeBooking

* Break apart PaymentClient


Approved-by: Joakim Jäderberg
This commit is contained in:
Anton Gunnarsson
2025-11-27 10:04:10 +00:00
parent 9937dfd002
commit c69993fa96
4 changed files with 329 additions and 137 deletions

View File

@@ -32,7 +32,6 @@ import { env } from "../../../../env/client"
import { useBookingFlowContext } from "../../../hooks/useBookingFlowContext" import { useBookingFlowContext } from "../../../hooks/useBookingFlowContext"
import { clearBookingWidgetState } from "../../../hooks/useBookingWidgetState" import { clearBookingWidgetState } from "../../../hooks/useBookingWidgetState"
import { useHandleBookingStatus } from "../../../hooks/useHandleBookingStatus" import { useHandleBookingStatus } from "../../../hooks/useHandleBookingStatus"
import { useIsLoggedIn } from "../../../hooks/useIsLoggedIn"
import useLang from "../../../hooks/useLang" import useLang from "../../../hooks/useLang"
import { useEnterDetailsStore } from "../../../stores/enter-details" import { useEnterDetailsStore } from "../../../stores/enter-details"
import ConfirmBooking from "../Confirm" import ConfirmBooking from "../Confirm"
@@ -44,6 +43,7 @@ import {
hasFlexibleRate, hasFlexibleRate,
hasPrepaidRate, hasPrepaidRate,
isPaymentMethodEnum, isPaymentMethodEnum,
mustGuaranteeBooking,
writePaymentInfoToSessionStorage, writePaymentInfoToSessionStorage,
} from "./helpers" } from "./helpers"
import { type PaymentFormData, paymentSchema } from "./schema" import { type PaymentFormData, paymentSchema } from "./schema"
@@ -51,6 +51,7 @@ import { getPaymentHeadingConfig } from "./utils"
import styles from "./payment.module.css" import styles from "./payment.module.css"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema" import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema"
import type { CreditCard } from "@scandic-hotels/trpc/types/user" import type { CreditCard } from "@scandic-hotels/trpc/types/user"
@@ -74,10 +75,13 @@ export default function PaymentClient({
const intl = useIntl() const intl = useIntl()
const pathname = usePathname() const pathname = usePathname()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const isUserLoggedIn = useIsLoggedIn()
const { getTopOffset } = useStickyPosition({}) const { getTopOffset } = useStickyPosition({})
const { user } = useBookingFlowContext() const { user, isLoggedIn } = useBookingFlowContext()
const [refId, setRefId] = useState("")
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
useState(false)
const [priceChangeData, setPriceChangeData] =
useState<PriceChangeData | null>(null)
const [showBookingAlert, setShowBookingAlert] = useState(false) const [showBookingAlert, setShowBookingAlert] = useState(false)
const { const {
@@ -96,35 +100,16 @@ export default function PaymentClient({
runPreSubmitCallbacks: state.actions.runPreSubmitCallbacks, runPreSubmitCallbacks: state.actions.runPreSubmitCallbacks,
})) }))
const bookingMustBeGuaranteed = rooms.some(({ room }, idx) => { const bookingMustBeGuaranteed = mustGuaranteeBooking({
if (idx === 0 && isUserLoggedIn && room.memberMustBeGuaranteed) { isUserLoggedIn: isLoggedIn,
return true booking,
} rooms,
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 [priceChangeData, setPriceChangeData] =
useState<PriceChangeData | null>(null)
const { toDate, fromDate, hotelId } = booking
const hasPrepaidRates = rooms.some(hasPrepaidRate) const hasPrepaidRates = rooms.some(hasPrepaidRate)
const hasFlexRates = rooms.some(hasFlexibleRate) const hasFlexRates = rooms.some(hasFlexibleRate)
const hasOnlyFlexRates = rooms.every(hasFlexibleRate) const hasOnlyFlexRates = rooms.every(hasFlexibleRate)
const hasMixedRates = hasPrepaidRates && hasFlexRates const hasMixedRates = hasPrepaidRates && hasFlexRates
const isRedemptionBooking = booking.searchType === SEARCH_TYPE_REDEMPTION
const methods = useForm<PaymentFormData>({ const methods = useForm<PaymentFormData>({
defaultValues: { defaultValues: {
@@ -171,9 +156,9 @@ export default function PaymentClient({
const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata) const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata)
if (hasPriceChange) { if (hasPriceChange) {
const priceChangeData = booking.rooms.map( const priceChangeData = booking.rooms
(room) => room.priceChangedMetadata || null .map((room) => room.priceChangedMetadata || null)
) .filter(isNotNull)
setPriceChangeData(priceChangeData) setPriceChangeData(priceChangeData)
} else { } else {
setIsPollingForBookingStatus(true) setIsPollingForBookingStatus(true)
@@ -204,19 +189,9 @@ export default function PaymentClient({
}, },
}) })
const bookingStatus = useHandleBookingStatus({ const { toDate, fromDate, hotelId } = booking
refId, const trackPaymentError = useCallback(
expectedStatuses: [BookingStatusEnum.BookingCompleted],
maxRetries,
retryInterval,
enabled: isPollingForBookingStatus,
})
const handlePaymentError = useCallback(
(errorMessage: string) => { (errorMessage: string) => {
setShowBookingAlert(true)
setIsSubmitting(false)
const currentPaymentMethod = methods.getValues("paymentMethod") const currentPaymentMethod = methods.getValues("paymentMethod")
const smsEnable = methods.getValues("smsConfirmation") const smsEnable = methods.getValues("smsConfirmation")
const guarantee = methods.getValues("guarantee") const guarantee = methods.getValues("guarantee")
@@ -255,52 +230,30 @@ export default function PaymentClient({
} }
}, },
[ [
methods,
savedCreditCards,
hotelId,
bookingMustBeGuaranteed, bookingMustBeGuaranteed,
hasOnlyFlexRates, hasOnlyFlexRates,
setIsSubmitting, hotelId,
methods,
savedCreditCards,
] ]
) )
useEffect(() => { const handlePaymentError = useCallback(
if (bookingStatus?.data?.booking.paymentUrl) { (errorMessage: string) => {
router.push(bookingStatus.data.booking.paymentUrl) setShowBookingAlert(true)
} else if ( setIsSubmitting(false)
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)
} else if (bookingStatus.isTimeout) {
handlePaymentError("Timeout")
}
}, [
bookingStatus.data,
bookingStatus.isTimeout,
router,
intl,
lang,
handlePaymentError,
])
const getPaymentMethod = useCallback( trackPaymentError(errorMessage)
(paymentMethod: string | null | undefined): PaymentMethodEnum => {
if (hasFlexRates) {
return PaymentMethodEnum.card
}
return paymentMethod && isPaymentMethodEnum(paymentMethod)
? paymentMethod
: PaymentMethodEnum.card
}, },
[hasFlexRates] [setIsSubmitting, trackPaymentError]
) )
useBookingStatusRedirect({
refId,
enabled: isPollingForBookingStatus,
onError: handlePaymentError,
})
const scrollToInvalidField = useCallback(async (): Promise<boolean> => { 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 // 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
@@ -308,20 +261,12 @@ export default function PaymentClient({
const errorNames = Object.keys(methods.formState.errors) const errorNames = Object.keys(methods.formState.errors)
const firstIncompleteRoomIndex = rooms.findIndex((room) => !room.isComplete) const firstIncompleteRoomIndex = rooms.findIndex((room) => !room.isComplete)
const scrollToElement = (el: HTMLElement) => {
const offset = getTopOffset()
const top = el.getBoundingClientRect().top + window.scrollY - offset - 20
window.scrollTo({ top, behavior: "smooth" })
const input = el.querySelector<HTMLElement>("input")
input?.focus({ preventScroll: true })
}
if (invalidField) { if (invalidField) {
scrollToElement(invalidField) scrollToElement(invalidField, getTopOffset())
} else if (errorNames.length > 0) { } else if (errorNames.length > 0) {
const firstErrorEl = document.querySelector(`[name="${errorNames[0]}"]`) const firstErrorEl = document.querySelector(`[name="${errorNames[0]}"]`)
if (firstErrorEl) { if (firstErrorEl) {
scrollToElement(firstErrorEl as HTMLElement) scrollToElement(firstErrorEl as HTMLElement, getTopOffset())
} }
} }
@@ -338,63 +283,32 @@ export default function PaymentClient({
return return
} }
const paymentMethod = getPaymentMethod(data.paymentMethod) const paymentMethod = getPaymentMethod(data.paymentMethod, hasFlexRates)
const savedCreditCard = savedCreditCards?.find( const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod (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 guarantee = data.guarantee
const useSavedCard = savedCreditCard
? {
card: {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
},
}
: {}
const shouldUsePayment = const shouldUsePayment =
guarantee || bookingMustBeGuaranteed || !hasOnlyFlexRates guarantee || bookingMustBeGuaranteed || !hasOnlyFlexRates
const payment = shouldUsePayment const payment = shouldUsePayment
? { ? getPaymentData({ paymentMethod, savedCreditCard, lang })
paymentMethod: paymentMethod,
...useSavedCard,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
}
: undefined : undefined
const paymentMethodType = savedCreditCard const paymentMethodType = savedCreditCard
? savedCreditCard.type ? savedCreditCard.type
: paymentMethod : paymentMethod
if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) { trackPaymentEvents({
const lateArrivalGuarantee = guarantee ? "yes" : "mandatory" isSavedCreditCard: !!savedCreditCard,
writeGlaToSessionStorage( paymentMethodType,
lateArrivalGuarantee, guarantee,
hotelId, smsEnable: data.smsConfirmation,
paymentMethodType, bookingMustBeGuaranteed,
!!savedCreditCard hasOnlyFlexRates,
) hotelId,
trackGlaSaveCardAttempt({ })
hotelId,
hasSavedCreditCard: !!savedCreditCard,
creditCardType: savedCreditCard?.cardType,
lateArrivalGuarantee,
})
} else if (!hasOnlyFlexRates) {
trackPaymentEvent({
event: "paymentAttemptStart",
hotelId,
method: paymentMethodType,
isSavedCreditCard: !!savedCreditCard,
smsEnable: data.smsConfirmation,
status: "attempt",
})
}
writePaymentInfoToSessionStorage(paymentMethodType, !!savedCreditCard)
const payload: CreateBookingInput = { const payload: CreateBookingInput = {
checkInDate: fromDate, checkInDate: fromDate,
@@ -406,7 +320,7 @@ export default function PaymentClient({
({ room }, idx): CreateBookingInput["rooms"][number] => { ({ room }, idx): CreateBookingInput["rooms"][number] => {
const isMainRoom = idx === 0 const isMainRoom = idx === 0
let rateCode = "" let rateCode = ""
if (isMainRoom && isUserLoggedIn) { if (isMainRoom && isLoggedIn) {
rateCode = booking.rooms[idx].rateCode rateCode = booking.rooms[idx].rateCode
} else if ( } else if (
(room.guest.join || room.guest.membershipNo) && (room.guest.join || room.guest.membershipNo) &&
@@ -500,17 +414,17 @@ export default function PaymentClient({
[ [
setIsSubmitting, setIsSubmitting,
scrollToInvalidField, scrollToInvalidField,
getPaymentMethod, hasFlexRates,
savedCreditCards, savedCreditCards,
lang,
bookingMustBeGuaranteed, bookingMustBeGuaranteed,
hasOnlyFlexRates, hasOnlyFlexRates,
lang,
fromDate, fromDate,
toDate, toDate,
hotelId, hotelId,
rooms, rooms,
initiateBooking, initiateBooking,
isUserLoggedIn, isLoggedIn,
booking.rooms, booking.rooms,
user?.data?.partnerLoyaltyNumber, user?.data?.partnerLoyaltyNumber,
] ]
@@ -526,6 +440,7 @@ export default function PaymentClient({
const { preHeading, heading, subHeading, showLearnMore } = const { preHeading, heading, subHeading, showLearnMore } =
getPaymentHeadingConfig(intl, bookingMustBeGuaranteed, hasOnlyFlexRates) getPaymentHeadingConfig(intl, bookingMustBeGuaranteed, hasOnlyFlexRates)
const isRedemptionBooking = booking.searchType === SEARCH_TYPE_REDEMPTION
return ( return (
<section <section
className={cx(styles.paymentSection, { className={cx(styles.paymentSection, {
@@ -599,3 +514,148 @@ export default function PaymentClient({
</section> </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 })
}
const getPaymentMethod = (
paymentMethod: string | null | undefined,
hasFlexRates: boolean
): PaymentMethodEnum => {
if (hasFlexRates) {
return PaymentMethodEnum.card
}
return paymentMethod && isPaymentMethodEnum(paymentMethod)
? paymentMethod
: PaymentMethodEnum.card
}
function createPaymentCallbackUrl(lang: Lang) {
return `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
}
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 getPaymentData({
paymentMethod,
savedCreditCard,
lang,
}: {
paymentMethod: PaymentMethodEnum
savedCreditCard?: CreditCard
lang: Lang
}) {
const paymentRedirectUrl = createPaymentCallbackUrl(lang)
return {
paymentMethod: paymentMethod,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
card: savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined,
}
}
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)
}

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest"
import { mustGuaranteeBooking } from "./helpers"
const buildRoom = (
overrides: Partial<{
memberMustBeGuaranteed: boolean
mustBeGuaranteed: boolean
guest: { join: boolean; membershipNo?: string }
}> = {}
) => ({
room: {
memberMustBeGuaranteed: false,
mustBeGuaranteed: false,
guest: { join: false, membershipNo: undefined },
...overrides,
},
})
describe("mustGuaranteeBooking", () => {
it("returns true when the first room requires a member guarantee for a logged-in user", () => {
const rooms = [
buildRoom({
memberMustBeGuaranteed: true,
mustBeGuaranteed: false,
}),
]
const booking = { rooms: [{}] }
expect(
mustGuaranteeBooking({
isUserLoggedIn: true,
booking,
rooms,
})
).toBe(true)
})
it("returns memberMustBeGuaranteed when guest has membership details and counter rate code", () => {
const rooms = [
buildRoom(),
buildRoom({
memberMustBeGuaranteed: true,
guest: { join: true },
}),
]
const booking = { rooms: [{}, { counterRateCode: "COUNTER" }] }
expect(
mustGuaranteeBooking({
isUserLoggedIn: false,
booking,
rooms,
})
).toBe(true)
})
it("returns false when member condition is not met despite counter rate code", () => {
const rooms = [
buildRoom(),
buildRoom({
memberMustBeGuaranteed: false,
guest: { join: true },
}),
]
const booking = { rooms: [{}, { counterRateCode: "COUNTER" }] }
expect(
mustGuaranteeBooking({
isUserLoggedIn: false,
booking,
rooms,
})
).toBe(false)
})
it("falls back to mustBeGuaranteed when no member-specific rules apply", () => {
const rooms = [
buildRoom({
memberMustBeGuaranteed: false,
mustBeGuaranteed: true,
}),
]
const booking = { rooms: [{}] }
expect(
mustGuaranteeBooking({
isUserLoggedIn: false,
booking,
rooms,
})
).toBe(true)
})
})

View File

@@ -89,3 +89,37 @@ export function writePaymentInfoToSessionStorage(
export function clearPaymentInfoSessionStorage() { export function clearPaymentInfoSessionStorage() {
sessionStorage.removeItem(paymentInfoStorageName) sessionStorage.removeItem(paymentInfoStorageName)
} }
export function mustGuaranteeBooking({
isUserLoggedIn,
booking,
rooms,
}: {
isUserLoggedIn: boolean
booking: { rooms: { counterRateCode?: string }[] }
rooms: {
room: {
memberMustBeGuaranteed?: boolean
mustBeGuaranteed: boolean
guest: {
join: boolean
membershipNo?: string
}
}
}[]
}) {
return 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
})
}

View File

@@ -2,4 +2,4 @@ export type PriceChangeData = Array<{
roomPrice: number roomPrice: number
totalPrice: number totalPrice: number
packagePrice?: number packagePrice?: number
} | null> }>