From 0039a8a9b300b9201dcaec2ffe3a6337e81a8e67 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 23:36:05 +0300 Subject: [PATCH 1/4] Implement logo preloading on page load and enhance branding image display with fade-in effect --- src/api/branding.ts | 36 ++++++++++++++++ src/components/layout/Layout.tsx | 25 ++++++++--- src/main.tsx | 4 ++ src/pages/Login.tsx | 74 ++++++++++---------------------- 4 files changed, 82 insertions(+), 57 deletions(-) diff --git a/src/api/branding.ts b/src/api/branding.ts index 0067175..8d21793 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -12,6 +12,7 @@ export interface AnimationEnabled { } const BRANDING_CACHE_KEY = 'cabinet_branding' +const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded' // Get cached branding from localStorage export const getCachedBranding = (): BrandingInfo | null => { @@ -35,6 +36,41 @@ export const setCachedBranding = (branding: BrandingInfo) => { } } +// Preload logo image for instant display +export const preloadLogo = (branding: BrandingInfo): Promise => { + return new Promise((resolve) => { + if (!branding.has_custom_logo || !branding.logo_url) { + resolve() + return + } + + const logoUrl = `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}` + + // Check if already preloaded in this session + const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY) + if (preloaded === logoUrl) { + resolve() + return + } + + const img = new Image() + img.onload = () => { + sessionStorage.setItem(LOGO_PRELOADED_KEY, logoUrl) + resolve() + } + img.onerror = () => resolve() + img.src = logoUrl + }) +} + +// Initialize logo preload from cache on page load +export const initLogoPreload = () => { + const cached = getCachedBranding() + if (cached) { + preloadLogo(cached) + } +} + export const brandingApi = { // Get current branding (public, no auth required) getBranding: async (): Promise => { diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 01cfd90..3c242ed 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -9,7 +9,7 @@ import TicketNotificationBell from '../TicketNotificationBell' import AnimatedBackground from '../AnimatedBackground' import { contestsApi } from '../../api/contests' import { pollsApi } from '../../api/polls' -import { brandingApi, getCachedBranding, setCachedBranding } from '../../api/branding' +import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo } from '../../api/branding' import { wheelApi } from '../../api/wheel' import { themeColorsApi } from '../../api/themeColors' import { promoApi } from '../../api/promo' @@ -167,12 +167,17 @@ export default function Layout({ children }: LayoutProps) { } }, [mobileMenuOpen]) + // State to track if logo image has loaded + const [logoLoaded, setLogoLoaded] = useState(false) + // Fetch branding settings with localStorage cache for instant load const { data: branding } = useQuery({ queryKey: ['branding'], queryFn: async () => { const data = await brandingApi.getBranding() setCachedBranding(data) // Update cache + // Preload logo in background + preloadLogo(data) return data }, initialData: getCachedBranding() ?? undefined, // Use cached data immediately @@ -286,11 +291,19 @@ export default function Layout({ children }: LayoutProps) {
{/* Logo */} -
- {hasCustomLogo && logoUrl ? ( - {appName - ) : ( - {logoLetter} +
+ {/* Always show letter as fallback */} + + {logoLetter} + + {/* Logo image with smooth fade-in */} + {hasCustomLogo && logoUrl && ( + {appName setLogoLoaded(true)} + /> )}
{appName && ( diff --git a/src/main.tsx b/src/main.tsx index 6297c78..8deee7f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,9 +5,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' import { ThemeColorsProvider } from './providers/ThemeColorsProvider' import { ToastProvider } from './components/Toast' +import { initLogoPreload } from './api/branding' import './i18n' import './styles/globals.css' +// Preload logo from cache immediately on page load +initLogoPreload() + const queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index d6a8c85..7b98c03 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -3,47 +3,11 @@ import { useNavigate, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../store/auth' -import { brandingApi, type BrandingInfo } from '../api/branding' +import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, type BrandingInfo } from '../api/branding' import { getAndClearReturnUrl } from '../utils/token' import LanguageSwitcher from '../components/LanguageSwitcher' import TelegramLoginButton from '../components/TelegramLoginButton' -const BRANDING_CACHE_KEY = 'cabinet-branding-cache' -const BRANDING_CACHE_TTL = 1000 * 60 * 60 // 1 hour - -const getCachedBranding = (): BrandingInfo | undefined => { - if (typeof window === 'undefined') { - return undefined - } - try { - const raw = localStorage.getItem(BRANDING_CACHE_KEY) - if (!raw) return undefined - const parsed = JSON.parse(raw) as { data?: BrandingInfo; timestamp?: number } - if (!parsed?.data || !parsed.timestamp) return undefined - if (Date.now() - parsed.timestamp > BRANDING_CACHE_TTL) { - localStorage.removeItem(BRANDING_CACHE_KEY) - return undefined - } - return parsed.data - } catch { - return undefined - } -} - -const cacheBranding = (data: BrandingInfo) => { - if (typeof window === 'undefined') { - return - } - try { - localStorage.setItem( - BRANDING_CACHE_KEY, - JSON.stringify({ data, timestamp: Date.now() }) - ) - } catch { - // Ignore storage errors (e.g., private mode) - } -} - export default function Login() { const { t } = useTranslation() const navigate = useNavigate() @@ -55,6 +19,7 @@ export default function Login() { const [error, setError] = useState('') const [isLoading, setIsLoading] = useState(false) const [isTelegramWebApp, setIsTelegramWebApp] = useState(false) + const [logoLoaded, setLogoLoaded] = useState(false) // Получаем URL для возврата после авторизации const getReturnUrl = useCallback(() => { @@ -72,22 +37,21 @@ export default function Login() { return '/' }, [location.state]) - // Fetch branding + // Fetch branding with unified cache const cachedBranding = useMemo(() => getCachedBranding(), []) const { data: branding } = useQuery({ queryKey: ['branding'], - queryFn: brandingApi.getBranding, + queryFn: async () => { + const data = await brandingApi.getBranding() + setCachedBranding(data) + preloadLogo(data) + return data + }, staleTime: 60000, - placeholderData: cachedBranding, + initialData: cachedBranding ?? undefined, }) - useEffect(() => { - if (branding) { - cacheBranding(branding) - } - }, [branding]) - const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '' const appName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN') const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V' @@ -168,11 +132,19 @@ export default function Login() {
{/* Logo */}
-
- {branding?.has_custom_logo && logoUrl ? ( - {appName - ) : ( - {appLogo} +
+ {/* Always show letter as fallback */} + + {appLogo} + + {/* Logo image with smooth fade-in */} + {branding?.has_custom_logo && logoUrl && ( + {appName setLogoLoaded(true)} + /> )}
{appName && ( From bdfc74534ab726c1a4cfd3424225b899298d9644 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 23:44:02 +0300 Subject: [PATCH 2/4] Refactor PeriodTariffModal to remove server traffic limits and add custom days and traffic options for flexible tariffs --- src/pages/AdminTariffs.tsx | 241 +++++++++++++++++++++++-------------- 1 file changed, 148 insertions(+), 93 deletions(-) diff --git a/src/pages/AdminTariffs.tsx b/src/pages/AdminTariffs.tsx index 7c7d514..eb7a2ee 100644 --- a/src/pages/AdminTariffs.tsx +++ b/src/pages/AdminTariffs.tsx @@ -9,8 +9,7 @@ import { TariffCreateRequest, TariffUpdateRequest, PeriodPrice, - ServerInfo, - ServerTrafficLimit + ServerInfo } from '../api/tariffs' // Icons @@ -146,9 +145,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri tariff?.period_prices?.length ? tariff.period_prices : [] ) const [selectedSquads, setSelectedSquads] = useState(tariff?.allowed_squads || []) - const [serverTrafficLimits, setServerTrafficLimits] = useState>( - tariff?.server_traffic_limits || {} - ) // Докупка трафика const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false) const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0) @@ -159,6 +155,18 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri // Режим сброса трафика const [trafficResetMode, setTrafficResetMode] = useState(tariff?.traffic_reset_mode || null) + // Плавающий тариф - произвольное количество дней + const [customDaysEnabled, setCustomDaysEnabled] = useState(tariff?.custom_days_enabled || false) + const [pricePerDayKopeks, setPricePerDayKopeks] = useState(tariff?.price_per_day_kopeks || 0) + const [minDays, setMinDays] = useState(tariff?.min_days || 1) + const [maxDays, setMaxDays] = useState(tariff?.max_days || 365) + + // Плавающий тариф - произвольный трафик + const [customTrafficEnabled, setCustomTrafficEnabled] = useState(tariff?.custom_traffic_enabled || false) + const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState(tariff?.traffic_price_per_gb_kopeks || 0) + const [minTrafficGb, setMinTrafficGb] = useState(tariff?.min_traffic_gb || 1) + const [maxTrafficGb, setMaxTrafficGb] = useState(tariff?.max_traffic_gb || 1000) + // Новый период для добавления const [newPeriodDays, setNewPeriodDays] = useState(30) const [newPeriodPrice, setNewPeriodPrice] = useState(300) @@ -166,13 +174,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri const [activeTab, setActiveTab] = useState<'basic' | 'periods' | 'servers' | 'extra'>('basic') const handleSubmit = () => { - const filteredLimits: Record = {} - for (const uuid of selectedSquads) { - if (serverTrafficLimits[uuid] && serverTrafficLimits[uuid].traffic_limit_gb > 0) { - filteredLimits[uuid] = serverTrafficLimits[uuid] - } - } - const data: TariffCreateRequest | TariffUpdateRequest = { name, description: description || undefined, @@ -183,24 +184,25 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri tier_level: tierLevel, period_prices: periodPrices.filter(p => p.price_kopeks > 0), allowed_squads: selectedSquads, - server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {}, traffic_topup_enabled: trafficTopupEnabled, traffic_topup_packages: trafficTopupPackages, max_topup_traffic_gb: maxTopupTrafficGb, is_daily: false, daily_price_kopeks: 0, traffic_reset_mode: trafficResetMode, + // Плавающий тариф + custom_days_enabled: customDaysEnabled, + price_per_day_kopeks: pricePerDayKopeks, + min_days: minDays, + max_days: maxDays, + custom_traffic_enabled: customTrafficEnabled, + traffic_price_per_gb_kopeks: trafficPricePerGbKopeks, + min_traffic_gb: minTrafficGb, + max_traffic_gb: maxTrafficGb, } onSave(data) } - const updateServerTrafficLimit = (uuid: string, limitGb: number) => { - setServerTrafficLimits(prev => ({ - ...prev, - [uuid]: { traffic_limit_gb: limitGb } - })) - } - const toggleServer = (uuid: string) => { setSelectedSquads(prev => prev.includes(uuid) @@ -430,27 +432,24 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri {activeTab === 'servers' && (

- Выберите серверы, доступные на этом тарифе. Можно задать индивидуальный лимит трафика для каждого. + Выберите серверы, доступные на этом тарифе.

{servers.length === 0 ? (

Нет доступных серверов

) : ( servers.map(server => { const isSelected = selectedSquads.includes(server.squad_uuid) - const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0 return (
toggleServer(server.squad_uuid)} + className={`p-3 rounded-lg transition-colors cursor-pointer ${ isSelected ? 'bg-accent-500/20 border border-accent-500/50' : 'bg-dark-700 hover:bg-dark-600 border border-transparent' }`} > -
toggleServer(server.squad_uuid)} - className="flex items-center gap-3 cursor-pointer" - > +
{server.country_code} )}
- {isSelected && ( -
- Лимит трафика: - e.stopPropagation()} - onChange={e => { - e.stopPropagation() - updateServerTrafficLimit(server.squad_uuid, Math.max(0, parseInt(e.target.value) || 0)) - }} - className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-accent-500" - min={0} - placeholder="0" - /> - ГБ - {serverLimit === 0 && ( - (использовать общий) - )} -
- )}
) }) @@ -585,6 +563,124 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri )}
+ {/* Плавающий тариф - произвольное количество дней */} +
+
+
+

Произвольное количество дней

+

Пользователь сам выбирает срок подписки

+
+ +
+ {customDaysEnabled && ( +
+
+ Цена за день: + setPricePerDayKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={0} + step={0.1} + /> + +
+
+ Мин. дней: + setMinDays(Math.max(1, parseInt(e.target.value) || 1))} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={1} + /> +
+
+ Макс. дней: + setMaxDays(Math.max(1, parseInt(e.target.value) || 1))} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={1} + /> +
+
+ )} +
+ + {/* Плавающий тариф - произвольный трафик */} +
+
+
+

Произвольный объём трафика

+

Пользователь сам выбирает объём трафика при покупке

+
+ +
+ {customTrafficEnabled && ( +
+
+ Цена за 1 ГБ: + setTrafficPricePerGbKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={0} + step={0.1} + /> + +
+
+ Мин. ГБ: + setMinTrafficGb(Math.max(1, parseInt(e.target.value) || 1))} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={1} + /> +
+
+ Макс. ГБ: + setMaxTrafficGb(Math.max(1, parseInt(e.target.value) || 1))} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={1} + /> +
+
+ )} +
+ {/* Режим сброса трафика */}

Режим сброса трафика

@@ -636,7 +732,7 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri + )} + {/* Theme toggle button - only show if both themes are enabled */} {canToggle && (