Files
teknav-bak/app/teknav-auth.jsx
T
arsam 437698f019 Import source: app/teknav-auth
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:26:13 +03:30

989 lines
46 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// teknav-auth.jsx — Login / Signup page
import { useState, useEffect } from 'react';
import { useAuth, useNav, useToast } from './teknav-ui.jsx';
import { ApiError } from './src/lib/api.js';
import { api } from './src/lib/api.js';
import TeknavCAP from './src/lib/TeknavCAP.jsx';
// ── Hooks ─────────────────────────────────────────────────────────────────────
function useTimeGreeting() {
const h = new Date().getHours();
if (h < 5) return 'شب بخیر';
if (h < 12) return 'صبح بخیر';
if (h < 17) return 'روز بخیر';
if (h < 21) return 'عصر بخیر';
return 'شب بخیر';
}
function useCountUp(target, delayMs = 0) {
const [count, setCount] = useState(0);
useEffect(() => {
const t = setTimeout(() => {
const steps = 36;
let step = 0;
const iv = setInterval(() => {
step++;
const ease = 1 - Math.pow(1 - step / steps, 3);
setCount(Math.floor(ease * target));
if (step >= steps) { setCount(target); clearInterval(iv); }
}, 1600 / steps);
return () => clearInterval(iv);
}, delayMs);
return () => clearTimeout(t);
}, [target, delayMs]);
return count;
}
// ── Page-scoped CSS ───────────────────────────────────────────────────────────
function AuthStyles() {
return (
<style>{`
@keyframes orbit { from{transform:rotate(0deg)} to{transform:rotate(360deg)} }
@keyframes orbitR { from{transform:rotate(0deg)} to{transform:rotate(-360deg)} }
@keyframes tickerUp { 0%{transform:translateY(0)} 100%{transform:translateY(-50%)} }
@keyframes bgGrid {
0% { background-position: 0 0; }
100% { background-position: 52px 52px; }
}
@keyframes logoGlow {
0%,100% { box-shadow: 0 10px 36px rgba(200,149,28,0.35); }
50% { box-shadow: 0 10px 56px rgba(200,149,28,0.6), 0 0 80px rgba(200,149,28,0.15); }
}
@keyframes fadeSlideUp {
from { opacity:0; transform:translateY(16px); }
to { opacity:1; transform:translateY(0); }
}
@keyframes statReveal {
0% { opacity:0; transform:scale(0.75) translateY(6px); }
60% { transform:scale(1.05) translateY(-2px); }
100% { opacity:1; transform:scale(1) translateY(0); }
}
@keyframes dividerExpand {
from { width: 0; opacity: 0; }
to { width: 48px; opacity: 1; }
}
@keyframes blink {
0%,100% { opacity: 1; } 50% { opacity: 0.3; }
}
/* ── Layout ── */
.auth-layout {
display: flex; min-height: 100vh;
direction: rtl; font-family: Vazirmatn, sans-serif;
}
.auth-form-col {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 48px 32px; min-height: 100vh;
position: relative; overflow: hidden;
background: #FAF7F0;
background-image:
linear-gradient(rgba(196,106,77,0.028) 1px, transparent 1px),
linear-gradient(90deg, rgba(196,106,77,0.028) 1px, transparent 1px);
background-size: 52px 52px;
animation: bgGrid 38s linear infinite;
}
.auth-form-col::before {
content: '';
position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(ellipse 70% 50% at 50% 100%, rgba(250,240,235,0.7) 0%, transparent 100%);
}
.auth-brand-col {
width: 440px; flex-shrink: 0;
background: #0B2226;
position: relative; overflow: hidden;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 60px 44px;
}
.auth-mobile-logo { display: none; }
@media (max-width: 880px) {
.auth-brand-col { display: none; }
.auth-mobile-logo { display: flex; }
.auth-form-col { padding: 36px 20px; }
}
/* ── Inputs ── */
.auth-input-wrap input:focus {
border-color: #C8951C !important;
background: #fff !important;
box-shadow: 0 0 0 3px rgba(200,149,28,0.1) !important;
}
/* ── Buttons ── */
.auth-gold-btn:hover:not(:disabled) {
opacity: 0.91;
transform: translateY(-1px);
box-shadow: 0 7px 22px rgba(200,149,28,0.38) !important;
}
.auth-gold-btn:active:not(:disabled) { transform: translateY(0); }
.auth-oauth-btn:hover {
border-color: #C46A4D !important;
background: #FFFCFB !important;
}
.auth-tab-btn {
flex: 1; padding: 10px 0; border: none; cursor: pointer;
font-family: Vazirmatn, sans-serif; font-size: 13px; font-weight: 700;
border-radius: 9px; transition: all 0.22s;
}
.auth-switch-link {
background: none; border: none; cursor: pointer;
color: #C46A4D; font-weight: 700;
font-family: Vazirmatn, sans-serif; font-size: 13px;
text-decoration: underline; text-underline-offset: 3px;
transition: color 0.15s;
}
.auth-switch-link:hover { color: #C8951C; }
.auth-forgot-btn {
background: none; border: none; cursor: pointer;
color: #9AA5A6; font-size: 12px;
font-family: Vazirmatn, sans-serif;
text-decoration: underline; text-underline-offset: 2px;
padding: 0; transition: color 0.15s;
}
.auth-forgot-btn:hover { color: #C46A4D; }
/* ── Chips ── */
.req-chip {
font-size: 10px; padding: 2px 8px; border-radius: 20px;
display: inline-flex; align-items: center; gap: 3px;
transition: all 0.25s; font-family: Vazirmatn, sans-serif;
}
/* ── Brand panel ── */
.ticker-mask {
-webkit-mask-image: linear-gradient(to bottom, transparent, black 18%, black 82%, transparent);
mask-image: linear-gradient(to bottom, transparent, black 18%, black 82%, transparent);
}
.brand-stat { animation: statReveal 0.55s cubic-bezier(.34,1.56,.64,1) both; }
.brand-stat:nth-child(2) { animation-delay: 0.18s; }
.brand-stat:nth-child(3) { animation-delay: 0.33s; }
.auth-card { animation: fadeSlideUp 0.45s cubic-bezier(.2,.8,.2,1) both; }
`}</style>
);
}
// ── Icons ─────────────────────────────────────────────────────────────────────
function EyeIcon({ open }) {
return open ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
);
}
function GoogleIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615Z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18Z" fill="#34A853"/>
<path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332Z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58Z" fill="#EA4335"/>
</svg>
);
}
function GithubIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/>
</svg>
);
}
// ── Brand Panel ───────────────────────────────────────────────────────────────
const TICKER_TOPICS = [
'هوش مصنوعی و آینده توسعه نرم‌افزار',
'امنیت سایبری در دوران پس از کوانتوم',
'پردازش ابری بومی‌سازی‌شده در ایران',
'متن‌باز در اکوسیستم فارسی‌زبان',
'زیرساخت‌های توزیع‌شده مقیاس‌پذیر',
'یادگیری عمیق با مجموعه‌داده‌های فارسی',
'معماری میکروسرویس در محیط تولید',
'مدیریت داده در مقیاس پتابایت',
];
const BRAND_WORDS = [
{ text: 'هوش مصنوعی', s: 12, x: 6, y: 12, spd: 3.2 },
{ text: 'ابر', s: 10, x: 72, y: 22, spd: 4.1 },
{ text: 'امنیت', s: 11, x: 18, y: 74, spd: 3.6 },
{ text: 'متن‌باز', s: 10, x: 62, y: 84, spd: 4.5 },
{ text: 'پردازش', s: 11, x: 4, y: 48, spd: 3.9 },
{ text: 'شبکه', s: 10, x: 76, y: 58, spd: 3.3 },
{ text: 'داده', s: 12, x: 38, y: 8, spd: 4.8 },
{ text: 'زیرساخت', s: 10, x: 48, y: 91, spd: 3.7 },
];
function BrandTicker() {
const doubled = [...TICKER_TOPICS, ...TICKER_TOPICS];
return (
<div className="ticker-mask" style={{ overflow: 'hidden', height: 92, width: '100%' }}>
<div style={{ animation: 'tickerUp 22s linear infinite' }}>
{doubled.map((item, i) => (
<div key={i} style={{
fontSize: 11, color: 'rgba(250,247,240,0.26)',
padding: '5px 0', borderBottom: '1px solid rgba(250,247,240,0.04)',
fontFamily: 'Vazirmatn,sans-serif',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<span style={{ color: 'rgba(200,149,28,0.4)', fontSize: 7, flexShrink: 0 }}>◉</span>
{item}
</div>
))}
</div>
</div>
);
}
function BrandPanel() {
const [mouse, setMouse] = useState({ x: 0, y: 0 });
const articles = useCountUp(23, 700);
const readers = useCountUp(8400, 1000);
const series = useCountUp(6, 1300);
useEffect(() => {
const h = (e) => setMouse({
x: e.clientX / window.innerWidth - 0.5,
y: e.clientY / window.innerHeight - 0.5,
});
window.addEventListener('mousemove', h, { passive: true });
return () => window.removeEventListener('mousemove', h);
}, []);
return (
<div className="auth-brand-col">
{/* Giant watermark letter */}
<div aria-hidden style={{
position: 'absolute', top: '-8%', left: '-14%',
fontSize: 360, fontWeight: 900, lineHeight: 1,
color: 'rgba(255,255,255,0.016)',
fontFamily: 'PelakFA,Vazirmatn,sans-serif',
userSelect: 'none', pointerEvents: 'none',
}}>ت</div>
{/* Dot grid */}
<div aria-hidden style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.032) 1px, transparent 1px)',
backgroundSize: '28px 28px',
}} />
{/* Parallax glow — follows cursor */}
<div aria-hidden style={{
position: 'absolute', top: '18%', left: '50%',
transform: `translate(-50%, 0) translate(${mouse.x * 18}px, ${mouse.y * 18}px)`,
width: 300, height: 300,
background: 'radial-gradient(circle, rgba(200,149,28,0.14) 0%, transparent 70%)',
transition: 'transform 0.9s cubic-bezier(.2,.8,.2,1)',
pointerEvents: 'none',
}} />
{/* Terracotta accent glow */}
<div aria-hidden style={{
position: 'absolute', bottom: '-4%', left: '-4%',
width: 220, height: 220,
background: 'radial-gradient(circle, rgba(196,106,77,0.1) 0%, transparent 70%)',
pointerEvents: 'none',
}} />
{/* Floating word bubbles — subtle parallax */}
<div aria-hidden style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
transform: `translate(${mouse.x * -12}px, ${mouse.y * -12}px)`,
transition: 'transform 0.8s cubic-bezier(.2,.8,.2,1)',
}}>
{BRAND_WORDS.map((w, i) => (
<div key={i} style={{
position: 'absolute', left: `${w.x}%`, top: `${w.y}%`,
fontSize: w.s, color: 'rgba(250,247,240,0.13)',
fontFamily: 'Vazirmatn,sans-serif',
padding: '3px 10px',
border: '1px solid rgba(250,247,240,0.07)',
borderRadius: 20, whiteSpace: 'nowrap',
animation: `float-y ${w.spd}s ease-in-out infinite`,
animationDelay: `${i * 0.38}s`,
}}>{w.text}</div>
))}
</div>
{/* ── Content ── */}
<div style={{ position: 'relative', zIndex: 1, width: '100%', textAlign: 'center' }}>
{/* Orbital logo */}
<div style={{ position: 'relative', width: 100, height: 100, margin: '0 auto 28px' }}>
{/* Outer dashed ring */}
<div aria-hidden style={{
position: 'absolute', inset: -46,
border: '1px dashed rgba(200,149,28,0.08)',
borderRadius: '50%', animation: 'orbit 44s linear infinite',
}} />
{/* Middle ring + orbiting dot */}
<div aria-hidden style={{
position: 'absolute', inset: -28,
border: '1px solid rgba(200,149,28,0.2)',
borderRadius: '50%', animation: 'orbitR 17s linear infinite',
}}>
<div style={{
position: 'absolute', top: -4, left: '50%', marginLeft: -4,
width: 8, height: 8, borderRadius: '50%',
background: '#C8951C',
boxShadow: '0 0 10px rgba(200,149,28,0.7)',
}} />
</div>
{/* Inner ring + dot */}
<div aria-hidden style={{
position: 'absolute', inset: -14,
border: '1px solid rgba(250,247,240,0.07)',
borderRadius: '50%', animation: 'orbit 9s linear infinite',
}}>
<div style={{
position: 'absolute', bottom: -3, left: '50%', marginLeft: -3,
width: 6, height: 6, borderRadius: '50%',
background: 'rgba(250,247,240,0.3)',
}} />
</div>
{/* Logo square */}
<div style={{
width: '100%', height: '100%', borderRadius: 26,
background: 'linear-gradient(145deg, #C8951C 0%, #F4C36A 50%, #C8951C 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'logoGlow 4.5s ease-in-out infinite',
}}>
<span style={{ color: '#0B2226', fontSize: 44, fontWeight: 900, fontFamily: 'Vazirmatn,sans-serif' }}>ت</span>
</div>
</div>
{/* Wordmark */}
<div style={{
fontFamily: 'PelakFA,Vazirmatn,sans-serif',
fontSize: 50, fontWeight: 800, color: '#FAF7F0',
lineHeight: 1, letterSpacing: '-1px', marginBottom: 5,
}}>تکناو</div>
<div style={{
fontSize: 10, letterSpacing: '0.32em', fontWeight: 500,
color: 'rgba(250,247,240,0.28)', marginBottom: 22,
}}>TEKNAV · IR</div>
{/* Animated divider */}
<div style={{
height: 1.5, margin: '0 auto 20px',
background: 'linear-gradient(90deg, transparent, rgba(200,149,28,0.65), transparent)',
animation: 'dividerExpand 1.2s ease 0.3s both',
}} />
{/* Tagline */}
<p style={{
fontSize: 14, fontFamily: 'Vazirmatn,sans-serif',
color: 'rgba(250,247,240,0.55)', lineHeight: 2,
direction: 'rtl', margin: '0 0 26px',
}}>
آخرین دانش فناوری،{' '}
<span style={{ color: 'rgba(200,149,28,0.88)', fontWeight: 600 }}>اول به فارسی</span>
</p>
{/* Live stats — count-up */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 20, marginBottom: 26 }}>
{[
{ n: articles, label: 'مقاله', delay: '0s', suffix: '+' },
{ n: readers, label: 'خواننده', delay: '0.18s', suffix: '' },
{ n: series, label: 'سری', delay: '0.34s', suffix: '' },
].map(({ n, label, delay, suffix }) => (
<div key={label} className="brand-stat" style={{ animationDelay: delay }}>
<div style={{
fontSize: 24, fontWeight: 900, color: '#FAF7F0', lineHeight: 1,
fontFamily: 'PelakFA,Vazirmatn,sans-serif',
}}>
{n.toLocaleString('fa-IR')}{suffix}
</div>
<div style={{ fontSize: 10, color: 'rgba(250,247,240,0.32)', marginTop: 5, letterSpacing: '0.05em' }}>{label}</div>
</div>
))}
</div>
{/* Divider + ticker */}
<div style={{ borderTop: '1px solid rgba(250,247,240,0.06)', paddingTop: 16, marginBottom: 10 }}>
<div style={{
fontSize: 9, color: 'rgba(250,247,240,0.22)',
letterSpacing: '0.2em', marginBottom: 10, textTransform: 'uppercase',
}}>موضوعات داغ</div>
<BrandTicker />
</div>
{/* Online pulse indicator */}
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 7, marginTop: 10,
fontSize: 10, color: 'rgba(250,247,240,0.27)',
fontFamily: 'Vazirmatn,sans-serif',
}}>
<span style={{
width: 7, height: 7, borderRadius: '50%',
background: '#2F8F6B', flexShrink: 0,
boxShadow: '0 0 0 0 rgba(47,143,107,0.4)',
animation: 'pulseRing 2.2s infinite',
display: 'inline-block',
}} />
در حال حاضر آنلاین
</div>
</div>
</div>
);
}
// ── AuthInput ─────────────────────────────────────────────────────────────────
function AuthInput({ label, hint, error, suffix, inputProps = {} }) {
const { style: inputStyle, ...rest } = inputProps;
return (
<div>
{label && (
<label style={{
display: 'block', marginBottom: 6, fontSize: 12, fontWeight: 600,
color: error ? '#C94C4C' : '#7B8C8E', letterSpacing: '0.03em',
}}>{label}</label>
)}
<div style={{ position: 'relative' }} className="auth-input-wrap">
<input {...rest} style={{
width: '100%',
padding: suffix ? '13px 16px 13px 46px' : '13px 16px',
border: `1.5px solid ${error ? '#C94C4C' : '#E4DDD2'}`,
borderRadius: 10, fontSize: 14,
fontFamily: 'Vazirmatn,sans-serif',
background: '#FAFAF8', color: '#1A2E30',
outline: 'none',
transition: 'border-color 0.2s, background 0.2s, box-shadow 0.2s',
...(inputStyle || {}),
}} />
{suffix && (
<div style={{
position: 'absolute', left: 14, top: '50%', transform: 'translateY(-50%)',
color: '#9AA5A6', display: 'flex', alignItems: 'center',
}}>{suffix}</div>
)}
</div>
{hint && !error && <div style={{ fontSize: 11, color: '#9AA5A6', marginTop: 4, lineHeight: 1.6 }}>{hint}</div>}
{error && <div style={{ fontSize: 11, color: '#C94C4C', marginTop: 4 }}>{error}</div>}
</div>
);
}
// ── Password strength ─────────────────────────────────────────────────────────
function PasswordStrength({ pw }) {
const checks = [
{ label: '۸+ کاراکتر', ok: pw.length >= 8 },
{ label: 'حرف کوچک', ok: /[a-z]/.test(pw) },
{ label: 'حرف بزرگ', ok: /[A-Z]/.test(pw) },
{ label: 'عدد', ok: /[0-9]/.test(pw) },
{ label: 'نماد', ok: /[^a-zA-Z0-9]/.test(pw) },
];
const score = checks.filter(c => c.ok).length;
const barColor = score <= 2 ? '#C94C4C' : score === 3 ? '#D08A22' : '#2F8F6B';
return (
<div style={{ marginTop: 8 }}>
<div style={{ display: 'flex', gap: 3, marginBottom: 7 }}>
{[1,2,3,4,5].map(i => (
<div key={i} style={{
flex: 1, height: 3, borderRadius: 2,
background: i <= score ? barColor : '#E8E2D8',
transition: 'background 0.3s',
}} />
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{checks.map(({ label, ok }) => (
<span key={label} className="req-chip" style={{
background: ok ? 'rgba(47,143,107,0.09)' : '#F2EFE9',
color: ok ? '#2F8F6B' : '#9AA5A6',
border: `1px solid ${ok ? 'rgba(47,143,107,0.22)' : '#E4DDD2'}`,
}}>{ok ? '✓' : '·'} {label}</span>
))}
</div>
</div>
);
}
// ── Forgot password modal ─────────────────────────────────────────────────────
function ForgotPasswordModal({ onClose }) {
const toast = useToast();
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const send = async (e) => {
e.preventDefault();
if (!email.trim()) { toast('ایمیل را وارد کنید', 'error'); return; }
setLoading(true);
try {
await api.post('/api/auth/forgot-password', { email: email.trim().toLowerCase() });
setSent(true);
} catch {
toast('خطا در ارسال ایمیل. لطفاً دوباره تلاش کنید', 'error');
} finally { setLoading(false); }
};
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 700, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(11,34,38,0.62)' }} />
<div style={{
position: 'relative', background: '#FAF7F0', borderRadius: 18, padding: '36px 32px',
maxWidth: 400, width: '90%', direction: 'rtl',
boxShadow: '0 32px 80px rgba(0,0,0,0.24)', fontFamily: 'Vazirmatn,sans-serif',
border: '1px solid #E4DDD2', animation: 'fadeSlideUp 0.25s ease both',
}}>
<h3 style={{ margin: '0 0 6px', fontSize: 18, fontWeight: 800, color: '#1A2E30', fontFamily: 'PelakFA,Vazirmatn,sans-serif' }}>بازیابی رمز عبور</h3>
<p style={{ fontSize: 13, color: '#7B8C8E', margin: '0 0 20px', lineHeight: 1.7 }}>
{sent
? 'اگر این ایمیل در سیستم ثبت شده باشد، لینک بازنشانی برای شما ارسال خواهد شد.'
: 'آدرس ایمیل ثبت‌شده خود را وارد کنید.'}
</p>
{sent ? (
<button onClick={onClose} style={goldBtn(false)}>بستن</button>
) : (
<form onSubmit={send} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<AuthInput
label="ایمیل"
inputProps={{
type: 'email', value: email, onChange: e => setEmail(e.target.value),
placeholder: 'you@example.com', autoComplete: 'email',
style: { direction: 'ltr', textAlign: 'left' },
}}
/>
<div style={{ display: 'flex', gap: 10 }}>
<button type="button" onClick={onClose} style={cancelBtn}>انصراف</button>
<button type="submit" disabled={loading} style={{ ...goldBtn(loading), flex: 1 }}>
{loading ? '…' : 'ارسال لینک'}
</button>
</div>
</form>
)}
</div>
</div>
);
}
// ── Login form ────────────────────────────────────────────────────────────────
function LoginForm({ onSuccess }) {
const { login } = useAuth();
const toast = useToast();
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [captcha, setCaptcha] = useState({ captchaId: '', userSolution: '' });
const [loading, setLoading] = useState(false);
const [showPass, setShowPass] = useState(false);
const [showForgot, setShowForgot] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!identifier || !password) { toast('نام کاربری/ایمیل و رمز عبور الزامی است', 'error'); return; }
if (!captcha.captchaId || !captcha.userSolution.trim()) { toast('کد امنیتی را وارد کنید', 'error'); return; }
setLoading(true);
try {
const user = await login(identifier, password, captcha.captchaId, captcha.userSolution);
if (user) onSuccess(user);
else toast('ایمیل یا رمز عبور نادرست است', 'error');
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 429) toast('تعداد تلاش‌های ناموفق از حد مجاز گذشت. چند دقیقه صبر کنید', 'error');
else if (err.status === 401) toast('نام کاربری، ایمیل یا رمز عبور نادرست است', 'error');
else if (err.status === 403) toast('این حساب غیرفعال است', 'error');
else if (err.status === 400) toast('کد امنیتی نامعتبر است — کد جدید دریافت کنید', 'error');
else toast('خطا در اتصال به سرور', 'error');
} else toast('خطای غیرمنتظره', 'error');
} finally { setLoading(false); }
};
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<AuthInput
label="نام کاربری یا ایمیل"
inputProps={{
type: 'text', value: identifier, onChange: e => setIdentifier(e.target.value),
placeholder: '@username یا you@example.com', autoComplete: 'username',
style: { direction: 'ltr', textAlign: 'left' },
}}
/>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: '#7B8C8E', letterSpacing: '0.03em' }}>رمز عبور</label>
<button type="button" onClick={() => setShowForgot(true)} className="auth-forgot-btn">فراموشی رمز؟</button>
</div>
<AuthInput
inputProps={{
type: showPass ? 'text' : 'password', value: password,
onChange: e => setPassword(e.target.value),
placeholder: '••••••••', autoComplete: 'current-password',
style: { direction: 'ltr', textAlign: 'left' },
}}
suffix={
<button type="button" onClick={() => setShowPass(s => !s)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: '#9AA5A6' }}>
<EyeIcon open={showPass} />
</button>
}
/>
</div>
<TeknavCAP onChange={setCaptcha} />
<button type="submit" disabled={loading} className="auth-gold-btn" style={goldBtn(loading)}>
{loading ? (
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<span style={{ width: 14, height: 14, border: '2px solid rgba(15,42,46,0.25)', borderTopColor: '#0F2A2E', borderRadius: '50%', display: 'inline-block', animation: 'spin 0.7s linear infinite' }} />
در حال ورود...
</span>
) : 'ورود به حساب'}
</button>
{showForgot && <ForgotPasswordModal onClose={() => setShowForgot(false)} />}
</form>
);
}
// ── Signup form ───────────────────────────────────────────────────────────────
function SignupForm({ onSuccess }) {
const { signup } = useAuth();
const toast = useToast();
const [name, setName] = useState('');
const [username, setUsername] = useState('');
const [identifier, setIdentifier] = useState('');
const [identifierType, setIdentifierType] = useState('email');
const [password, setPassword] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [captcha, setCaptcha] = useState({ captchaId: '', userSolution: '' });
const [loading, setLoading] = useState(false);
const [showPass, setShowPass] = useState(false);
const isPhone = identifierType === 'phone';
const pwMatch = confirmPw && password === confirmPw;
const pwMismatch = confirmPw && password !== confirmPw;
const handleSubmit = async (e) => {
e.preventDefault();
if (!name.trim()) { toast('نام الزامی است', 'error'); return; }
if (!/^[a-z0-9_][a-z0-9_.]{1,28}[a-z0-9_]$/.test(username.trim().toLowerCase().replace(/^@/, ''))) {
toast('نام کاربری معتبر وارد کنید', 'error'); return;
}
if (!identifier.trim()) { toast(isPhone ? 'شماره موبایل الزامی است' : 'ایمیل الزامی است', 'error'); return; }
if (password.length < 8) { toast('رمز عبور باید حداقل ۸ کاراکتر باشد', 'error'); return; }
if (password !== confirmPw) { toast('رمزهای عبور تطابق ندارند', 'error'); return; }
if (!captcha.captchaId || !captcha.userSolution.trim()) { toast('کد امنیتی را وارد کنید', 'error'); return; }
setLoading(true);
try {
const payload = {
name: name.trim(),
username: username.trim().toLowerCase().replace(/^@/, ''),
password, captchaId: captcha.captchaId, userSolution: captcha.userSolution,
...(isPhone ? { phone: identifier.trim() } : { email: identifier.trim().toLowerCase() }),
};
const user = await signup(payload);
if (user) onSuccess(user);
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 409) {
const msg = err.body?.error;
toast(msg === 'username_taken' ? 'این نام کاربری قبلاً گرفته شده است' : isPhone ? 'این شماره قبلاً ثبت شده است' : 'این ایمیل قبلاً ثبت شده است', 'error');
} else if (err.status === 429) {
toast('تعداد ثبت‌نام از این آی‌پی زیاد است. کمی صبر کنید', 'error');
} else if (err.status === 400) {
toast(err.body?.error === 'invalid_captcha' ? 'کد امنیتی نامعتبر است' : 'اطلاعات وارد‌شده معتبر نیست', 'error');
} else toast('خطا در اتصال به سرور', 'error');
} else toast('خطای غیرمنتظره', 'error');
} finally { setLoading(false); }
};
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
<AuthInput
label="نام نمایشی"
inputProps={{
type: 'text', value: name, onChange: e => setName(e.target.value),
placeholder: 'مثال: علی رضایی', autoComplete: 'name',
}}
/>
<AuthInput
label="نام کاربری"
hint={username ? `پروفایل: @${username.replace(/^@/, '')}` : 'فقط حروف لاتین، عدد، نقطه و زیرخط'}
inputProps={{
type: 'text', value: username,
onChange: e => setUsername(e.target.value.toLowerCase().replace(/^@/, '')),
placeholder: 'ali_rezaei', autoComplete: 'username',
style: { direction: 'ltr', textAlign: 'left' },
}}
/>
<div>
<div style={{ display: 'flex', background: '#F0EDE6', borderRadius: 8, padding: 3, gap: 3, marginBottom: 9, width: 'fit-content' }}>
{[{ key: 'email', label: 'ایمیل' }, { key: 'phone', label: 'موبایل' }].map(t => (
<button key={t.key} type="button" onClick={() => { setIdentifierType(t.key); setIdentifier(''); }}
style={{
padding: '5px 16px', borderRadius: 6, fontSize: 12, fontWeight: 600,
border: 'none', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif',
background: identifierType === t.key ? '#fff' : 'transparent',
color: identifierType === t.key ? '#1A2E30' : '#9AA5A6',
boxShadow: identifierType === t.key ? '0 1px 4px rgba(0,0,0,0.07)' : 'none',
transition: 'all 0.18s',
}}>{t.label}</button>
))}
</div>
<AuthInput
inputProps={{
type: isPhone ? 'tel' : 'email', value: identifier,
onChange: e => setIdentifier(e.target.value),
placeholder: isPhone ? '09123456789' : 'you@example.com',
autoComplete: isPhone ? 'tel' : 'email',
style: { direction: 'ltr', textAlign: 'left' },
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 12, fontWeight: 600, color: '#7B8C8E', letterSpacing: '0.03em' }}>رمز عبور</label>
<AuthInput
inputProps={{
type: showPass ? 'text' : 'password', value: password,
onChange: e => setPassword(e.target.value),
placeholder: '••••••••', autoComplete: 'new-password',
style: { direction: 'ltr', textAlign: 'left' },
}}
suffix={
<button type="button" onClick={() => setShowPass(s => !s)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: '#9AA5A6' }}>
<EyeIcon open={showPass} />
</button>
}
/>
{password && <PasswordStrength pw={password} />}
</div>
<AuthInput
label="تکرار رمز عبور"
error={pwMismatch ? 'رمزها تطابق ندارند' : undefined}
suffix={pwMatch ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#2F8F6B" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : null}
inputProps={{
type: 'password', value: confirmPw,
onChange: e => setConfirmPw(e.target.value),
placeholder: '••••••••', autoComplete: 'new-password',
style: { direction: 'ltr', textAlign: 'left' },
}}
/>
<TeknavCAP onChange={setCaptcha} />
<p style={{ fontSize: 11, color: '#9AA5A6', margin: 0, lineHeight: 1.8 }}>
با ثبت‌نام،{' '}
<a href="#" style={{ color: '#C46A4D', textDecoration: 'underline', textUnderlineOffset: '2px' }}>شرایط استفاده</a>
{' '}و{' '}
<a href="#" style={{ color: '#C46A4D', textDecoration: 'underline', textUnderlineOffset: '2px' }}>سیاست حریم خصوصی</a>
{' '}تکناو را می‌پذیرید.
</p>
<button type="submit" disabled={loading} className="auth-gold-btn" style={goldBtn(loading)}>
{loading ? (
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<span style={{ width: 14, height: 14, border: '2px solid rgba(15,42,46,0.25)', borderTopColor: '#0F2A2E', borderRadius: '50%', display: 'inline-block', animation: 'spin 0.7s linear infinite' }} />
در حال ثبت‌نام...
</span>
) : 'ایجاد حساب کاربری'}
</button>
</form>
);
}
// ── OAuth buttons ─────────────────────────────────────────────────────────────
function OAuthButtons({ oauthError }) {
const { startOAuth } = useAuth();
const [providers, setProviders] = useState([]);
useEffect(() => {
api.get('/api/auth/oauth/providers')
.then(d => setProviders(d?.providers ?? []))
.catch(() => {});
}, []);
if (providers.length === 0 && !oauthError) return null;
return (
<div style={{ marginBottom: 20 }}>
{oauthError && (
<div style={{ background: '#FEF2F2', border: '1px solid #FCA5A5', borderRadius: 10, padding: '10px 14px', marginBottom: 14, fontSize: 12, color: '#991B1B' }}>
{oauthError === 'account_suspended' ? 'این حساب معلق شده است' : 'ورود با این روش ممکن نبود. دوباره تلاش کنید'}
</div>
)}
{providers.length > 0 && (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
{providers.includes('google') && (
<button type="button" onClick={() => startOAuth('google')} className="auth-oauth-btn" style={oauthBtnStyle}>
<GoogleIcon /><span>ادامه با Google</span>
</button>
)}
{providers.includes('github') && (
<button type="button" onClick={() => startOAuth('github')} className="auth-oauth-btn" style={oauthBtnStyle}>
<GithubIcon /><span>ادامه با GitHub</span>
</button>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '18px 0' }}>
<div style={{ flex: 1, height: 1, background: '#E8E2D8' }} />
<span style={{ fontSize: 11, color: '#B4BCBE', whiteSpace: 'nowrap', letterSpacing: '0.05em' }}>یا با ایمیل</span>
<div style={{ flex: 1, height: 1, background: '#E8E2D8' }} />
</div>
</>
)}
</div>
);
}
// ── Main LoginPage ────────────────────────────────────────────────────────────
function LoginPage() {
const { navigate } = useNav();
const toast = useToast();
const { user, refreshMe } = useAuth();
const [tab, setTab] = useState('login');
const greeting = useTimeGreeting();
const hashParams = new URLSearchParams(window.location.hash.split('?')[1] || '');
const oauthError = hashParams.get('error') || null;
const emailVerifyToken = new URLSearchParams(window.location.search).get('verifyEmail');
useEffect(() => {
if (!emailVerifyToken || !user) return;
api.post('/api/auth/email/verify/confirm', { token: emailVerifyToken })
.then(async () => {
const freshUser = await refreshMe?.();
toast('ایمیل شما تایید شد');
window.history.replaceState(null, '', window.location.pathname + window.location.hash);
navigate((freshUser?.role ?? user.role) === 'reader' ? '/' : '/admin');
})
.catch(() => toast('تایید ایمیل ناموفق بود یا لینک منقضی شده است', 'error'));
}, [emailVerifyToken, user]);
useEffect(() => {
if (user && !emailVerifyToken) navigate(user.role === 'reader' ? '/' : '/admin');
}, [user, emailVerifyToken]);
const handleSuccess = (u) => {
toast(`خوش آمدید، ${u.name}!`);
navigate(u.role === 'reader' ? '/' : '/admin');
};
return (
<>
<AuthStyles />
<div className="auth-layout">
{/* ── Form column (right in RTL) ──────────────────────────────── */}
<div className="auth-form-col">
{/* Radial vignette overlay */}
<div aria-hidden style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
background: 'radial-gradient(ellipse 80% 55% at 50% 5%, rgba(250,247,240,0) 0%, rgba(250,247,240,0.55) 100%)',
}} />
<div style={{ width: '100%', maxWidth: 420, position: 'relative', zIndex: 1 }}>
{/* Mobile logo */}
<div className="auth-mobile-logo" style={{ justifyContent: 'center', alignItems: 'center', gap: 10, marginBottom: 32 }}>
<div style={{
width: 44, height: 44, borderRadius: 14,
background: 'linear-gradient(145deg, #C8951C, #D49A2A)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 14px rgba(200,149,28,0.3)',
}}>
<span style={{ color: '#0F2A2E', fontSize: 21, fontWeight: 900 }}>ت</span>
</div>
<span style={{ fontSize: 24, fontWeight: 900, color: '#0F2A2E', fontFamily: 'PelakFA,Vazirmatn,sans-serif' }}>تکناو</span>
</div>
{/* Heading with vertical accent bar + time-based greeting */}
<div className="auth-card" style={{ marginBottom: 22 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<div style={{
width: 3.5, height: 30, borderRadius: 2, flexShrink: 0,
background: 'linear-gradient(to bottom, #C46A4D, rgba(200,149,28,0.6))',
}} />
<h1 style={{
margin: 0, fontFamily: 'PelakFA,Vazirmatn,sans-serif',
fontSize: 25, fontWeight: 800, color: '#0F2A2E', lineHeight: 1.25,
}}>
{tab === 'login' ? greeting : 'عضو تکناو شوید'}
</h1>
</div>
<p style={{ margin: '0 0 0 14px', fontSize: 13, color: '#9AA5A6', lineHeight: 1.6 }}>
{tab === 'login'
? 'با حساب کاربری خود وارد شوید'
: 'در چند ثانیه حساب رایگان بسازید'}
</p>
</div>
{/* Pill tab switcher */}
<div style={{
display: 'flex', background: '#EDE9E2', borderRadius: 12,
padding: 4, marginBottom: 24, gap: 3,
}}>
{[{ key: 'login', label: 'ورود' }, { key: 'signup', label: 'ثبت‌نام' }].map(t => (
<button key={t.key} onClick={() => setTab(t.key)} className="auth-tab-btn" style={{
background: tab === t.key ? '#fff' : 'transparent',
color: tab === t.key ? '#0F2A2E' : '#9AA5A6',
boxShadow: tab === t.key ? '0 1px 6px rgba(0,0,0,0.1)' : 'none',
}}>{t.label}</button>
))}
</div>
{/* OAuth */}
<OAuthButtons oauthError={tab === 'login' ? oauthError : null} />
{/* Form */}
<div className="auth-card">
{tab === 'login'
? <LoginForm onSuccess={handleSuccess} />
: <SignupForm onSuccess={handleSuccess} />}
</div>
{/* Switch prompt */}
<div style={{ textAlign: 'center', marginTop: 22, fontSize: 13, color: '#9AA5A6' }}>
{tab === 'login' ? (
<>حساب ندارید؟{' '}<button onClick={() => setTab('signup')} className="auth-switch-link">ثبت‌نام کنید</button></>
) : (
<>قبلاً حساب ساخته‌اید؟{' '}<button onClick={() => setTab('login')} className="auth-switch-link">وارد شوید</button></>
)}
</div>
<div style={{ textAlign: 'center', marginTop: 28, fontSize: 10, color: '#C4C8C9', letterSpacing: '0.06em' }}>
TEKNAV · نشریه فناوری فارسی
</div>
</div>
</div>
{/* ── Brand column (left in RTL) ──────────────────────────────── */}
<BrandPanel />
</div>
</>
);
}
// ── Shared styles ─────────────────────────────────────────────────────────────
const goldBtn = (loading) => ({
width: '100%', padding: '13px 0', borderRadius: 10, border: 'none',
background: loading ? '#E8E2D8' : 'linear-gradient(135deg, #C8951C 0%, #D49A2A 60%, #C8951C 100%)',
color: loading ? '#B0B8BA' : '#0F2A2E',
fontSize: 14, fontWeight: 700, cursor: loading ? 'not-allowed' : 'pointer',
fontFamily: 'Vazirmatn,sans-serif',
boxShadow: loading ? 'none' : '0 4px 16px rgba(200,149,28,0.22)',
transition: 'opacity 0.2s, transform 0.15s, box-shadow 0.2s',
letterSpacing: '0.02em', marginTop: 4,
});
const cancelBtn = {
flex: 1, padding: '12px 0', border: '1.5px solid #E4DDD2', borderRadius: 10,
background: 'transparent', color: '#7B8C8E', cursor: 'pointer',
fontSize: 13, fontWeight: 600, fontFamily: 'Vazirmatn,sans-serif',
};
const oauthBtnStyle = {
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
width: '100%', padding: '11px 0', borderRadius: 10,
border: '1.5px solid #E4DDD2', background: '#fff', cursor: 'pointer',
fontFamily: 'Vazirmatn,sans-serif', fontSize: 13, fontWeight: 600, color: '#1A2E30',
transition: 'border-color 0.18s, background 0.18s',
};
export { LoginPage };