Merged in fix/STAY-135 (pull request #3368)

Fix/STAY-135 & STAY-127

* fix: make quantity and delivery separate steps in mobile

* fix: update design for delivery step in ancillary flow

* fix: add error state for missing time

* fix: only allow points or cash payment for ancillaries

* fix: break out stepper to design system

* fix: update design of select quantity step in add ancillaries flow

* fix: add error states for quantity

* fix: handle insufficient points case

* fix: update stepper to include optional disabledMessage tooltip

* fix: handle validations

* fix: change name to camel case


Approved-by: Bianca Widstam
Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
Christel Westerberg
2025-12-18 13:31:43 +00:00
parent 2c8b920dd8
commit 6b08d5a113
54 changed files with 1498 additions and 872 deletions

View File

@@ -0,0 +1,22 @@
.form {
display: flex;
flex-direction: column;
overflow-y: hidden;
width: 100%;
}
.modalScrollable {
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
padding: var(--Space-x1) var(--Space-x2) var(--Space-x2);
}
@media screen and (min-width: 768px) {
.modalScrollable {
padding: var(--Space-x1) var(--Space-x3) var(--Space-x3);
}
}

View File

@@ -0,0 +1,311 @@
"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 { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
import { toast } from "@scandic-hotels/design-system/Toast"
import { trpc } from "@scandic-hotels/trpc/client"
import { 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,
PaymentChoiceEnum,
} from "../schema"
import Steps from "../Steps"
import Summary from "../Summary"
import {
buildBreakfastPackages,
getErrorMessage,
getGuaranteeCallback,
} from "../utils"
import styles from "./form.module.css"
import type {
AddAncillaryFormProps,
AncillaryErrorMessage,
AncillaryItem,
} from "@/types/components/myPages/myStay/ancillaries"
export default function Form({
booking,
user,
savedCreditCards,
}: AddAncillaryFormProps) {
const { closeModal, selectedAncillary, breakfastData, isBreakfast, isOpen } =
useAddAncillaryStore((state) => ({
selectedAncillary: state.selectedAncillary,
closeModal: state.closeModal,
breakfastData: state.breakfastData,
isBreakfast: state.isBreakfast,
isOpen: state.isOpen,
}))
const intl = useIntl()
const lang = useLang()
const router = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
const [errorMessage, setErrorMessage] =
useState<AncillaryErrorMessage | null>(null)
const guaranteeRedirectUrl = getGuaranteeCallback(lang, pathname)
const deliveryTimeOptions = generateDeliveryOptions()
const hasInsufficientPoints =
(user?.membership?.currentPoints ?? 0) < (selectedAncillary?.points ?? 0)
const onlyCardIsAvailable =
!user || hasInsufficientPoints || !selectedAncillary?.points
const formMethods = useForm({
defaultValues: {
quantity:
onlyCardIsAvailable || !selectedAncillary?.requiresQuantity ? 1 : 0,
paymentChoice: onlyCardIsAvailable ? PaymentChoiceEnum.Card : undefined,
deliveryTime: selectedAncillary?.requiresDeliveryTime
? booking.ancillary?.deliveryTime
: deliveryTimeOptions[0].value,
optionalText: "",
termsAndConditions: false,
paymentMethod: booking.guaranteeInfo
? PaymentMethodEnum.card
: savedCreditCards?.length
? savedCreditCards[0].id
: PaymentMethodEnum.card,
},
shouldFocusError: true,
mode: "onChange",
reValidateMode: "onChange",
resolver: zodResolver(ancillaryFormSchema),
})
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)
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setErrorMessage(getErrorMessage(intl, errorCode))
queryParams.delete("ancillary")
queryParams.delete("errorCode")
router.replace(`${pathname}?${queryParams.toString()}`)
}
}, [searchParams, pathname, formMethods, router, booking.guaranteeInfo, intl])
const ancillaryErrorMessage = intl.formatMessage(
{
id: "addAncillaryFlowModal.errorMessage.ancillary",
defaultMessage:
"Something went wrong. {ancillary} could not be added to your booking!",
},
{ ancillary: selectedAncillary?.title }
)
const utils = trpc.useUtils()
const addAncillary = trpc.booking.packages.useMutation()
const { guaranteeBooking, isLoading, handleGuaranteeError } =
useGuaranteeBooking(booking.refId, true, booking.hotelId)
async function handleAncillarySubmission(
data: AncillaryFormData,
packages: {
code: string
quantity: number
comment: string | undefined
}[]
) {
await addAncillary.mutateAsync(
{
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)
closeModal()
}
},
onError: () => {
trackAncillaryFailed(
packages,
data.deliveryTime,
selectedAncillary,
breakfastData
)
toast.error(ancillaryErrorMessage)
closeModal()
},
}
)
}
async 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
await guaranteeBooking.mutateAsync({
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 = async (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.paymentChoice === PaymentChoiceEnum.Points
if (shouldSkipGuarantee) {
await handleAncillarySubmission(data, packagesToAdd)
} else {
await handleGuaranteePayment(data, packagesToAdd)
}
}
useEffect(() => {
if (!isOpen) {
formMethods.reset()
}
}, [isOpen, formMethods])
if (isLoading) {
return (
<div>
<LoadingSpinner />
</div>
)
}
return (
<FormProvider {...formMethods}>
<form className={styles.form} id="add-ancillary-form-id">
<div className={styles.modalScrollable}>
<Steps
user={user}
savedCreditCards={savedCreditCards}
error={errorMessage}
/>
</div>
<Summary onSubmit={formMethods.handleSubmit(onSubmit)} />
</form>
</FormProvider>
)
}