diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx
index d7cb93f..67bb643 100644
--- a/src/components/blocking/ChannelSubscriptionScreen.tsx
+++ b/src/components/blocking/ChannelSubscriptionScreen.tsx
@@ -7,7 +7,8 @@ const CHECK_COOLDOWN_SECONDS = 5;
export default function ChannelSubscriptionScreen() {
const { t } = useTranslation();
- const { channelInfo, clearBlocking } = useBlockingStore();
+ const channelInfo = useBlockingStore((state) => state.channelInfo);
+ const clearBlocking = useBlockingStore((state) => state.clearBlocking);
const [isChecking, setIsChecking] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [error, setError] = useState
(null);
diff --git a/src/components/blocking/MaintenanceScreen.tsx b/src/components/blocking/MaintenanceScreen.tsx
index f51e68c..6302eed 100644
--- a/src/components/blocking/MaintenanceScreen.tsx
+++ b/src/components/blocking/MaintenanceScreen.tsx
@@ -3,7 +3,7 @@ import { useBlockingStore } from '../../store/blocking';
export default function MaintenanceScreen() {
const { t } = useTranslation();
- const { maintenanceInfo } = useBlockingStore();
+ const maintenanceInfo = useBlockingStore((state) => state.maintenanceInfo);
return (
diff --git a/src/components/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx
index 1ff846d..f47fe3c 100644
--- a/src/components/layout/AppShell/AppHeader.tsx
+++ b/src/components/layout/AppShell/AppHeader.tsx
@@ -5,6 +5,7 @@ import { useState, useEffect } from 'react';
import { initDataUser } from '@telegram-apps/sdk-react';
import { useAuthStore } from '@/store/auth';
+import { useShallow } from 'zustand/shallow';
import { useTheme } from '@/hooks/useTheme';
import { usePlatform } from '@/platform';
import {
@@ -77,7 +78,9 @@ export function AppHeader({
}: AppHeaderProps) {
const { t } = useTranslation();
const location = useLocation();
- const { user, logout, isAdmin } = useAuthStore();
+ const { user, logout, isAdmin } = useAuthStore(
+ useShallow((state) => ({ user: state.user, logout: state.logout, isAdmin: state.isAdmin })),
+ );
const { toggleTheme, isDark } = useTheme();
const { haptic, platform } = usePlatform();
const [userPhotoUrl, setUserPhotoUrl] = useState(null);
diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx
index eb339f4..0b2de7b 100644
--- a/src/components/layout/AppShell/AppShell.tsx
+++ b/src/components/layout/AppShell/AppShell.tsx
@@ -193,7 +193,8 @@ interface AppShellProps {
export function AppShell({ children }: AppShellProps) {
const { t } = useTranslation();
const location = useLocation();
- const { isAdmin, logout } = useAuthStore();
+ const isAdmin = useAuthStore((state) => state.isAdmin);
+ const logout = useAuthStore((state) => state.logout);
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
useTelegramSDK();
const haptic = useHaptic();
diff --git a/src/components/layout/AppShell/Aurora.tsx b/src/components/layout/AppShell/Aurora.tsx
index 278f70f..534dc00 100644
--- a/src/components/layout/AppShell/Aurora.tsx
+++ b/src/components/layout/AppShell/Aurora.tsx
@@ -132,12 +132,13 @@ let _webglAvailable: boolean | null = null;
function isWebglAvailable(): boolean {
if (_webglAvailable === null) {
try {
- const renderer = new Renderer({
- alpha: true,
- antialias: false,
- powerPreference: 'low-power',
- });
- _webglAvailable = !!renderer.gl;
+ const canvas = document.createElement('canvas');
+ const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
+ _webglAvailable = !!gl;
+ if (gl) {
+ const loseCtx = gl.getExtension('WEBGL_lose_context');
+ if (loseCtx) loseCtx.loseContext();
+ }
} catch {
_webglAvailable = false;
}
@@ -258,15 +259,16 @@ function AuroraImpl() {
resize();
let lastTime = 0;
- const targetFPS = 20;
+ const targetFPS = 10;
const frameInterval = 1000 / targetFPS;
const speed = 0.3;
function animate(currentTime: number) {
- animationFrameRef.current = requestAnimationFrame(animate);
-
const delta = currentTime - lastTime;
- if (delta < frameInterval) return;
+ if (delta < frameInterval) {
+ animationFrameRef.current = requestAnimationFrame(animate);
+ return;
+ }
lastTime = currentTime - (delta % frameInterval);
@@ -274,15 +276,45 @@ function AuroraImpl() {
programRef.current.uniforms.uTime.value += speed * 0.01;
rendererRef.current.render({ scene: mesh });
}
+
+ animationFrameRef.current = requestAnimationFrame(animate);
}
+
+ function handleVisibilityChange() {
+ if (document.hidden) {
+ cancelAnimationFrame(animationFrameRef.current);
+ animationFrameRef.current = 0;
+ } else {
+ lastTime = 0;
+ animationFrameRef.current = requestAnimationFrame(animate);
+ }
+ }
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('resize', resize);
cancelAnimationFrame(animationFrameRef.current);
- if (rendererRef.current && container.contains(rendererRef.current.gl.canvas)) {
- container.removeChild(rendererRef.current.gl.canvas);
+
+ if (rendererRef.current) {
+ const glCtx = rendererRef.current.gl;
+
+ // Delete GPU resources
+ if (programRef.current) {
+ glCtx.deleteProgram(programRef.current.program);
+ }
+
+ // Force-release the WebGL context
+ const loseCtx = glCtx.getExtension('WEBGL_lose_context');
+ if (loseCtx) loseCtx.loseContext();
+
+ if (container.contains(glCtx.canvas)) {
+ container.removeChild(glCtx.canvas);
+ }
}
+
rendererRef.current = null;
programRef.current = null;
};
diff --git a/src/components/layout/AppShell/DesktopSidebar.tsx b/src/components/layout/AppShell/DesktopSidebar.tsx
index 8f36328..4af5611 100644
--- a/src/components/layout/AppShell/DesktopSidebar.tsx
+++ b/src/components/layout/AppShell/DesktopSidebar.tsx
@@ -49,7 +49,8 @@ export function DesktopSidebar({
}: DesktopSidebarProps) {
const { t } = useTranslation();
const location = useLocation();
- const { user, logout } = useAuthStore();
+ const user = useAuthStore((state) => state.user);
+ const logout = useAuthStore((state) => state.logout);
const { haptic } = usePlatform();
// Branding
diff --git a/src/components/navigation/CommandPalette/CommandPalette.tsx b/src/components/navigation/CommandPalette/CommandPalette.tsx
index c949ec7..e5add68 100644
--- a/src/components/navigation/CommandPalette/CommandPalette.tsx
+++ b/src/components/navigation/CommandPalette/CommandPalette.tsx
@@ -62,7 +62,7 @@ export function CommandPalette({
const { t } = useTranslation();
const navigate = useNavigate();
const { haptic } = usePlatform();
- const { isAdmin } = useAuthStore();
+ const isAdmin = useAuthStore((state) => state.isAdmin);
const { toggleTheme, isDark } = useTheme();
const [search, setSearch] = useState('');
diff --git a/src/hooks/useBranding.ts b/src/hooks/useBranding.ts
index 125ab15..c561600 100644
--- a/src/hooks/useBranding.ts
+++ b/src/hooks/useBranding.ts
@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import { useTelegramSDK, setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
@@ -14,8 +14,8 @@ const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
export function useBranding() {
- const { isAuthenticated } = useAuthStore();
- const { isFullscreen, isTelegramWebApp, requestFullscreen, isMobile } = useTelegramSDK();
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
+ const { isTelegramWebApp, requestFullscreen, isMobile } = useTelegramSDK();
// Branding data
const { data: branding } = useQuery({
@@ -62,13 +62,16 @@ export function useBranding() {
staleTime: 60000,
});
+ const fullscreenRequestedRef = useRef(false);
+
useEffect(() => {
if (!fullscreenSetting || !isTelegramWebApp) return;
setCachedFullscreenEnabled(fullscreenSetting.enabled);
- if (fullscreenSetting.enabled && !isFullscreen && isMobile) {
+ if (fullscreenSetting.enabled && isMobile && !fullscreenRequestedRef.current) {
+ fullscreenRequestedRef.current = true;
requestFullscreen();
}
- }, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
+ }, [fullscreenSetting, isTelegramWebApp, requestFullscreen, isMobile]);
return {
appName,
diff --git a/src/hooks/useFeatureFlags.ts b/src/hooks/useFeatureFlags.ts
index 594e4a9..356d357 100644
--- a/src/hooks/useFeatureFlags.ts
+++ b/src/hooks/useFeatureFlags.ts
@@ -6,7 +6,7 @@ import { contestsApi } from '@/api/contests';
import { pollsApi } from '@/api/polls';
export function useFeatureFlags() {
- const { isAuthenticated } = useAuthStore();
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const { data: referralTerms } = useQuery({
queryKey: ['referral-terms'],
diff --git a/src/locales/en.json b/src/locales/en.json
index ea37eb5..4360b98 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -2902,6 +2902,7 @@
"notVerified": "Not verified",
"verificationRequired": "Please verify your email to use email login.",
"resendVerification": "Resend Verification Email",
+ "resendIn": "Resend in {{seconds}} sec.",
"verificationResent": "Verification email resent!",
"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.",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 20cc322..68f682c 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -2402,6 +2402,7 @@
"notVerified": "تایید نشده",
"verificationRequired": "لطفاً ایمیل خود را تایید کنید تا از ورود با ایمیل استفاده کنید.",
"resendVerification": "ارسال مجدد ایمیل تایید",
+ "resendIn": "ارسال مجدد در {{seconds}} ثانیه",
"verificationResent": "ایمیل تایید مجدداً ارسال شد!",
"canLoginWithEmail": "اکنون میتوانید با ایمیل و رمز عبور وارد شوید.",
"emailAlreadyRegistered": "این ایمیل قبلاً به حساب دیگری متصل شده است. اگر این ایمیل شماست، با آن وارد شوید یا با پشتیبانی تماس بگیرید.",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 29147d4..e14a4e9 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -3454,6 +3454,7 @@
"notVerified": "Не подтверждён",
"verificationRequired": "Подтвердите email для использования входа по почте.",
"resendVerification": "Отправить письмо повторно",
+ "resendIn": "Повторить через {{seconds}} сек.",
"verificationResent": "Письмо отправлено повторно!",
"canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.",
"emailAlreadyRegistered": "Этот email уже привязан к другому аккаунту. Если это ваш email, войдите через него или обратитесь в поддержку.",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 18dceb5..1f856e7 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -2401,6 +2401,7 @@
"notVerified": "未验证",
"verificationRequired": "请验证邮箱以使用邮箱登录。",
"resendVerification": "重新发送验证邮件",
+ "resendIn": "{{seconds}} 秒后重新发送",
"verificationResent": "验证邮件已重新发送!",
"canLoginWithEmail": "现在您可以使用邮箱和密码登录。",
"emailAlreadyRegistered": "此邮箱已绑定到其他账户。如果这是您的邮箱,请使用邮箱登录或联系客服。",
diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx
index 39932fc..5a7ee2d 100644
--- a/src/pages/AdminBanSystem.tsx
+++ b/src/pages/AdminBanSystem.tsx
@@ -411,8 +411,8 @@ export default function AdminBanSystem() {
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
diff --git a/src/pages/AdminBroadcastCreate.tsx b/src/pages/AdminBroadcastCreate.tsx
index 6e514f8..5e6073b 100644
--- a/src/pages/AdminBroadcastCreate.tsx
+++ b/src/pages/AdminBroadcastCreate.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useMemo } from 'react';
+import { useState, useRef, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -135,6 +135,14 @@ export default function AdminBroadcastCreate() {
const [mediaPreview, setMediaPreview] = useState(null);
const [uploadedFileId, setUploadedFileId] = useState(null);
const [isUploading, setIsUploading] = useState(false);
+ const mediaPreviewRef = useRef(null);
+
+ // Revoke blob URLs on unmount to prevent memory leaks
+ useEffect(() => {
+ return () => {
+ if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
+ };
+ }, []);
// Email-specific state
const [emailSubject, setEmailSubject] = useState('');
@@ -271,7 +279,10 @@ export default function AdminBroadcastCreate() {
if (file.type.startsWith('image/')) {
setMediaType('photo');
- setMediaPreview(URL.createObjectURL(file));
+ if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
+ const url = URL.createObjectURL(file);
+ mediaPreviewRef.current = url;
+ setMediaPreview(url);
} else if (file.type.startsWith('video/')) {
setMediaType('video');
setMediaPreview(null);
@@ -295,6 +306,10 @@ export default function AdminBroadcastCreate() {
// Remove media
const handleRemoveMedia = () => {
+ if (mediaPreviewRef.current) {
+ URL.revokeObjectURL(mediaPreviewRef.current);
+ mediaPreviewRef.current = null;
+ }
setMediaFile(null);
setMediaPreview(null);
setUploadedFileId(null);
diff --git a/src/pages/AdminBroadcasts.tsx b/src/pages/AdminBroadcasts.tsx
index 131635f..cab9f08 100644
--- a/src/pages/AdminBroadcasts.tsx
+++ b/src/pages/AdminBroadcasts.tsx
@@ -137,7 +137,13 @@ export default function AdminBroadcasts() {
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin', 'broadcasts', 'list', page],
queryFn: () => adminBroadcastsApi.list(limit, page * limit),
- refetchInterval: 5000, // Auto refresh every 5s
+ refetchInterval: (query) => {
+ const items = query.state.data?.items;
+ const hasActive = items?.some((b: { status: string }) =>
+ ['queued', 'in_progress', 'cancelling'].includes(b.status),
+ );
+ return hasActive ? 5000 : false;
+ },
});
const broadcasts = data?.items || [];
diff --git a/src/pages/AdminPinnedMessageCreate.tsx b/src/pages/AdminPinnedMessageCreate.tsx
index b405da7..4398c4b 100644
--- a/src/pages/AdminPinnedMessageCreate.tsx
+++ b/src/pages/AdminPinnedMessageCreate.tsx
@@ -86,6 +86,14 @@ export default function AdminPinnedMessageCreate() {
const [uploadedFileId, setUploadedFileId] = useState(null);
const [isUploading, setIsUploading] = useState(false);
const [existingMediaType, setExistingMediaType] = useState<'photo' | 'video' | null>(null);
+ const mediaPreviewRef = useRef(null);
+
+ // Revoke blob URLs on unmount to prevent memory leaks
+ useEffect(() => {
+ return () => {
+ if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
+ };
+ }, []);
// Load existing message for editing
const { data: existingMessage, isLoading: isLoadingMessage } = useQuery({
@@ -138,7 +146,10 @@ export default function AdminPinnedMessageCreate() {
let detectedType: 'photo' | 'video' = 'photo';
if (file.type.startsWith('image/')) {
detectedType = 'photo';
- setMediaPreview(URL.createObjectURL(file));
+ if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
+ const url = URL.createObjectURL(file);
+ mediaPreviewRef.current = url;
+ setMediaPreview(url);
} else if (file.type.startsWith('video/')) {
detectedType = 'video';
setMediaPreview(null);
@@ -159,6 +170,10 @@ export default function AdminPinnedMessageCreate() {
// Remove media
const handleRemoveMedia = () => {
+ if (mediaPreviewRef.current) {
+ URL.revokeObjectURL(mediaPreviewRef.current);
+ mediaPreviewRef.current = null;
+ }
setMediaFile(null);
setMediaPreview(null);
setUploadedFileId(null);
diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx
index 45093c4..35b6126 100644
--- a/src/pages/AdminRemnawave.tsx
+++ b/src/pages/AdminRemnawave.tsx
@@ -42,8 +42,8 @@ const BackIcon = () => (
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
@@ -363,9 +363,15 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
);
}
+ // Use (total - available) instead of raw "used" to exclude disk cache/buffers
+ // This matches what htop/free show as actual application memory usage
+ const memoryActualUsed =
+ stats.server_info.memory_available > 0
+ ? stats.server_info.memory_total - stats.server_info.memory_available
+ : stats.server_info.memory_used;
const memoryUsedPercent =
stats.server_info.memory_total > 0
- ? Math.round((stats.server_info.memory_used / stats.server_info.memory_total) * 100)
+ ? Math.round((memoryActualUsed / stats.server_info.memory_total) * 100)
: 0;
return (
@@ -446,7 +452,7 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
💾}
color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'}
/>
diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx
index b123ddb..b28c07d 100644
--- a/src/pages/AdminTrafficUsage.tsx
+++ b/src/pages/AdminTrafficUsage.tsx
@@ -35,8 +35,8 @@ declare module '@tanstack/react-table' {
const formatBytes = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx
index 0d690ba..74a5f20 100644
--- a/src/pages/AdminUserDetail.tsx
+++ b/src/pages/AdminUserDetail.tsx
@@ -766,10 +766,10 @@ export default function AdminUserDetail() {
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
const copyToClipboard = async (text: string) => {
diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx
index 0fe2a71..f6f9809 100644
--- a/src/pages/Balance.tsx
+++ b/src/pages/Balance.tsx
@@ -40,7 +40,7 @@ const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => (
export default function Balance() {
const { t } = useTranslation();
- const { refreshUser } = useAuthStore();
+ const refreshUser = useAuthStore((state) => state.refreshUser);
const queryClient = useQueryClient();
const { formatAmount, currencySymbol } = useCurrency();
const [searchParams] = useSearchParams();
diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx
index e9fe6c9..2b250cb 100644
--- a/src/pages/Connection.tsx
+++ b/src/pages/Connection.tsx
@@ -14,7 +14,8 @@ import InstallationGuide from '../components/connection/InstallationGuide';
export default function Connection() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
- const { user, isAdmin } = useAuthStore();
+ const user = useAuthStore((state) => state.user);
+ const isAdmin = useAuthStore((state) => state.isAdmin);
const { isTelegramWebApp } = useTelegramSDK();
const { impact: hapticImpact } = useHaptic();
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index ee246f2..cbccda3 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -48,14 +48,15 @@ const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
export default function Dashboard() {
const { t } = useTranslation();
- const { user, refreshUser } = useAuthStore();
+ const user = useAuthStore((state) => state.user);
+ const refreshUser = useAuthStore((state) => state.refreshUser);
const queryClient = useQueryClient();
const navigate = useNavigate();
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
const [trialError, setTrialError] = useState(null);
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
const [showOnboarding, setShowOnboarding] = useState(false);
- const { blockingType } = useBlockingStore();
+ const blockingType = useBlockingStore((state) => state.blockingType);
// Refresh user data on mount
useEffect(() => {
diff --git a/src/pages/DeepLinkRedirect.tsx b/src/pages/DeepLinkRedirect.tsx
index f594932..60c0828 100644
--- a/src/pages/DeepLinkRedirect.tsx
+++ b/src/pages/DeepLinkRedirect.tsx
@@ -61,9 +61,24 @@ export default function DeepLinkRedirect() {
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V';
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
+ // Parse raw query string to preserve '+' chars in base64 crypto links.
+ // URLSearchParams decodes '+' as space, breaking ss://, vless:// etc.
+ // decodeURIComponent does NOT treat '+' as space, so parsing raw pairs is correct.
+ const getRawParam = (key: string): string => {
+ const search = window.location.search.substring(1);
+ for (const pair of search.split('&')) {
+ const idx = pair.indexOf('=');
+ if (idx === -1) continue;
+ if (decodeURIComponent(pair.substring(0, idx)) === key) {
+ return decodeURIComponent(pair.substring(idx + 1));
+ }
+ }
+ return '';
+ };
+
// Get parameters
- const deepLink = searchParams.get('url') || searchParams.get('deeplink') || '';
- const subscriptionUrl = searchParams.get('sub') || '';
+ const deepLink = getRawParam('url') || getRawParam('deeplink') || '';
+ const subscriptionUrl = getRawParam('sub') || '';
const appParam = searchParams.get('app') || '';
// Detect app from deep link
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index d429cdb..0203b7e 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../store/auth';
+import { useShallow } from 'zustand/shallow';
import { authApi } from '../api/auth';
import { isValidEmail } from '../utils/validation';
import {
@@ -32,7 +33,15 @@ export default function Login() {
loginWithTelegram,
loginWithEmail,
registerWithEmail,
- } = useAuthStore();
+ } = useAuthStore(
+ useShallow((state) => ({
+ isAuthenticated: state.isAuthenticated,
+ isLoading: state.isLoading,
+ loginWithTelegram: state.loginWithTelegram,
+ loginWithEmail: state.loginWithEmail,
+ registerWithEmail: state.registerWithEmail,
+ })),
+ );
// Get referral code from localStorage (captured from ?ref= param at module level in auth store)
const referralCode = getPendingReferralCode() || '';
diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx
index 7078fc5..dfe797f 100644
--- a/src/pages/OAuthCallback.tsx
+++ b/src/pages/OAuthCallback.tsx
@@ -26,7 +26,8 @@ export default function OAuthCallback() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [error, setError] = useState('');
- const { loginWithOAuth, isAuthenticated } = useAuthStore();
+ const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth);
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
useEffect(() => {
if (isAuthenticated) {
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx
index b811f23..8884393 100644
--- a/src/pages/Profile.tsx
+++ b/src/pages/Profile.tsx
@@ -61,7 +61,8 @@ const PencilIcon = () => (
export default function Profile() {
const { t } = useTranslation();
- const { user, setUser } = useAuthStore();
+ const user = useAuthStore((state) => state.user);
+ const setUser = useAuthStore((state) => state.setUser);
const queryClient = useQueryClient();
const [email, setEmail] = useState('');
@@ -77,6 +78,7 @@ export default function Profile() {
const [changeCode, setChangeCode] = useState('');
const [changeError, setChangeError] = useState(null);
const [resendCooldown, setResendCooldown] = useState(0);
+ const [verificationResendCooldown, setVerificationResendCooldown] = useState(0);
const newEmailInputRef = useRef(null);
const codeInputRef = useRef(null);
@@ -171,6 +173,7 @@ export default function Profile() {
onSuccess: () => {
setSuccess(t('profile.verificationResent'));
setError(null);
+ setVerificationResendCooldown(UI.RESEND_COOLDOWN_SEC);
},
onError: (err: { response?: { data?: { detail?: string } } }) => {
setError(err.response?.data?.detail || t('common.error'));
@@ -228,7 +231,7 @@ export default function Profile() {
},
});
- // Resend cooldown timer
+ // Resend cooldown timers
useEffect(() => {
if (resendCooldown <= 0) return;
const timer = setInterval(() => {
@@ -237,6 +240,14 @@ export default function Profile() {
return () => clearInterval(timer);
}, [resendCooldown]);
+ useEffect(() => {
+ if (verificationResendCooldown <= 0) return;
+ const timer = setInterval(() => {
+ setVerificationResendCooldown((prev) => Math.max(0, prev - 1));
+ }, 1000);
+ return () => clearInterval(timer);
+ }, [verificationResendCooldown]);
+
// Auto-focus inputs on step change
useEffect(() => {
const timer = setTimeout(() => {
@@ -454,34 +465,34 @@ export default function Profile() {
- {(user.auth_type === 'telegram' || user.auth_type === 'email') && (
-
- )}
+