From fd8f44127898c2b00c32a431ef6f718e5317c843 Mon Sep 17 00:00:00 2001 From: jeromehardaway Date: Thu, 11 Jun 2026 12:39:52 -0400 Subject: [PATCH] feat(profile): Back profile tab state with URL query Derive the active profile tab from router.query.tab instead of local useState so reloads keep the current tab and tabs can be deep-linked (e.g. /profile/?tab=settings). Tab clicks shallow-push the query so getServerSideProps does not re-run, and back/forward navigation works because state is derived from the URL on every render. Invalid or absent ?tab= values fall back to command-center via the new resolveProfileTab helper; non-owners requesting the owner-only settings tab fall back the same way. Closes #1057 --- __tests__/lib/profile-tabs.test.ts | 26 ++++++++++++++++++++++++++ src/lib/profile-tabs.ts | 15 +++++++++++++++ src/pages/profile/[id].tsx | 14 +++++++++++--- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 __tests__/lib/profile-tabs.test.ts create mode 100644 src/lib/profile-tabs.ts diff --git a/__tests__/lib/profile-tabs.test.ts b/__tests__/lib/profile-tabs.test.ts new file mode 100644 index 000000000..61cbfba48 --- /dev/null +++ b/__tests__/lib/profile-tabs.test.ts @@ -0,0 +1,26 @@ +import { resolveProfileTab } from "@/lib/profile-tabs"; +import { PROFILE_TABS } from "@/types/profile"; + +describe("resolveProfileTab", () => { + it("returns each valid tab id unchanged", () => { + for (const tab of PROFILE_TABS) { + expect(resolveProfileTab(tab.id)).toBe(tab.id); + } + }); + + it("defaults to command-center when the value is absent", () => { + expect(resolveProfileTab(undefined)).toBe("command-center"); + }); + + it("defaults to command-center for garbage values", () => { + expect(resolveProfileTab("not-a-tab")).toBe("command-center"); + expect(resolveProfileTab("")).toBe("command-center"); + expect(resolveProfileTab("SETTINGS")).toBe("command-center"); + }); + + it("uses the first entry when the query value is an array", () => { + expect(resolveProfileTab(["settings", "arsenal"])).toBe("settings"); + expect(resolveProfileTab(["bogus", "settings"])).toBe("command-center"); + expect(resolveProfileTab([])).toBe("command-center"); + }); +}); diff --git a/src/lib/profile-tabs.ts b/src/lib/profile-tabs.ts new file mode 100644 index 000000000..870b876ce --- /dev/null +++ b/src/lib/profile-tabs.ts @@ -0,0 +1,15 @@ +import { PROFILE_TABS, type ProfileTab } from "@/types/profile"; + +const VALID_TAB_IDS = new Set(PROFILE_TABS.map((tab) => tab.id)); + +/** + * Resolves a `?tab=` query value to a valid ProfileTab. + * Falls back to "command-center" when the value is absent or invalid. + */ +export function resolveProfileTab(queryValue: string | string[] | undefined): ProfileTab { + const value = Array.isArray(queryValue) ? queryValue[0] : queryValue; + if (value && VALID_TAB_IDS.has(value)) { + return value as ProfileTab; + } + return "command-center"; +} diff --git a/src/pages/profile/[id].tsx b/src/pages/profile/[id].tsx index 6a12fc297..814669e88 100644 --- a/src/pages/profile/[id].tsx +++ b/src/pages/profile/[id].tsx @@ -4,9 +4,9 @@ import { useMount } from "@hooks"; import Layout01 from "@layout/layout-01"; import Spinner from "@ui/spinner"; import type { GetServerSideProps, NextPage } from "next"; +import { useRouter } from "next/router"; import { getServerSession } from "next-auth/next"; import { signOut } from "next-auth/react"; -import { useState } from "react"; import { ActivityFeed, GitHubReadme, @@ -25,6 +25,7 @@ import useGitHubProfile from "@/hooks/use-github-profile"; import useLearningStats from "@/hooks/use-learning-stats"; import useProfileForm from "@/hooks/use-profile-form"; import prisma from "@/lib/prisma"; +import { resolveProfileTab } from "@/lib/profile-tabs"; import { options } from "@/pages/api/auth/options"; import type { ProfileTab, ProfileUser } from "@/types/profile"; @@ -44,7 +45,10 @@ type PageWithLayout = NextPage & { const MemberProfile: PageWithLayout = ({ user, isOwner }) => { const mounted = useMount(); - const [activeTab, setActiveTab] = useState("command-center"); + const router = useRouter(); + const requestedTab = resolveProfileTab(router.query.tab); + // Settings is owner-only; fall back for non-owners deep-linking ?tab=settings + const activeTab = requestedTab === "settings" && !isOwner ? "command-center" : requestedTab; // Pass the target user's ID to hooks so they fetch that member's data const targetId = isOwner ? undefined : user.id; @@ -60,6 +64,10 @@ const MemberProfile: PageWithLayout = ({ user, isOwner }) => { ); } + const handleTabChange = (tab: ProfileTab) => { + void router.push({ query: { ...router.query, tab } }, undefined, { shallow: true }); + }; + const handleLogout = async () => { try { await signOut({ callbackUrl: "/login" }); @@ -101,7 +109,7 @@ const MemberProfile: PageWithLayout = ({ user, isOwner }) => { onLogout={handleLogout} /> - + {/* Command Center — GitHub overview */} {activeTab === "command-center" && (