From e2f81ad28dd5caaee8d5b5a0796b4b78a6c36ace Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 2 Apr 2026 05:44:06 +0300 Subject: [PATCH 1/3] fix: subscription tab not highlighted on detail pages isActive used exact pathname match (===), so navigating to /subscriptions/:id or /subscriptions/:id/renew would not highlight the Subscription tab. Changed to startsWith for non-root paths in both MobileBottomNav and DesktopSidebar. --- src/components/layout/AppShell/DesktopSidebar.tsx | 3 ++- src/components/layout/AppShell/MobileBottomNav.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/layout/AppShell/DesktopSidebar.tsx b/src/components/layout/AppShell/DesktopSidebar.tsx index 1f38940..7799435 100644 --- a/src/components/layout/AppShell/DesktopSidebar.tsx +++ b/src/components/layout/AppShell/DesktopSidebar.tsx @@ -74,7 +74,8 @@ export function DesktopSidebar({ const hasCustomLogo = branding?.has_custom_logo || false; const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; - const isActive = (path: string) => location.pathname === path; + const isActive = (path: string) => + path === '/' ? location.pathname === '/' : location.pathname.startsWith(path); const isAdminActive = () => location.pathname.startsWith('/admin'); const navItems = [ diff --git a/src/components/layout/AppShell/MobileBottomNav.tsx b/src/components/layout/AppShell/MobileBottomNav.tsx index 9d72221..f143787 100644 --- a/src/components/layout/AppShell/MobileBottomNav.tsx +++ b/src/components/layout/AppShell/MobileBottomNav.tsx @@ -23,7 +23,8 @@ export function MobileBottomNav({ const location = useLocation(); const { haptic } = usePlatform(); - const isActive = (path: string) => location.pathname === path; + const isActive = (path: string) => + path === '/' ? location.pathname === '/' : location.pathname.startsWith(path); // Core navigation items for bottom bar // When wheel is enabled, it replaces Support in the bottom nav (Support is still accessible via hamburger menu) From 6fd0668a243ed9ada8485586216e3956d35edc8e Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 2 Apr 2026 07:06:38 +0300 Subject: [PATCH 2/3] feat: move email linking form into Connected Accounts page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users couldn't find the email linking form buried in the Profile page. Now the Email row in Connected Accounts shows a "Привязать" button (consistent with Google/Yandex/Discord) that expands an inline form with email, password, and confirm password fields. - Added email to isLinkableProvider check - Inline AnimatePresence form expands under Email card (no popups) - Removed duplicate registration form from Profile page - Profile page now shows a redirect button to Connected Accounts for users without email (email change/verify stays in Profile) --- src/pages/ConnectedAccounts.tsx | 164 +++++++++++++++++++++++++++++++- src/pages/Profile.tsx | 116 +--------------------- 2 files changed, 166 insertions(+), 114 deletions(-) diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 7a839bd..824b5c4 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -2,9 +2,9 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { authApi } from '../api/auth'; -import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; +import { brandingApi, type TelegramWidgetConfig, type EmailAuthEnabled } from '../api/branding'; import { useToast } from '../components/Toast'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; @@ -13,6 +13,8 @@ import ProviderIcon from '../components/ProviderIcon'; import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from '../utils/oauth'; import { getTelegramInitData } from '../hooks/useTelegramSDK'; import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform'; +import { useAuthStore } from '../store/auth'; +import { isValidEmail } from '../utils/validation'; import type { LinkedProvider } from '../types'; const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; @@ -20,7 +22,7 @@ const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; const isOAuthProvider = (provider: string): boolean => OAUTH_PROVIDERS.includes(provider); const isLinkableProvider = (provider: string): boolean => - isOAuthProvider(provider) || provider === 'telegram'; + isOAuthProvider(provider) || provider === 'telegram' || provider === 'email'; // SessionStorage key for Telegram link CSRF state export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state'; @@ -314,6 +316,22 @@ export default function ConnectedAccounts() { const pendingLinkProvider = useRef(null); const blurTimeoutRef = useRef>(undefined); + // Email linking inline form state + const [emailFormOpen, setEmailFormOpen] = useState(false); + const [emailValue, setEmailValue] = useState(''); + const [emailPassword, setEmailPassword] = useState(''); + const [emailConfirmPassword, setEmailConfirmPassword] = useState(''); + const [emailError, setEmailError] = useState(null); + const [emailSuccess, setEmailSuccess] = useState(null); + const setUser = useAuthStore((state) => state.setUser); + + const { data: emailAuthConfig } = useQuery({ + queryKey: ['email-auth-enabled'], + queryFn: brandingApi.getEmailAuthEnabled, + staleTime: 60000, + }); + const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true; + const inTelegram = useIsTelegram(); const platform = usePlatform(); @@ -375,6 +393,57 @@ export default function ConnectedAccounts() { }, }); + const registerEmailMutation = useMutation({ + mutationFn: ({ email, password }: { email: string; password: string }) => + authApi.registerEmail(email, password), + onSuccess: async (response) => { + if (response.merge_required && response.merge_token) { + navigate(`/merge/${response.merge_token}`, { replace: true }); + return; + } + setEmailSuccess(t('profile.emailSent')); + setEmailError(null); + setEmailValue(''); + setEmailPassword(''); + setEmailConfirmPassword(''); + const updatedUser = await authApi.getMe(); + setUser(updatedUser); + queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); + queryClient.invalidateQueries({ queryKey: ['user'] }); + }, + onError: (err: { response?: { data?: { detail?: string } } }) => { + const detail = err.response?.data?.detail; + if (detail?.includes('already registered')) { + setEmailError(t('profile.emailAlreadyRegistered')); + } else if (detail?.includes('already have a verified email')) { + setEmailError(t('profile.alreadyHaveEmail')); + } else { + setEmailError(detail || t('common.error')); + } + setEmailSuccess(null); + }, + }); + + const handleEmailSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setEmailError(null); + setEmailSuccess(null); + + if (!emailValue.trim() || !isValidEmail(emailValue.trim())) { + setEmailError(t('profile.invalidEmail', 'Please enter a valid email address')); + return; + } + if (!emailPassword || emailPassword.length < 8) { + setEmailError(t('profile.passwordMinLength')); + return; + } + if (emailPassword !== emailConfirmPassword) { + setEmailError(t('profile.passwordsMismatch')); + return; + } + registerEmailMutation.mutate({ email: emailValue, password: emailPassword }); + }; + const canUnlink = (provider: LinkedProvider): boolean => { if (!provider.linked) return false; if (!isOAuthProvider(provider.provider)) return false; @@ -470,6 +539,23 @@ export default function ConnectedAccounts() { }; const renderLinkButton = (provider: LinkedProvider) => { + if (provider.provider === 'email') { + if (!isEmailAuthEnabled) return null; + return ( + + ); + } + if (provider.provider === 'telegram') { if (inTelegram && getTelegramInitData()) { // Mini App: one-click button @@ -583,6 +669,78 @@ export default function ConnectedAccounts() { )} + + {/* Inline email linking form */} + {provider.provider === 'email' && !provider.linked && ( + + {emailFormOpen && ( + +
+

+ {t('profile.linkEmailDescription')} +

+
+
+ + setEmailValue(e.target.value)} + placeholder="your@email.com" + className="input" + autoComplete="email" + /> +
+
+ + setEmailPassword(e.target.value)} + placeholder={t('profile.passwordPlaceholder')} + className="input" + autoComplete="new-password" + /> +

{t('profile.passwordHint')}

+
+
+ + setEmailConfirmPassword(e.target.value)} + placeholder={t('profile.confirmPasswordPlaceholder')} + className="input" + autoComplete="new-password" + /> +
+ + {emailError && ( +
+ {emailError} +
+ )} + {emailSuccess && ( +
+ {emailSuccess} +
+ )} + + +
+
+
+ )} +
+ )} ))} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index eb09234..953bc49 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -67,9 +67,6 @@ export default function Profile() { const setUser = useAuthStore((state) => state.setUser); const queryClient = useQueryClient(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [copied, setCopied] = useState(false); @@ -145,37 +142,6 @@ export default function Profile() { window.open(telegramUrl, '_blank', 'noopener,noreferrer'); }; - const registerEmailMutation = useMutation({ - mutationFn: ({ email, password }: { email: string; password: string }) => - authApi.registerEmail(email, password), - onSuccess: async (response) => { - // Backend returns merge_required when email belongs to another user - if (response.merge_required && response.merge_token) { - navigate(`/merge/${response.merge_token}`, { replace: true }); - return; - } - setSuccess(t('profile.emailSent')); - setError(null); - setEmail(''); - setPassword(''); - setConfirmPassword(''); - 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('already registered')) { - setError(t('profile.emailAlreadyRegistered')); - } else if (detail?.includes('already have a verified email')) { - setError(t('profile.alreadyHaveEmail')); - } else { - setError(detail || t('common.error')); - } - setSuccess(null); - }, - }); - const resendVerificationMutation = useMutation({ mutationFn: authApi.resendVerification, onSuccess: () => { @@ -339,29 +305,6 @@ export default function Profile() { updateNotificationsMutation.mutate(update); }; - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - setSuccess(null); - - if (!email.trim() || !isValidEmail(email.trim())) { - setError(t('profile.invalidEmail', 'Please enter a valid email address')); - return; - } - - if (!password || password.length < 8) { - setError(t('profile.passwordMinLength')); - return; - } - - if (password !== confirmPassword) { - setError(t('profile.passwordsMismatch')); - return; - } - - registerEmailMutation.mutate({ email, password }); - }; - return ( ) : ( -
-

{t('profile.linkEmailDescription')}

- -
-
- - setEmail(e.target.value)} - placeholder="your@email.com" - className="input" - /> -
- -
- - setPassword(e.target.value)} - placeholder={t('profile.passwordPlaceholder')} - className="input" - /> -

{t('profile.passwordHint')}

-
- -
- - setConfirmPassword(e.target.value)} - placeholder={t('profile.confirmPasswordPlaceholder')} - className="input" - /> -
- - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - - -
+
+

{t('profile.linkEmailDescription')}

+
)} From 7892630e3bd4006e7f5dcd95d5e378c8a4d7e8f7 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 2 Apr 2026 07:16:00 +0300 Subject: [PATCH 3/3] fix: add key prop to AnimatePresence, accessibility labels, remove hardcoded fallback - Added key="email-link-form" on motion.div inside AnimatePresence for proper exit animations - Added htmlFor/id pairs on all three label+input groups for screen reader accessibility - Replaced hardcoded "your@email.com" placeholder with neutral format - Removed hardcoded English fallback from t('profile.invalidEmail') --- src/pages/ConnectedAccounts.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 824b5c4..e89e0a6 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -430,7 +430,7 @@ export default function ConnectedAccounts() { setEmailSuccess(null); if (!emailValue.trim() || !isValidEmail(emailValue.trim())) { - setEmailError(t('profile.invalidEmail', 'Please enter a valid email address')); + setEmailError(t('profile.invalidEmail')); return; } if (!emailPassword || emailPassword.length < 8) { @@ -675,6 +675,7 @@ export default function ConnectedAccounts() { {emailFormOpen && (
- + setEmailValue(e.target.value)} - placeholder="your@email.com" + placeholder="email@example.com" className="input" autoComplete="email" />
- + setEmailPassword(e.target.value)} @@ -710,8 +717,11 @@ export default function ConnectedAccounts() {

{t('profile.passwordHint')}

- + setEmailConfirmPassword(e.target.value)}