mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
@@ -138,7 +138,7 @@ export const authApi = {
|
|||||||
provider: string,
|
provider: string,
|
||||||
): Promise<{ authorize_url: string; state: string }> => {
|
): Promise<{ authorize_url: string; state: string }> => {
|
||||||
const response = await apiClient.get<{ authorize_url: string; state: string }>(
|
const response = await apiClient.get<{ authorize_url: string; state: string }>(
|
||||||
`/cabinet/auth/oauth/${provider}/authorize`,
|
`/cabinet/auth/oauth/${encodeURIComponent(provider)}/authorize`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -146,7 +146,7 @@ export const authApi = {
|
|||||||
// OAuth: callback (exchange code for tokens)
|
// OAuth: callback (exchange code for tokens)
|
||||||
oauthCallback: async (provider: string, code: string, state: string): Promise<AuthResponse> => {
|
oauthCallback: async (provider: string, code: string, state: string): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>(
|
const response = await apiClient.post<AuthResponse>(
|
||||||
`/cabinet/auth/oauth/${provider}/callback`,
|
`/cabinet/auth/oauth/${encodeURIComponent(provider)}/callback`,
|
||||||
{ code, state },
|
{ code, state },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -1,490 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { authApi } from '../api/auth';
|
|
||||||
import { isValidEmail } from '../utils/validation';
|
|
||||||
import { UI } from '../config/constants';
|
|
||||||
import { useAuthStore } from '../store/auth';
|
|
||||||
import { useTelegramSDK } from '../hooks/useTelegramSDK';
|
|
||||||
|
|
||||||
// Icons
|
|
||||||
const CloseIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const EmailIcon = () => (
|
|
||||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CheckIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface ChangeEmailModalProps {
|
|
||||||
onClose: () => void;
|
|
||||||
currentEmail: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Step = 'email' | 'code' | 'success';
|
|
||||||
|
|
||||||
function useIsMobile() {
|
|
||||||
const [isMobile, setIsMobile] = useState(() => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return window.innerWidth < 640;
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
const check = () => setIsMobile(window.innerWidth < 640);
|
|
||||||
window.addEventListener('resize', check);
|
|
||||||
return () => window.removeEventListener('resize', check);
|
|
||||||
}, []);
|
|
||||||
return isMobile;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RESEND_COOLDOWN = UI.RESEND_COOLDOWN_SEC;
|
|
||||||
|
|
||||||
export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { setUser } = useAuthStore();
|
|
||||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
|
|
||||||
const isMobileScreen = useIsMobile();
|
|
||||||
|
|
||||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const codeInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>('email');
|
|
||||||
const [newEmail, setNewEmail] = useState('');
|
|
||||||
const [code, setCode] = useState('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [resendCooldown, setResendCooldown] = useState(0);
|
|
||||||
|
|
||||||
const safeBottom = isTelegramWebApp
|
|
||||||
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
onClose();
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
// Keyboard: Escape to close
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [handleClose]);
|
|
||||||
|
|
||||||
// Scroll lock
|
|
||||||
useEffect(() => {
|
|
||||||
const scrollY = window.scrollY;
|
|
||||||
const preventScroll = (e: TouchEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-modal-content]')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
const preventWheel = (e: WheelEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-modal-content]')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
document.addEventListener('touchmove', preventScroll, { passive: false });
|
|
||||||
document.addEventListener('wheel', preventWheel, { passive: false });
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('touchmove', preventScroll);
|
|
||||||
document.removeEventListener('wheel', preventWheel);
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
window.scrollTo(0, scrollY);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Resend cooldown timer
|
|
||||||
useEffect(() => {
|
|
||||||
if (resendCooldown <= 0) return;
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setResendCooldown((prev) => Math.max(0, prev - 1));
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [resendCooldown]);
|
|
||||||
|
|
||||||
// Auto-focus inputs
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (step === 'email' && emailInputRef.current) {
|
|
||||||
emailInputRef.current.focus();
|
|
||||||
} else if (step === 'code' && codeInputRef.current) {
|
|
||||||
codeInputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [step]);
|
|
||||||
|
|
||||||
// Request email change mutation
|
|
||||||
const requestChangeMutation = useMutation({
|
|
||||||
mutationFn: (email: string) => authApi.requestEmailChange(email),
|
|
||||||
onSuccess: () => {
|
|
||||||
setError(null);
|
|
||||||
setStep('code');
|
|
||||||
setResendCooldown(RESEND_COOLDOWN);
|
|
||||||
},
|
|
||||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
|
||||||
const detail = err.response?.data?.detail;
|
|
||||||
if (detail?.includes('already registered') || detail?.includes('already in use')) {
|
|
||||||
setError(t('profile.changeEmail.emailAlreadyUsed'));
|
|
||||||
} else if (detail?.includes('same as current')) {
|
|
||||||
setError(t('profile.changeEmail.sameEmail'));
|
|
||||||
} else if (detail?.includes('rate limit') || detail?.includes('too many')) {
|
|
||||||
setError(t('profile.changeEmail.tooManyRequests'));
|
|
||||||
} else {
|
|
||||||
setError(detail || t('common.error'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify code mutation
|
|
||||||
const verifyCodeMutation = useMutation({
|
|
||||||
mutationFn: (verificationCode: string) => authApi.verifyEmailChange(verificationCode),
|
|
||||||
onSuccess: async () => {
|
|
||||||
setError(null);
|
|
||||||
setStep('success');
|
|
||||||
// Refresh user data
|
|
||||||
const updatedUser = await authApi.getMe();
|
|
||||||
setUser(updatedUser);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
|
||||||
},
|
|
||||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
|
||||||
const detail = err.response?.data?.detail;
|
|
||||||
if (detail?.includes('invalid') || detail?.includes('wrong')) {
|
|
||||||
setError(t('profile.changeEmail.invalidCode'));
|
|
||||||
} else if (detail?.includes('expired')) {
|
|
||||||
setError(t('profile.changeEmail.codeExpired'));
|
|
||||||
} else {
|
|
||||||
setError(detail || t('common.error'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSendCode = () => {
|
|
||||||
setError(null);
|
|
||||||
if (!newEmail.trim()) {
|
|
||||||
setError(t('profile.emailRequired'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isValidEmail(newEmail.trim())) {
|
|
||||||
setError(t('profile.invalidEmail'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (newEmail.toLowerCase().trim() === currentEmail.toLowerCase()) {
|
|
||||||
setError(t('profile.changeEmail.sameEmail'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
requestChangeMutation.mutate(newEmail.trim());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerifyCode = () => {
|
|
||||||
setError(null);
|
|
||||||
if (!code.trim()) {
|
|
||||||
setError(t('profile.changeEmail.enterCode'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (code.trim().length < 4) {
|
|
||||||
setError(t('profile.changeEmail.invalidCode'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
verifyCodeMutation.mutate(code.trim());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResendCode = () => {
|
|
||||||
if (resendCooldown > 0) return;
|
|
||||||
requestChangeMutation.mutate(newEmail.trim());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
setStep('email');
|
|
||||||
setCode('');
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPending = requestChangeMutation.isPending || verifyCodeMutation.isPending;
|
|
||||||
|
|
||||||
// Content JSX
|
|
||||||
const contentJSX = (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Header icon */}
|
|
||||||
<div className="flex items-center gap-4 pb-1">
|
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-accent-500/20 to-accent-600/20 text-accent-400">
|
|
||||||
<EmailIcon />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-lg font-bold text-dark-100">{t('profile.changeEmail.title')}</h3>
|
|
||||||
<p className="text-sm text-dark-400">
|
|
||||||
{step === 'email' && t('profile.changeEmail.description')}
|
|
||||||
{step === 'code' && t('profile.changeEmail.enterCodeDescription')}
|
|
||||||
{step === 'success' && t('profile.changeEmail.successDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current email display */}
|
|
||||||
{step === 'email' && (
|
|
||||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
|
|
||||||
<p className="text-xs text-dark-500">{t('profile.changeEmail.currentEmail')}</p>
|
|
||||||
<p className="mt-1 font-medium text-dark-200">{currentEmail}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Email input step */}
|
|
||||||
{step === 'email' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-400">
|
|
||||||
{t('profile.changeEmail.newEmail')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={emailInputRef}
|
|
||||||
type="email"
|
|
||||||
value={newEmail}
|
|
||||||
onChange={(e) => setNewEmail(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSendCode();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="new@email.com"
|
|
||||||
className="input w-full"
|
|
||||||
autoComplete="email"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSendCode}
|
|
||||||
disabled={isPending || !newEmail.trim()}
|
|
||||||
className="btn-primary w-full"
|
|
||||||
>
|
|
||||||
{requestChangeMutation.isPending ? (
|
|
||||||
<span className="flex items-center justify-center gap-2">
|
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
||||||
{t('common.loading')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
t('profile.changeEmail.sendCode')
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Code verification step */}
|
|
||||||
{step === 'code' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-xl border border-accent-500/30 bg-accent-500/10 p-4">
|
|
||||||
<p className="text-sm text-accent-400">
|
|
||||||
{t('profile.changeEmail.codeSentTo', { email: newEmail })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-400">
|
|
||||||
{t('profile.changeEmail.verificationCode')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={codeInputRef}
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleVerifyCode();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="000000"
|
|
||||||
maxLength={6}
|
|
||||||
className="input w-full text-center text-2xl tracking-[0.5em]"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleVerifyCode}
|
|
||||||
disabled={isPending || !code.trim()}
|
|
||||||
className="btn-primary w-full"
|
|
||||||
>
|
|
||||||
{verifyCodeMutation.isPending ? (
|
|
||||||
<span className="flex items-center justify-center gap-2">
|
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
||||||
{t('common.loading')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
t('profile.changeEmail.verify')
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleBack}
|
|
||||||
className="text-sm text-dark-400 hover:text-dark-200"
|
|
||||||
>
|
|
||||||
{t('common.back')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleResendCode}
|
|
||||||
disabled={resendCooldown > 0 || requestChangeMutation.isPending}
|
|
||||||
className={`text-sm ${
|
|
||||||
resendCooldown > 0 ? 'text-dark-500' : 'text-accent-400 hover:text-accent-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{resendCooldown > 0
|
|
||||||
? t('profile.changeEmail.resendIn', { seconds: resendCooldown })
|
|
||||||
: t('profile.changeEmail.resendCode')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success step */}
|
|
||||||
{step === 'success' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-col items-center rounded-xl border border-success-500/30 bg-success-500/10 p-6">
|
|
||||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-success-500/20">
|
|
||||||
<CheckIcon />
|
|
||||||
</div>
|
|
||||||
<p className="text-center font-medium text-success-400">
|
|
||||||
{t('profile.changeEmail.success')}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-center text-sm text-dark-400">{newEmail}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" onClick={handleClose} className="btn-primary w-full">
|
|
||||||
{t('common.close')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3">
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5 shrink-0 text-error-400"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm text-error-400">{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render modal based on screen size
|
|
||||||
const modalContent = isMobileScreen ? (
|
|
||||||
<>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div className="fixed inset-0 z-[9998] bg-black/70" onClick={handleClose} />
|
|
||||||
{/* Bottom sheet */}
|
|
||||||
<div
|
|
||||||
data-modal-content
|
|
||||||
className="fixed inset-x-0 bottom-0 z-[9999] flex max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-dark-900"
|
|
||||||
style={{
|
|
||||||
paddingBottom: safeBottom
|
|
||||||
? `${safeBottom + 20}px`
|
|
||||||
: 'max(20px, env(safe-area-inset-bottom))',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Handle bar */}
|
|
||||||
<div className="flex justify-center pb-1 pt-3">
|
|
||||||
<div className="h-1 w-10 rounded-full bg-dark-600" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-5 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<EmailIcon />
|
|
||||||
<span className="text-lg font-bold text-dark-100">
|
|
||||||
{t('profile.changeEmail.title')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="-mr-2 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-800"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent" />
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="overflow-y-auto px-5 py-5">{contentJSX}</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[10vh]"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-modal-content
|
|
||||||
className="w-full max-w-md overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900 shadow-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between border-b border-dark-700/50 bg-gradient-to-r from-dark-800/80 to-dark-800/40 px-6 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent-500/10 text-accent-400">
|
|
||||||
<EmailIcon />
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold text-dark-100">
|
|
||||||
{t('profile.changeEmail.title')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="-mr-1 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-700"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6">{contentJSX}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
return createPortal(modalContent, document.body);
|
|
||||||
}
|
|
||||||
return modalContent;
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type EmailAuthEnabled,
|
type EmailAuthEnabled,
|
||||||
} from '../api/branding';
|
} from '../api/branding';
|
||||||
import { getAndClearReturnUrl } from '../utils/token';
|
import { getAndClearReturnUrl } from '../utils/token';
|
||||||
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
|
import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
import TelegramLoginButton from '../components/TelegramLoginButton';
|
import TelegramLoginButton from '../components/TelegramLoginButton';
|
||||||
import OAuthProviderIcon from '../components/OAuthProviderIcon';
|
import OAuthProviderIcon from '../components/OAuthProviderIcon';
|
||||||
@@ -54,6 +54,12 @@ export default function Login() {
|
|||||||
const [forgotPasswordSent, setForgotPasswordSent] = useState(false);
|
const [forgotPasswordSent, setForgotPasswordSent] = useState(false);
|
||||||
const [forgotPasswordLoading, setForgotPasswordLoading] = useState(false);
|
const [forgotPasswordLoading, setForgotPasswordLoading] = useState(false);
|
||||||
const [forgotPasswordError, setForgotPasswordError] = useState('');
|
const [forgotPasswordError, setForgotPasswordError] = useState('');
|
||||||
|
const [showEmailForm, setShowEmailForm] = useState(() => !!referralCode);
|
||||||
|
|
||||||
|
// Telegram safe area insets
|
||||||
|
const { safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
|
||||||
|
const safeTop = Math.max(safeAreaInset.top, contentSafeAreaInset.top);
|
||||||
|
const safeBottom = Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom);
|
||||||
|
|
||||||
// Получаем URL для возврата после авторизации
|
// Получаем URL для возврата после авторизации
|
||||||
const getReturnUrl = useCallback(() => {
|
const getReturnUrl = useCallback(() => {
|
||||||
@@ -100,7 +106,7 @@ export default function Login() {
|
|||||||
queryFn: authApi.getOAuthProviders,
|
queryFn: authApi.getOAuthProviders,
|
||||||
staleTime: 60000,
|
staleTime: 60000,
|
||||||
});
|
});
|
||||||
const oauthProviders = oauthData?.providers ?? [];
|
const oauthProviders = Array.isArray(oauthData?.providers) ? oauthData.providers : [];
|
||||||
|
|
||||||
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
|
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -109,6 +115,17 @@ export default function Login() {
|
|||||||
setOauthLoading(provider);
|
setOauthLoading(provider);
|
||||||
try {
|
try {
|
||||||
const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
|
const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
|
||||||
|
|
||||||
|
// Validate redirect URL — only allow HTTPS to prevent open redirect
|
||||||
|
try {
|
||||||
|
const parsed = new URL(authorize_url);
|
||||||
|
if (parsed.protocol !== 'https:') {
|
||||||
|
throw new Error('Invalid OAuth redirect URL');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid OAuth redirect URL');
|
||||||
|
}
|
||||||
|
|
||||||
saveOAuthState(state, provider);
|
saveOAuthState(state, provider);
|
||||||
window.location.href = authorize_url;
|
window.location.href = authorize_url;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -122,7 +139,7 @@ export default function Login() {
|
|||||||
// If email auth is disabled but user came with ref param, redirect to bot
|
// If email auth is disabled but user came with ref param, redirect to bot
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (referralCode && emailAuthConfig?.enabled === false && botUsername) {
|
if (referralCode && emailAuthConfig?.enabled === false && botUsername) {
|
||||||
window.location.href = `https://t.me/${botUsername}?start=${referralCode}`;
|
window.location.href = `https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`;
|
||||||
}
|
}
|
||||||
}, [referralCode, emailAuthConfig, botUsername]);
|
}, [referralCode, emailAuthConfig, botUsername]);
|
||||||
|
|
||||||
@@ -165,6 +182,7 @@ export default function Login() {
|
|||||||
const error = err as { response?: { status?: number; data?: { detail?: string } } };
|
const error = err as { response?: { status?: number; data?: { detail?: string } } };
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
const detail = error.response?.data?.detail;
|
const detail = error.response?.data?.detail;
|
||||||
|
if (import.meta.env.DEV)
|
||||||
console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail);
|
console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail);
|
||||||
|
|
||||||
if (status === 401 && attempt < MAX_RETRIES) {
|
if (status === 401 && attempt < MAX_RETRIES) {
|
||||||
@@ -200,7 +218,7 @@ export default function Login() {
|
|||||||
const error = err as { response?: { status?: number; data?: { detail?: string } } };
|
const error = err as { response?: { status?: number; data?: { detail?: string } } };
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
const detail = error.response?.data?.detail;
|
const detail = error.response?.data?.detail;
|
||||||
console.warn('Telegram auth retry failed:', status, detail);
|
if (import.meta.env.DEV) console.warn('Telegram auth retry failed:', status, detail);
|
||||||
setError(
|
setError(
|
||||||
detail ||
|
detail ||
|
||||||
t('auth.telegramRetryFailed', 'Authorization failed. Close the app and try again.'),
|
t('auth.telegramRetryFailed', 'Authorization failed. Close the app and try again.'),
|
||||||
@@ -301,45 +319,57 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
|
<div
|
||||||
|
className="flex min-h-[100dvh] items-center justify-center px-4 sm:px-6 lg:px-8"
|
||||||
|
style={{
|
||||||
|
paddingTop:
|
||||||
|
safeTop > 0 ? `${safeTop + 16}px` : 'calc(1rem + env(safe-area-inset-top, 0px))',
|
||||||
|
paddingBottom:
|
||||||
|
safeBottom > 0 ? `${safeBottom + 16}px` : 'calc(1rem + env(safe-area-inset-bottom, 0px))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Background gradient */}
|
{/* Background gradient */}
|
||||||
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
|
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
|
||||||
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
|
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
|
||||||
|
|
||||||
{/* Language switcher */}
|
{/* Language switcher */}
|
||||||
<div className="fixed right-4 top-4 z-50">
|
<div
|
||||||
|
className="fixed right-3 z-50"
|
||||||
|
style={{
|
||||||
|
top: safeTop > 0 ? `${safeTop + 12}px` : 'calc(12px + env(safe-area-inset-top, 0px))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full max-w-md space-y-8">
|
<div className="relative w-full max-w-md space-y-5">
|
||||||
{/* Logo */}
|
{/* Logo & branding */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="relative mx-auto mb-6 flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 shadow-lg shadow-accent-500/30">
|
<div className="relative mx-auto mb-3 flex h-12 w-12 items-center justify-center overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800/80 shadow-md">
|
||||||
{/* Always show letter as fallback */}
|
{/* Letter fallback */}
|
||||||
<span
|
<span
|
||||||
className={`absolute text-2xl font-bold text-white transition-opacity duration-200 ${branding?.has_custom_logo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}
|
className={`absolute text-lg font-bold text-accent-400 transition-opacity duration-200 ${branding?.has_custom_logo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}
|
||||||
>
|
>
|
||||||
{appLogo}
|
{appLogo}
|
||||||
</span>
|
</span>
|
||||||
{/* Logo image with smooth fade-in */}
|
{/* Logo image */}
|
||||||
{branding?.has_custom_logo && logoUrl && (
|
{branding?.has_custom_logo && logoUrl && (
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
alt={appName || 'Logo'}
|
alt={appName || 'Logo'}
|
||||||
className={`absolute h-full w-full object-cover transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
className={`absolute h-full w-full object-contain transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
onLoad={() => setLogoLoaded(true)}
|
onLoad={() => setLogoLoaded(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{appName && <h1 className="text-3xl font-bold text-dark-50">{appName}</h1>}
|
{appName && <h1 className="text-2xl font-bold text-dark-50">{appName}</h1>}
|
||||||
<p className="mt-2 text-dark-400">{t('auth.loginSubtitle')}</p>
|
|
||||||
|
|
||||||
{/* Referral Banner - only show when email auth is enabled */}
|
{/* Referral Banner */}
|
||||||
{referralCode && isEmailAuthEnabled && (
|
{referralCode && isEmailAuthEnabled && (
|
||||||
<div className="mt-4 rounded-xl border border-accent-500/30 bg-accent-500/10 p-3">
|
<div className="mt-3 rounded-xl border border-accent-500/30 bg-accent-500/10 p-2.5">
|
||||||
<div className="flex items-center gap-2 text-accent-400">
|
<div className="flex items-center justify-center gap-2 text-accent-400">
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 flex-shrink-0"
|
className="h-4 w-4 flex-shrink-0"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -351,7 +381,7 @@ export default function Login() {
|
|||||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium">{t('auth.referralInvite')}</span>
|
<span className="text-xs font-medium">{t('auth.referralInvite')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -360,9 +390,9 @@ export default function Login() {
|
|||||||
{/* Check Email Screen */}
|
{/* Check Email Screen */}
|
||||||
{registeredEmail ? (
|
{registeredEmail ? (
|
||||||
<div className="card text-center">
|
<div className="card text-center">
|
||||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-success-500/20">
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-success-500/20">
|
||||||
<svg
|
<svg
|
||||||
className="h-8 w-8 text-success-400"
|
className="h-7 w-7 text-success-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -375,14 +405,14 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mb-2 text-xl font-bold text-dark-50">
|
<h2 className="mb-2 text-lg font-bold text-dark-50">
|
||||||
{t('auth.checkEmail', 'Check your email')}
|
{t('auth.checkEmail', 'Check your email')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-4 text-dark-400">
|
<p className="mb-3 text-sm text-dark-400">
|
||||||
{t('auth.verificationSent', 'We sent a verification link to:')}
|
{t('auth.verificationSent', 'We sent a verification link to:')}
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-6 font-medium text-accent-400">{registeredEmail}</p>
|
<p className="mb-4 text-sm font-medium text-accent-400">{registeredEmail}</p>
|
||||||
<p className="mb-6 text-sm text-dark-500">
|
<p className="mb-5 text-xs text-dark-500">
|
||||||
{t(
|
{t(
|
||||||
'auth.clickLinkToVerify',
|
'auth.clickLinkToVerify',
|
||||||
'Click the link in the email to verify your account and log in.',
|
'Click the link in the email to verify your account and log in.',
|
||||||
@@ -399,31 +429,29 @@ export default function Login() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Card */
|
/* Main auth card */
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-3 text-sm text-error-400">
|
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-2.5 text-sm text-error-400">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Telegram auth section */}
|
{/* Telegram auth section */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-3">
|
||||||
<p className="text-center text-sm text-dark-400">{t('auth.registerHint')}</p>
|
|
||||||
|
|
||||||
{isLoading && isTelegramWebApp ? (
|
{isLoading && isTelegramWebApp ? (
|
||||||
<div className="py-8 text-center">
|
<div className="py-6 text-center">
|
||||||
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
<p className="text-sm text-dark-400">{t('auth.authenticating')}</p>
|
<p className="text-sm text-dark-400">{t('auth.authenticating')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : isTelegramWebApp && error ? (
|
) : isTelegramWebApp && error ? (
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-3 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={handleRetryTelegramAuth}
|
onClick={handleRetryTelegramAuth}
|
||||||
className="btn-primary mx-auto flex items-center gap-2 px-6 py-3"
|
className="btn-primary mx-auto flex items-center gap-2 px-5 py-2.5"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-4 w-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -449,50 +477,175 @@ export default function Login() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OAuth providers section */}
|
{/* OAuth providers - compact icon row */}
|
||||||
{oauthProviders.length > 0 && (
|
{oauthProviders.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="my-6 flex items-center gap-3">
|
<div className="my-4 flex items-center gap-3">
|
||||||
<div className="h-px flex-1 bg-dark-700" />
|
<div className="h-px flex-1 bg-dark-700" />
|
||||||
<span className="text-xs text-dark-500">{t('auth.or', 'or')}</span>
|
<span className="text-xs text-dark-500">{t('auth.or', 'or')}</span>
|
||||||
<div className="h-px flex-1 bg-dark-700" />
|
<div className="h-px flex-1 bg-dark-700" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="flex items-stretch gap-2">
|
||||||
{oauthProviders.map((provider) => (
|
{oauthProviders.map((provider) => (
|
||||||
<button
|
<button
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOAuthLogin(provider.name)}
|
onClick={() => handleOAuthLogin(provider.name)}
|
||||||
disabled={oauthLoading !== null}
|
disabled={oauthLoading !== null}
|
||||||
className="hover:bg-dark-750 flex w-full items-center justify-center gap-3 rounded-xl border border-dark-700 bg-dark-800 px-4 py-3 text-sm font-medium text-dark-100 transition-colors hover:border-dark-600 disabled:opacity-50"
|
className="flex flex-1 flex-col items-center justify-center gap-1.5 rounded-xl border border-dark-700 bg-dark-800/80 py-2.5 transition-all hover:border-dark-600 hover:bg-dark-700 disabled:opacity-50"
|
||||||
|
title={provider.display_name}
|
||||||
>
|
>
|
||||||
{oauthLoading === provider.name ? (
|
{oauthLoading === provider.name ? (
|
||||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-dark-400 border-t-white" />
|
<span className="h-5 w-5 animate-spin rounded-full border-2 border-dark-400 border-t-white" />
|
||||||
) : (
|
) : (
|
||||||
<OAuthProviderIcon provider={provider.name} className="h-5 w-5" />
|
<OAuthProviderIcon provider={provider.name} className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
{t(
|
<span className="text-[10px] leading-none text-dark-500">
|
||||||
`auth.continueWith${provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}`,
|
{provider.display_name}
|
||||||
`Continue with ${provider.display_name}`,
|
</span>
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email auth section - only when enabled */}
|
{/* Email auth section - collapsible */}
|
||||||
{isEmailAuthEnabled && (
|
{isEmailAuthEnabled && (
|
||||||
<>
|
<>
|
||||||
{/* Divider */}
|
<div className="my-4 flex items-center gap-3">
|
||||||
<div className="my-6 flex items-center gap-3">
|
|
||||||
<div className="h-px flex-1 bg-dark-700" />
|
<div className="h-px flex-1 bg-dark-700" />
|
||||||
<span className="text-xs text-dark-500">{t('auth.or', 'or')}</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEmailForm(!showEmailForm)}
|
||||||
|
className="flex items-center gap-1.5 rounded-full border border-dark-700 bg-dark-800/60 px-3.5 py-1.5 text-xs font-medium text-dark-300 transition-all hover:border-dark-600 hover:bg-dark-700 hover:text-dark-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{t('auth.loginWithEmail')}</span>
|
||||||
|
<svg
|
||||||
|
className={`h-3 w-3 text-dark-400 transition-transform duration-300 ${showEmailForm ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<div className="h-px flex-1 bg-dark-700" />
|
<div className="h-px flex-1 bg-dark-700" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5">
|
{/* Collapsible email form */}
|
||||||
{/* Login / Register toggle */}
|
<div
|
||||||
|
className={`grid transition-[grid-template-rows] duration-300 ease-in-out ${
|
||||||
|
showEmailForm ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
||||||
|
}`}
|
||||||
|
style={{ transform: 'translateZ(0)' }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="space-y-4 pb-1 pt-1">
|
||||||
|
{showForgotPassword ? (
|
||||||
|
/* Forgot password screen - replaces login/register */
|
||||||
|
forgotPasswordSent ? (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-success-500/20">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-success-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-dark-100">
|
||||||
|
{t('auth.checkEmail', 'Check your email')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-dark-400">
|
||||||
|
{t(
|
||||||
|
'auth.passwordResetSent',
|
||||||
|
'If an account exists with this email, we sent password reset instructions.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeForgotPasswordModal}
|
||||||
|
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||||
|
>
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-center text-sm text-dark-400">
|
||||||
|
{t(
|
||||||
|
'auth.forgotPasswordHint',
|
||||||
|
'Enter your email and we will send you instructions to reset your password.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleForgotPassword} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="forgotEmail" className="label">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="forgotEmail"
|
||||||
|
type="email"
|
||||||
|
value={forgotPasswordEmail}
|
||||||
|
onChange={(e) => setForgotPasswordEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="input"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{forgotPasswordError && (
|
||||||
|
<p className="text-sm text-error-400">{forgotPasswordError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={forgotPasswordLoading}
|
||||||
|
className="btn-primary w-full py-2.5"
|
||||||
|
>
|
||||||
|
{forgotPasswordLoading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
{t('common.loading')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t('auth.sendResetLink', 'Send reset link')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeForgotPasswordModal}
|
||||||
|
className="text-sm text-dark-400 transition-colors hover:text-dark-200"
|
||||||
|
>
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
/* Normal login / register */
|
||||||
|
<>
|
||||||
<div className="flex rounded-lg bg-dark-800 p-1">
|
<div className="flex rounded-lg bg-dark-800 p-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -518,8 +671,7 @@ export default function Login() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="space-y-4" onSubmit={handleEmailSubmit}>
|
<form className="space-y-3" onSubmit={handleEmailSubmit}>
|
||||||
{/* First name field - only for registration */}
|
|
||||||
{authMode === 'register' && (
|
{authMode === 'register' && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="firstName" className="label">
|
<label htmlFor="firstName" className="label">
|
||||||
@@ -531,7 +683,10 @@ export default function Login() {
|
|||||||
type="text"
|
type="text"
|
||||||
autoComplete="given-name"
|
autoComplete="given-name"
|
||||||
className="input"
|
className="input"
|
||||||
placeholder={t('auth.firstNamePlaceholder', 'Your name (optional)')}
|
placeholder={t(
|
||||||
|
'auth.firstNamePlaceholder',
|
||||||
|
'Your name (optional)',
|
||||||
|
)}
|
||||||
value={firstName}
|
value={firstName}
|
||||||
onChange={(e) => setFirstName(e.target.value)}
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -563,7 +718,9 @@ export default function Login() {
|
|||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
|
autoComplete={
|
||||||
|
authMode === 'login' ? 'current-password' : 'new-password'
|
||||||
|
}
|
||||||
required
|
required
|
||||||
className="input"
|
className="input"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
@@ -572,7 +729,6 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Confirm password - only for registration */}
|
|
||||||
{authMode === 'register' && (
|
{authMode === 'register' && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="confirmPassword" className="label">
|
<label htmlFor="confirmPassword" className="label">
|
||||||
@@ -592,7 +748,11 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="submit" disabled={isLoading} className="btn-primary w-full py-3">
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="btn-primary w-full py-2.5"
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<span className="flex items-center justify-center gap-2">
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
@@ -606,7 +766,6 @@ export default function Login() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Verification notice for registration */}
|
|
||||||
{authMode === 'register' && (
|
{authMode === 'register' && (
|
||||||
<p className="text-center text-xs text-dark-500">
|
<p className="text-center text-xs text-dark-500">
|
||||||
{t(
|
{t(
|
||||||
@@ -616,9 +775,8 @@ export default function Login() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Forgot password link - only for login */}
|
|
||||||
{authMode === 'login' && (
|
{authMode === 'login' && (
|
||||||
<div className="space-y-2 text-center">
|
<div className="text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowForgotPassword(true)}
|
onClick={() => setShowForgotPassword(true)}
|
||||||
@@ -628,117 +786,16 @@ export default function Login() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Forgot Password Modal */}
|
|
||||||
{showForgotPassword && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-black/60" onClick={closeForgotPasswordModal} />
|
|
||||||
<div className="relative w-full max-w-md rounded-2xl border border-dark-700 bg-dark-900 p-6">
|
|
||||||
<button
|
|
||||||
onClick={closeForgotPasswordModal}
|
|
||||||
className="absolute right-4 top-4 text-dark-400 transition-colors hover:text-dark-200"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{forgotPasswordSent ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-success-500/20">
|
|
||||||
<svg
|
|
||||||
className="h-8 w-8 text-success-400"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="mb-2 text-xl font-bold text-dark-50">
|
|
||||||
{t('auth.checkEmail', 'Check your email')}
|
|
||||||
</h3>
|
|
||||||
<p className="mb-4 text-dark-400">
|
|
||||||
{t(
|
|
||||||
'auth.passwordResetSent',
|
|
||||||
'If an account exists with this email, we sent password reset instructions.',
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<button onClick={closeForgotPasswordModal} className="btn-primary w-full">
|
|
||||||
{t('common.close', 'Close')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h3 className="mb-2 text-xl font-bold text-dark-50">
|
|
||||||
{t('auth.forgotPassword', 'Forgot password?')}
|
|
||||||
</h3>
|
|
||||||
<p className="mb-6 text-dark-400">
|
|
||||||
{t(
|
|
||||||
'auth.forgotPasswordHint',
|
|
||||||
'Enter your email and we will send you instructions to reset your password.',
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleForgotPassword} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="forgotEmail" className="label">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="forgotEmail"
|
|
||||||
type="email"
|
|
||||||
value={forgotPasswordEmail}
|
|
||||||
onChange={(e) => setForgotPasswordEmail(e.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
className="input"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{forgotPasswordError && (
|
|
||||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-3 text-sm text-error-400">
|
|
||||||
{forgotPasswordError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={forgotPasswordLoading}
|
|
||||||
className="btn-primary w-full"
|
|
||||||
>
|
|
||||||
{forgotPasswordLoading ? (
|
|
||||||
<span className="flex items-center justify-center gap-2">
|
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
||||||
{t('common.loading')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
t('auth.sendResetLink', 'Send reset link')
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import { isValidEmail } from '../utils/validation';
|
import { isValidEmail } from '../utils/validation';
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '../api/notifications';
|
} from '../api/notifications';
|
||||||
import { referralApi } from '../api/referral';
|
import { referralApi } from '../api/referral';
|
||||||
import { brandingApi, type EmailAuthEnabled } from '../api/branding';
|
import { brandingApi, type EmailAuthEnabled } from '../api/branding';
|
||||||
import ChangeEmailModal from '../components/ChangeEmailModal';
|
import { UI } from '../config/constants';
|
||||||
import { Card } from '@/components/data-display/Card';
|
import { Card } from '@/components/data-display/Card';
|
||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
import { Switch } from '@/components/primitives/Switch';
|
import { Switch } from '@/components/primitives/Switch';
|
||||||
@@ -70,7 +70,15 @@ export default function Profile() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [showChangeEmailModal, setShowChangeEmailModal] = useState(false);
|
|
||||||
|
// Inline email change flow
|
||||||
|
const [changeEmailStep, setChangeEmailStep] = useState<'email' | 'code' | 'success' | null>(null);
|
||||||
|
const [newEmail, setNewEmail] = useState('');
|
||||||
|
const [changeCode, setChangeCode] = useState('');
|
||||||
|
const [changeError, setChangeError] = useState<string | null>(null);
|
||||||
|
const [resendCooldown, setResendCooldown] = useState(0);
|
||||||
|
const newEmailInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const codeInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Referral data
|
// Referral data
|
||||||
const { data: referralInfo } = useQuery({
|
const { data: referralInfo } = useQuery({
|
||||||
@@ -170,6 +178,117 @@ export default function Profile() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Email change mutations
|
||||||
|
const requestEmailChangeMutation = useMutation({
|
||||||
|
mutationFn: (emailAddr: string) => authApi.requestEmailChange(emailAddr),
|
||||||
|
onSuccess: () => {
|
||||||
|
setChangeError(null);
|
||||||
|
setChangeEmailStep('code');
|
||||||
|
setResendCooldown(UI.RESEND_COOLDOWN_SEC);
|
||||||
|
},
|
||||||
|
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
||||||
|
const detail = err.response?.data?.detail;
|
||||||
|
if (detail?.includes('already registered') || detail?.includes('already in use')) {
|
||||||
|
setChangeError(t('profile.changeEmail.emailAlreadyUsed'));
|
||||||
|
} else if (detail?.includes('same as current')) {
|
||||||
|
setChangeError(t('profile.changeEmail.sameEmail'));
|
||||||
|
} else if (detail?.includes('rate limit') || detail?.includes('too many')) {
|
||||||
|
setChangeError(t('profile.changeEmail.tooManyRequests'));
|
||||||
|
} else {
|
||||||
|
setChangeError(detail || t('common.error'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyEmailChangeMutation = useMutation({
|
||||||
|
mutationFn: (verificationCode: string) => authApi.verifyEmailChange(verificationCode),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setChangeError(null);
|
||||||
|
setChangeEmailStep('success');
|
||||||
|
const updatedUser = await authApi.getMe();
|
||||||
|
setUser(updatedUser);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||||
|
},
|
||||||
|
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
||||||
|
const detail = err.response?.data?.detail;
|
||||||
|
if (detail?.includes('invalid') || detail?.includes('wrong')) {
|
||||||
|
setChangeError(t('profile.changeEmail.invalidCode'));
|
||||||
|
} else if (detail?.includes('expired')) {
|
||||||
|
setChangeError(t('profile.changeEmail.codeExpired'));
|
||||||
|
} else {
|
||||||
|
setChangeError(detail || t('common.error'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resend cooldown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendCooldown <= 0) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setResendCooldown((prev) => Math.max(0, prev - 1));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [resendCooldown]);
|
||||||
|
|
||||||
|
// Auto-focus inputs on step change
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (changeEmailStep === 'email') newEmailInputRef.current?.focus();
|
||||||
|
else if (changeEmailStep === 'code') codeInputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [changeEmailStep]);
|
||||||
|
|
||||||
|
// Auto-close success after 3s
|
||||||
|
useEffect(() => {
|
||||||
|
if (changeEmailStep !== 'success') return;
|
||||||
|
const timer = setTimeout(() => resetChangeEmail(), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [changeEmailStep]);
|
||||||
|
|
||||||
|
const resetChangeEmail = () => {
|
||||||
|
setChangeEmailStep(null);
|
||||||
|
setNewEmail('');
|
||||||
|
setChangeCode('');
|
||||||
|
setChangeError(null);
|
||||||
|
setResendCooldown(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendChangeCode = () => {
|
||||||
|
setChangeError(null);
|
||||||
|
if (!newEmail.trim()) {
|
||||||
|
setChangeError(t('profile.emailRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidEmail(newEmail.trim())) {
|
||||||
|
setChangeError(t('profile.invalidEmail'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (user?.email && newEmail.toLowerCase().trim() === user.email.toLowerCase()) {
|
||||||
|
setChangeError(t('profile.changeEmail.sameEmail'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestEmailChangeMutation.mutate(newEmail.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyChangeCode = () => {
|
||||||
|
setChangeError(null);
|
||||||
|
if (!changeCode.trim()) {
|
||||||
|
setChangeError(t('profile.changeEmail.enterCode'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (changeCode.trim().length < 4) {
|
||||||
|
setChangeError(t('profile.changeEmail.invalidCode'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verifyEmailChangeMutation.mutate(changeCode.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendChangeCode = () => {
|
||||||
|
if (resendCooldown > 0) return;
|
||||||
|
requestEmailChangeMutation.mutate(newEmail.trim());
|
||||||
|
};
|
||||||
|
|
||||||
const { data: notificationSettings, isLoading: notificationsLoading } = useQuery({
|
const { data: notificationSettings, isLoading: notificationsLoading } = useQuery({
|
||||||
queryKey: ['notification-settings'],
|
queryKey: ['notification-settings'],
|
||||||
queryFn: notificationsApi.getSettings,
|
queryFn: notificationsApi.getSettings,
|
||||||
@@ -324,20 +443,31 @@ export default function Profile() {
|
|||||||
<p className="mb-4 text-sm text-warning-400">
|
<p className="mb-4 text-sm text-warning-400">
|
||||||
{t('profile.verificationRequired')}
|
{t('profile.verificationRequired')}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => resendVerificationMutation.mutate()}
|
onClick={() => resendVerificationMutation.mutate()}
|
||||||
loading={resendVerificationMutation.isPending}
|
loading={resendVerificationMutation.isPending}
|
||||||
>
|
>
|
||||||
{t('profile.resendVerification')}
|
{t('profile.resendVerification')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{(user.auth_type === 'telegram' || user.auth_type === 'email') && (
|
||||||
|
<button
|
||||||
|
onClick={() => setChangeEmailStep('email')}
|
||||||
|
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||||
|
>
|
||||||
|
{t('profile.changeEmail.button')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{user.email_verified && (
|
{user.email_verified &&
|
||||||
|
(user.auth_type === 'telegram' || user.auth_type === 'email') && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
|
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowChangeEmailModal(true)}
|
onClick={() => setChangeEmailStep('email')}
|
||||||
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||||
>
|
>
|
||||||
<PencilIcon />
|
<PencilIcon />
|
||||||
@@ -345,6 +475,147 @@ export default function Profile() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Inline email change flow */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{changeEmailStep === 'email' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="space-y-3 border-t border-dark-800/50 pt-4">
|
||||||
|
<label className="block text-sm font-medium text-dark-400">
|
||||||
|
{t('profile.changeEmail.newEmail')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={newEmailInputRef}
|
||||||
|
type="email"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendChangeCode();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="new@email.com"
|
||||||
|
className="input w-full"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
{changeError && <p className="text-sm text-error-400">{changeError}</p>}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleSendChangeCode}
|
||||||
|
loading={requestEmailChangeMutation.isPending}
|
||||||
|
disabled={!newEmail.trim()}
|
||||||
|
>
|
||||||
|
{t('profile.changeEmail.sendCode')}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
onClick={resetChangeEmail}
|
||||||
|
className="text-sm text-dark-400 hover:text-dark-200"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changeEmailStep === 'code' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="space-y-3 border-t border-dark-800/50 pt-4">
|
||||||
|
<div className="rounded-linear border border-accent-500/30 bg-accent-500/10 p-3">
|
||||||
|
<p className="text-sm text-accent-400">
|
||||||
|
{t('profile.changeEmail.codeSentTo', { email: newEmail })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="block text-sm font-medium text-dark-400">
|
||||||
|
{t('profile.changeEmail.verificationCode')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={codeInputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={changeCode}
|
||||||
|
onChange={(e) => setChangeCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleVerifyChangeCode();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="000000"
|
||||||
|
maxLength={6}
|
||||||
|
className="input w-full text-center text-2xl tracking-[0.5em]"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
{changeError && <p className="text-sm text-error-400">{changeError}</p>}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleVerifyChangeCode}
|
||||||
|
loading={verifyEmailChangeMutation.isPending}
|
||||||
|
disabled={!changeCode.trim()}
|
||||||
|
>
|
||||||
|
{t('profile.changeEmail.verify')}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setChangeEmailStep('email');
|
||||||
|
setChangeCode('');
|
||||||
|
setChangeError(null);
|
||||||
|
}}
|
||||||
|
className="text-sm text-dark-400 hover:text-dark-200"
|
||||||
|
>
|
||||||
|
{t('common.back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleResendChangeCode}
|
||||||
|
disabled={resendCooldown > 0 || requestEmailChangeMutation.isPending}
|
||||||
|
className={`text-sm ${resendCooldown > 0 ? 'text-dark-500' : 'text-accent-400 hover:text-accent-300'}`}
|
||||||
|
>
|
||||||
|
{resendCooldown > 0
|
||||||
|
? t('profile.changeEmail.resendIn', { seconds: resendCooldown })
|
||||||
|
: t('profile.changeEmail.resendCode')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changeEmailStep === 'success' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="border-t border-dark-800/50 pt-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-linear border border-success-500/30 bg-success-500/10 p-4">
|
||||||
|
<CheckIcon />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-success-400">
|
||||||
|
{t('profile.changeEmail.success')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-dark-400">{newEmail}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -586,14 +857,6 @@ export default function Profile() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Change Email Modal */}
|
|
||||||
{showChangeEmailModal && user?.email && (
|
|
||||||
<ChangeEmailModal
|
|
||||||
onClose={() => setShowChangeEmailModal(false)}
|
|
||||||
currentEmail={user.email}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function TopUpAmount() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
|
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
|
||||||
useCurrency();
|
useCurrency();
|
||||||
const { openInvoice } = usePlatform();
|
const { openInvoice, openTelegramLink, openLink } = usePlatform();
|
||||||
const haptic = useHaptic();
|
const haptic = useHaptic();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -281,6 +281,15 @@ export default function TopUpAmount() {
|
|||||||
: convertAmount(rub).toFixed(currencyDecimals);
|
: convertAmount(rub).toFixed(currencyDecimals);
|
||||||
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending;
|
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending;
|
||||||
|
|
||||||
|
const handleOpenPayment = () => {
|
||||||
|
if (!paymentUrl) return;
|
||||||
|
if (paymentUrl.includes('t.me/')) {
|
||||||
|
openTelegramLink(paymentUrl);
|
||||||
|
} else {
|
||||||
|
openLink(paymentUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopyUrl = async () => {
|
const handleCopyUrl = async () => {
|
||||||
if (!paymentUrl) return;
|
if (!paymentUrl) return;
|
||||||
try {
|
try {
|
||||||
@@ -478,15 +487,14 @@ export default function TopUpAmount() {
|
|||||||
|
|
||||||
<p className="text-sm text-dark-400">{t('balance.clickToOpenPayment')}</p>
|
<p className="text-sm text-dark-400">{t('balance.clickToOpenPayment')}</p>
|
||||||
|
|
||||||
<a
|
<button
|
||||||
href={paymentUrl}
|
type="button"
|
||||||
target="_blank"
|
onClick={handleOpenPayment}
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-success-500 font-bold text-white transition-colors hover:bg-success-400 active:bg-success-600"
|
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-success-500 font-bold text-white transition-colors hover:bg-success-400 active:bg-success-600"
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon />
|
<ExternalLinkIcon />
|
||||||
<span>{t('balance.openPaymentPage')}</span>
|
<span>{t('balance.openPaymentPage')}</span>
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1 rounded-lg border border-dark-700/50 bg-dark-800/70 px-3 py-2">
|
<div className="min-w-0 flex-1 rounded-lg border border-dark-700/50 bg-dark-800/70 px-3 py-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user