Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ export default function RootLayout({
<body className="min-h-screen flex flex-col antialiased">
<ThemeProvider>
<HumanMachineProvider>
<Header />
<PrivyAuthProvider>
<Header />
<main className="flex-1">
<MachineContent>{children}</MachineContent>
</main>
Expand Down
23 changes: 4 additions & 19 deletions components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -69,18 +71,7 @@ export function Header() {
</svg>
</button>

<Link
href={siteConfig.appUrl}
className="hidden sm:inline-block text-[14px] font-ui font-medium text-(--foreground)/70 hover:text-(--foreground) transition-colors px-4 py-1.5 rounded-full border border-(--border) hover:border-(--foreground)/20"
>
Sign In
</Link>
<Link
href={siteConfig.appUrl}
className="bg-(--foreground) text-(--background) px-5 py-2 rounded-full text-[14px] font-ui font-semibold hover:opacity-90 transition-opacity"
>
Sign Up
</Link>
<HeaderAuth />
</div>
</div>

Expand All @@ -98,13 +89,7 @@ export function Header() {
{item.label}
</Link>
))}
<Link
href={siteConfig.appUrl}
className="block px-3 py-2.5 text-sm font-ui font-medium text-(--foreground)/70 sm:hidden"
onClick={() => setMobileOpen(false)}
>
Sign In
</Link>
<MobileSignInLink onNavigate={() => setMobileOpen(false)} />
</div>
</div>
)}
Expand Down
69 changes: 69 additions & 0 deletions components/layout/HeaderAccountMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
};

/**
* 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<HTMLDivElement>(null);
useClickOutside(menuRef, () => setOpen(false));

return (
<div ref={menuRef} className="relative">
<button
onClick={() => setOpen(o => !o)}
aria-label="Account menu"
aria-haspopup="menu"
aria-expanded={open}
className="flex h-8 w-8 items-center justify-center rounded-full bg-(--foreground) text-[13px] font-ui font-semibold text-(--background) transition-opacity hover:opacity-90"
>
{accountInitial(email)}
</button>
{open && (
<div
role="menu"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Screen-reader users can be told this is an ARIA menu, but keyboard interaction stays like plain links/buttons because menu keyboard behavior isn’t implemented. Consider either implementing full WAI-ARIA menu keyboard/focus handling or removing role="menu"/role="menuitem" so semantics match actual behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/layout/HeaderAccountMenu.tsx, line 36:

<comment>Screen-reader users can be told this is an ARIA menu, but keyboard interaction stays like plain links/buttons because menu keyboard behavior isn’t implemented. Consider either implementing full WAI-ARIA menu keyboard/focus handling or removing `role="menu"`/`role="menuitem"` so semantics match actual behavior.</comment>

<file context>
@@ -0,0 +1,69 @@
+      </button>
+      {open && (
+        <div
+          role="menu"
+          className="absolute right-0 top-full mt-2 w-56 rounded-xl bg-(--background) py-1.5"
+          style={{
</file context>

className="absolute right-0 top-full mt-2 w-56 rounded-xl bg-(--background) py-1.5"
style={{
boxShadow:
"0px 0px 0px 1px var(--border), 0px 4px 8px rgba(0,0,0,0.06), 0px 8px 16px -4px rgba(0,0,0,0.04)",
}}
>
{email && (
<p className="truncate px-3.5 pb-1.5 pt-1 text-[12px] text-(--foreground)/50">
{email}
</p>
)}
<a
href={siteConfig.appUrl}
role="menuitem"
className="block px-3.5 py-2 text-[13px] font-ui font-medium text-(--foreground) transition-colors hover:bg-(--foreground)/5"
>
Open app
</a>
<button
role="menuitem"
onClick={() => {
setOpen(false);
void onLogout();
}}
className="block w-full px-3.5 py-2 text-left text-[13px] font-ui font-medium text-(--foreground)/70 transition-colors hover:bg-(--foreground)/5"
>
Log out
</button>
</div>
)}
</div>
);
}
39 changes: 39 additions & 0 deletions components/layout/HeaderAuth.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HeaderAccountMenu email={user?.email?.address} onLogout={logout} />
);
}

return (
<>
<Link
href={siteConfig.appUrl}
className="hidden sm:inline-block text-[14px] font-ui font-medium text-(--foreground)/70 hover:text-(--foreground) transition-colors px-4 py-1.5 rounded-full border border-(--border) hover:border-(--foreground)/20"
>
Sign In
</Link>
<Link
href={siteConfig.appUrl}
className="bg-(--foreground) text-(--background) px-5 py-2 rounded-full text-[14px] font-ui font-semibold hover:opacity-90 transition-opacity"
>
Sign Up
</Link>
</>
);
}
23 changes: 23 additions & 0 deletions components/layout/MobileSignInLink.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link
href={siteConfig.appUrl}
className="block px-3 py-2.5 text-sm font-ui font-medium text-(--foreground)/70 sm:hidden"
onClick={onNavigate}
>
Sign In
</Link>
);
}
3 changes: 2 additions & 1 deletion contexts/PrivyAuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions lib/format/__tests__/accountInitial.test.ts
Original file line number Diff line number Diff line change
@@ -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("?");
});
});
9 changes: 9 additions & 0 deletions lib/format/accountInitial.ts
Original file line number Diff line number Diff line change
@@ -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() : "?";
}