Merged in fix/STAY-2-GLA-cancelled (pull request #3109)

Fix/STAY-2 GLA cancelled

* fix: show toast on cancelling GLA flow

* fix: show the ancillary GLA errors as inline alerts


Approved-by: Bianca Widstam
Approved-by: Erik Tiekstra
This commit is contained in:
Christel Westerberg
2025-11-12 08:29:05 +00:00
parent 2c044de187
commit c3b71a05d9
9 changed files with 236 additions and 136 deletions

View File

@@ -29,6 +29,7 @@ import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/an
export default function ConfirmationStep({
savedCreditCards,
user,
error,
}: ConfirmationStepProps) {
const intl = useIntl()
const lang = useLang()
@@ -138,14 +139,19 @@ export default function ConfirmationStep({
</PaymentOptionsGroup>
) : (
<>
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "addAncillary.confirmationStep.guaranteeAddCard",
defaultMessage:
"By adding a card you also guarantee your room booking for late arrival.",
})}
/>
{error ? (
<Alert type={error.type} text={error.message} />
) : (
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "addAncillary.confirmationStep.guaranteeAddCard",
defaultMessage:
"By adding a card you also guarantee your room booking for late arrival.",
})}
/>
)}
<SelectPaymentMethod
paymentMethods={(savedCreditCards ?? []).map((card) => ({
...card,

View File

@@ -10,7 +10,7 @@ import SelectQuantityStep from "../SelectQuantityStep"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Desktop({ user, savedCreditCards }: StepsProps) {
export default function Desktop({ user, savedCreditCards, error }: StepsProps) {
const currentStep = useAddAncillaryStore((state) => state.currentStep)
if (currentStep === AncillaryStepEnum.selectAncillary) {
return <SelectAncillaryStep />
@@ -22,5 +22,11 @@ export default function Desktop({ user, savedCreditCards }: StepsProps) {
return <DeliveryMethodStep />
}
return <ConfirmationStep savedCreditCards={savedCreditCards} user={user} />
return (
<ConfirmationStep
savedCreditCards={savedCreditCards}
user={user}
error={error}
/>
)
}

View File

@@ -9,7 +9,7 @@ import SelectQuantityStep from "../SelectQuantityStep"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Mobile({ user, savedCreditCards }: StepsProps) {
export default function Mobile({ user, savedCreditCards, error }: StepsProps) {
const { currentStep, selectedAncillary } = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
@@ -23,5 +23,11 @@ export default function Mobile({ user, savedCreditCards }: StepsProps) {
</>
)
}
return <ConfirmationStep savedCreditCards={savedCreditCards} user={user} />
return (
<ConfirmationStep
savedCreditCards={savedCreditCards}
user={user}
error={error}
/>
)
}

View File

@@ -16,13 +16,11 @@ import Modal from "@scandic-hotels/design-system/Modal"
import { toast } from "@scandic-hotels/design-system/Toast"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import { isWebview } from "@/constants/routes/webviews"
import { env } from "@/env/client"
import {
AncillaryStepEnum,
type BreakfastData,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
@@ -41,16 +39,23 @@ import {
trackGlaAncillaryAttempt,
} from "@/utils/tracking/myStay"
import { isAncillaryError } from "../../../utils"
import { type AncillaryFormData, ancillaryFormSchema } from "../schema"
import ActionButtons from "./ActionButtons"
import PriceDetails from "./PriceDetails"
import Steps from "./Steps"
import {
buildBreakfastPackages,
calculateBreakfastData,
getErrorMessage,
} from "./utils"
import styles from "./addAncillaryFlowModal.module.css"
import type {
AddAncillaryFlowModalProps,
Packages,
AncillaryErrorMessage,
AncillaryItem,
} from "@/types/components/myPages/myStay/ancillaries"
export default function AddAncillaryFlowModal({
@@ -81,6 +86,8 @@ export default function AddAncillaryFlowModal({
const pathname = usePathname()
const [isPriceDetailsOpen, setIsPriceDetailsOpen] = useState(false)
const [errorMessage, setErrorMessage] =
useState<AncillaryErrorMessage | null>(null)
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang, isWebview(pathname))}`
const deliveryTimeOptions = generateDeliveryOptions()
@@ -94,7 +101,7 @@ export default function AddAncillaryFlowModal({
quantityWithPoints: null,
quantityWithCard:
!user || hasInsufficientPoints || isBreakfast ? 1 : null,
deliveryTime: defaultDeliveryTime,
deliveryTime: booking.ancillary?.deliveryTime ?? defaultDeliveryTime,
optionalText: "",
termsAndConditions: false,
paymentMethod: booking.guaranteeInfo
@@ -127,16 +134,6 @@ export default function AddAncillaryFlowModal({
const { guaranteeBooking, isLoading, handleGuaranteeError } =
useGuaranteeBooking(booking.refId, true, booking.hotelId)
function validateTermsAndConditions(data: AncillaryFormData): boolean {
if (!data.termsAndConditions) {
formMethods.setError("termsAndConditions", {
message: "You must accept the terms",
})
return false
}
return true
}
function handleAncillarySubmission(
data: AncillaryFormData,
packages: {
@@ -206,8 +203,10 @@ export default function AddAncillaryFlowModal({
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleGuaranteePayment(data: AncillaryFormData, packages: any) {
function handleGuaranteePayment(
data: AncillaryFormData,
packages: AncillaryItem[]
) {
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
@@ -239,34 +238,7 @@ export default function AddAncillaryFlowModal({
}
}
function buildBreakfastPackages(
data: AncillaryFormData,
breakfastData: BreakfastData
) {
const packages = [
{
code: BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
quantity: breakfastData.nrOfAdults,
comment: data.optionalText || undefined,
},
{
code: BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
quantity: breakfastData.nrOfPayingChildren,
comment: data.optionalText || undefined,
},
{
code: BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
quantity: breakfastData.nrOfFreeChildren,
comment: data.optionalText || undefined,
},
]
return packages.filter((pkg) => pkg.quantity > 0)
}
const onSubmit = (data: AncillaryFormData) => {
if (!validateTermsAndConditions(data)) return
const packagesToAdd = !isBreakfast
? buildAncillaryPackages(data, selectedAncillary)
: breakfastData
@@ -300,14 +272,9 @@ export default function AddAncillaryFlowModal({
}
useEffect(() => {
const errorCode = searchParams.get("errorCode")
const ancillary = searchParams.get("ancillary")
if ((errorCode && ancillary) || errorCode === "AncillaryFailed") {
if (isAncillaryError(searchParams)) {
const errorCode = searchParams.get("errorCode")
const queryParams = new URLSearchParams(searchParams.toString())
if (ancillary) {
queryParams.delete("ancillary")
}
queryParams.delete("errorCode")
const savedData = getAncillarySessionData()
if (savedData?.formData) {
const updatedFormData = {
@@ -318,9 +285,13 @@ export default function AddAncillaryFlowModal({
}
formMethods.reset(updatedFormData)
}
setErrorMessage(getErrorMessage(intl, errorCode))
queryParams.delete("ancillary")
queryParams.delete("errorCode")
router.replace(`${pathname}?${queryParams.toString()}`)
}
}, [searchParams, pathname, formMethods, router, booking.guaranteeInfo])
}, [searchParams, pathname, formMethods, router, booking.guaranteeInfo, intl])
useEffect(() => {
setBreakfastData(
@@ -424,7 +395,11 @@ export default function AddAncillaryFlowModal({
)}
</>
)}
<Steps user={user} savedCreditCards={savedCreditCards} />
<Steps
user={user}
savedCreditCards={savedCreditCards}
error={errorMessage}
/>
{currentStep === AncillaryStepEnum.selectAncillary ? null : (
<div
className={
@@ -520,61 +495,3 @@ function BreakfastPriceList() {
</div>
)
}
/**
* This function calculates some breakfast data in the store.
* It is used in various places in the add flow, but only needs
* to be calculated once.
*/
function calculateBreakfastData(
isBreakfast: boolean,
packages: Packages | null,
nrOfAdults: number,
childrenAges: number[],
nrOfNights: number
): BreakfastData | null {
if (!isBreakfast) {
return null
}
const { nrOfPayingChildren, nrOfFreeChildren } = childrenAges.reduce(
(total, childAge) => {
if (childAge >= 4) {
total.nrOfPayingChildren = total.nrOfPayingChildren + 1
} else {
total.nrOfFreeChildren = total.nrOfFreeChildren + 1
}
return total
},
{ nrOfPayingChildren: 0, nrOfFreeChildren: 0 }
)
const adultPackage = packages?.find(
(p) => p.code === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
)
const childPackage = packages?.find(
(p) => p.code === BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST
)
const priceAdult = adultPackage?.localPrice.price
const priceChild = childPackage?.localPrice.price
const currency =
adultPackage?.localPrice.currency ?? childPackage?.localPrice.currency
if (
typeof priceAdult !== "number" ||
typeof priceChild !== "number" ||
typeof currency !== "string"
) {
return null
} else {
return {
nrOfAdults,
nrOfPayingChildren,
nrOfFreeChildren,
nrOfNights,
priceAdult,
priceChild,
currency,
}
}
}

View File

@@ -0,0 +1,136 @@
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import type { Packages } from "@scandic-hotels/trpc/types/packages"
import type { IntlShape } from "react-intl"
import type { AncillaryErrorMessage } from "@/types/components/myPages/myStay/ancillaries"
import type { BreakfastData } from "@/stores/my-stay/add-ancillary-flow"
import type { AncillaryFormData } from "../schema"
/**
* This function calculates some breakfast data in the store.
* It is used in various places in the add flow, but only needs
* to be calculated once.
*/
export function calculateBreakfastData(
isBreakfast: boolean,
packages: Packages | null,
nrOfAdults: number,
childrenAges: number[],
nrOfNights: number
): BreakfastData | null {
if (!isBreakfast || !packages) {
return null
}
const { nrOfPayingChildren, nrOfFreeChildren } = childrenAges.reduce(
(total, childAge) => {
if (childAge >= 4) {
total.nrOfPayingChildren = total.nrOfPayingChildren + 1
} else {
total.nrOfFreeChildren = total.nrOfFreeChildren + 1
}
return total
},
{ nrOfPayingChildren: 0, nrOfFreeChildren: 0 }
)
const adultPackage = packages.find(
(p) => p.code === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
)
const childPackage = packages.find(
(p) => p.code === BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST
)
const priceAdult = adultPackage?.localPrice.price
const priceChild = childPackage?.localPrice.price
const currency =
adultPackage?.localPrice.currency ?? childPackage?.localPrice.currency
if (
typeof priceAdult !== "number" ||
typeof priceChild !== "number" ||
typeof currency !== "string"
) {
return null
} else {
return {
nrOfAdults,
nrOfPayingChildren,
nrOfFreeChildren,
nrOfNights,
priceAdult,
priceChild,
currency,
}
}
}
export function buildBreakfastPackages(
data: AncillaryFormData,
breakfastData: BreakfastData
) {
const packages = [
{
code: BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
quantity: breakfastData.nrOfAdults,
comment: data.optionalText || undefined,
},
{
code: BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
quantity: breakfastData.nrOfPayingChildren,
comment: data.optionalText || undefined,
},
{
code: BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
quantity: breakfastData.nrOfFreeChildren,
comment: data.optionalText || undefined,
},
]
return packages.filter((pkg) => pkg.quantity > 0)
}
export function getErrorMessage(
intl: IntlShape,
errorCode: string | null
): AncillaryErrorMessage {
switch (errorCode) {
case BookingErrorCodeEnum.TransactionFailed:
return {
message: intl.formatMessage({
id: "guaranteePayment.failed",
defaultMessage:
"We had an issue guaranteeing your booking. Please try again.",
}),
type: AlertTypeEnum.Alarm,
}
case BookingErrorCodeEnum.TransactionCancelled:
return {
message: intl.formatMessage({
id: "guaranteePayment.cancelled",
defaultMessage:
"You have cancelled the payment. Your booking is not guaranteed.",
}),
type: AlertTypeEnum.Warning,
}
case "AncillaryFailed":
return {
message: intl.formatMessage({
id: "guaranteePayment.ancillaryFailed",
defaultMessage:
"The product could not be added. Your booking is guaranteed. Please try again.",
}),
type: AlertTypeEnum.Alarm,
}
default:
return {
message: intl.formatMessage({
id: "guaranteePayment.genericError",
defaultMessage: "Something went wrong! Please try again later.",
}),
type: AlertTypeEnum.Alarm,
}
}
}

View File

@@ -1,7 +1,7 @@
.list {
display: flex;
flex-direction: column;
align-items: start;
align-items: flex-start;
margin: 0;
padding: 0;
gap: var(--Space-x15);

View File

@@ -69,3 +69,9 @@ export function hasModifiableRate(cancellationRule: string | null): boolean {
cancellationRule === CancellationRuleEnum.Changeable
)
}
export function isAncillaryError(searchParams: URLSearchParams): boolean {
const errorCode = searchParams.get("errorCode")
const ancillary = searchParams.get("ancillary")
return Boolean((errorCode && ancillary) || errorCode === "AncillaryFailed")
}

View File

@@ -1,13 +1,16 @@
"use client"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect } from "react"
import { useCallback, useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { toast } from "@scandic-hotels/design-system/Toast"
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
import { isAncillaryError } from "@/components/HotelReservation/MyStay/utils"
export function useGuaranteePaymentFailedToast() {
const hasRunOnce = useRef(false)
const intl = useIntl()
const searchParams = useSearchParams()
const pathname = usePathname()
@@ -16,11 +19,11 @@ export function useGuaranteePaymentFailedToast() {
const getErrorMessage = useCallback(
(errorCode: string | null) => {
switch (errorCode) {
case "AncillaryFailed":
case BookingErrorCodeEnum.TransactionCancelled:
return intl.formatMessage({
id: "guaranteePayment.ancillaryFailed",
id: "guaranteePayment.cancelled",
defaultMessage:
"The product could not be added. Your booking is guaranteed. Please try again.",
"You have cancelled the payment. Your booking is not guaranteed.",
})
default:
return intl.formatMessage({
@@ -34,25 +37,33 @@ export function useGuaranteePaymentFailedToast() {
)
useEffect(() => {
const errorCode = searchParams.get("errorCode")
const errorMessage = getErrorMessage(errorCode)
if (!errorCode || errorCode === BookingErrorCodeEnum.TransactionCancelled)
// To prevent multiple toasts in strict mode
if (hasRunOnce.current) {
return
}
const errorCode = searchParams.get("errorCode")
if (!errorCode) {
return
}
// Ancillary errors are handled in AddAncillaryFlowModal
if (isAncillaryError(searchParams)) {
hasRunOnce.current = true
return
}
const errorMessage = getErrorMessage(errorCode)
const toastType =
errorCode === BookingErrorCodeEnum.TransactionCancelled
? "warning"
: "error"
toast[toastType](errorMessage)
const ancillary = searchParams.get("ancillary")
if ((errorCode && ancillary) || errorCode === "AncillaryFailed") {
return
}
toast[toastType](errorMessage)
const queryParams = new URLSearchParams(searchParams.toString())
queryParams.delete("errorCode")
router.push(`${pathname}?${queryParams.toString()}`)
hasRunOnce.current = true
}, [searchParams, pathname, router, getErrorMessage])
}

View File

@@ -1,3 +1,4 @@
import type { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import type {
ancillaryPackagesSchema,
packagesSchema,
@@ -24,6 +25,12 @@ export interface AddedAncillariesProps {
booking: Room
}
export interface AncillaryItem {
code: string
quantity: number
comment: string | undefined
}
export interface AddAncillaryFlowModalProps {
booking: Room
packages: Packages | null
@@ -34,15 +41,20 @@ export interface AddAncillaryFlowModalProps {
export interface SelectQuantityStepProps {
user: User | null
}
export interface AncillaryErrorMessage {
type: AlertTypeEnum
message: string
}
export interface ConfirmationStepProps {
savedCreditCards: CreditCard[] | null
user: User | null
error: AncillaryErrorMessage | null
}
export interface StepsProps {
user: User | null
savedCreditCards: CreditCard[] | null
error: AncillaryErrorMessage | null
}
export interface ActionButtonsProps {