diff --git a/package-lock.json b/package-lock.json index 06809b49497..dcec0566781 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "react-aria": "^3.33.1", "react-cookie": "^7.1.0", "react-dom": "^18.2.0", + "react-intersection-observer": "^9.10.3", "react-stately": "^3.31.1", "server-only": "^0.0.1", "uuid": "^9.0.1", @@ -94,7 +95,6 @@ "lint-staged": "^15.2.7", "mjml-browser": "^4.15.3", "prettier": "3.3.3", - "react-intersection-observer": "^9.10.3", "sass": "^1.77.6", "storybook": "^8.1.11", "stylelint": "^16.7.0", @@ -24313,7 +24313,6 @@ "version": "9.10.3", "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.10.3.tgz", "integrity": "sha512-9NYfKwPZRovB6QJee7fDg0zz/SyYrqXtn5xTZU0vwLtLVBtfu9aZt1pVmr825REE49VPDZ7Lm5SNHjJBOTZHpA==", - "dev": true, "peerDependencies": { "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index e45c082685f..5af6960ec94 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "react-aria": "^3.33.1", "react-cookie": "^7.1.0", "react-dom": "^18.2.0", + "react-intersection-observer": "^9.10.3", "react-stately": "^3.31.1", "server-only": "^0.0.1", "uuid": "^9.0.1", @@ -146,7 +147,6 @@ "lint-staged": "^15.2.7", "mjml-browser": "^4.15.3", "prettier": "3.3.3", - "react-intersection-observer": "^9.10.3", "sass": "^1.77.6", "storybook": "^8.1.11", "stylelint": "^16.7.0", diff --git a/src/app/(proper_react)/(redesign)/(authenticated)/user/(dashboard)/dashboard/View.tsx b/src/app/(proper_react)/(redesign)/(authenticated)/user/(dashboard)/dashboard/View.tsx index 801d04cba3d..52337c82436 100644 --- a/src/app/(proper_react)/(redesign)/(authenticated)/user/(dashboard)/dashboard/View.tsx +++ b/src/app/(proper_react)/(redesign)/(authenticated)/user/(dashboard)/dashboard/View.tsx @@ -87,7 +87,9 @@ export type TabData = { export const View = (props: Props) => { const l10n = useL10n(); - const recordTelemetry = useTelemetry(props.experimentationId); + const recordTelemetry = useTelemetry({ + experimentationId: props.experimentationId, + }); const countryCode = useContext(CountryCodeContext); const pathname = usePathname(); diff --git a/src/app/(proper_react)/(redesign)/(public)/FreeScanCta.tsx b/src/app/(proper_react)/(redesign)/(public)/FreeScanCta.tsx index be4ddc2d119..0251217dd56 100644 --- a/src/app/(proper_react)/(redesign)/(public)/FreeScanCta.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/FreeScanCta.tsx @@ -6,24 +6,14 @@ import { signIn } from "next-auth/react"; import { useCookies } from "react-cookie"; -import { SignUpForm, Props as SignUpFormProps } from "./SignUpForm"; +import { Props, SignUpForm } from "./SignUpForm"; import { TelemetryButton } from "../../../components/client/TelemetryButton"; import { modifyAttributionsForUrlSearchParams } from "../../../functions/universal/attributions"; import { ExperimentData } from "../../../../telemetry/generated/nimbus/experiments"; import { useL10n } from "../../../hooks/l10n"; import { WaitlistCta } from "./ScanLimit"; - -export type Props = { - eligibleForPremium: boolean; - signUpCallbackUrl: string; - isHero?: boolean; - eventId: { - cta: string; - field?: string; - }; - scanLimitReached: boolean; - placeholder?: string; -}; +import { useViewTelemetry } from "../../../hooks/useViewTelemetry"; +import { RefObject } from "react"; export function getAttributionSearchParams({ cookies, @@ -60,12 +50,16 @@ export function getAttributionSearchParams({ } export const FreeScanCta = ( - props: SignUpFormProps & { + props: Props & { experimentData: ExperimentData; }, ) => { const l10n = useL10n(); const [cookies] = useCookies(["attributionsFirstTouch"]); + const telemetryButtonId = `${props.eventId.cta}-${props.experimentData["landing-page-free-scan-cta"].variant}`; + const refViewTelemetry = useViewTelemetry("ctaButton", { + button_id: telemetryButtonId, + }); if ( !props.experimentData["landing-page-free-scan-cta"].enabled || props.experimentData["landing-page-free-scan-cta"].variant === @@ -88,12 +82,13 @@ export const FreeScanCta = ( ) : (
} variant="primary" event={{ module: "ctaButton", name: "click", data: { - button_id: `${props.eventId.cta}-${props.experimentData["landing-page-free-scan-cta"].variant}`, + button_id: telemetryButtonId, }, }} onPress={() => { diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingView.test.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingView.test.tsx index cff25795f40..3793bf44a68 100644 --- a/src/app/(proper_react)/(redesign)/(public)/LandingView.test.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/LandingView.test.tsx @@ -5,6 +5,7 @@ import { it, expect } from "@jest/globals"; import { composeStory } from "@storybook/react"; import { + act, getAllByRole, getByRole, getByText, @@ -27,6 +28,7 @@ import Meta, { } from "./LandingView.stories"; import { deleteAllCookies } from "../../../functions/client/deleteAllCookies"; import { defaultExperimentData } from "../../../../telemetry/generated/nimbus/experiments"; +import { mockIsIntersecting } from "react-intersection-observer/test-utils"; jest.mock("next-auth/react", () => { return { @@ -950,7 +952,7 @@ describe("Free scan CTA experiment", () => { expect(waitlistCta[0]).toBeInTheDocument(); }); - it("sends telemetry for the different experiment variants", async () => { + it("sends telemetry when clicking on one of the experiment variants", async () => { const mockedRecord = useTelemetry(); const user = userEvent.setup(); const ComposedDashboard = composeStory(LandingUs, Meta); @@ -983,6 +985,39 @@ describe("Free scan CTA experiment", () => { ); }); + it("sends telemetry when a free scan CTA is shown in the viewport", () => { + const mockedRecord = useTelemetry(); + const ComposedDashboard = composeStory(LandingUs, Meta); + render( + , + ); + + // jsdom will complain about not being able to navigate to a different page + // after clicking the link; suppress that error, as it's not relevant to the + // test: + jest.spyOn(console, "error").mockImplementation(() => undefined); + + const submitButton = screen.getAllByRole("button", { + name: "Get free scan", + }); + act(() => { + mockIsIntersecting(submitButton[0], true); + }); + expect(mockedRecord).toHaveBeenCalledWith( + "ctaButton", + "view", + expect.objectContaining({ button_id: "clicked_get_scan_header-ctaOnly" }), + ); + }); + it("passes the expected URL to the identity provider", async () => { const user = userEvent.setup(); const ComposedDashboard = composeStory(LandingUs, Meta); diff --git a/src/app/(proper_react)/(redesign)/(public)/SignUpForm.tsx b/src/app/(proper_react)/(redesign)/(public)/SignUpForm.tsx index d46a9264310..83051921292 100644 --- a/src/app/(proper_react)/(redesign)/(public)/SignUpForm.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/SignUpForm.tsx @@ -4,12 +4,13 @@ "use client"; -import { FormEventHandler, useId, useState } from "react"; +import { FormEventHandler, RefObject, useId, useState } from "react"; import { signIn } from "next-auth/react"; import { useL10n } from "../../../hooks/l10n"; import { Button } from "../../../components/client/Button"; import styles from "./SignUpForm.module.scss"; import { useTelemetry } from "../../../hooks/useTelemetry"; +import { useViewTelemetry } from "../../../hooks/useViewTelemetry"; import { VisuallyHidden } from "../../../components/server/VisuallyHidden"; import { WaitlistCta } from "./ScanLimit"; import { useCookies } from "react-cookie"; @@ -18,14 +19,14 @@ import { ExperimentData } from "../../../../telemetry/generated/nimbus/experimen export type Props = { eligibleForPremium: boolean; - signUpCallbackUrl: string; - isHero?: boolean; eventId: { cta: string; field?: string; }; scanLimitReached: boolean; + signUpCallbackUrl: string; experimentData?: ExperimentData; + isHero?: boolean; placeholder?: string; }; @@ -34,6 +35,9 @@ export const SignUpForm = (props: Props) => { const l10n = useL10n(); const [emailInput, setEmailInput] = useState(""); const record = useTelemetry(); + const refViewTelemetry = useViewTelemetry("ctaButton", { + button_id: props.eventId.cta, + }); const [cookies] = useCookies(["attributionsFirstTouch"]); const onSubmit: FormEventHandler = (event) => { @@ -62,7 +66,11 @@ export const SignUpForm = (props: Props) => { return props.scanLimitReached ? ( ) : ( -
+ } + className={styles.form} + onSubmit={onSubmit} + > { ]); const pathname = usePathname(); - const recordTelemetry = useTelemetry(props.experimentationId); + const recordTelemetry = useTelemetry({ + experimentationId: props.experimentationId, + }); if ( props.experimentationId.startsWith("guest") && diff --git a/src/app/hooks/useTelemetry.ts b/src/app/hooks/useTelemetry.ts index 28cb014be1d..f880534fa40 100644 --- a/src/app/hooks/useTelemetry.ts +++ b/src/app/hooks/useTelemetry.ts @@ -17,9 +17,13 @@ const TelemetryPlatforms = { Ga: "ga", } as const; -export const useTelemetry = (experimentationId?: string) => { +export type TelemetryArgs = { + experimentationId?: string; +}; + +export const useTelemetry = (args?: TelemetryArgs) => { const path = usePathname(); - const recordGlean = useGlean(experimentationId); + const recordGlean = useGlean(args?.experimentationId); const { Glean, Ga } = TelemetryPlatforms; const recordTelemetry = < diff --git a/src/app/hooks/useViewTelemetry.ts b/src/app/hooks/useViewTelemetry.ts new file mode 100644 index 00000000000..5bafa869f1c --- /dev/null +++ b/src/app/hooks/useViewTelemetry.ts @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { IntersectionOptions, useInView } from "react-intersection-observer"; +import { TelemetryArgs, useTelemetry } from "./useTelemetry"; +import { GleanMetricMap } from "../../telemetry/generated/_map"; +import { RefObject } from "react"; + +export function useViewTelemetry< + EventModule extends keyof Pick, + EventName extends keyof GleanMetricMap[EventModule], +>( + eventModule: EventModule, + args: TelemetryArgs & GleanMetricMap[EventModule][EventName], + options?: IntersectionOptions, +) { + const { experimentationId, ...telemetryArgs } = args; + const recordTelemetry = useTelemetry({ experimentationId }); + const { ref } = useInView({ + skip: Object.keys(telemetryArgs).length === 0, + threshold: 1, + triggerOnce: true, + ...options, + onChange: (inView) => { + // Since this function is only triggered once when an element is entering + // the viewport and not again after leaving it. With the current setting + // the following condition is not expected to get called. Keeping the + // condition in place in case this changes. + /* c8 ignore next 3 */ + if (!inView) { + return; + } + recordTelemetry(eventModule, "view", telemetryArgs); + }, + }); + + return ref as unknown as RefObject; +} diff --git a/src/telemetry/metrics.yaml b/src/telemetry/metrics.yaml index 71c2dfca2d0..37d1cd582c1 100644 --- a/src/telemetry/metrics.yaml +++ b/src/telemetry/metrics.yaml @@ -429,6 +429,38 @@ cta_button: description: Which tier of plan the user is on [Free, Plus] type: string + view: + type: event + description: | + A button that has a specific call-to-action entered the viewport. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1823766 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1823766 + data_sensitivity: + - interaction + notification_emails: + - rhelmer@mozilla.com + expires: never + extra_keys: + path: + description: The path of the page. + type: string + user_id: + description: Mozilla accounts user ID. + type: string + session_id: + description: An ID that allows us to track “sessions” of the user experience within the product. + type: string + flow_id: + description: A randomly generated unique identifier for following user flows within the FxA system. + type: string + button_id: + description: The ID of the button that entered the viewport, or some way to identify where on the page the interaction is located. + plan_tier: + description: Which tier of plan the user is on [Free, Plus] + type: string + csat_survey: click: type: event