Files
dashboards/src/components/auth/AuthForm.tsx
T
Alireza HassaniandCursor 68365457a1 Fix auth login flow, Iran Yekan/Doran fonts, and account tiles.
Prevent stale /auth/me from wiping a fresh session, unblock login submit, allow localhost HMR, and drop the tile "مشاهده" CTA.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 11:10:37 +03:30

562 lines
19 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.
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import {
FormEvent,
InputHTMLAttributes,
ReactNode,
useState,
} from "react";
import ManaLogo from "@/components/ui/ManaLogo";
import { ApiError } from "@/lib/api";
import { useAuth } from "@/lib/auth";
type AuthMode = "login" | "register";
type LoginMethod = "password" | "otp";
function AuthFormInner() {
const router = useRouter();
const {
login,
sendLoginOtp,
loginWithOtp,
registerSendCode,
registerVerify,
} = useAuth();
const [mode, setMode] = useState<AuthMode>("login");
const [loginMethod, setLoginMethod] = useState<LoginMethod>("password");
const [otpSent, setOtpSent] = useState(false);
const [registerOtpSent, setRegisterOtpSent] = useState(false);
const [pendingCell, setPendingCell] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const isLogin = mode === "login";
function afterAuth(_role: string) {
router.replace("/account");
}
async function handleLoginPassword(event?: FormEvent<HTMLFormElement>) {
event?.preventDefault();
event?.stopPropagation();
setError(null);
setBusy(true);
const formEl =
event?.currentTarget ??
(document.getElementById("auth-login-form") as HTMLFormElement | null);
if (!formEl) {
setError("فرم ورود پیدا نشد");
setBusy(false);
return;
}
const form = new FormData(formEl);
const cellNumber = String(form.get("cellNumber") ?? "")
.trim()
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
const password = String(form.get("password") ?? "");
try {
const user = await login(cellNumber, password);
afterAuth(user.role);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ورود ناموفق بود");
} finally {
setBusy(false);
}
}
async function handleLoginOtp(event?: FormEvent<HTMLFormElement>) {
event?.preventDefault();
event?.stopPropagation();
setError(null);
setBusy(true);
const formEl =
event?.currentTarget ??
(document.getElementById("auth-login-form") as HTMLFormElement | null);
if (!formEl) {
setError("فرم ورود پیدا نشد");
setBusy(false);
return;
}
const form = new FormData(formEl);
const cellNumber = String(form.get("cellNumber") ?? "")
.trim()
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
const code = String(form.get("code") ?? "").trim();
try {
if (!otpSent) {
await sendLoginOtp(cellNumber);
setPendingCell(cellNumber);
setOtpSent(true);
} else {
const user = await loginWithOtp(cellNumber || pendingCell, code);
afterAuth(user.role);
}
} catch (err) {
setError(err instanceof ApiError ? err.message : "خطا در ورود با کد");
} finally {
setBusy(false);
}
}
async function handleRegister(event?: FormEvent<HTMLFormElement>) {
event?.preventDefault();
event?.stopPropagation();
setError(null);
setBusy(true);
const formEl =
event?.currentTarget ??
(document.getElementById("auth-register-form") as HTMLFormElement | null);
if (!formEl) {
setError("فرم ثبت‌نام پیدا نشد");
setBusy(false);
return;
}
const form = new FormData(formEl);
const firstName = String(form.get("firstName") ?? "").trim();
const lastName = String(form.get("lastName") ?? "").trim();
const cellNumber = String(form.get("cellNumber") ?? "")
.trim()
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
const password = String(form.get("password") ?? "");
const confirm = String(form.get("confirm") ?? "");
const code = String(form.get("code") ?? "").trim();
try {
if (!registerOtpSent) {
if (password !== confirm) {
setError("رمز عبور و تکرار آن یکسان نیست");
return;
}
await registerSendCode({ firstName, lastName, cellNumber, password });
setPendingCell(cellNumber);
setRegisterOtpSent(true);
} else {
const user = await registerVerify(cellNumber || pendingCell, code);
afterAuth(user.role);
}
} catch (err) {
setError(err instanceof ApiError ? err.message : "ثبت‌نام ناموفق بود");
} finally {
setBusy(false);
}
}
return (
<div className="auth-canvas relative flex min-h-dvh items-center justify-center overflow-hidden px-4 py-10 sm:px-6 lg:px-10">
<div className="relative z-10 mx-auto flex w-full max-w-5xl flex-col items-center gap-7">
<div className="animate-auth-fade flex w-full flex-col items-center">
<div dir="ltr" className="flex flex-col items-center">
<ManaLogo
href={
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
"http://mana-collection.com:3000"
}
width={320}
priority
showTagline
variant="white"
blackBg
hoverSwap
taglineSize={19}
className="w-[320px] max-w-[min(320px,100%)]"
/>
</div>
</div>
<div className="flex w-full flex-col items-stretch gap-0 overflow-hidden border border-line bg-surface shadow-[0_24px_80px_rgba(0,0,0,0.06)] md:h-[min(680px,86vh)] md:flex-row">
<section className="animate-auth-rise relative flex w-full flex-col px-7 py-8 sm:px-10 sm:py-10 md:w-[62%] md:overflow-hidden md:px-12 lg:px-14">
<div className="mb-6 flex shrink-0 items-center gap-6 border-b border-line">
<button
type="button"
onClick={() => {
setMode("login");
setError(null);
}}
className={`relative cursor-pointer py-3 text-sm transition-colors ${
isLogin ? "text-ink" : "text-muted hover:text-ink"
}`}
>
ورود
{isLogin && (
<span className="absolute inset-x-0 -bottom-px h-px bg-ink" />
)}
</button>
<button
type="button"
onClick={() => {
setMode("register");
setError(null);
}}
className={`relative cursor-pointer py-3 text-sm transition-colors ${
!isLogin ? "text-ink" : "text-muted hover:text-ink"
}`}
>
ثبت نام
{!isLogin && (
<span className="absolute inset-x-0 -bottom-px h-px bg-ink" />
)}
</button>
</div>
{error ? (
<p className="mb-4 text-xs leading-6 text-red-600">{error}</p>
) : null}
<div className="flex min-h-0 flex-1 items-center">
{isLogin ? (
<AuthPanel
formId="auth-login-form"
title="ورود به حساب"
subtitle="با شماره موبایل و رمز عبور یا کد یکبارمصرف وارد شوید."
onSubmit={
loginMethod === "password"
? handleLoginPassword
: handleLoginOtp
}
>
<Field
id="login-phone"
label="شماره موبایل"
name="cellNumber"
type="tel"
inputMode="numeric"
autoComplete="off"
placeholder="09123456789"
dir="ltr"
pattern="09[0-9]{9}"
required
defaultValue={pendingCell || undefined}
/>
{loginMethod === "password" ? (
<Field
id="login-password"
label="رمز عبور"
name="password"
type="password"
autoComplete="off"
placeholder="••••••••"
dir="ltr"
required
minLength={4}
/>
) : otpSent ? (
<Field
id="login-otp"
label="کد تأیید"
name="code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
dir="ltr"
required
minLength={4}
maxLength={8}
/>
) : null}
<button
type="button"
className="auth-button"
disabled={busy}
onClick={() => {
void (loginMethod === "password"
? handleLoginPassword()
: handleLoginOtp());
}}
>
<span>
{busy
? "لطفاً صبر کنید…"
: loginMethod === "otp" && !otpSent
? "ارسال کد"
: "ورود به حساب"}
</span>
</button>
<div className="mt-1 flex items-center justify-between gap-3 text-xs">
{loginMethod === "password" ? (
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1.5 text-muted transition-colors hover:text-ink"
onClick={() => {
setLoginMethod("otp");
setOtpSent(false);
setError(null);
}}
>
<OtpIcon />
ورود از طریق کد یکبار مصرف
</button>
) : (
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1.5 text-muted transition-colors hover:text-ink"
onClick={() => {
setLoginMethod("password");
setOtpSent(false);
setError(null);
}}
>
<KeyMiniIcon />
ورود با رمز عبور
</button>
)}
<button
type="button"
className="cursor-pointer text-muted transition-colors hover:text-ink"
onClick={() => {
setError("بازیابی رمز عبور به‌زودی فعال می‌شود.");
}}
>
فراموشی رمز عبور
</button>
</div>
</AuthPanel>
) : (
<AuthPanel
formId="auth-register-form"
title="عضویت در مانا کالکشن"
subtitle="با تکمیل اطلاعات زیر، ما یک حساب کاربری برای شما ایجاد میکنیم."
onSubmit={handleRegister}
>
{!registerOtpSent ? (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field
id="register-first"
label="نام"
name="firstName"
type="text"
autoComplete="off"
required
/>
<Field
id="register-last"
label="نام خانوادگی"
name="lastName"
type="text"
autoComplete="off"
required
/>
</div>
<Field
id="register-phone"
label="شماره موبایل"
name="cellNumber"
type="tel"
inputMode="numeric"
autoComplete="off"
placeholder="09123456789"
dir="ltr"
pattern="09[0-9]{9}"
required
/>
<Field
id="register-password"
label="رمز عبور"
name="password"
type="password"
autoComplete="new-password"
placeholder="••••••••"
dir="ltr"
required
minLength={4}
/>
<Field
id="register-confirm"
label="تکرار رمز عبور"
name="confirm"
type="password"
autoComplete="new-password"
placeholder="••••••••"
dir="ltr"
required
minLength={4}
/>
</>
) : (
<>
<Field
id="register-phone-confirm"
label="شماره موبایل"
name="cellNumber"
type="tel"
autoComplete="off"
dir="ltr"
defaultValue={pendingCell || undefined}
required
/>
<Field
id="register-otp"
label="کد تأیید پیامکی"
name="code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
dir="ltr"
required
minLength={4}
maxLength={8}
/>
</>
)}
<button
type="button"
className="auth-button"
disabled={busy}
onClick={() => {
void handleRegister();
}}
>
<span>
{busy
? "لطفاً صبر کنید…"
: registerOtpSent
? "تأیید و ایجاد حساب"
: "ارسال کد تأیید"}
</span>
</button>
</AuthPanel>
)}
</div>
<p className="mt-6 shrink-0 text-xs leading-6 text-muted">
با ادامه، شرایط استفاده و حریم خصوصی مانا را می‌پذیرید.
</p>
</section>
<aside className="animate-auth-fade relative hidden overflow-hidden border-line md:block md:h-auto md:w-[38%] md:border-r">
<Image
src="/images/aiony-haust-owp8uQgoK8U-unsplash.jpg"
alt="نمای مد مینیمال مانا کالکشن"
fill
priority
sizes="(max-width: 768px) 100vw, 38vw"
className="animate-image-drift object-cover object-center"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/45 via-black/5 to-transparent" />
<p className="absolute bottom-6 right-6 left-6 font-display text-lg text-white/95">
Quiet elegance.
</p>
</aside>
<aside className="relative h-44 w-full overflow-hidden border-t border-line md:hidden">
<Image
src="/images/aiony-haust-owp8uQgoK8U-unsplash.jpg"
alt="نمای مد مینیمال مانا کالکشن"
fill
priority
sizes="(max-width: 768px) 100vw, 40vw"
className="object-cover object-center"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
</aside>
</div>
</div>
</div>
);
}
export default function AuthForm() {
return <AuthFormInner />;
}
function AuthPanel({
formId,
title,
subtitle,
onSubmit,
children,
}: {
formId: string;
title: string;
subtitle: string;
onSubmit: (event?: FormEvent<HTMLFormElement>) => void;
children: ReactNode;
}) {
return (
<div className="w-full">
<h1 className="font-doran mb-2 text-xl font-medium text-ink sm:text-2xl">
{title}
</h1>
<p className="mb-8 max-w-sm text-sm leading-7 text-muted">{subtitle}</p>
<form
id={formId}
noValidate
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
onSubmit(event);
}}
className="flex w-full flex-col gap-4"
autoComplete="off"
>
{children}
</form>
</div>
);
}
function Field({
id,
label,
autoComplete = "off",
...props
}: {
id: string;
label: string;
} & InputHTMLAttributes<HTMLInputElement>) {
return (
<div className="w-full">
<label htmlFor={id} className="auth-label">
{label}
</label>
<input
id={id}
className="auth-input"
{...props}
autoComplete={autoComplete}
/>
</div>
);
}
function OtpIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
<rect
x="5.5"
y="2.5"
width="13"
height="19"
rx="2"
stroke="currentColor"
strokeWidth="1.4"
/>
<path
d="M10 17.5h4"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
<circle cx="12" cy="9" r="1.2" fill="currentColor" />
</svg>
);
}
function KeyMiniIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="8" cy="14" r="3" stroke="currentColor" strokeWidth="1.4" />
<path
d="M10.5 11.5 19 3l2 2-2.5 2.5L20 9l-2 2-1.5-1.5"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}