Merge pull request #232 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-02-23 21:31:48 +03:00
committed by GitHub
44 changed files with 541 additions and 335 deletions

View File

@@ -90,7 +90,8 @@ const AdminPinnedMessageCreate = lazy(() => import('./pages/AdminPinnedMessageCr
const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview')); const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview'));
function ProtectedRoute({ children }: { children: React.ReactNode }) { 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(); const location = useLocation();
if (isLoading) { if (isLoading) {
@@ -107,7 +108,9 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
} }
function AdminRoute({ 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(); const location = useLocation();
if (isLoading) { if (isLoading) {
@@ -133,7 +136,7 @@ function LazyPage({ children }: { children: React.ReactNode }) {
} }
function BlockingOverlay() { function BlockingOverlay() {
const { blockingType } = useBlockingStore(); const blockingType = useBlockingStore((state) => state.blockingType);
if (blockingType === 'maintenance') { if (blockingType === 'maintenance') {
return <MaintenanceScreen />; return <MaintenanceScreen />;

View File

@@ -16,6 +16,8 @@ import { ToastProvider } from './components/Toast';
import { TooltipProvider } from './components/primitives/Tooltip'; import { TooltipProvider } from './components/primitives/Tooltip';
import { isInTelegramWebApp } from './hooks/useTelegramSDK'; import { isInTelegramWebApp } from './hooks/useTelegramSDK';
const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const;
/** /**
* Manages Telegram BackButton visibility based on navigation location. * Manages Telegram BackButton visibility based on navigation location.
* Shows back button on non-root routes, hides on root. * Shows back button on non-root routes, hides on root.
@@ -77,7 +79,7 @@ export function AppWithNavigator() {
<TooltipProvider> <TooltipProvider>
<ToastProvider> <ToastProvider>
<WebSocketProvider> <WebSocketProvider>
<Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}> <Twemoji options={TWEMOJI_OPTIONS}>
<App /> <App />
</Twemoji> </Twemoji>
</WebSocketProvider> </WebSocketProvider>

View File

@@ -63,6 +63,7 @@ export type TrafficParams = {
}; };
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const MAX_CACHE_ENTRIES = 20;
const trafficCache = new Map<string, { data: TrafficUsageResponse; timestamp: number }>(); const trafficCache = new Map<string, { data: TrafficUsageResponse; timestamp: number }>();
@@ -106,6 +107,16 @@ export const adminTrafficApi = {
trafficCache.set(key, { data, timestamp: Date.now() }); 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; return data;
}, },

View File

@@ -78,7 +78,9 @@ const CloseIcon = () => (
export default function SuccessNotificationModal() { export default function SuccessNotificationModal() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); 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 { formatAmount, currencySymbol } = useCurrency();
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK(); const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK();
const haptic = useHaptic(); const haptic = useHaptic();

View File

@@ -33,7 +33,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isAuthenticated } = useAuthStore(); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const { showToast } = useToast(); const { showToast } = useToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);

View File

@@ -221,9 +221,10 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
{/* Progress bar */} {/* Progress bar */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-dark-800/50"> <div className="absolute bottom-0 left-0 right-0 h-1 bg-dark-800/50">
<div <div
className={`h-full ${style.progress} opacity-60`} className={`h-full w-full ${style.progress} opacity-60`}
style={{ style={{
animation: `shrink ${toast.duration}ms linear forwards`, animation: `shrink ${toast.duration}ms linear forwards`,
transformOrigin: 'left',
}} }}
/> />
</div> </div>

View File

@@ -18,9 +18,9 @@ export default function WebSocketNotifications() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToast(); const { showToast } = useToast();
const { refreshUser } = useAuthStore(); const refreshUser = useAuthStore((state) => state.refreshUser);
const { formatAmount, currencySymbol } = useCurrency(); const { formatAmount, currencySymbol } = useCurrency();
const { show: showSuccessModal } = useSuccessNotification(); const showSuccessModal = useSuccessNotification((state) => state.show);
const handleMessage = useCallback( const handleMessage = useCallback(
(message: WSMessage) => { (message: WSMessage) => {

View File

@@ -3,7 +3,7 @@ import { useBlockingStore } from '../../store/blocking';
export default function BlacklistedScreen() { export default function BlacklistedScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const { blacklistedInfo } = useBlockingStore(); const blacklistedInfo = useBlockingStore((state) => state.blacklistedInfo);
return ( return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"> <div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">

View File

@@ -7,7 +7,8 @@ const CHECK_COOLDOWN_SECONDS = 5;
export default function ChannelSubscriptionScreen() { export default function ChannelSubscriptionScreen() {
const { t } = useTranslation(); 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 [isChecking, setIsChecking] = useState(false);
const [cooldown, setCooldown] = useState(0); const [cooldown, setCooldown] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);

View File

@@ -3,7 +3,7 @@ import { useBlockingStore } from '../../store/blocking';
export default function MaintenanceScreen() { export default function MaintenanceScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const { maintenanceInfo } = useBlockingStore(); const maintenanceInfo = useBlockingStore((state) => state.maintenanceInfo);
return ( return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"> <div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">

View File

@@ -5,6 +5,7 @@ import { useState, useEffect } from 'react';
import { initDataUser } from '@telegram-apps/sdk-react'; import { initDataUser } from '@telegram-apps/sdk-react';
import { useAuthStore } from '@/store/auth'; import { useAuthStore } from '@/store/auth';
import { useShallow } from 'zustand/shallow';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { usePlatform } from '@/platform'; import { usePlatform } from '@/platform';
import { import {
@@ -77,7 +78,9 @@ export function AppHeader({
}: AppHeaderProps) { }: AppHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); 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 { toggleTheme, isDark } = useTheme();
const { haptic, platform } = usePlatform(); const { haptic, platform } = usePlatform();
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null); const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null);

View File

@@ -193,7 +193,8 @@ interface AppShellProps {
export function AppShell({ children }: AppShellProps) { export function AppShell({ children }: AppShellProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); 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 } = const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
useTelegramSDK(); useTelegramSDK();
const haptic = useHaptic(); const haptic = useHaptic();

View File

@@ -132,12 +132,13 @@ let _webglAvailable: boolean | null = null;
function isWebglAvailable(): boolean { function isWebglAvailable(): boolean {
if (_webglAvailable === null) { if (_webglAvailable === null) {
try { try {
const renderer = new Renderer({ const canvas = document.createElement('canvas');
alpha: true, const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
antialias: false, _webglAvailable = !!gl;
powerPreference: 'low-power', if (gl) {
}); const loseCtx = gl.getExtension('WEBGL_lose_context');
_webglAvailable = !!renderer.gl; if (loseCtx) loseCtx.loseContext();
}
} catch { } catch {
_webglAvailable = false; _webglAvailable = false;
} }
@@ -258,15 +259,16 @@ function AuroraImpl() {
resize(); resize();
let lastTime = 0; let lastTime = 0;
const targetFPS = 20; const targetFPS = 10;
const frameInterval = 1000 / targetFPS; const frameInterval = 1000 / targetFPS;
const speed = 0.3; const speed = 0.3;
function animate(currentTime: number) { function animate(currentTime: number) {
animationFrameRef.current = requestAnimationFrame(animate);
const delta = currentTime - lastTime; const delta = currentTime - lastTime;
if (delta < frameInterval) return; if (delta < frameInterval) {
animationFrameRef.current = requestAnimationFrame(animate);
return;
}
lastTime = currentTime - (delta % frameInterval); lastTime = currentTime - (delta % frameInterval);
@@ -274,15 +276,45 @@ function AuroraImpl() {
programRef.current.uniforms.uTime.value += speed * 0.01; programRef.current.uniforms.uTime.value += speed * 0.01;
rendererRef.current.render({ scene: mesh }); 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); animationFrameRef.current = requestAnimationFrame(animate);
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('resize', resize); window.removeEventListener('resize', resize);
cancelAnimationFrame(animationFrameRef.current); 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; rendererRef.current = null;
programRef.current = null; programRef.current = null;
}; };

View File

@@ -49,7 +49,8 @@ export function DesktopSidebar({
}: DesktopSidebarProps) { }: DesktopSidebarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const { user, logout } = useAuthStore(); const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
const { haptic } = usePlatform(); const { haptic } = usePlatform();
// Branding // Branding

View File

@@ -62,7 +62,7 @@ export function CommandPalette({
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { haptic } = usePlatform(); const { haptic } = usePlatform();
const { isAdmin } = useAuthStore(); const isAdmin = useAuthStore((state) => state.isAdmin);
const { toggleTheme, isDark } = useTheme(); const { toggleTheme, isDark } = useTheme();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth'; import { useAuthStore } from '@/store/auth';
import { useTelegramSDK, setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK'; 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'; const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
export function useBranding() { export function useBranding() {
const { isAuthenticated } = useAuthStore(); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const { isFullscreen, isTelegramWebApp, requestFullscreen, isMobile } = useTelegramSDK(); const { isTelegramWebApp, requestFullscreen, isMobile } = useTelegramSDK();
// Branding data // Branding data
const { data: branding } = useQuery({ const { data: branding } = useQuery({
@@ -62,13 +62,16 @@ export function useBranding() {
staleTime: 60000, staleTime: 60000,
}); });
const fullscreenRequestedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!fullscreenSetting || !isTelegramWebApp) return; if (!fullscreenSetting || !isTelegramWebApp) return;
setCachedFullscreenEnabled(fullscreenSetting.enabled); setCachedFullscreenEnabled(fullscreenSetting.enabled);
if (fullscreenSetting.enabled && !isFullscreen && isMobile) { if (fullscreenSetting.enabled && isMobile && !fullscreenRequestedRef.current) {
fullscreenRequestedRef.current = true;
requestFullscreen(); requestFullscreen();
} }
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]); }, [fullscreenSetting, isTelegramWebApp, requestFullscreen, isMobile]);
return { return {
appName, appName,

View File

@@ -6,7 +6,7 @@ import { contestsApi } from '@/api/contests';
import { pollsApi } from '@/api/polls'; import { pollsApi } from '@/api/polls';
export function useFeatureFlags() { export function useFeatureFlags() {
const { isAuthenticated } = useAuthStore(); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const { data: referralTerms } = useQuery({ const { data: referralTerms } = useQuery({
queryKey: ['referral-terms'], queryKey: ['referral-terms'],

View File

@@ -2902,6 +2902,7 @@
"notVerified": "Not verified", "notVerified": "Not verified",
"verificationRequired": "Please verify your email to use email login.", "verificationRequired": "Please verify your email to use email login.",
"resendVerification": "Resend Verification Email", "resendVerification": "Resend Verification Email",
"resendIn": "Resend in {{seconds}} sec.",
"verificationResent": "Verification email resent!", "verificationResent": "Verification email resent!",
"canLoginWithEmail": "You can now log in using your email and password.", "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.", "emailAlreadyRegistered": "This email is already linked to another account. If this is your email, log in with it or contact support.",

View File

@@ -2402,6 +2402,7 @@
"notVerified": "تایید نشده", "notVerified": "تایید نشده",
"verificationRequired": "لطفاً ایمیل خود را تایید کنید تا از ورود با ایمیل استفاده کنید.", "verificationRequired": "لطفاً ایمیل خود را تایید کنید تا از ورود با ایمیل استفاده کنید.",
"resendVerification": "ارسال مجدد ایمیل تایید", "resendVerification": "ارسال مجدد ایمیل تایید",
"resendIn": "ارسال مجدد در {{seconds}} ثانیه",
"verificationResent": "ایمیل تایید مجدداً ارسال شد!", "verificationResent": "ایمیل تایید مجدداً ارسال شد!",
"canLoginWithEmail": "اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", "canLoginWithEmail": "اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.",
"emailAlreadyRegistered": "این ایمیل قبلاً به حساب دیگری متصل شده است. اگر این ایمیل شماست، با آن وارد شوید یا با پشتیبانی تماس بگیرید.", "emailAlreadyRegistered": "این ایمیل قبلاً به حساب دیگری متصل شده است. اگر این ایمیل شماست، با آن وارد شوید یا با پشتیبانی تماس بگیرید.",

View File

@@ -3454,6 +3454,7 @@
"notVerified": "Не подтверждён", "notVerified": "Не подтверждён",
"verificationRequired": "Подтвердите email для использования входа по почте.", "verificationRequired": "Подтвердите email для использования входа по почте.",
"resendVerification": "Отправить письмо повторно", "resendVerification": "Отправить письмо повторно",
"resendIn": "Повторить через {{seconds}} сек.",
"verificationResent": "Письмо отправлено повторно!", "verificationResent": "Письмо отправлено повторно!",
"canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.", "canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.",
"emailAlreadyRegistered": "Этот email уже привязан к другому аккаунту. Если это ваш email, войдите через него или обратитесь в поддержку.", "emailAlreadyRegistered": "Этот email уже привязан к другому аккаунту. Если это ваш email, войдите через него или обратитесь в поддержку.",

View File

@@ -2401,6 +2401,7 @@
"notVerified": "未验证", "notVerified": "未验证",
"verificationRequired": "请验证邮箱以使用邮箱登录。", "verificationRequired": "请验证邮箱以使用邮箱登录。",
"resendVerification": "重新发送验证邮件", "resendVerification": "重新发送验证邮件",
"resendIn": "{{seconds}} 秒后重新发送",
"verificationResent": "验证邮件已重新发送!", "verificationResent": "验证邮件已重新发送!",
"canLoginWithEmail": "现在您可以使用邮箱和密码登录。", "canLoginWithEmail": "现在您可以使用邮箱和密码登录。",
"emailAlreadyRegistered": "此邮箱已绑定到其他账户。如果这是您的邮箱,请使用邮箱登录或联系客服。", "emailAlreadyRegistered": "此邮箱已绑定到其他账户。如果这是您的邮箱,请使用邮箱登录或联系客服。",

View File

@@ -411,8 +411,8 @@ export default function AdminBanSystem() {
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); 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]}`; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}; };

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useMemo } from 'react'; import { useState, useRef, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -135,6 +135,14 @@ export default function AdminBroadcastCreate() {
const [mediaPreview, setMediaPreview] = useState<string | null>(null); const [mediaPreview, setMediaPreview] = useState<string | null>(null);
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null); const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const mediaPreviewRef = useRef<string | null>(null);
// Revoke blob URLs on unmount to prevent memory leaks
useEffect(() => {
return () => {
if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
};
}, []);
// Email-specific state // Email-specific state
const [emailSubject, setEmailSubject] = useState(''); const [emailSubject, setEmailSubject] = useState('');
@@ -271,7 +279,10 @@ export default function AdminBroadcastCreate() {
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
setMediaType('photo'); 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/')) { } else if (file.type.startsWith('video/')) {
setMediaType('video'); setMediaType('video');
setMediaPreview(null); setMediaPreview(null);
@@ -295,6 +306,10 @@ export default function AdminBroadcastCreate() {
// Remove media // Remove media
const handleRemoveMedia = () => { const handleRemoveMedia = () => {
if (mediaPreviewRef.current) {
URL.revokeObjectURL(mediaPreviewRef.current);
mediaPreviewRef.current = null;
}
setMediaFile(null); setMediaFile(null);
setMediaPreview(null); setMediaPreview(null);
setUploadedFileId(null); setUploadedFileId(null);

View File

@@ -137,7 +137,13 @@ export default function AdminBroadcasts() {
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, refetch } = useQuery({
queryKey: ['admin', 'broadcasts', 'list', page], queryKey: ['admin', 'broadcasts', 'list', page],
queryFn: () => adminBroadcastsApi.list(limit, page * limit), 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 || []; const broadcasts = data?.items || [];

View File

@@ -86,6 +86,14 @@ export default function AdminPinnedMessageCreate() {
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null); const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [existingMediaType, setExistingMediaType] = useState<'photo' | 'video' | null>(null); const [existingMediaType, setExistingMediaType] = useState<'photo' | 'video' | null>(null);
const mediaPreviewRef = useRef<string | null>(null);
// Revoke blob URLs on unmount to prevent memory leaks
useEffect(() => {
return () => {
if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
};
}, []);
// Load existing message for editing // Load existing message for editing
const { data: existingMessage, isLoading: isLoadingMessage } = useQuery({ const { data: existingMessage, isLoading: isLoadingMessage } = useQuery({
@@ -138,7 +146,10 @@ export default function AdminPinnedMessageCreate() {
let detectedType: 'photo' | 'video' = 'photo'; let detectedType: 'photo' | 'video' = 'photo';
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
detectedType = 'photo'; 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/')) { } else if (file.type.startsWith('video/')) {
detectedType = 'video'; detectedType = 'video';
setMediaPreview(null); setMediaPreview(null);
@@ -159,6 +170,10 @@ export default function AdminPinnedMessageCreate() {
// Remove media // Remove media
const handleRemoveMedia = () => { const handleRemoveMedia = () => {
if (mediaPreviewRef.current) {
URL.revokeObjectURL(mediaPreviewRef.current);
mediaPreviewRef.current = null;
}
setMediaFile(null); setMediaFile(null);
setMediaPreview(null); setMediaPreview(null);
setUploadedFileId(null); setUploadedFileId(null);

View File

@@ -42,8 +42,8 @@ const BackIcon = () => (
const formatBytes = (bytes: number): string => { const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); 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]; 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 = const memoryUsedPercent =
stats.server_info.memory_total > 0 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; : 0;
return ( return (
@@ -446,7 +452,7 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
<StatCard <StatCard
label={t('admin.remnawave.overview.memory', 'Memory')} label={t('admin.remnawave.overview.memory', 'Memory')}
value={`${memoryUsedPercent}%`} value={`${memoryUsedPercent}%`}
subValue={`${formatBytes(stats.server_info.memory_used)} / ${formatBytes(stats.server_info.memory_total)}`} subValue={`${formatBytes(memoryActualUsed)} / ${formatBytes(stats.server_info.memory_total)}`}
icon={<span className="text-lg">💾</span>} icon={<span className="text-lg">💾</span>}
color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'} color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'}
/> />

View File

@@ -35,8 +35,8 @@ declare module '@tanstack/react-table' {
const formatBytes = (bytes: number): string => { const formatBytes = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); 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]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}; };

View File

@@ -766,10 +766,10 @@ export default function AdminUserDetail() {
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
if (bytes < 1024) return `${bytes} B`; const k = 1024;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}; };
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {

View File

@@ -40,7 +40,7 @@ const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => (
export default function Balance() { export default function Balance() {
const { t } = useTranslation(); const { t } = useTranslation();
const { refreshUser } = useAuthStore(); const refreshUser = useAuthStore((state) => state.refreshUser);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { formatAmount, currencySymbol } = useCurrency(); const { formatAmount, currencySymbol } = useCurrency();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();

View File

@@ -14,7 +14,8 @@ import InstallationGuide from '../components/connection/InstallationGuide';
export default function Connection() { export default function Connection() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, isAdmin } = useAuthStore(); const user = useAuthStore((state) => state.user);
const isAdmin = useAuthStore((state) => state.isAdmin);
const { isTelegramWebApp } = useTelegramSDK(); const { isTelegramWebApp } = useTelegramSDK();
const { impact: hapticImpact } = useHaptic(); const { impact: hapticImpact } = useHaptic();

View File

@@ -48,14 +48,15 @@ const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
export default function Dashboard() { export default function Dashboard() {
const { t } = useTranslation(); const { t } = useTranslation();
const { user, refreshUser } = useAuthStore(); const user = useAuthStore((state) => state.user);
const refreshUser = useAuthStore((state) => state.refreshUser);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { formatAmount, currencySymbol, formatPositive } = useCurrency(); const { formatAmount, currencySymbol, formatPositive } = useCurrency();
const [trialError, setTrialError] = useState<string | null>(null); const [trialError, setTrialError] = useState<string | null>(null);
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding(); const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
const [showOnboarding, setShowOnboarding] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false);
const { blockingType } = useBlockingStore(); const blockingType = useBlockingStore((state) => state.blockingType);
// Refresh user data on mount // Refresh user data on mount
useEffect(() => { useEffect(() => {

View File

@@ -61,9 +61,24 @@ export default function DeepLinkRedirect() {
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'; const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V';
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; 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 // Get parameters
const deepLink = searchParams.get('url') || searchParams.get('deeplink') || ''; const deepLink = getRawParam('url') || getRawParam('deeplink') || '';
const subscriptionUrl = searchParams.get('sub') || ''; const subscriptionUrl = getRawParam('sub') || '';
const appParam = searchParams.get('app') || ''; const appParam = searchParams.get('app') || '';
// Detect app from deep link // Detect app from deep link

View File

@@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { useShallow } from 'zustand/shallow';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { isValidEmail } from '../utils/validation'; import { isValidEmail } from '../utils/validation';
import { import {
@@ -32,7 +33,15 @@ export default function Login() {
loginWithTelegram, loginWithTelegram,
loginWithEmail, loginWithEmail,
registerWithEmail, 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) // Get referral code from localStorage (captured from ?ref= param at module level in auth store)
const referralCode = getPendingReferralCode() || ''; const referralCode = getPendingReferralCode() || '';

View File

@@ -26,7 +26,8 @@ export default function OAuthCallback() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [error, setError] = useState(''); const [error, setError] = useState('');
const { loginWithOAuth, isAuthenticated } = useAuthStore(); const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {

View File

@@ -61,7 +61,8 @@ const PencilIcon = () => (
export default function Profile() { export default function Profile() {
const { t } = useTranslation(); const { t } = useTranslation();
const { user, setUser } = useAuthStore(); const user = useAuthStore((state) => state.user);
const setUser = useAuthStore((state) => state.setUser);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -77,6 +78,7 @@ export default function Profile() {
const [changeCode, setChangeCode] = useState(''); const [changeCode, setChangeCode] = useState('');
const [changeError, setChangeError] = useState<string | null>(null); const [changeError, setChangeError] = useState<string | null>(null);
const [resendCooldown, setResendCooldown] = useState(0); const [resendCooldown, setResendCooldown] = useState(0);
const [verificationResendCooldown, setVerificationResendCooldown] = useState(0);
const newEmailInputRef = useRef<HTMLInputElement>(null); const newEmailInputRef = useRef<HTMLInputElement>(null);
const codeInputRef = useRef<HTMLInputElement>(null); const codeInputRef = useRef<HTMLInputElement>(null);
@@ -171,6 +173,7 @@ export default function Profile() {
onSuccess: () => { onSuccess: () => {
setSuccess(t('profile.verificationResent')); setSuccess(t('profile.verificationResent'));
setError(null); setError(null);
setVerificationResendCooldown(UI.RESEND_COOLDOWN_SEC);
}, },
onError: (err: { response?: { data?: { detail?: string } } }) => { onError: (err: { response?: { data?: { detail?: string } } }) => {
setError(err.response?.data?.detail || t('common.error')); setError(err.response?.data?.detail || t('common.error'));
@@ -228,7 +231,7 @@ export default function Profile() {
}, },
}); });
// Resend cooldown timer // Resend cooldown timers
useEffect(() => { useEffect(() => {
if (resendCooldown <= 0) return; if (resendCooldown <= 0) return;
const timer = setInterval(() => { const timer = setInterval(() => {
@@ -237,6 +240,14 @@ export default function Profile() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [resendCooldown]); }, [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 // Auto-focus inputs on step change
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -454,34 +465,34 @@ export default function Profile() {
<Button <Button
onClick={() => resendVerificationMutation.mutate()} onClick={() => resendVerificationMutation.mutate()}
loading={resendVerificationMutation.isPending} loading={resendVerificationMutation.isPending}
disabled={verificationResendCooldown > 0}
> >
{t('profile.resendVerification')} {verificationResendCooldown > 0
? t('profile.resendIn', { seconds: verificationResendCooldown })
: t('profile.resendVerification')}
</Button> </Button>
{(user.auth_type === 'telegram' || user.auth_type === 'email') && ( <button
<button onClick={() => setChangeEmailStep('email')}
onClick={() => setChangeEmailStep('email')} className="text-sm text-accent-400 transition-colors hover:text-accent-300"
className="text-sm text-accent-400 transition-colors hover:text-accent-300" >
> {t('profile.changeEmail.button')}
{t('profile.changeEmail.button')} </button>
</button>
)}
</div> </div>
</div> </div>
)} )}
{user.email_verified && {user.email_verified && (
(user.auth_type === 'telegram' || user.auth_type === 'email') && ( <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p> <button
<button onClick={() => setChangeEmailStep('email')}
onClick={() => setChangeEmailStep('email')} className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300" >
> <PencilIcon />
<PencilIcon /> <span>{t('profile.changeEmail.button')}</span>
<span>{t('profile.changeEmail.button')}</span> </button>
</button> </div>
</div> )}
)}
{/* Inline email change flow */} {/* Inline email change flow */}
<AnimatePresence> <AnimatePresence>

View File

@@ -114,6 +114,7 @@ export default function ReferralPartnerApply() {
<input <input
type="number" type="number"
min={0} min={0}
max={2000000000}
className="input w-full" className="input w-full"
value={form.expected_monthly_referrals ?? ''} value={form.expected_monthly_referrals ?? ''}
onChange={(e) => onChange={(e) =>

View File

@@ -89,24 +89,47 @@ export default function Subscription() {
// Helper to format price from kopeks // Helper to format price from kopeks
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`; 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 = ( const applyPromoDiscount = (
priceKopeks: number, priceKopeks: number,
hasExistingDiscount: boolean = false, existingOriginalPrice?: number | null,
): { ): {
price: number; price: number;
original: number | null; original: number | null;
percent: 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 const hasExisting = (existingOriginalPrice ?? 0) > priceKopeks;
if (!activeDiscount?.is_active || !activeDiscount.discount_percent || hasExistingDiscount) { const hasPromo = !!activeDiscount?.is_active && !!activeDiscount.discount_percent;
return { price: priceKopeks, original: null, percent: null };
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 { return {
price: discountedPrice, price: finalPrice,
original: priceKopeks, original: priceKopeks,
percent: activeDiscount.discount_percent, percent: activeDiscount!.discount_percent!,
isPromoGroup: false,
}; };
}; };
@@ -2338,7 +2361,9 @@ export default function Subscription() {
<div> <div>
<div className="text-lg font-semibold text-dark-100">{tariff.name}</div> <div className="text-lg font-semibold text-dark-100">{tariff.name}</div>
{tariff.description && ( {tariff.description && (
<div className="mt-1 text-sm text-dark-400">{tariff.description}</div> <div className="mt-1 whitespace-pre-line text-sm text-dark-400">
{tariff.description}
</div>
)} )}
</div> </div>
{isCurrentTariff && ( {isCurrentTariff && (
@@ -2416,41 +2441,34 @@ export default function Subscription() {
const dailyPrice = const dailyPrice =
tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0; tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0;
const originalDailyPrice = tariff.original_daily_price_kopeks || 0; const originalDailyPrice = tariff.original_daily_price_kopeks || 0;
const hasExistingDailyDiscount = originalDailyPrice > dailyPrice;
if (dailyPrice > 0) { if (dailyPrice > 0) {
// Apply promo discount if no existing discount
const promoDaily = applyPromoDiscount( const promoDaily = applyPromoDiscount(
dailyPrice, dailyPrice,
hasExistingDailyDiscount, originalDailyPrice > dailyPrice ? originalDailyPrice : undefined,
); );
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="font-medium text-accent-400"> <span className="font-medium text-accent-400">
{formatPrice(promoDaily.price)} {formatPrice(promoDaily.price)}
</span> </span>
{/* Show original price from promo group or promo offer */} {promoDaily.original &&
{(hasExistingDailyDiscount || promoDaily.original) && ( promoDaily.original > promoDaily.price && (
<span className="text-xs text-dark-500 line-through"> <span className="text-xs text-dark-500 line-through">
{formatPrice( {formatPrice(promoDaily.original)}
hasExistingDailyDiscount </span>
? originalDailyPrice )}
: promoDaily.original!,
)}
</span>
)}
<span>{t('subscription.tariff.perDay')}</span> <span>{t('subscription.tariff.perDay')}</span>
{/* Show discount badge */} {/* Show discount badge */}
{tariff.daily_discount_percent && {promoDaily.percent && promoDaily.percent > 0 && (
tariff.daily_discount_percent > 0 ? ( <span
<span className="rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400"> className={`rounded px-1.5 py-0.5 text-xs ${
-{tariff.daily_discount_percent}% promoDaily.isPromoGroup
? 'bg-success-500/20 text-success-400'
: 'bg-orange-500/20 text-orange-400'
}`}
>
-{promoDaily.percent}%
</span> </span>
) : (
promoDaily.percent && (
<span className="rounded bg-orange-500/20 px-1.5 py-0.5 text-xs text-orange-400">
-{promoDaily.percent}%
</span>
)
)} )}
</span> </span>
); );
@@ -2458,14 +2476,9 @@ export default function Subscription() {
// Period-based price // Period-based price
if (tariff.periods.length > 0) { if (tariff.periods.length > 0) {
const firstPeriod = tariff.periods[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( const promoPeriod = applyPromoDiscount(
firstPeriod?.price_kopeks || 0, firstPeriod?.price_kopeks || 0,
hasExistingDiscount, firstPeriod?.original_price_kopeks,
); );
return ( return (
<span className="flex flex-wrap items-center gap-2"> <span className="flex flex-wrap items-center gap-2">
@@ -2473,27 +2486,22 @@ export default function Subscription() {
<span className="font-medium text-accent-400"> <span className="font-medium text-accent-400">
{formatPrice(promoPeriod.price)} {formatPrice(promoPeriod.price)}
</span> </span>
{/* Show original price from promo group or promo offer */} {promoPeriod.original &&
{(hasExistingDiscount || promoPeriod.original) && ( promoPeriod.original > promoPeriod.price && (
<span className="text-xs text-dark-500 line-through"> <span className="text-xs text-dark-500 line-through">
{formatPrice( {formatPrice(promoPeriod.original)}
hasExistingDiscount
? firstPeriod.original_price_kopeks!
: promoPeriod.original!,
)}
</span>
)}
{/* Show discount badge */}
{hasExistingDiscount && firstPeriod.discount_percent ? (
<span className="rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400">
-{firstPeriod.discount_percent}%
</span>
) : (
promoPeriod.percent && (
<span className="rounded bg-orange-500/20 px-1.5 py-0.5 text-xs text-orange-400">
-{promoPeriod.percent}%
</span> </span>
) )}
{promoPeriod.percent && promoPeriod.percent > 0 && (
<span
className={`rounded px-1.5 py-0.5 text-xs ${
promoPeriod.isPromoGroup
? 'bg-success-500/20 text-success-400'
: 'bg-orange-500/20 text-orange-400'
}`}
>
-{promoPeriod.percent}%
</span>
)} )}
</span> </span>
); );
@@ -2702,24 +2710,17 @@ export default function Subscription() {
{selectedTariff.periods.length > 0 && !useCustomDays && ( {selectedTariff.periods.length > 0 && !useCustomDays && (
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{selectedTariff.periods.map((period) => { {selectedTariff.periods.map((period) => {
const hasExistingDiscount = !!(
period.original_price_kopeks &&
period.original_price_kopeks > period.price_kopeks
);
const promoPeriod = applyPromoDiscount( const promoPeriod = applyPromoDiscount(
period.price_kopeks, period.price_kopeks,
hasExistingDiscount, period.original_price_kopeks,
); );
const displayDiscount = hasExistingDiscount const displayDiscount = promoPeriod.percent;
? period.discount_percent const displayOriginal = promoPeriod.original;
: promoPeriod.percent;
const displayOriginal = hasExistingDiscount
? period.original_price_kopeks
: promoPeriod.original;
const displayPrice = promoPeriod.price; const displayPrice = promoPeriod.price;
const displayPerMonth = hasExistingDiscount const displayPerMonth =
? period.price_per_month_kopeks displayPrice !== period.price_kopeks
: Math.round(promoPeriod.price / (period.days / 30)); ? Math.round(displayPrice / Math.max(1, period.days / 30))
: period.price_per_month_kopeks;
return ( return (
<button <button
@@ -2737,7 +2738,7 @@ export default function Subscription() {
{displayDiscount && displayDiscount > 0 && ( {displayDiscount && displayDiscount > 0 && (
<div <div
className={`absolute -right-2 -top-2 rounded-full px-2 py-0.5 text-xs font-medium text-white ${ className={`absolute -right-2 -top-2 rounded-full px-2 py-0.5 text-xs font-medium text-white ${
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500' promoPeriod.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
}`} }`}
> >
-{displayDiscount}% -{displayDiscount}%
@@ -2821,14 +2822,15 @@ export default function Subscription() {
{(() => { {(() => {
const basePrice = const basePrice =
customDays * (selectedTariff.price_per_day_kopeks ?? 0); customDays * (selectedTariff.price_per_day_kopeks ?? 0);
const hasExistingDiscount = !!( const existingOriginal =
selectedTariff.original_price_per_day_kopeks && selectedTariff.original_price_per_day_kopeks &&
selectedTariff.original_price_per_day_kopeks > selectedTariff.original_price_per_day_kopeks >
(selectedTariff.price_per_day_kopeks ?? 0) (selectedTariff.price_per_day_kopeks ?? 0)
); ? customDays * selectedTariff.original_price_per_day_kopeks
: undefined;
const promoCustom = applyPromoDiscount( const promoCustom = applyPromoDiscount(
basePrice, basePrice,
hasExistingDiscount, existingOriginal,
); );
return ( return (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
@@ -2841,16 +2843,23 @@ export default function Subscription() {
<span className="font-medium text-accent-400"> <span className="font-medium text-accent-400">
{formatPrice(promoCustom.price)} {formatPrice(promoCustom.price)}
</span> </span>
{promoCustom.original && ( {promoCustom.original &&
<> promoCustom.original > promoCustom.price && (
<span className="text-xs text-dark-500 line-through"> <>
{formatPrice(promoCustom.original)} <span className="text-xs text-dark-500 line-through">
</span> {formatPrice(promoCustom.original)}
<span className="rounded bg-orange-500/20 px-1.5 py-0.5 text-xs text-orange-400"> </span>
-{promoCustom.percent}% <span
</span> className={`rounded px-1.5 py-0.5 text-xs ${
</> promoCustom.isPromoGroup
)} ? 'bg-success-500/20 text-success-400'
: 'bg-orange-500/20 text-orange-400'
}`}
>
-{promoCustom.percent}%
</span>
</>
)}
</div> </div>
</div> </div>
); );
@@ -2956,14 +2965,20 @@ export default function Subscription() {
const basePeriodPrice = useCustomDays const basePeriodPrice = useCustomDays
? customDays * (selectedTariff.price_per_day_kopeks ?? 0) ? customDays * (selectedTariff.price_per_day_kopeks ?? 0)
: selectedTariffPeriod?.price_kopeks || 0; : selectedTariffPeriod?.price_kopeks || 0;
const hasExistingPeriodDiscount = const existingPeriodOriginal = useCustomDays
!useCustomDays && selectedTariffPeriod?.original_price_kopeks ? selectedTariff.original_price_per_day_kopeks &&
? selectedTariffPeriod.original_price_kopeks > selectedTariff.original_price_per_day_kopeks >
selectedTariffPeriod.price_kopeks (selectedTariff.price_per_day_kopeks ?? 0)
: false; ? customDays * selectedTariff.original_price_per_day_kopeks
: undefined
: selectedTariffPeriod?.original_price_kopeks &&
selectedTariffPeriod.original_price_kopeks >
selectedTariffPeriod.price_kopeks
? selectedTariffPeriod.original_price_kopeks
: undefined;
const promoPeriod = applyPromoDiscount( const promoPeriod = applyPromoDiscount(
basePeriodPrice, basePeriodPrice,
hasExistingPeriodDiscount, existingPeriodOriginal,
); );
const trafficPrice = const trafficPrice =
@@ -2988,11 +3003,12 @@ export default function Subscription() {
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span> <span>{formatPrice(promoPeriod.price)}</span>
{promoPeriod.original && ( {promoPeriod.original &&
<span className="text-xs text-dark-500 line-through"> promoPeriod.original > promoPeriod.price && (
{formatPrice(promoPeriod.original)} <span className="text-xs text-dark-500 line-through">
</span> {formatPrice(promoPeriod.original)}
)} </span>
)}
</div> </div>
</div> </div>
) : ( ) : (
@@ -3035,16 +3051,12 @@ export default function Subscription() {
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span> <span>{formatPrice(promoPeriod.price)}</span>
{(hasExistingPeriodDiscount || {promoPeriod.original &&
promoPeriod.original) && ( promoPeriod.original > promoPeriod.price && (
<span className="text-xs text-dark-500 line-through"> <span className="text-xs text-dark-500 line-through">
{formatPrice( {formatPrice(promoPeriod.original)}
hasExistingPeriodDiscount </span>
? selectedTariffPeriod.original_price_kopeks! )}
: promoPeriod.original!,
)}
</span>
)}
</div> </div>
</div> </div>
)} )}
@@ -3177,19 +3189,10 @@ export default function Subscription() {
{currentStep === 'period' && classicOptions && ( {currentStep === 'period' && classicOptions && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{classicOptions.periods.map((period) => { {classicOptions.periods.map((period) => {
const hasExistingDiscount = !!(
period.discount_percent && period.discount_percent > 0
);
const promoPeriod = applyPromoDiscount( const promoPeriod = applyPromoDiscount(
period.price_kopeks, 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 ( return (
<button <button
@@ -3219,13 +3222,13 @@ export default function Subscription() {
: '' : ''
}`} }`}
> >
{displayDiscount && displayDiscount > 0 && ( {promoPeriod.percent && promoPeriod.percent > 0 && (
<div <div
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${ className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500' promoPeriod.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
}`} }`}
> >
-{displayDiscount}% -{promoPeriod.percent}%
</div> </div>
)} )}
<div className="text-lg font-semibold text-dark-100">{period.label}</div> <div className="text-lg font-semibold text-dark-100">{period.label}</div>
@@ -3233,9 +3236,9 @@ export default function Subscription() {
<span className="font-medium text-accent-400"> <span className="font-medium text-accent-400">
{formatPrice(promoPeriod.price)} {formatPrice(promoPeriod.price)}
</span> </span>
{displayOriginal && displayOriginal > promoPeriod.price && ( {promoPeriod.original && promoPeriod.original > promoPeriod.price && (
<span className="text-sm text-dark-500 line-through"> <span className="text-sm text-dark-500 line-through">
{formatPrice(displayOriginal)} {formatPrice(promoPeriod.original)}
</span> </span>
)} )}
</div> </div>
@@ -3249,12 +3252,9 @@ export default function Subscription() {
{currentStep === 'traffic' && selectedPeriod?.traffic.options && ( {currentStep === 'traffic' && selectedPeriod?.traffic.options && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{selectedPeriod.traffic.options.map((option) => { {selectedPeriod.traffic.options.map((option) => {
const hasExistingDiscount = !!(
option.discount_percent && option.discount_percent > 0
);
const promoTraffic = applyPromoDiscount( const promoTraffic = applyPromoDiscount(
option.price_kopeks, option.price_kopeks,
hasExistingDiscount, option.original_price_kopeks,
); );
return ( return (
@@ -3268,41 +3268,24 @@ export default function Subscription() {
: '' : ''
} ${!option.is_available ? 'cursor-not-allowed opacity-50' : ''}`} } ${!option.is_available ? 'cursor-not-allowed opacity-50' : ''}`}
> >
{(() => { {promoTraffic.percent && promoTraffic.percent > 0 && (
const trafficDisplayDiscount = hasExistingDiscount <div
? option.discount_percent className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
: promoTraffic.percent; promoTraffic.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
const trafficDisplayOriginal = hasExistingDiscount }`}
? option.original_price_kopeks >
: promoTraffic.original; -{promoTraffic.percent}%
return ( </div>
<> )}
{trafficDisplayDiscount && trafficDisplayDiscount > 0 && ( <div className="text-lg font-semibold text-dark-100">{option.label}</div>
<div <div className="mt-1 flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${ <span className="text-accent-400">{formatPrice(promoTraffic.price)}</span>
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500' {promoTraffic.original && promoTraffic.original > promoTraffic.price && (
}`} <span className="text-xs text-dark-500 line-through">
> {formatPrice(promoTraffic.original)}
-{trafficDisplayDiscount}% </span>
</div> )}
)} </div>
<div className="text-lg font-semibold text-dark-100">
{option.label}
</div>
<div className="mt-1 flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<span className="text-accent-400">
{formatPrice(promoTraffic.price)}
</span>
{trafficDisplayOriginal &&
trafficDisplayOriginal > promoTraffic.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(trafficDisplayOriginal)}
</span>
)}
</div>
</>
);
})()}
</button> </button>
); );
})} })}
@@ -3322,12 +3305,9 @@ export default function Subscription() {
return true; return true;
}) })
.map((server) => { .map((server) => {
const hasExistingDiscount = !!(
server.discount_percent && server.discount_percent > 0
);
const promoServer = applyPromoDiscount( const promoServer = applyPromoDiscount(
server.price_kopeks, server.price_kopeks,
hasExistingDiscount, server.original_price_kopeks,
); );
return ( return (
@@ -3343,20 +3323,15 @@ export default function Subscription() {
: 'cursor-not-allowed border-dark-800/30 bg-dark-900/30 opacity-50' : 'cursor-not-allowed border-dark-800/30 bg-dark-900/30 opacity-50'
}`} }`}
> >
{(() => { {promoServer.percent && promoServer.percent > 0 ? (
const serverDisplayDiscount = hasExistingDiscount <div
? server.discount_percent className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
: promoServer.percent; promoServer.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
return serverDisplayDiscount && serverDisplayDiscount > 0 ? ( }`}
<div >
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${ -{promoServer.percent}%
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500' </div>
}`} ) : null}
>
-{serverDisplayDiscount}%
</div>
) : null;
})()}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border-2 ${ className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border-2 ${
@@ -3374,16 +3349,12 @@ export default function Subscription() {
{formatPrice(promoServer.price)} {formatPrice(promoServer.price)}
{t('subscription.perMonth')} {t('subscription.perMonth')}
</span> </span>
{(() => { {promoServer.original &&
const serverOriginal = hasExistingDiscount promoServer.original > promoServer.price ? (
? server.original_price_kopeks <span className="text-xs text-dark-500 line-through">
: promoServer.original; {formatPrice(promoServer.original)}
return serverOriginal && serverOriginal > promoServer.price ? ( </span>
<span className="text-xs text-dark-500 line-through"> ) : null}
{formatPrice(serverOriginal)}
</span>
) : null;
})()}
</div> </div>
</div> </div>
</div> </div>
@@ -3448,28 +3419,26 @@ export default function Subscription() {
) : preview ? ( ) : preview ? (
<div className="space-y-4 rounded-xl bg-dark-800/50 p-5"> <div className="space-y-4 rounded-xl bg-dark-800/50 p-5">
{/* Active promo discount banner */} {/* Active promo discount banner */}
{activeDiscount?.is_active && {activeDiscount?.is_active && activeDiscount.discount_percent && (
activeDiscount.discount_percent && <div className="flex items-center justify-center gap-2 rounded-lg border border-orange-500/30 bg-orange-500/10 p-3">
!preview.original_price_kopeks && ( <svg
<div className="flex items-center justify-center gap-2 rounded-lg border border-orange-500/30 bg-orange-500/10 p-3"> className="h-4 w-4 text-orange-400"
<svg fill="none"
className="h-4 w-4 text-orange-400" viewBox="0 0 24 24"
fill="none" stroke="currentColor"
viewBox="0 0 24 24" strokeWidth={2}
stroke="currentColor" >
strokeWidth={2} <path
> strokeLinecap="round"
<path strokeLinejoin="round"
strokeLinecap="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
strokeLinejoin="round" />
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" </svg>
/> <span className="text-sm font-medium text-orange-400">
</svg> {t('promo.discountApplied')} -{activeDiscount.discount_percent}%
<span className="text-sm font-medium text-orange-400"> </span>
{t('promo.discountApplied')} -{activeDiscount.discount_percent}% </div>
</span> )}
</div>
)}
{preview.breakdown.map((item, idx) => ( {preview.breakdown.map((item, idx) => (
<div key={idx} className="flex justify-between text-dark-300"> <div key={idx} className="flex justify-between text-dark-300">
@@ -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( const promoTotal = applyPromoDiscount(
preview.total_price_kopeks, preview.total_price_kopeks,
hasServerDiscount, preview.original_price_kopeks,
); );
const displayTotal = promoTotal.price;
const displayOriginal = hasServerDiscount
? preview.original_price_kopeks
: promoTotal.original;
return ( return (
<div className="flex items-center justify-between border-t border-dark-700/50 pt-4"> <div className="flex items-center justify-between border-t border-dark-700/50 pt-4">
@@ -3497,11 +3460,11 @@ export default function Subscription() {
</span> </span>
<div className="text-right"> <div className="text-right">
<div className="text-2xl font-bold text-accent-400"> <div className="text-2xl font-bold text-accent-400">
{formatPrice(displayTotal)} {formatPrice(promoTotal.price)}
</div> </div>
{displayOriginal && ( {promoTotal.original && promoTotal.original > promoTotal.price && (
<div className="text-sm text-dark-500 line-through"> <div className="text-sm text-dark-500 line-through">
{formatPrice(displayOriginal)} {formatPrice(promoTotal.original)}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
@@ -151,7 +151,7 @@ export default function Support() {
log.debug('Component loaded'); log.debug('Component loaded');
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useAuthStore(); const isAdmin = useAuthStore((state) => state.isAdmin);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { openTelegramLink, openLink } = usePlatform(); const { openTelegramLink, openLink } = usePlatform();
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null); const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null);
@@ -166,6 +166,34 @@ export default function Support() {
const [replyAttachment, setReplyAttachment] = useState<MediaAttachment | null>(null); const [replyAttachment, setReplyAttachment] = useState<MediaAttachment | null>(null);
const createFileInputRef = useRef<HTMLInputElement>(null); const createFileInputRef = useRef<HTMLInputElement>(null);
const replyFileInputRef = useRef<HTMLInputElement>(null); const replyFileInputRef = useRef<HTMLInputElement>(null);
const createPreviewRef = useRef<string | null>(null);
const replyPreviewRef = useRef<string | null>(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 // Get support configuration
const { data: supportConfig, isLoading: configLoading } = useQuery({ const { data: supportConfig, isLoading: configLoading } = useQuery({
@@ -213,8 +241,11 @@ export default function Support() {
return; 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); const preview = URL.createObjectURL(file);
previewRef.current = preview;
setAttachment({ file, preview, uploading: true }); setAttachment({ file, preview, uploading: true });
try { try {
@@ -250,7 +281,7 @@ export default function Support() {
setShowCreateForm(false); setShowCreateForm(false);
setNewTitle(''); setNewTitle('');
setNewMessage(''); setNewMessage('');
setCreateAttachment(null); clearCreateAttachment();
setSelectedTicket(ticket); setSelectedTicket(ticket);
}, },
}); });
@@ -268,7 +299,7 @@ export default function Support() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] }); queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] });
setReplyMessage(''); setReplyMessage('');
setReplyAttachment(null); clearReplyAttachment();
}, },
}); });
@@ -444,7 +475,7 @@ export default function Support() {
onClick={() => { onClick={() => {
setShowCreateForm(true); setShowCreateForm(true);
setSelectedTicket(null); setSelectedTicket(null);
setCreateAttachment(null); clearCreateAttachment();
}} }}
> >
<PlusIcon /> <PlusIcon />
@@ -469,7 +500,7 @@ export default function Support() {
onClick={() => { onClick={() => {
setSelectedTicket(ticket as unknown as TicketDetail); setSelectedTicket(ticket as unknown as TicketDetail);
setShowCreateForm(false); setShowCreateForm(false);
setReplyAttachment(null); clearReplyAttachment();
}} }}
className={`w-full rounded-bento border p-4 text-left transition-all ${ className={`w-full rounded-bento border p-4 text-left transition-all ${
selectedTicket?.id === ticket.id selectedTicket?.id === ticket.id
@@ -574,7 +605,7 @@ export default function Support() {
{createAttachment ? ( {createAttachment ? (
<AttachmentPreview <AttachmentPreview
attachment={createAttachment} attachment={createAttachment}
onRemove={() => setCreateAttachment(null)} onRemove={() => clearCreateAttachment()}
/> />
) : ( ) : (
<button <button
@@ -608,7 +639,7 @@ export default function Support() {
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setShowCreateForm(false); setShowCreateForm(false);
setCreateAttachment(null); clearCreateAttachment();
}} }}
> >
{t('common.cancel')} {t('common.cancel')}
@@ -715,7 +746,7 @@ export default function Support() {
{replyAttachment ? ( {replyAttachment ? (
<AttachmentPreview <AttachmentPreview
attachment={replyAttachment} attachment={replyAttachment}
onRemove={() => setReplyAttachment(null)} onRemove={() => clearReplyAttachment()}
/> />
) : ( ) : (
<button <button

View File

@@ -8,7 +8,8 @@ export default function TelegramCallback() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [error, setError] = useState(''); const [error, setError] = useState('');
const { loginWithTelegramWidget, isAuthenticated } = useAuthStore(); const loginWithTelegramWidget = useAuthStore((state) => state.loginWithTelegramWidget);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {

View File

@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { useShallow } from 'zustand/shallow';
import { brandingApi } from '../api/branding'; import { brandingApi } from '../api/branding';
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
@@ -33,7 +34,17 @@ export default function TelegramRedirect() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore(); const {
loginWithTelegram,
isAuthenticated,
isLoading: authLoading,
} = useAuthStore(
useShallow((state) => ({
loginWithTelegram: state.loginWithTelegram,
isAuthenticated: state.isAuthenticated,
isLoading: state.isLoading,
})),
);
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading'); const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading');
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [retryCount, setRetryCount] = useState(() => { const [retryCount, setRetryCount] = useState(() => {

View File

@@ -3,6 +3,7 @@ import { useSearchParams, Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { useShallow } from 'zustand/shallow';
import { consumeCampaignSlug } from '../utils/campaign'; import { consumeCampaignSlug } from '../utils/campaign';
import { tokenStorage } from '../utils/token'; import { tokenStorage } from '../utils/token';
import LanguageSwitcher from '../components/LanguageSwitcher'; import LanguageSwitcher from '../components/LanguageSwitcher';
@@ -13,7 +14,13 @@ export default function VerifyEmail() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [error, setError] = useState(''); const [error, setError] = useState('');
const { setTokens, setUser, checkAdminStatus } = useAuthStore(); const { setTokens, setUser, checkAdminStatus } = useAuthStore(
useShallow((state) => ({
setTokens: state.setTokens,
setUser: state.setUser,
checkAdminStatus: state.checkAdminStatus,
})),
);
const hasVerified = useRef(false); const hasVerified = useRef(false);
useEffect(() => { useEffect(() => {

View File

@@ -9,7 +9,8 @@ export type { WSMessage } from './WebSocketContext';
const isDev = import.meta.env.DEV; const isDev = import.meta.env.DEV;
export function WebSocketProvider({ children }: { children: React.ReactNode }) { export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const { accessToken, isAuthenticated } = useAuthStore(); const accessToken = useAuthStore((state) => state.accessToken);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

View File

@@ -192,7 +192,7 @@
position: relative; position: relative;
} }
/* Global Noise Texture */ /* Global Noise Texture — no mix-blend-mode to avoid fullscreen GPU compositing */
body::before { body::before {
content: ''; content: '';
position: fixed; position: fixed;
@@ -201,7 +201,13 @@
pointer-events: none; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
opacity: 0.03; opacity: 0.03;
mix-blend-mode: overlay; }
/* Disable noise on mobile to save GPU */
@media (max-width: 1023px) {
body::before {
display: none;
}
} }
/* Optimize main scrollable content */ /* Optimize main scrollable content */
@@ -440,9 +446,7 @@ img.twemoji {
/* Glass effect - Dark (optimized for mobile) */ /* Glass effect - Dark (optimized for mobile) */
.glass { .glass {
@apply border border-dark-700/30 bg-dark-900/80; @apply border border-dark-700/30 bg-dark-900/80;
/* GPU acceleration */
transform: translateZ(0); transform: translateZ(0);
will-change: transform;
} }
/* Enable backdrop-blur only on desktop where it's performant */ /* Enable backdrop-blur only on desktop where it's performant */
@@ -1431,13 +1435,13 @@ input[type='checkbox']:hover:not(:checked) {
animation: wheel-glow 2s ease-in-out infinite; animation: wheel-glow 2s ease-in-out infinite;
} }
/* Toast progress bar animation */ /* Toast progress bar animation — use scaleX instead of width to avoid layout thrashing */
@keyframes shrink { @keyframes shrink {
from { from {
width: 100%; transform: scaleX(1);
} }
to { to {
width: 0%; transform: scaleX(0);
} }
} }
@@ -1454,10 +1458,8 @@ input[type='checkbox']:hover:not(:checked) {
.wave-blob { .wave-blob {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
filter: blur(60px); /* Reduced from 80px for better performance */ filter: blur(60px);
opacity: 0.5; opacity: 0.5;
/* GPU acceleration */
will-change: transform;
transform: translateZ(0); transform: translateZ(0);
backface-visibility: hidden; backface-visibility: hidden;
} }
@@ -1644,22 +1646,18 @@ input[type='checkbox']:hover:not(:checked) {
@keyframes backdropFadeIn { @keyframes backdropFadeIn {
from { from {
opacity: 0; opacity: 0;
backdrop-filter: blur(0);
} }
to { to {
opacity: 1; opacity: 1;
backdrop-filter: blur(8px);
} }
} }
@keyframes backdropFadeOut { @keyframes backdropFadeOut {
from { from {
opacity: 1; opacity: 1;
backdrop-filter: blur(8px);
} }
to { to {
opacity: 0; opacity: 0;
backdrop-filter: blur(0);
} }
} }

View File

@@ -158,24 +158,73 @@ export const tokenStorage = {
}, },
}; };
/**
* Extract Telegram user ID from raw initData string.
* Does NOT validate cryptographic signature — used only for client-side identity comparison.
*/
function extractTelegramUserId(initData: string): string | null {
try {
const params = new URLSearchParams(initData);
const userJson = params.get('user');
if (!userJson) return null;
const user = JSON.parse(userJson);
return user.id != null ? String(user.id) : null;
} catch {
return null;
}
}
const TG_USER_ID_KEY = 'tg_user_id';
/**
* Detect Telegram account switch and clear stale auth data.
*
* Telegram Mini App WebView shares localStorage across accounts on the same device
* (confirmed bug on Desktop, likely on mobile too). This means that when user A logs in,
* then user B opens the same Mini App, user A's refresh_token persists in localStorage.
*
* The old approach stored initData in sessionStorage for comparison, but sessionStorage
* is cleared on tab close — so the comparison never triggers between separate openings.
*
* Fix: store the Telegram user ID in localStorage (survives tab close) and compare it
* with the fresh initData on every app launch.
*/
export function clearStaleSessionIfNeeded(freshInitData: string | null): void { export function clearStaleSessionIfNeeded(freshInitData: string | null): void {
if (!freshInitData) return; if (!freshInitData) return;
try { try {
const stored = sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT); const currentTgUserId = extractTelegramUserId(freshInitData);
if (stored && stored !== freshInitData) { // PRIMARY CHECK: compare Telegram user ID stored in localStorage (survives tab close)
// New Telegram session (different user) — clear all auth tokens const storedTgUserId = localStorage.getItem(TG_USER_ID_KEY);
if (storedTgUserId && currentTgUserId && storedTgUserId !== currentTgUserId) {
// Account switch detected — purge all auth data
sessionStorage.removeItem(TOKEN_KEYS.ACCESS); sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
sessionStorage.removeItem(TOKEN_KEYS.REFRESH); sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
sessionStorage.removeItem(TOKEN_KEYS.USER); sessionStorage.removeItem(TOKEN_KEYS.USER);
localStorage.removeItem(TOKEN_KEYS.REFRESH); localStorage.removeItem(TOKEN_KEYS.REFRESH);
localStorage.removeItem('cabinet-auth');
}
// SECONDARY CHECK: same-session switch (initData changed without tab close)
const storedInitData = sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT);
if (storedInitData && storedInitData !== freshInitData) {
sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
sessionStorage.removeItem(TOKEN_KEYS.USER);
localStorage.removeItem(TOKEN_KEYS.REFRESH);
localStorage.removeItem('cabinet-auth');
}
// Persist current Telegram user ID for future comparisons
if (currentTgUserId) {
localStorage.setItem(TG_USER_ID_KEY, currentTgUserId);
} }
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData); sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData);
localStorage.removeItem(TOKEN_KEYS.TELEGRAM_INIT); localStorage.removeItem(TOKEN_KEYS.TELEGRAM_INIT);
} catch { } catch {
// Storage недоступен // Storage not available
} }
} }