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>
562 lines
19 KiB
TypeScript
562 lines
19 KiB
TypeScript
"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>
|
||
);
|
||
}
|