From 6e3c7b89beaaa287562e6ac22c18c7e660a15ca4 Mon Sep 17 00:00:00 2001 From: jeromehardaway Date: Tue, 12 May 2026 22:42:10 -0400 Subject: [PATCH] feat(header): Add global user menu with logout to all header variants Closes #1054. Adds a new component to all three header variants (header, header-01, header-02). When signed in, users see an avatar button that opens a dropdown with profile, settings, and logout actions. When signed out, the header shows a "Sign in" link. While the session is loading, a skeleton placeholder renders to prevent FOUC. - New src/components/header/user-menu.tsx with useSession() state handling, click-outside + Escape close, and a hardened signOut handler that surfaces failures via window.alert (matches the existing profile-page pattern). - header-01 picks up filterMenuByAuth so the main menu hides auth-only items for unauthenticated users, like the other variants. --- src/components/header/user-menu.tsx | 144 ++++++++++++++++++++++++++++ src/layouts/headers/header-01.tsx | 36 ++++--- src/layouts/headers/header-02.tsx | 20 +++- src/layouts/headers/header.tsx | 27 +++++- 4 files changed, 207 insertions(+), 20 deletions(-) create mode 100644 src/components/header/user-menu.tsx diff --git a/src/components/header/user-menu.tsx b/src/components/header/user-menu.tsx new file mode 100644 index 000000000..7efc6434c --- /dev/null +++ b/src/components/header/user-menu.tsx @@ -0,0 +1,144 @@ +import { useClickOutside } from "@hooks"; +import clsx from "clsx"; +import Image from "next/image"; +import Link from "next/link"; +import { signOut, useSession } from "next-auth/react"; +import { useState } from "react"; + +type TProps = { + /** Tone of surrounding header — affects placeholder/avatar contrast on dark headers. */ + mode?: "light" | "dark"; + className?: string; +}; + +const UserMenu = ({ mode = "dark", className }: TProps) => { + const { data: session, status } = useSession(); + const [open, setOpen] = useState(false); + const ref = useClickOutside(() => setOpen(false)); + + const handleLogout = async () => { + setOpen(false); + try { + await signOut({ callbackUrl: "/login" }); + } catch (error) { + console.error("[UserMenu] signOut failed:", error); + window.alert( + "Logout failed. Please try again, or clear your browser cookies for this site." + ); + } + }; + + if (status === "loading") { + return ( +
- setOffcanvas(false)} menu={menu} /> + setOffcanvas(false)} + menu={filteredMenu} + /> ); }; diff --git a/src/layouts/headers/header-02.tsx b/src/layouts/headers/header-02.tsx index 1ac851178..65e935ff2 100644 --- a/src/layouts/headers/header-02.tsx +++ b/src/layouts/headers/header-02.tsx @@ -1,12 +1,13 @@ +import UserMenu from "@components/header/user-menu"; import Logo from "@components/logo"; import MainMenu from "@components/menu/main-menu"; import menu, { filterMenuByAuth } from "@data/menu"; import { useSticky } from "@hooks"; import BurgerButton from "@ui/burger-button"; import clsx from "clsx"; -import { useSession } from "next-auth/react"; import dynamic from "next/dynamic"; import { useRouter } from "next/router"; +import { useSession } from "next-auth/react"; import { useEffect, useMemo, useState } from "react"; const MobileMenu = dynamic(() => import("../../components/menu/mobile-menu"), { @@ -51,11 +52,16 @@ const Header = ({ shadow, fluid }: TProps) => { fluid && "tw-max-w-full tw-px-3.8 3xl:tw-px-37" )} > - + -
+
+ setOffcanvas(true)} label="Toggle Menu" @@ -65,7 +71,11 @@ const Header = ({ shadow, fluid }: TProps) => {
- setOffcanvas(false)} menu={filteredMenu} /> + setOffcanvas(false)} + menu={filteredMenu} + /> ); }; diff --git a/src/layouts/headers/header.tsx b/src/layouts/headers/header.tsx index 0c5645e6b..57d6222e7 100644 --- a/src/layouts/headers/header.tsx +++ b/src/layouts/headers/header.tsx @@ -1,3 +1,4 @@ +import UserMenu from "@components/header/user-menu"; import Logo from "@components/logo"; import MainMenu from "@components/menu/main-menu"; import Social01 from "@components/socials/social-01"; @@ -7,9 +8,9 @@ import BurgerButton from "@ui/burger-button"; import Button from "@ui/button"; import CountdownTimer from "@ui/countdown-timer/layout-03"; import clsx from "clsx"; -import { useSession } from "next-auth/react"; import dynamic from "next/dynamic"; import { useRouter } from "next/router"; +import { useSession } from "next-auth/react"; import { useEffect, useMemo, useState } from "react"; const MobileMenu = dynamic(() => import("../../components/menu/mobile-menu"), { @@ -91,14 +92,28 @@ const Header = ({ shadow, fluid }: TProps) => {
- + - + 2026 Cohort Active
+ {
- setOffcanvas(false)} menu={filteredMenu} /> + setOffcanvas(false)} + menu={filteredMenu} + /> ); };