From 760761729bd2da4f0c0be2e415471e2b8787fd49 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 15:54:31 +0300 Subject: [PATCH] Add files via upload --- src/components/ChangeEmailModal.tsx | 504 ++++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 src/components/ChangeEmailModal.tsx diff --git a/src/components/ChangeEmailModal.tsx b/src/components/ChangeEmailModal.tsx new file mode 100644 index 0000000..bfbd11e --- /dev/null +++ b/src/components/ChangeEmailModal.tsx @@ -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 = () => ( + + + +); + +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 = 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(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]); + + // 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 = ( +
+ {/* 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; +}