Merged in feat/SW-1997-tracking-gla-my-stay-ancillaries (pull request #1657)

Feat/SW-1997 tracking gla my stay ancillaries

* feat(SW-1996): tracking gla my stay

* feat(SW-1996): update gla tracking

* feat(SW-1996): fix comment

* feat(SW-1997): add tracking for gla my stay and ancillaries

* feat(SW-1997): rebase master

* feat(SW-1997): fix duplicate import

* feat(SW-1997): add hotelId and category for ancillaries, and add more tracking

* feat(SW-1997): remove commments and fix spelling mistake

* feat(SW-1997): if addAncillary failed, but guarantee is successful, default to card in booking


Approved-by: Niclas Edenvin
This commit is contained in:
Bianca Widstam
2025-04-01 09:38:36 +00:00
parent e6c9e25222
commit 35c1724afb
15 changed files with 596 additions and 182 deletions

View File

@@ -10,7 +10,9 @@ import {
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import { type AncillaryFormData, quantitySchema } from "../../schema"
import { trackAddAncillary } from "@/utils/tracking/myStay"
import { type AncillaryQuantityFormData,quantitySchema } from "../../schema"
import styles from "./actionButtons.module.css"
@@ -27,12 +29,14 @@ export default function ActionButtons({
selectQuantity,
selectDeliveryTime,
selectQuantityAndDeliveryTime,
selectedAncillary,
} = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
prevStep: state.prevStep,
selectQuantity: state.selectQuantity,
selectDeliveryTime: state.selectDeliveryTime,
selectQuantityAndDeliveryTime: state.selectQuantityAndDeliveryTime,
selectedAncillary: state.selectedAncillary,
}))
const isMobile = useMediaQuery("(max-width: 767px)")
const { setError } = useFormContext()
@@ -41,10 +45,10 @@ export default function ActionButtons({
const isConfirmStep = currentStep === AncillaryStepEnum.confirmation
const confirmLabel = intl.formatMessage({ id: "Confirm" })
const continueLabel = intl.formatMessage({ id: "Continue" })
const quantityWithCard = useWatch<AncillaryFormData>({
const quantityWithCard = useWatch<AncillaryQuantityFormData>({
name: "quantityWithCard",
})
const quantityWithPoints = useWatch<AncillaryFormData>({
const quantityWithPoints = useWatch<AncillaryQuantityFormData>({
name: "quantityWithPoints",
})
function handleNextStep() {
@@ -54,6 +58,11 @@ export default function ActionButtons({
quantityWithPoints,
})
if (validatedQuantity.success) {
trackAddAncillary(
selectedAncillary,
quantityWithCard,
quantityWithPoints
)
if (isMobile) {
selectQuantityAndDeliveryTime()
} else {

View File

@@ -26,8 +26,14 @@ 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 {
buildAncillaryPackages,
clearAncillarySessionData,
generateDeliveryOptions,
getAncillarySessionData,
@@ -113,128 +119,155 @@ export default function AddAncillaryFlowModal({
}
const utils = trpc.useUtils()
const addAncillary = trpc.booking.packages.useMutation({
onSuccess: (data, variables) => {
if (data) {
clearAncillarySessionData()
closeModal()
utils.booking.confirmation.invalidate({
confirmationNumber: variables.confirmationNumber,
})
const addAncillary = trpc.booking.packages.useMutation()
toast.success(
intl.formatMessage(
{ id: "{ancillary} added to your booking!" },
{ ancillary: selectedAncillary?.title }
)
)
router.refresh()
} else {
toast.error(ancillaryErrorMessage)
}
},
onError: () => {
toast.error(ancillaryErrorMessage)
},
})
const { guaranteeBooking, isLoading, handleGuaranteeError } =
useGuaranteeBooking({
confirmationNumber: booking.confirmationNumber,
isAncillaryFlow: true,
})
const { guaranteeBooking, isLoading } = useGuaranteeBooking({
confirmationNumber: booking.confirmationNumber,
})
const onSubmit = (data: AncillaryFormData) => {
function validateTermsAndConditions(data: AncillaryFormData): boolean {
if (!data.termsAndConditions) {
formMethods.setError("termsAndConditions", {
message: "You must accept the terms",
})
return
return false
}
return true
}
setAncillarySessionData({
formData: data,
selectedAncillary,
})
if (booking.guaranteeInfo) {
const packagesToAdd = []
if (selectedAncillary?.id && data.quantityWithCard) {
if (!isBreakfast) {
packagesToAdd.push({
code: selectedAncillary.id,
quantity: data.quantityWithCard,
comment: data.optionalText || undefined,
})
} else {
if (!breakfastData) {
toast.error(
intl.formatMessage({
id: "Something went wrong!",
})
)
return
}
packagesToAdd.push({
code: BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
quantity: breakfastData.nrOfAdults,
comment: data.optionalText || undefined,
})
packagesToAdd.push({
code: BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
quantity: breakfastData.nrOfPayingChildren,
comment: data.optionalText || undefined,
})
packagesToAdd.push({
code: BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
quantity: breakfastData.nrOfFreeChildren,
comment: data.optionalText || undefined,
})
}
}
if (selectedAncillary?.loyaltyCode && data.quantityWithPoints) {
packagesToAdd.push({
code: selectedAncillary.loyaltyCode,
quantity: data.quantityWithPoints,
comment: data.optionalText || undefined,
})
}
addAncillary.mutate({
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: packagesToAdd,
packages: packages,
language: lang,
})
} else {
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
if (booking.confirmationNumber) {
const card = savedCreditCard
? {
},
{
onSuccess: (result) => {
if (result) {
trackAncillarySuccess(
booking.confirmationNumber,
packages,
data.deliveryTime,
"ancillary",
selectedAncillary,
booking.guaranteeInfo?.cardType,
booking.roomTypeCode
)
toast.success(
intl.formatMessage(
{ id: "{ancillary} added to your booking!" },
{ ancillary: selectedAncillary?.title }
)
)
clearAncillarySessionData()
closeModal()
utils.booking.confirmation.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 {
toast.error(
intl.formatMessage({
id: "Something went wrong!",
})
)
}
: 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({ id: "Something went wrong!" }))
return
}
if (booking.guaranteeInfo) {
handleAncillarySubmission(data, packagesToAdd)
} else {
handleGuaranteePayment(data, packagesToAdd)
}
}
@@ -249,11 +282,17 @@ export default function AddAncillaryFlowModal({
queryParams.delete("errorCode")
const savedData = getAncillarySessionData()
if (savedData?.formData) {
formMethods.reset(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])
}, [searchParams, pathname, formMethods, router, booking.guaranteeInfo])
useEffect(() => {
setBreakfastData(

View File

@@ -1,6 +1,7 @@
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
import { trackViewAncillary } from "@/utils/tracking/myStay"
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
@@ -13,8 +14,15 @@ export default function WrappedAncillaryCard({
}: WrappedAncillaryProps) {
const { description, ...ancillaryWithoutDescription } = ancillary
const selectAncillary = useAddAncillaryStore((state) => state.selectAncillary)
return (
<div role="button" onClick={() => selectAncillary(ancillary)}>
<div
role="button"
onClick={() => {
selectAncillary(ancillary)
trackViewAncillary(ancillary)
}}
>
<AncillaryCard ancillary={ancillaryWithoutDescription} />
</div>
)

View File

@@ -6,6 +6,7 @@ const quantitySchemaWithoutRefine = z.object({
quantityWithPoints: z.number().nullable(),
quantityWithCard: z.number().nullable(),
})
export const quantitySchema = z
.object({})
.merge(quantitySchemaWithoutRefine)
@@ -35,4 +36,7 @@ export const ancillaryFormSchema = z
}
)
export type AncillaryQuantityFormData = z.output<
typeof quantitySchemaWithoutRefine
>
export type AncillaryFormData = z.output<typeof ancillaryFormSchema>

View File

@@ -9,6 +9,7 @@ import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { trackRemoveAncillary } from "@/utils/tracking/myStay"
import { getBreakfastPackagesFromAncillaryFlow } from "../../utils/hasBreakfastPackage"
import RemoveButton from "./RemoveButton"
@@ -180,7 +181,14 @@ export function AddedAncillaries({
confirmationNumber={booking.confirmationNumber}
code={ancillary.code}
title={ancillaryTitle}
onSuccess={router.refresh}
onSuccess={() => {
trackRemoveAncillary(
ancillary,
booking.hotelId,
booking.ancillary?.deliveryTime
)
router.refresh()
}}
/>
</div>
) : null}

View File

@@ -6,8 +6,16 @@ import { useEffect } from "react"
import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import {
trackAncillaryFailed,
trackAncillarySuccess,
} from "@/utils/tracking/myStay"
import { clearAncillarySessionData, getAncillarySessionData } from "../utils"
import {
buildAncillaryPackages,
clearAncillarySessionData,
getAncillarySessionData,
} from "../utils"
import type { Lang } from "@/constants/languages"
@@ -22,15 +30,7 @@ export default function GuaranteeAncillaryHandler({
}) {
const router = useRouter()
const addAncillary = trpc.booking.packages.useMutation({
onSuccess: () => {
clearAncillarySessionData()
router.replace(returnUrl)
},
onError: () => {
router.replace(`${returnUrl}&errorCode=AncillaryFailed`)
},
})
const addAncillary = trpc.booking.packages.useMutation()
useEffect(() => {
if (addAncillary.isPending || addAncillary.submittedAt) {
@@ -44,33 +44,49 @@ export default function GuaranteeAncillaryHandler({
}
const { formData, selectedAncillary } = sessionData
const packages = []
const packages = buildAncillaryPackages(formData, selectedAncillary)
if (selectedAncillary?.id && formData.quantityWithCard) {
packages.push({
code: selectedAncillary.id,
quantity: formData.quantityWithCard,
comment: formData.optionalText || undefined,
})
}
if (selectedAncillary?.loyaltyCode && formData.quantityWithPoints) {
packages.push({
code: selectedAncillary.loyaltyCode,
quantity: formData.quantityWithPoints,
comment: formData.optionalText || undefined,
})
}
addAncillary.mutate({
confirmationNumber,
ancillaryComment: formData.optionalText,
ancillaryDeliveryTime: selectedAncillary.requiresDeliveryTime
? formData.deliveryTime
: undefined,
packages,
language: lang,
})
addAncillary.mutate(
{
confirmationNumber,
ancillaryComment: formData.optionalText,
ancillaryDeliveryTime: selectedAncillary.requiresDeliveryTime
? formData.deliveryTime
: undefined,
packages,
language: lang,
},
{
onSuccess: (data) => {
if (data) {
trackAncillarySuccess(
confirmationNumber,
packages,
formData.deliveryTime,
"room + ancillary",
selectedAncillary
)
clearAncillarySessionData()
router.replace(returnUrl)
} else {
trackAncillaryFailed(
packages,
formData.deliveryTime,
selectedAncillary
)
router.replace(`${returnUrl}&errorCode=AncillaryFailed`)
}
},
onError: () => {
trackAncillaryFailed(
packages,
formData.deliveryTime,
selectedAncillary
)
router.replace(`${returnUrl}&errorCode=AncillaryFailed`)
},
}
)
}, [confirmationNumber, returnUrl, addAncillary, lang, router])
return <LoadingSpinner />

View File

@@ -126,6 +126,8 @@ export function Ancillaries({
requiresDeliveryTime: false,
loyaltyCode: undefined,
points: undefined,
hotelId: Number(booking.hotelId),
categoryName: "Food",
}
: undefined
@@ -135,6 +137,7 @@ export function Ancillaries({
booking.rateDefinition.breakfastIncluded,
intl,
packages,
booking.hotelId,
])
const allAncillaries = useMemo(() => {

View File

@@ -1,4 +1,7 @@
import type { Ancillary } from "@/types/components/myPages/myStay/ancillaries"
import type {
Ancillary,
SelectedAncillary,
} from "@/types/components/myPages/myStay/ancillaries"
import type { AncillaryFormData } from "./AddAncillaryFlow/schema"
export const generateDeliveryOptions = () => {
@@ -9,6 +12,32 @@ export const generateDeliveryOptions = () => {
value: slot,
}))
}
export function buildAncillaryPackages(
data: AncillaryFormData,
ancillary: SelectedAncillary | null
) {
const packages = []
if (ancillary?.id && data.quantityWithCard) {
packages.push({
code: ancillary.id,
quantity: data.quantityWithCard,
comment: data.optionalText || undefined,
})
}
if (ancillary?.loyaltyCode && data.quantityWithPoints) {
packages.push({
code: ancillary.loyaltyCode,
quantity: data.quantityWithPoints,
comment: data.optionalText || undefined,
})
}
return packages
}
const ancillarySessionKey = "ancillarySessionData"
export const getAncillarySessionData = ():
| {

View File

@@ -0,0 +1,110 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { PaymentCallbackStatusEnum } from "@/constants/booking"
import LoadingSpinner from "@/components/LoadingSpinner"
import { trackEvent } from "@/utils/tracking/base"
import {
buildAncillaryPackages,
getAncillarySessionData,
} from "../../Ancillaries/utils"
interface TrackGuaranteeProps {
status: string
isAncillaryFlow?: boolean
redirectUrl: string
errorMessage?: string
}
export default function TrackGuarantee({
status,
isAncillaryFlow = false,
redirectUrl,
errorMessage,
}: TrackGuaranteeProps) {
const router = useRouter()
useEffect(() => {
const trackAncillaryPaymentEvent = (event: string, status: string) => {
const sessionData = getAncillarySessionData()
const { formData, selectedAncillary } = sessionData || {}
const packages =
formData && selectedAncillary
? buildAncillaryPackages(formData, selectedAncillary)
: []
trackEvent({
event,
paymentInfo: { status },
ancillaries: packages.map((pkg) => ({
hotelId: selectedAncillary?.hotelId,
productId: pkg.code,
productUnits: pkg.quantity,
productPoints: selectedAncillary?.points,
productDeliveryTime: formData?.deliveryTime,
productPrice: selectedAncillary?.price,
productName: selectedAncillary?.title,
productCategory: selectedAncillary?.categoryName,
})),
lateArrivalGuarantee: "yes",
guaranteedProduct: "room + ancillary",
})
}
const trackGuaranteePaymentEvent = (event: string, status: string) => {
trackEvent({
event,
hotelInfo: {
lateArrivalGuarantee: "yes",
guaranteedProduct: "room",
},
paymentInfo: {
status,
...(errorMessage && { errorMessage }),
},
})
}
switch (status) {
case PaymentCallbackStatusEnum.Success:
trackEvent({
event: "guaranteeBookingSuccess",
hotelInfo: {
lateArrivalGuarantee: "yes",
guaranteedProduct: "room",
},
})
break
case PaymentCallbackStatusEnum.Cancel:
isAncillaryFlow
? trackAncillaryPaymentEvent(
"GuaranteeCancelAncillary",
"glacardsavecancelled"
)
: trackGuaranteePaymentEvent(
"glaCardSaveCancelled",
"glacardsavecancelled"
)
break
case PaymentCallbackStatusEnum.Error:
isAncillaryFlow
? trackAncillaryPaymentEvent(
"GuaranteeFailAncillary",
"glacardsavefailed"
)
: trackGuaranteePaymentEvent("glaCardSaveFailed", "glacardsavefailed")
break
}
router.replace(redirectUrl)
}, [status, isAncillaryFlow, redirectUrl, errorMessage, router])
return <LoadingSpinner />
}

View File

@@ -66,10 +66,11 @@ export default function GuaranteeLateArrival({
})
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}`
const { guaranteeBooking, isLoading } = useGuaranteeBooking({
confirmationNumber: bookedRoom.confirmationNumber,
handleBookingCompleted: router.refresh,
})
const { guaranteeBooking, isLoading, handleGuaranteeError } =
useGuaranteeBooking({
confirmationNumber: bookedRoom.confirmationNumber,
handleBookingCompleted: router.refresh,
})
if (isLoading) {
return (
@@ -83,12 +84,7 @@ export default function GuaranteeLateArrival({
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
trackGlaSaveCardAttempt(
bookedRoom.hotelId,
data.paymentMethod,
savedCreditCard,
"yes"
)
trackGlaSaveCardAttempt(bookedRoom.hotelId, savedCreditCard, "yes")
if (bookedRoom.confirmationNumber) {
const card = savedCreditCard
? {
@@ -106,6 +102,7 @@ export default function GuaranteeLateArrival({
cancel: `${guaranteeRedirectUrl}?status=cancel&RefId=${encodeURIComponent(refId)}`,
})
} else {
handleGuaranteeError("No confirmation number")
toast.error(
intl.formatMessage({
id: "Something went wrong!",