From 540931750163c945b333e8995de2de65e80c8bde Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 07:17:49 +0300 Subject: [PATCH 01/31] Add VITE_APP_VERSION environment variable and integrate versioning into the application - Introduced VITE_APP_VERSION in Dockerfile and GitHub workflows to capture the version from Git tags. - Updated vite.config.ts to define a global constant for the app version. - Enhanced Layout component to display the app version in the footer for admin pages. - Added logic to conditionally render the LanguageSwitcher based on active promotions. --- .github/workflows/docker.yml | 1 + .github/workflows/release.yml | 1 + Dockerfile | 2 + src/components/layout/Layout.tsx | 70 ++++++++++++++++++++++---------- src/vite-env.d.ts | 3 ++ vite.config.ts | 17 ++++++++ 6 files changed, 73 insertions(+), 21 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 57fbd02..00c644b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -62,3 +62,4 @@ jobs: VITE_TELEGRAM_BOT_USERNAME= VITE_APP_NAME=Cabinet VITE_APP_LOGO=V + VITE_APP_VERSION=${{ github.ref_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe4ab04..8122984 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,7 @@ jobs: VITE_TELEGRAM_BOT_USERNAME: VITE_APP_NAME: Bedolaga Cabinet VITE_APP_LOGO: V + VITE_APP_VERSION: ${{ github.ref_name }} - name: Create dist archive run: | diff --git a/Dockerfile b/Dockerfile index 8293e10..47c7a63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,12 +18,14 @@ ARG VITE_API_URL=/api ARG VITE_TELEGRAM_BOT_USERNAME ARG VITE_APP_NAME=Cabinet ARG VITE_APP_LOGO=V +ARG VITE_APP_VERSION # Set environment variables for build ENV VITE_API_URL=$VITE_API_URL ENV VITE_TELEGRAM_BOT_USERNAME=$VITE_TELEGRAM_BOT_USERNAME ENV VITE_APP_NAME=$VITE_APP_NAME ENV VITE_APP_LOGO=$VITE_APP_LOGO +ENV VITE_APP_VERSION=$VITE_APP_VERSION # Build the application RUN npm run build diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index ad725d1..d3a4734 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -11,6 +11,7 @@ import { pollsApi } from '../../api/polls' import { brandingApi } from '../../api/branding' import { wheelApi } from '../../api/wheel' import { themeColorsApi } from '../../api/themeColors' +import { promoApi } from '../../api/promo' import { useTheme } from '../../hooks/useTheme' // Fallback branding from environment variables @@ -210,6 +211,17 @@ export default function Layout({ children }: LayoutProps) { retry: false, }) + // Fetch active discount to determine mobile layout + const { data: activeDiscount } = useQuery({ + queryKey: ['active-discount'], + queryFn: promoApi.getActiveDiscount, + enabled: isAuthenticated, + staleTime: 30000, + }) + + // Check if promo is active (to hide language switcher on mobile) + const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent + const navItems = useMemo(() => { const items = [ { path: '/', label: t('nav.dashboard'), icon: HomeIcon }, @@ -339,7 +351,10 @@ export default function Layout({ children }: LayoutProps) { - + {/* Hide language switcher on mobile when promo is active */} +
+ +
{/* Profile - Desktop */}
@@ -389,29 +404,33 @@ export default function Layout({ children }: LayoutProps) {
{/* User info */} -
- {userPhotoUrl ? ( - Avatar { - e.currentTarget.style.display = 'none' - e.currentTarget.nextElementSibling?.classList.remove('hidden') - }} - /> - ) : null} -
- -
-
-
- {user?.first_name || user?.username} +
+
+ {userPhotoUrl ? ( + Avatar { + e.currentTarget.style.display = 'none' + e.currentTarget.nextElementSibling?.classList.remove('hidden') + }} + /> + ) : null} +
+
-
- @{user?.username || `ID: ${user?.telegram_id}`} +
+
+ {user?.first_name || user?.username} +
+
+ @{user?.username || `ID: ${user?.telegram_id}`} +
+ {/* Language switcher in mobile menu when promo is active */} + {isPromoActive && }
{/* Nav items */} @@ -480,6 +499,15 @@ export default function Layout({ children }: LayoutProps) {
{children}
+ + {/* Version footer for admin pages */} + {isAdminActive() && ( +
+ + Cabinet {__APP_VERSION__} + +
+ )} {/* Mobile Bottom Navigation - only core items */} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index c61a7b6..8add16d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,8 @@ /// +// Global constant defined in vite.config.ts +declare const __APP_VERSION__: string + interface ImportMetaEnv { readonly VITE_API_URL: string readonly VITE_TELEGRAM_BOT_USERNAME?: string diff --git a/vite.config.ts b/vite.config.ts index 01d8f8a..45054e0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,26 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { execSync } from 'child_process' + +// Get version from Git tag or fallback to 'dev' +function getGitVersion(): string { + try { + // Try to get version from git tag + return execSync('git describe --tags --always', { encoding: 'utf-8' }).trim() + } catch { + return 'dev' + } +} + +const appVersion = process.env.VITE_APP_VERSION || getGitVersion() // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + // Define global constants + define: { + __APP_VERSION__: JSON.stringify(appVersion), + }, // Base path - use '/' for standalone Docker deployment // Change to '/cabinet/' if serving from a sub-path base: '/', From 638f1d54560fcd558dc867c03cef246b36d15655 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 07:39:09 +0300 Subject: [PATCH 02/31] Refactor Dockerfile and Vite configuration to remove VITE_APP_VERSION and streamline build process - Removed VITE_APP_VERSION from Dockerfile and GitHub workflows. - Simplified Vite configuration by eliminating the Git version retrieval function and global constant for app version. - Updated ColorPicker and other components to enhance accessibility with aria-labels. - Introduced unique identifiers for buttons in the AppEditorModal for better React key management. - Improved error handling in the Login component to provide user-friendly messages without exposing sensitive data. --- .github/workflows/docker.yml | 1 - .github/workflows/release.yml | 1 - Dockerfile | 3 - src/api/adminApps.ts | 1 + src/api/wheel.ts | 31 +- src/components/ColorPicker.tsx | 17 +- src/components/ConnectionModal.tsx | 2 +- src/components/InsufficientBalancePrompt.tsx | 2 +- src/components/Onboarding.tsx | 4 +- src/components/TopUpModal.tsx | 83 ++++- src/components/layout/Layout.tsx | 12 +- src/pages/AdminApps.tsx | 10 +- src/pages/AdminDashboard.tsx | 4 +- src/pages/AdminPanel.tsx | 348 +++++++++++-------- src/pages/AdminWheel.tsx | 4 +- src/pages/DeepLinkRedirect.tsx | 34 +- src/pages/Login.tsx | 16 +- src/pages/Support.tsx | 52 ++- src/types/index.ts | 1 + src/vite-env.d.ts | 3 - vite.config.ts | 17 - 21 files changed, 401 insertions(+), 245 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 00c644b..57fbd02 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -62,4 +62,3 @@ jobs: VITE_TELEGRAM_BOT_USERNAME= VITE_APP_NAME=Cabinet VITE_APP_LOGO=V - VITE_APP_VERSION=${{ github.ref_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8122984..fe4ab04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,6 @@ jobs: VITE_TELEGRAM_BOT_USERNAME: VITE_APP_NAME: Bedolaga Cabinet VITE_APP_LOGO: V - VITE_APP_VERSION: ${{ github.ref_name }} - name: Create dist archive run: | diff --git a/Dockerfile b/Dockerfile index 47c7a63..de296d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ WORKDIR /app COPY package.json package-lock.json* ./ # Install dependencies -# Use npm install if no lock file, npm ci if lock file exists RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi # Copy source code @@ -18,14 +17,12 @@ ARG VITE_API_URL=/api ARG VITE_TELEGRAM_BOT_USERNAME ARG VITE_APP_NAME=Cabinet ARG VITE_APP_LOGO=V -ARG VITE_APP_VERSION # Set environment variables for build ENV VITE_API_URL=$VITE_API_URL ENV VITE_TELEGRAM_BOT_USERNAME=$VITE_TELEGRAM_BOT_USERNAME ENV VITE_APP_NAME=$VITE_APP_NAME ENV VITE_APP_LOGO=$VITE_APP_LOGO -ENV VITE_APP_VERSION=$VITE_APP_VERSION # Build the application RUN npm run build diff --git a/src/api/adminApps.ts b/src/api/adminApps.ts index 51a69af..b57a78c 100644 --- a/src/api/adminApps.ts +++ b/src/api/adminApps.ts @@ -9,6 +9,7 @@ export interface LocalizedText { } export interface AppButton { + id?: string // Unique identifier for React key (client-side only) buttonLink: string buttonText: LocalizedText } diff --git a/src/api/wheel.ts b/src/api/wheel.ts index c076e3b..7511f94 100644 --- a/src/api/wheel.ts +++ b/src/api/wheel.ts @@ -98,6 +98,22 @@ export interface WheelPrizeAdmin { updated_at: string | null } +// Type for creating a new prize (excludes id, config_id which are auto-generated) +export interface CreateWheelPrizeData { + prize_type: string + prize_value: number + display_name: string + emoji?: string + color?: string + prize_value_kopeks: number + sort_order?: number + manual_probability?: number | null + is_active?: boolean + promo_balance_bonus_kopeks?: number + promo_subscription_days?: number + promo_traffic_gb?: number +} + export interface AdminWheelConfig { id: number is_enabled: boolean @@ -223,20 +239,7 @@ export const adminWheelApi = { }, // Create prize - createPrize: async (data: { - prize_type: string - prize_value: number - display_name: string - emoji?: string - color?: string - prize_value_kopeks: number - sort_order?: number - manual_probability?: number | null - is_active?: boolean - promo_balance_bonus_kopeks?: number - promo_subscription_days?: number - promo_traffic_gb?: number - }): Promise => { + createPrize: async (data: CreateWheelPrizeData): Promise => { const response = await apiClient.post('/cabinet/admin/wheel/prizes', data) return response.data }, diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 9889dd0..e841b7a 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -81,14 +81,15 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C {description &&

{description}

}
- {/* Color preview button */} + {/* Color preview button - min 44px for touch accessibility */}
diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 2ac3e2b..ef89184 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -286,7 +286,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {

{t('subscription.connection.title')}

- @@ -382,6 +384,8 @@ export default function Layout({ children }: LayoutProps) { @@ -500,14 +504,6 @@ export default function Layout({ children }: LayoutProps) { {children}
- {/* Version footer for admin pages */} - {isAdminActive() && ( -
- - Cabinet {__APP_VERSION__} - -
- )} {/* Mobile Bottom Navigation - only core items */} diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index 0a25598..b702355 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -189,7 +189,13 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa const addButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => { const step = editedApp[stepKey] || emptyAppStep() const buttons = step.buttons || [] - updateButtons(stepKey, [...buttons, { buttonLink: '', buttonText: emptyLocalizedText() }]) + // Generate unique id for React key + const newButton: AppButton = { + id: `btn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + buttonLink: '', + buttonText: emptyLocalizedText() + } + updateButtons(stepKey, [...buttons, newButton]) } const removeButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', index: number) => { @@ -253,7 +259,7 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa
{buttons.map((button, index) => ( -
+
{t('admin.apps.button')} #{index + 1} @@ -141,6 +142,29 @@ export default function Support() { const createFileInputRef = useRef(null) const replyFileInputRef = useRef(null) + // Cleanup function to revoke object URLs and prevent memory leaks + const clearAttachment = useCallback(( + attachment: MediaAttachment | null, + setAttachment: (a: MediaAttachment | null) => void + ) => { + if (attachment?.preview) { + URL.revokeObjectURL(attachment.preview) + } + setAttachment(null) + }, []) + + // Cleanup on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (createAttachment?.preview) { + URL.revokeObjectURL(createAttachment.preview) + } + if (replyAttachment?.preview) { + URL.revokeObjectURL(replyAttachment.preview) + } + } + }, []) // Empty deps - only run on unmount + // Get support configuration const { data: supportConfig, isLoading: configLoading } = useQuery({ queryKey: ['support-config'], @@ -162,8 +186,14 @@ export default function Support() { // Handle file selection const handleFileSelect = async ( file: File, - setAttachment: (a: MediaAttachment | null) => void + setAttachment: (a: MediaAttachment | null) => void, + currentAttachment: MediaAttachment | null ) => { + // Revoke old object URL before creating new one + if (currentAttachment?.preview) { + URL.revokeObjectURL(currentAttachment.preview) + } + // Validate file type const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] if (!allowedTypes.includes(file.type)) { @@ -224,7 +254,7 @@ export default function Support() { setShowCreateForm(false) setNewTitle('') setNewMessage('') - setCreateAttachment(null) + clearAttachment(createAttachment, setCreateAttachment) setSelectedTicket(ticket) }, }) @@ -242,7 +272,7 @@ export default function Support() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] }) setReplyMessage('') - setReplyAttachment(null) + clearAttachment(replyAttachment, setReplyAttachment) }, }) @@ -443,7 +473,7 @@ export default function Support() { onClick={() => { setShowCreateForm(true) setSelectedTicket(null) - setCreateAttachment(null) + clearAttachment(createAttachment, setCreateAttachment) }} className="btn-primary" > @@ -469,7 +499,7 @@ export default function Support() { onClick={() => { setSelectedTicket(ticket as unknown as TicketDetail) setShowCreateForm(false) - setReplyAttachment(null) + clearAttachment(replyAttachment, setReplyAttachment) }} className={`w-full text-left p-4 rounded-xl border transition-all ${ selectedTicket?.id === ticket.id @@ -555,14 +585,14 @@ export default function Support() { className="hidden" onChange={(e) => { const file = e.target.files?.[0] - if (file) handleFileSelect(file, setCreateAttachment) + if (file) handleFileSelect(file, setCreateAttachment, createAttachment) e.target.value = '' }} /> {createAttachment ? ( setCreateAttachment(null)} + onRemove={() => clearAttachment(createAttachment, setCreateAttachment)} /> ) : (
@@ -347,7 +366,9 @@ export default function AdminDashboard() {
- +
+ +

{t('adminDashboard.nodes.title')}

@@ -395,7 +416,9 @@ export default function AdminDashboard() { {/* Revenue Chart */}

- +
+ +

{t('adminDashboard.revenue.title')}

{t('adminDashboard.revenue.last7Days')}

@@ -417,7 +440,9 @@ export default function AdminDashboard() { {/* Subscription Stats */}
- +
+ +

{t('adminDashboard.subscriptions.title')}

{t('adminDashboard.subscriptions.subtitle')}

@@ -478,7 +503,9 @@ export default function AdminDashboard() { {stats?.servers && (
- +
+ +

{t('adminDashboard.servers.title')}

@@ -506,7 +533,9 @@ export default function AdminDashboard() { {stats?.tariff_stats && stats.tariff_stats.tariffs.length > 0 && (
- +
+ +

{t('adminDashboard.tariffs.title')}

{t('adminDashboard.tariffs.subtitle')}

diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index e386d8a..2743827 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -1,393 +1,331 @@ import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' -// Icons - smaller versions for mobile -const TicketIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +// Modern icons with consistent styling +const ChartBarIcon = () => ( + + + +) + +const BanknotesIcon = () => ( + + + +) + +const UsersIcon = () => ( + + + +) + +const ChatBubbleIcon = () => ( + + + +) + +const NoSymbolIcon = () => ( + + + +) + +const CreditCardIcon = () => ( + + + +) + +const TicketIcon = () => ( + ) -const CogIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const GiftIcon = () => ( + + + +) + +const MegaphoneIcon = () => ( + + + +) + +const PaperAirplaneIcon = () => ( + + + +) + +const SparklesIcon = () => ( + + + +) + +const CogIcon = () => ( + ) -const PhoneIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const DevicePhoneMobileIcon = () => ( + ) -const WheelIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const TariffIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const ServerIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const ServerStackIcon = () => ( + ) -const AdminIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const ChartIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const BanSystemIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const BroadcastIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const PromocodeIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const CampaignIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const PaymentsIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const PromoOffersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const RemnawaveIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - +const CubeTransparentIcon = () => ( + + ) const ChevronRightIcon = () => ( - + ) -interface AdminSection { +interface AdminItem { to: string icon: React.ReactNode - mobileIcon: React.ReactNode title: string description: string - color: string - bgColor: string - textColor: string } -// Mobile compact card -function MobileAdminCard({ to, mobileIcon, title, bgColor, textColor }: AdminSection) { +interface AdminGroup { + id: string + title: string + icon: string + gradient: string + borderColor: string + iconBg: string + iconColor: string + items: AdminItem[] +} + +function AdminCard({ to, icon, title, description, iconBg, iconColor }: AdminItem & { iconBg: string; iconColor: string }) { return ( -
-
- {mobileIcon} -
-
- {title} - - ) -} - -// Desktop card -function DesktopAdminCard({ to, icon, title, description, bgColor, textColor }: AdminSection) { - return ( - -
-
- {icon} -
+
+ {icon}
-

{title}

-

{description}

+

{title}

+

{description}

+
+
+
- ) } -// Section group definition -interface SectionGroup { - title: string - emoji: string - sections: AdminSection[] -} - -export default function AdminPanel() { - const { t } = useTranslation() - - // Grouped admin sections - const sectionGroups: SectionGroup[] = [ - { - title: 'Аналитика', - emoji: '📊', - sections: [ - { - to: '/admin/dashboard', - icon: , - mobileIcon: , - title: t('admin.nav.dashboard'), - description: t('admin.panel.dashboardDesc'), - color: 'success', - bgColor: 'bg-emerald-500/20', - textColor: 'text-emerald-400' - }, - { - to: '/admin/payments', - icon: , - mobileIcon: , - title: t('admin.nav.payments', 'Платежи'), - description: t('admin.panel.paymentsDesc', 'Проверка платежей'), - color: 'lime', - bgColor: 'bg-lime-500/20', - textColor: 'text-lime-400' - }, - ] - }, - { - title: 'Пользователи', - emoji: '👥', - sections: [ - { - to: '/admin/users', - icon: , - mobileIcon: , - title: t('admin.nav.users', 'Пользователи'), - description: t('admin.panel.usersDesc', 'Управление пользователями'), - color: 'indigo', - bgColor: 'bg-indigo-500/20', - textColor: 'text-indigo-400' - }, - { - to: '/admin/tickets', - icon: , - mobileIcon: , - title: t('admin.nav.tickets'), - description: t('admin.panel.ticketsDesc'), - color: 'warning', - bgColor: 'bg-amber-500/20', - textColor: 'text-amber-400' - }, - { - to: '/admin/ban-system', - icon: , - mobileIcon: , - title: t('admin.nav.banSystem'), - description: t('admin.panel.banSystemDesc'), - color: 'error', - bgColor: 'bg-red-500/20', - textColor: 'text-red-400' - }, - ] - }, - { - title: 'Тарифы и продажи', - emoji: '💰', - sections: [ - { - to: '/admin/tariffs', - icon: , - mobileIcon: , - title: t('admin.nav.tariffs'), - description: t('admin.panel.tariffsDesc'), - color: 'info', - bgColor: 'bg-cyan-500/20', - textColor: 'text-cyan-400' - }, - { - to: '/admin/promocodes', - icon: , - mobileIcon: , - title: t('admin.nav.promocodes', 'Промокоды'), - description: t('admin.panel.promocodesDesc', 'Управление промокодами'), - color: 'violet', - bgColor: 'bg-violet-500/20', - textColor: 'text-violet-400' - }, - { - to: '/admin/promo-offers', - icon: , - mobileIcon: , - title: t('admin.nav.promoOffers', 'Промопредложения'), - description: t('admin.panel.promoOffersDesc', 'Персональные скидки и предложения'), - color: 'orange', - bgColor: 'bg-orange-500/20', - textColor: 'text-orange-400' - }, - ] - }, - { - title: 'Маркетинг', - emoji: '📣', - sections: [ - { - to: '/admin/campaigns', - icon: , - mobileIcon: , - title: t('admin.nav.campaigns', 'Кампании'), - description: t('admin.panel.campaignsDesc', 'Рекламные кампании'), - color: 'orange', - bgColor: 'bg-orange-500/20', - textColor: 'text-orange-400' - }, - { - to: '/admin/broadcasts', - icon: , - mobileIcon: , - title: t('admin.nav.broadcasts'), - description: t('admin.panel.broadcastsDesc'), - color: 'orange', - bgColor: 'bg-orange-500/20', - textColor: 'text-orange-400' - }, - { - to: '/admin/wheel', - icon: , - mobileIcon: , - title: t('admin.nav.wheel'), - description: t('admin.panel.wheelDesc'), - color: 'error', - bgColor: 'bg-rose-500/20', - textColor: 'text-rose-400' - }, - ] - }, - { - title: 'Система', - emoji: '⚙️', - sections: [ - { - to: '/admin/settings', - icon: , - mobileIcon: , - title: t('admin.nav.settings'), - description: t('admin.panel.settingsDesc'), - color: 'accent', - bgColor: 'bg-blue-500/20', - textColor: 'text-blue-400' - }, - { - to: '/admin/apps', - icon: , - mobileIcon: , - title: t('admin.nav.apps'), - description: t('admin.panel.appsDesc'), - color: 'success', - bgColor: 'bg-teal-500/20', - textColor: 'text-teal-400' - }, - { - to: '/admin/servers', - icon: , - mobileIcon: , - title: t('admin.nav.servers'), - description: t('admin.panel.serversDesc'), - color: 'purple', - bgColor: 'bg-purple-500/20', - textColor: 'text-purple-400' - }, - { - to: '/admin/remnawave', - icon: , - mobileIcon: , - title: t('admin.nav.remnawave', 'RemnaWave'), - description: t('admin.panel.remnawaveDesc', 'Управление панелью и статистика'), - color: 'purple', - bgColor: 'bg-purple-500/20', - textColor: 'text-purple-400' - }, - ] - }, - ] - - // Flatten all sections for mobile view - const allSections = sectionGroups.flatMap(group => group.sections) - +function GroupSection({ group }: { group: AdminGroup }) { return ( -
- {/* Header - compact on mobile */} -
-
- -
-
-

{t('admin.panel.title')}

-

{t('admin.panel.subtitle')}

+
+ {/* Group Header */} +
+
+ {group.icon} +

{group.title}

- {/* Mobile: Compact grid (all sections) */} -
- {allSections.map((section) => ( - - ))} -
- - {/* Tablet/Desktop: Grouped sections */} -
- {sectionGroups.map((group) => ( -
- {/* Group header */} -
- {group.emoji} -

{group.title}

-
- - {/* Group cards */} -
- {group.sections.map((section) => ( - - ))} -
-
+ {/* Group Items */} +
+ {group.items.map((item) => ( + + ))} +
+
+ ) +} + +export default function AdminPanel() { + const { t } = useTranslation() + + const groups: AdminGroup[] = [ + { + id: 'analytics', + title: t('admin.groups.analytics', 'Аналитика'), + icon: '📊', + gradient: 'from-emerald-500/10 to-emerald-500/5', + borderColor: 'border-emerald-500/20', + iconBg: 'bg-emerald-500/20', + iconColor: 'text-emerald-400', + items: [ + { + to: '/admin/dashboard', + icon: , + title: t('admin.nav.dashboard'), + description: t('admin.panel.dashboardDesc'), + }, + { + to: '/admin/payments', + icon: , + title: t('admin.nav.payments', 'Платежи'), + description: t('admin.panel.paymentsDesc', 'История и проверка платежей'), + }, + ], + }, + { + id: 'users', + title: t('admin.groups.users', 'Пользователи'), + icon: '👥', + gradient: 'from-blue-500/10 to-blue-500/5', + borderColor: 'border-blue-500/20', + iconBg: 'bg-blue-500/20', + iconColor: 'text-blue-400', + items: [ + { + to: '/admin/users', + icon: , + title: t('admin.nav.users', 'Пользователи'), + description: t('admin.panel.usersDesc', 'Управление пользователями'), + }, + { + to: '/admin/tickets', + icon: , + title: t('admin.nav.tickets'), + description: t('admin.panel.ticketsDesc'), + }, + { + to: '/admin/ban-system', + icon: , + title: t('admin.nav.banSystem'), + description: t('admin.panel.banSystemDesc'), + }, + ], + }, + { + id: 'tariffs', + title: t('admin.groups.tariffs', 'Тарифы и продажи'), + icon: '💰', + gradient: 'from-amber-500/10 to-amber-500/5', + borderColor: 'border-amber-500/20', + iconBg: 'bg-amber-500/20', + iconColor: 'text-amber-400', + items: [ + { + to: '/admin/tariffs', + icon: , + title: t('admin.nav.tariffs'), + description: t('admin.panel.tariffsDesc'), + }, + { + to: '/admin/promocodes', + icon: , + title: t('admin.nav.promocodes', 'Промокоды'), + description: t('admin.panel.promocodesDesc', 'Управление промокодами'), + }, + { + to: '/admin/promo-offers', + icon: , + title: t('admin.nav.promoOffers', 'Промопредложения'), + description: t('admin.panel.promoOffersDesc', 'Персональные скидки'), + }, + ], + }, + { + id: 'marketing', + title: t('admin.groups.marketing', 'Маркетинг'), + icon: '📣', + gradient: 'from-rose-500/10 to-rose-500/5', + borderColor: 'border-rose-500/20', + iconBg: 'bg-rose-500/20', + iconColor: 'text-rose-400', + items: [ + { + to: '/admin/campaigns', + icon: , + title: t('admin.nav.campaigns', 'Кампании'), + description: t('admin.panel.campaignsDesc', 'Рекламные кампании'), + }, + { + to: '/admin/broadcasts', + icon: , + title: t('admin.nav.broadcasts'), + description: t('admin.panel.broadcastsDesc'), + }, + { + to: '/admin/wheel', + icon: , + title: t('admin.nav.wheel'), + description: t('admin.panel.wheelDesc'), + }, + ], + }, + { + id: 'system', + title: t('admin.groups.system', 'Система'), + icon: '⚙️', + gradient: 'from-violet-500/10 to-violet-500/5', + borderColor: 'border-violet-500/20', + iconBg: 'bg-violet-500/20', + iconColor: 'text-violet-400', + items: [ + { + to: '/admin/settings', + icon: , + title: t('admin.nav.settings'), + description: t('admin.panel.settingsDesc'), + }, + { + to: '/admin/apps', + icon: , + title: t('admin.nav.apps'), + description: t('admin.panel.appsDesc'), + }, + { + to: '/admin/servers', + icon: , + title: t('admin.nav.servers'), + description: t('admin.panel.serversDesc'), + }, + { + to: '/admin/remnawave', + icon: , + title: t('admin.nav.remnawave', 'RemnaWave'), + description: t('admin.panel.remnawaveDesc', 'Управление панелью'), + }, + ], + }, + ] + + return ( +
+ {/* Header */} +
+

{t('admin.panel.title')}

+

{t('admin.panel.subtitle')}

+
+ + {/* Groups Grid */} +
+ {groups.map((group) => ( + ))}
From 3bab058983a537e1c9b799cc590456e35c572c33 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 07:51:17 +0300 Subject: [PATCH 04/31] Enhance AdminPanel with modern icon components for improved visual consistency - Introduced new icon components for analytics, users, tariffs, marketing, and system sections. - Updated AdminGroup interface to accept React nodes for icons, enhancing flexibility. - Refactored icon rendering in GroupSection for better styling and layout. --- src/pages/AdminPanel.tsx | 48 ++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 2743827..3708998 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -1,6 +1,38 @@ import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' +// Group header icons +const AnalyticsGroupIcon = () => ( + + + +) + +const UsersGroupIcon = () => ( + + + +) + +const TariffsGroupIcon = () => ( + + + +) + +const MarketingGroupIcon = () => ( + + + +) + +const SystemGroupIcon = () => ( + + + + +) + // Modern icons with consistent styling const ChartBarIcon = () => ( @@ -109,7 +141,7 @@ interface AdminItem { interface AdminGroup { id: string title: string - icon: string + icon: React.ReactNode gradient: string borderColor: string iconBg: string @@ -143,7 +175,9 @@ function GroupSection({ group }: { group: AdminGroup }) { {/* Group Header */}
- {group.icon} +
+ {group.icon} +

{group.title}

@@ -170,7 +204,7 @@ export default function AdminPanel() { { id: 'analytics', title: t('admin.groups.analytics', 'Аналитика'), - icon: '📊', + icon: , gradient: 'from-emerald-500/10 to-emerald-500/5', borderColor: 'border-emerald-500/20', iconBg: 'bg-emerald-500/20', @@ -193,7 +227,7 @@ export default function AdminPanel() { { id: 'users', title: t('admin.groups.users', 'Пользователи'), - icon: '👥', + icon: , gradient: 'from-blue-500/10 to-blue-500/5', borderColor: 'border-blue-500/20', iconBg: 'bg-blue-500/20', @@ -222,7 +256,7 @@ export default function AdminPanel() { { id: 'tariffs', title: t('admin.groups.tariffs', 'Тарифы и продажи'), - icon: '💰', + icon: , gradient: 'from-amber-500/10 to-amber-500/5', borderColor: 'border-amber-500/20', iconBg: 'bg-amber-500/20', @@ -251,7 +285,7 @@ export default function AdminPanel() { { id: 'marketing', title: t('admin.groups.marketing', 'Маркетинг'), - icon: '📣', + icon: , gradient: 'from-rose-500/10 to-rose-500/5', borderColor: 'border-rose-500/20', iconBg: 'bg-rose-500/20', @@ -280,7 +314,7 @@ export default function AdminPanel() { { id: 'system', title: t('admin.groups.system', 'Система'), - icon: '⚙️', + icon: , gradient: 'from-violet-500/10 to-violet-500/5', borderColor: 'border-violet-500/20', iconBg: 'bg-violet-500/20', From 34ec53495ecc9d304151405fac8f2ef7c5dbc36d Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 07:57:23 +0300 Subject: [PATCH 05/31] Add files via upload --- src/components/ConnectionModal.tsx | 5 +- src/components/TopUpModal.tsx | 84 ++++++------------------------ 2 files changed, 18 insertions(+), 71 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index ef89184..0d4f22f 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -227,8 +227,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { if (isCustomScheme && tg?.openLink) { // For custom URL schemes - open redirect page in external browser via Telegram + // try_browser: true - показывает диалог для перехода во внешний браузер (важно для мобильных) try { - tg.openLink(redirectUrl, { try_instant_view: false }) + tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) return } catch (e) { console.warn('tg.openLink failed:', e) @@ -286,7 +287,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {

{t('subscription.connection.title')}

- @@ -142,29 +141,6 @@ export default function Support() { const createFileInputRef = useRef(null) const replyFileInputRef = useRef(null) - // Cleanup function to revoke object URLs and prevent memory leaks - const clearAttachment = useCallback(( - attachment: MediaAttachment | null, - setAttachment: (a: MediaAttachment | null) => void - ) => { - if (attachment?.preview) { - URL.revokeObjectURL(attachment.preview) - } - setAttachment(null) - }, []) - - // Cleanup on unmount to prevent memory leaks - useEffect(() => { - return () => { - if (createAttachment?.preview) { - URL.revokeObjectURL(createAttachment.preview) - } - if (replyAttachment?.preview) { - URL.revokeObjectURL(replyAttachment.preview) - } - } - }, []) // Empty deps - only run on unmount - // Get support configuration const { data: supportConfig, isLoading: configLoading } = useQuery({ queryKey: ['support-config'], @@ -186,14 +162,8 @@ export default function Support() { // Handle file selection const handleFileSelect = async ( file: File, - setAttachment: (a: MediaAttachment | null) => void, - currentAttachment: MediaAttachment | null + setAttachment: (a: MediaAttachment | null) => void ) => { - // Revoke old object URL before creating new one - if (currentAttachment?.preview) { - URL.revokeObjectURL(currentAttachment.preview) - } - // Validate file type const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] if (!allowedTypes.includes(file.type)) { @@ -254,7 +224,7 @@ export default function Support() { setShowCreateForm(false) setNewTitle('') setNewMessage('') - clearAttachment(createAttachment, setCreateAttachment) + setCreateAttachment(null) setSelectedTicket(ticket) }, }) @@ -272,7 +242,7 @@ export default function Support() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] }) setReplyMessage('') - clearAttachment(replyAttachment, setReplyAttachment) + setReplyAttachment(null) }, }) @@ -348,7 +318,7 @@ export default function Support() { if (webApp?.openLink) { log.debug('Using openLink') try { - webApp.openLink(webUrl) + webApp.openLink(webUrl, { try_browser: true }) return } catch (e) { log.error('openLink failed:', e) @@ -370,7 +340,7 @@ export default function Support() { buttonAction: () => { const webApp = window.Telegram?.WebApp if (webApp?.openLink) { - webApp.openLink(supportConfig.support_url!) + webApp.openLink(supportConfig.support_url!, { try_browser: true }) } else { window.open(supportConfig.support_url!, '_blank') } @@ -402,7 +372,7 @@ export default function Support() { webApp.openTelegramLink(webUrl) } else if (webApp?.openLink) { log.debug('Fallback using openLink') - webApp.openLink(webUrl) + webApp.openLink(webUrl, { try_browser: true }) } else { log.debug('Fallback using window.open') window.open(webUrl, '_blank') @@ -473,7 +443,7 @@ export default function Support() { onClick={() => { setShowCreateForm(true) setSelectedTicket(null) - clearAttachment(createAttachment, setCreateAttachment) + setCreateAttachment(null) }} className="btn-primary" > @@ -499,7 +469,7 @@ export default function Support() { onClick={() => { setSelectedTicket(ticket as unknown as TicketDetail) setShowCreateForm(false) - clearAttachment(replyAttachment, setReplyAttachment) + setReplyAttachment(null) }} className={`w-full text-left p-4 rounded-xl border transition-all ${ selectedTicket?.id === ticket.id @@ -585,14 +555,14 @@ export default function Support() { className="hidden" onChange={(e) => { const file = e.target.files?.[0] - if (file) handleFileSelect(file, setCreateAttachment, createAttachment) + if (file) handleFileSelect(file, setCreateAttachment) e.target.value = '' }} /> {createAttachment ? ( clearAttachment(createAttachment, setCreateAttachment)} + onRemove={() => setCreateAttachment(null)} /> ) : ( + )} + ) : (
{t('adminDashboard.nodes.noNodes')} @@ -499,36 +600,6 @@ export default function AdminDashboard() {
- {/* Server Stats */} - {stats?.servers && ( -
-
-
- -
-

{t('adminDashboard.servers.title')}

-
-
-
-
{t('adminDashboard.servers.total')}
-
{stats.servers.total_servers}
-
-
-
{t('adminDashboard.servers.available')}
-
{stats.servers.available_servers}
-
-
-
{t('adminDashboard.servers.withConnections')}
-
{stats.servers.servers_with_connections}
-
-
-
{t('adminDashboard.servers.revenue')}
-
{formatAmount(stats.servers.total_revenue_rubles)} {currencySymbol}
-
-
-
- )} - {/* Tariff Stats */} {stats?.tariff_stats && stats.tariff_stats.tariffs.length > 0 && (
@@ -582,6 +653,262 @@ export default function AdminDashboard() {
)} + + {/* Extended Stats Grid */} +
+ {/* Top Referrers */} + {referrers && (referrers.by_earnings.length > 0 || referrers.by_invited.length > 0) && ( +
+
+
+
+ +
+
+

Топ рефералов

+

+ {referrers.total_referrers} реф., {referrers.total_referrals} пригл. +

+
+
+
+ + {/* Tabs */} +
+ + +
+ +
+ {(referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited).slice(0, 5).map((ref, idx) => ( +
+
+ + {idx + 1} + +
+
{ref.display_name}
+ {ref.username &&
@{ref.username}
} +
+
+
+ {referrersTab === 'earnings' ? ( + <> +
{formatAmount(ref.earnings_total_kopeks / 100)} {currencySymbol}
+
{ref.invited_count} пригл.
+ + ) : ( + <> +
{ref.invited_count} чел.
+
{formatAmount(ref.earnings_total_kopeks / 100)} {currencySymbol}
+ + )} +
+
+ ))} +
+ + {/* Period Stats */} +
+
+
Сегодня
+
+ {formatAmount( + (referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited) + .reduce((sum, r) => sum + r.earnings_today_kopeks, 0) / 100 + )} {currencySymbol} +
+
+
+
Неделя
+
+ {formatAmount( + (referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited) + .reduce((sum, r) => sum + r.earnings_week_kopeks, 0) / 100 + )} {currencySymbol} +
+
+
+
Месяц
+
+ {formatAmount( + (referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited) + .reduce((sum, r) => sum + r.earnings_month_kopeks, 0) / 100 + )} {currencySymbol} +
+
+
+
+ )} + + {/* Top Campaigns */} + {campaigns && campaigns.campaigns.length > 0 && ( +
+
+
+ +
+
+

Топ РК ссылок

+

+ {campaigns.total_campaigns} камп., {campaigns.total_registrations} рег. +

+
+
+ +
+ {campaigns.campaigns.slice(0, 5).map((campaign, idx) => ( +
+
+ + {idx + 1} + +
+
{campaign.name}
+
?start={campaign.start_parameter}
+
+
+
+
{formatAmount(campaign.total_revenue_kopeks / 100)} {currencySymbol}
+
+ {campaign.registrations} · {campaign.conversion_rate.toFixed(0)}% +
+
+
+ ))} +
+ +
+
+ Всего от РК + {formatAmount(campaigns.total_revenue_kopeks / 100)} {currencySymbol} +
+
+
+ )} +
+ + {/* Recent Payments */} + {payments && payments.payments.length > 0 && ( +
+
+
+
+ +
+
+

Последние платежи

+

+ Сегодня: {formatAmount(payments.total_today_kopeks / 100)} {currencySymbol} + · За неделю: {formatAmount(payments.total_week_kopeks / 100)} {currencySymbol} +

+
+
+
+ + {/* Desktop Table */} +
+ + + + + + + + + + + + {payments.payments.slice(0, 10).map((payment) => ( + + + + + + + + ))} + +
ПользовательТипСуммаМетодДата
+
{payment.display_name}
+ {payment.username &&
@{payment.username}
} +
+ + {payment.type_display} + + + {formatAmount(payment.amount_rubles)} {currencySymbol} + + {payment.payment_method || '-'} + + + {new Date(payment.created_at).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} + +
+
+ + {/* Mobile Cards */} +
+ {payments.payments.slice(0, 10).map((payment) => ( +
+
+
+ + {payment.type_display} + + {payment.display_name} +
+ + {formatAmount(payment.amount_rubles)} {currencySymbol} + +
+
+ {payment.payment_method || '-'} + + {new Date(payment.created_at).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} + +
+
+ ))} +
+
+ )}
) } From af5d8645a14b2e62c05cc4011406dca6e5db6f95 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 09:32:42 +0300 Subject: [PATCH 10/31] Update promocodes.ts --- src/api/promocodes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts index f2589d9..b1ecb2c 100644 --- a/src/api/promocodes.ts +++ b/src/api/promocodes.ts @@ -2,7 +2,7 @@ import apiClient from './client' // ============== Types ============== -export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group' +export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group' | 'discount' export interface PromoCode { id: number From 704f4d882dabf9a2389d968550bac63b86fb6175 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 09:33:07 +0300 Subject: [PATCH 11/31] Update AdminPromocodes.tsx --- src/pages/AdminPromocodes.tsx | 59 ++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/pages/AdminPromocodes.tsx b/src/pages/AdminPromocodes.tsx index ab7122d..3d5255b 100644 --- a/src/pages/AdminPromocodes.tsx +++ b/src/pages/AdminPromocodes.tsx @@ -87,6 +87,7 @@ const getTypeLabel = (type: PromoCodeType): string => { subscription_days: 'Дни подписки', trial_subscription: 'Тестовая подписка', promo_group: 'Группа скидок', + discount: 'Скидка %', } return labels[type] || type } @@ -97,6 +98,7 @@ const getTypeColor = (type: PromoCodeType): string => { subscription_days: 'bg-blue-500/20 text-blue-400', trial_subscription: 'bg-purple-500/20 text-purple-400', promo_group: 'bg-amber-500/20 text-amber-400', + discount: 'bg-pink-500/20 text-pink-400', } return colors[type] || 'bg-dark-600 text-dark-300' } @@ -146,10 +148,14 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }: const [promoGroupId, setPromoGroupId] = useState(promocode?.promo_group_id || null) const handleSubmit = () => { + // Для discount: balance_bonus_kopeks = процент (целое число), subscription_days = часы + // Для balance: balance_bonus_kopeks = рубли * 100 const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = { code: code.trim().toUpperCase(), type, - balance_bonus_kopeks: Math.round(balanceBonusRubles * 100), + balance_bonus_kopeks: type === 'discount' + ? Math.round(balanceBonusRubles) // процент как целое число + : Math.round(balanceBonusRubles * 100), // рубли в копейки subscription_days: subscriptionDays, max_uses: maxUses, is_active: isActive, @@ -165,6 +171,7 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }: if (type === 'balance' && balanceBonusRubles <= 0) return false if ((type === 'subscription_days' || type === 'trial_subscription') && subscriptionDays <= 0) return false if (type === 'promo_group' && !promoGroupId) return false + if (type === 'discount' && (balanceBonusRubles <= 0 || balanceBonusRubles > 100 || subscriptionDays <= 0)) return false return true } @@ -207,6 +214,7 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }: +
@@ -262,6 +270,40 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }:
)} + {type === 'discount' && ( + <> +
+ +
+ setBalanceBonusRubles(Math.min(100, Math.max(0, parseFloat(e.target.value) || 0)))} + className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={1} + max={100} + /> + % +
+

Скидка применяется один раз при оплате

+
+
+ +
+ setSubscriptionDays(Math.max(0, parseInt(e.target.value) || 0))} + className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={1} + /> + часов +
+

Сколько часов после активации скидка действует

+
+ + )} + {/* Max Uses */}
@@ -578,6 +620,18 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal +{promocode.subscription_days}
)} + {promocode.type === 'discount' && ( + <> +
+ Скидка: + -{promocode.balance_bonus_kopeks}% +
+
+ Действует: + {promocode.subscription_days} часов +
+ + )}
Лимит: @@ -936,6 +990,9 @@ export default function AdminPromocodes() { {(promo.type === 'subscription_days' || promo.type === 'trial_subscription') && ( +{promo.subscription_days} дней )} + {promo.type === 'discount' && ( + -{promo.balance_bonus_kopeks}% на {promo.subscription_days}ч + )} Использовано: {promo.current_uses}/{promo.max_uses === 0 ? '∞' : promo.max_uses} From 02303bdff9a0c1358903bef2cbac444968ea5d8e Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 10:19:04 +0300 Subject: [PATCH 12/31] Update branding.ts --- src/api/branding.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/api/branding.ts b/src/api/branding.ts index e948691..b61f2e5 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -7,6 +7,10 @@ export interface BrandingInfo { has_custom_logo: boolean } +export interface AnimationEnabled { + enabled: boolean +} + export const brandingApi = { // Get current branding (public, no auth required) getBranding: async (): Promise => { @@ -45,4 +49,16 @@ export const brandingApi = { } return `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}` }, + + // Get animation enabled (public, no auth required) + getAnimationEnabled: async (): Promise => { + const response = await apiClient.get('/cabinet/branding/animation') + return response.data + }, + + // Update animation enabled (admin only) + updateAnimationEnabled: async (enabled: boolean): Promise => { + const response = await apiClient.patch('/cabinet/branding/animation', { enabled }) + return response.data + }, } From 1eef2495bbf3a1724a21c8299087578a2eb9bcf6 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 10:19:58 +0300 Subject: [PATCH 13/31] Add files via upload --- src/components/AnimatedBackground.tsx | 190 ++++++++++++++++++++++++++ src/components/layout/Layout.tsx | 4 + 2 files changed, 194 insertions(+) create mode 100644 src/components/AnimatedBackground.tsx diff --git a/src/components/AnimatedBackground.tsx b/src/components/AnimatedBackground.tsx new file mode 100644 index 0000000..4695887 --- /dev/null +++ b/src/components/AnimatedBackground.tsx @@ -0,0 +1,190 @@ +import { useQuery } from '@tanstack/react-query' +import { brandingApi } from '../api/branding' + +export default function AnimatedBackground() { + const { data: animationSettings } = useQuery({ + queryKey: ['animation-enabled'], + queryFn: brandingApi.getAnimationEnabled, + staleTime: 1000 * 60 * 5, // 5 minutes + retry: false, + }) + + // Don't render if animation is disabled + if (animationSettings && !animationSettings.enabled) { + return null + } + + return ( + <> + {/* SVG Filter for glow effect */} + + + + + + + + + + + {/* Animated wave gradients */} +
+
+
+
+
+
+ + + + ) +} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index a6263e9..8ab2c62 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/auth' import LanguageSwitcher from '../LanguageSwitcher' import PromoDiscountBadge from '../PromoDiscountBadge' import TicketNotificationBell from '../TicketNotificationBell' +import AnimatedBackground from '../AnimatedBackground' import { contestsApi } from '../../api/contests' import { pollsApi } from '../../api/polls' import { brandingApi } from '../../api/branding' @@ -270,6 +271,9 @@ export default function Layout({ children }: LayoutProps) { return (
+ {/* Animated Background */} + + {/* Header */}
From 17ba56fbc2fac16b54c04dfbb480b0b4df9bd3e9 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 10:21:03 +0300 Subject: [PATCH 14/31] Update AdminSettings.tsx --- src/pages/AdminSettings.tsx | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index e8cfa56..c3bc9de 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -864,6 +864,19 @@ export default function AdminSettings() { queryFn: brandingApi.getBranding, }) + // Animation toggle query and mutation + const { data: animationSettings } = useQuery({ + queryKey: ['animation-enabled'], + queryFn: brandingApi.getAnimationEnabled, + }) + + const updateAnimationMutation = useMutation({ + mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['animation-enabled'] }) + }, + }) + const updateNameMutation = useMutation({ mutationFn: (name: string) => brandingApi.updateName(name), onSuccess: () => { @@ -1187,6 +1200,29 @@ export default function AdminSettings() {

+ + {/* Animation Toggle */} +
+
+
+

Анимированный фон

+

+ Волновая анимация на фоне для всех пользователей +

+
+ +
+
{/* Theme Colors Card */} From 841828a3bca2c836e728701320208e3e5250b02e Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 10:27:31 +0300 Subject: [PATCH 15/31] Update AnimatedBackground.tsx --- src/components/AnimatedBackground.tsx | 46 +++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/components/AnimatedBackground.tsx b/src/components/AnimatedBackground.tsx index 4695887..8f0508b 100644 --- a/src/components/AnimatedBackground.tsx +++ b/src/components/AnimatedBackground.tsx @@ -1,16 +1,56 @@ +import { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { brandingApi } from '../api/branding' +const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled' + +// Get cached value from localStorage +const getCachedAnimationEnabled = (): boolean | null => { + try { + const cached = localStorage.getItem(ANIMATION_CACHE_KEY) + if (cached !== null) { + return cached === 'true' + } + } catch { + // localStorage not available + } + return null +} + +// Update cache in localStorage +export const setCachedAnimationEnabled = (enabled: boolean) => { + try { + localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled)) + } catch { + // localStorage not available + } +} + export default function AnimatedBackground() { + // Start with cached value (null means unknown yet) + const [isEnabled, setIsEnabled] = useState(() => getCachedAnimationEnabled()) + const { data: animationSettings } = useQuery({ queryKey: ['animation-enabled'], queryFn: brandingApi.getAnimationEnabled, - staleTime: 1000 * 60 * 5, // 5 minutes + staleTime: 1000 * 60, // 1 minute - быстрее обновляется при изменении админом + refetchOnWindowFocus: true, // Обновить при возврате в окно retry: false, }) - // Don't render if animation is disabled - if (animationSettings && !animationSettings.enabled) { + // Update state and cache when data arrives + useEffect(() => { + if (animationSettings !== undefined) { + const enabled = animationSettings.enabled + setIsEnabled(enabled) + setCachedAnimationEnabled(enabled) + } + }, [animationSettings]) + + // Don't render anything until we know the state (from cache or server) + // If cache says disabled, don't render + // If cache is null (first visit), default to not showing to avoid flash + if (isEnabled !== true) { return null } From 5840c35f44c71b639bfee0ceba3f47518a1b4fd6 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 10:28:02 +0300 Subject: [PATCH 16/31] Update AdminSettings.tsx --- src/pages/AdminSettings.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index c3bc9de..ccd878a 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import { adminSettingsApi, SettingDefinition, SettingCategorySummary } from '../api/adminSettings' import { brandingApi } from '../api/branding' +import { setCachedAnimationEnabled } from '../components/AnimatedBackground' import { themeColorsApi } from '../api/themeColors' import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme' import { ColorPicker } from '../components/ColorPicker' @@ -872,7 +873,9 @@ export default function AdminSettings() { const updateAnimationMutation = useMutation({ mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled), - onSuccess: () => { + onSuccess: (data) => { + // Update local cache immediately for instant effect + setCachedAnimationEnabled(data.enabled) queryClient.invalidateQueries({ queryKey: ['animation-enabled'] }) }, }) From e53d805bef91374412e970119d848d8e0f3d3eed Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 10:31:50 +0300 Subject: [PATCH 17/31] Update branding.ts --- src/api/branding.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/api/branding.ts b/src/api/branding.ts index b61f2e5..0067175 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -11,6 +11,30 @@ export interface AnimationEnabled { enabled: boolean } +const BRANDING_CACHE_KEY = 'cabinet_branding' + +// Get cached branding from localStorage +export const getCachedBranding = (): BrandingInfo | null => { + try { + const cached = localStorage.getItem(BRANDING_CACHE_KEY) + if (cached) { + return JSON.parse(cached) + } + } catch { + // localStorage not available or invalid JSON + } + return null +} + +// Update branding cache in localStorage +export const setCachedBranding = (branding: BrandingInfo) => { + try { + localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding)) + } catch { + // localStorage not available + } +} + export const brandingApi = { // Get current branding (public, no auth required) getBranding: async (): Promise => { From cde7909925414dddc74a53d4b17ac259d7354b03 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 10:32:29 +0300 Subject: [PATCH 18/31] Update Layout.tsx --- src/components/layout/Layout.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 8ab2c62..01cfd90 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 } from '../../api/branding' +import { brandingApi, getCachedBranding, setCachedBranding } from '../../api/branding' import { wheelApi } from '../../api/wheel' import { themeColorsApi } from '../../api/themeColors' import { promoApi } from '../../api/promo' @@ -167,15 +167,21 @@ export default function Layout({ children }: LayoutProps) { } }, [mobileMenuOpen]) - // Fetch branding settings + // Fetch branding settings with localStorage cache for instant load const { data: branding } = useQuery({ queryKey: ['branding'], - queryFn: brandingApi.getBranding, + queryFn: async () => { + const data = await brandingApi.getBranding() + setCachedBranding(data) // Update cache + return data + }, + initialData: getCachedBranding() ?? undefined, // Use cached data immediately staleTime: 60000, // 1 minute + refetchOnWindowFocus: true, retry: 1, }) - // Computed branding values - use fallback only if branding not loaded yet + // Computed branding values - use fallback only if no branding and no cache const appName = branding ? branding.name : FALLBACK_NAME // Empty string is valid (logo-only mode) const logoLetter = branding?.logo_letter || FALLBACK_LOGO const hasCustomLogo = branding?.has_custom_logo || false From 164ec7a38058794e298d569618eecd1a36226801 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 10:32:58 +0300 Subject: [PATCH 19/31] Update AdminSettings.tsx --- src/pages/AdminSettings.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index ccd878a..6080066 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import { adminSettingsApi, SettingDefinition, SettingCategorySummary } from '../api/adminSettings' -import { brandingApi } from '../api/branding' +import { brandingApi, setCachedBranding } from '../api/branding' import { setCachedAnimationEnabled } from '../components/AnimatedBackground' import { themeColorsApi } from '../api/themeColors' import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme' @@ -882,7 +882,8 @@ export default function AdminSettings() { const updateNameMutation = useMutation({ mutationFn: (name: string) => brandingApi.updateName(name), - onSuccess: () => { + onSuccess: (data) => { + setCachedBranding(data) // Update cache immediately queryClient.invalidateQueries({ queryKey: ['branding'] }) setEditingName(false) }, @@ -890,14 +891,16 @@ export default function AdminSettings() { const uploadLogoMutation = useMutation({ mutationFn: (file: File) => brandingApi.uploadLogo(file), - onSuccess: () => { + onSuccess: (data) => { + setCachedBranding(data) // Update cache immediately queryClient.invalidateQueries({ queryKey: ['branding'] }) }, }) const deleteLogoMutation = useMutation({ mutationFn: () => brandingApi.deleteLogo(), - onSuccess: () => { + onSuccess: (data) => { + setCachedBranding(data) // Update cache immediately queryClient.invalidateQueries({ queryKey: ['branding'] }) }, }) From 1e7b6d2f36c31fdf35e288a45294f713ecb40f00 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 22:36:44 +0300 Subject: [PATCH 20/31] Update ConnectionModal.tsx --- src/components/ConnectionModal.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 0d4f22f..473022d 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -61,12 +61,8 @@ const platformIconComponents: Record = { // Platform order for display const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] -// Allowed app schemes for deep links -const allowedAppSchemes = [ - 'happ://', 'flclash://', 'clash://', 'sing-box://', 'v2rayng://', - 'sub://', 'shadowrocket://', 'hiddify://', 'streisand://', - 'quantumult://', 'surge://', 'loon://', 'nekobox://', 'v2box://' -] +// Dangerous schemes that should never be allowed +const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] /** * Validate URL to prevent XSS via javascript: and other dangerous schemes @@ -87,20 +83,20 @@ function isValidExternalUrl(url: string | undefined): boolean { } /** - * Validate deep link URL - only allows known VPN app schemes + * Validate deep link URL - blocks dangerous schemes only + * Allows any custom app scheme (clash://, hiddify://, etc.) */ function isValidDeepLink(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() // Block dangerous schemes - const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { return false } - // Allow known app schemes - return allowedAppSchemes.some(scheme => lowerUrl.startsWith(scheme)) + // Allow any URL that has a scheme (contains ://) + return lowerUrl.includes('://') } // Detect user's platform from user agent From 0039a8a9b300b9201dcaec2ffe3a6337e81a8e67 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 23:36:05 +0300 Subject: [PATCH 21/31] 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 22/31] 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 && ( {/* Dropdown - mobile: fixed centered, desktop: absolute right */} From 6048d90e46dd6a30a1e62580b75027ac644aa1c5 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 01:21:40 +0300 Subject: [PATCH 26/31] Enhance Layout and useTelegramWebApp hook: add contentSafeAreaInset state and adjust header padding for fullscreen mode to improve layout responsiveness. --- src/components/layout/Layout.tsx | 10 ++++++++-- src/hooks/useTelegramWebApp.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index e7af97f..f4c76bb 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -142,7 +142,7 @@ export default function Layout({ children }: LayoutProps) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const { toggleTheme, isDark } = useTheme() const [userPhotoUrl, setUserPhotoUrl] = useState(null) - const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen } = useTelegramWebApp() + const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() // Fetch enabled themes from API - same source of truth as AdminSettings const { data: enabledThemes } = useQuery({ @@ -300,7 +300,13 @@ export default function Layout({ children }: LayoutProps) { {/* Header */} -
+
{/* Logo */} diff --git a/src/hooks/useTelegramWebApp.ts b/src/hooks/useTelegramWebApp.ts index 6f2b9a0..e8402b4 100644 --- a/src/hooks/useTelegramWebApp.ts +++ b/src/hooks/useTelegramWebApp.ts @@ -8,6 +8,7 @@ export function useTelegramWebApp() { const [isFullscreen, setIsFullscreen] = useState(false) const [isTelegramWebApp, setIsTelegramWebApp] = useState(false) const [safeAreaInset, setSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 }) + const [contentSafeAreaInset, setContentSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 }) const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined @@ -20,6 +21,7 @@ export function useTelegramWebApp() { setIsTelegramWebApp(true) setIsFullscreen(webApp.isFullscreen || false) setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) // Expand WebApp to full height webApp.expand() @@ -29,12 +31,23 @@ export function useTelegramWebApp() { const handleFullscreenChanged = () => { setIsFullscreen(webApp.isFullscreen || false) setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + } + + // Listen for safe area changes + const handleSafeAreaChanged = () => { + setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) } webApp.onEvent('fullscreenChanged', handleFullscreenChanged) + webApp.onEvent('safeAreaChanged', handleSafeAreaChanged) + webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged) return () => { webApp.offEvent('fullscreenChanged', handleFullscreenChanged) + webApp.offEvent('safeAreaChanged', handleSafeAreaChanged) + webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged) } }, [webApp]) @@ -86,6 +99,7 @@ export function useTelegramWebApp() { isFullscreen, isFullscreenSupported, safeAreaInset, + contentSafeAreaInset, requestFullscreen, exitFullscreen, toggleFullscreen, From 3a98fdff7d7a5438b80f1c8d56e650e5d864b067 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 01:27:29 +0300 Subject: [PATCH 27/31] Update Layout component: add right padding in fullscreen mode for Telegram native controls to enhance layout usability. --- src/components/layout/Layout.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index f4c76bb..ec4bac8 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -307,7 +307,13 @@ export default function Layout({ children }: LayoutProps) { paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top)}px` : undefined, }} > -
+
{/* Logo */} From 05e969a3aa11a45c432ae8aca58568f2eab67d3d Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 01:32:56 +0300 Subject: [PATCH 28/31] Fix Layout component: change padding from right to left for Telegram native controls in fullscreen mode to improve layout consistency. --- src/components/layout/Layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index ec4bac8..ba327ae 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -310,8 +310,8 @@ export default function Layout({ children }: LayoutProps) {
From 13f6380763ebaa4c0536db48818f7f5bc0ad2fd0 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 01:35:22 +0300 Subject: [PATCH 29/31] Update Layout component: adjust padding for safe area in fullscreen mode to accommodate Telegram native controls, enhancing layout functionality. --- src/components/layout/Layout.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index ba327ae..0c82aee 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -303,17 +303,11 @@ export default function Layout({ children }: LayoutProps) {
-
+
{/* Logo */} From d77d3ffd24311742d52adb6aacc6a2ce6f5ee53c Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 01:39:20 +0300 Subject: [PATCH 30/31] Optimize CSS for mobile and desktop: implement smooth scrolling for desktop only, enhance card and glass component styles for better performance on mobile, and ensure backdrop-blur effects are applied conditionally based on screen size. --- src/styles/globals.css | 44 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/styles/globals.css b/src/styles/globals.css index 2deee88..6aca2cc 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -102,19 +102,34 @@ html { overflow-x: hidden; - scroll-behavior: smooth; + } + + /* Smooth scroll only on desktop - mobile uses native */ + @media (min-width: 1024px) { + html { + scroll-behavior: smooth; + } } /* Dark theme (default) */ body, .dark body { @apply bg-dark-950 text-dark-100 font-sans antialiased; overscroll-behavior-y: contain; + /* iOS smooth scrolling */ + -webkit-overflow-scrolling: touch; + } + + /* Optimize main scrollable content */ + main { + /* Prevent layout shifts during scroll */ + contain: layout style; } /* Light theme - Champagne */ .light body { @apply bg-champagne-200 text-champagne-900 font-sans antialiased; overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; } /* Light theme text color overrides - convert dark-* text colors to readable colors */ @@ -310,10 +325,19 @@ @layer components { /* ========== DARK THEME COMPONENTS (default) ========== */ - /* Cards - Dark */ + /* Cards - Dark (optimized for mobile) */ .card { - @apply bg-dark-900/50 backdrop-blur-sm rounded-2xl border border-dark-800/50 p-5 sm:p-6 + @apply bg-dark-900/70 rounded-2xl border border-dark-800/50 p-5 sm:p-6 transition-all duration-300 ease-smooth; + /* GPU acceleration for smooth scroll */ + transform: translateZ(0); + } + + /* Enable backdrop-blur only on desktop */ + @media (min-width: 1024px) { + .card { + @apply bg-dark-900/50 backdrop-blur-sm; + } } .card-hover { @@ -329,9 +353,19 @@ @apply card animate-slide-up; } - /* Glass effect - Dark */ + /* Glass effect - Dark (optimized for mobile) */ .glass { - @apply bg-dark-900/30 backdrop-blur-xl border border-dark-700/30; + @apply bg-dark-900/80 border border-dark-700/30; + /* GPU acceleration */ + transform: translateZ(0); + will-change: transform; + } + + /* Enable backdrop-blur only on desktop where it's performant */ + @media (min-width: 1024px) { + .glass { + @apply bg-dark-900/30 backdrop-blur-xl; + } } /* Buttons - Dark */ From 9343522c1d5c8cf589a06048c8117d4331c86cb6 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 01:57:06 +0300 Subject: [PATCH 31/31] Enhance ColorPicker component: add RGB slider functionality, implement Telegram WebApp compatibility, and optimize color conversion methods. Update styles for preset colors and improve performance with memoization. --- src/components/ColorPicker.tsx | 165 +++++++++++++++++----- src/components/TicketNotificationBell.tsx | 14 +- src/styles/globals.css | 77 +++++++++- 3 files changed, 218 insertions(+), 38 deletions(-) diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index e841b7a..6b90673 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useMemo, useCallback } from 'react' interface ColorPickerProps { value: string @@ -8,6 +8,28 @@ interface ColorPickerProps { disabled?: boolean } +// Check if running in Telegram WebApp (native color picker causes crash) +const isTelegramWebApp = (): boolean => { + return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp +} + +// Convert hex to RGB +const hexToRgb = (hex: string): { r: number; g: number; b: number } => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : { r: 0, g: 0, b: 0 } +} + +// Convert RGB to hex +const rgbToHex = (r: number, g: number, b: number): string => { + return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('') +} + const PRESET_COLORS = [ '#3b82f6', // Blue '#ef4444', // Red @@ -21,18 +43,36 @@ const PRESET_COLORS = [ '#f97316', // Orange '#6366f1', // Indigo '#a855f7', // Purple + '#ffffff', // White + '#64748b', // Slate + '#1e293b', // Dark + '#000000', // Black ] export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) { const [isOpen, setIsOpen] = useState(false) const [localValue, setLocalValue] = useState(value) + const [rgb, setRgb] = useState(() => hexToRgb(value)) const containerRef = useRef(null) const colorInputRef = useRef(null) + // Memoize Telegram check to avoid recalculating + const isTelegram = useMemo(() => isTelegramWebApp(), []) + useEffect(() => { setLocalValue(value) + setRgb(hexToRgb(value)) }, [value]) + // Handle RGB slider change + const handleRgbChange = useCallback((channel: 'r' | 'g' | 'b', val: number) => { + const newRgb = { ...rgb, [channel]: val } + setRgb(newRgb) + const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b) + setLocalValue(hex) + onChange(hex) + }, [rgb, onChange]) + // Close on outside click useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -103,46 +143,107 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C maxLength={7} /> - {/* Hidden native color picker for direct access */} - - - {/* Native picker button - min 44px for touch accessibility */} - + + {/* Native picker button - min 44px for touch accessibility */} + + + )}
- {/* Dropdown with presets */} + {/* Dropdown with presets and RGB sliders */} {isOpen && ( -
+
+ {/* RGB Sliders - shown in Telegram instead of native picker */} + {isTelegram && ( +
+
RGB
+
+ {/* Red */} +
+ R + handleRgbChange('r', parseInt(e.target.value))} + className="flex-1 h-2 rounded-full appearance-none cursor-pointer" + style={{ + background: `linear-gradient(to right, rgb(0,${rgb.g},${rgb.b}), rgb(255,${rgb.g},${rgb.b}))`, + }} + /> + {rgb.r} +
+ {/* Green */} +
+ G + handleRgbChange('g', parseInt(e.target.value))} + className="flex-1 h-2 rounded-full appearance-none cursor-pointer" + style={{ + background: `linear-gradient(to right, rgb(${rgb.r},0,${rgb.b}), rgb(${rgb.r},255,${rgb.b}))`, + }} + /> + {rgb.g} +
+ {/* Blue */} +
+ B + handleRgbChange('b', parseInt(e.target.value))} + className="flex-1 h-2 rounded-full appearance-none cursor-pointer" + style={{ + background: `linear-gradient(to right, rgb(${rgb.r},${rgb.g},0), rgb(${rgb.r},${rgb.g},255))`, + }} + /> + {rgb.b} +
+
+
+ )} +
Preset colors
-
+
{PRESET_COLORS.map((preset) => (