"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 { Typography } from "@scandic-hotels/design-system/Typography" import { trpc } from "@scandic-hotels/trpc/client" import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast" import { env } from "@/env/client" import { AncillaryStepEnum, type BreakfastData, useAddAncillaryStore, } from "@/stores/my-stay/add-ancillary-flow" import { buildAncillaryPackages, clearAncillarySessionData, generateDeliveryOptions, getAncillarySessionData, setAncillarySessionData, } from "@/components/HotelReservation/MyStay/utils/ancillaries" import LoadingSpinner from "@/components/LoadingSpinner" import Modal from "@/components/Modal" import { toast } from "@/components/TempDesignSystem/Toasts" import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking" import useLang from "@/hooks/useLang" import { trackAncillaryFailed, trackAncillarySuccess, trackGlaAncillaryAttempt, } from "@/utils/tracking/myStay" import { type AncillaryFormData, ancillaryFormSchema } from "../schema" import ActionButtons from "./ActionButtons" import PriceDetails from "./PriceDetails" import Steps from "./Steps" import styles from "./addAncillaryFlowModal.module.css" import type { AddAncillaryFlowModalProps, Packages, } 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 guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}` 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: 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( { 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 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: { 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( { 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: any) { 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") } } 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 ? buildBreakfastPackages(data, breakfastData) : [] if (isBreakfast && !breakfastData) { toast.error( intl.formatMessage({ 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(() => { const errorCode = searchParams.get("errorCode") const ancillary = searchParams.get("ancillary") if ((errorCode && ancillary) || errorCode === "AncillaryFailed") { const queryParams = new URLSearchParams(searchParams.toString()) if (ancillary) { queryParams.delete("ancillary") } queryParams.delete("errorCode") const savedData = getAncillarySessionData() if (savedData?.formData) { const updatedFormData = { ...savedData.formData, paymentMethod: booking?.guaranteeInfo ? PaymentMethodEnum.card : savedData.formData.paymentMethod, } formMethods.reset(updatedFormData) } router.replace(`${pathname}?${queryParams.toString()}`) } }, [searchParams, pathname, formMethods, router, booking.guaranteeInfo]) 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 (
) } const modalTitle = currentStep === AncillaryStepEnum.selectAncillary ? intl.formatMessage({ defaultMessage: "Upgrade your stay", }) : selectedAncillary?.title return (
{selectedAncillary && ( <> {currentStep !== AncillaryStepEnum.confirmation && (
{isBreakfast ? ( ) : (

{formatPrice( intl, selectedAncillary.price.total, selectedAncillary.price.currency )}

)}
{selectedAncillary.points && (

{intl.formatMessage( { defaultMessage: "{value} points", }, { value: selectedAncillary.points, } )}

)}
{selectedAncillary.description && (

)}
)} )} {currentStep === AncillaryStepEnum.selectAncillary ? null : (
)}
) } function BreakfastPriceList() { const intl = useIntl() const breakfastData = useAddAncillaryStore((state) => state.breakfastData) if (!breakfastData) { return intl.formatMessage({ defaultMessage: "Unable to display breakfast prices.", }) } return (
{intl.formatMessage( { defaultMessage: "{price}/night per adult", }, { price: `${breakfastData.priceAdult} ${breakfastData.currency}`, } )} {breakfastData.nrOfPayingChildren > 0 && ( <>
{intl.formatMessage( { defaultMessage: "{price}/night for kids (ages 4–12)", }, { price: `${breakfastData.priceChild} ${breakfastData.currency}`, } )} )} {breakfastData.nrOfFreeChildren > 0 && ( <>
{intl.formatMessage({ defaultMessage: "Free for kids (under 4)", })} )}
) } /** * 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, } } }