brackt/app/routes/onboarding.tsx

111 lines
4 KiB
TypeScript
Raw Normal View History

import { redirect, Form } from "react-router";
import { auth } from "~/lib/auth.server";
import { findUserById, findUserByUsername, updateUser, USERNAME_RE } 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" }];
}
export function suggestUsername(displayName: string | null): string {
if (!displayName) return "";
const cleaned = displayName
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "")
.slice(0, 20);
return cleaned.length >= 3 ? cleaned : "";
}
export function safeRedirectTo(value: string | null): string {
if (!value) return "/";
return value.startsWith("/") && !value.startsWith("//") ? value : "/";
}
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("/");
const redirectTo = safeRedirectTo(new URL(request.url).searchParams.get("redirectTo"));
return { suggestion: suggestUsername(user.displayName), redirectTo };
}
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() ?? "";
const redirectTo = safeRedirectTo(formData.get("redirectTo") as string | null);
if (!USERNAME_RE.test(username)) {
return {
error: "Username must be 330 characters and contain only letters, numbers, underscores, or hyphens.",
redirectTo,
};
}
const existing = await findUserByUsername(username);
if (existing && existing.id !== session.user.id) {
return { error: "That username is already taken.", redirectTo };
}
try {
await updateUser(session.user.id, { username });
} catch {
return { error: "That username is already taken.", redirectTo };
}
return redirect(redirectTo);
}
export default function OnboardingPage({ loaderData, actionData }: Route.ComponentProps) {
const redirectTo = actionData && "redirectTo" in actionData ? actionData.redirectTo : loaderData.redirectTo;
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">
<input type="hidden" name="redirectTo" value={redirectTo} />
<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>
);
}