Skip to content
Merged
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
179 changes: 179 additions & 0 deletions frontend/module/auth/register/RegisterPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

interface RegisterForm {
fullName: string;
email: string;
password: string;
confirmPassword: string;
}

interface FieldErrors {
fullName?: string;
email?: string;
password?: string;
confirmPassword?: string;
}

function getStrength(password: string): { label: string; color: string; width: string } {
if (password.length === 0) return { label: "", color: "", width: "w-0" };
let score = 0;
if (password.length >= 8) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
if (score <= 1) return { label: "Weak", color: "bg-red-500", width: "w-1/3" };
if (score <= 2) return { label: "Medium", color: "bg-yellow-500", width: "w-2/3" };
return { label: "Strong", color: "bg-green-500", width: "w-full" };
}

export default function RegisterPage() {
const router = useRouter();
const [form, setForm] = useState<RegisterForm>({
fullName: "",
email: "",
password: "",
confirmPassword: "",
});
const [errors, setErrors] = useState<FieldErrors>({});
const [serverError, setServerError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

const strength = getStrength(form.password);

function validate(): boolean {
const next: FieldErrors = {};
if (!form.fullName.trim()) next.fullName = "Full name is required";
if (!form.email.trim()) next.email = "Email is required";
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
next.email = "Enter a valid email address";
if (!form.password) next.password = "Password is required";
if (!form.confirmPassword) next.confirmPassword = "Please confirm your password";
else if (form.password !== form.confirmPassword)
next.confirmPassword = "Passwords do not match";
setErrors(next);
return Object.keys(next).length === 0;
}

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setServerError(null);
if (!validate()) return;
setLoading(true);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/auth/register`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fullName: form.fullName,
email: form.email,
password: form.password,
}),
}
);
if (res.status === 409) {
setServerError("An account with this email already exists.");
return;
}
if (!res.ok) {
setServerError("Something went wrong. Please try again.");
return;
}
router.push("/login?registered=true");
} catch {
setServerError("Network error. Please try again.");
} finally {
setLoading(false);
}
}

function field(
id: keyof RegisterForm,
label: string,
type: string,
autoComplete: string
) {
return (
<div>
<label htmlFor={id} className="mb-1 block text-sm font-medium text-gray-700">
{label}
</label>
<input
id={id}
type={type}
autoComplete={autoComplete}
value={form[id]}
onChange={(e) => setForm({ ...form, [id]: e.target.value })}
aria-describedby={errors[id] ? `${id}-error` : undefined}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors[id] && (
<p id={`${id}-error`} className="mt-1 text-xs text-red-600">
{errors[id]}
</p>
)}
</div>
);
}

return (
<main className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-md">
<h1 className="mb-6 text-2xl font-bold text-gray-900">Create account</h1>

{serverError && (
<p role="alert" className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
{serverError}
</p>
)}

<form onSubmit={handleSubmit} noValidate className="space-y-5">
{field("fullName", "Full name", "text", "name")}
{field("email", "Email", "email", "email")}

<div>
<label htmlFor="password" className="mb-1 block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
autoComplete="new-password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
aria-describedby={errors.password ? "password-error" : undefined}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{form.password && (
<div className="mt-1.5">
<div className="h-1.5 w-full rounded-full bg-gray-200">
<div className={`h-1.5 rounded-full transition-all ${strength.color} ${strength.width}`} />
</div>
<p className="mt-0.5 text-xs text-gray-500">{strength.label}</p>
</div>
)}
{errors.password && (
<p id="password-error" className="mt-1 text-xs text-red-600">
{errors.password}
</p>
)}
</div>

{field("confirmPassword", "Confirm password", "password", "new-password")}

<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60"
>
{loading ? "Creating account…" : "Create account"}
</button>
</form>
</div>
</main>
);
}
Loading