diff --git a/src/App.tsx b/src/App.tsx index b39bd3e..7aa7fb7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -90,7 +90,8 @@ const AdminPinnedMessageCreate = lazy(() => import('./pages/AdminPinnedMessageCr const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview')); function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isLoading } = useAuthStore(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isLoading = useAuthStore((state) => state.isLoading); const location = useLocation(); if (isLoading) { @@ -107,7 +108,9 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { } function AdminRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isLoading, isAdmin } = useAuthStore(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isLoading = useAuthStore((state) => state.isLoading); + const isAdmin = useAuthStore((state) => state.isAdmin); const location = useLocation(); if (isLoading) { @@ -133,7 +136,7 @@ function LazyPage({ children }: { children: React.ReactNode }) { } function BlockingOverlay() { - const { blockingType } = useBlockingStore(); + const blockingType = useBlockingStore((state) => state.blockingType); if (blockingType === 'maintenance') { return ; diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index 6f6dfeb..bd5b6f2 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -16,6 +16,8 @@ import { ToastProvider } from './components/Toast'; import { TooltipProvider } from './components/primitives/Tooltip'; import { isInTelegramWebApp } from './hooks/useTelegramSDK'; +const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const; + /** * Manages Telegram BackButton visibility based on navigation location. * Shows back button on non-root routes, hides on root. @@ -77,7 +79,7 @@ export function AppWithNavigator() { - + diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index 437e126..130a95c 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -63,6 +63,7 @@ export type TrafficParams = { }; const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const MAX_CACHE_ENTRIES = 20; const trafficCache = new Map(); @@ -106,6 +107,16 @@ export const adminTrafficApi = { trafficCache.set(key, { data, timestamp: Date.now() }); + // Evict oldest entries to prevent unbounded memory growth + if (trafficCache.size > MAX_CACHE_ENTRIES) { + const iterator = trafficCache.keys(); + while (trafficCache.size > MAX_CACHE_ENTRIES) { + const oldest = iterator.next(); + if (oldest.done) break; + trafficCache.delete(oldest.value); + } + } + return data; }, diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx index a97b49a..de87f6c 100644 --- a/src/components/SuccessNotificationModal.tsx +++ b/src/components/SuccessNotificationModal.tsx @@ -78,7 +78,9 @@ const CloseIcon = () => ( export default function SuccessNotificationModal() { const { t } = useTranslation(); const navigate = useNavigate(); - const { isOpen, data, hide } = useSuccessNotification(); + const isOpen = useSuccessNotification((state) => state.isOpen); + const data = useSuccessNotification((state) => state.data); + const hide = useSuccessNotification((state) => state.hide); const { formatAmount, currencySymbol } = useCurrency(); const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK(); const haptic = useHaptic(); diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index 693f58d..24018a8 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -33,7 +33,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const { isAuthenticated } = useAuthStore(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const { showToast } = useToast(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 0b85413..48abd30 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -221,9 +221,10 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { {/* Progress bar */}
diff --git a/src/components/WebSocketNotifications.tsx b/src/components/WebSocketNotifications.tsx index 3baffb3..5d93cbe 100644 --- a/src/components/WebSocketNotifications.tsx +++ b/src/components/WebSocketNotifications.tsx @@ -18,9 +18,9 @@ export default function WebSocketNotifications() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { showToast } = useToast(); - const { refreshUser } = useAuthStore(); + const refreshUser = useAuthStore((state) => state.refreshUser); const { formatAmount, currencySymbol } = useCurrency(); - const { show: showSuccessModal } = useSuccessNotification(); + const showSuccessModal = useSuccessNotification((state) => state.show); const handleMessage = useCallback( (message: WSMessage) => { diff --git a/src/components/blocking/BlacklistedScreen.tsx b/src/components/blocking/BlacklistedScreen.tsx index 1cbedb8..c5051ad 100644 --- a/src/components/blocking/BlacklistedScreen.tsx +++ b/src/components/blocking/BlacklistedScreen.tsx @@ -3,7 +3,7 @@ import { useBlockingStore } from '../../store/blocking'; export default function BlacklistedScreen() { const { t } = useTranslation(); - const { blacklistedInfo } = useBlockingStore(); + const blacklistedInfo = useBlockingStore((state) => state.blacklistedInfo); return (
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') && ( - - )} +
)} - {user.email_verified && - (user.auth_type === 'telegram' || user.auth_type === 'email') && ( -
-

{t('profile.canLoginWithEmail')}

- -
- )} + {user.email_verified && ( +
+

{t('profile.canLoginWithEmail')}

+ +
+ )} {/* Inline email change flow */} diff --git a/src/pages/ReferralPartnerApply.tsx b/src/pages/ReferralPartnerApply.tsx index 98a444f..9638c04 100644 --- a/src/pages/ReferralPartnerApply.tsx +++ b/src/pages/ReferralPartnerApply.tsx @@ -114,6 +114,7 @@ export default function ReferralPartnerApply() { diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index eaf5a1b..7756531 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -89,24 +89,47 @@ export default function Subscription() { // Helper to format price from kopeks const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`; - // Helper to apply promo discount to a price + // Helper to apply promo discount to a price, stacking with existing promo group discount const applyPromoDiscount = ( priceKopeks: number, - hasExistingDiscount: boolean = false, + existingOriginalPrice?: number | null, ): { price: number; original: number | null; percent: number | null; + isPromoGroup: boolean; } => { - // Only apply promo discount if no existing discount (from promo group) and we have an active promo discount - if (!activeDiscount?.is_active || !activeDiscount.discount_percent || hasExistingDiscount) { - return { price: priceKopeks, original: null, percent: null }; + const hasExisting = (existingOriginalPrice ?? 0) > priceKopeks; + const hasPromo = !!activeDiscount?.is_active && !!activeDiscount.discount_percent; + + if (!hasExisting && !hasPromo) { + return { price: priceKopeks, original: null, percent: null, isPromoGroup: false }; } - const discountedPrice = Math.round(priceKopeks * (1 - activeDiscount.discount_percent / 100)); + + let finalPrice = priceKopeks; + if (hasPromo) { + finalPrice = Math.round(priceKopeks * (1 - activeDiscount!.discount_percent! / 100)); + } + + if (hasExisting) { + // Promo group discount exists — calculate combined percent from deepest original + const combinedPercent = hasPromo + ? Math.round((1 - finalPrice / existingOriginalPrice!) * 100) + : Math.round((1 - priceKopeks / existingOriginalPrice!) * 100); + return { + price: finalPrice, + original: existingOriginalPrice!, + percent: combinedPercent, + isPromoGroup: true, + }; + } + + // Only promo offer discount (no promo group) return { - price: discountedPrice, + price: finalPrice, original: priceKopeks, - percent: activeDiscount.discount_percent, + percent: activeDiscount!.discount_percent!, + isPromoGroup: false, }; }; @@ -2338,7 +2361,9 @@ export default function Subscription() {
{tariff.name}
{tariff.description && ( -
{tariff.description}
+
+ {tariff.description} +
)}
{isCurrentTariff && ( @@ -2416,41 +2441,34 @@ export default function Subscription() { const dailyPrice = tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0; const originalDailyPrice = tariff.original_daily_price_kopeks || 0; - const hasExistingDailyDiscount = originalDailyPrice > dailyPrice; if (dailyPrice > 0) { - // Apply promo discount if no existing discount const promoDaily = applyPromoDiscount( dailyPrice, - hasExistingDailyDiscount, + originalDailyPrice > dailyPrice ? originalDailyPrice : undefined, ); return ( {formatPrice(promoDaily.price)} - {/* Show original price from promo group or promo offer */} - {(hasExistingDailyDiscount || promoDaily.original) && ( - - {formatPrice( - hasExistingDailyDiscount - ? originalDailyPrice - : promoDaily.original!, - )} - - )} + {promoDaily.original && + promoDaily.original > promoDaily.price && ( + + {formatPrice(promoDaily.original)} + + )} {t('subscription.tariff.perDay')} {/* Show discount badge */} - {tariff.daily_discount_percent && - tariff.daily_discount_percent > 0 ? ( - - -{tariff.daily_discount_percent}% + {promoDaily.percent && promoDaily.percent > 0 && ( + + -{promoDaily.percent}% - ) : ( - promoDaily.percent && ( - - -{promoDaily.percent}% - - ) )} ); @@ -2458,14 +2476,9 @@ export default function Subscription() { // Period-based price if (tariff.periods.length > 0) { const firstPeriod = tariff.periods[0]; - const hasExistingDiscount = !!( - firstPeriod?.original_price_kopeks && - firstPeriod.original_price_kopeks > firstPeriod.price_kopeks - ); - // Apply promo discount if no existing discount const promoPeriod = applyPromoDiscount( firstPeriod?.price_kopeks || 0, - hasExistingDiscount, + firstPeriod?.original_price_kopeks, ); return ( @@ -2473,27 +2486,22 @@ export default function Subscription() { {formatPrice(promoPeriod.price)} - {/* Show original price from promo group or promo offer */} - {(hasExistingDiscount || promoPeriod.original) && ( - - {formatPrice( - hasExistingDiscount - ? firstPeriod.original_price_kopeks! - : promoPeriod.original!, - )} - - )} - {/* Show discount badge */} - {hasExistingDiscount && firstPeriod.discount_percent ? ( - - -{firstPeriod.discount_percent}% - - ) : ( - promoPeriod.percent && ( - - -{promoPeriod.percent}% + {promoPeriod.original && + promoPeriod.original > promoPeriod.price && ( + + {formatPrice(promoPeriod.original)} - ) + )} + {promoPeriod.percent && promoPeriod.percent > 0 && ( + + -{promoPeriod.percent}% + )} ); @@ -2702,24 +2710,17 @@ export default function Subscription() { {selectedTariff.periods.length > 0 && !useCustomDays && (
{selectedTariff.periods.map((period) => { - const hasExistingDiscount = !!( - period.original_price_kopeks && - period.original_price_kopeks > period.price_kopeks - ); const promoPeriod = applyPromoDiscount( period.price_kopeks, - hasExistingDiscount, + period.original_price_kopeks, ); - const displayDiscount = hasExistingDiscount - ? period.discount_percent - : promoPeriod.percent; - const displayOriginal = hasExistingDiscount - ? period.original_price_kopeks - : promoPeriod.original; + const displayDiscount = promoPeriod.percent; + const displayOriginal = promoPeriod.original; const displayPrice = promoPeriod.price; - const displayPerMonth = hasExistingDiscount - ? period.price_per_month_kopeks - : Math.round(promoPeriod.price / (period.days / 30)); + const displayPerMonth = + displayPrice !== period.price_kopeks + ? Math.round(displayPrice / Math.max(1, period.days / 30)) + : period.price_per_month_kopeks; return (
) : ( @@ -3035,16 +3051,12 @@ export default function Subscription() {
{formatPrice(promoPeriod.price)} - {(hasExistingPeriodDiscount || - promoPeriod.original) && ( - - {formatPrice( - hasExistingPeriodDiscount - ? selectedTariffPeriod.original_price_kopeks! - : promoPeriod.original!, - )} - - )} + {promoPeriod.original && + promoPeriod.original > promoPeriod.price && ( + + {formatPrice(promoPeriod.original)} + + )}
)} @@ -3177,19 +3189,10 @@ export default function Subscription() { {currentStep === 'period' && classicOptions && (
{classicOptions.periods.map((period) => { - const hasExistingDiscount = !!( - period.discount_percent && period.discount_percent > 0 - ); const promoPeriod = applyPromoDiscount( period.price_kopeks, - hasExistingDiscount, + period.original_price_kopeks, ); - const displayDiscount = hasExistingDiscount - ? period.discount_percent - : promoPeriod.percent; - const displayOriginal = hasExistingDiscount - ? period.original_price_kopeks - : promoPeriod.original; return (
@@ -3249,12 +3252,9 @@ export default function Subscription() { {currentStep === 'traffic' && selectedPeriod?.traffic.options && (
{selectedPeriod.traffic.options.map((option) => { - const hasExistingDiscount = !!( - option.discount_percent && option.discount_percent > 0 - ); const promoTraffic = applyPromoDiscount( option.price_kopeks, - hasExistingDiscount, + option.original_price_kopeks, ); return ( @@ -3268,41 +3268,24 @@ export default function Subscription() { : '' } ${!option.is_available ? 'cursor-not-allowed opacity-50' : ''}`} > - {(() => { - const trafficDisplayDiscount = hasExistingDiscount - ? option.discount_percent - : promoTraffic.percent; - const trafficDisplayOriginal = hasExistingDiscount - ? option.original_price_kopeks - : promoTraffic.original; - return ( - <> - {trafficDisplayDiscount && trafficDisplayDiscount > 0 && ( -
- -{trafficDisplayDiscount}% -
- )} -
- {option.label} -
-
- - {formatPrice(promoTraffic.price)} - - {trafficDisplayOriginal && - trafficDisplayOriginal > promoTraffic.price && ( - - {formatPrice(trafficDisplayOriginal)} - - )} -
- - ); - })()} + {promoTraffic.percent && promoTraffic.percent > 0 && ( +
+ -{promoTraffic.percent}% +
+ )} +
{option.label}
+
+ {formatPrice(promoTraffic.price)} + {promoTraffic.original && promoTraffic.original > promoTraffic.price && ( + + {formatPrice(promoTraffic.original)} + + )} +
); })} @@ -3322,12 +3305,9 @@ export default function Subscription() { return true; }) .map((server) => { - const hasExistingDiscount = !!( - server.discount_percent && server.discount_percent > 0 - ); const promoServer = applyPromoDiscount( server.price_kopeks, - hasExistingDiscount, + server.original_price_kopeks, ); return ( @@ -3343,20 +3323,15 @@ export default function Subscription() { : 'cursor-not-allowed border-dark-800/30 bg-dark-900/30 opacity-50' }`} > - {(() => { - const serverDisplayDiscount = hasExistingDiscount - ? server.discount_percent - : promoServer.percent; - return serverDisplayDiscount && serverDisplayDiscount > 0 ? ( -
- -{serverDisplayDiscount}% -
- ) : null; - })()} + {promoServer.percent && promoServer.percent > 0 ? ( +
+ -{promoServer.percent}% +
+ ) : null}
- {(() => { - const serverOriginal = hasExistingDiscount - ? server.original_price_kopeks - : promoServer.original; - return serverOriginal && serverOriginal > promoServer.price ? ( - - {formatPrice(serverOriginal)} - - ) : null; - })()} + {promoServer.original && + promoServer.original > promoServer.price ? ( + + {formatPrice(promoServer.original)} + + ) : null}
@@ -3448,28 +3419,26 @@ export default function Subscription() { ) : preview ? (
{/* Active promo discount banner */} - {activeDiscount?.is_active && - activeDiscount.discount_percent && - !preview.original_price_kopeks && ( -
- - - - - {t('promo.discountApplied')} -{activeDiscount.discount_percent}% - -
- )} + {activeDiscount?.is_active && activeDiscount.discount_percent && ( +
+ + + + + {t('promo.discountApplied')} -{activeDiscount.discount_percent}% + +
+ )} {preview.breakdown.map((item, idx) => (
@@ -3479,16 +3448,10 @@ export default function Subscription() { ))} {(() => { - // Apply promo discount if not already applied by server - const hasServerDiscount = !!preview.original_price_kopeks; const promoTotal = applyPromoDiscount( preview.total_price_kopeks, - hasServerDiscount, + preview.original_price_kopeks, ); - const displayTotal = promoTotal.price; - const displayOriginal = hasServerDiscount - ? preview.original_price_kopeks - : promoTotal.original; return (
@@ -3497,11 +3460,11 @@ export default function Subscription() {
- {formatPrice(displayTotal)} + {formatPrice(promoTotal.price)}
- {displayOriginal && ( + {promoTotal.original && promoTotal.original > promoTotal.price && (
- {formatPrice(displayOriginal)} + {formatPrice(promoTotal.original)}
)}
diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index a1bd3d4..822d512 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; @@ -151,7 +151,7 @@ export default function Support() { log.debug('Component loaded'); const { t } = useTranslation(); - const { isAdmin } = useAuthStore(); + const isAdmin = useAuthStore((state) => state.isAdmin); const queryClient = useQueryClient(); const { openTelegramLink, openLink } = usePlatform(); const [selectedTicket, setSelectedTicket] = useState(null); @@ -166,6 +166,34 @@ export default function Support() { const [replyAttachment, setReplyAttachment] = useState(null); const createFileInputRef = useRef(null); const replyFileInputRef = useRef(null); + const createPreviewRef = useRef(null); + const replyPreviewRef = useRef(null); + + // Revoke blob URLs on unmount to prevent memory leaks + useEffect(() => { + const createRef = createPreviewRef; + const replyRef = replyPreviewRef; + return () => { + if (createRef.current) URL.revokeObjectURL(createRef.current); + if (replyRef.current) URL.revokeObjectURL(replyRef.current); + }; + }, []); + + const clearCreateAttachment = () => { + if (createPreviewRef.current) { + URL.revokeObjectURL(createPreviewRef.current); + createPreviewRef.current = null; + } + clearCreateAttachment(); + }; + + const clearReplyAttachment = () => { + if (replyPreviewRef.current) { + URL.revokeObjectURL(replyPreviewRef.current); + replyPreviewRef.current = null; + } + clearReplyAttachment(); + }; // Get support configuration const { data: supportConfig, isLoading: configLoading } = useQuery({ @@ -213,8 +241,11 @@ export default function Support() { return; } - // Create preview + // Revoke old blob URL before creating new one + const previewRef = setAttachment === setCreateAttachment ? createPreviewRef : replyPreviewRef; + if (previewRef.current) URL.revokeObjectURL(previewRef.current); const preview = URL.createObjectURL(file); + previewRef.current = preview; setAttachment({ file, preview, uploading: true }); try { @@ -250,7 +281,7 @@ export default function Support() { setShowCreateForm(false); setNewTitle(''); setNewMessage(''); - setCreateAttachment(null); + clearCreateAttachment(); setSelectedTicket(ticket); }, }); @@ -268,7 +299,7 @@ export default function Support() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] }); setReplyMessage(''); - setReplyAttachment(null); + clearReplyAttachment(); }, }); @@ -444,7 +475,7 @@ export default function Support() { onClick={() => { setShowCreateForm(true); setSelectedTicket(null); - setCreateAttachment(null); + clearCreateAttachment(); }} > @@ -469,7 +500,7 @@ export default function Support() { onClick={() => { setSelectedTicket(ticket as unknown as TicketDetail); setShowCreateForm(false); - setReplyAttachment(null); + clearReplyAttachment(); }} className={`w-full rounded-bento border p-4 text-left transition-all ${ selectedTicket?.id === ticket.id @@ -574,7 +605,7 @@ export default function Support() { {createAttachment ? ( setCreateAttachment(null)} + onRemove={() => clearCreateAttachment()} /> ) : (