brackt/app/routes/onboarding.tsx
Claude 1a2421e134
Add username onboarding prompt for new signups
New users (especially via OAuth/Google) are redirected to /onboarding
to choose a display username before accessing the app. Adds unique
constraint on users.username and case-insensitive uniqueness validation.

https://claude.ai/code/session_01UinBMAy9d6dQzzbcUAdq8J
2026-05-15 17:52:12 +00:00

95 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { redirect, Form } from "react-router";
import { auth } from "~/lib/auth.server";
import { findUserById, findUserByUsername, updateUser } from "~/models/user";
import type { Route } from "./+types/onboarding";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
export function meta(): Route.MetaDescriptors {
return [{ title: "Choose a Username - Brackt" }];
}
function suggestUsername(displayName: string | null): string {
if (!displayName) return "";
return displayName
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "")
.slice(0, 20);
}
const USERNAME_RE = /^[a-zA-Z0-9_-]{3,30}$/;
export async function loader({ request }: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return redirect("/login");
const user = await findUserById(session.user.id);
if (!user) return redirect("/login");
if (user.username) return redirect("/");
return { suggestion: suggestUsername(user.displayName) };
}
export async function action({ request }: Route.ActionArgs) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return redirect("/login");
const formData = await request.formData();
const username = (formData.get("username") as string | null)?.trim() ?? "";
if (!USERNAME_RE.test(username)) {
return {
error: "Username must be 330 characters and contain only letters, numbers, underscores, or hyphens.",
};
}
const existing = await findUserByUsername(username);
if (existing && existing.id !== session.user.id) {
return { error: "That username is already taken." };
}
await updateUser(session.user.id, { username });
return redirect("/");
}
export default function OnboardingPage({ loaderData, actionData }: Route.ComponentProps) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">Choose a username</CardTitle>
<CardDescription>
Pick a username that will be shown to other players. You can change it later in Settings.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
defaultValue={loaderData.suggestion}
placeholder="e.g. fantasy_king"
minLength={3}
maxLength={30}
autoFocus
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
330 characters. Letters, numbers, underscores, and hyphens only.
</p>
</div>
{actionData && "error" in actionData && (
<p className="text-sm text-destructive">{actionData.error}</p>
)}
<Button type="submit" className="w-full">
Continue
</Button>
</Form>
</CardContent>
</Card>
</div>
);
}