Merged in feat/SW-1149-handle-status-polling (pull request #1562)

Feat/SW-1149 handle status polling

* feat(SW-1149): move terms and conditions sections to separate component and added copy

* feat(SW-1149): Added client component to handle success callback for payment flow

* fix: check for bookingCompleted status as well

* feat(SW-1587): use alert instead of toast for showing payment errors

* fix: added enum for payment callback status

* fix: proper way of checking for multiple statuses

* fix: update schema type

* fix: use localised link to customer service

* fix: update to use enum for status strings


Approved-by: Arvid Norlin
This commit is contained in:
Tobias Johansson
2025-03-20 07:38:29 +00:00
parent 200ed55a2c
commit ac493fe325
25 changed files with 384 additions and 155 deletions

View File

@@ -29,7 +29,6 @@
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: 0 var(--Spacing-x2);
width: min(800px, 100%);
}

View File

@@ -8,20 +8,15 @@ import { Button } from "@scandic-hotels/design-system/Button"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { PaymentMethodEnum } from "@/constants/booking"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { InfoCircleIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import Divider from "@/components/TempDesignSystem/Divider"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import MySavedCards from "../Payment/MySavedCards"
import PaymentOption from "../Payment/PaymentOption"
import TermsAndConditions from "../Payment/TermsAndConditions"
import styles from "./confirm.module.css"
@@ -35,7 +30,6 @@ export default function ConfirmBooking({
savedCreditCards,
}: ConfirmBookingProps) {
const intl = useIntl()
const lang = useLang()
const [isModalOpen, setModalOpen] = useState(false)
const { watch } = useFormContext()
@@ -130,50 +124,7 @@ export default function ConfirmBooking({
)}
</div>
<div className={styles.checkboxContainer}>
<Checkbox name="smsConfirmation">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</p>
</Typography>
</Checkbox>
<div className={styles.checkbox}>
<Checkbox name="termsAndConditions" topAlign>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage(
{
id: "By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.",
},
{
termsAndConditionsLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
>
{str}
</Link>
),
privacyPolicyLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"
>
{str}
</Link>
),
}
)}
</p>
</Typography>
</Checkbox>
</div>
<TermsAndConditions />
</div>
</div>
)

View File

@@ -0,0 +1,84 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { useIntl } from "react-intl"
import { BookingErrorCodeEnum } from "@/constants/booking"
import { useEnterDetailsStore } from "@/stores/enter-details"
import Alert from "@/components/TempDesignSystem/Alert"
import styles from "./paymentAlert.module.css"
import { AlertTypeEnum } from "@/types/enums/alert"
function useBookingErrorAlert() {
const updateSearchParams = useEnterDetailsStore(
(state) => state.actions.updateSeachParamString
)
const intl = useIntl()
const searchParams = useSearchParams()
const pathname = usePathname()
const errorCode = searchParams.get("errorCode")
const errorMessage = getErrorMessage(errorCode)
const severityLevel =
errorCode === BookingErrorCodeEnum.TransactionCancelled
? AlertTypeEnum.Warning
: AlertTypeEnum.Alarm
const [showAlert, setShowAlert] = useState(!!errorCode)
function getErrorMessage(errorCode: string | null) {
switch (errorCode) {
case BookingErrorCodeEnum.TransactionCancelled:
return intl.formatMessage({
id: "You have now cancelled your payment.",
})
default:
return intl.formatMessage({
id: "We had an issue processing your booking. Please try again. No charges have been made.",
})
}
}
function discardAlert() {
setShowAlert(false)
const queryParams = new URLSearchParams(searchParams.toString())
queryParams.delete("errorCode")
updateSearchParams(queryParams.toString())
window.history.replaceState({}, "", `${pathname}?${queryParams.toString()}`)
}
return { showAlert, errorMessage, severityLevel, discardAlert, setShowAlert }
}
interface PaymentAlertProps {
isVisible?: boolean
}
export default function PaymentAlert({ isVisible = false }: PaymentAlertProps) {
const { showAlert, errorMessage, severityLevel, discardAlert, setShowAlert } =
useBookingErrorAlert()
useEffect(() => {
if (isVisible) {
setShowAlert(true)
}
}, [isVisible, setShowAlert])
if (!showAlert) return null
return (
<div className={styles.wrapper}>
<Alert
type={severityLevel}
variant="inline"
text={errorMessage}
close={discardAlert}
/>
</div>
)
}

View File

@@ -0,0 +1,4 @@
.wrapper {
margin-top: var(--Spacing-x3);
max-width: min(100%, 620px);
}

View File

@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { PaymentCallbackStatusEnum } from "@/constants/booking"
import { detailsStorageName } from "@/stores/enter-details"
import LoadingSpinner from "@/components/LoadingSpinner"
@@ -11,7 +12,7 @@ import { convertObjToSearchParams } from "@/utils/url"
import type { PersistedState } from "@/types/stores/enter-details"
export default function PaymentCallback({
export default function HandleErrorCallback({
returnUrl,
searchObject,
status,
@@ -19,7 +20,7 @@ export default function PaymentCallback({
}: {
returnUrl: string
searchObject: URLSearchParams
status: "error" | "success" | "cancel"
status: PaymentCallbackStatusEnum
errorMessage?: string
}) {
const router = useRouter()
@@ -34,14 +35,14 @@ export default function PaymentCallback({
searchObject
)
if (status === "cancel") {
if (status === PaymentCallbackStatusEnum.Cancel) {
trackPaymentEvent({
event: "paymentCancel",
hotelId: detailsStorage.booking.hotelId,
status: "cancelled",
})
}
if (status === "error") {
if (status === PaymentCallbackStatusEnum.Error) {
trackPaymentEvent({
event: "paymentFail",
hotelId: detailsStorage.booking.hotelId,

View File

@@ -0,0 +1,68 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { BookingStatusEnum, MEMBERSHIP_FAILED_ERROR } from "@/constants/booking"
import LoadingSpinner from "@/components/LoadingSpinner"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import TimeoutSpinner from "./TimeoutSpinner"
const validBookingStatuses = [
BookingStatusEnum.PaymentSucceeded,
BookingStatusEnum.BookingCompleted,
]
interface HandleStatusPollingProps {
confirmationNumber: string
successRedirectUrl: string
}
export default function HandleSuccessCallback({
confirmationNumber,
successRedirectUrl,
}: HandleStatusPollingProps) {
const router = useRouter()
const {
data: bookingStatus,
error,
isTimeout,
} = useHandleBookingStatus({
confirmationNumber,
expectedStatuses: validBookingStatuses,
maxRetries: 10,
retryInterval: 2000,
enabled: true,
})
useEffect(() => {
if (!bookingStatus?.reservationStatus) {
return
}
if (
validBookingStatuses.includes(
bookingStatus.reservationStatus as BookingStatusEnum
)
) {
// a successful booking can still have membership errors
const membershipFailedError = bookingStatus.errors.find(
(e) => e.errorCode === MEMBERSHIP_FAILED_ERROR
)
const errorParam = membershipFailedError
? `&errorCode=${membershipFailedError.errorCode}`
: ""
router.replace(`${successRedirectUrl}${errorParam}`)
}
}, [bookingStatus, successRedirectUrl, router])
if (isTimeout || error) {
return <TimeoutSpinner />
}
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,45 @@
"use client"
import { useIntl } from "react-intl"
import { customerService } from "@/constants/currentWebHrefs"
import LoadingSpinner from "@/components/LoadingSpinner"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import styles from "./timeoutSpinner.module.css"
export default function TimeoutSpinner() {
const intl = useIntl()
const lang = useLang()
return (
<div className={styles.container}>
<LoadingSpinner />
<Subtitle className={styles.heading}>
{intl.formatMessage({ id: "Taking longer than usual" })}
</Subtitle>
<Body textAlign="center" className={styles.messageContainer}>
{intl.formatMessage(
{
id: "We are still confirming your booking. This is usually a matter of minutes and we do apologise for the wait. Please check your inbox for a booking confirmation email and if you still haven't received it by end of day, please contact our <link>customer support</link>.",
},
{
link: (text) => (
<Link
href={customerService[lang]}
variant="underscored"
target="_blank"
>
{text}
</Link>
),
}
)}
</Body>
</div>
)
}

View File

@@ -0,0 +1,18 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: var(--Spacing-x2);
text-align: center;
}
.container .heading {
margin-bottom: var(--Spacing-x1);
}
.messageContainer {
max-width: 435px;
text-align: center;
}

View File

@@ -13,10 +13,6 @@ import {
PAYMENT_METHOD_TITLES,
PaymentMethodEnum,
} from "@/constants/booking"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import {
bookingConfirmation,
selectRate,
@@ -27,15 +23,10 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
import useLang from "@/hooks/useLang"
import { trackPaymentEvent } from "@/utils/tracking"
@@ -46,8 +37,10 @@ import GuaranteeDetails from "./GuaranteeDetails"
import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum } from "./helpers"
import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown"
import MySavedCards from "./MySavedCards"
import PaymentAlert from "./PaymentAlert"
import PaymentOption from "./PaymentOption"
import { type PaymentFormData, paymentSchema } from "./schema"
import TermsAndConditions from "./TermsAndConditions"
import styles from "./payment.module.css"
@@ -74,6 +67,8 @@ export default function PaymentClient({
const intl = useIntl()
const searchParams = useSearchParams()
const [showPaymentAlert, setShowPaymentAlert] = useState(false)
const { booking, canProceedToPayment, rooms, totalPrice } =
useEnterDetailsStore((state) => ({
booking: state.booking,
@@ -109,8 +104,6 @@ export default function PaymentClient({
const hasFlexRates = rooms.some(hasFlexibleRate)
const hasMixedRates = hasPrepaidRates && hasFlexRates
usePaymentFailedToast()
const methods = useForm<PaymentFormData>({
defaultValues: {
paymentMethod: savedCreditCards?.length
@@ -181,7 +174,7 @@ export default function PaymentClient({
const bookingStatus = useHandleBookingStatus({
confirmationNumber: bookingNumber,
expectedStatus: BookingStatusEnum.BookingCompleted,
expectedStatuses: [BookingStatusEnum.BookingCompleted],
maxRetries,
retryInterval,
enabled: isPollingForBookingStatus,
@@ -189,11 +182,8 @@ export default function PaymentClient({
const handlePaymentError = useCallback(
(errorMessage: string) => {
toast.error(
intl.formatMessage({
id: "We had an issue processing your booking. Please try again. No charges have been made.",
})
)
setShowPaymentAlert(true)
const currentPaymentMethod = methods.getValues("paymentMethod")
const smsEnable = methods.getValues("smsConfirmation")
const isSavedCreditCard = savedCreditCards?.some(
@@ -210,7 +200,7 @@ export default function PaymentClient({
status: "failed",
})
},
[intl, methods, savedCreditCards, hotelId]
[methods, savedCreditCards, hotelId]
)
useEffect(() => {
@@ -396,6 +386,7 @@ export default function PaymentClient({
? confirm
: payment}
</Title>
<PaymentAlert isVisible={showPaymentAlert} />
</header>
<FormProvider {...methods}>
<form
@@ -466,49 +457,7 @@ export default function PaymentClient({
</section>
<section className={styles.section}>
<Caption>
{intl.formatMessage(
{
id: "By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.",
},
{
termsAndConditionsLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
>
{str}
</Link>
),
privacyPolicyLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"
>
{str}
</Link>
),
}
)}
</Caption>
<Checkbox name="termsAndConditions">
<Caption>
{intl.formatMessage({
id: "I accept the terms and conditions",
})}
</Caption>
</Checkbox>
<Checkbox name="smsConfirmation">
<Caption>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</Caption>
</Checkbox>
<TermsAndConditions />
</section>
</>
)}

View File

@@ -0,0 +1,70 @@
import { useIntl } from "react-intl"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang"
import styles from "../payment.module.css"
export default function TermsAndConditions() {
const intl = useIntl()
const lang = useLang()
return (
<>
<Caption>
{intl.formatMessage(
{
id: "By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.",
},
{
termsAndConditionsLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
weight="bold"
size="small"
>
{str}
</Link>
),
privacyPolicyLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"
weight="bold"
size="small"
>
{str}
</Link>
),
}
)}
</Caption>
<Checkbox name="termsAndConditions">
<Caption>
{intl.formatMessage({
id: "I accept the terms and conditions",
})}
</Caption>
</Checkbox>
<Checkbox name="smsConfirmation">
<Caption>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</Caption>
</Checkbox>
</>
)
}

View File

@@ -98,7 +98,7 @@ export default function GuaranteeLateArrival({
const bookingStatus = useHandleBookingStatus({
confirmationNumber: booking.confirmationNumber,
expectedStatus: BookingStatusEnum.BookingCompleted,
expectedStatuses: [BookingStatusEnum.BookingCompleted],
maxRetries,
retryInterval,
enabled: isPollingForBookingStatus,
@@ -195,7 +195,6 @@ export default function GuaranteeLateArrival({
{
termsAndConditionsLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
@@ -205,7 +204,6 @@ export default function GuaranteeLateArrival({
),
privacyPolicyLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"