Initial commit: Mana Collection dashboards (Next.js).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-07 20:50:24 +03:30
co-authored by Cursor
commit 18b29f5b62
95 changed files with 16414 additions and 0 deletions
+538
View File
@@ -0,0 +1,538 @@
"use client";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import {
FormEvent,
InputHTMLAttributes,
ReactNode,
Suspense,
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 searchParams = useSearchParams();
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";
const customerHint = searchParams.get("as") === "customer";
function afterAuth(role: string) {
if (role === "CUSTOMER") {
setError(
"این پنل مخصوص فروشندگان است. مشتریان از وب‌سایت مانا کالکشن وارد شوند.",
);
return;
}
router.replace("/");
}
async function handleLoginPassword(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setBusy(true);
const form = new FormData(event.currentTarget);
const cellNumber = String(form.get("cellNumber") ?? "").trim();
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();
setError(null);
setBusy(true);
const form = new FormData(event.currentTarget);
const cellNumber = String(form.get("cellNumber") ?? "").trim();
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();
setError(null);
setBusy(true);
const form = new FormData(event.currentTarget);
const firstName = String(form.get("firstName") ?? "").trim();
const lastName = String(form.get("lastName") ?? "").trim();
const cellNumber = String(form.get("cellNumber") ?? "").trim();
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>
{customerHint ? (
<p className="mb-4 text-xs leading-6 text-muted">
حساب شما مشتری است. برای مدیریت فروشگاه باید با حساب فروشنده وارد
شوید.
</p>
) : null}
{error ? (
<p className="mb-4 text-xs leading-6 text-red-600">{error}</p>
) : null}
<div className="grid min-h-0 flex-1">
<div
className={`col-start-1 row-start-1 flex items-center transition-opacity duration-300 ${
isLogin
? "relative z-10 opacity-100"
: "pointer-events-none invisible z-0 opacity-0"
}`}
aria-hidden={!isLogin}
>
<AuthPanel
title="ورود به وبسایت"
subtitle="با شماره موبایل و رمز عبور یا کد یکبارمصرف وارد پنل فروشگاه شوید."
onSubmit={
loginMethod === "password"
? handleLoginPassword
: handleLoginOtp
}
>
<Field
id="login-phone"
label="شماره موبایل"
name="cellNumber"
type="tel"
inputMode="numeric"
autoComplete="tel"
placeholder="09123456789"
dir="ltr"
pattern="09[0-9]{9}"
required={isLogin}
defaultValue={pendingCell}
/>
{loginMethod === "password" ? (
<Field
id="login-password"
label="رمز عبور"
name="password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
dir="ltr"
required={isLogin}
minLength={4}
/>
) : otpSent ? (
<Field
id="login-otp"
label="کد تأیید"
name="code"
type="text"
inputMode="numeric"
placeholder="123456"
dir="ltr"
required
minLength={4}
maxLength={8}
/>
) : null}
<button
type="submit"
className="auth-button"
disabled={busy}
tabIndex={isLogin ? 0 : -1}
>
<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>
</div>
<div
className={`col-start-1 row-start-1 flex items-center transition-opacity duration-300 ${
!isLogin
? "relative z-10 opacity-100"
: "pointer-events-none invisible z-0 opacity-0"
}`}
aria-hidden={isLogin}
>
<AuthPanel
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="given-name"
required={!isLogin}
/>
<Field
id="register-last"
label="نام خانوادگی"
name="lastName"
type="text"
autoComplete="family-name"
required={!isLogin}
/>
</div>
<Field
id="register-phone"
label="شماره موبایل"
name="cellNumber"
type="tel"
inputMode="numeric"
placeholder="09123456789"
dir="ltr"
pattern="09[0-9]{9}"
required={!isLogin}
/>
<Field
id="register-password"
label="رمز عبور"
name="password"
type="password"
autoComplete="new-password"
placeholder="••••••••"
dir="ltr"
required={!isLogin}
minLength={4}
/>
<Field
id="register-confirm"
label="تکرار رمز عبور"
name="confirm"
type="password"
autoComplete="new-password"
placeholder="••••••••"
dir="ltr"
required={!isLogin}
minLength={4}
/>
</>
) : (
<>
<Field
id="register-phone-confirm"
label="شماره موبایل"
name="cellNumber"
type="tel"
dir="ltr"
defaultValue={pendingCell}
required
/>
<Field
id="register-otp"
label="کد تأیید پیامکی"
name="code"
type="text"
inputMode="numeric"
dir="ltr"
required
minLength={4}
maxLength={8}
/>
</>
)}
<button
type="submit"
className="auth-button"
disabled={busy}
tabIndex={!isLogin ? 0 : -1}
>
<span>
{busy
? "لطفاً صبر کنید…"
: registerOtpSent
? "تأیید و ایجاد حساب"
: "ارسال کد تأیید"}
</span>
</button>
</AuthPanel>
</div>
</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 (
<Suspense
fallback={
<div className="flex min-h-dvh items-center justify-center text-sm text-muted">
در حال بارگذاری…
</div>
}
>
<AuthFormInner />
</Suspense>
);
}
function AuthPanel({
title,
subtitle,
onSubmit,
children,
}: {
title: string;
subtitle: string;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
children: ReactNode;
}) {
return (
<div className="w-full">
<h1 className="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 onSubmit={onSubmit} className="flex w-full flex-col gap-4">
{children}
</form>
</div>
);
}
function Field({
id,
label,
...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} />
</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>
);
}