Initial commit: Mana Collection dashboards (Next.js).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user