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'));
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 <MaintenanceScreen />;

View File

@@ -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() {
<TooltipProvider>
<ToastProvider>
<WebSocketProvider>
<Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}>
<Twemoji options={TWEMOJI_OPTIONS}>
<App />
</Twemoji>
</WebSocketProvider>

View File

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

View File

@@ -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();

View File

@@ -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<HTMLDivElement>(null);

View File

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

View File

@@ -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) => {

View File

@@ -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 (
<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() {
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<string | null>(null);

View File

@@ -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 (
<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 { 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<string | null>(null);

View File

@@ -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();

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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('');

View File

@@ -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,

View File

@@ -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'],

View File

@@ -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.",

View File

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

View File

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

View File

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

View File

@@ -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]}`;
};

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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -135,6 +135,14 @@ export default function AdminBroadcastCreate() {
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
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
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);

View File

@@ -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 || [];

View File

@@ -86,6 +86,14 @@ export default function AdminPinnedMessageCreate() {
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
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
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);

View File

@@ -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) {
<StatCard
label={t('admin.remnawave.overview.memory', 'Memory')}
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>}
color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'}
/>

View File

@@ -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];
};

View File

@@ -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) => {

View File

@@ -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();

View File

@@ -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();

View File

@@ -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<string | null>(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(() => {

View File

@@ -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

View File

@@ -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() || '';

View File

@@ -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) {

View File

@@ -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<string | null>(null);
const [resendCooldown, setResendCooldown] = useState(0);
const [verificationResendCooldown, setVerificationResendCooldown] = useState(0);
const newEmailInputRef = useRef<HTMLInputElement>(null);
const codeInputRef = useRef<HTMLInputElement>(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() {
<Button
onClick={() => resendVerificationMutation.mutate()}
loading={resendVerificationMutation.isPending}
disabled={verificationResendCooldown > 0}
>
{t('profile.resendVerification')}
{verificationResendCooldown > 0
? t('profile.resendIn', { seconds: verificationResendCooldown })
: t('profile.resendVerification')}
</Button>
{(user.auth_type === 'telegram' || user.auth_type === 'email') && (
<button
onClick={() => setChangeEmailStep('email')}
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('profile.changeEmail.button')}
</button>
)}
<button
onClick={() => setChangeEmailStep('email')}
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('profile.changeEmail.button')}
</button>
</div>
</div>
)}
{user.email_verified &&
(user.auth_type === 'telegram' || user.auth_type === 'email') && (
<div className="flex items-center justify-between">
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
<button
onClick={() => setChangeEmailStep('email')}
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
>
<PencilIcon />
<span>{t('profile.changeEmail.button')}</span>
</button>
</div>
)}
{user.email_verified && (
<div className="flex items-center justify-between">
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
<button
onClick={() => setChangeEmailStep('email')}
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
>
<PencilIcon />
<span>{t('profile.changeEmail.button')}</span>
</button>
</div>
)}
{/* Inline email change flow */}
<AnimatePresence>

View File

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

View File

@@ -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() {
<div>
<div className="text-lg font-semibold text-dark-100">{tariff.name}</div>
{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>
{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 (
<span className="flex items-center gap-2">
<span className="font-medium text-accent-400">
{formatPrice(promoDaily.price)}
</span>
{/* Show original price from promo group or promo offer */}
{(hasExistingDailyDiscount || promoDaily.original) && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(
hasExistingDailyDiscount
? originalDailyPrice
: promoDaily.original!,
)}
</span>
)}
{promoDaily.original &&
promoDaily.original > promoDaily.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoDaily.original)}
</span>
)}
<span>{t('subscription.tariff.perDay')}</span>
{/* Show discount badge */}
{tariff.daily_discount_percent &&
tariff.daily_discount_percent > 0 ? (
<span className="rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400">
-{tariff.daily_discount_percent}%
{promoDaily.percent && promoDaily.percent > 0 && (
<span
className={`rounded px-1.5 py-0.5 text-xs ${
promoDaily.isPromoGroup
? 'bg-success-500/20 text-success-400'
: 'bg-orange-500/20 text-orange-400'
}`}
>
-{promoDaily.percent}%
</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>
);
@@ -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 (
<span className="flex flex-wrap items-center gap-2">
@@ -2473,27 +2486,22 @@ export default function Subscription() {
<span className="font-medium text-accent-400">
{formatPrice(promoPeriod.price)}
</span>
{/* Show original price from promo group or promo offer */}
{(hasExistingDiscount || promoPeriod.original) && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(
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}%
{promoPeriod.original &&
promoPeriod.original > promoPeriod.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</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>
);
@@ -2702,24 +2710,17 @@ export default function Subscription() {
{selectedTariff.periods.length > 0 && !useCustomDays && (
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{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 (
<button
@@ -2737,7 +2738,7 @@ export default function Subscription() {
{displayDiscount && displayDiscount > 0 && (
<div
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}%
@@ -2821,14 +2822,15 @@ export default function Subscription() {
{(() => {
const basePrice =
customDays * (selectedTariff.price_per_day_kopeks ?? 0);
const hasExistingDiscount = !!(
const existingOriginal =
selectedTariff.original_price_per_day_kopeks &&
selectedTariff.original_price_per_day_kopeks >
(selectedTariff.price_per_day_kopeks ?? 0)
);
? customDays * selectedTariff.original_price_per_day_kopeks
: undefined;
const promoCustom = applyPromoDiscount(
basePrice,
hasExistingDiscount,
existingOriginal,
);
return (
<div className="flex justify-between text-sm">
@@ -2841,16 +2843,23 @@ export default function Subscription() {
<span className="font-medium text-accent-400">
{formatPrice(promoCustom.price)}
</span>
{promoCustom.original && (
<>
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoCustom.original)}
</span>
<span className="rounded bg-orange-500/20 px-1.5 py-0.5 text-xs text-orange-400">
-{promoCustom.percent}%
</span>
</>
)}
{promoCustom.original &&
promoCustom.original > promoCustom.price && (
<>
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoCustom.original)}
</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>
);
@@ -2956,14 +2965,20 @@ export default function Subscription() {
const basePeriodPrice = useCustomDays
? customDays * (selectedTariff.price_per_day_kopeks ?? 0)
: selectedTariffPeriod?.price_kopeks || 0;
const hasExistingPeriodDiscount =
!useCustomDays && selectedTariffPeriod?.original_price_kopeks
? selectedTariffPeriod.original_price_kopeks >
selectedTariffPeriod.price_kopeks
: false;
const existingPeriodOriginal = useCustomDays
? selectedTariff.original_price_per_day_kopeks &&
selectedTariff.original_price_per_day_kopeks >
(selectedTariff.price_per_day_kopeks ?? 0)
? 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(
basePeriodPrice,
hasExistingPeriodDiscount,
existingPeriodOriginal,
);
const trafficPrice =
@@ -2988,11 +3003,12 @@ export default function Subscription() {
</span>
<div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span>
{promoPeriod.original && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</span>
)}
{promoPeriod.original &&
promoPeriod.original > promoPeriod.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</span>
)}
</div>
</div>
) : (
@@ -3035,16 +3051,12 @@ export default function Subscription() {
</span>
<div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span>
{(hasExistingPeriodDiscount ||
promoPeriod.original) && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(
hasExistingPeriodDiscount
? selectedTariffPeriod.original_price_kopeks!
: promoPeriod.original!,
)}
</span>
)}
{promoPeriod.original &&
promoPeriod.original > promoPeriod.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</span>
)}
</div>
</div>
)}
@@ -3177,19 +3189,10 @@ export default function Subscription() {
{currentStep === 'period' && classicOptions && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{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 (
<button
@@ -3219,13 +3222,13 @@ export default function Subscription() {
: ''
}`}
>
{displayDiscount && displayDiscount > 0 && (
{promoPeriod.percent && promoPeriod.percent > 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 ${
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500'
promoPeriod.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
}`}
>
-{displayDiscount}%
-{promoPeriod.percent}%
</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">
{formatPrice(promoPeriod.price)}
</span>
{displayOriginal && displayOriginal > promoPeriod.price && (
{promoPeriod.original && promoPeriod.original > promoPeriod.price && (
<span className="text-sm text-dark-500 line-through">
{formatPrice(displayOriginal)}
{formatPrice(promoPeriod.original)}
</span>
)}
</div>
@@ -3249,12 +3252,9 @@ export default function Subscription() {
{currentStep === 'traffic' && selectedPeriod?.traffic.options && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{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 && (
<div
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'
}`}
>
-{trafficDisplayDiscount}%
</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>
</>
);
})()}
{promoTraffic.percent && promoTraffic.percent > 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 ${
promoTraffic.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
}`}
>
-{promoTraffic.percent}%
</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>
{promoTraffic.original && promoTraffic.original > promoTraffic.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoTraffic.original)}
</span>
)}
</div>
</button>
);
})}
@@ -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 ? (
<div
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'
}`}
>
-{serverDisplayDiscount}%
</div>
) : null;
})()}
{promoServer.percent && promoServer.percent > 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.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
}`}
>
-{promoServer.percent}%
</div>
) : null}
<div className="flex items-center gap-3">
<div
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)}
{t('subscription.perMonth')}
</span>
{(() => {
const serverOriginal = hasExistingDiscount
? server.original_price_kopeks
: promoServer.original;
return serverOriginal && serverOriginal > promoServer.price ? (
<span className="text-xs text-dark-500 line-through">
{formatPrice(serverOriginal)}
</span>
) : null;
})()}
{promoServer.original &&
promoServer.original > promoServer.price ? (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoServer.original)}
</span>
) : null}
</div>
</div>
</div>
@@ -3448,28 +3419,26 @@ export default function Subscription() {
) : preview ? (
<div className="space-y-4 rounded-xl bg-dark-800/50 p-5">
{/* Active promo discount banner */}
{activeDiscount?.is_active &&
activeDiscount.discount_percent &&
!preview.original_price_kopeks && (
<div className="flex items-center justify-center gap-2 rounded-lg border border-orange-500/30 bg-orange-500/10 p-3">
<svg
className="h-4 w-4 text-orange-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
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">
{t('promo.discountApplied')} -{activeDiscount.discount_percent}%
</span>
</div>
)}
{activeDiscount?.is_active && 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">
<svg
className="h-4 w-4 text-orange-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
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">
{t('promo.discountApplied')} -{activeDiscount.discount_percent}%
</span>
</div>
)}
{preview.breakdown.map((item, idx) => (
<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(
preview.total_price_kopeks,
hasServerDiscount,
preview.original_price_kopeks,
);
const displayTotal = promoTotal.price;
const displayOriginal = hasServerDiscount
? preview.original_price_kopeks
: promoTotal.original;
return (
<div className="flex items-center justify-between border-t border-dark-700/50 pt-4">
@@ -3497,11 +3460,11 @@ export default function Subscription() {
</span>
<div className="text-right">
<div className="text-2xl font-bold text-accent-400">
{formatPrice(displayTotal)}
{formatPrice(promoTotal.price)}
</div>
{displayOriginal && (
{promoTotal.original && promoTotal.original > promoTotal.price && (
<div className="text-sm text-dark-500 line-through">
{formatPrice(displayOriginal)}
{formatPrice(promoTotal.original)}
</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 { 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<TicketDetail | null>(null);
@@ -166,6 +166,34 @@ export default function Support() {
const [replyAttachment, setReplyAttachment] = useState<MediaAttachment | null>(null);
const createFileInputRef = 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
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();
}}
>
<PlusIcon />
@@ -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 ? (
<AttachmentPreview
attachment={createAttachment}
onRemove={() => setCreateAttachment(null)}
onRemove={() => clearCreateAttachment()}
/>
) : (
<button
@@ -608,7 +639,7 @@ export default function Support() {
variant="secondary"
onClick={() => {
setShowCreateForm(false);
setCreateAttachment(null);
clearCreateAttachment();
}}
>
{t('common.cancel')}
@@ -715,7 +746,7 @@ export default function Support() {
{replyAttachment ? (
<AttachmentPreview
attachment={replyAttachment}
onRemove={() => setReplyAttachment(null)}
onRemove={() => clearReplyAttachment()}
/>
) : (
<button

View File

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

View File

@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } 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 { brandingApi } from '../api/branding';
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
@@ -33,7 +34,17 @@ export default function TelegramRedirect() {
const { t } = useTranslation();
const navigate = useNavigate();
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 [errorMessage, setErrorMessage] = useState('');
const [retryCount, setRetryCount] = useState(() => {

View File

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

View File

@@ -9,7 +9,8 @@ export type { WSMessage } from './WebSocketContext';
const isDev = import.meta.env.DEV;
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 reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

View File

@@ -192,7 +192,7 @@
position: relative;
}
/* Global Noise Texture */
/* Global Noise Texture — no mix-blend-mode to avoid fullscreen GPU compositing */
body::before {
content: '';
position: fixed;
@@ -201,7 +201,13 @@
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");
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 */
@@ -440,9 +446,7 @@ img.twemoji {
/* Glass effect - Dark (optimized for mobile) */
.glass {
@apply border border-dark-700/30 bg-dark-900/80;
/* GPU acceleration */
transform: translateZ(0);
will-change: transform;
}
/* 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;
}
/* Toast progress bar animation */
/* Toast progress bar animation — use scaleX instead of width to avoid layout thrashing */
@keyframes shrink {
from {
width: 100%;
transform: scaleX(1);
}
to {
width: 0%;
transform: scaleX(0);
}
}
@@ -1454,10 +1458,8 @@ input[type='checkbox']:hover:not(:checked) {
.wave-blob {
position: absolute;
border-radius: 50%;
filter: blur(60px); /* Reduced from 80px for better performance */
filter: blur(60px);
opacity: 0.5;
/* GPU acceleration */
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
@@ -1644,22 +1646,18 @@ input[type='checkbox']:hover:not(:checked) {
@keyframes backdropFadeIn {
from {
opacity: 0;
backdrop-filter: blur(0);
}
to {
opacity: 1;
backdrop-filter: blur(8px);
}
}
@keyframes backdropFadeOut {
from {
opacity: 1;
backdrop-filter: blur(8px);
}
to {
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 {
if (!freshInitData) return;
try {
const stored = sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT);
const currentTgUserId = extractTelegramUserId(freshInitData);
if (stored && stored !== freshInitData) {
// New Telegram session (different user) — clear all auth tokens
// PRIMARY CHECK: compare Telegram user ID stored in localStorage (survives tab close)
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.REFRESH);
sessionStorage.removeItem(TOKEN_KEYS.USER);
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);
localStorage.removeItem(TOKEN_KEYS.TELEGRAM_INIT);
} catch {
// Storage недоступен
// Storage not available
}
}