diff --git a/__tests__/components/providers/MixpanelSetup.test.tsx b/__tests__/components/providers/MixpanelSetup.test.tsx index 7ce69c6cb3..704d08515b 100644 --- a/__tests__/components/providers/MixpanelSetup.test.tsx +++ b/__tests__/components/providers/MixpanelSetup.test.tsx @@ -8,7 +8,12 @@ const identifyMock = jest.fn(); const initAnalyticsMock = jest.fn(); const trackPageViewMock = jest.fn(); -let connectedProfile: { id: number } | null = null; +let connectedProfile: { + id: number; + normalised_handle?: string | null; + wallets?: Array<{ wallet: string }>; +} | null = null; +let fetchingProfile = false; let pathname = "/"; let performanceConsent: boolean | undefined = undefined; let searchParams = new URLSearchParams(); @@ -16,6 +21,7 @@ let searchParams = new URLSearchParams(); jest.mock("@/components/auth/Auth", () => ({ useAuth: () => ({ connectedProfile, + fetchingProfile, }), })); @@ -41,6 +47,7 @@ jest.mock("@/services/analytics/mixpanel", () => ({ describe("MixpanelSetup", () => { beforeEach(() => { connectedProfile = null; + fetchingProfile = false; pathname = "/"; performanceConsent = undefined; searchParams = new URLSearchParams(); @@ -134,6 +141,88 @@ describe("MixpanelSetup", () => { }); }); + it("tracks anonymous profile views separately from signed-in viewers", () => { + performanceConsent = true; + pathname = "/alice/collected"; + + render(); + + expect(trackPageViewMock).toHaveBeenCalledWith("/alice/collected", { + has_connected_profile: false, + logical_page: "profile_collected", + page_group: "profile", + profile_viewer_context: "anonymous", + route_pattern: "/:handle/collected", + }); + }); + + it("marks own profile tabs as self views", () => { + performanceConsent = true; + pathname = "/alice/collected"; + connectedProfile = { + id: 42, + normalised_handle: "alice", + wallets: [], + }; + + render(); + + expect(trackPageViewMock).toHaveBeenCalledWith("/alice/collected", { + has_connected_profile: true, + logical_page: "profile_collected", + page_group: "profile", + profile_viewer_context: "self", + route_pattern: "/:handle/collected", + }); + }); + + it("marks other users' profile tabs as other views", () => { + performanceConsent = true; + pathname = "/bob/collected"; + connectedProfile = { + id: 42, + normalised_handle: "alice", + wallets: [{ wallet: "0xabc" }], + }; + + render(); + + expect(trackPageViewMock).toHaveBeenCalledWith("/bob/collected", { + has_connected_profile: true, + logical_page: "profile_collected", + page_group: "profile", + profile_viewer_context: "other", + route_pattern: "/:handle/collected", + }); + }); + + it("waits for profile ownership data before tracking profile page views", () => { + performanceConsent = true; + pathname = "/alice/collected"; + fetchingProfile = true; + + const { rerender } = render(); + + expect(trackPageViewMock).not.toHaveBeenCalled(); + + fetchingProfile = false; + connectedProfile = { + id: 42, + normalised_handle: "alice", + wallets: [], + }; + rerender(); + + expect(trackPageViewMock).toHaveBeenCalledTimes(1); + expect(trackPageViewMock).toHaveBeenCalledWith("/alice/collected", { + has_connected_profile: true, + logical_page: "profile_collected", + page_group: "profile", + profile_viewer_context: "self", + route_pattern: "/:handle/collected", + }); + }); + it("identifies connected profiles and resets when consent is revoked", () => { performanceConsent = true; pathname = "/waves"; diff --git a/__tests__/components/user/layout/UserPageTabs.test.tsx b/__tests__/components/user/layout/UserPageTabs.test.tsx index d26400fb11..d2cc56bc4e 100644 --- a/__tests__/components/user/layout/UserPageTabs.test.tsx +++ b/__tests__/components/user/layout/UserPageTabs.test.tsx @@ -1,7 +1,6 @@ -import { AuthContext } from "@/components/auth/Auth"; import UserPageTabs from "@/components/user/layout/UserPageTabs"; import { USER_PAGE_TAB_IDS } from "@/components/user/layout/userTabs.config"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { useParams, usePathname, @@ -15,6 +14,10 @@ jest.mock("next/navigation", () => ({ useSearchParams: jest.fn(), useParams: jest.fn(), })); +const useAuthMock = jest.fn(); +jest.mock("@/components/auth/Auth", () => ({ + useAuth: () => useAuthMock(), +})); const capacitorMock = jest.fn(); jest.mock("@/hooks/useCapacitor", () => ({ __esModule: true, @@ -32,14 +35,24 @@ const { useCookieConsent, } = require("@/components/cookies/CookieConsentContext"); -const renderTabs = ( - showWaves: boolean, - isIos: boolean, - country: string = "US", - connectedProfile: any = null -) => { - (useRouter as jest.Mock).mockReturnValue({ push: jest.fn() }); - (usePathname as jest.Mock).mockReturnValue("/[user]"); +const renderTabs = ({ + showWaves, + isIos, + country = "US", + connectedProfile = null, + fetchingProfile = false, + pathname = "/[user]", +}: { + showWaves: boolean; + isIos: boolean; + country?: string; + connectedProfile?: any; + fetchingProfile?: boolean; + pathname?: string; +}) => { + const router = { push: jest.fn(), replace: jest.fn() }; + (useRouter as jest.Mock).mockReturnValue(router); + (usePathname as jest.Mock).mockReturnValue(pathname); (useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams()); (useParams as jest.Mock).mockReturnValue({ user: "testuser" }); capacitorMock.mockReturnValue({ isIos }); @@ -49,16 +62,20 @@ const renderTabs = ( consent: jest.fn(), reject: jest.fn(), }); - return render( - - - - ); + useAuthMock.mockReturnValue({ + showWaves, + connectedProfile, + fetchingProfile, + }); + return { + router, + ...render(), + }; }; describe("UserPageTabs", () => { it("filters tabs based on context and platform", () => { - renderTabs(false, false); + renderTabs({ showWaves: false, isIos: false }); const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); expect(tabs).not.toContain(USER_PAGE_TAB_IDS.BRAIN); expect(tabs).not.toContain("stats"); @@ -66,41 +83,90 @@ describe("UserPageTabs", () => { }); it("hides subscriptions tab on iOS non-US", () => { - renderTabs(true, true, "CA"); + renderTabs({ showWaves: true, isIos: true, country: "CA" }); const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); expect(tabs).not.toContain(USER_PAGE_TAB_IDS.SUBSCRIPTIONS); }); it("shows proxy tab when viewing own profile by handle", () => { - renderTabs(false, false, "US", { - normalised_handle: "testuser", - wallets: [], + renderTabs({ + showWaves: false, + isIos: false, + connectedProfile: { + normalised_handle: "testuser", + wallets: [], + }, }); const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); expect(tabs).toContain(USER_PAGE_TAB_IDS.PROXY); }); it("shows proxy tab when viewing own profile by wallet", () => { - renderTabs(false, false, "US", { - normalised_handle: "otherhandle", - wallets: [{ wallet: "testuser" }], + renderTabs({ + showWaves: false, + isIos: false, + connectedProfile: { + normalised_handle: "otherhandle", + wallets: [{ wallet: "testuser" }], + }, }); const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); expect(tabs).toContain(USER_PAGE_TAB_IDS.PROXY); }); it("hides proxy tab when viewing another user's profile", () => { - renderTabs(false, false, "US", { - normalised_handle: "anotheruser", - wallets: [{ wallet: "0xSomeOtherWallet" }], + renderTabs({ + showWaves: false, + isIos: false, + connectedProfile: { + normalised_handle: "anotheruser", + wallets: [{ wallet: "0xSomeOtherWallet" }], + }, }); const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); expect(tabs).not.toContain(USER_PAGE_TAB_IDS.PROXY); }); it("hides proxy tab when not connected", () => { - renderTabs(false, false, "US", null); + renderTabs({ showWaves: false, isIos: false }); const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); expect(tabs).not.toContain(USER_PAGE_TAB_IDS.PROXY); }); + + it("keeps the proxy tab visible while ownership is still loading", () => { + const { router } = renderTabs({ + showWaves: false, + isIos: false, + pathname: "/testuser/proxy", + fetchingProfile: true, + }); + + const tabs = screen.getAllByTestId("tab").map((t) => t.textContent); + expect(tabs).toContain(USER_PAGE_TAB_IDS.PROXY); + expect(router.replace).not.toHaveBeenCalled(); + }); + + it("redirects away from the proxy tab after loading when the profile is not owned", async () => { + const { rerender, router } = renderTabs({ + showWaves: false, + isIos: false, + pathname: "/testuser/proxy", + fetchingProfile: true, + }); + + useAuthMock.mockReturnValue({ + showWaves: false, + connectedProfile: { + normalised_handle: "someoneelse", + wallets: [{ wallet: "0xSomeOtherWallet" }], + }, + fetchingProfile: false, + }); + + rerender(); + + await waitFor(() => { + expect(router.replace).toHaveBeenCalledWith("/testuser"); + }); + }); }); diff --git a/__tests__/helpers/ProfileHelpers.extra.test.ts b/__tests__/helpers/ProfileHelpers.extra.test.ts index 10af8321b9..6ffa808998 100644 --- a/__tests__/helpers/ProfileHelpers.extra.test.ts +++ b/__tests__/helpers/ProfileHelpers.extra.test.ts @@ -1,59 +1,163 @@ -import { getProfileConnectedStatus, profileAndConsolidationsToProfileMin } from '@/helpers/ProfileHelpers'; -import { ProfileConnectedStatus } from '@/entities/IProfile'; +import { + getProfileConnectedStatus, + getProfileViewerContext, + isOwnProfileRoute, + profileAndConsolidationsToProfileMin, +} from "@/helpers/ProfileHelpers"; +import { ProfileConnectedStatus } from "@/entities/IProfile"; -describe('getProfileConnectedStatus', () => { - it('returns NOT_CONNECTED when profile missing', () => { - expect(getProfileConnectedStatus({ profile: null, isProxy: false })).toBe(ProfileConnectedStatus.NOT_CONNECTED); +describe("getProfileConnectedStatus", () => { + it("returns NOT_CONNECTED when profile missing", () => { + expect(getProfileConnectedStatus({ profile: null, isProxy: false })).toBe( + ProfileConnectedStatus.NOT_CONNECTED + ); }); - it('returns PROXY when isProxy true', () => { - expect(getProfileConnectedStatus({ profile: {} as any, isProxy: true })).toBe(ProfileConnectedStatus.PROXY); + it("returns PROXY when isProxy true", () => { + expect( + getProfileConnectedStatus({ profile: {} as any, isProxy: true }) + ).toBe(ProfileConnectedStatus.PROXY); }); - it('returns NO_PROFILE when handle missing', () => { + it("returns NO_PROFILE when handle missing", () => { const profile = { handle: null } as any; - expect(getProfileConnectedStatus({ profile, isProxy: false })).toBe(ProfileConnectedStatus.NO_PROFILE); + expect(getProfileConnectedStatus({ profile, isProxy: false })).toBe( + ProfileConnectedStatus.NO_PROFILE + ); }); - it('returns HAVE_PROFILE otherwise', () => { - const profile = { handle: 'user' } as any; - expect(getProfileConnectedStatus({ profile, isProxy: false })).toBe(ProfileConnectedStatus.HAVE_PROFILE); + it("returns HAVE_PROFILE otherwise", () => { + const profile = { handle: "user" } as any; + expect(getProfileConnectedStatus({ profile, isProxy: false })).toBe( + ProfileConnectedStatus.HAVE_PROFILE + ); }); }); -describe('profileAndConsolidationsToProfileMin', () => { - it('returns null when id or handle missing', () => { - expect(profileAndConsolidationsToProfileMin({ profile: { id: '', handle: '' } as any })).toBeNull(); +describe("isOwnProfileRoute", () => { + it("matches the connected profile by normalized handle", () => { + expect( + isOwnProfileRoute({ + connectedProfile: { + normalised_handle: "alice", + wallets: [], + } as any, + handleOrWallet: "Alice", + }) + ).toBe(true); }); - it('maps fields correctly', () => { + it("matches the connected profile by wallet address", () => { + expect( + isOwnProfileRoute({ + connectedProfile: { + normalised_handle: "alice", + wallets: [{ wallet: "0xabc123" }], + } as any, + handleOrWallet: "0xAbC123", + }) + ).toBe(true); + }); + + it("returns false for another profile", () => { + expect( + isOwnProfileRoute({ + connectedProfile: { + normalised_handle: "alice", + wallets: [{ wallet: "0xabc123" }], + } as any, + handleOrWallet: "bob", + }) + ).toBe(false); + }); +}); + +describe("getProfileViewerContext", () => { + it("returns anonymous when no viewer is connected", () => { + expect( + getProfileViewerContext({ + connectedProfile: null, + handleOrWallet: "alice", + }) + ).toBe("anonymous"); + }); + + it("returns self for the connected user's profile", () => { + expect( + getProfileViewerContext({ + connectedProfile: { + normalised_handle: "alice", + wallets: [], + } as any, + handleOrWallet: "alice", + }) + ).toBe("self"); + }); + + it("returns other for another signed-in user's profile", () => { + expect( + getProfileViewerContext({ + connectedProfile: { + normalised_handle: "alice", + wallets: [], + } as any, + handleOrWallet: "bob", + }) + ).toBe("other"); + }); + + it("returns null when the profile target is missing", () => { + expect( + getProfileViewerContext({ + connectedProfile: null, + handleOrWallet: " ", + }) + ).toBeNull(); + }); +}); + +describe("profileAndConsolidationsToProfileMin", () => { + it("returns null when id or handle missing", () => { + expect( + profileAndConsolidationsToProfileMin({ + profile: { id: "", handle: "" } as any, + }) + ).toBeNull(); + }); + + it("maps fields correctly", () => { const profile = { - id: '1', - handle: 'user', - pfp: 'img', - banner1: 'b1', - banner2: 'b2', + id: "1", + handle: "user", + pfp: "img", + banner1: "b1", + banner2: "b2", cic: 1, rep: 2, tdh: 3, level: 4, - primary_wallet: '0x1' + primary_wallet: "0x1", } as any; const res = profileAndConsolidationsToProfileMin({ profile })!; expect(res).toEqual({ - id: '1', - handle: 'user', - pfp: 'img', - banner1_color: 'b1', - banner2_color: 'b2', + id: "1", + handle: "user", + pfp: "img", + banner1_color: null, + banner2_color: null, cic: 1, rep: 2, tdh: 3, + tdh_rate: undefined, + xtdh: undefined, + xtdh_rate: undefined, level: 4, archived: false, - primary_address: '0x1', + primary_address: "0x1", active_main_stage_submission_ids: undefined, - winner_main_stage_drop_ids: [] + winner_main_stage_drop_ids: undefined, + is_wave_creator: undefined, + artist_of_prevote_cards: undefined, }); }); }); diff --git a/components/providers/MixpanelSetup.tsx b/components/providers/MixpanelSetup.tsx index 836abdb38d..87afec57e1 100644 --- a/components/providers/MixpanelSetup.tsx +++ b/components/providers/MixpanelSetup.tsx @@ -2,6 +2,7 @@ import { useAuth } from "@/components/auth/Auth"; import { useCookieConsent } from "@/components/cookies/CookieConsentContext"; +import { getProfileViewerContext } from "@/helpers/ProfileHelpers"; import { clearIdentity, disableAnalytics, @@ -13,10 +14,14 @@ import { classifyPageView } from "@/services/analytics/pageClassification"; import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, useRef } from "react"; +const getProfileRouteTarget = (pathname: string): string | null => { + return pathname.split("/").find((segment) => segment.length > 0) ?? null; +}; + export default function MixpanelSetup() { const pathname = usePathname(); const searchParams = useSearchParams(); - const { connectedProfile } = useAuth(); + const { connectedProfile, fetchingProfile } = useAuth(); const { performanceConsent } = useCookieConsent(); const lastTrackedPageKeyRef = useRef(null); const identifiedProfileIdRef = useRef(null); @@ -25,6 +30,12 @@ export default function MixpanelSetup() { pathname, searchParams, }); + const profileViewerContext = pageView.logicalPage.startsWith("profile_") + ? getProfileViewerContext({ + connectedProfile, + handleOrWallet: getProfileRouteTarget(pathname), + }) + : null; useEffect(() => { if (!hasConsent) { @@ -68,6 +79,10 @@ export default function MixpanelSetup() { return; } + if (pageView.logicalPage.startsWith("profile_") && fetchingProfile) { + return; + } + if (lastTrackedPageKeyRef.current === pageView.trackingKey) { return; } @@ -78,9 +93,17 @@ export default function MixpanelSetup() { connectedProfile?.id !== undefined && connectedProfile.id !== null, logical_page: pageView.logicalPage, page_group: pageView.pageGroup, + profile_viewer_context: profileViewerContext ?? undefined, route_pattern: pageView.routePattern, }); - }, [connectedProfile?.id, hasConsent, pageView, pathname]); + }, [ + connectedProfile?.id, + fetchingProfile, + hasConsent, + pageView, + pathname, + profileViewerContext, + ]); return null; } diff --git a/components/user/layout/UserPageTabs.tsx b/components/user/layout/UserPageTabs.tsx index 51cde919ff..91d2fcfc20 100644 --- a/components/user/layout/UserPageTabs.tsx +++ b/components/user/layout/UserPageTabs.tsx @@ -1,7 +1,8 @@ "use client"; -import { AuthContext } from "@/components/auth/Auth"; +import { useAuth } from "@/components/auth/Auth"; import { useCookieConsent } from "@/components/cookies/CookieConsentContext"; +import { isOwnProfileRoute } from "@/helpers/ProfileHelpers"; import useCapacitor from "@/hooks/useCapacitor"; import { faChevronLeft, @@ -14,19 +15,12 @@ import { useRouter, useSearchParams, } from "next/navigation"; -import { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import UserPageTab from "./UserPageTab"; import { DEFAULT_USER_PAGE_TAB, USER_PAGE_TABS, - type UserPageTabConfig, + USER_PAGE_TAB_IDS, type UserPageTabKey, type UserPageVisibilityContext, getUserPageTabByRoute, @@ -72,11 +66,6 @@ const resolveTabFromPath = (pathname: string): UserPageTabKey => { return match?.id ?? DEFAULT_TAB; }; -const filterVisibleTabs = ( - tabs: readonly UserPageTabConfig[], - context: UserPageVisibilityContext -) => tabs.filter((tab) => (tab.isVisible ? tab.isVisible(context) : true)); - export default function UserPageTabs() { const pathname = usePathname() ?? ""; const router = useRouter(); @@ -86,16 +75,13 @@ export default function UserPageTabs() { const searchString = searchParams?.toString() ?? ""; const capacitor = useCapacitor(); const { country } = useCookieConsent(); - const { showWaves, connectedProfile } = useContext(AuthContext); + const { showWaves, connectedProfile, fetchingProfile } = useAuth(); const isOwnProfile = useMemo(() => { - if (!connectedProfile || !handleOrWallet) return false; - const lower = handleOrWallet.toLowerCase(); - if (connectedProfile.normalised_handle === lower) return true; - return ( - connectedProfile.wallets?.some((w) => w.wallet.toLowerCase() === lower) ?? - false - ); + return isOwnProfileRoute({ + connectedProfile, + handleOrWallet, + }); }, [connectedProfile, handleOrWallet]); const visibilityContext = useMemo( @@ -119,9 +105,24 @@ export default function UserPageTabs() { [pathname] ); + const preserveProxyTabWhileOwnershipLoads = + fetchingProfile && + !connectedProfile && + resolvedTabFromPath === USER_PAGE_TAB_IDS.PROXY; + const visibleTabs = useMemo( - () => filterVisibleTabs(USER_PAGE_TABS, visibilityContext), - [visibilityContext] + () => + USER_PAGE_TABS.filter((tab) => { + if ( + preserveProxyTabWhileOwnershipLoads && + tab.id === USER_PAGE_TAB_IDS.PROXY + ) { + return true; + } + + return tab.isVisible ? tab.isVisible(visibilityContext) : true; + }), + [preserveProxyTabWhileOwnershipLoads, visibilityContext] ); const resolvedTabIsVisible = useMemo( diff --git a/helpers/ProfileHelpers.ts b/helpers/ProfileHelpers.ts index 3489fc2e91..374e6d936e 100644 --- a/helpers/ProfileHelpers.ts +++ b/helpers/ProfileHelpers.ts @@ -2,6 +2,72 @@ import { ProfileConnectedStatus } from "@/entities/IProfile"; import type { ProfileMinWithoutSubs } from "./ProfileTypes"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { getBannerColorValue } from "@/helpers/profile-banner.helpers"; + +type ProfileIdentityForOwnership = + | Pick + | null + | undefined; + +type ProfileViewerContext = "self" | "other" | "anonymous"; + +const normalizeProfileTarget = ( + handleOrWallet: string | null | undefined +): string | null => { + if (typeof handleOrWallet !== "string") { + return null; + } + + const normalizedTarget = handleOrWallet.trim().toLowerCase(); + return normalizedTarget.length > 0 ? normalizedTarget : null; +}; + +export const isOwnProfileRoute = ({ + connectedProfile, + handleOrWallet, +}: { + readonly connectedProfile: ProfileIdentityForOwnership; + readonly handleOrWallet: string | null | undefined; +}): boolean => { + const normalizedTarget = normalizeProfileTarget(handleOrWallet); + if (!connectedProfile || !normalizedTarget) { + return false; + } + + if (connectedProfile.normalised_handle?.toLowerCase() === normalizedTarget) { + return true; + } + + return ( + connectedProfile.wallets?.some( + (wallet) => wallet.wallet.toLowerCase() === normalizedTarget + ) ?? false + ); +}; + +export const getProfileViewerContext = ({ + connectedProfile, + handleOrWallet, +}: { + readonly connectedProfile: ProfileIdentityForOwnership; + readonly handleOrWallet: string | null | undefined; +}): ProfileViewerContext | null => { + const normalizedTarget = normalizeProfileTarget(handleOrWallet); + if (!normalizedTarget) { + return null; + } + + if (!connectedProfile) { + return "anonymous"; + } + + return isOwnProfileRoute({ + connectedProfile, + handleOrWallet: normalizedTarget, + }) + ? "self" + : "other"; +}; + export const getProfileConnectedStatus = ({ profile, isProxy,