"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 { Typography } from "@scandic-hotels/design-system/Typography" import { PaymentMethodEnum } from "@/constants/booking" import { guaranteeCallback } from "@/constants/routes/hotelReservation" import { env } from "@/env/client" import { dt } from "@/lib/dt" import { trpc } from "@/lib/trpc/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 Image from "@/components/Image" import LoadingSpinner from "@/components/LoadingSpinner" import Modal from "@/components/Modal" import Divider from "@/components/TempDesignSystem/Divider" import { toast } from "@/components/TempDesignSystem/Toasts" import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking" import useLang from "@/hooks/useLang" import { formatPrice } from "@/utils/numberFormatting" 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" import { BreakfastPackageEnum } from "@/types/enums/breakfast" export default function AddAncillaryFlowModal({ booking, packages, user, savedCreditCards, refId, }: 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 ? 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.confirmationNumber, true) 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( { confirmationNumber: booking.confirmationNumber, 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, booking.guaranteeInfo?.cardType, booking.roomTypeCode ) toast.success( intl.formatMessage( { defaultMessage: "{ancillary} added to your booking!", }, { ancillary: selectedAncillary?.title } ) ) clearAncillarySessionData() closeModal() utils.booking.get.invalidate({ confirmationNumber: booking.confirmationNumber, }) router.refresh() } else { trackAncillaryFailed(packages, data.deliveryTime, selectedAncillary) toast.error(ancillaryErrorMessage) } }, onError: () => { trackAncillaryFailed(packages, data.deliveryTime, selectedAncillary) toast.error(ancillaryErrorMessage) }, } ) } function handleGuaranteePayment(data: AncillaryFormData, packages: any) { const savedCreditCard = savedCreditCards?.find( (card) => card.id === data.paymentMethod ) trackGlaAncillaryAttempt( savedCreditCard, packages, selectedAncillary, data.deliveryTime ) if (booking.confirmationNumber) { const card = savedCreditCard ? { alias: savedCreditCard.alias, expiryDate: savedCreditCard.expirationDate, cardType: savedCreditCard.cardType, } : undefined guaranteeBooking.mutate({ confirmationNumber: booking.confirmationNumber, language: lang, ...(card && { card }), success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}&ancillary=1`, error: `${guaranteeRedirectUrl}?status=error&RefId=${encodeURIComponent(refId)}&ancillary=1`, cancel: `${guaranteeRedirectUrl}?status=cancel&RefId=${encodeURIComponent(refId)}&ancillary=1`, }) } else { handleGuaranteeError("No confirmation number") } } function buildBreakfastPackages( data: AncillaryFormData, breakfastData: BreakfastData ) { return [ { 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, }, ] } const onSubmit = (data: AncillaryFormData) => { if (!validateTermsAndConditions(data)) return setAncillarySessionData({ formData: data, selectedAncillary, }) const packagesToAdd = !isBreakfast ? buildAncillaryPackages(data, selectedAncillary) : breakfastData ? buildBreakfastPackages(data, breakfastData) : [] if (isBreakfast && !breakfastData) { toast.error( intl.formatMessage({ defaultMessage: "Something went wrong!", }) ) return } 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 && ( <>
{selectedAncillary.title}
{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: "Can not show breakfast prices.", }) } return (
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} {`${breakfastData.priceAdult} ${breakfastData.currency} / ${intl.formatMessage( { defaultMessage: "Adult", } )}`} {breakfastData.nrOfPayingChildren > 0 && ( <>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} {`${breakfastData.priceChild} ${breakfastData.currency} / ${intl.formatMessage( { defaultMessage: "Years", } )} 4-12`} )} {breakfastData.nrOfFreeChildren > 0 && ( <>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} {`${intl.formatMessage({ defaultMessage: "Free", })} / ${intl.formatMessage( { defaultMessage: "Under {age} years", }, { age: 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( (acc, curr) => (curr >= 4 ? [acc[0] + 1, acc[1]] : [acc[0], acc[1] + 1]), [0, 0] ) const priceAdult = packages?.find( (p) => p.code === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST )?.localPrice.price const priceChild = packages?.find( (p) => p.code === BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST )?.localPrice.price const currency = packages?.find( (p) => p.code === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST )?.localPrice.currency if ( typeof priceAdult !== "number" || typeof priceChild !== "number" || typeof currency !== "string" ) { return null } else { return { nrOfAdults, nrOfPayingChildren, nrOfFreeChildren, nrOfNights, priceAdult, priceChild, currency, } } }