diff --git a/app/layout.tsx b/app/layout.tsx index c688d64..9fe719c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -96,8 +96,8 @@ export default function RootLayout({ -
+
{children}
diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index b92aa79..ea18a35 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -6,6 +6,8 @@ import Image from "next/image"; import { siteConfig } from "@/lib/config"; import { nav } from "@/lib/nav"; import { useTheme } from "@/contexts/ThemeContext"; +import { HeaderAuth } from "@/components/layout/HeaderAuth"; +import { MobileSignInLink } from "@/components/layout/MobileSignInLink"; import { Sun, Moon } from "lucide-react"; export function Header() { @@ -69,18 +71,7 @@ export function Header() { - - Sign In - - - Sign Up - + @@ -98,13 +89,7 @@ export function Header() { {item.label} ))} - setMobileOpen(false)} - > - Sign In - + setMobileOpen(false)} /> )} diff --git a/components/layout/HeaderAccountMenu.tsx b/components/layout/HeaderAccountMenu.tsx new file mode 100644 index 0000000..563de64 --- /dev/null +++ b/components/layout/HeaderAccountMenu.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useRef, useState } from "react"; +import { siteConfig } from "@/lib/config"; +import { accountInitial } from "@/lib/format/accountInitial"; +import { useClickOutside } from "@/hooks/useClickOutside"; + +type HeaderAccountMenuProps = { + email: string | undefined; + onLogout: () => Promise; +}; + +/** + * The signed-in header control: an avatar with the account's initial that + * opens a small menu — the session email, "Open app" into chat, and log out. + * Gives the marketing surface the logout it never had (chat#1850). + */ +export function HeaderAccountMenu({ email, onLogout }: HeaderAccountMenuProps) { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + useClickOutside(menuRef, () => setOpen(false)); + + return ( +
+ + {open && ( +
+ {email && ( +

+ {email} +

+ )} + + Open app + + +
+ )} +
+ ); +} diff --git a/components/layout/HeaderAuth.tsx b/components/layout/HeaderAuth.tsx new file mode 100644 index 0000000..86f6c1c --- /dev/null +++ b/components/layout/HeaderAuth.tsx @@ -0,0 +1,39 @@ +"use client"; + +import Link from "next/link"; +import { usePrivy } from "@privy-io/react-auth"; +import { siteConfig } from "@/lib/config"; +import { HeaderAccountMenu } from "@/components/layout/HeaderAccountMenu"; + +/** + * The header's auth cluster. Signed out (and while Privy initializes) it + * renders the Sign In / Sign Up pair; once a Privy session exists it flips to + * the account menu, so the header stops pretending the visitor is anonymous + * after they authenticated through the valuation flow (chat#1850). + */ +export function HeaderAuth() { + const { ready, authenticated, user, logout } = usePrivy(); + + if (ready && authenticated) { + return ( + + ); + } + + return ( + <> + + Sign In + + + Sign Up + + + ); +} diff --git a/components/layout/MobileSignInLink.tsx b/components/layout/MobileSignInLink.tsx new file mode 100644 index 0000000..75ea1e0 --- /dev/null +++ b/components/layout/MobileSignInLink.tsx @@ -0,0 +1,23 @@ +"use client"; + +import Link from "next/link"; +import { usePrivy } from "@privy-io/react-auth"; +import { siteConfig } from "@/lib/config"; + +/** + * The mobile menu's Sign In entry. Hidden for signed-in sessions — on mobile + * the account menu in the header bar already carries "Open app" and log out. + */ +export function MobileSignInLink({ onNavigate }: { onNavigate: () => void }) { + const { ready, authenticated } = usePrivy(); + if (ready && authenticated) return null; + return ( + + Sign In + + ); +} diff --git a/contexts/PrivyAuthProvider.tsx b/contexts/PrivyAuthProvider.tsx index 4afffbe..56b7ae1 100644 --- a/contexts/PrivyAuthProvider.tsx +++ b/contexts/PrivyAuthProvider.tsx @@ -6,7 +6,8 @@ import { siteConfig } from "@/lib/config"; /** * App-wide Privy auth provider (email-only, brand-themed). Rendered once at the - * root (app/layout.tsx) so any page can use the valuation flow directly — no + * root (app/layout.tsx), wrapping the header too, so any page can use the + * valuation flow directly and the header can reflect the session — no * per-page wrapper. Privy initializes on the client; the provider renders a * plain context on the server, so pages stay statically prerenderable * (chat#1798, chat#1814). Build fails if `NEXT_PUBLIC_PRIVY_APP_ID` is unset, so diff --git a/lib/format/__tests__/accountInitial.test.ts b/lib/format/__tests__/accountInitial.test.ts new file mode 100644 index 0000000..c827001 --- /dev/null +++ b/lib/format/__tests__/accountInitial.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { accountInitial } from "@/lib/format/accountInitial"; + +describe("accountInitial", () => { + it("returns the uppercased first letter of the email", () => { + expect(accountInitial("sweets@recoupable.dev")).toBe("S"); + }); + + it("keeps an already-uppercase first letter", () => { + expect(accountInitial("Ana@example.com")).toBe("A"); + }); + + it("skips non-alphanumeric leading characters", () => { + expect(accountInitial(".dots.first@example.com")).toBe("D"); + }); + + it("uses a digit when the email starts with one", () => { + expect(accountInitial("9lives@example.com")).toBe("9"); + }); + + it("falls back to ? when there is no email", () => { + expect(accountInitial(undefined)).toBe("?"); + expect(accountInitial(null)).toBe("?"); + expect(accountInitial("")).toBe("?"); + }); +}); diff --git a/lib/format/accountInitial.ts b/lib/format/accountInitial.ts new file mode 100644 index 0000000..8a1096d --- /dev/null +++ b/lib/format/accountInitial.ts @@ -0,0 +1,9 @@ +/** + * The single character shown in the header avatar for a signed-in account: + * the first alphanumeric character of the email, uppercased. "?" when no + * email is available so the avatar never renders empty. + */ +export function accountInitial(email: string | null | undefined): string { + const match = email?.match(/[a-z0-9]/i); + return match ? match[0].toUpperCase() : "?"; +}