From 870cba003cf9a594596a3eeb0e7b8e1b4f53b5f0 Mon Sep 17 00:00:00 2001 From: Amr Gaber Date: Tue, 26 May 2026 23:56:22 -0500 Subject: [PATCH] feat(profile): optional display name on register + nudge banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend slice of #46. Login-by-username (#46 step 3) is deferred — it depends on auth-api work that hasn't shipped yet. - Add an optional Display-name input on /register, between confirm- password and the TOS checkbox. Empty submission is fine. If provided, PATCH /auth/me after the auto-login completes. Failures are swallowed so a transient PATCH error doesn't undo a successful registration — the /profile nudge below will re-prompt. - Add a nudge banner at the top of /profile when display_name is null. Dismissible per session via sessionStorage, auto-disappears once a name is set. Doesn't interrupt the cross-origin redirect flow because new users registering through a consumer-app link (e.g. hera-streamer-…) typically bounce out before reaching /profile. OAuth (Google/Steam) users already get display_name auto-populated from the provider profile by the auth-api, so the banner only fires for password registrants who skipped the optional field. --- src/pages/profile/profile-page.test.tsx | 64 +++++++++++++++++++++++++ src/pages/profile/profile-page.tsx | 35 +++++++++++++- src/pages/register/register-page.tsx | 31 ++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/pages/profile/profile-page.test.tsx b/src/pages/profile/profile-page.test.tsx index 62f63de..842b7f1 100644 --- a/src/pages/profile/profile-page.test.tsx +++ b/src/pages/profile/profile-page.test.tsx @@ -212,6 +212,70 @@ describe("ProfilePage display name", () => { }) }) +describe("ProfilePage display name nudge", () => { + it("shows the nudge banner when display_name is null", async () => { + window.sessionStorage.clear() + const me = authMeHandler({ + id: "u-1", + email: "player@example.com", + display_name: null, + avatar_url: null, + tos_accepted_at: "2026-01-01T00:00:00Z", + }) + server.use(me.handler, consentsHandler) + + await renderWithFileRoutes(<>, { initialLocation: "/profile" }) + + expect(await screen.findByTestId("display-name-nudge")).toBeInTheDocument() + }) + + it("hides the nudge banner when display_name is set", async () => { + window.sessionStorage.clear() + const me = authMeHandler({ + id: "u-1", + email: "player@example.com", + display_name: "ZeroEmpires", + avatar_url: null, + tos_accepted_at: "2026-01-01T00:00:00Z", + }) + server.use(me.handler, consentsHandler) + + await renderWithFileRoutes(<>, { initialLocation: "/profile" }) + + // Wait for the page to settle by finding a known element + await screen.findByLabelText("Display name") + expect(screen.queryByTestId("display-name-nudge")).not.toBeInTheDocument() + }) + + it("dismisses the nudge for the session when X is clicked", async () => { + window.sessionStorage.clear() + const me = authMeHandler({ + id: "u-1", + email: "player@example.com", + display_name: null, + avatar_url: null, + tos_accepted_at: "2026-01-01T00:00:00Z", + }) + server.use(me.handler, consentsHandler) + + await renderWithFileRoutes(<>, { initialLocation: "/profile" }) + + const nudge = await screen.findByTestId("display-name-nudge") + expect(nudge).toBeInTheDocument() + + const dismissBtn = screen.getByRole("button", { + name: /dismiss display name suggestion/i, + }) + const user = userEvent.setup() + await user.click(dismissBtn) + + expect(screen.queryByTestId("display-name-nudge")).not.toBeInTheDocument() + expect( + window.sessionStorage.getItem("cb_display_name_nudge_dismissed") + ).toBe("1") + }) +}) + describe("ProfilePage connected accounts", () => { it("shows Connect buttons for unlinked providers and the linked status for linked ones", async () => { const me = authMeHandler({ diff --git a/src/pages/profile/profile-page.tsx b/src/pages/profile/profile-page.tsx index 7e4dd2a..60a411d 100644 --- a/src/pages/profile/profile-page.tsx +++ b/src/pages/profile/profile-page.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react" import { Link, useNavigate } from "@tanstack/react-router" -import { LoaderCircle } from "lucide-react" +import { LoaderCircle, X } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -20,6 +20,8 @@ import { import type { ProviderConnection } from "@/lib/oauth-state" import { Route as ProfileRoute } from "@/routes/profile" +const DISPLAY_NAME_NUDGE_KEY = "cb_display_name_nudge_dismissed" + type ProviderId = "google" | "steam" const PROVIDERS: { id: ProviderId; label: string }[] = [ @@ -81,6 +83,18 @@ export function ProfilePage() { message: string remediation: string[] } | null>(null) + const [nudgeDismissed, setNudgeDismissed] = useState(() => + typeof window === "undefined" + ? false + : window.sessionStorage.getItem(DISPLAY_NAME_NUDGE_KEY) === "1" + ) + + const showDisplayNameNudge = !auth.displayName && !nudgeDismissed + + function dismissNudge() { + window.sessionStorage.setItem(DISPLAY_NAME_NUDGE_KEY, "1") + setNudgeDismissed(true) + } useEffect(() => { setDisplayName(auth.displayName ?? "") @@ -270,6 +284,25 @@ export function ProfilePage() { + {showDisplayNameNudge && ( +
+

+ You're showing up as your email across the site. Pick a display + name below to personalize your account. +

+ +
+ )}
Email diff --git a/src/pages/register/register-page.tsx b/src/pages/register/register-page.tsx index b124d91..ceeb26f 100644 --- a/src/pages/register/register-page.tsx +++ b/src/pages/register/register-page.tsx @@ -19,6 +19,8 @@ import { getErrorMessage } from "@/lib/api-errors" import { useAuth } from "@/lib/auth" import { submitConsents, type ConsentInput } from "@/lib/consent" +const DISPLAY_NAME_MAX_LENGTH = 100 + export function RegisterPage() { const auth = useAuth() const navigate = useNavigate() @@ -27,6 +29,7 @@ export function RegisterPage() { const [email, setEmail] = useState("") const [password, setPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("") + const [displayName, setDisplayName] = useState("") const [tosAccepted, setTosAccepted] = useState(false) const [analyticsConsent, setAnalyticsConsent] = useState(false) const [sessionReplayConsent, setSessionReplayConsent] = useState(false) @@ -71,6 +74,17 @@ export function RegisterPage() { } catch { // Swallow — user is signed up; they'll be prompted again on profile. } + const trimmedDisplayName = displayName.trim() + if (trimmedDisplayName) { + try { + await api.patch("auth/me", { + json: { display_name: trimmedDisplayName }, + }) + } catch { + // Swallow — user is signed up; the /profile nudge banner will + // prompt them to set it later if this PATCH failed. + } + } // Refresh the AuthContext (reads /auth/me with the new cookie) before // navigating, so the destination's beforeLoad sees isAuthenticated=true. await auth.checkAuth() @@ -136,6 +150,23 @@ export function RegisterPage() { autoComplete="new-password" />
+
+ + setDisplayName(e.target.value)} + maxLength={DISPLAY_NAME_MAX_LENGTH} + autoComplete="nickname" + /> +