Files
Michael Zetterberg f3936f41d8 Merged in fix/label-sync (pull request #2163)
fix: english label sync

* fix: english label sync

* fix: sync from Lokalise


Approved-by: Linus Flood
2025-05-21 04:11:22 +00:00

560 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 { 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 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,
}: 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<AncillaryFormData>({
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,
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)
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.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,
})
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" color="subtle" />
<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="baseSurfaceSubtleNormal" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage(
{
defaultMessage: "{price} / Years 4-12 / Night",
},
{
price: `${breakfastData.priceChild} ${breakfastData.currency}`,
}
)}
</span>
</Typography>
</>
)}
{breakfastData.nrOfFreeChildren > 0 && (
<>
<div className={styles.divider}>
<Divider variant="vertical" color="baseSurfaceSubtleNormal" />
</div>
<Typography variant="Body/Paragraph/mdBold">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<span>{`${intl.formatMessage({
defaultMessage: "Free",
})} / ${intl.formatMessage(
{
defaultMessage: "Under {age} years",
},
{ age: 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,
}
}
}