mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -2402,6 +2402,7 @@
|
||||
"notVerified": "تایید نشده",
|
||||
"verificationRequired": "لطفاً ایمیل خود را تایید کنید تا از ورود با ایمیل استفاده کنید.",
|
||||
"resendVerification": "ارسال مجدد ایمیل تایید",
|
||||
"resendIn": "ارسال مجدد در {{seconds}} ثانیه",
|
||||
"verificationResent": "ایمیل تایید مجدداً ارسال شد!",
|
||||
"canLoginWithEmail": "اکنون میتوانید با ایمیل و رمز عبور وارد شوید.",
|
||||
"emailAlreadyRegistered": "این ایمیل قبلاً به حساب دیگری متصل شده است. اگر این ایمیل شماست، با آن وارد شوید یا با پشتیبانی تماس بگیرید.",
|
||||
|
||||
@@ -3454,6 +3454,7 @@
|
||||
"notVerified": "Не подтверждён",
|
||||
"verificationRequired": "Подтвердите email для использования входа по почте.",
|
||||
"resendVerification": "Отправить письмо повторно",
|
||||
"resendIn": "Повторить через {{seconds}} сек.",
|
||||
"verificationResent": "Письмо отправлено повторно!",
|
||||
"canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.",
|
||||
"emailAlreadyRegistered": "Этот email уже привязан к другому аккаунту. Если это ваш email, войдите через него или обратитесь в поддержку.",
|
||||
|
||||
@@ -2401,6 +2401,7 @@
|
||||
"notVerified": "未验证",
|
||||
"verificationRequired": "请验证邮箱以使用邮箱登录。",
|
||||
"resendVerification": "重新发送验证邮件",
|
||||
"resendIn": "{{seconds}} 秒后重新发送",
|
||||
"verificationResent": "验证邮件已重新发送!",
|
||||
"canLoginWithEmail": "现在您可以使用邮箱和密码登录。",
|
||||
"emailAlreadyRegistered": "此邮箱已绑定到其他账户。如果这是您的邮箱,请使用邮箱登录或联系客服。",
|
||||
|
||||
@@ -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]}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() || '';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user