mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
@@ -108,4 +108,20 @@ export const authApi = {
|
||||
const response = await apiClient.get<User>('/cabinet/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Request email change - sends verification code to new email
|
||||
requestEmailChange: async (newEmail: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/cabinet/auth/email/change', {
|
||||
new_email: newEmail,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Verify email change with code
|
||||
verifyEmailChange: async (code: string): Promise<{ message: string; email: string }> => {
|
||||
const response = await apiClient.post('/cabinet/auth/email/change/verify', {
|
||||
code,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
504
src/components/ChangeEmailModal.tsx
Normal file
504
src/components/ChangeEmailModal.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
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 { useAuthStore } from '../store/auth';
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||
|
||||
// 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 = 60; // seconds
|
||||
|
||||
export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { setUser } = useAuthStore();
|
||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp();
|
||||
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]);
|
||||
|
||||
// Telegram back button
|
||||
useEffect(() => {
|
||||
if (!webApp?.BackButton) return;
|
||||
webApp.BackButton.show();
|
||||
webApp.BackButton.onClick(handleClose);
|
||||
return () => {
|
||||
webApp.BackButton.offClick(handleClose);
|
||||
webApp.BackButton.hide();
|
||||
};
|
||||
}, [webApp, 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 validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email.trim());
|
||||
};
|
||||
|
||||
const handleSendCode = () => {
|
||||
setError(null);
|
||||
if (!newEmail.trim()) {
|
||||
setError(t('profile.emailRequired'));
|
||||
return;
|
||||
}
|
||||
if (!validateEmail(newEmail)) {
|
||||
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;
|
||||
}
|
||||
@@ -2044,6 +2044,28 @@
|
||||
"canLoginWithEmail": "You can now log in using your email and password.",
|
||||
"emailAlreadyRegistered": "This email is already linked to another account. If this is your email, log in with it or contact support.",
|
||||
"alreadyHaveEmail": "You already have a verified email linked.",
|
||||
"changeEmail": {
|
||||
"button": "Change email",
|
||||
"title": "Change Email",
|
||||
"description": "Enter your new email address. A verification code will be sent to it.",
|
||||
"currentEmail": "Current email",
|
||||
"newEmail": "New email",
|
||||
"sendCode": "Send code",
|
||||
"enterCode": "Enter code",
|
||||
"enterCodeDescription": "Enter the verification code sent to your new email.",
|
||||
"verificationCode": "Verification code",
|
||||
"codeSentTo": "Code sent to {{email}}",
|
||||
"verify": "Verify",
|
||||
"resendCode": "Resend code",
|
||||
"resendIn": "Resend in {{seconds}} sec.",
|
||||
"success": "Email changed successfully!",
|
||||
"successDescription": "Your email has been successfully updated.",
|
||||
"emailAlreadyUsed": "This email is already used by another account.",
|
||||
"sameEmail": "New email is the same as current.",
|
||||
"invalidCode": "Invalid verification code.",
|
||||
"codeExpired": "Code has expired. Please request a new one.",
|
||||
"tooManyRequests": "Too many requests. Please try again later."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notification Settings",
|
||||
"subscriptionExpiry": "Subscription Expiry",
|
||||
|
||||
@@ -1670,6 +1670,28 @@
|
||||
"canLoginWithEmail": "اکنون میتوانید با ایمیل و رمز عبور وارد شوید.",
|
||||
"emailAlreadyRegistered": "این ایمیل قبلاً به حساب دیگری متصل شده است. اگر این ایمیل شماست، با آن وارد شوید یا با پشتیبانی تماس بگیرید.",
|
||||
"alreadyHaveEmail": "شما قبلاً یک ایمیل تایید شده متصل کردهاید.",
|
||||
"changeEmail": {
|
||||
"button": "تغییر ایمیل",
|
||||
"title": "تغییر ایمیل",
|
||||
"description": "آدرس ایمیل جدید را وارد کنید. کد تایید به آن ارسال خواهد شد.",
|
||||
"currentEmail": "ایمیل فعلی",
|
||||
"newEmail": "ایمیل جدید",
|
||||
"sendCode": "ارسال کد",
|
||||
"enterCode": "کد را وارد کنید",
|
||||
"enterCodeDescription": "کد تایید ارسال شده به ایمیل جدید را وارد کنید.",
|
||||
"verificationCode": "کد تایید",
|
||||
"codeSentTo": "کد به {{email}} ارسال شد",
|
||||
"verify": "تایید",
|
||||
"resendCode": "ارسال مجدد کد",
|
||||
"resendIn": "ارسال مجدد در {{seconds}} ثانیه",
|
||||
"success": "ایمیل با موفقیت تغییر یافت!",
|
||||
"successDescription": "ایمیل شما با موفقیت بهروزرسانی شد.",
|
||||
"emailAlreadyUsed": "این ایمیل توسط حساب دیگری استفاده میشود.",
|
||||
"sameEmail": "ایمیل جدید با ایمیل فعلی یکسان است.",
|
||||
"invalidCode": "کد تایید نامعتبر است.",
|
||||
"codeExpired": "کد منقضی شده است. لطفاً کد جدید درخواست کنید.",
|
||||
"tooManyRequests": "درخواستهای زیادی ارسال شده. لطفاً بعداً دوباره امتحان کنید."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "تنظیمات اعلان",
|
||||
"subscriptionExpiry": "انقضای اشتراک",
|
||||
|
||||
@@ -2729,6 +2729,28 @@
|
||||
"canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.",
|
||||
"emailAlreadyRegistered": "Этот email уже привязан к другому аккаунту. Если это ваш email, войдите через него или обратитесь в поддержку.",
|
||||
"alreadyHaveEmail": "У вас уже привязан подтверждённый email.",
|
||||
"changeEmail": {
|
||||
"button": "Сменить почту",
|
||||
"title": "Смена почты",
|
||||
"description": "Введите новый email адрес. На него будет отправлен код подтверждения.",
|
||||
"currentEmail": "Текущий email",
|
||||
"newEmail": "Новый email",
|
||||
"sendCode": "Отправить код",
|
||||
"enterCode": "Введите код",
|
||||
"enterCodeDescription": "Введите код подтверждения, отправленный на новую почту.",
|
||||
"verificationCode": "Код подтверждения",
|
||||
"codeSentTo": "Код отправлен на {{email}}",
|
||||
"verify": "Подтвердить",
|
||||
"resendCode": "Отправить код повторно",
|
||||
"resendIn": "Повторить через {{seconds}} сек.",
|
||||
"success": "Email успешно изменён!",
|
||||
"successDescription": "Ваш email был успешно обновлён.",
|
||||
"emailAlreadyUsed": "Этот email уже используется другим аккаунтом.",
|
||||
"sameEmail": "Новый email совпадает с текущим.",
|
||||
"invalidCode": "Неверный код подтверждения.",
|
||||
"codeExpired": "Срок действия кода истёк. Запросите новый.",
|
||||
"tooManyRequests": "Слишком много запросов. Попробуйте позже."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Настройки уведомлений",
|
||||
"subscriptionExpiry": "Окончание подписки",
|
||||
|
||||
@@ -1669,6 +1669,28 @@
|
||||
"canLoginWithEmail": "现在您可以使用邮箱和密码登录。",
|
||||
"emailAlreadyRegistered": "此邮箱已绑定到其他账户。如果这是您的邮箱,请使用邮箱登录或联系客服。",
|
||||
"alreadyHaveEmail": "您已经绑定了已验证的邮箱。",
|
||||
"changeEmail": {
|
||||
"button": "更换邮箱",
|
||||
"title": "更换邮箱",
|
||||
"description": "输入新邮箱地址。验证码将发送到该邮箱。",
|
||||
"currentEmail": "当前邮箱",
|
||||
"newEmail": "新邮箱",
|
||||
"sendCode": "发送验证码",
|
||||
"enterCode": "输入验证码",
|
||||
"enterCodeDescription": "输入发送到新邮箱的验证码。",
|
||||
"verificationCode": "验证码",
|
||||
"codeSentTo": "验证码已发送至 {{email}}",
|
||||
"verify": "确认",
|
||||
"resendCode": "重新发送验证码",
|
||||
"resendIn": "{{seconds}} 秒后重新发送",
|
||||
"success": "邮箱更换成功!",
|
||||
"successDescription": "您的邮箱已成功更新。",
|
||||
"emailAlreadyUsed": "此邮箱已被其他账户使用。",
|
||||
"sameEmail": "新邮箱与当前邮箱相同。",
|
||||
"invalidCode": "验证码无效。",
|
||||
"codeExpired": "验证码已过期,请重新获取。",
|
||||
"tooManyRequests": "请求过于频繁,请稍后再试。"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知设置",
|
||||
"subscriptionExpiry": "订阅到期",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../api/notifications';
|
||||
import { referralApi } from '../api/referral';
|
||||
import { brandingApi, type EmailAuthEnabled } from '../api/branding';
|
||||
import ChangeEmailModal from '../components/ChangeEmailModal';
|
||||
|
||||
// Icons
|
||||
const CopyIcon = () => (
|
||||
@@ -42,6 +43,16 @@ const ArrowRightIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PencilIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Profile() {
|
||||
const { t } = useTranslation();
|
||||
const { user, setUser } = useAuthStore();
|
||||
@@ -53,6 +64,7 @@ export default function Profile() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showChangeEmailModal, setShowChangeEmailModal] = useState(false);
|
||||
|
||||
// Referral data
|
||||
const { data: referralInfo } = useQuery({
|
||||
@@ -311,7 +323,16 @@ export default function Profile() {
|
||||
)}
|
||||
|
||||
{user.email_verified && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
|
||||
<button
|
||||
onClick={() => setShowChangeEmailModal(true)}
|
||||
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
<PencilIcon />
|
||||
<span>{t('profile.changeEmail.button')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -618,6 +639,14 @@ export default function Profile() {
|
||||
<p className="text-dark-400">{t('profile.notifications.unavailable')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Email Modal */}
|
||||
{showChangeEmailModal && user?.email && (
|
||||
<ChangeEmailModal
|
||||
onClose={() => setShowChangeEmailModal(false)}
|
||||
currentEmail={user.email}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user