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
This commit is contained in:
Tobias Johansson
2025-06-10 06:35:13 +00:00
parent 3688d5ece8
commit ead822fa62
5 changed files with 199 additions and 10 deletions

View File

@@ -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 })
}

View File

@@ -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])

View File

@@ -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])

View File

@@ -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<T extends FieldValues>(
formType: FormType,
subscribe: UseFromSubscribe<T>,
control: Control<T>,
nameSuffix: string = ""
) {
const [formStarted, setFormStarted] = useState(false)
const lastAccessedField = useRef<string | undefined>(undefined)
const formState = useFormState({ control })
const previousErrors = useRef<FieldErrors<T>>({})
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])
}

View File

@@ -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,
},
})
}
}