317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import { useState, useCallback, useEffect, useId } from 'react';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
import { CloseIcon } from './icons/CloseIcon';
|
|
import { GoogleIcon } from './icons/GoogleIcon';
|
|
import { trackEvent } from '../../lib/analytics';
|
|
import { useModalA11y } from '../../hooks/useModalA11y';
|
|
|
|
type View = 'login' | 'register' | 'forgot';
|
|
|
|
export default function AuthModal({
|
|
onClose,
|
|
onAuthenticated,
|
|
onLogin,
|
|
onRegister,
|
|
onOAuthLogin,
|
|
onForgotPassword,
|
|
loading,
|
|
error,
|
|
onClearError,
|
|
initialTab = 'login',
|
|
}: {
|
|
onClose: () => void;
|
|
onAuthenticated?: () => void;
|
|
onLogin: (email: string, password: string) => Promise<void>;
|
|
onRegister: (email: string, password: string) => Promise<void>;
|
|
onOAuthLogin: (provider: string) => Promise<void>;
|
|
onForgotPassword: (email: string) => Promise<void>;
|
|
loading: boolean;
|
|
error: string | null;
|
|
onClearError: () => void;
|
|
initialTab?: 'login' | 'register';
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [view, setView] = useState<View>(initialTab);
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [resetSent, setResetSent] = useState(false);
|
|
const dialogRef = useModalA11y();
|
|
const fieldId = useId();
|
|
const emailInputId = `${fieldId}-email`;
|
|
const passwordInputId = `${fieldId}-password`;
|
|
|
|
useEffect(() => {
|
|
trackEvent('Auth Modal Open', { tab: initialTab });
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [onClose]);
|
|
|
|
const switchView = useCallback(
|
|
(newView: View) => {
|
|
setView(newView);
|
|
setResetSent(false);
|
|
onClearError();
|
|
},
|
|
[onClearError]
|
|
);
|
|
|
|
const handleSubmit = useCallback(
|
|
async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
if (view === 'login') {
|
|
await onLogin(email, password);
|
|
onAuthenticated?.();
|
|
onClose();
|
|
} else if (view === 'register') {
|
|
await onRegister(email, password);
|
|
onAuthenticated?.();
|
|
onClose();
|
|
} else {
|
|
await onForgotPassword(email);
|
|
setResetSent(true);
|
|
}
|
|
} catch {
|
|
// Error is handled by the hook
|
|
}
|
|
},
|
|
[view, email, password, onLogin, onRegister, onForgotPassword, onAuthenticated, onClose]
|
|
);
|
|
|
|
const handleOAuth = useCallback(
|
|
async (provider: string) => {
|
|
try {
|
|
await onOAuthLogin(provider);
|
|
onAuthenticated?.();
|
|
onClose();
|
|
} catch {
|
|
// Error is handled by the hook
|
|
}
|
|
},
|
|
[onOAuthLogin, onAuthenticated, onClose]
|
|
);
|
|
|
|
const title =
|
|
view === 'login'
|
|
? t('auth.logIn')
|
|
: view === 'register'
|
|
? t('auth.createAccount')
|
|
: t('auth.resetPassword');
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[10001] flex items-center justify-center"
|
|
onMouseDown={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
role="presentation"
|
|
>
|
|
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="auth-modal-title"
|
|
tabIndex={-1}
|
|
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
|
<h2 id="auth-modal-title" className="text-lg font-semibold text-navy-950 dark:text-white">
|
|
{title}
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t('common.close')}
|
|
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
|
>
|
|
<CloseIcon className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tabs (hidden in forgot view) */}
|
|
{view !== 'forgot' && (
|
|
<div className="flex px-5 gap-4 border-b border-warm-200 dark:border-warm-700">
|
|
<button
|
|
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
|
|
view === 'login'
|
|
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
|
|
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
|
}`}
|
|
onClick={() => switchView('login')}
|
|
>
|
|
{t('auth.logIn')}
|
|
</button>
|
|
<button
|
|
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
|
|
view === 'register'
|
|
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
|
|
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
|
}`}
|
|
onClick={() => switchView('register')}
|
|
>
|
|
{t('auth.createAccount')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="p-5 space-y-4">
|
|
{/* Value prop */}
|
|
{view !== 'forgot' && (
|
|
<p className="text-xs text-warm-500 dark:text-warm-400 text-center">
|
|
{t('auth.valueProp')}
|
|
</p>
|
|
)}
|
|
|
|
{/* OAuth buttons (hidden in forgot view) */}
|
|
{view !== 'forgot' && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOAuth('google')}
|
|
disabled={loading}
|
|
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-100 text-sm font-medium hover:bg-warm-50 dark:hover:bg-warm-700 disabled:opacity-50 disabled:cursor-wait"
|
|
>
|
|
<GoogleIcon className="w-4 h-4" />
|
|
{t('auth.continueWithGoogle')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
|
|
<span className="text-xs text-warm-400 dark:text-warm-500">{t('common.or')}</span>
|
|
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Email form */}
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label
|
|
htmlFor={emailInputId}
|
|
className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"
|
|
>
|
|
{t('auth.email')}
|
|
</label>
|
|
<input
|
|
id={emailInputId}
|
|
name="email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
autoComplete="email"
|
|
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
|
placeholder={t('auth.emailPlaceholder')}
|
|
/>
|
|
</div>
|
|
|
|
{view !== 'forgot' && (
|
|
<div>
|
|
<label
|
|
htmlFor={passwordInputId}
|
|
className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"
|
|
>
|
|
{t('auth.password')}
|
|
</label>
|
|
<input
|
|
id={passwordInputId}
|
|
name="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
minLength={8}
|
|
autoComplete={view === 'register' ? 'new-password' : 'current-password'}
|
|
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
|
placeholder={
|
|
view === 'register'
|
|
? t('auth.passwordPlaceholderRegister')
|
|
: t('auth.passwordPlaceholderLogin')
|
|
}
|
|
/>
|
|
{view === 'login' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => switchView('forgot')}
|
|
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
|
>
|
|
{t('auth.forgotPassword')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{view === 'forgot' && resetSent && (
|
|
<p className="text-sm text-teal-700 dark:text-teal-400">{t('auth.resetSent')}</p>
|
|
)}
|
|
|
|
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
|
|
|
{!(view === 'forgot' && resetSent) && (
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
|
|
>
|
|
{loading
|
|
? t('auth.pleaseWait')
|
|
: view === 'login'
|
|
? t('auth.logIn')
|
|
: view === 'register'
|
|
? t('auth.createAccount')
|
|
: t('auth.sendResetLink')}
|
|
</button>
|
|
)}
|
|
|
|
{view === 'forgot' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => switchView('login')}
|
|
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
|
>
|
|
{t('auth.backToLogin')}
|
|
</button>
|
|
)}
|
|
|
|
{view === 'register' && (
|
|
<p className="text-[11px] leading-relaxed text-center text-warm-400 dark:text-warm-500">
|
|
<Trans
|
|
i18nKey="auth.registerConsent"
|
|
components={{
|
|
terms: (
|
|
<a
|
|
href="/terms"
|
|
target="_blank"
|
|
rel="noopener"
|
|
className="underline hover:text-teal-600 dark:hover:text-teal-400"
|
|
/>
|
|
),
|
|
privacy: (
|
|
<a
|
|
href="/privacy"
|
|
target="_blank"
|
|
rel="noopener"
|
|
className="underline hover:text-teal-600 dark:hover:text-teal-400"
|
|
/>
|
|
),
|
|
}}
|
|
/>
|
|
</p>
|
|
)}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|