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..74085ddb87
--- /dev/null
+++ b/services/analytics/pageClassification.ts
@@ -0,0 +1,243 @@
+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 {
+ 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 {
+ 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(Boolean);
+ 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);
+}