From ead822fa6250092b1aa12f0c5a395104dbd0f726 Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Tue, 10 Jun 2025 06:35:13 +0000 Subject: [PATCH] Merged in fix/SW-2679-tracking-signup-details-forms (pull request #2236) feat(SW-2679): Added form tracking for checkout and signup * feat(SW-2679): Added form tracking for checkout and signup * fix(SW-2679): fixes from review Approved-by: Michael Zetterberg --- .../components/Forms/Signup/index.tsx | 5 + .../EnterDetails/Details/Multiroom/index.tsx | 7 +- .../EnterDetails/Details/RoomOne/index.tsx | 21 ++-- .../components/TrackingSDK/hooks.ts | 95 ++++++++++++++++++- apps/scandic-web/utils/tracking/form.ts | 81 ++++++++++++++++ 5 files changed, 199 insertions(+), 10 deletions(-) create mode 100644 apps/scandic-web/utils/tracking/form.ts diff --git a/apps/scandic-web/components/Forms/Signup/index.tsx b/apps/scandic-web/components/Forms/Signup/index.tsx index 594a2562d..6562a05ea 100644 --- a/apps/scandic-web/components/Forms/Signup/index.tsx +++ b/apps/scandic-web/components/Forms/Signup/index.tsx @@ -24,6 +24,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Title from "@/components/TempDesignSystem/Text/Title" import { toast } from "@/components/TempDesignSystem/Toasts" +import { useFormTracking } from "@/components/TrackingSDK/hooks" import useLang from "@/hooks/useLang" import { type SignUpSchema, signUpSchema } from "./schema" @@ -99,6 +100,10 @@ export default function SignupForm({ title }: SignUpFormProps) { shouldFocusError: true, }) + const { control, subscribe } = methods + + useFormTracking("signup", subscribe, control) + async function onSubmit(data: SignUpSchema) { signup.mutate({ ...data, language: lang }) } diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Details/Multiroom/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/Multiroom/index.tsx index 50dde6d3c..5562088ec 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Details/Multiroom/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/Multiroom/index.tsx @@ -12,6 +12,7 @@ import CountrySelect from "@/components/TempDesignSystem/Form/Country" import Input from "@/components/TempDesignSystem/Form/Input" import Phone from "@/components/TempDesignSystem/Form/Phone" import Footnote from "@/components/TempDesignSystem/Text/Footnote" +import { useFormTracking } from "@/components/TrackingSDK/hooks" import { useRoomContext } from "@/contexts/Details/Room" import MemberPriceModal from "../MemberPriceModal" @@ -84,11 +85,15 @@ export default function Details() { }) const { - formState: { isValid }, handleSubmit, trigger, + control, + subscribe, + formState: { isValid }, } = methods + useFormTracking("checkout", subscribe, control, ` - room ${roomNr}`) + useEffect(() => { addPreSubmitCallback(`${idx}-details`, trigger) }, [addPreSubmitCallback, idx, trigger]) diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx index d47cf8ada..753180455 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx @@ -12,6 +12,7 @@ import CountrySelect from "@/components/TempDesignSystem/Form/Country" import Input from "@/components/TempDesignSystem/Form/Input" import Phone from "@/components/TempDesignSystem/Form/Phone" import Footnote from "@/components/TempDesignSystem/Text/Footnote" +import { useFormTracking } from "@/components/TrackingSDK/hooks" import { useRoomContext } from "@/contexts/Details/Room" import MemberPriceModal from "../MemberPriceModal" @@ -30,7 +31,8 @@ const formID = "enter-details" export default function Details({ user }: DetailsProps) { const intl = useIntl() - const { addPreSubmitCallback } = useEnterDetailsStore((state) => ({ + const { lastRoom, addPreSubmitCallback } = useEnterDetailsStore((state) => ({ + lastRoom: state.lastRoom, addPreSubmitCallback: state.actions.addPreSubmitCallback, })) @@ -77,11 +79,14 @@ export default function Details({ user }: DetailsProps) { reValidateMode: "onChange", }) - const { - formState: { isValid }, - handleSubmit, - trigger, - } = methods + const { formState, handleSubmit, trigger, control, subscribe } = methods + + useFormTracking( + "checkout", + subscribe, + control, + lastRoom === idx ? "" : " - room 1" + ) useEffect(() => { addPreSubmitCallback(`${idx}-details`, trigger) @@ -95,12 +100,12 @@ export default function Details({ user }: DetailsProps) { ) const updateDetailsStore = useCallback(() => { - if (isValid) { + if (formState.isValid) { handleSubmit(onSubmit)() } else { setIncomplete() } - }, [handleSubmit, isValid, onSubmit, setIncomplete]) + }, [handleSubmit, formState.isValid, onSubmit, setIncomplete]) useEffect(updateDetailsStore, [updateDetailsStore]) diff --git a/apps/scandic-web/components/TrackingSDK/hooks.ts b/apps/scandic-web/components/TrackingSDK/hooks.ts index 1f7456600..46b2482cd 100644 --- a/apps/scandic-web/components/TrackingSDK/hooks.ts +++ b/apps/scandic-web/components/TrackingSDK/hooks.ts @@ -1,7 +1,14 @@ "use client" - +import isEqual from "fast-deep-equal" import { usePathname } from "next/navigation" import { startTransition, useEffect, useRef, useState } from "react" +import { + type Control, + type FieldErrors, + type FieldValues, + useFormState, + type UseFromSubscribe, +} from "react-hook-form" import { trpc } from "@/lib/trpc/client" import useRouterTransitionStore from "@/stores/router-transition" @@ -11,6 +18,12 @@ import useLang from "@/hooks/useLang" import { useSessionId } from "@/hooks/useSessionId" import { promiseWithTimeout } from "@/utils/promiseWithTimeout" import { createSDKPageObject, trackPageView } from "@/utils/tracking" +import { + type FormType, + trackFormAbandonment, + trackFormInputStarted, + trackFormValidationError, +} from "@/utils/tracking/form" import type { TrackingSDKProps, @@ -261,3 +274,83 @@ const getPageLoadTimeEntry = () => { observer.observe({ type: "navigation", buffered: true }) }) } + +export function useFormTracking( + formType: FormType, + subscribe: UseFromSubscribe, + control: Control, + nameSuffix: string = "" +) { + const [formStarted, setFormStarted] = useState(false) + const lastAccessedField = useRef(undefined) + const formState = useFormState({ control }) + const previousErrors = useRef>({}) + + useEffect(() => { + const errors = formState.errors + const prevErrors = previousErrors.current + + const isNewErrors = !isEqual(errors, prevErrors) + if (Object.keys(errors).length && isNewErrors) { + const errorString = Object.values(errors) + .map((err) => { + if (err && "message" in err) { + return err.message + } + const nested = Object.values(err ?? {}).find( + (val) => val && typeof val === "object" && "message" in val + ) + if (nested) { + return nested.message + } + return undefined + }) + .filter(Boolean) + .join("|") + + trackFormValidationError(formType, errorString, nameSuffix) + previousErrors.current = { ...errors } + } + }, [formType, formState, nameSuffix]) + + useEffect(() => { + const unsubscribe = subscribe({ + formState: { touchedFields: true }, + callback: (data) => { + if ("name" in data) { + lastAccessedField.current = data.name as string + } + + if (!formStarted) { + trackFormInputStarted(formType, nameSuffix) + setFormStarted(true) + } + }, + }) + return () => unsubscribe() + }, [subscribe, formType, nameSuffix, formStarted]) + + useEffect(() => { + if (!formStarted || !lastAccessedField.current) return + + const lastField = lastAccessedField.current + + function handleBeforeUnload() { + trackFormAbandonment(formType, lastField, nameSuffix) + } + + function handleVisibilityChange() { + if (document.visibilityState === "hidden") { + trackFormAbandonment(formType, lastField, nameSuffix) + } + } + + window.addEventListener("beforeunload", handleBeforeUnload) + window.addEventListener("visibilitychange", handleVisibilityChange) + + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload) + window.removeEventListener("visibilitychange", handleVisibilityChange) + } + }, [formStarted, formType, nameSuffix]) +} diff --git a/apps/scandic-web/utils/tracking/form.ts b/apps/scandic-web/utils/tracking/form.ts new file mode 100644 index 000000000..9ed99d6e3 --- /dev/null +++ b/apps/scandic-web/utils/tracking/form.ts @@ -0,0 +1,81 @@ +import { trackEvent } from "./base" + +export type FormType = "checkout" | "signup" + +export function trackFormInputStarted(type: FormType, nameSuffix?: string) { + if (type === "checkout") { + trackEvent({ + event: "formStart", + form: { + action: "checkout form start", + name: "checkout enter detail" + nameSuffix, + type: type, + }, + }) + } else if (type === "signup") { + trackEvent({ + event: "formStart", + form: { + action: "signup form start", + name: "member registration" + nameSuffix, + type: type, + }, + }) + } +} + +export function trackFormAbandonment( + type: FormType, + lastAccessedField: string, + nameSuffix?: string +) { + if (type === "checkout") { + trackEvent({ + event: "formAbandonment", + form: { + action: "checkout form abandonment", + name: "checkout enter detail" + nameSuffix, + type: type, + lastAccessedField, + }, + }) + } else if (type === "signup") { + trackEvent({ + event: "formAbandonment", + form: { + action: "signup form abandonment", + name: "member registration" + nameSuffix, + type: type, + lastAccessedField, + }, + }) + } +} + +export function trackFormValidationError( + type: FormType, + errorMessage: string, + nameSuffix?: string +) { + if (type === "checkout") { + trackEvent({ + event: "formError", + form: { + action: "checkout form error", + name: "checkout enter detail" + nameSuffix, + type: type, + errorMessage, + }, + }) + } else if (type === "signup") { + trackEvent({ + event: "formError", + form: { + action: "signup form error", + name: "member registration" + nameSuffix, + type: type, + errorMessage, + }, + }) + } +}