From 55f741e8311df4720233e70b49a4cd5b3890572b Mon Sep 17 00:00:00 2001 From: GelatoGenesis Date: Mon, 9 Mar 2026 10:46:00 +0200 Subject: [PATCH 1/3] Logical page views for mixpanel Signed-off-by: GelatoGenesis --- .../providers/MixpanelSetup.test.tsx | 38 +++ .../analytics/pageClassification.test.ts | 75 ++++++ components/providers/MixpanelSetup.tsx | 23 +- services/analytics/pageClassification.ts | 219 ++++++++++++++++++ 4 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 __tests__/services/analytics/pageClassification.test.ts create mode 100644 services/analytics/pageClassification.ts diff --git a/__tests__/components/providers/MixpanelSetup.test.tsx b/__tests__/components/providers/MixpanelSetup.test.tsx index cdf38777d3..7ce69c6cb3 100644 --- a/__tests__/components/providers/MixpanelSetup.test.tsx +++ b/__tests__/components/providers/MixpanelSetup.test.tsx @@ -11,6 +11,7 @@ const trackPageViewMock = jest.fn(); let connectedProfile: { id: number } | null = null; let pathname = "/"; let performanceConsent: boolean | undefined = undefined; +let searchParams = new URLSearchParams(); jest.mock("@/components/auth/Auth", () => ({ useAuth: () => ({ @@ -26,6 +27,7 @@ jest.mock("@/components/cookies/CookieConsentContext", () => ({ jest.mock("next/navigation", () => ({ usePathname: () => pathname, + useSearchParams: () => searchParams, })); jest.mock("@/services/analytics/mixpanel", () => ({ @@ -41,6 +43,7 @@ describe("MixpanelSetup", () => { connectedProfile = null; pathname = "/"; performanceConsent = undefined; + searchParams = new URLSearchParams(); clearIdentityMock.mockReset(); disableAnalyticsMock.mockReset(); identifyMock.mockReset(); @@ -68,6 +71,9 @@ describe("MixpanelSetup", () => { expect(identifyMock).toHaveBeenCalledWith("42"); expect(trackPageViewMock).toHaveBeenCalledWith("/waves", { has_connected_profile: true, + logical_page: "waves_index", + page_group: "waves", + route_pattern: "/waves", }); expect(identifyMock.mock.invocationCallOrder[0]).toBeLessThan( trackPageViewMock.mock.invocationCallOrder[0] @@ -83,6 +89,9 @@ describe("MixpanelSetup", () => { expect(initAnalyticsMock).toHaveBeenCalledTimes(1); expect(trackPageViewMock).toHaveBeenCalledWith("/waves", { has_connected_profile: false, + logical_page: "waves_index", + page_group: "waves", + route_pattern: "/waves", }); rerender(); @@ -93,6 +102,35 @@ describe("MixpanelSetup", () => { expect(trackPageViewMock).toHaveBeenCalledTimes(2); expect(trackPageViewMock).toHaveBeenLastCalledWith("/notifications", { has_connected_profile: false, + logical_page: "notifications", + page_group: "notifications", + route_pattern: "/notifications", + }); + }); + + it("tracks drop detail views separately when the drop query changes", () => { + performanceConsent = true; + pathname = "/waves/wave-1"; + searchParams = new URLSearchParams("drop=drop-1"); + + const { rerender } = render(); + + expect(trackPageViewMock).toHaveBeenCalledWith("/waves/wave-1", { + has_connected_profile: false, + logical_page: "wave_drop_detail", + page_group: "waves", + route_pattern: "/waves/:waveId?drop=:dropId", + }); + + searchParams = new URLSearchParams("drop=drop-2"); + rerender(); + + expect(trackPageViewMock).toHaveBeenCalledTimes(2); + expect(trackPageViewMock).toHaveBeenLastCalledWith("/waves/wave-1", { + has_connected_profile: false, + logical_page: "wave_drop_detail", + page_group: "waves", + route_pattern: "/waves/:waveId?drop=:dropId", }); }); diff --git a/__tests__/services/analytics/pageClassification.test.ts b/__tests__/services/analytics/pageClassification.test.ts new file mode 100644 index 0000000000..0ac5bb0da1 --- /dev/null +++ b/__tests__/services/analytics/pageClassification.test.ts @@ -0,0 +1,75 @@ +import { classifyPageView } from "@/services/analytics/pageClassification"; + +describe("classifyPageView", () => { + it("classifies the waves index page", () => { + expect(classifyPageView({ pathname: "/waves" })).toEqual({ + logicalPage: "waves_index", + pageGroup: "waves", + routePattern: "/waves", + trackingKey: "/waves", + }); + }); + + it("classifies wave detail pages", () => { + expect( + classifyPageView({ + pathname: "/waves/cbf4ca5f-06b3-4ea6-bf7d-b193a0699eed", + }) + ).toEqual({ + logicalPage: "wave_page", + pageGroup: "waves", + routePattern: "/waves/:waveId", + trackingKey: "/waves/cbf4ca5f-06b3-4ea6-bf7d-b193a0699eed", + }); + }); + + it("classifies wave drop detail pages from the drop query param", () => { + expect( + classifyPageView({ + pathname: "/waves/cbf4ca5f-06b3-4ea6-bf7d-b193a0699eed", + searchParams: new URLSearchParams("drop=drop-123"), + }) + ).toEqual({ + logicalPage: "wave_drop_detail", + pageGroup: "waves", + routePattern: "/waves/:waveId?drop=:dropId", + trackingKey: "/waves/cbf4ca5f-06b3-4ea6-bf7d-b193a0699eed?drop=drop-123", + }); + }); + + it("classifies profile root pages", () => { + expect(classifyPageView({ pathname: "/alice" })).toEqual({ + logicalPage: "profile_identity", + pageGroup: "profile", + routePattern: "/:handle", + trackingKey: "/alice", + }); + }); + + it("classifies known profile tab routes", () => { + expect(classifyPageView({ pathname: "/alice/collected" })).toEqual({ + logicalPage: "profile_collected", + pageGroup: "profile", + routePattern: "/:handle/collected", + trackingKey: "/alice/collected", + }); + }); + + it("does not misclassify reserved roots as profiles", () => { + expect(classifyPageView({ pathname: "/the-memes" })).toEqual({ + logicalPage: "the_memes", + pageGroup: "the_memes", + routePattern: "/the-memes", + trackingKey: "/the-memes", + }); + }); + + it("falls back to a generic profile subpage for unknown user subroutes", () => { + expect(classifyPageView({ pathname: "/alice/followers" })).toEqual({ + logicalPage: "profile_subpage", + pageGroup: "profile", + routePattern: "/:handle/:subpage", + trackingKey: "/alice/followers", + }); + }); +}); diff --git a/components/providers/MixpanelSetup.tsx b/components/providers/MixpanelSetup.tsx index 4acf8b4937..836abdb38d 100644 --- a/components/providers/MixpanelSetup.tsx +++ b/components/providers/MixpanelSetup.tsx @@ -9,21 +9,27 @@ import { initAnalytics, trackPageView, } from "@/services/analytics/mixpanel"; -import { usePathname } from "next/navigation"; +import { classifyPageView } from "@/services/analytics/pageClassification"; +import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, useRef } from "react"; export default function MixpanelSetup() { const pathname = usePathname(); + const searchParams = useSearchParams(); const { connectedProfile } = useAuth(); const { performanceConsent } = useCookieConsent(); - const lastTrackedPathRef = useRef(null); + const lastTrackedPageKeyRef = useRef(null); const identifiedProfileIdRef = useRef(null); const hasConsent = performanceConsent === true; + const pageView = classifyPageView({ + pathname, + searchParams, + }); useEffect(() => { if (!hasConsent) { disableAnalytics(); - lastTrackedPathRef.current = null; + lastTrackedPageKeyRef.current = null; identifiedProfileIdRef.current = null; return; } @@ -58,20 +64,23 @@ export default function MixpanelSetup() { }, [connectedProfile?.id, hasConsent]); useEffect(() => { - if (!hasConsent || !pathname) { + if (!hasConsent) { return; } - if (lastTrackedPathRef.current === pathname) { + if (lastTrackedPageKeyRef.current === pageView.trackingKey) { return; } - lastTrackedPathRef.current = pathname; + lastTrackedPageKeyRef.current = pageView.trackingKey; trackPageView(pathname, { has_connected_profile: connectedProfile?.id !== undefined && connectedProfile.id !== null, + logical_page: pageView.logicalPage, + page_group: pageView.pageGroup, + route_pattern: pageView.routePattern, }); - }, [connectedProfile?.id, hasConsent, pathname]); + }, [connectedProfile?.id, hasConsent, pageView, pathname]); return null; } diff --git a/services/analytics/pageClassification.ts b/services/analytics/pageClassification.ts new file mode 100644 index 0000000000..4cf1d037f7 --- /dev/null +++ b/services/analytics/pageClassification.ts @@ -0,0 +1,219 @@ +import { USER_PAGE_TABS } from "@/components/user/layout/userTabs.config"; + +type SearchParamsLike = + | Pick + | { get(name: string): string | null } + | null + | undefined; + +type PageViewClassification = { + readonly logicalPage: string; + readonly pageGroup: string; + readonly routePattern: string; + readonly trackingKey: string; +}; + +const RESERVED_TOP_LEVEL_ROUTE_SEGMENTS = new Set([ + "6529-gradient", + "about", + "accept-connection-sharing", + "access", + "api", + "author", + "blog", + "buidl", + "capital", + "casabatllo", + "category", + "cdn-cgi", + "city", + "consolidation-mapping-tool", + "delegation", + "delegation-mapping-tool", + "dispute-resolution", + "education", + "element_category", + "email-signatures", + "emma", + "error", + "feed", + "gm-or-die-small-mp4", + "meme-accounting", + "meme-calendar", + "meme-gas", + "meme-lab", + "messages", + "museum", + "network", + "news", + "nextgen", + "nft-activity", + "notifications", + "om", + "open-data", + "open-mobile", + "rememes", + "restricted", + "sentry-example-page", + "slide-page", + "the-memes", + "tools", + "waves", + "xtdh", +]); + +const PROFILE_ROUTE_CLASSIFICATIONS = new Map( + USER_PAGE_TABS.map((tab) => [ + tab.route.toLowerCase(), + { + logicalPage: + tab.route.length > 0 + ? `profile_${toAnalyticsIdentifier(tab.route)}` + : "profile_identity", + routePattern: tab.route.length > 0 ? `/:handle/${tab.route}` : "/:handle", + }, + ]) +); + +function toAnalyticsIdentifier(value: string): string { + return value + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "_") + .replaceAll(/^_+|_+$/g, ""); +} + +function classifyHomePage(): PageViewClassification { + return { + logicalPage: "home", + pageGroup: "home", + routePattern: "/", + trackingKey: "/", + }; +} + +function classifyWavesPage( + pathname: string, + segments: readonly string[], + searchParams: SearchParamsLike +): PageViewClassification { + if (segments.length === 1) { + return { + logicalPage: "waves_index", + pageGroup: "waves", + routePattern: "/waves", + trackingKey: pathname, + }; + } + + if (segments[1]?.toLowerCase() === "create") { + return { + logicalPage: "waves_create", + pageGroup: "waves", + routePattern: "/waves/create", + trackingKey: pathname, + }; + } + + const dropId = searchParams?.get("drop"); + if (dropId) { + return { + logicalPage: "wave_drop_detail", + pageGroup: "waves", + routePattern: "/waves/:waveId?drop=:dropId", + trackingKey: `${pathname}?drop=${dropId}`, + }; + } + + return { + logicalPage: "wave_page", + pageGroup: "waves", + routePattern: "/waves/:waveId", + trackingKey: pathname, + }; +} + +function classifyProfilePage( + pathname: string, + segments: readonly string[] +): PageViewClassification | null { + const handle = segments[0]?.toLowerCase(); + if (!handle || RESERVED_TOP_LEVEL_ROUTE_SEGMENTS.has(handle)) { + return null; + } + + const subroute = segments[1]?.toLowerCase() ?? ""; + const knownProfileRoute = PROFILE_ROUTE_CLASSIFICATIONS.get(subroute); + if (knownProfileRoute) { + return { + logicalPage: knownProfileRoute.logicalPage, + pageGroup: "profile", + routePattern: knownProfileRoute.routePattern, + trackingKey: pathname, + }; + } + + if (segments.length === 1) { + return { + logicalPage: "profile_identity", + pageGroup: "profile", + routePattern: "/:handle", + trackingKey: pathname, + }; + } + + return { + logicalPage: "profile_subpage", + pageGroup: "profile", + routePattern: "/:handle/:subpage", + trackingKey: pathname, + }; +} + +function classifyFallbackPage( + pathname: string, + segments: readonly string[] +): PageViewClassification { + const pageGroup = + segments.length > 0 ? toAnalyticsIdentifier(segments[0] ?? "") : "home"; + const logicalPage = + segments.length > 0 ? toAnalyticsIdentifier(segments.join("_")) : "home"; + + return { + logicalPage: logicalPage || "home", + pageGroup: pageGroup || "home", + routePattern: pathname, + trackingKey: pathname, + }; +} + +export function classifyPageView(args: { + pathname: string; + searchParams?: SearchParamsLike; +}): PageViewClassification { + const pathname = args.pathname.trim(); + if (pathname.length === 0 || pathname === "/") { + return classifyHomePage(); + } + + const normalizedPathname = pathname.startsWith("/") + ? pathname + : `/${pathname}`; + const segments = normalizedPathname.split("/").filter((segment) => segment); + const firstSegment = segments[0]?.toLowerCase(); + + if (firstSegment === "waves") { + return classifyWavesPage( + normalizedPathname, + segments, + args.searchParams ?? null + ); + } + + const profilePage = classifyProfilePage(normalizedPathname, segments); + if (profilePage) { + return profilePage; + } + + return classifyFallbackPage(normalizedPathname, segments); +} From ea159aa77ca4af72ac21c11a28dfb5d8d9a72521 Mon Sep 17 00:00:00 2001 From: GelatoGenesis Date: Mon, 9 Mar 2026 10:55:56 +0200 Subject: [PATCH 2/3] Fixed a regex security issue Signed-off-by: GelatoGenesis --- services/analytics/pageClassification.ts | 34 ++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/services/analytics/pageClassification.ts b/services/analytics/pageClassification.ts index 4cf1d037f7..33c3b3ce42 100644 --- a/services/analytics/pageClassification.ts +++ b/services/analytics/pageClassification.ts @@ -76,11 +76,35 @@ const PROFILE_ROUTE_CLASSIFICATIONS = new Map( ); function toAnalyticsIdentifier(value: string): string { - return value - .trim() - .toLowerCase() - .replaceAll(/[^a-z0-9]+/g, "_") - .replaceAll(/^_+|_+$/g, ""); + const normalized = value.trim().toLowerCase(); + let result = ""; + let previousWasUnderscore = false; + + for (const character of normalized) { + const isLowercaseLetter = character >= "a" && character <= "z"; + const isDigit = character >= "0" && character <= "9"; + + if (isLowercaseLetter || isDigit) { + result += character; + previousWasUnderscore = false; + continue; + } + + if (!previousWasUnderscore) { + result += "_"; + previousWasUnderscore = true; + } + } + + while (result.startsWith("_")) { + result = result.slice(1); + } + + while (result.endsWith("_")) { + result = result.slice(0, -1); + } + + return result; } function classifyHomePage(): PageViewClassification { From 0746f6665314d11cce13b3d8ee88f783bce70e26 Mon Sep 17 00:00:00 2001 From: GelatoGenesis Date: Mon, 9 Mar 2026 11:10:42 +0200 Subject: [PATCH 3/3] small sonar fix Signed-off-by: GelatoGenesis --- services/analytics/pageClassification.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analytics/pageClassification.ts b/services/analytics/pageClassification.ts index 33c3b3ce42..74085ddb87 100644 --- a/services/analytics/pageClassification.ts +++ b/services/analytics/pageClassification.ts @@ -223,7 +223,7 @@ export function classifyPageView(args: { const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`; - const segments = normalizedPathname.split("/").filter((segment) => segment); + const segments = normalizedPathname.split("/").filter(Boolean); const firstSegment = segments[0]?.toLowerCase(); if (firstSegment === "waves") {