feat(SW-3225): Move ParkingInformation to design-system * Inline ParkingInformation types to remove trpc dependency * Move ParkingInformation to design-system * Move numberFormatting to common package * Add deps to external * Fix imports and i18n script * Add common as dependency * Merge branch 'master' into feat/sw-3225-move-parking-information-to-booking-flow Approved-by: Linus Flood
569 lines
17 KiB
TypeScript
569 lines
17 KiB
TypeScript
"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 (
|
||
<div>
|
||
<LoadingSpinner />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const modalTitle =
|
||
currentStep === AncillaryStepEnum.selectAncillary
|
||
? intl.formatMessage({
|
||
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(
|
||
{
|
||
defaultMessage: "{value} points",
|
||
},
|
||
{
|
||
value: 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} />
|
||
{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({
|
||
defaultMessage: "Unable to display breakfast prices.",
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className={styles.breakfastPriceList}>
|
||
<Typography variant="Body/Paragraph/mdBold">
|
||
<span>
|
||
{intl.formatMessage(
|
||
{
|
||
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(
|
||
{
|
||
defaultMessage: "{price}/night for kids (ages 4–12)",
|
||
},
|
||
{
|
||
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({
|
||
defaultMessage: "Free for kids (under 4)",
|
||
})}
|
||
</span>
|
||
</Typography>
|
||
</>
|
||
)}
|
||
</div>
|
||
</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,
|
||
}
|
||
}
|
||
}
|