766 lines
27 KiB
TypeScript
766 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import {
|
||
AlertTriangle,
|
||
ArrowRight,
|
||
KeyRound,
|
||
Loader2,
|
||
MessageSquareMore,
|
||
Smartphone,
|
||
} from "lucide-react";
|
||
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||
import OtpCodeField from "@/components/OtpCodeField";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { useAuth } from "@/contexts/AuthContext";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
import { api } from "@/lib/api";
|
||
import { Link, Navigate, useNavigate } from "@/lib/router";
|
||
import { resolveErrorMessage } from "@/lib/utils";
|
||
|
||
type AuthStep = "mobile" | "password" | "otp_login" | "otp_register" | "register_details";
|
||
|
||
const OTP_LENGTH = 5;
|
||
const USERNAME_REGEX = /^[A-Za-z0-9._-]{3,30}$/;
|
||
|
||
const sanitizeUsername = (value: string) => value.replace(/[^A-Za-z0-9._-]/g, "");
|
||
const sanitizeText = (value: string) => value.trim();
|
||
const normalizeDigits = (value: string) =>
|
||
value
|
||
.replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0))
|
||
.replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660));
|
||
const sanitizeMobile = (value: string) => normalizeDigits(value).replace(/[^\d]/g, "");
|
||
|
||
const createEmptyRegisterForm = () => ({
|
||
username: "",
|
||
email: "",
|
||
password: "",
|
||
first_name: "",
|
||
last_name: "",
|
||
student_id: "",
|
||
year_of_study: "",
|
||
major: null as string | null,
|
||
university: null as string | null,
|
||
});
|
||
|
||
function GoogleIcon() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24">
|
||
<path
|
||
d="M21.805 10.023h-9.72v3.955h5.57c-.24 1.272-.96 2.35-2.042 3.07v2.548h3.3c1.933-1.78 3.042-4.4 3.042-7.506 0-.692-.062-1.357-.15-2.067Z"
|
||
fill="#4285F4"
|
||
/>
|
||
<path
|
||
d="M12.085 22c2.79 0 5.13-.925 6.84-2.504l-3.3-2.548c-.924.617-2.103.986-3.54.986-2.705 0-4.99-1.823-5.807-4.28H2.87v2.626A10.33 10.33 0 0 0 12.085 22Z"
|
||
fill="#34A853"
|
||
/>
|
||
<path
|
||
d="M6.278 13.654A6.214 6.214 0 0 1 5.95 11.7c0-.68.117-1.34.328-1.954V7.12H2.87A10.31 10.31 0 0 0 1.75 11.7c0 1.65.39 3.218 1.12 4.58l3.408-2.626Z"
|
||
fill="#FBBC05"
|
||
/>
|
||
<path
|
||
d="M12.085 5.466c1.52 0 2.882.522 3.955 1.55l2.966-2.966C17.21 2.387 14.874 1.4 12.085 1.4A10.33 10.33 0 0 0 2.87 7.12l3.408 2.626c.818-2.457 3.103-4.28 5.807-4.28Z"
|
||
fill="#EA4335"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export default function Auth() {
|
||
const navigate = useNavigate();
|
||
const { toast } = useToast();
|
||
const { login, loginWithOtp, isAuthenticated } = useAuth();
|
||
const [step, setStep] = useState<AuthStep>("mobile");
|
||
const [authLoading, setAuthLoading] = useState(false);
|
||
const [mobile, setMobile] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
const [otpCode, setOtpCode] = useState("");
|
||
const [lookupState, setLookupState] = useState<{ exists: boolean; has_password: boolean } | null>(null);
|
||
const [otpCooldowns, setOtpCooldowns] = useState<Record<"login" | "register", number>>({
|
||
login: 0,
|
||
register: 0,
|
||
});
|
||
const [registerForm, setRegisterForm] = useState(createEmptyRegisterForm);
|
||
|
||
useEffect(() => {
|
||
const timer = window.setInterval(() => {
|
||
setOtpCooldowns((current) => ({
|
||
login: Math.max(current.login - 1, 0),
|
||
register: Math.max(current.register - 1, 0),
|
||
}));
|
||
}, 1000);
|
||
return () => window.clearInterval(timer);
|
||
}, []);
|
||
|
||
const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||
const data = await api.getMajorsPaged(params);
|
||
return {
|
||
count: data.count,
|
||
results: data.results.map((major) => ({ value: String(major.code), label: major.label })),
|
||
};
|
||
}, []);
|
||
|
||
const loadUniversities = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||
const data = await api.getUniversitiesPaged(params);
|
||
return {
|
||
count: data.count,
|
||
results: data.results.map((university) => ({ value: String(university.code), label: university.label })),
|
||
};
|
||
}, []);
|
||
const majorsLoading = false;
|
||
const universitiesLoading = false;
|
||
|
||
const stepMeta = useMemo(() => {
|
||
switch (step) {
|
||
case "password":
|
||
return {
|
||
title: "رمز عبور حساب",
|
||
description: "برای این شماره موبایل حساب فعال پیدا شد. رمز عبور را وارد کنید یا روش ورود را به کد پیامکی تغییر دهید.",
|
||
};
|
||
case "otp_login":
|
||
return {
|
||
title: "ورود با کد پیامکی",
|
||
description: "کد ۵ رقمی ارسالشده به موبایل را وارد کنید تا وارد حساب خود شوید.",
|
||
};
|
||
case "otp_register":
|
||
return {
|
||
title: "تایید موبایل",
|
||
description: "ابتدا موبایل شما با کد پیامکی تایید میشود، سپس فرم تکمیل اطلاعات نمایش داده خواهد شد.",
|
||
};
|
||
case "register_details":
|
||
return {
|
||
title: "تکمیل اطلاعات حساب",
|
||
description: "موبایل شما تایید شد. حالا اطلاعات تکمیلی و رمز عبور را وارد کنید تا ثبتنام نهایی شود.",
|
||
};
|
||
default:
|
||
return {
|
||
title: "ورود و ثبتنام",
|
||
description: "ابتدا شماره موبایل را وارد کنید تا مسیر مناسب برای ورود یا ثبتنام به شما نمایش داده شود.",
|
||
};
|
||
}
|
||
}, [step]);
|
||
|
||
if (isAuthenticated) {
|
||
return <Navigate to="/profile" replace />;
|
||
}
|
||
|
||
const updateRegisterForm = <K extends keyof ReturnType<typeof createEmptyRegisterForm>>(
|
||
key: K,
|
||
value: ReturnType<typeof createEmptyRegisterForm>[K],
|
||
) => {
|
||
setRegisterForm((current) => ({ ...current, [key]: value }));
|
||
};
|
||
|
||
const resetToMobileStep = () => {
|
||
setStep("mobile");
|
||
setPassword("");
|
||
setOtpCode("");
|
||
setLookupState(null);
|
||
setRegisterForm(createEmptyRegisterForm());
|
||
};
|
||
|
||
const ensureValidMobile = (value: string) => {
|
||
const normalizedMobile = sanitizeMobile(value);
|
||
if (normalizedMobile.length !== 11 || !normalizedMobile.startsWith("09")) {
|
||
toast({
|
||
title: "شماره موبایل نامعتبر است",
|
||
description: "لطفاً شماره موبایل را به شکل 09xxxxxxxxx وارد کنید.",
|
||
variant: "destructive",
|
||
});
|
||
return null;
|
||
}
|
||
return normalizedMobile;
|
||
};
|
||
|
||
const sendOtpAndAdvance = async (mode: "login" | "register", mobileValue = mobile) => {
|
||
const normalizedMobile = ensureValidMobile(mobileValue);
|
||
if (!normalizedMobile) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setAuthLoading(true);
|
||
const payload = await api.sendOtp({ mobile: normalizedMobile, mode });
|
||
setMobile(normalizedMobile);
|
||
setOtpCode("");
|
||
setOtpCooldowns((current) => ({
|
||
...current,
|
||
[mode]: Math.min(payload.expires_in_seconds, 120),
|
||
}));
|
||
setStep(mode === "login" ? "otp_login" : "otp_register");
|
||
toast({
|
||
title: "کد تایید ارسال شد",
|
||
description: payload.message,
|
||
variant: "success",
|
||
});
|
||
} catch (error: unknown) {
|
||
toast({
|
||
title: "ارسال کد ناموفق بود",
|
||
description: resolveErrorMessage(error, "ارسال کد پیامکی انجام نشد."),
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setAuthLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleMobileStep = async (event: React.FormEvent) => {
|
||
event.preventDefault();
|
||
const normalizedMobile = ensureValidMobile(mobile);
|
||
if (!normalizedMobile) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setAuthLoading(true);
|
||
const lookup = await api.checkMobile(normalizedMobile);
|
||
setMobile(normalizedMobile);
|
||
setLookupState(lookup);
|
||
setPassword("");
|
||
setOtpCode("");
|
||
|
||
if (lookup.exists && lookup.has_password) {
|
||
setStep("password");
|
||
return;
|
||
}
|
||
|
||
await sendOtpAndAdvance(lookup.exists ? "login" : "register", normalizedMobile);
|
||
} catch (error: unknown) {
|
||
toast({
|
||
title: "بررسی شماره موبایل ناموفق بود",
|
||
description: resolveErrorMessage(error, "امکان ادامه با این شماره موبایل وجود ندارد."),
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setAuthLoading(false);
|
||
}
|
||
};
|
||
|
||
const handlePasswordLogin = async (event: React.FormEvent) => {
|
||
event.preventDefault();
|
||
if (!password) {
|
||
toast({
|
||
title: "رمز عبور لازم است",
|
||
description: "برای ادامه، رمز عبور حساب را وارد کنید.",
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setAuthLoading(true);
|
||
await login(mobile, password);
|
||
toast({
|
||
title: "ورود انجام شد",
|
||
description: "خوش آمدید. حساب شما آماده استفاده است.",
|
||
variant: "success",
|
||
});
|
||
navigate("/profile");
|
||
} catch (error: unknown) {
|
||
toast({
|
||
title: "ورود ناموفق بود",
|
||
description: resolveErrorMessage(error, "اطلاعات ورود صحیح نیست."),
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setAuthLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleOtpLogin = async (event: React.FormEvent) => {
|
||
event.preventDefault();
|
||
if (otpCode.length !== OTP_LENGTH) {
|
||
toast({
|
||
title: "کد تایید ناقص است",
|
||
description: "کد ۵ رقمی پیامکشده را کامل وارد کنید.",
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setAuthLoading(true);
|
||
await loginWithOtp(mobile, otpCode);
|
||
toast({
|
||
title: "ورود پیامکی انجام شد",
|
||
description: "با موفقیت وارد حساب کاربری خود شدید.",
|
||
variant: "success",
|
||
});
|
||
navigate("/profile");
|
||
} catch (error: unknown) {
|
||
toast({
|
||
title: "ورود با کد ناموفق بود",
|
||
description: resolveErrorMessage(error, "کد واردشده معتبر نیست."),
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setAuthLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleRegisterOtpVerification = async (event: React.FormEvent) => {
|
||
event.preventDefault();
|
||
if (otpCode.length !== OTP_LENGTH) {
|
||
toast({
|
||
title: "کد تایید ناقص است",
|
||
description: "کد ۵ رقمی پیامکشده را کامل وارد کنید.",
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setAuthLoading(true);
|
||
await api.verifyRegisterOtp({ mobile, code: otpCode });
|
||
setStep("register_details");
|
||
toast({
|
||
title: "موبایل تایید شد",
|
||
description: "حالا اطلاعات تکمیلی حساب را وارد کنید.",
|
||
variant: "success",
|
||
});
|
||
} catch (error: unknown) {
|
||
toast({
|
||
title: "تایید کد ناموفق بود",
|
||
description: resolveErrorMessage(error, "کد واردشده معتبر نیست."),
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setAuthLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleRegister = async (event: React.FormEvent) => {
|
||
event.preventDefault();
|
||
|
||
if (!USERNAME_REGEX.test(registerForm.username)) {
|
||
toast({
|
||
title: "نام کاربری نامعتبر است",
|
||
description: "نام کاربری باید ۳ تا ۳۰ کاراکتر و فقط شامل حروف لاتین، عدد، نقطه، آندرلاین یا خط تیره باشد.",
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (registerForm.password.length < 8) {
|
||
toast({
|
||
title: "رمز عبور کوتاه است",
|
||
description: "رمز عبور باید حداقل ۸ کاراکتر داشته باشد.",
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (registerForm.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(registerForm.email.trim())) {
|
||
toast({
|
||
title: "ایمیل نامعتبر است",
|
||
description: "اگر ایمیل وارد میکنید، فرمت آن باید صحیح باشد.",
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setAuthLoading(true);
|
||
await api.register({
|
||
username: registerForm.username,
|
||
mobile,
|
||
code: otpCode,
|
||
password: registerForm.password,
|
||
email: registerForm.email ? sanitizeText(registerForm.email) : null,
|
||
first_name: registerForm.first_name ? sanitizeText(registerForm.first_name) : null,
|
||
last_name: registerForm.last_name ? sanitizeText(registerForm.last_name) : null,
|
||
student_id: registerForm.student_id ? sanitizeMobile(registerForm.student_id) : null,
|
||
year_of_study: registerForm.year_of_study ? Number(registerForm.year_of_study) : null,
|
||
major: registerForm.major,
|
||
university: registerForm.university,
|
||
});
|
||
|
||
toast({
|
||
title: "ثبتنام کامل شد",
|
||
description: "اکنون میتوانید با موبایل و رمز عبور یا کد پیامکی وارد شوید.",
|
||
variant: "success",
|
||
});
|
||
setLookupState({ exists: true, has_password: true });
|
||
setPassword("");
|
||
setRegisterForm(createEmptyRegisterForm());
|
||
setOtpCode("");
|
||
setStep("password");
|
||
} catch (error: unknown) {
|
||
toast({
|
||
title: "ثبتنام ناموفق بود",
|
||
description: resolveErrorMessage(error, "اطلاعات ارسالی قابل پذیرش نیست."),
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setAuthLoading(false);
|
||
}
|
||
};
|
||
|
||
const renderStepHeader = () =>
|
||
step !== "mobile" ? (
|
||
<div className="mb-6 flex items-center justify-between rounded-[1.5rem] border border-border/70 bg-muted/25 p-4">
|
||
<div className="text-right">
|
||
<p className="text-xs text-muted-foreground">شماره موبایل انتخابشده</p>
|
||
<p className="mt-1 font-semibold" dir="ltr">
|
||
{mobile}
|
||
</p>
|
||
</div>
|
||
<Button type="button" variant="ghost" className="rounded-xl text-sm" onClick={resetToMobileStep}>
|
||
<ArrowRight className="ml-2 h-4 w-4" />
|
||
تغییر شماره
|
||
</Button>
|
||
</div>
|
||
) : null;
|
||
|
||
const renderMobileStep = () => (
|
||
<form className="space-y-5" onSubmit={handleMobileStep}>
|
||
<div>
|
||
<Label htmlFor="mobile" className="mb-2 block text-right">
|
||
شماره موبایل
|
||
</Label>
|
||
<Input
|
||
id="mobile"
|
||
type="tel"
|
||
dir="ltr"
|
||
inputMode="numeric"
|
||
maxLength={11}
|
||
value={mobile}
|
||
onChange={(event) => setMobile(sanitizeMobile(event.target.value))}
|
||
placeholder="09xxxxxxxxx"
|
||
className="h-12 rounded-2xl"
|
||
/>
|
||
</div>
|
||
|
||
<Button type="submit" className="h-12 w-full rounded-2xl" disabled={authLoading}>
|
||
{authLoading ? (
|
||
<>
|
||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||
در حال بررسی...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Smartphone className="ml-2 h-4 w-4" />
|
||
ادامه
|
||
</>
|
||
)}
|
||
</Button>
|
||
|
||
<div className="relative">
|
||
<div className="absolute inset-0 flex items-center">
|
||
<span className="w-full border-t border-border/70" />
|
||
</div>
|
||
<div className="relative flex justify-center text-xs uppercase">
|
||
<span className="bg-background px-3 text-muted-foreground">یا</span>
|
||
</div>
|
||
</div>
|
||
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="h-12 w-full rounded-2xl border-border/70 bg-background/80"
|
||
onClick={() => void api.startGoogleLogin()}
|
||
>
|
||
<GoogleIcon />
|
||
<span>ادامه با حساب گوگل</span>
|
||
</Button>
|
||
</form>
|
||
);
|
||
|
||
const renderPasswordStep = () => (
|
||
<form className="space-y-4" onSubmit={handlePasswordLogin}>
|
||
{renderStepHeader()}
|
||
|
||
<div>
|
||
<Label htmlFor="password" className="mb-2 block text-right">
|
||
رمز عبور
|
||
</Label>
|
||
<Input
|
||
id="password"
|
||
type="password"
|
||
value={password}
|
||
onChange={(event) => setPassword(event.target.value)}
|
||
className="h-12 rounded-2xl"
|
||
/>
|
||
</div>
|
||
|
||
<Button type="submit" className="h-12 w-full rounded-2xl" disabled={authLoading}>
|
||
{authLoading ? (
|
||
<>
|
||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||
در حال ورود...
|
||
</>
|
||
) : (
|
||
<>
|
||
<KeyRound className="ml-2 h-4 w-4" />
|
||
ورود با رمز عبور
|
||
</>
|
||
)}
|
||
</Button>
|
||
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="h-12 w-full rounded-2xl"
|
||
disabled={authLoading || otpCooldowns.login > 0}
|
||
onClick={() => void sendOtpAndAdvance("login")}
|
||
>
|
||
{otpCooldowns.login > 0 ? `ارسال مجدد تا ${otpCooldowns.login} ثانیه` : "ورود با کد پیامکی"}
|
||
</Button>
|
||
|
||
<Link
|
||
to="/reset-password"
|
||
className="block text-right text-sm text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
|
||
>
|
||
رمز عبور را فراموش کردهام
|
||
</Link>
|
||
</form>
|
||
);
|
||
|
||
const renderOtpStep = (mode: "login" | "register") => {
|
||
const isLogin = mode === "login";
|
||
|
||
return (
|
||
<form className="space-y-4" onSubmit={isLogin ? handleOtpLogin : handleRegisterOtpVerification}>
|
||
{renderStepHeader()}
|
||
|
||
<div>
|
||
<Label className="mb-3 block text-right">کد ۵ رقمی پیامکی</Label>
|
||
<OtpCodeField value={otpCode} onChange={(value) => setOtpCode(normalizeDigits(value))} disabled={authLoading} />
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-3 sm:flex-row-reverse">
|
||
<Button type="submit" className="h-12 flex-1 rounded-2xl" disabled={authLoading || otpCode.length !== OTP_LENGTH}>
|
||
{authLoading ? (
|
||
<>
|
||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||
در حال بررسی...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Smartphone className="ml-2 h-4 w-4" />
|
||
{isLogin ? "ورود با کد" : "تایید کد و ادامه"}
|
||
</>
|
||
)}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="h-12 rounded-2xl"
|
||
disabled={authLoading || otpCooldowns[mode] > 0}
|
||
onClick={() => void sendOtpAndAdvance(mode)}
|
||
>
|
||
{otpCooldowns[mode] > 0 ? `ارسال مجدد تا ${otpCooldowns[mode]} ثانیه` : "ارسال مجدد کد"}
|
||
</Button>
|
||
</div>
|
||
|
||
{isLogin && lookupState?.has_password ? (
|
||
<Button type="button" variant="ghost" className="h-11 w-full rounded-2xl" onClick={() => setStep("password")}>
|
||
بازگشت به ورود با رمز عبور
|
||
</Button>
|
||
) : null}
|
||
</form>
|
||
);
|
||
};
|
||
|
||
const renderRegisterDetailsStep = () => (
|
||
<form className="space-y-5" onSubmit={handleRegister}>
|
||
{renderStepHeader()}
|
||
|
||
<div className="rounded-[1.5rem] border border-emerald-500/20 bg-emerald-500/10 p-4 text-right text-sm text-emerald-900 dark:text-emerald-100">
|
||
این شماره موبایل با موفقیت تایید شده است. برای ثبتنام فقط کافی است اطلاعات باقیمانده را تکمیل کنید.
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div>
|
||
<Label htmlFor="register-first-name" className="mb-2 block text-right">
|
||
نام
|
||
</Label>
|
||
<Input
|
||
id="register-first-name"
|
||
value={registerForm.first_name}
|
||
onChange={(event) => updateRegisterForm("first_name", event.target.value)}
|
||
className="h-12 rounded-2xl"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="register-last-name" className="mb-2 block text-right">
|
||
نام خانوادگی
|
||
</Label>
|
||
<Input
|
||
id="register-last-name"
|
||
value={registerForm.last_name}
|
||
onChange={(event) => updateRegisterForm("last_name", event.target.value)}
|
||
className="h-12 rounded-2xl"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div>
|
||
<Label htmlFor="register-username" className="mb-2 block text-right">
|
||
نام کاربری
|
||
</Label>
|
||
<Input
|
||
id="register-username"
|
||
dir="ltr"
|
||
value={registerForm.username}
|
||
onChange={(event) => updateRegisterForm("username", sanitizeUsername(event.target.value))}
|
||
placeholder="latin.username"
|
||
className="h-12 rounded-2xl"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="register-password" className="mb-2 block text-right">
|
||
رمز عبور
|
||
</Label>
|
||
<Input
|
||
id="register-password"
|
||
type="password"
|
||
value={registerForm.password}
|
||
onChange={(event) => updateRegisterForm("password", event.target.value)}
|
||
className="h-12 rounded-2xl"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div>
|
||
<Label htmlFor="register-email" className="mb-2 block text-right">
|
||
ایمیل (اختیاری)
|
||
</Label>
|
||
<Input
|
||
id="register-email"
|
||
type="email"
|
||
value={registerForm.email}
|
||
onChange={(event) => updateRegisterForm("email", event.target.value)}
|
||
placeholder="user@example.com"
|
||
className="h-12 rounded-2xl"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="register-student-id" className="mb-2 block text-right">
|
||
شماره دانشجویی (اختیاری)
|
||
</Label>
|
||
<Input
|
||
id="register-student-id"
|
||
dir="ltr"
|
||
inputMode="numeric"
|
||
value={registerForm.student_id}
|
||
onChange={(event) => updateRegisterForm("student_id", sanitizeMobile(event.target.value))}
|
||
className="h-12 rounded-2xl"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div>
|
||
<Label htmlFor="register-university" className="mb-2 block text-right">
|
||
دانشگاه
|
||
</Label>
|
||
{universitiesLoading ? (
|
||
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
|
||
) : (
|
||
<AsyncSearchableCombobox
|
||
loadOptions={loadUniversities}
|
||
value={registerForm.university}
|
||
onChange={(value) => updateRegisterForm("university", value)}
|
||
placeholder="انتخاب دانشگاه"
|
||
searchPlaceholder="نام دانشگاه را بنویسید..."
|
||
emptyText="دانشگاهی پیدا نشد"
|
||
className="h-12 rounded-2xl"
|
||
/>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="register-major" className="mb-2 block text-right">
|
||
رشته تحصیلی (اختیاری)
|
||
</Label>
|
||
{majorsLoading ? (
|
||
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
|
||
) : (
|
||
<AsyncSearchableCombobox
|
||
loadOptions={loadMajors}
|
||
value={registerForm.major}
|
||
onChange={(value) => updateRegisterForm("major", value)}
|
||
placeholder="انتخاب رشته"
|
||
searchPlaceholder="نام رشته را بنویسید..."
|
||
emptyText="رشتهای پیدا نشد"
|
||
className="h-12 rounded-2xl"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="register-year" className="mb-2 block text-right">
|
||
سال ورودی (اختیاری)
|
||
</Label>
|
||
<Input
|
||
id="register-year"
|
||
dir="ltr"
|
||
inputMode="numeric"
|
||
value={registerForm.year_of_study}
|
||
onChange={(event) => updateRegisterForm("year_of_study", sanitizeMobile(event.target.value))}
|
||
className="h-12 rounded-2xl"
|
||
/>
|
||
</div>
|
||
|
||
<Button type="submit" className="h-12 w-full rounded-2xl" disabled={authLoading}>
|
||
{authLoading ? (
|
||
<>
|
||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||
در حال ثبتنام...
|
||
</>
|
||
) : (
|
||
<>
|
||
<MessageSquareMore className="ml-2 h-4 w-4" />
|
||
تکمیل ثبتنام
|
||
</>
|
||
)}
|
||
</Button>
|
||
|
||
<p className="text-right text-xs leading-6 text-muted-foreground">
|
||
ایمیل در این مرحله اختیاری است و برای ارسال پیام استفاده نخواهد شد. اطلاعرسانیهای مهم از طریق پیامک یا اعلان داخل سایت انجام میشوند.
|
||
</p>
|
||
</form>
|
||
);
|
||
|
||
return (
|
||
<div
|
||
className="min-h-screen bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.12),transparent_35%),radial-gradient(circle_at_bottom_left,rgba(15,23,42,0.08),transparent_30%)] px-4 py-10"
|
||
dir="rtl"
|
||
>
|
||
<div className="mx-auto w-full max-w-2xl">
|
||
<Card className="border-border/70 bg-background/90 shadow-xl backdrop-blur-xl">
|
||
<CardHeader className="text-right">
|
||
<CardTitle>{stepMeta.title}</CardTitle>
|
||
<CardDescription>{stepMeta.description}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
<div className="rounded-[1.5rem] border border-amber-400/30 bg-amber-500/10 p-4 text-sm leading-7 text-amber-900 dark:text-amber-100">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
|
||
<div className="text-right">
|
||
<p className="font-semibold">نکته مهم برای کاربران قبلی</p>
|
||
<p className="mt-2">
|
||
اگر دسترسی به ایمیل ندارید و رمز عبور را فراموش کردهاید، در همین مرحله از دکمه گوگل استفاده کنید. اگر ایمیل حساب شما با گوگل یکسان باشد، میتوانید حساب را بازیابی کرده و بعد موبایل خود را تایید کنید.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{step === "mobile" ? renderMobileStep() : null}
|
||
{step === "password" ? renderPasswordStep() : null}
|
||
{step === "otp_login" ? renderOtpStep("login") : null}
|
||
{step === "otp_register" ? renderOtpStep("register") : null}
|
||
{step === "register_details" ? renderRegisterDetailsStep() : null}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|