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'));
|
const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview'));
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore();
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
const isLoading = useAuthStore((state) => state.isLoading);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -107,7 +108,9 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AdminRoute({ children }: { children: React.ReactNode }) {
|
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading, isAdmin } = useAuthStore();
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
const isLoading = useAuthStore((state) => state.isLoading);
|
||||||
|
const isAdmin = useAuthStore((state) => state.isAdmin);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -133,7 +136,7 @@ function LazyPage({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BlockingOverlay() {
|
function BlockingOverlay() {
|
||||||
const { blockingType } = useBlockingStore();
|
const blockingType = useBlockingStore((state) => state.blockingType);
|
||||||
|
|
||||||
if (blockingType === 'maintenance') {
|
if (blockingType === 'maintenance') {
|
||||||
return <MaintenanceScreen />;
|
return <MaintenanceScreen />;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { ToastProvider } from './components/Toast';
|
|||||||
import { TooltipProvider } from './components/primitives/Tooltip';
|
import { TooltipProvider } from './components/primitives/Tooltip';
|
||||||
import { isInTelegramWebApp } from './hooks/useTelegramSDK';
|
import { isInTelegramWebApp } from './hooks/useTelegramSDK';
|
||||||
|
|
||||||
|
const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages Telegram BackButton visibility based on navigation location.
|
* Manages Telegram BackButton visibility based on navigation location.
|
||||||
* Shows back button on non-root routes, hides on root.
|
* Shows back button on non-root routes, hides on root.
|
||||||
@@ -77,7 +79,7 @@ export function AppWithNavigator() {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}>
|
<Twemoji options={TWEMOJI_OPTIONS}>
|
||||||
<App />
|
<App />
|
||||||
</Twemoji>
|
</Twemoji>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export type TrafficParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const MAX_CACHE_ENTRIES = 20;
|
||||||
|
|
||||||
const trafficCache = new Map<string, { data: TrafficUsageResponse; timestamp: number }>();
|
const trafficCache = new Map<string, { data: TrafficUsageResponse; timestamp: number }>();
|
||||||
|
|
||||||
@@ -106,6 +107,16 @@ export const adminTrafficApi = {
|
|||||||
|
|
||||||
trafficCache.set(key, { data, timestamp: Date.now() });
|
trafficCache.set(key, { data, timestamp: Date.now() });
|
||||||
|
|
||||||
|
// Evict oldest entries to prevent unbounded memory growth
|
||||||
|
if (trafficCache.size > MAX_CACHE_ENTRIES) {
|
||||||
|
const iterator = trafficCache.keys();
|
||||||
|
while (trafficCache.size > MAX_CACHE_ENTRIES) {
|
||||||
|
const oldest = iterator.next();
|
||||||
|
if (oldest.done) break;
|
||||||
|
trafficCache.delete(oldest.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ const CloseIcon = () => (
|
|||||||
export default function SuccessNotificationModal() {
|
export default function SuccessNotificationModal() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isOpen, data, hide } = useSuccessNotification();
|
const isOpen = useSuccessNotification((state) => state.isOpen);
|
||||||
|
const data = useSuccessNotification((state) => state.data);
|
||||||
|
const hide = useSuccessNotification((state) => state.hide);
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK();
|
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK();
|
||||||
const haptic = useHaptic();
|
const haptic = useHaptic();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { isAuthenticated } = useAuthStore();
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
@@ -221,9 +221,10 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
|||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-dark-800/50">
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-dark-800/50">
|
||||||
<div
|
<div
|
||||||
className={`h-full ${style.progress} opacity-60`}
|
className={`h-full w-full ${style.progress} opacity-60`}
|
||||||
style={{
|
style={{
|
||||||
animation: `shrink ${toast.duration}ms linear forwards`,
|
animation: `shrink ${toast.duration}ms linear forwards`,
|
||||||
|
transformOrigin: 'left',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ export default function WebSocketNotifications() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { refreshUser } = useAuthStore();
|
const refreshUser = useAuthStore((state) => state.refreshUser);
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
const { show: showSuccessModal } = useSuccessNotification();
|
const showSuccessModal = useSuccessNotification((state) => state.show);
|
||||||
|
|
||||||
const handleMessage = useCallback(
|
const handleMessage = useCallback(
|
||||||
(message: WSMessage) => {
|
(message: WSMessage) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useBlockingStore } from '../../store/blocking';
|
|||||||
|
|
||||||
export default function BlacklistedScreen() {
|
export default function BlacklistedScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { blacklistedInfo } = useBlockingStore();
|
const blacklistedInfo = useBlockingStore((state) => state.blacklistedInfo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
|
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const CHECK_COOLDOWN_SECONDS = 5;
|
|||||||
|
|
||||||
export default function ChannelSubscriptionScreen() {
|
export default function ChannelSubscriptionScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { channelInfo, clearBlocking } = useBlockingStore();
|
const channelInfo = useBlockingStore((state) => state.channelInfo);
|
||||||
|
const clearBlocking = useBlockingStore((state) => state.clearBlocking);
|
||||||
const [isChecking, setIsChecking] = useState(false);
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
const [cooldown, setCooldown] = useState(0);
|
const [cooldown, setCooldown] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useBlockingStore } from '../../store/blocking';
|
|||||||
|
|
||||||
export default function MaintenanceScreen() {
|
export default function MaintenanceScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { maintenanceInfo } = useBlockingStore();
|
const maintenanceInfo = useBlockingStore((state) => state.maintenanceInfo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
|
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { initDataUser } from '@telegram-apps/sdk-react';
|
import { initDataUser } from '@telegram-apps/sdk-react';
|
||||||
|
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { usePlatform } from '@/platform';
|
import { usePlatform } from '@/platform';
|
||||||
import {
|
import {
|
||||||
@@ -77,7 +78,9 @@ export function AppHeader({
|
|||||||
}: AppHeaderProps) {
|
}: AppHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout, isAdmin } = useAuthStore();
|
const { user, logout, isAdmin } = useAuthStore(
|
||||||
|
useShallow((state) => ({ user: state.user, logout: state.logout, isAdmin: state.isAdmin })),
|
||||||
|
);
|
||||||
const { toggleTheme, isDark } = useTheme();
|
const { toggleTheme, isDark } = useTheme();
|
||||||
const { haptic, platform } = usePlatform();
|
const { haptic, platform } = usePlatform();
|
||||||
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null);
|
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -193,7 +193,8 @@ interface AppShellProps {
|
|||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { isAdmin, logout } = useAuthStore();
|
const isAdmin = useAuthStore((state) => state.isAdmin);
|
||||||
|
const logout = useAuthStore((state) => state.logout);
|
||||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
|
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
|
||||||
useTelegramSDK();
|
useTelegramSDK();
|
||||||
const haptic = useHaptic();
|
const haptic = useHaptic();
|
||||||
|
|||||||
@@ -132,12 +132,13 @@ let _webglAvailable: boolean | null = null;
|
|||||||
function isWebglAvailable(): boolean {
|
function isWebglAvailable(): boolean {
|
||||||
if (_webglAvailable === null) {
|
if (_webglAvailable === null) {
|
||||||
try {
|
try {
|
||||||
const renderer = new Renderer({
|
const canvas = document.createElement('canvas');
|
||||||
alpha: true,
|
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||||
antialias: false,
|
_webglAvailable = !!gl;
|
||||||
powerPreference: 'low-power',
|
if (gl) {
|
||||||
});
|
const loseCtx = gl.getExtension('WEBGL_lose_context');
|
||||||
_webglAvailable = !!renderer.gl;
|
if (loseCtx) loseCtx.loseContext();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
_webglAvailable = false;
|
_webglAvailable = false;
|
||||||
}
|
}
|
||||||
@@ -258,15 +259,16 @@ function AuroraImpl() {
|
|||||||
resize();
|
resize();
|
||||||
|
|
||||||
let lastTime = 0;
|
let lastTime = 0;
|
||||||
const targetFPS = 20;
|
const targetFPS = 10;
|
||||||
const frameInterval = 1000 / targetFPS;
|
const frameInterval = 1000 / targetFPS;
|
||||||
const speed = 0.3;
|
const speed = 0.3;
|
||||||
|
|
||||||
function animate(currentTime: number) {
|
function animate(currentTime: number) {
|
||||||
animationFrameRef.current = requestAnimationFrame(animate);
|
|
||||||
|
|
||||||
const delta = currentTime - lastTime;
|
const delta = currentTime - lastTime;
|
||||||
if (delta < frameInterval) return;
|
if (delta < frameInterval) {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
lastTime = currentTime - (delta % frameInterval);
|
lastTime = currentTime - (delta % frameInterval);
|
||||||
|
|
||||||
@@ -274,15 +276,45 @@ function AuroraImpl() {
|
|||||||
programRef.current.uniforms.uTime.value += speed * 0.01;
|
programRef.current.uniforms.uTime.value += speed * 0.01;
|
||||||
rendererRef.current.render({ scene: mesh });
|
rendererRef.current.render({ scene: mesh });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.hidden) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = 0;
|
||||||
|
} else {
|
||||||
|
lastTime = 0;
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
animationFrameRef.current = requestAnimationFrame(animate);
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
window.removeEventListener('resize', resize);
|
window.removeEventListener('resize', resize);
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
if (rendererRef.current && container.contains(rendererRef.current.gl.canvas)) {
|
|
||||||
container.removeChild(rendererRef.current.gl.canvas);
|
if (rendererRef.current) {
|
||||||
|
const glCtx = rendererRef.current.gl;
|
||||||
|
|
||||||
|
// Delete GPU resources
|
||||||
|
if (programRef.current) {
|
||||||
|
glCtx.deleteProgram(programRef.current.program);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force-release the WebGL context
|
||||||
|
const loseCtx = glCtx.getExtension('WEBGL_lose_context');
|
||||||
|
if (loseCtx) loseCtx.loseContext();
|
||||||
|
|
||||||
|
if (container.contains(glCtx.canvas)) {
|
||||||
|
container.removeChild(glCtx.canvas);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rendererRef.current = null;
|
rendererRef.current = null;
|
||||||
programRef.current = null;
|
programRef.current = null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export function DesktopSidebar({
|
|||||||
}: DesktopSidebarProps) {
|
}: DesktopSidebarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout } = useAuthStore();
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const logout = useAuthStore((state) => state.logout);
|
||||||
const { haptic } = usePlatform();
|
const { haptic } = usePlatform();
|
||||||
|
|
||||||
// Branding
|
// Branding
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function CommandPalette({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { haptic } = usePlatform();
|
const { haptic } = usePlatform();
|
||||||
const { isAdmin } = useAuthStore();
|
const isAdmin = useAuthStore((state) => state.isAdmin);
|
||||||
const { toggleTheme, isDark } = useTheme();
|
const { toggleTheme, isDark } = useTheme();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
import { useTelegramSDK, setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
|
import { useTelegramSDK, setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
|
||||||
@@ -14,8 +14,8 @@ const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
|
|||||||
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
|
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
|
||||||
|
|
||||||
export function useBranding() {
|
export function useBranding() {
|
||||||
const { isAuthenticated } = useAuthStore();
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const { isFullscreen, isTelegramWebApp, requestFullscreen, isMobile } = useTelegramSDK();
|
const { isTelegramWebApp, requestFullscreen, isMobile } = useTelegramSDK();
|
||||||
|
|
||||||
// Branding data
|
// Branding data
|
||||||
const { data: branding } = useQuery({
|
const { data: branding } = useQuery({
|
||||||
@@ -62,13 +62,16 @@ export function useBranding() {
|
|||||||
staleTime: 60000,
|
staleTime: 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fullscreenRequestedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fullscreenSetting || !isTelegramWebApp) return;
|
if (!fullscreenSetting || !isTelegramWebApp) return;
|
||||||
setCachedFullscreenEnabled(fullscreenSetting.enabled);
|
setCachedFullscreenEnabled(fullscreenSetting.enabled);
|
||||||
if (fullscreenSetting.enabled && !isFullscreen && isMobile) {
|
if (fullscreenSetting.enabled && isMobile && !fullscreenRequestedRef.current) {
|
||||||
|
fullscreenRequestedRef.current = true;
|
||||||
requestFullscreen();
|
requestFullscreen();
|
||||||
}
|
}
|
||||||
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
|
}, [fullscreenSetting, isTelegramWebApp, requestFullscreen, isMobile]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appName,
|
appName,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { contestsApi } from '@/api/contests';
|
|||||||
import { pollsApi } from '@/api/polls';
|
import { pollsApi } from '@/api/polls';
|
||||||
|
|
||||||
export function useFeatureFlags() {
|
export function useFeatureFlags() {
|
||||||
const { isAuthenticated } = useAuthStore();
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
|
||||||
const { data: referralTerms } = useQuery({
|
const { data: referralTerms } = useQuery({
|
||||||
queryKey: ['referral-terms'],
|
queryKey: ['referral-terms'],
|
||||||
|
|||||||
@@ -2902,6 +2902,7 @@
|
|||||||
"notVerified": "Not verified",
|
"notVerified": "Not verified",
|
||||||
"verificationRequired": "Please verify your email to use email login.",
|
"verificationRequired": "Please verify your email to use email login.",
|
||||||
"resendVerification": "Resend Verification Email",
|
"resendVerification": "Resend Verification Email",
|
||||||
|
"resendIn": "Resend in {{seconds}} sec.",
|
||||||
"verificationResent": "Verification email resent!",
|
"verificationResent": "Verification email resent!",
|
||||||
"canLoginWithEmail": "You can now log in using your email and password.",
|
"canLoginWithEmail": "You can now log in using your email and password.",
|
||||||
"emailAlreadyRegistered": "This email is already linked to another account. If this is your email, log in with it or contact support.",
|
"emailAlreadyRegistered": "This email is already linked to another account. If this is your email, log in with it or contact support.",
|
||||||
|
|||||||
@@ -2402,6 +2402,7 @@
|
|||||||
"notVerified": "تایید نشده",
|
"notVerified": "تایید نشده",
|
||||||
"verificationRequired": "لطفاً ایمیل خود را تایید کنید تا از ورود با ایمیل استفاده کنید.",
|
"verificationRequired": "لطفاً ایمیل خود را تایید کنید تا از ورود با ایمیل استفاده کنید.",
|
||||||
"resendVerification": "ارسال مجدد ایمیل تایید",
|
"resendVerification": "ارسال مجدد ایمیل تایید",
|
||||||
|
"resendIn": "ارسال مجدد در {{seconds}} ثانیه",
|
||||||
"verificationResent": "ایمیل تایید مجدداً ارسال شد!",
|
"verificationResent": "ایمیل تایید مجدداً ارسال شد!",
|
||||||
"canLoginWithEmail": "اکنون میتوانید با ایمیل و رمز عبور وارد شوید.",
|
"canLoginWithEmail": "اکنون میتوانید با ایمیل و رمز عبور وارد شوید.",
|
||||||
"emailAlreadyRegistered": "این ایمیل قبلاً به حساب دیگری متصل شده است. اگر این ایمیل شماست، با آن وارد شوید یا با پشتیبانی تماس بگیرید.",
|
"emailAlreadyRegistered": "این ایمیل قبلاً به حساب دیگری متصل شده است. اگر این ایمیل شماست، با آن وارد شوید یا با پشتیبانی تماس بگیرید.",
|
||||||
|
|||||||
@@ -3454,6 +3454,7 @@
|
|||||||
"notVerified": "Не подтверждён",
|
"notVerified": "Не подтверждён",
|
||||||
"verificationRequired": "Подтвердите email для использования входа по почте.",
|
"verificationRequired": "Подтвердите email для использования входа по почте.",
|
||||||
"resendVerification": "Отправить письмо повторно",
|
"resendVerification": "Отправить письмо повторно",
|
||||||
|
"resendIn": "Повторить через {{seconds}} сек.",
|
||||||
"verificationResent": "Письмо отправлено повторно!",
|
"verificationResent": "Письмо отправлено повторно!",
|
||||||
"canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.",
|
"canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.",
|
||||||
"emailAlreadyRegistered": "Этот email уже привязан к другому аккаунту. Если это ваш email, войдите через него или обратитесь в поддержку.",
|
"emailAlreadyRegistered": "Этот email уже привязан к другому аккаунту. Если это ваш email, войдите через него или обратитесь в поддержку.",
|
||||||
|
|||||||
@@ -2401,6 +2401,7 @@
|
|||||||
"notVerified": "未验证",
|
"notVerified": "未验证",
|
||||||
"verificationRequired": "请验证邮箱以使用邮箱登录。",
|
"verificationRequired": "请验证邮箱以使用邮箱登录。",
|
||||||
"resendVerification": "重新发送验证邮件",
|
"resendVerification": "重新发送验证邮件",
|
||||||
|
"resendIn": "{{seconds}} 秒后重新发送",
|
||||||
"verificationResent": "验证邮件已重新发送!",
|
"verificationResent": "验证邮件已重新发送!",
|
||||||
"canLoginWithEmail": "现在您可以使用邮箱和密码登录。",
|
"canLoginWithEmail": "现在您可以使用邮箱和密码登录。",
|
||||||
"emailAlreadyRegistered": "此邮箱已绑定到其他账户。如果这是您的邮箱,请使用邮箱登录或联系客服。",
|
"emailAlreadyRegistered": "此邮箱已绑定到其他账户。如果这是您的邮箱,请使用邮箱登录或联系客服。",
|
||||||
|
|||||||
@@ -411,8 +411,8 @@ export default function AdminBanSystem() {
|
|||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useMemo } from 'react';
|
import { useState, useRef, useMemo, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -135,6 +135,14 @@ export default function AdminBroadcastCreate() {
|
|||||||
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
||||||
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const mediaPreviewRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Revoke blob URLs on unmount to prevent memory leaks
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Email-specific state
|
// Email-specific state
|
||||||
const [emailSubject, setEmailSubject] = useState('');
|
const [emailSubject, setEmailSubject] = useState('');
|
||||||
@@ -271,7 +279,10 @@ export default function AdminBroadcastCreate() {
|
|||||||
|
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
setMediaType('photo');
|
setMediaType('photo');
|
||||||
setMediaPreview(URL.createObjectURL(file));
|
if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
mediaPreviewRef.current = url;
|
||||||
|
setMediaPreview(url);
|
||||||
} else if (file.type.startsWith('video/')) {
|
} else if (file.type.startsWith('video/')) {
|
||||||
setMediaType('video');
|
setMediaType('video');
|
||||||
setMediaPreview(null);
|
setMediaPreview(null);
|
||||||
@@ -295,6 +306,10 @@ export default function AdminBroadcastCreate() {
|
|||||||
|
|
||||||
// Remove media
|
// Remove media
|
||||||
const handleRemoveMedia = () => {
|
const handleRemoveMedia = () => {
|
||||||
|
if (mediaPreviewRef.current) {
|
||||||
|
URL.revokeObjectURL(mediaPreviewRef.current);
|
||||||
|
mediaPreviewRef.current = null;
|
||||||
|
}
|
||||||
setMediaFile(null);
|
setMediaFile(null);
|
||||||
setMediaPreview(null);
|
setMediaPreview(null);
|
||||||
setUploadedFileId(null);
|
setUploadedFileId(null);
|
||||||
|
|||||||
@@ -137,7 +137,13 @@ export default function AdminBroadcasts() {
|
|||||||
const { data, isLoading, refetch } = useQuery({
|
const { data, isLoading, refetch } = useQuery({
|
||||||
queryKey: ['admin', 'broadcasts', 'list', page],
|
queryKey: ['admin', 'broadcasts', 'list', page],
|
||||||
queryFn: () => adminBroadcastsApi.list(limit, page * limit),
|
queryFn: () => adminBroadcastsApi.list(limit, page * limit),
|
||||||
refetchInterval: 5000, // Auto refresh every 5s
|
refetchInterval: (query) => {
|
||||||
|
const items = query.state.data?.items;
|
||||||
|
const hasActive = items?.some((b: { status: string }) =>
|
||||||
|
['queued', 'in_progress', 'cancelling'].includes(b.status),
|
||||||
|
);
|
||||||
|
return hasActive ? 5000 : false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const broadcasts = data?.items || [];
|
const broadcasts = data?.items || [];
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ export default function AdminPinnedMessageCreate() {
|
|||||||
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [existingMediaType, setExistingMediaType] = useState<'photo' | 'video' | null>(null);
|
const [existingMediaType, setExistingMediaType] = useState<'photo' | 'video' | null>(null);
|
||||||
|
const mediaPreviewRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Revoke blob URLs on unmount to prevent memory leaks
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Load existing message for editing
|
// Load existing message for editing
|
||||||
const { data: existingMessage, isLoading: isLoadingMessage } = useQuery({
|
const { data: existingMessage, isLoading: isLoadingMessage } = useQuery({
|
||||||
@@ -138,7 +146,10 @@ export default function AdminPinnedMessageCreate() {
|
|||||||
let detectedType: 'photo' | 'video' = 'photo';
|
let detectedType: 'photo' | 'video' = 'photo';
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
detectedType = 'photo';
|
detectedType = 'photo';
|
||||||
setMediaPreview(URL.createObjectURL(file));
|
if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
mediaPreviewRef.current = url;
|
||||||
|
setMediaPreview(url);
|
||||||
} else if (file.type.startsWith('video/')) {
|
} else if (file.type.startsWith('video/')) {
|
||||||
detectedType = 'video';
|
detectedType = 'video';
|
||||||
setMediaPreview(null);
|
setMediaPreview(null);
|
||||||
@@ -159,6 +170,10 @@ export default function AdminPinnedMessageCreate() {
|
|||||||
|
|
||||||
// Remove media
|
// Remove media
|
||||||
const handleRemoveMedia = () => {
|
const handleRemoveMedia = () => {
|
||||||
|
if (mediaPreviewRef.current) {
|
||||||
|
URL.revokeObjectURL(mediaPreviewRef.current);
|
||||||
|
mediaPreviewRef.current = null;
|
||||||
|
}
|
||||||
setMediaFile(null);
|
setMediaFile(null);
|
||||||
setMediaPreview(null);
|
setMediaPreview(null);
|
||||||
setUploadedFileId(null);
|
setUploadedFileId(null);
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ const BackIcon = () => (
|
|||||||
const formatBytes = (bytes: number): string => {
|
const formatBytes = (bytes: number): string => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -363,9 +363,15 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use (total - available) instead of raw "used" to exclude disk cache/buffers
|
||||||
|
// This matches what htop/free show as actual application memory usage
|
||||||
|
const memoryActualUsed =
|
||||||
|
stats.server_info.memory_available > 0
|
||||||
|
? stats.server_info.memory_total - stats.server_info.memory_available
|
||||||
|
: stats.server_info.memory_used;
|
||||||
const memoryUsedPercent =
|
const memoryUsedPercent =
|
||||||
stats.server_info.memory_total > 0
|
stats.server_info.memory_total > 0
|
||||||
? Math.round((stats.server_info.memory_used / stats.server_info.memory_total) * 100)
|
? Math.round((memoryActualUsed / stats.server_info.memory_total) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -446,7 +452,7 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
|
|||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.remnawave.overview.memory', 'Memory')}
|
label={t('admin.remnawave.overview.memory', 'Memory')}
|
||||||
value={`${memoryUsedPercent}%`}
|
value={`${memoryUsedPercent}%`}
|
||||||
subValue={`${formatBytes(stats.server_info.memory_used)} / ${formatBytes(stats.server_info.memory_total)}`}
|
subValue={`${formatBytes(memoryActualUsed)} / ${formatBytes(stats.server_info.memory_total)}`}
|
||||||
icon={<span className="text-lg">💾</span>}
|
icon={<span className="text-lg">💾</span>}
|
||||||
color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'}
|
color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ declare module '@tanstack/react-table' {
|
|||||||
const formatBytes = (bytes: number): string => {
|
const formatBytes = (bytes: number): string => {
|
||||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -766,10 +766,10 @@ export default function AdminUserDetail() {
|
|||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
const k = 1024;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = async (text: string) => {
|
const copyToClipboard = async (text: string) => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => (
|
|||||||
|
|
||||||
export default function Balance() {
|
export default function Balance() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { refreshUser } = useAuthStore();
|
const refreshUser = useAuthStore((state) => state.refreshUser);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import InstallationGuide from '../components/connection/InstallationGuide';
|
|||||||
export default function Connection() {
|
export default function Connection() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isAdmin } = useAuthStore();
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const isAdmin = useAuthStore((state) => state.isAdmin);
|
||||||
const { isTelegramWebApp } = useTelegramSDK();
|
const { isTelegramWebApp } = useTelegramSDK();
|
||||||
const { impact: hapticImpact } = useHaptic();
|
const { impact: hapticImpact } = useHaptic();
|
||||||
|
|
||||||
|
|||||||
@@ -48,14 +48,15 @@ const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
|||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user, refreshUser } = useAuthStore();
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const refreshUser = useAuthStore((state) => state.refreshUser);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
|
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
|
||||||
const [trialError, setTrialError] = useState<string | null>(null);
|
const [trialError, setTrialError] = useState<string | null>(null);
|
||||||
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
|
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||||
const { blockingType } = useBlockingStore();
|
const blockingType = useBlockingStore((state) => state.blockingType);
|
||||||
|
|
||||||
// Refresh user data on mount
|
// Refresh user data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -61,9 +61,24 @@ export default function DeepLinkRedirect() {
|
|||||||
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V';
|
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V';
|
||||||
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
|
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
|
||||||
|
|
||||||
|
// Parse raw query string to preserve '+' chars in base64 crypto links.
|
||||||
|
// URLSearchParams decodes '+' as space, breaking ss://, vless:// etc.
|
||||||
|
// decodeURIComponent does NOT treat '+' as space, so parsing raw pairs is correct.
|
||||||
|
const getRawParam = (key: string): string => {
|
||||||
|
const search = window.location.search.substring(1);
|
||||||
|
for (const pair of search.split('&')) {
|
||||||
|
const idx = pair.indexOf('=');
|
||||||
|
if (idx === -1) continue;
|
||||||
|
if (decodeURIComponent(pair.substring(0, idx)) === key) {
|
||||||
|
return decodeURIComponent(pair.substring(idx + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
// Get parameters
|
// Get parameters
|
||||||
const deepLink = searchParams.get('url') || searchParams.get('deeplink') || '';
|
const deepLink = getRawParam('url') || getRawParam('deeplink') || '';
|
||||||
const subscriptionUrl = searchParams.get('sub') || '';
|
const subscriptionUrl = getRawParam('sub') || '';
|
||||||
const appParam = searchParams.get('app') || '';
|
const appParam = searchParams.get('app') || '';
|
||||||
|
|
||||||
// Detect app from deep link
|
// Detect app from deep link
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import { isValidEmail } from '../utils/validation';
|
import { isValidEmail } from '../utils/validation';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,15 @@ export default function Login() {
|
|||||||
loginWithTelegram,
|
loginWithTelegram,
|
||||||
loginWithEmail,
|
loginWithEmail,
|
||||||
registerWithEmail,
|
registerWithEmail,
|
||||||
} = useAuthStore();
|
} = useAuthStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
loginWithTelegram: state.loginWithTelegram,
|
||||||
|
loginWithEmail: state.loginWithEmail,
|
||||||
|
registerWithEmail: state.registerWithEmail,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
// Get referral code from localStorage (captured from ?ref= param at module level in auth store)
|
// Get referral code from localStorage (captured from ?ref= param at module level in auth store)
|
||||||
const referralCode = getPendingReferralCode() || '';
|
const referralCode = getPendingReferralCode() || '';
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export default function OAuthCallback() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const { loginWithOAuth, isAuthenticated } = useAuthStore();
|
const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth);
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ const PencilIcon = () => (
|
|||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user, setUser } = useAuthStore();
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const setUser = useAuthStore((state) => state.setUser);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -77,6 +78,7 @@ export default function Profile() {
|
|||||||
const [changeCode, setChangeCode] = useState('');
|
const [changeCode, setChangeCode] = useState('');
|
||||||
const [changeError, setChangeError] = useState<string | null>(null);
|
const [changeError, setChangeError] = useState<string | null>(null);
|
||||||
const [resendCooldown, setResendCooldown] = useState(0);
|
const [resendCooldown, setResendCooldown] = useState(0);
|
||||||
|
const [verificationResendCooldown, setVerificationResendCooldown] = useState(0);
|
||||||
const newEmailInputRef = useRef<HTMLInputElement>(null);
|
const newEmailInputRef = useRef<HTMLInputElement>(null);
|
||||||
const codeInputRef = useRef<HTMLInputElement>(null);
|
const codeInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -171,6 +173,7 @@ export default function Profile() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSuccess(t('profile.verificationResent'));
|
setSuccess(t('profile.verificationResent'));
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setVerificationResendCooldown(UI.RESEND_COOLDOWN_SEC);
|
||||||
},
|
},
|
||||||
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
onError: (err: { response?: { data?: { detail?: string } } }) => {
|
||||||
setError(err.response?.data?.detail || t('common.error'));
|
setError(err.response?.data?.detail || t('common.error'));
|
||||||
@@ -228,7 +231,7 @@ export default function Profile() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resend cooldown timer
|
// Resend cooldown timers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resendCooldown <= 0) return;
|
if (resendCooldown <= 0) return;
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
@@ -237,6 +240,14 @@ export default function Profile() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [resendCooldown]);
|
}, [resendCooldown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (verificationResendCooldown <= 0) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setVerificationResendCooldown((prev) => Math.max(0, prev - 1));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [verificationResendCooldown]);
|
||||||
|
|
||||||
// Auto-focus inputs on step change
|
// Auto-focus inputs on step change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -454,34 +465,34 @@ export default function Profile() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => resendVerificationMutation.mutate()}
|
onClick={() => resendVerificationMutation.mutate()}
|
||||||
loading={resendVerificationMutation.isPending}
|
loading={resendVerificationMutation.isPending}
|
||||||
|
disabled={verificationResendCooldown > 0}
|
||||||
>
|
>
|
||||||
{t('profile.resendVerification')}
|
{verificationResendCooldown > 0
|
||||||
|
? t('profile.resendIn', { seconds: verificationResendCooldown })
|
||||||
|
: t('profile.resendVerification')}
|
||||||
</Button>
|
</Button>
|
||||||
{(user.auth_type === 'telegram' || user.auth_type === 'email') && (
|
<button
|
||||||
<button
|
onClick={() => setChangeEmailStep('email')}
|
||||||
onClick={() => setChangeEmailStep('email')}
|
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||||
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
|
>
|
||||||
>
|
{t('profile.changeEmail.button')}
|
||||||
{t('profile.changeEmail.button')}
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{user.email_verified &&
|
{user.email_verified && (
|
||||||
(user.auth_type === 'telegram' || user.auth_type === 'email') && (
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
|
||||||
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
|
<button
|
||||||
<button
|
onClick={() => setChangeEmailStep('email')}
|
||||||
onClick={() => setChangeEmailStep('email')}
|
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||||
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
>
|
||||||
>
|
<PencilIcon />
|
||||||
<PencilIcon />
|
<span>{t('profile.changeEmail.button')}</span>
|
||||||
<span>{t('profile.changeEmail.button')}</span>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inline email change flow */}
|
{/* Inline email change flow */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export default function ReferralPartnerApply() {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
|
max={2000000000}
|
||||||
className="input w-full"
|
className="input w-full"
|
||||||
value={form.expected_monthly_referrals ?? ''}
|
value={form.expected_monthly_referrals ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|||||||
@@ -89,24 +89,47 @@ export default function Subscription() {
|
|||||||
// Helper to format price from kopeks
|
// Helper to format price from kopeks
|
||||||
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`;
|
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`;
|
||||||
|
|
||||||
// Helper to apply promo discount to a price
|
// Helper to apply promo discount to a price, stacking with existing promo group discount
|
||||||
const applyPromoDiscount = (
|
const applyPromoDiscount = (
|
||||||
priceKopeks: number,
|
priceKopeks: number,
|
||||||
hasExistingDiscount: boolean = false,
|
existingOriginalPrice?: number | null,
|
||||||
): {
|
): {
|
||||||
price: number;
|
price: number;
|
||||||
original: number | null;
|
original: number | null;
|
||||||
percent: number | null;
|
percent: number | null;
|
||||||
|
isPromoGroup: boolean;
|
||||||
} => {
|
} => {
|
||||||
// Only apply promo discount if no existing discount (from promo group) and we have an active promo discount
|
const hasExisting = (existingOriginalPrice ?? 0) > priceKopeks;
|
||||||
if (!activeDiscount?.is_active || !activeDiscount.discount_percent || hasExistingDiscount) {
|
const hasPromo = !!activeDiscount?.is_active && !!activeDiscount.discount_percent;
|
||||||
return { price: priceKopeks, original: null, percent: null };
|
|
||||||
|
if (!hasExisting && !hasPromo) {
|
||||||
|
return { price: priceKopeks, original: null, percent: null, isPromoGroup: false };
|
||||||
}
|
}
|
||||||
const discountedPrice = Math.round(priceKopeks * (1 - activeDiscount.discount_percent / 100));
|
|
||||||
|
let finalPrice = priceKopeks;
|
||||||
|
if (hasPromo) {
|
||||||
|
finalPrice = Math.round(priceKopeks * (1 - activeDiscount!.discount_percent! / 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasExisting) {
|
||||||
|
// Promo group discount exists — calculate combined percent from deepest original
|
||||||
|
const combinedPercent = hasPromo
|
||||||
|
? Math.round((1 - finalPrice / existingOriginalPrice!) * 100)
|
||||||
|
: Math.round((1 - priceKopeks / existingOriginalPrice!) * 100);
|
||||||
|
return {
|
||||||
|
price: finalPrice,
|
||||||
|
original: existingOriginalPrice!,
|
||||||
|
percent: combinedPercent,
|
||||||
|
isPromoGroup: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only promo offer discount (no promo group)
|
||||||
return {
|
return {
|
||||||
price: discountedPrice,
|
price: finalPrice,
|
||||||
original: priceKopeks,
|
original: priceKopeks,
|
||||||
percent: activeDiscount.discount_percent,
|
percent: activeDiscount!.discount_percent!,
|
||||||
|
isPromoGroup: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2338,7 +2361,9 @@ export default function Subscription() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-semibold text-dark-100">{tariff.name}</div>
|
<div className="text-lg font-semibold text-dark-100">{tariff.name}</div>
|
||||||
{tariff.description && (
|
{tariff.description && (
|
||||||
<div className="mt-1 text-sm text-dark-400">{tariff.description}</div>
|
<div className="mt-1 whitespace-pre-line text-sm text-dark-400">
|
||||||
|
{tariff.description}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isCurrentTariff && (
|
{isCurrentTariff && (
|
||||||
@@ -2416,41 +2441,34 @@ export default function Subscription() {
|
|||||||
const dailyPrice =
|
const dailyPrice =
|
||||||
tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0;
|
tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0;
|
||||||
const originalDailyPrice = tariff.original_daily_price_kopeks || 0;
|
const originalDailyPrice = tariff.original_daily_price_kopeks || 0;
|
||||||
const hasExistingDailyDiscount = originalDailyPrice > dailyPrice;
|
|
||||||
if (dailyPrice > 0) {
|
if (dailyPrice > 0) {
|
||||||
// Apply promo discount if no existing discount
|
|
||||||
const promoDaily = applyPromoDiscount(
|
const promoDaily = applyPromoDiscount(
|
||||||
dailyPrice,
|
dailyPrice,
|
||||||
hasExistingDailyDiscount,
|
originalDailyPrice > dailyPrice ? originalDailyPrice : undefined,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="font-medium text-accent-400">
|
<span className="font-medium text-accent-400">
|
||||||
{formatPrice(promoDaily.price)}
|
{formatPrice(promoDaily.price)}
|
||||||
</span>
|
</span>
|
||||||
{/* Show original price from promo group or promo offer */}
|
{promoDaily.original &&
|
||||||
{(hasExistingDailyDiscount || promoDaily.original) && (
|
promoDaily.original > promoDaily.price && (
|
||||||
<span className="text-xs text-dark-500 line-through">
|
<span className="text-xs text-dark-500 line-through">
|
||||||
{formatPrice(
|
{formatPrice(promoDaily.original)}
|
||||||
hasExistingDailyDiscount
|
</span>
|
||||||
? originalDailyPrice
|
)}
|
||||||
: promoDaily.original!,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{t('subscription.tariff.perDay')}</span>
|
<span>{t('subscription.tariff.perDay')}</span>
|
||||||
{/* Show discount badge */}
|
{/* Show discount badge */}
|
||||||
{tariff.daily_discount_percent &&
|
{promoDaily.percent && promoDaily.percent > 0 && (
|
||||||
tariff.daily_discount_percent > 0 ? (
|
<span
|
||||||
<span className="rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400">
|
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||||
-{tariff.daily_discount_percent}%
|
promoDaily.isPromoGroup
|
||||||
|
? 'bg-success-500/20 text-success-400'
|
||||||
|
: 'bg-orange-500/20 text-orange-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
-{promoDaily.percent}%
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
promoDaily.percent && (
|
|
||||||
<span className="rounded bg-orange-500/20 px-1.5 py-0.5 text-xs text-orange-400">
|
|
||||||
-{promoDaily.percent}%
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -2458,14 +2476,9 @@ export default function Subscription() {
|
|||||||
// Period-based price
|
// Period-based price
|
||||||
if (tariff.periods.length > 0) {
|
if (tariff.periods.length > 0) {
|
||||||
const firstPeriod = tariff.periods[0];
|
const firstPeriod = tariff.periods[0];
|
||||||
const hasExistingDiscount = !!(
|
|
||||||
firstPeriod?.original_price_kopeks &&
|
|
||||||
firstPeriod.original_price_kopeks > firstPeriod.price_kopeks
|
|
||||||
);
|
|
||||||
// Apply promo discount if no existing discount
|
|
||||||
const promoPeriod = applyPromoDiscount(
|
const promoPeriod = applyPromoDiscount(
|
||||||
firstPeriod?.price_kopeks || 0,
|
firstPeriod?.price_kopeks || 0,
|
||||||
hasExistingDiscount,
|
firstPeriod?.original_price_kopeks,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
@@ -2473,27 +2486,22 @@ export default function Subscription() {
|
|||||||
<span className="font-medium text-accent-400">
|
<span className="font-medium text-accent-400">
|
||||||
{formatPrice(promoPeriod.price)}
|
{formatPrice(promoPeriod.price)}
|
||||||
</span>
|
</span>
|
||||||
{/* Show original price from promo group or promo offer */}
|
{promoPeriod.original &&
|
||||||
{(hasExistingDiscount || promoPeriod.original) && (
|
promoPeriod.original > promoPeriod.price && (
|
||||||
<span className="text-xs text-dark-500 line-through">
|
<span className="text-xs text-dark-500 line-through">
|
||||||
{formatPrice(
|
{formatPrice(promoPeriod.original)}
|
||||||
hasExistingDiscount
|
|
||||||
? firstPeriod.original_price_kopeks!
|
|
||||||
: promoPeriod.original!,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Show discount badge */}
|
|
||||||
{hasExistingDiscount && firstPeriod.discount_percent ? (
|
|
||||||
<span className="rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400">
|
|
||||||
-{firstPeriod.discount_percent}%
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
promoPeriod.percent && (
|
|
||||||
<span className="rounded bg-orange-500/20 px-1.5 py-0.5 text-xs text-orange-400">
|
|
||||||
-{promoPeriod.percent}%
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)}
|
||||||
|
{promoPeriod.percent && promoPeriod.percent > 0 && (
|
||||||
|
<span
|
||||||
|
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||||
|
promoPeriod.isPromoGroup
|
||||||
|
? 'bg-success-500/20 text-success-400'
|
||||||
|
: 'bg-orange-500/20 text-orange-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
-{promoPeriod.percent}%
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -2702,24 +2710,17 @@ export default function Subscription() {
|
|||||||
{selectedTariff.periods.length > 0 && !useCustomDays && (
|
{selectedTariff.periods.length > 0 && !useCustomDays && (
|
||||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
{selectedTariff.periods.map((period) => {
|
{selectedTariff.periods.map((period) => {
|
||||||
const hasExistingDiscount = !!(
|
|
||||||
period.original_price_kopeks &&
|
|
||||||
period.original_price_kopeks > period.price_kopeks
|
|
||||||
);
|
|
||||||
const promoPeriod = applyPromoDiscount(
|
const promoPeriod = applyPromoDiscount(
|
||||||
period.price_kopeks,
|
period.price_kopeks,
|
||||||
hasExistingDiscount,
|
period.original_price_kopeks,
|
||||||
);
|
);
|
||||||
const displayDiscount = hasExistingDiscount
|
const displayDiscount = promoPeriod.percent;
|
||||||
? period.discount_percent
|
const displayOriginal = promoPeriod.original;
|
||||||
: promoPeriod.percent;
|
|
||||||
const displayOriginal = hasExistingDiscount
|
|
||||||
? period.original_price_kopeks
|
|
||||||
: promoPeriod.original;
|
|
||||||
const displayPrice = promoPeriod.price;
|
const displayPrice = promoPeriod.price;
|
||||||
const displayPerMonth = hasExistingDiscount
|
const displayPerMonth =
|
||||||
? period.price_per_month_kopeks
|
displayPrice !== period.price_kopeks
|
||||||
: Math.round(promoPeriod.price / (period.days / 30));
|
? Math.round(displayPrice / Math.max(1, period.days / 30))
|
||||||
|
: period.price_per_month_kopeks;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -2737,7 +2738,7 @@ export default function Subscription() {
|
|||||||
{displayDiscount && displayDiscount > 0 && (
|
{displayDiscount && displayDiscount > 0 && (
|
||||||
<div
|
<div
|
||||||
className={`absolute -right-2 -top-2 rounded-full px-2 py-0.5 text-xs font-medium text-white ${
|
className={`absolute -right-2 -top-2 rounded-full px-2 py-0.5 text-xs font-medium text-white ${
|
||||||
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500'
|
promoPeriod.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
-{displayDiscount}%
|
-{displayDiscount}%
|
||||||
@@ -2821,14 +2822,15 @@ export default function Subscription() {
|
|||||||
{(() => {
|
{(() => {
|
||||||
const basePrice =
|
const basePrice =
|
||||||
customDays * (selectedTariff.price_per_day_kopeks ?? 0);
|
customDays * (selectedTariff.price_per_day_kopeks ?? 0);
|
||||||
const hasExistingDiscount = !!(
|
const existingOriginal =
|
||||||
selectedTariff.original_price_per_day_kopeks &&
|
selectedTariff.original_price_per_day_kopeks &&
|
||||||
selectedTariff.original_price_per_day_kopeks >
|
selectedTariff.original_price_per_day_kopeks >
|
||||||
(selectedTariff.price_per_day_kopeks ?? 0)
|
(selectedTariff.price_per_day_kopeks ?? 0)
|
||||||
);
|
? customDays * selectedTariff.original_price_per_day_kopeks
|
||||||
|
: undefined;
|
||||||
const promoCustom = applyPromoDiscount(
|
const promoCustom = applyPromoDiscount(
|
||||||
basePrice,
|
basePrice,
|
||||||
hasExistingDiscount,
|
existingOriginal,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
@@ -2841,16 +2843,23 @@ export default function Subscription() {
|
|||||||
<span className="font-medium text-accent-400">
|
<span className="font-medium text-accent-400">
|
||||||
{formatPrice(promoCustom.price)}
|
{formatPrice(promoCustom.price)}
|
||||||
</span>
|
</span>
|
||||||
{promoCustom.original && (
|
{promoCustom.original &&
|
||||||
<>
|
promoCustom.original > promoCustom.price && (
|
||||||
<span className="text-xs text-dark-500 line-through">
|
<>
|
||||||
{formatPrice(promoCustom.original)}
|
<span className="text-xs text-dark-500 line-through">
|
||||||
</span>
|
{formatPrice(promoCustom.original)}
|
||||||
<span className="rounded bg-orange-500/20 px-1.5 py-0.5 text-xs text-orange-400">
|
</span>
|
||||||
-{promoCustom.percent}%
|
<span
|
||||||
</span>
|
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||||
</>
|
promoCustom.isPromoGroup
|
||||||
)}
|
? 'bg-success-500/20 text-success-400'
|
||||||
|
: 'bg-orange-500/20 text-orange-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
-{promoCustom.percent}%
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2956,14 +2965,20 @@ export default function Subscription() {
|
|||||||
const basePeriodPrice = useCustomDays
|
const basePeriodPrice = useCustomDays
|
||||||
? customDays * (selectedTariff.price_per_day_kopeks ?? 0)
|
? customDays * (selectedTariff.price_per_day_kopeks ?? 0)
|
||||||
: selectedTariffPeriod?.price_kopeks || 0;
|
: selectedTariffPeriod?.price_kopeks || 0;
|
||||||
const hasExistingPeriodDiscount =
|
const existingPeriodOriginal = useCustomDays
|
||||||
!useCustomDays && selectedTariffPeriod?.original_price_kopeks
|
? selectedTariff.original_price_per_day_kopeks &&
|
||||||
? selectedTariffPeriod.original_price_kopeks >
|
selectedTariff.original_price_per_day_kopeks >
|
||||||
selectedTariffPeriod.price_kopeks
|
(selectedTariff.price_per_day_kopeks ?? 0)
|
||||||
: false;
|
? customDays * selectedTariff.original_price_per_day_kopeks
|
||||||
|
: undefined
|
||||||
|
: selectedTariffPeriod?.original_price_kopeks &&
|
||||||
|
selectedTariffPeriod.original_price_kopeks >
|
||||||
|
selectedTariffPeriod.price_kopeks
|
||||||
|
? selectedTariffPeriod.original_price_kopeks
|
||||||
|
: undefined;
|
||||||
const promoPeriod = applyPromoDiscount(
|
const promoPeriod = applyPromoDiscount(
|
||||||
basePeriodPrice,
|
basePeriodPrice,
|
||||||
hasExistingPeriodDiscount,
|
existingPeriodOriginal,
|
||||||
);
|
);
|
||||||
|
|
||||||
const trafficPrice =
|
const trafficPrice =
|
||||||
@@ -2988,11 +3003,12 @@ export default function Subscription() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{formatPrice(promoPeriod.price)}</span>
|
<span>{formatPrice(promoPeriod.price)}</span>
|
||||||
{promoPeriod.original && (
|
{promoPeriod.original &&
|
||||||
<span className="text-xs text-dark-500 line-through">
|
promoPeriod.original > promoPeriod.price && (
|
||||||
{formatPrice(promoPeriod.original)}
|
<span className="text-xs text-dark-500 line-through">
|
||||||
</span>
|
{formatPrice(promoPeriod.original)}
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -3035,16 +3051,12 @@ export default function Subscription() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{formatPrice(promoPeriod.price)}</span>
|
<span>{formatPrice(promoPeriod.price)}</span>
|
||||||
{(hasExistingPeriodDiscount ||
|
{promoPeriod.original &&
|
||||||
promoPeriod.original) && (
|
promoPeriod.original > promoPeriod.price && (
|
||||||
<span className="text-xs text-dark-500 line-through">
|
<span className="text-xs text-dark-500 line-through">
|
||||||
{formatPrice(
|
{formatPrice(promoPeriod.original)}
|
||||||
hasExistingPeriodDiscount
|
</span>
|
||||||
? selectedTariffPeriod.original_price_kopeks!
|
)}
|
||||||
: promoPeriod.original!,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -3177,19 +3189,10 @@ export default function Subscription() {
|
|||||||
{currentStep === 'period' && classicOptions && (
|
{currentStep === 'period' && classicOptions && (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
{classicOptions.periods.map((period) => {
|
{classicOptions.periods.map((period) => {
|
||||||
const hasExistingDiscount = !!(
|
|
||||||
period.discount_percent && period.discount_percent > 0
|
|
||||||
);
|
|
||||||
const promoPeriod = applyPromoDiscount(
|
const promoPeriod = applyPromoDiscount(
|
||||||
period.price_kopeks,
|
period.price_kopeks,
|
||||||
hasExistingDiscount,
|
period.original_price_kopeks,
|
||||||
);
|
);
|
||||||
const displayDiscount = hasExistingDiscount
|
|
||||||
? period.discount_percent
|
|
||||||
: promoPeriod.percent;
|
|
||||||
const displayOriginal = hasExistingDiscount
|
|
||||||
? period.original_price_kopeks
|
|
||||||
: promoPeriod.original;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -3219,13 +3222,13 @@ export default function Subscription() {
|
|||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{displayDiscount && displayDiscount > 0 && (
|
{promoPeriod.percent && promoPeriod.percent > 0 && (
|
||||||
<div
|
<div
|
||||||
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
|
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
|
||||||
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500'
|
promoPeriod.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
-{displayDiscount}%
|
-{promoPeriod.percent}%
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
|
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
|
||||||
@@ -3233,9 +3236,9 @@ export default function Subscription() {
|
|||||||
<span className="font-medium text-accent-400">
|
<span className="font-medium text-accent-400">
|
||||||
{formatPrice(promoPeriod.price)}
|
{formatPrice(promoPeriod.price)}
|
||||||
</span>
|
</span>
|
||||||
{displayOriginal && displayOriginal > promoPeriod.price && (
|
{promoPeriod.original && promoPeriod.original > promoPeriod.price && (
|
||||||
<span className="text-sm text-dark-500 line-through">
|
<span className="text-sm text-dark-500 line-through">
|
||||||
{formatPrice(displayOriginal)}
|
{formatPrice(promoPeriod.original)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3249,12 +3252,9 @@ export default function Subscription() {
|
|||||||
{currentStep === 'traffic' && selectedPeriod?.traffic.options && (
|
{currentStep === 'traffic' && selectedPeriod?.traffic.options && (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{selectedPeriod.traffic.options.map((option) => {
|
{selectedPeriod.traffic.options.map((option) => {
|
||||||
const hasExistingDiscount = !!(
|
|
||||||
option.discount_percent && option.discount_percent > 0
|
|
||||||
);
|
|
||||||
const promoTraffic = applyPromoDiscount(
|
const promoTraffic = applyPromoDiscount(
|
||||||
option.price_kopeks,
|
option.price_kopeks,
|
||||||
hasExistingDiscount,
|
option.original_price_kopeks,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -3268,41 +3268,24 @@ export default function Subscription() {
|
|||||||
: ''
|
: ''
|
||||||
} ${!option.is_available ? 'cursor-not-allowed opacity-50' : ''}`}
|
} ${!option.is_available ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||||
>
|
>
|
||||||
{(() => {
|
{promoTraffic.percent && promoTraffic.percent > 0 && (
|
||||||
const trafficDisplayDiscount = hasExistingDiscount
|
<div
|
||||||
? option.discount_percent
|
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
|
||||||
: promoTraffic.percent;
|
promoTraffic.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
|
||||||
const trafficDisplayOriginal = hasExistingDiscount
|
}`}
|
||||||
? option.original_price_kopeks
|
>
|
||||||
: promoTraffic.original;
|
-{promoTraffic.percent}%
|
||||||
return (
|
</div>
|
||||||
<>
|
)}
|
||||||
{trafficDisplayDiscount && trafficDisplayDiscount > 0 && (
|
<div className="text-lg font-semibold text-dark-100">{option.label}</div>
|
||||||
<div
|
<div className="mt-1 flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
|
||||||
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
|
<span className="text-accent-400">{formatPrice(promoTraffic.price)}</span>
|
||||||
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500'
|
{promoTraffic.original && promoTraffic.original > promoTraffic.price && (
|
||||||
}`}
|
<span className="text-xs text-dark-500 line-through">
|
||||||
>
|
{formatPrice(promoTraffic.original)}
|
||||||
-{trafficDisplayDiscount}%
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
<div className="text-lg font-semibold text-dark-100">
|
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
|
|
||||||
<span className="text-accent-400">
|
|
||||||
{formatPrice(promoTraffic.price)}
|
|
||||||
</span>
|
|
||||||
{trafficDisplayOriginal &&
|
|
||||||
trafficDisplayOriginal > promoTraffic.price && (
|
|
||||||
<span className="text-xs text-dark-500 line-through">
|
|
||||||
{formatPrice(trafficDisplayOriginal)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -3322,12 +3305,9 @@ export default function Subscription() {
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map((server) => {
|
.map((server) => {
|
||||||
const hasExistingDiscount = !!(
|
|
||||||
server.discount_percent && server.discount_percent > 0
|
|
||||||
);
|
|
||||||
const promoServer = applyPromoDiscount(
|
const promoServer = applyPromoDiscount(
|
||||||
server.price_kopeks,
|
server.price_kopeks,
|
||||||
hasExistingDiscount,
|
server.original_price_kopeks,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -3343,20 +3323,15 @@ export default function Subscription() {
|
|||||||
: 'cursor-not-allowed border-dark-800/30 bg-dark-900/30 opacity-50'
|
: 'cursor-not-allowed border-dark-800/30 bg-dark-900/30 opacity-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{(() => {
|
{promoServer.percent && promoServer.percent > 0 ? (
|
||||||
const serverDisplayDiscount = hasExistingDiscount
|
<div
|
||||||
? server.discount_percent
|
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
|
||||||
: promoServer.percent;
|
promoServer.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
|
||||||
return serverDisplayDiscount && serverDisplayDiscount > 0 ? (
|
}`}
|
||||||
<div
|
>
|
||||||
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
|
-{promoServer.percent}%
|
||||||
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500'
|
</div>
|
||||||
}`}
|
) : null}
|
||||||
>
|
|
||||||
-{serverDisplayDiscount}%
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border-2 ${
|
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border-2 ${
|
||||||
@@ -3374,16 +3349,12 @@ export default function Subscription() {
|
|||||||
{formatPrice(promoServer.price)}
|
{formatPrice(promoServer.price)}
|
||||||
{t('subscription.perMonth')}
|
{t('subscription.perMonth')}
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{promoServer.original &&
|
||||||
const serverOriginal = hasExistingDiscount
|
promoServer.original > promoServer.price ? (
|
||||||
? server.original_price_kopeks
|
<span className="text-xs text-dark-500 line-through">
|
||||||
: promoServer.original;
|
{formatPrice(promoServer.original)}
|
||||||
return serverOriginal && serverOriginal > promoServer.price ? (
|
</span>
|
||||||
<span className="text-xs text-dark-500 line-through">
|
) : null}
|
||||||
{formatPrice(serverOriginal)}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3448,28 +3419,26 @@ export default function Subscription() {
|
|||||||
) : preview ? (
|
) : preview ? (
|
||||||
<div className="space-y-4 rounded-xl bg-dark-800/50 p-5">
|
<div className="space-y-4 rounded-xl bg-dark-800/50 p-5">
|
||||||
{/* Active promo discount banner */}
|
{/* Active promo discount banner */}
|
||||||
{activeDiscount?.is_active &&
|
{activeDiscount?.is_active && activeDiscount.discount_percent && (
|
||||||
activeDiscount.discount_percent &&
|
<div className="flex items-center justify-center gap-2 rounded-lg border border-orange-500/30 bg-orange-500/10 p-3">
|
||||||
!preview.original_price_kopeks && (
|
<svg
|
||||||
<div className="flex items-center justify-center gap-2 rounded-lg border border-orange-500/30 bg-orange-500/10 p-3">
|
className="h-4 w-4 text-orange-400"
|
||||||
<svg
|
fill="none"
|
||||||
className="h-4 w-4 text-orange-400"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
strokeWidth={2}
|
||||||
stroke="currentColor"
|
>
|
||||||
strokeWidth={2}
|
<path
|
||||||
>
|
strokeLinecap="round"
|
||||||
<path
|
strokeLinejoin="round"
|
||||||
strokeLinecap="round"
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||||
strokeLinejoin="round"
|
/>
|
||||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
</svg>
|
||||||
/>
|
<span className="text-sm font-medium text-orange-400">
|
||||||
</svg>
|
{t('promo.discountApplied')} -{activeDiscount.discount_percent}%
|
||||||
<span className="text-sm font-medium text-orange-400">
|
</span>
|
||||||
{t('promo.discountApplied')} -{activeDiscount.discount_percent}%
|
</div>
|
||||||
</span>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{preview.breakdown.map((item, idx) => (
|
{preview.breakdown.map((item, idx) => (
|
||||||
<div key={idx} className="flex justify-between text-dark-300">
|
<div key={idx} className="flex justify-between text-dark-300">
|
||||||
@@ -3479,16 +3448,10 @@ export default function Subscription() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
// Apply promo discount if not already applied by server
|
|
||||||
const hasServerDiscount = !!preview.original_price_kopeks;
|
|
||||||
const promoTotal = applyPromoDiscount(
|
const promoTotal = applyPromoDiscount(
|
||||||
preview.total_price_kopeks,
|
preview.total_price_kopeks,
|
||||||
hasServerDiscount,
|
preview.original_price_kopeks,
|
||||||
);
|
);
|
||||||
const displayTotal = promoTotal.price;
|
|
||||||
const displayOriginal = hasServerDiscount
|
|
||||||
? preview.original_price_kopeks
|
|
||||||
: promoTotal.original;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-t border-dark-700/50 pt-4">
|
<div className="flex items-center justify-between border-t border-dark-700/50 pt-4">
|
||||||
@@ -3497,11 +3460,11 @@ export default function Subscription() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-2xl font-bold text-accent-400">
|
<div className="text-2xl font-bold text-accent-400">
|
||||||
{formatPrice(displayTotal)}
|
{formatPrice(promoTotal.price)}
|
||||||
</div>
|
</div>
|
||||||
{displayOriginal && (
|
{promoTotal.original && promoTotal.original > promoTotal.price && (
|
||||||
<div className="text-sm text-dark-500 line-through">
|
<div className="text-sm text-dark-500 line-through">
|
||||||
{formatPrice(displayOriginal)}
|
{formatPrice(promoTotal.original)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
@@ -151,7 +151,7 @@ export default function Support() {
|
|||||||
log.debug('Component loaded');
|
log.debug('Component loaded');
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useAuthStore();
|
const isAdmin = useAuthStore((state) => state.isAdmin);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { openTelegramLink, openLink } = usePlatform();
|
const { openTelegramLink, openLink } = usePlatform();
|
||||||
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null);
|
||||||
@@ -166,6 +166,34 @@ export default function Support() {
|
|||||||
const [replyAttachment, setReplyAttachment] = useState<MediaAttachment | null>(null);
|
const [replyAttachment, setReplyAttachment] = useState<MediaAttachment | null>(null);
|
||||||
const createFileInputRef = useRef<HTMLInputElement>(null);
|
const createFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const replyFileInputRef = useRef<HTMLInputElement>(null);
|
const replyFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const createPreviewRef = useRef<string | null>(null);
|
||||||
|
const replyPreviewRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Revoke blob URLs on unmount to prevent memory leaks
|
||||||
|
useEffect(() => {
|
||||||
|
const createRef = createPreviewRef;
|
||||||
|
const replyRef = replyPreviewRef;
|
||||||
|
return () => {
|
||||||
|
if (createRef.current) URL.revokeObjectURL(createRef.current);
|
||||||
|
if (replyRef.current) URL.revokeObjectURL(replyRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearCreateAttachment = () => {
|
||||||
|
if (createPreviewRef.current) {
|
||||||
|
URL.revokeObjectURL(createPreviewRef.current);
|
||||||
|
createPreviewRef.current = null;
|
||||||
|
}
|
||||||
|
clearCreateAttachment();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReplyAttachment = () => {
|
||||||
|
if (replyPreviewRef.current) {
|
||||||
|
URL.revokeObjectURL(replyPreviewRef.current);
|
||||||
|
replyPreviewRef.current = null;
|
||||||
|
}
|
||||||
|
clearReplyAttachment();
|
||||||
|
};
|
||||||
|
|
||||||
// Get support configuration
|
// Get support configuration
|
||||||
const { data: supportConfig, isLoading: configLoading } = useQuery({
|
const { data: supportConfig, isLoading: configLoading } = useQuery({
|
||||||
@@ -213,8 +241,11 @@ export default function Support() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create preview
|
// Revoke old blob URL before creating new one
|
||||||
|
const previewRef = setAttachment === setCreateAttachment ? createPreviewRef : replyPreviewRef;
|
||||||
|
if (previewRef.current) URL.revokeObjectURL(previewRef.current);
|
||||||
const preview = URL.createObjectURL(file);
|
const preview = URL.createObjectURL(file);
|
||||||
|
previewRef.current = preview;
|
||||||
setAttachment({ file, preview, uploading: true });
|
setAttachment({ file, preview, uploading: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -250,7 +281,7 @@ export default function Support() {
|
|||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setNewTitle('');
|
setNewTitle('');
|
||||||
setNewMessage('');
|
setNewMessage('');
|
||||||
setCreateAttachment(null);
|
clearCreateAttachment();
|
||||||
setSelectedTicket(ticket);
|
setSelectedTicket(ticket);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -268,7 +299,7 @@ export default function Support() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] });
|
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] });
|
||||||
setReplyMessage('');
|
setReplyMessage('');
|
||||||
setReplyAttachment(null);
|
clearReplyAttachment();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -444,7 +475,7 @@ export default function Support() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCreateForm(true);
|
setShowCreateForm(true);
|
||||||
setSelectedTicket(null);
|
setSelectedTicket(null);
|
||||||
setCreateAttachment(null);
|
clearCreateAttachment();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
@@ -469,7 +500,7 @@ export default function Support() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedTicket(ticket as unknown as TicketDetail);
|
setSelectedTicket(ticket as unknown as TicketDetail);
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setReplyAttachment(null);
|
clearReplyAttachment();
|
||||||
}}
|
}}
|
||||||
className={`w-full rounded-bento border p-4 text-left transition-all ${
|
className={`w-full rounded-bento border p-4 text-left transition-all ${
|
||||||
selectedTicket?.id === ticket.id
|
selectedTicket?.id === ticket.id
|
||||||
@@ -574,7 +605,7 @@ export default function Support() {
|
|||||||
{createAttachment ? (
|
{createAttachment ? (
|
||||||
<AttachmentPreview
|
<AttachmentPreview
|
||||||
attachment={createAttachment}
|
attachment={createAttachment}
|
||||||
onRemove={() => setCreateAttachment(null)}
|
onRemove={() => clearCreateAttachment()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -608,7 +639,7 @@ export default function Support() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setCreateAttachment(null);
|
clearCreateAttachment();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
@@ -715,7 +746,7 @@ export default function Support() {
|
|||||||
{replyAttachment ? (
|
{replyAttachment ? (
|
||||||
<AttachmentPreview
|
<AttachmentPreview
|
||||||
attachment={replyAttachment}
|
attachment={replyAttachment}
|
||||||
onRemove={() => setReplyAttachment(null)}
|
onRemove={() => clearReplyAttachment()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export default function TelegramCallback() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const { loginWithTelegramWidget, isAuthenticated } = useAuthStore();
|
const loginWithTelegramWidget = useAuthStore((state) => state.loginWithTelegramWidget);
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
import { brandingApi } from '../api/branding';
|
import { brandingApi } from '../api/branding';
|
||||||
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
|
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
|
||||||
|
|
||||||
@@ -33,7 +34,17 @@ export default function TelegramRedirect() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore();
|
const {
|
||||||
|
loginWithTelegram,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading: authLoading,
|
||||||
|
} = useAuthStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
loginWithTelegram: state.loginWithTelegram,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading');
|
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading');
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [retryCount, setRetryCount] = useState(() => {
|
const [retryCount, setRetryCount] = useState(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useSearchParams, Link, useNavigate } from 'react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
import { consumeCampaignSlug } from '../utils/campaign';
|
import { consumeCampaignSlug } from '../utils/campaign';
|
||||||
import { tokenStorage } from '../utils/token';
|
import { tokenStorage } from '../utils/token';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
@@ -13,7 +14,13 @@ export default function VerifyEmail() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
|
const { setTokens, setUser, checkAdminStatus } = useAuthStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
setTokens: state.setTokens,
|
||||||
|
setUser: state.setUser,
|
||||||
|
checkAdminStatus: state.checkAdminStatus,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const hasVerified = useRef(false);
|
const hasVerified = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export type { WSMessage } from './WebSocketContext';
|
|||||||
const isDev = import.meta.env.DEV;
|
const isDev = import.meta.env.DEV;
|
||||||
|
|
||||||
export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { accessToken, isAuthenticated } = useAuthStore();
|
const accessToken = useAuthStore((state) => state.accessToken);
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|||||||
@@ -192,7 +192,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global Noise Texture */
|
/* Global Noise Texture — no mix-blend-mode to avoid fullscreen GPU compositing */
|
||||||
body::before {
|
body::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -201,7 +201,13 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||||
opacity: 0.03;
|
opacity: 0.03;
|
||||||
mix-blend-mode: overlay;
|
}
|
||||||
|
|
||||||
|
/* Disable noise on mobile to save GPU */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
body::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Optimize main scrollable content */
|
/* Optimize main scrollable content */
|
||||||
@@ -440,9 +446,7 @@ img.twemoji {
|
|||||||
/* Glass effect - Dark (optimized for mobile) */
|
/* Glass effect - Dark (optimized for mobile) */
|
||||||
.glass {
|
.glass {
|
||||||
@apply border border-dark-700/30 bg-dark-900/80;
|
@apply border border-dark-700/30 bg-dark-900/80;
|
||||||
/* GPU acceleration */
|
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
will-change: transform;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enable backdrop-blur only on desktop where it's performant */
|
/* Enable backdrop-blur only on desktop where it's performant */
|
||||||
@@ -1431,13 +1435,13 @@ input[type='checkbox']:hover:not(:checked) {
|
|||||||
animation: wheel-glow 2s ease-in-out infinite;
|
animation: wheel-glow 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toast progress bar animation */
|
/* Toast progress bar animation — use scaleX instead of width to avoid layout thrashing */
|
||||||
@keyframes shrink {
|
@keyframes shrink {
|
||||||
from {
|
from {
|
||||||
width: 100%;
|
transform: scaleX(1);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
width: 0%;
|
transform: scaleX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,10 +1458,8 @@ input[type='checkbox']:hover:not(:checked) {
|
|||||||
.wave-blob {
|
.wave-blob {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
filter: blur(60px); /* Reduced from 80px for better performance */
|
filter: blur(60px);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
/* GPU acceleration */
|
|
||||||
will-change: transform;
|
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
@@ -1644,22 +1646,18 @@ input[type='checkbox']:hover:not(:checked) {
|
|||||||
@keyframes backdropFadeIn {
|
@keyframes backdropFadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
backdrop-filter: blur(0);
|
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes backdropFadeOut {
|
@keyframes backdropFadeOut {
|
||||||
from {
|
from {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
backdrop-filter: blur(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,24 +158,73 @@ export const tokenStorage = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract Telegram user ID from raw initData string.
|
||||||
|
* Does NOT validate cryptographic signature — used only for client-side identity comparison.
|
||||||
|
*/
|
||||||
|
function extractTelegramUserId(initData: string): string | null {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(initData);
|
||||||
|
const userJson = params.get('user');
|
||||||
|
if (!userJson) return null;
|
||||||
|
const user = JSON.parse(userJson);
|
||||||
|
return user.id != null ? String(user.id) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TG_USER_ID_KEY = 'tg_user_id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect Telegram account switch and clear stale auth data.
|
||||||
|
*
|
||||||
|
* Telegram Mini App WebView shares localStorage across accounts on the same device
|
||||||
|
* (confirmed bug on Desktop, likely on mobile too). This means that when user A logs in,
|
||||||
|
* then user B opens the same Mini App, user A's refresh_token persists in localStorage.
|
||||||
|
*
|
||||||
|
* The old approach stored initData in sessionStorage for comparison, but sessionStorage
|
||||||
|
* is cleared on tab close — so the comparison never triggers between separate openings.
|
||||||
|
*
|
||||||
|
* Fix: store the Telegram user ID in localStorage (survives tab close) and compare it
|
||||||
|
* with the fresh initData on every app launch.
|
||||||
|
*/
|
||||||
export function clearStaleSessionIfNeeded(freshInitData: string | null): void {
|
export function clearStaleSessionIfNeeded(freshInitData: string | null): void {
|
||||||
if (!freshInitData) return;
|
if (!freshInitData) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stored = sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT);
|
const currentTgUserId = extractTelegramUserId(freshInitData);
|
||||||
|
|
||||||
if (stored && stored !== freshInitData) {
|
// PRIMARY CHECK: compare Telegram user ID stored in localStorage (survives tab close)
|
||||||
// New Telegram session (different user) — clear all auth tokens
|
const storedTgUserId = localStorage.getItem(TG_USER_ID_KEY);
|
||||||
|
if (storedTgUserId && currentTgUserId && storedTgUserId !== currentTgUserId) {
|
||||||
|
// Account switch detected — purge all auth data
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
|
sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.USER);
|
sessionStorage.removeItem(TOKEN_KEYS.USER);
|
||||||
localStorage.removeItem(TOKEN_KEYS.REFRESH);
|
localStorage.removeItem(TOKEN_KEYS.REFRESH);
|
||||||
|
localStorage.removeItem('cabinet-auth');
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECONDARY CHECK: same-session switch (initData changed without tab close)
|
||||||
|
const storedInitData = sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT);
|
||||||
|
if (storedInitData && storedInitData !== freshInitData) {
|
||||||
|
sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
|
||||||
|
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
||||||
|
sessionStorage.removeItem(TOKEN_KEYS.USER);
|
||||||
|
localStorage.removeItem(TOKEN_KEYS.REFRESH);
|
||||||
|
localStorage.removeItem('cabinet-auth');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist current Telegram user ID for future comparisons
|
||||||
|
if (currentTgUserId) {
|
||||||
|
localStorage.setItem(TG_USER_ID_KEY, currentTgUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData);
|
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData);
|
||||||
localStorage.removeItem(TOKEN_KEYS.TELEGRAM_INIT);
|
localStorage.removeItem(TOKEN_KEYS.TELEGRAM_INIT);
|
||||||
} catch {
|
} catch {
|
||||||
// Storage недоступен
|
// Storage not available
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user