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 { clearBookingWidgetState } from "../../../hooks/useBookingWidgetState"
import { useHandleBookingStatus } from "../../../hooks/useHandleBookingStatus"
import { useIsLoggedIn } from "../../../hooks/useIsLoggedIn"
import useLang from "../../../hooks/useLang"
import { useEnterDetailsStore } from "../../../stores/enter-details"
import ConfirmBooking from "../Confirm"
@@ -44,6 +43,7 @@ import {
hasFlexibleRate,
hasPrepaidRate,
isPaymentMethodEnum,
mustGuaranteeBooking,
writePaymentInfoToSessionStorage,
} from "./helpers"
import { type PaymentFormData, paymentSchema } from "./schema"
@@ -51,6 +51,7 @@ import { getPaymentHeadingConfig } from "./utils"
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 { CreditCard } from "@scandic-hotels/trpc/types/user"
@@ -74,10 +75,13 @@ export default function PaymentClient({
const intl = useIntl()
const pathname = usePathname()
const searchParams = useSearchParams()
const isUserLoggedIn = useIsLoggedIn()
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 {
@@ -96,35 +100,16 @@ export default function PaymentClient({
runPreSubmitCallbacks: state.actions.runPreSubmitCallbacks,
}))
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 bookingMustBeGuaranteed = mustGuaranteeBooking({
isUserLoggedIn: isLoggedIn,
booking,
rooms,
})
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 hasFlexRates = rooms.some(hasFlexibleRate)
const hasOnlyFlexRates = rooms.every(hasFlexibleRate)
const hasMixedRates = hasPrepaidRates && hasFlexRates
const isRedemptionBooking = booking.searchType === SEARCH_TYPE_REDEMPTION
const methods = useForm<PaymentFormData>({
defaultValues: {
@@ -171,9 +156,9 @@ export default function PaymentClient({
const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata)
if (hasPriceChange) {
const priceChangeData = booking.rooms.map(
(room) => room.priceChangedMetadata || null
)
const priceChangeData = booking.rooms
.map((room) => room.priceChangedMetadata || null)
.filter(isNotNull)
setPriceChangeData(priceChangeData)
} else {
setIsPollingForBookingStatus(true)
@@ -204,19 +189,9 @@ export default function PaymentClient({
},
})
const bookingStatus = useHandleBookingStatus({
refId,
expectedStatuses: [BookingStatusEnum.BookingCompleted],
maxRetries,
retryInterval,
enabled: isPollingForBookingStatus,
})
const handlePaymentError = useCallback(
const { toDate, fromDate, hotelId } = booking
const trackPaymentError = useCallback(
(errorMessage: string) => {
setShowBookingAlert(true)
setIsSubmitting(false)
const currentPaymentMethod = methods.getValues("paymentMethod")
const smsEnable = methods.getValues("smsConfirmation")
const guarantee = methods.getValues("guarantee")
@@ -255,52 +230,30 @@ export default function PaymentClient({
}
},
[
methods,
savedCreditCards,
hotelId,
bookingMustBeGuaranteed,
hasOnlyFlexRates,
setIsSubmitting,
hotelId,
methods,
savedCreditCards,
]
)
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]
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 handlePaymentError = useCallback(
(errorMessage: string) => {
setShowBookingAlert(true)
setIsSubmitting(false)
const getPaymentMethod = useCallback(
(paymentMethod: string | null | undefined): PaymentMethodEnum => {
if (hasFlexRates) {
return PaymentMethodEnum.card
}
return paymentMethod && isPaymentMethodEnum(paymentMethod)
? paymentMethod
: PaymentMethodEnum.card
trackPaymentError(errorMessage)
},
[hasFlexRates]
[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
@@ -308,20 +261,12 @@ export default function PaymentClient({
const errorNames = Object.keys(methods.formState.errors)
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) {
scrollToElement(invalidField)
scrollToElement(invalidField, getTopOffset())
} else if (errorNames.length > 0) {
const firstErrorEl = document.querySelector(`[name="${errorNames[0]}"]`)
if (firstErrorEl) {
scrollToElement(firstErrorEl as HTMLElement)
scrollToElement(firstErrorEl as HTMLElement, getTopOffset())
}
}
@@ -338,63 +283,32 @@ export default function PaymentClient({
return
}
const paymentMethod = getPaymentMethod(data.paymentMethod)
const paymentMethod = getPaymentMethod(data.paymentMethod, hasFlexRates)
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`,
}
? getPaymentData({ paymentMethod, savedCreditCard, lang })
: undefined
const paymentMethodType = savedCreditCard
? savedCreditCard.type
: paymentMethod
if (guarantee || (bookingMustBeGuaranteed && hasOnlyFlexRates)) {
const lateArrivalGuarantee = guarantee ? "yes" : "mandatory"
writeGlaToSessionStorage(
lateArrivalGuarantee,
hotelId,
paymentMethodType,
!!savedCreditCard
)
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)
trackPaymentEvents({
isSavedCreditCard: !!savedCreditCard,
paymentMethodType,
guarantee,
smsEnable: data.smsConfirmation,
bookingMustBeGuaranteed,
hasOnlyFlexRates,
hotelId,
})
const payload: CreateBookingInput = {
checkInDate: fromDate,
@@ -406,7 +320,7 @@ export default function PaymentClient({
({ room }, idx): CreateBookingInput["rooms"][number] => {
const isMainRoom = idx === 0
let rateCode = ""
if (isMainRoom && isUserLoggedIn) {
if (isMainRoom && isLoggedIn) {
rateCode = booking.rooms[idx].rateCode
} else if (
(room.guest.join || room.guest.membershipNo) &&
@@ -500,17 +414,17 @@ export default function PaymentClient({
[
setIsSubmitting,
scrollToInvalidField,
getPaymentMethod,
hasFlexRates,
savedCreditCards,
lang,
bookingMustBeGuaranteed,
hasOnlyFlexRates,
lang,
fromDate,
toDate,
hotelId,
rooms,
initiateBooking,
isUserLoggedIn,
isLoggedIn,
booking.rooms,
user?.data?.partnerLoyaltyNumber,
]
@@ -526,6 +440,7 @@ export default function PaymentClient({
const { preHeading, heading, subHeading, showLearnMore } =
getPaymentHeadingConfig(intl, bookingMustBeGuaranteed, hasOnlyFlexRates)
const isRedemptionBooking = booking.searchType === SEARCH_TYPE_REDEMPTION
return (
<section
className={cx(styles.paymentSection, {
@@ -599,3 +514,148 @@ export default function PaymentClient({
</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() {
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
totalPrice: number
packagePrice?: number
} | null>
}>