From dcfd362c43403d4f8729298e2e802ce326ee7d4f Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 05:42:47 +0300 Subject: [PATCH] refactor: replace ChangeEmailModal with inline email change flow in Profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove modal-based email change (491 lines deleted) - Add inline step-by-step flow directly in email card (email → code → success) - Add change email button for unverified emails (typo fix use case) - Hide change email for OAuth users (google, yandex, discord, vk) - Auto-focus inputs, Enter submit, animated transitions, 3s auto-close on success --- src/components/ChangeEmailModal.tsx | 490 ---------------------------- src/pages/Profile.tsx | 321 ++++++++++++++++-- 2 files changed, 292 insertions(+), 519 deletions(-) delete mode 100644 src/components/ChangeEmailModal.tsx diff --git a/src/components/ChangeEmailModal.tsx b/src/components/ChangeEmailModal.tsx deleted file mode 100644 index fe49917..0000000 --- a/src/components/ChangeEmailModal.tsx +++ /dev/null @@ -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 = () => ( - - - -); - -const EmailIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -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(null); - const codeInputRef = useRef(null); - - const [step, setStep] = useState('email'); - const [newEmail, setNewEmail] = useState(''); - const [code, setCode] = useState(''); - const [error, setError] = useState(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 = ( -
- {/* Header icon */} -
-
- -
-
-

{t('profile.changeEmail.title')}

-

- {step === 'email' && t('profile.changeEmail.description')} - {step === 'code' && t('profile.changeEmail.enterCodeDescription')} - {step === 'success' && t('profile.changeEmail.successDescription')} -

-
-
- - {/* Current email display */} - {step === 'email' && ( -
-

{t('profile.changeEmail.currentEmail')}

-

{currentEmail}

-
- )} - - {/* Email input step */} - {step === 'email' && ( -
-
- - setNewEmail(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSendCode(); - } - }} - placeholder="new@email.com" - className="input w-full" - autoComplete="email" - autoFocus - /> -
- - -
- )} - - {/* Code verification step */} - {step === 'code' && ( -
-
-

- {t('profile.changeEmail.codeSentTo', { email: newEmail })} -

-
- -
- - 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 - /> -
- - - -
- - -
-
- )} - - {/* Success step */} - {step === 'success' && ( -
-
-
- -
-

- {t('profile.changeEmail.success')} -

-

{newEmail}

-
- - -
- )} - - {/* Error message */} - {error && ( -
- - - - {error} -
- )} -
- ); - - // Render modal based on screen size - const modalContent = isMobileScreen ? ( - <> - {/* Backdrop */} -
- {/* Bottom sheet */} -
e.stopPropagation()} - > - {/* Handle bar */} -
-
-
- - {/* Header */} -
-
- - - {t('profile.changeEmail.title')} - -
- -
- - {/* Divider */} -
- - {/* Content */} -
{contentJSX}
-
- - ) : ( -
-
e.stopPropagation()} - > - {/* Header */} -
-
-
- -
- - {t('profile.changeEmail.title')} - -
- -
- - {/* Content */} -
{contentJSX}
-
-
- ); - - if (typeof document !== 'undefined') { - return createPortal(modalContent, document.body); - } - return modalContent; -} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 75f7d2b..389c074 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { Link } from 'react-router'; import { useTranslation } from 'react-i18next'; 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 { authApi } from '../api/auth'; import { isValidEmail } from '../utils/validation'; @@ -13,7 +13,7 @@ import { } from '../api/notifications'; import { referralApi } from '../api/referral'; 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 { Button } from '@/components/primitives/Button'; import { Switch } from '@/components/primitives/Switch'; @@ -70,7 +70,15 @@ export default function Profile() { const [error, setError] = useState(null); const [success, setSuccess] = useState(null); 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(null); + const [resendCooldown, setResendCooldown] = useState(0); + const newEmailInputRef = useRef(null); + const codeInputRef = useRef(null); // Referral data 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({ queryKey: ['notification-settings'], queryFn: notificationsApi.getSettings, @@ -324,27 +443,179 @@ export default function Profile() {

{t('profile.verificationRequired')}

- +
+ + {(user.auth_type === 'telegram' || user.auth_type === 'email') && ( + + )} +
)} - {user.email_verified && ( -
-

{t('profile.canLoginWithEmail')}

- +
+ )} + + {/* Inline email change flow */} + + {changeEmailStep === 'email' && ( + - - {t('profile.changeEmail.button')} - -
- )} +
+ + setNewEmail(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSendChangeCode(); + } + }} + placeholder="new@email.com" + className="input w-full" + autoComplete="email" + /> + {changeError &&

{changeError}

} +
+ + +
+
+ + )} + + {changeEmailStep === 'code' && ( + +
+
+

+ {t('profile.changeEmail.codeSentTo', { email: newEmail })} +

+
+ + 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 &&

{changeError}

} +
+
+ + +
+ +
+
+
+ )} + + {changeEmailStep === 'success' && ( + +
+
+ +
+

+ {t('profile.changeEmail.success')} +

+

{newEmail}

+
+
+
+
+ )} +
) : (
@@ -586,14 +857,6 @@ export default function Profile() { )} - - {/* Change Email Modal */} - {showChangeEmailModal && user?.email && ( - setShowChangeEmailModal(false)} - currentEmail={user.email} - /> - )} ); }