Files
Christel Westerberg c3b71a05d9 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
2025-11-12 08:29:05 +00:00

498 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { guaranteeCallback } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
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 { isWebview } from "@/constants/routes/webviews"
import { env } from "@/env/client"
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import {
buildAncillaryPackages,
clearAncillarySessionData,
generateDeliveryOptions,
getAncillarySessionData,
setAncillarySessionData,
} from "@/components/HotelReservation/MyStay/utils/ancillaries"
import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking"
import useLang from "@/hooks/useLang"
import {
trackAncillaryFailed,
trackAncillarySuccess,
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,
AncillaryErrorMessage,
AncillaryItem,
} from "@/types/components/myPages/myStay/ancillaries"
export default function AddAncillaryFlowModal({
booking,
packages,
user,
savedCreditCards,
}: AddAncillaryFlowModalProps) {
const {
currentStep,
selectedAncillary,
closeModal,
breakfastData,
setBreakfastData,
isBreakfast,
} = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
closeModal: state.closeModal,
breakfastData: state.breakfastData,
setBreakfastData: state.setBreakfastData,
isBreakfast: state.isBreakfast,
}))
const intl = useIntl()
const lang = useLang()
const router = useRouter()
const searchParams = useSearchParams()
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()
const defaultDeliveryTime = deliveryTimeOptions[0].value
const hasInsufficientPoints =
(user?.membership?.currentPoints ?? 0) < (selectedAncillary?.points ?? 0)
const formMethods = useForm({
defaultValues: {
quantityWithPoints: null,
quantityWithCard:
!user || hasInsufficientPoints || isBreakfast ? 1 : null,
deliveryTime: booking.ancillary?.deliveryTime ?? defaultDeliveryTime,
optionalText: "",
termsAndConditions: false,
paymentMethod: booking.guaranteeInfo
? PaymentMethodEnum.card
: savedCreditCards?.length
? savedCreditCards[0].id
: PaymentMethodEnum.card,
},
mode: "onChange",
reValidateMode: "onChange",
resolver: zodResolver(ancillaryFormSchema),
})
const ancillaryErrorMessage = intl.formatMessage(
{
id: "addAncillaryFlowModal.errorMessage.ancillary",
defaultMessage:
"Something went wrong. {ancillary} could not be added to your booking!",
},
{ ancillary: selectedAncillary?.title }
)
function togglePriceDetails() {
setIsPriceDetailsOpen((isOpen) => !isOpen)
}
const utils = trpc.useUtils()
const addAncillary = trpc.booking.packages.useMutation()
const { guaranteeBooking, isLoading, handleGuaranteeError } =
useGuaranteeBooking(booking.refId, true, booking.hotelId)
function handleAncillarySubmission(
data: AncillaryFormData,
packages: {
code: string
quantity: number
comment: string | undefined
}[]
) {
addAncillary.mutate(
{
refId: booking.refId,
ancillaryComment: data.optionalText,
ancillaryDeliveryTime: selectedAncillary?.requiresDeliveryTime
? data.deliveryTime
: undefined,
packages: packages,
language: lang,
},
{
onSuccess: (result) => {
if (result) {
trackAncillarySuccess(
booking.confirmationNumber,
packages,
data.deliveryTime,
"ancillary",
selectedAncillary,
breakfastData,
booking.guaranteeInfo?.cardType,
booking.roomTypeCode
)
toast.success(
intl.formatMessage(
{
id: "addAncillaryFlowModal.ancillaryAdded",
defaultMessage: "{ancillary} added to your booking!",
},
{ ancillary: selectedAncillary?.title }
)
)
clearAncillarySessionData()
closeModal()
utils.booking.get.invalidate({
refId: booking.refId,
})
router.refresh()
} else {
trackAncillaryFailed(
packages,
data.deliveryTime,
selectedAncillary,
breakfastData
)
toast.error(ancillaryErrorMessage)
}
},
onError: () => {
trackAncillaryFailed(
packages,
data.deliveryTime,
selectedAncillary,
breakfastData
)
toast.error(ancillaryErrorMessage)
},
}
)
}
function handleGuaranteePayment(
data: AncillaryFormData,
packages: AncillaryItem[]
) {
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
trackGlaAncillaryAttempt(
savedCreditCard,
packages,
selectedAncillary,
data.deliveryTime,
breakfastData
)
if (booking.refId) {
const card = savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined
guaranteeBooking.mutate({
refId: booking.refId,
language: lang,
...(card && { card }),
success: `${guaranteeRedirectUrl}?status=success&ancillary=1&RefId=${encodeURIComponent(booking.refId)}`,
error: `${guaranteeRedirectUrl}?status=error&ancillary=1&RefId=${encodeURIComponent(booking.refId)}`,
cancel: `${guaranteeRedirectUrl}?status=cancel&ancillary=1&RefId=${encodeURIComponent(booking.refId)}`,
})
} else {
handleGuaranteeError("No confirmation number")
}
}
const onSubmit = (data: AncillaryFormData) => {
const packagesToAdd = !isBreakfast
? buildAncillaryPackages(data, selectedAncillary)
: breakfastData
? buildBreakfastPackages(data, breakfastData)
: []
if (isBreakfast && !breakfastData) {
toast.error(
intl.formatMessage({
id: "errorMessage.somethingWentWrong",
defaultMessage: "Something went wrong!",
})
)
return
}
setAncillarySessionData({
formData: data,
selectedAncillary,
packages: packagesToAdd,
isBreakfast,
breakfastData,
})
const shouldSkipGuarantee =
booking.guaranteeInfo || (data.quantityWithCard ?? 0) <= 0
if (shouldSkipGuarantee) {
handleAncillarySubmission(data, packagesToAdd)
} else {
handleGuaranteePayment(data, packagesToAdd)
}
}
useEffect(() => {
if (isAncillaryError(searchParams)) {
const errorCode = searchParams.get("errorCode")
const queryParams = new URLSearchParams(searchParams.toString())
const savedData = getAncillarySessionData()
if (savedData?.formData) {
const updatedFormData = {
...savedData.formData,
paymentMethod: booking?.guaranteeInfo
? PaymentMethodEnum.card
: savedData.formData.paymentMethod,
}
formMethods.reset(updatedFormData)
}
setErrorMessage(getErrorMessage(intl, errorCode))
queryParams.delete("ancillary")
queryParams.delete("errorCode")
router.replace(`${pathname}?${queryParams.toString()}`)
}
}, [searchParams, pathname, formMethods, router, booking.guaranteeInfo, intl])
useEffect(() => {
setBreakfastData(
calculateBreakfastData(
isBreakfast,
packages,
booking.adults,
booking.childrenAges,
dt(booking.checkOutDate)
.startOf("day")
.diff(dt(booking.checkInDate).startOf("day"), "days")
)
)
}, [
booking.adults,
booking.checkInDate,
booking.checkOutDate,
booking.childrenAges,
isBreakfast,
packages,
setBreakfastData,
])
if (isLoading) {
return (
<div>
<LoadingSpinner />
</div>
)
}
const modalTitle =
currentStep === AncillaryStepEnum.selectAncillary
? intl.formatMessage({
id: "ancillaries.upgradeYourStay",
defaultMessage: "Upgrade your stay",
})
: selectedAncillary?.title
return (
<Modal isOpen={true} onToggle={closeModal} title={modalTitle}>
<div
className={`${styles.modalWrapper} ${currentStep === AncillaryStepEnum.selectAncillary ? styles.selectAncillarycontainer : ""}`}
>
<FormProvider {...formMethods}>
<form
onSubmit={formMethods.handleSubmit(onSubmit)}
className={styles.form}
id="add-ancillary-form-id"
>
<div className={styles.modalScrollable}>
{selectedAncillary && (
<>
{currentStep !== AncillaryStepEnum.confirmation && (
<div className={styles.contentContainer}>
<div className={styles.price}>
<Typography variant="Body/Paragraph/mdBold">
{isBreakfast ? (
<BreakfastPriceList />
) : (
<p>
{formatPrice(
intl,
selectedAncillary.price.total,
selectedAncillary.price.currency
)}
</p>
)}
</Typography>
{selectedAncillary.points && (
<div className={styles.pointsDivider}>
<Divider variant="vertical" />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{
points: selectedAncillary.points,
}
)}
</p>
</Typography>
</div>
)}
</div>
<div className={styles.description}>
{selectedAncillary.description && (
<Typography variant="Body/Paragraph/mdRegular">
<p
dangerouslySetInnerHTML={{
__html: selectedAncillary.description,
}}
></p>
</Typography>
)}
</div>
</div>
)}
</>
)}
<Steps
user={user}
savedCreditCards={savedCreditCards}
error={errorMessage}
/>
{currentStep === AncillaryStepEnum.selectAncillary ? null : (
<div
className={
currentStep === AncillaryStepEnum.confirmation ||
isBreakfast
? styles.confirmStep
: ""
}
>
<PriceDetails isPriceDetailsOpen={isPriceDetailsOpen} />
<ActionButtons
isPriceDetailsOpen={isPriceDetailsOpen}
togglePriceDetails={togglePriceDetails}
isSubmitting={addAncillary.isPending || isLoading}
/>
</div>
)}
</div>
</form>
</FormProvider>
</div>
</Modal>
)
}
function BreakfastPriceList() {
const intl = useIntl()
const breakfastData = useAddAncillaryStore((state) => state.breakfastData)
if (!breakfastData) {
return intl.formatMessage({
id: "ancillaries.unableToDisplayBreakfastPrices",
defaultMessage: "Unable to display breakfast prices.",
})
}
return (
<div>
<div className={styles.breakfastPriceList}>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage(
{
id: "addAncillaryFlowModal.pricePerNightPerAdult",
defaultMessage: "{price}/night per adult",
},
{
price: `${breakfastData.priceAdult} ${breakfastData.currency}`,
}
)}
</span>
</Typography>
{breakfastData.nrOfPayingChildren > 0 && (
<>
<div className={styles.divider}>
<Divider variant="vertical" color="Border/Divider/Subtle" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage(
{
id: "addAncillaryFlowModal.pricePerNightPerKids",
defaultMessage: "{price}/night for kids (ages 412)",
},
{
price: `${breakfastData.priceChild} ${breakfastData.currency}`,
}
)}
</span>
</Typography>
</>
)}
{breakfastData.nrOfFreeChildren > 0 && (
<>
<div className={styles.divider}>
<Divider variant="vertical" color="Border/Divider/Subtle" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage({
id: "addAncillaryFlowModal.freeBreakfastForKids",
defaultMessage: "Free for kids (under 4)",
})}
</span>
</Typography>
</>
)}
</div>
</div>
)
}