Files
pargar/frontend/src/components/register-form.tsx
T

421 lines
13 KiB
TypeScript
Raw Normal View History

"use client";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, ArrowRight, Loader2, RefreshCw, UserPlus } from "lucide-react";
import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api";
import type { User } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import {
validateEmail,
validateName,
validatePassword,
clampPhoneInput,
normalizePhone,
PHONE_INPUT_MAX_LEN,
validatePhone,
validateSecurityQA,
validateUsername,
} from "@/lib/validation";
const STEPS = [
{ id: 1, title: "هویت", hint: "نام، شناسه و تلفن" },
{ id: 2, title: "امنیت", hint: "ایمیل و سوال امنیتی" },
{ id: 3, title: "گذرواژه", hint: "رمز و کد امنیتی" },
] as const;
type CaptchaChallenge = {
challengeId: string;
imageBase64: string;
};
type RegisterFormProps = {
endpoint: string;
submitLabel: string;
description?: string;
};
function Field({
id,
label,
children,
}: {
id: string;
label: string;
children: ReactNode;
}) {
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
export function RegisterForm({ endpoint, submitLabel, description }: RegisterFormProps) {
const router = useRouter();
const setSession = useAuth((s) => s.setSession);
const [step, setStep] = useState(1);
const [name, setName] = useState("");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [phone, setPhone] = useState("");
const [securityQuestion, setSecurityQuestion] = useState("");
const [securityAnswer, setSecurityAnswer] = useState("");
const [captchaAnswer, setCaptchaAnswer] = useState("");
const [challenge, setChallenge] = useState<CaptchaChallenge | null>(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadCaptcha = useCallback(async () => {
setCaptchaLoading(true);
setCaptchaAnswer("");
try {
const data = await http.get<CaptchaChallenge>("/api/auth/captcha");
setChallenge(data);
} catch (err) {
setChallenge(null);
setError(toUserError(err, "بارگذاری کد امنیتی ممکن نشد"));
} finally {
setCaptchaLoading(false);
}
}, []);
useEffect(() => {
if (step === 3) {
void loadCaptcha();
}
}, [step, loadCaptcha]);
const goNext = () => {
setError(null);
if (step === 1) {
const nErr = validateName(name);
if (nErr) {
setError(nErr);
return;
}
const uErr = validateUsername(username);
if (uErr) {
setError(uErr);
return;
}
const pErr = validatePhone(phone);
if (pErr) {
setError(pErr);
return;
}
setStep(2);
return;
}
if (step === 2) {
const eErr = validateEmail(email);
if (eErr) {
setError(eErr);
return;
}
const sErr = validateSecurityQA(securityQuestion, securityAnswer);
if (sErr) {
setError(sErr);
return;
}
setStep(3);
}
};
const goBack = () => {
setError(null);
setStep((s) => Math.max(1, s - 1));
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (step < 3) {
goNext();
return;
}
setError(null);
const pwErr = validatePassword(password, passwordConfirm);
if (pwErr) {
setError(pwErr);
return;
}
if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید");
void loadCaptcha();
return;
}
setLoading(true);
try {
const data = await http.post<{ accessToken: string; refreshToken: string; user: User }>(
endpoint,
{
name: name.trim(),
username: username.trim().toLowerCase(),
email: email.trim().toLowerCase(),
password,
passwordConfirm,
phone: normalizePhone(phone),
securityQuestion: securityQuestion.trim(),
securityAnswer: securityAnswer.trim(),
challengeId: challenge.challengeId,
captchaAnswer,
},
);
setSession(data.accessToken, data.refreshToken, data.user);
router.push("/cap");
} catch (err) {
setError(toUserError(err, "ثبت‌نام ناموفق بود"));
void loadCaptcha();
} finally {
setLoading(false);
}
};
const currentStep = STEPS[step - 1];
const progressPct = (step / STEPS.length) * 100;
return (
<div className="mx-auto w-full max-w-md space-y-6">
{description && (
<p className="text-center text-sm leading-relaxed text-muted-foreground">{description}</p>
)}
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="font-bold text-foreground">
گام {step} از {STEPS.length}
</span>
<span className="text-muted-foreground">{currentStep?.hint}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
<ol className="grid grid-cols-3 gap-2" aria-label="مراحل ثبت‌نام">
{STEPS.map((s) => {
const active = step === s.id;
const done = step > s.id;
return (
<li
key={s.id}
className={cn(
"rounded-lg px-2 py-2 text-center text-xs font-medium transition-colors",
active && "bg-primary/10 text-primary",
done && "text-primary",
!active && !done && "text-muted-foreground",
)}
aria-current={active ? "step" : undefined}
>
{s.title}
</li>
);
})}
</ol>
</div>
<form onSubmit={(ev) => void submit(ev)} className="space-y-4">
{step === 1 && (
<>
<Field id="reg-name" label="نام و نام خانوادگی">
<Input
id="reg-name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
autoFocus
/>
</Field>
<Field id="reg-username" label="شناسه کاربری">
<Input
id="reg-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
dir="ltr"
className="text-left"
autoComplete="username"
/>
</Field>
<Field id="reg-phone" label="شماره تلفن">
<Input
id="reg-phone"
value={phone}
onChange={(e) => setPhone(clampPhoneInput(e.target.value))}
dir="ltr"
className="text-left"
placeholder="0912…"
autoComplete="tel"
inputMode="tel"
maxLength={PHONE_INPUT_MAX_LEN}
/>
</Field>
</>
)}
{step === 2 && (
<>
<Field id="reg-email" label="ایمیل">
<Input
id="reg-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
dir="ltr"
className="text-left"
autoComplete="email"
autoFocus
/>
</Field>
<Field id="reg-secQ" label="سوال امنیتی">
<Input
id="reg-secQ"
value={securityQuestion}
onChange={(e) => setSecurityQuestion(e.target.value)}
placeholder="مثلاً نام مدرسهٔ اول؟"
/>
</Field>
<Field id="reg-secA" label="جواب امنیتی">
<PasswordInput
id="reg-secA"
value={securityAnswer}
onChange={(e) => setSecurityAnswer(e.target.value)}
autoComplete="off"
dir="rtl"
placeholder="جواب سوال امنیتی"
/>
</Field>
</>
)}
{step === 3 && (
<>
<Field id="reg-password" label="رمز عبور">
<PasswordInput
id="reg-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
autoComplete="new-password"
autoFocus
placeholder="حداقل ۸ نویسه"
/>
</Field>
<Field id="reg-password-confirm" label="تکرار رمز عبور">
<PasswordInput
id="reg-password-confirm"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
minLength={8}
autoComplete="new-password"
placeholder="همان رمز را دوباره وارد کنید"
/>
</Field>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="reg-captcha">کد امنیتی</Label>
<button
type="button"
onClick={() => void loadCaptcha()}
disabled={captchaLoading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${captchaLoading ? "animate-spin" : ""}`} />
تازه‌سازی
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{captchaLoading || !challenge?.imageBase64 ? (
<span className="text-sm text-muted-foreground">…</span>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
)}
</div>
<Input
id="reg-captcha"
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
</>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex items-stretch gap-2.5 pt-1">
{step > 1 && (
<Button
type="button"
variant="outline"
size="lg"
className="h-12 shrink-0 px-4"
onClick={goBack}
disabled={loading}
>
<ArrowRight className="h-4 w-4" />
قبلی
</Button>
)}
{step < 3 ? (
<Button type="submit" variant="gradient" className="h-12 min-w-0 flex-1" size="lg">
بعدی
<ArrowLeft className="h-4 w-4" />
</Button>
) : (
<Button
type="submit"
variant="gradient"
className="h-12 min-w-0 flex-1"
size="lg"
disabled={loading || captchaLoading || !challenge}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
{submitLabel}
</Button>
)}
</div>
</form>
<p className="text-center text-sm text-muted-foreground">
حساب دارید؟{" "}
<Link href="/login" className="font-medium text-primary underline-offset-4 hover:underline">
ورود
</Link>
</p>
</div>
);
}