diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 51256ca..0000000 --- a/.eslintignore +++ /dev/null @@ -1,6 +0,0 @@ -dist -node_modules -*.config.js -*.config.ts -.github -public diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 5df859f..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], - 'react-hooks/exhaustive-deps': 'warn', - 'no-empty': 'warn', - }, -} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..53a044f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist +node_modules +public +*.min.js +*.min.css +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ca25bd0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,13 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "all", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "auto", + "jsxSingleQuote": false, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f33a89d --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,48 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import eslintConfigPrettier from 'eslint-config-prettier' + +export default tseslint.config( + { + ignores: ['dist', 'node_modules', 'public'], + }, + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + ...tseslint.configs.recommended, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-hooks/set-state-in-effect': 'off', + 'react-hooks/static-components': 'off', + 'react-hooks/immutability': 'off', + 'react-hooks/refs': 'off', + 'react-hooks/purity': 'off', + 'react-hooks/variables': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'no-empty': 'warn', + 'no-eval': 'error', + 'no-implied-eval': 'error', + 'no-new-func': 'error', + 'no-script-url': 'error', + }, + }, + eslintConfigPrettier, +) diff --git a/src/App.tsx b/src/App.tsx index be685c4..23cc45b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,364 +1,416 @@ -import { lazy, Suspense } from 'react' -import { Routes, Route, Navigate, useLocation } from 'react-router-dom' -import { useAuthStore } from './store/auth' -import { useBlockingStore } from './store/blocking' -import Layout from './components/layout/Layout' -import PageLoader from './components/common/PageLoader' -import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking' -import { saveReturnUrl } from './utils/token' -import { useAnalyticsCounters } from './hooks/useAnalyticsCounters' +import { lazy, Suspense } from 'react'; +import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { useAuthStore } from './store/auth'; +import { useBlockingStore } from './store/blocking'; +import Layout from './components/layout/Layout'; +import PageLoader from './components/common/PageLoader'; +import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking'; +import { saveReturnUrl } from './utils/token'; +import { useAnalyticsCounters } from './hooks/useAnalyticsCounters'; // Auth pages - load immediately (small) -import Login from './pages/Login' -import TelegramCallback from './pages/TelegramCallback' -import TelegramRedirect from './pages/TelegramRedirect' -import DeepLinkRedirect from './pages/DeepLinkRedirect' -import VerifyEmail from './pages/VerifyEmail' -import ResetPassword from './pages/ResetPassword' +import Login from './pages/Login'; +import TelegramCallback from './pages/TelegramCallback'; +import TelegramRedirect from './pages/TelegramRedirect'; +import DeepLinkRedirect from './pages/DeepLinkRedirect'; +import VerifyEmail from './pages/VerifyEmail'; +import ResetPassword from './pages/ResetPassword'; // User pages - lazy load -const Dashboard = lazy(() => import('./pages/Dashboard')) -const Subscription = lazy(() => import('./pages/Subscription')) -const Balance = lazy(() => import('./pages/Balance')) -const Referral = lazy(() => import('./pages/Referral')) -const Support = lazy(() => import('./pages/Support')) -const Profile = lazy(() => import('./pages/Profile')) -const Contests = lazy(() => import('./pages/Contests')) -const Polls = lazy(() => import('./pages/Polls')) -const Info = lazy(() => import('./pages/Info')) -const Wheel = lazy(() => import('./pages/Wheel')) +const Dashboard = lazy(() => import('./pages/Dashboard')); +const Subscription = lazy(() => import('./pages/Subscription')); +const Balance = lazy(() => import('./pages/Balance')); +const Referral = lazy(() => import('./pages/Referral')); +const Support = lazy(() => import('./pages/Support')); +const Profile = lazy(() => import('./pages/Profile')); +const Contests = lazy(() => import('./pages/Contests')); +const Polls = lazy(() => import('./pages/Polls')); +const Info = lazy(() => import('./pages/Info')); +const Wheel = lazy(() => import('./pages/Wheel')); // Admin pages - lazy load (only for admins) -const AdminPanel = lazy(() => import('./pages/AdminPanel')) -const AdminTickets = lazy(() => import('./pages/AdminTickets')) -const AdminSettings = lazy(() => import('./pages/AdminSettings')) -const AdminApps = lazy(() => import('./pages/AdminApps')) -const AdminWheel = lazy(() => import('./pages/AdminWheel')) -const AdminTariffs = lazy(() => import('./pages/AdminTariffs')) -const AdminServers = lazy(() => import('./pages/AdminServers')) -const AdminDashboard = lazy(() => import('./pages/AdminDashboard')) -const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem')) -const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts')) -const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes')) -const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns')) -const AdminUsers = lazy(() => import('./pages/AdminUsers')) -const AdminPayments = lazy(() => import('./pages/AdminPayments')) -const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods')) -const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')) -const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')) -const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')) +const AdminPanel = lazy(() => import('./pages/AdminPanel')); +const AdminTickets = lazy(() => import('./pages/AdminTickets')); +const AdminSettings = lazy(() => import('./pages/AdminSettings')); +const AdminApps = lazy(() => import('./pages/AdminApps')); +const AdminWheel = lazy(() => import('./pages/AdminWheel')); +const AdminTariffs = lazy(() => import('./pages/AdminTariffs')); +const AdminServers = lazy(() => import('./pages/AdminServers')); +const AdminDashboard = lazy(() => import('./pages/AdminDashboard')); +const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem')); +const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts')); +const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes')); +const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns')); +const AdminUsers = lazy(() => import('./pages/AdminUsers')); +const AdminPayments = lazy(() => import('./pages/AdminPayments')); +const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods')); +const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')); +const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')); +const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')); function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isLoading } = useAuthStore() - const location = useLocation() + const { isAuthenticated, isLoading } = useAuthStore(); + const location = useLocation(); if (isLoading) { - return + return ; } if (!isAuthenticated) { // Сохраняем текущий URL для возврата после авторизации - saveReturnUrl() - return + saveReturnUrl(); + return ; } - return {children} + return {children}; } function AdminRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isLoading, isAdmin } = useAuthStore() - const location = useLocation() + const { isAuthenticated, isLoading, isAdmin } = useAuthStore(); + const location = useLocation(); if (isLoading) { - return + return ; } if (!isAuthenticated) { // Сохраняем текущий URL для возврата после авторизации - saveReturnUrl() - return + saveReturnUrl(); + return ; } if (!isAdmin) { - return + return ; } - return {children} + return {children}; } // Suspense wrapper for lazy components function LazyPage({ children }: { children: React.ReactNode }) { - return ( - }> - {children} - - ) + return }>{children}; } function BlockingOverlay() { - const { blockingType } = useBlockingStore() + const { blockingType } = useBlockingStore(); if (blockingType === 'maintenance') { - return + return ; } if (blockingType === 'channel_subscription') { - return + return ; } - return null + return null; } function App() { - useAnalyticsCounters() + useAnalyticsCounters(); return ( <> - {/* Public routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Public routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - {/* Protected routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* Protected routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> - {/* Admin routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* Admin routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> - {/* Catch all */} - } /> - + {/* Catch all */} + } /> + - ) + ); } -export default App +export default App; diff --git a/src/api/admin.ts b/src/api/admin.ts index 1bd79fe..1d5faa4 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -1,306 +1,310 @@ -import apiClient from './client' +import apiClient from './client'; export interface AdminTicketUser { - id: number - telegram_id: number - username: string | null - first_name: string | null - last_name: string | null + id: number; + telegram_id: number; + username: string | null; + first_name: string | null; + last_name: string | null; } export interface AdminTicketMessage { - id: number - message_text: string - is_from_admin: boolean - has_media: boolean - media_type: string | null - media_file_id: string | null - media_caption: string | null - created_at: string + id: number; + message_text: string; + is_from_admin: boolean; + has_media: boolean; + media_type: string | null; + media_file_id: string | null; + media_caption: string | null; + created_at: string; } export interface AdminTicket { - id: number - title: string - status: string - priority: string - created_at: string - updated_at: string - closed_at: string | null - messages_count: number - user: AdminTicketUser | null - last_message: AdminTicketMessage | null + id: number; + title: string; + status: string; + priority: string; + created_at: string; + updated_at: string; + closed_at: string | null; + messages_count: number; + user: AdminTicketUser | null; + last_message: AdminTicketMessage | null; } export interface AdminTicketDetail { - id: number - title: string - status: string - priority: string - created_at: string - updated_at: string - closed_at: string | null - is_reply_blocked: boolean - user: AdminTicketUser | null - messages: AdminTicketMessage[] + id: number; + title: string; + status: string; + priority: string; + created_at: string; + updated_at: string; + closed_at: string | null; + is_reply_blocked: boolean; + user: AdminTicketUser | null; + messages: AdminTicketMessage[]; } export interface AdminTicketStats { - total: number - open: number - pending: number - answered: number - closed: number + total: number; + open: number; + pending: number; + answered: number; + closed: number; } export interface TicketSettings { - sla_enabled: boolean - sla_minutes: number - sla_check_interval_seconds: number - sla_reminder_cooldown_minutes: number - support_system_mode: string // tickets, contact, both - cabinet_user_notifications_enabled: boolean - cabinet_admin_notifications_enabled: boolean + sla_enabled: boolean; + sla_minutes: number; + sla_check_interval_seconds: number; + sla_reminder_cooldown_minutes: number; + support_system_mode: string; // tickets, contact, both + cabinet_user_notifications_enabled: boolean; + cabinet_admin_notifications_enabled: boolean; } export interface TicketSettingsUpdate { - sla_enabled?: boolean - sla_minutes?: number - sla_check_interval_seconds?: number - sla_reminder_cooldown_minutes?: number - support_system_mode?: string - cabinet_user_notifications_enabled?: boolean - cabinet_admin_notifications_enabled?: boolean + sla_enabled?: boolean; + sla_minutes?: number; + sla_check_interval_seconds?: number; + sla_reminder_cooldown_minutes?: number; + support_system_mode?: string; + cabinet_user_notifications_enabled?: boolean; + cabinet_admin_notifications_enabled?: boolean; } export interface AdminTicketListResponse { - items: AdminTicket[] - total: number - page: number - per_page: number - pages: number + items: AdminTicket[]; + total: number; + page: number; + per_page: number; + pages: number; } export const adminApi = { // Check if current user is admin checkIsAdmin: async (): Promise<{ is_admin: boolean }> => { - const response = await apiClient.get('/cabinet/auth/me/is-admin') - return response.data + const response = await apiClient.get('/cabinet/auth/me/is-admin'); + return response.data; }, // Get ticket statistics getTicketStats: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/tickets/stats') - return response.data + const response = await apiClient.get('/cabinet/admin/tickets/stats'); + return response.data; }, // Get all tickets - getTickets: async (params: { - page?: number - per_page?: number - status?: string - priority?: string - } = {}): Promise => { - const response = await apiClient.get('/cabinet/admin/tickets', { params }) - return response.data + getTickets: async ( + params: { + page?: number; + per_page?: number; + status?: string; + priority?: string; + } = {}, + ): Promise => { + const response = await apiClient.get('/cabinet/admin/tickets', { params }); + return response.data; }, // Get single ticket with messages getTicket: async (ticketId: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/tickets/${ticketId}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/tickets/${ticketId}`); + return response.data; }, // Reply to ticket replyToTicket: async (ticketId: number, message: string): Promise => { - const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message }) - return response.data + const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message }); + return response.data; }, // Update ticket status updateTicketStatus: async (ticketId: number, status: string): Promise => { - const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/status`, { status }) - return response.data + const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/status`, { status }); + return response.data; }, // Update ticket priority updateTicketPriority: async (ticketId: number, priority: string): Promise => { - const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, { priority }) - return response.data + const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, { + priority, + }); + return response.data; }, // Get ticket settings getTicketSettings: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/tickets/settings') - return response.data + const response = await apiClient.get('/cabinet/admin/tickets/settings'); + return response.data; }, // Update ticket settings updateTicketSettings: async (settings: TicketSettingsUpdate): Promise => { - const response = await apiClient.patch('/cabinet/admin/tickets/settings', settings) - return response.data + const response = await apiClient.patch('/cabinet/admin/tickets/settings', settings); + return response.data; }, -} +}; // ============ Dashboard Stats Types ============ export interface NodeStatus { - uuid: string - name: string - address: string - is_connected: boolean - is_disabled: boolean - users_online: number - traffic_used_bytes?: number - uptime?: string - xray_version?: string - node_version?: string - last_status_message?: string - xray_uptime?: string - is_xray_running?: boolean - cpu_count?: number - cpu_model?: string - total_ram?: string - country_code?: string + uuid: string; + name: string; + address: string; + is_connected: boolean; + is_disabled: boolean; + users_online: number; + traffic_used_bytes?: number; + uptime?: string; + xray_version?: string; + node_version?: string; + last_status_message?: string; + xray_uptime?: string; + is_xray_running?: boolean; + cpu_count?: number; + cpu_model?: string; + total_ram?: string; + country_code?: string; } export interface NodesOverview { - total: number - online: number - offline: number - disabled: number - total_users_online: number - nodes: NodeStatus[] + total: number; + online: number; + offline: number; + disabled: number; + total_users_online: number; + nodes: NodeStatus[]; } export interface RevenueData { - date: string - amount_kopeks: number - amount_rubles: number + date: string; + amount_kopeks: number; + amount_rubles: number; } export interface SubscriptionStats { - total: number - active: number - trial: number - paid: number - expired: number - purchased_today: number - purchased_week: number - purchased_month: number - trial_to_paid_conversion: number + total: number; + active: number; + trial: number; + paid: number; + expired: number; + purchased_today: number; + purchased_week: number; + purchased_month: number; + trial_to_paid_conversion: number; } export interface FinancialStats { - income_today_kopeks: number - income_today_rubles: number - income_month_kopeks: number - income_month_rubles: number - income_total_kopeks: number - income_total_rubles: number - subscription_income_kopeks: number - subscription_income_rubles: number + income_today_kopeks: number; + income_today_rubles: number; + income_month_kopeks: number; + income_month_rubles: number; + income_total_kopeks: number; + income_total_rubles: number; + subscription_income_kopeks: number; + subscription_income_rubles: number; } export interface ServerStats { - total_servers: number - available_servers: number - servers_with_connections: number - total_revenue_kopeks: number - total_revenue_rubles: number + total_servers: number; + available_servers: number; + servers_with_connections: number; + total_revenue_kopeks: number; + total_revenue_rubles: number; } export interface TariffStatItem { - tariff_id: number - tariff_name: string - active_subscriptions: number - trial_subscriptions: number - purchased_today: number - purchased_week: number - purchased_month: number + tariff_id: number; + tariff_name: string; + active_subscriptions: number; + trial_subscriptions: number; + purchased_today: number; + purchased_week: number; + purchased_month: number; } export interface TariffStats { - tariffs: TariffStatItem[] - total_tariff_subscriptions: number + tariffs: TariffStatItem[]; + total_tariff_subscriptions: number; } export interface DashboardStats { - nodes: NodesOverview - subscriptions: SubscriptionStats - financial: FinancialStats - servers: ServerStats - revenue_chart: RevenueData[] - tariff_stats?: TariffStats + nodes: NodesOverview; + subscriptions: SubscriptionStats; + financial: FinancialStats; + servers: ServerStats; + revenue_chart: RevenueData[]; + tariff_stats?: TariffStats; } // ============ Extended Stats Types ============ export interface TopReferrerItem { - user_id: number - telegram_id: number - username?: string - display_name: string - invited_count: number - invited_today: number - invited_week: number - invited_month: number - earnings_today_kopeks: number - earnings_week_kopeks: number - earnings_month_kopeks: number - earnings_total_kopeks: number + user_id: number; + telegram_id: number; + username?: string; + display_name: string; + invited_count: number; + invited_today: number; + invited_week: number; + invited_month: number; + earnings_today_kopeks: number; + earnings_week_kopeks: number; + earnings_month_kopeks: number; + earnings_total_kopeks: number; } export interface TopReferrersResponse { - by_earnings: TopReferrerItem[] - by_invited: TopReferrerItem[] - total_referrers: number - total_referrals: number - total_earnings_kopeks: number + by_earnings: TopReferrerItem[]; + by_invited: TopReferrerItem[]; + total_referrers: number; + total_referrals: number; + total_earnings_kopeks: number; } export interface TopCampaignItem { - id: number - name: string - start_parameter: string - bonus_type: string - is_active: boolean - registrations: number - conversions: number - conversion_rate: number - total_revenue_kopeks: number - avg_revenue_per_user_kopeks: number - created_at?: string + id: number; + name: string; + start_parameter: string; + bonus_type: string; + is_active: boolean; + registrations: number; + conversions: number; + conversion_rate: number; + total_revenue_kopeks: number; + avg_revenue_per_user_kopeks: number; + created_at?: string; } export interface TopCampaignsResponse { - campaigns: TopCampaignItem[] - total_campaigns: number - total_registrations: number - total_revenue_kopeks: number + campaigns: TopCampaignItem[]; + total_campaigns: number; + total_registrations: number; + total_revenue_kopeks: number; } export interface RecentPaymentItem { - id: number - user_id: number - telegram_id: number - username?: string - display_name: string - amount_kopeks: number - amount_rubles: number - type: string - type_display: string - payment_method?: string - description?: string - created_at: string - is_completed: boolean + id: number; + user_id: number; + telegram_id: number; + username?: string; + display_name: string; + amount_kopeks: number; + amount_rubles: number; + type: string; + type_display: string; + payment_method?: string; + description?: string; + created_at: string; + is_completed: boolean; } export interface RecentPaymentsResponse { - payments: RecentPaymentItem[] - total_count: number - total_today_kopeks: number - total_week_kopeks: number + payments: RecentPaymentItem[]; + total_count: number; + total_today_kopeks: number; + total_week_kopeks: number; } // ============ Dashboard Stats API ============ @@ -308,43 +312,51 @@ export interface RecentPaymentsResponse { export const statsApi = { // Get complete dashboard stats getDashboardStats: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/stats/dashboard') - return response.data + const response = await apiClient.get('/cabinet/admin/stats/dashboard'); + return response.data; }, // Get nodes status getNodesStatus: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/stats/nodes') - return response.data + const response = await apiClient.get('/cabinet/admin/stats/nodes'); + return response.data; }, // Restart a node restartNode: async (nodeUuid: string): Promise<{ success: boolean; message: string }> => { - const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/restart`) - return response.data + const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/restart`); + return response.data; }, // Toggle node (enable/disable) - toggleNode: async (nodeUuid: string): Promise<{ success: boolean; message: string; is_disabled: boolean }> => { - const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/toggle`) - return response.data + toggleNode: async ( + nodeUuid: string, + ): Promise<{ success: boolean; message: string; is_disabled: boolean }> => { + const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/toggle`); + return response.data; }, // Get top referrers getTopReferrers: async (limit: number = 20): Promise => { - const response = await apiClient.get('/cabinet/admin/stats/referrals/top', { params: { limit } }) - return response.data + const response = await apiClient.get('/cabinet/admin/stats/referrals/top', { + params: { limit }, + }); + return response.data; }, // Get top campaigns getTopCampaigns: async (limit: number = 20): Promise => { - const response = await apiClient.get('/cabinet/admin/stats/campaigns/top', { params: { limit } }) - return response.data + const response = await apiClient.get('/cabinet/admin/stats/campaigns/top', { + params: { limit }, + }); + return response.data; }, // Get recent payments getRecentPayments: async (limit: number = 50): Promise => { - const response = await apiClient.get('/cabinet/admin/stats/payments/recent', { params: { limit } }) - return response.data + const response = await apiClient.get('/cabinet/admin/stats/payments/recent', { + params: { limit }, + }); + return response.data; }, -} +}; diff --git a/src/api/adminApps.ts b/src/api/adminApps.ts index b57a78c..414f7c4 100644 --- a/src/api/adminApps.ts +++ b/src/api/adminApps.ts @@ -1,220 +1,240 @@ -import apiClient from './client' +import apiClient from './client'; export interface LocalizedText { - en: string - ru: string - zh?: string - fa?: string - fr?: string + en: string; + ru: string; + zh?: string; + fa?: string; + fr?: string; } export interface AppButton { - id?: string // Unique identifier for React key (client-side only) - buttonLink: string - buttonText: LocalizedText + id?: string; // Unique identifier for React key (client-side only) + buttonLink: string; + buttonText: LocalizedText; } export interface AppStep { - description: LocalizedText - buttons?: AppButton[] - title?: LocalizedText + description: LocalizedText; + buttons?: AppButton[]; + title?: LocalizedText; } export interface AppDefinition { - id: string - name: string - isFeatured: boolean - urlScheme: string - isNeedBase64Encoding?: boolean - installationStep: AppStep - addSubscriptionStep: AppStep - connectAndUseStep: AppStep - additionalBeforeAddSubscriptionStep?: AppStep - additionalAfterAddSubscriptionStep?: AppStep + id: string; + name: string; + isFeatured: boolean; + urlScheme: string; + isNeedBase64Encoding?: boolean; + installationStep: AppStep; + addSubscriptionStep: AppStep; + connectAndUseStep: AppStep; + additionalBeforeAddSubscriptionStep?: AppStep; + additionalAfterAddSubscriptionStep?: AppStep; } export interface AppConfigBranding { - name: string - logoUrl: string - supportUrl: string + name: string; + logoUrl: string; + supportUrl: string; } export interface AppConfigConfig { - additionalLocales: string[] - branding: AppConfigBranding + additionalLocales: string[]; + branding: AppConfigBranding; } export interface AppConfigResponse { - config: AppConfigConfig - platforms: Record + config: AppConfigConfig; + platforms: Record; } export const adminAppsApi = { // Get full app config getConfig: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/apps') - return response.data + const response = await apiClient.get('/cabinet/admin/apps'); + return response.data; }, // Get available platforms getPlatforms: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/apps/platforms') - return response.data + const response = await apiClient.get('/cabinet/admin/apps/platforms'); + return response.data; }, // Get apps for a platform getPlatformApps: async (platform: string): Promise => { - const response = await apiClient.get(`/cabinet/admin/apps/platforms/${platform}`) - return response.data + const response = await apiClient.get( + `/cabinet/admin/apps/platforms/${platform}`, + ); + return response.data; }, // Create a new app createApp: async (platform: string, app: AppDefinition): Promise => { - const response = await apiClient.post(`/cabinet/admin/apps/platforms/${platform}`, { - platform, - app, - }) - return response.data + const response = await apiClient.post( + `/cabinet/admin/apps/platforms/${platform}`, + { + platform, + app, + }, + ); + return response.data; }, // Update an app - updateApp: async (platform: string, appId: string, app: AppDefinition): Promise => { - const response = await apiClient.put(`/cabinet/admin/apps/platforms/${platform}/${appId}`, { - app, - }) - return response.data + updateApp: async ( + platform: string, + appId: string, + app: AppDefinition, + ): Promise => { + const response = await apiClient.put( + `/cabinet/admin/apps/platforms/${platform}/${appId}`, + { + app, + }, + ); + return response.data; }, // Delete an app deleteApp: async (platform: string, appId: string): Promise => { - await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`) + await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`); }, // Reorder apps reorderApps: async (platform: string, appIds: string[]): Promise => { await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, { app_ids: appIds, - }) + }); }, // Copy app to another platform - copyApp: async (platform: string, appId: string, targetPlatform: string): Promise<{ new_id: string }> => { + copyApp: async ( + platform: string, + appId: string, + targetPlatform: string, + ): Promise<{ new_id: string }> => { const response = await apiClient.post<{ new_id: string; target_platform: string }>( - `/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}` - ) - return response.data + `/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`, + ); + return response.data; }, // Get branding getBranding: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/apps/branding') - return response.data + const response = await apiClient.get('/cabinet/admin/apps/branding'); + return response.data; }, // Update branding updateBranding: async (branding: AppConfigBranding): Promise => { const response = await apiClient.put('/cabinet/admin/apps/branding', { branding, - }) - return response.data + }); + return response.data; }, // Get RemnaWave config status getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => { const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>( - '/cabinet/admin/apps/remnawave/status' - ) - return response.data + '/cabinet/admin/apps/remnawave/status', + ); + return response.data; }, // Set RemnaWave config UUID - setRemnaWaveUuid: async (uuid: string | null): Promise<{ enabled: boolean; config_uuid: string | null }> => { + setRemnaWaveUuid: async ( + uuid: string | null, + ): Promise<{ enabled: boolean; config_uuid: string | null }> => { const response = await apiClient.put<{ enabled: boolean; config_uuid: string | null }>( '/cabinet/admin/apps/remnawave/uuid', - { uuid } - ) - return response.data + { uuid }, + ); + return response.data; }, // List available RemnaWave configs - listRemnaWaveConfigs: async (): Promise<{ uuid: string; name: string; view_position: number }[]> => { + listRemnaWaveConfigs: async (): Promise< + { uuid: string; name: string; view_position: number }[] + > => { const response = await apiClient.get<{ uuid: string; name: string; view_position: number }[]>( - '/cabinet/admin/apps/remnawave/configs' - ) - return response.data + '/cabinet/admin/apps/remnawave/configs', + ); + return response.data; }, // Get RemnaWave subscription config getRemnaWaveConfig: async (): Promise => { const response = await apiClient.get<{ - uuid: string - name: string - view_position: number - config: RemnawaveConfig - }>('/cabinet/admin/apps/remnawave/config') - return response.data.config + uuid: string; + name: string; + view_position: number; + config: RemnawaveConfig; + }>('/cabinet/admin/apps/remnawave/config'); + return response.data.config; }, -} +}; // ============== RemnaWave Format Types ============== export interface RemnawaveButton { - url: string - text: LocalizedText + url: string; + text: LocalizedText; } export interface RemnawaveBlock { - title: LocalizedText - description: LocalizedText - buttons?: RemnawaveButton[] - svgIconKey?: string - svgIconColor?: string + title: LocalizedText; + description: LocalizedText; + buttons?: RemnawaveButton[]; + svgIconKey?: string; + svgIconColor?: string; } export interface RemnawaveApp { - name: string - featured?: boolean - urlScheme?: string - isNeedBase64Encoding?: boolean - blocks: RemnawaveBlock[] + name: string; + featured?: boolean; + urlScheme?: string; + isNeedBase64Encoding?: boolean; + blocks: RemnawaveBlock[]; } export interface RemnawavePlatform { - apps: RemnawaveApp[] + apps: RemnawaveApp[]; } export interface RemnawaveSvgItem { - svgString: string - tags?: string[] + svgString: string; + tags?: string[]; } export interface RemnawaveBaseSettings { - isShowTutorialButton: boolean - tutorialUrl: string + isShowTutorialButton: boolean; + tutorialUrl: string; } export interface RemnawaveBaseTranslations { - installApp: LocalizedText - addSubscription: LocalizedText - connectAndUse: LocalizedText - copyLink: LocalizedText - openApp: LocalizedText - tutorial: LocalizedText - close: LocalizedText + installApp: LocalizedText; + addSubscription: LocalizedText; + connectAndUse: LocalizedText; + copyLink: LocalizedText; + openApp: LocalizedText; + tutorial: LocalizedText; + close: LocalizedText; } export interface RemnawaveBrandingSettings { - name: string - logoUrl: string - supportUrl: string + name: string; + logoUrl: string; + supportUrl: string; } export interface RemnawaveConfig { - platforms: Record - svgLibrary?: Record - baseSettings?: RemnawaveBaseSettings - baseTranslations?: RemnawaveBaseTranslations - brandingSettings?: RemnawaveBrandingSettings + platforms: Record; + svgLibrary?: Record; + baseSettings?: RemnawaveBaseSettings; + baseTranslations?: RemnawaveBaseTranslations; + brandingSettings?: RemnawaveBrandingSettings; } // ============== Converter Functions ============== @@ -225,23 +245,29 @@ const emptyLocalizedText = (): LocalizedText => ({ zh: '', fa: '', fr: '', -}) +}); // Convert Cabinet button to RemnaWave button const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({ url: button.buttonLink, text: { ...emptyLocalizedText(), ...button.buttonText }, -}) +}); // Convert RemnaWave button to Cabinet button const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({ buttonLink: button.url, - buttonText: { en: button.text.en || '', ru: button.text.ru || '', zh: button.text.zh, fa: button.text.fa, fr: button.text.fr }, -}) + buttonText: { + en: button.text.en || '', + ru: button.text.ru || '', + zh: button.text.zh, + fa: button.text.fa, + fr: button.text.fr, + }, +}); // Convert Cabinet app to RemnaWave app format export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => { - const blocks: RemnawaveBlock[] = [] + const blocks: RemnawaveBlock[] = []; // Block 1: Installation blocks.push({ @@ -250,17 +276,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => { buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave), svgIconKey: 'download', svgIconColor: '#3B82F6', - }) + }); // Block 2 (optional): Additional before subscription - if (app.additionalBeforeAddSubscriptionStep?.description?.en || app.additionalBeforeAddSubscriptionStep?.description?.ru) { + if ( + app.additionalBeforeAddSubscriptionStep?.description?.en || + app.additionalBeforeAddSubscriptionStep?.description?.ru + ) { blocks.push({ - title: app.additionalBeforeAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Preparation', ru: 'Подготовка' }, - description: { ...emptyLocalizedText(), ...app.additionalBeforeAddSubscriptionStep.description }, + title: app.additionalBeforeAddSubscriptionStep.title || { + ...emptyLocalizedText(), + en: 'Preparation', + ru: 'Подготовка', + }, + description: { + ...emptyLocalizedText(), + ...app.additionalBeforeAddSubscriptionStep.description, + }, buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), svgIconKey: 'settings', svgIconColor: '#8B5CF6', - }) + }); } // Block 3: Add subscription @@ -270,17 +306,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => { buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), svgIconKey: 'plus', svgIconColor: '#10B981', - }) + }); // Block 4 (optional): Additional after subscription - if (app.additionalAfterAddSubscriptionStep?.description?.en || app.additionalAfterAddSubscriptionStep?.description?.ru) { + if ( + app.additionalAfterAddSubscriptionStep?.description?.en || + app.additionalAfterAddSubscriptionStep?.description?.ru + ) { blocks.push({ - title: app.additionalAfterAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Configuration', ru: 'Настройка' }, - description: { ...emptyLocalizedText(), ...app.additionalAfterAddSubscriptionStep.description }, + title: app.additionalAfterAddSubscriptionStep.title || { + ...emptyLocalizedText(), + en: 'Configuration', + ru: 'Настройка', + }, + description: { + ...emptyLocalizedText(), + ...app.additionalAfterAddSubscriptionStep.description, + }, buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), svgIconKey: 'settings', svgIconColor: '#F59E0B', - }) + }); } // Block 5: Connect and use @@ -290,7 +336,7 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => { buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave), svgIconKey: 'check', svgIconColor: '#22C55E', - }) + }); return { name: app.name, @@ -298,78 +344,104 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => { urlScheme: app.urlScheme, isNeedBase64Encoding: app.isNeedBase64Encoding, blocks, - } -} + }; +}; // Convert RemnaWave app to Cabinet app format export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => { - const blocks = app.blocks || [] + const blocks = app.blocks || []; // Default empty step const emptyStep = (): AppStep => ({ description: emptyLocalizedText(), buttons: [], - }) + }); // Try to identify blocks by their titles or position - let installationBlock: RemnawaveBlock | undefined - let subscriptionBlock: RemnawaveBlock | undefined - let connectBlock: RemnawaveBlock | undefined - let beforeSubBlock: RemnawaveBlock | undefined - let afterSubBlock: RemnawaveBlock | undefined + let installationBlock: RemnawaveBlock | undefined; + let subscriptionBlock: RemnawaveBlock | undefined; + let connectBlock: RemnawaveBlock | undefined; + let beforeSubBlock: RemnawaveBlock | undefined; + let afterSubBlock: RemnawaveBlock | undefined; // First pass: try to identify by title keywords for (const block of blocks) { - const enTitle = (block.title?.en || '').toLowerCase() - const ruTitle = (block.title?.ru || '').toLowerCase() + const enTitle = (block.title?.en || '').toLowerCase(); + const ruTitle = (block.title?.ru || '').toLowerCase(); if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) { - if (!installationBlock) installationBlock = block - } else if (enTitle.includes('subscription') || enTitle.includes('add') || ruTitle.includes('подписк') || ruTitle.includes('добав')) { - if (!subscriptionBlock) subscriptionBlock = block - } else if (enTitle.includes('connect') || enTitle.includes('use') || ruTitle.includes('подключ') || ruTitle.includes('использ')) { - if (!connectBlock) connectBlock = block + if (!installationBlock) installationBlock = block; + } else if ( + enTitle.includes('subscription') || + enTitle.includes('add') || + ruTitle.includes('подписк') || + ruTitle.includes('добав') + ) { + if (!subscriptionBlock) subscriptionBlock = block; + } else if ( + enTitle.includes('connect') || + enTitle.includes('use') || + ruTitle.includes('подключ') || + ruTitle.includes('использ') + ) { + if (!connectBlock) connectBlock = block; } } // Second pass: assign remaining blocks by position if not found by title if (!installationBlock && blocks.length > 0) { - installationBlock = blocks[0] + installationBlock = blocks[0]; } if (!subscriptionBlock && blocks.length > 1) { - subscriptionBlock = blocks.find(b => b !== installationBlock) || blocks[1] + subscriptionBlock = blocks.find((b) => b !== installationBlock) || blocks[1]; } if (!connectBlock && blocks.length > 2) { - connectBlock = blocks.find(b => b !== installationBlock && b !== subscriptionBlock) || blocks[blocks.length - 1] + connectBlock = + blocks.find((b) => b !== installationBlock && b !== subscriptionBlock) || + blocks[blocks.length - 1]; } // Assign additional blocks - const additionalBlocks = blocks.filter(b => - b !== installationBlock && b !== subscriptionBlock && b !== connectBlock - ) + const additionalBlocks = blocks.filter( + (b) => b !== installationBlock && b !== subscriptionBlock && b !== connectBlock, + ); if (additionalBlocks.length >= 1) { // Check if block appears before subscription - const subIndex = blocks.indexOf(subscriptionBlock!) - const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0]) + const subIndex = blocks.indexOf(subscriptionBlock!); + const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0]); if (firstAdditionalIndex < subIndex) { - beforeSubBlock = additionalBlocks[0] + beforeSubBlock = additionalBlocks[0]; if (additionalBlocks.length >= 2) { - afterSubBlock = additionalBlocks[1] + afterSubBlock = additionalBlocks[1]; } } else { - afterSubBlock = additionalBlocks[0] + afterSubBlock = additionalBlocks[0]; } } // Convert block to cabinet step const blockToStep = (block: RemnawaveBlock | undefined): AppStep => { - if (!block) return emptyStep() + if (!block) return emptyStep(); return { - description: { en: block.description?.en || '', ru: block.description?.ru || '', zh: block.description?.zh, fa: block.description?.fa, fr: block.description?.fr }, - title: block.title ? { en: block.title.en || '', ru: block.title.ru || '', zh: block.title.zh, fa: block.title.fa, fr: block.title.fr } : undefined, + description: { + en: block.description?.en || '', + ru: block.description?.ru || '', + zh: block.description?.zh, + fa: block.description?.fa, + fr: block.description?.fr, + }, + title: block.title + ? { + en: block.title.en || '', + ru: block.title.ru || '', + zh: block.title.zh, + fa: block.title.fa, + fr: block.title.fr, + } + : undefined, buttons: block.buttons?.map(remnawaveButtonToCabinet), - } - } + }; + }; return { id: `${app.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${platform}-${Date.now()}`, @@ -382,66 +454,114 @@ export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppD connectAndUseStep: blockToStep(connectBlock), additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined, additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined, - } -} + }; +}; // Export all apps from cabinet to RemnaWave format export const exportToRemnawaveFormat = ( platformApps: Record, - branding?: AppConfigBranding + branding?: AppConfigBranding, ): RemnawaveConfig => { - const platforms: Record = {} + const platforms: Record = {}; for (const [platform, apps] of Object.entries(platformApps)) { platforms[platform] = { apps: apps.map(cabinetAppToRemnawave), - } + }; } return { platforms, svgLibrary: { - download: { svgString: '' }, - plus: { svgString: '' }, - check: { svgString: '' }, - settings: { svgString: '' }, + download: { + svgString: + '', + }, + plus: { + svgString: + '', + }, + check: { + svgString: + '', + }, + settings: { + svgString: + '', + }, }, baseSettings: { isShowTutorialButton: false, tutorialUrl: '', }, baseTranslations: { - installApp: { en: 'Install App', ru: 'Установить приложение', zh: '安装应用', fa: 'نصب برنامه', fr: 'Installer l\'application' }, - addSubscription: { en: 'Add Subscription', ru: 'Добавить подписку', zh: '添加订阅', fa: 'اضافه کردن اشتراک', fr: 'Ajouter un abonnement' }, - connectAndUse: { en: 'Connect and Use', ru: 'Подключиться и использовать', zh: '连接并使用', fa: 'اتصال و استفاده', fr: 'Connecter et utiliser' }, - copyLink: { en: 'Copy Link', ru: 'Скопировать ссылку', zh: '复制链接', fa: 'کپی لینک', fr: 'Copier le lien' }, - openApp: { en: 'Open App', ru: 'Открыть приложение', zh: '打开应用', fa: 'باز کردن برنامه', fr: 'Ouvrir l\'application' }, + installApp: { + en: 'Install App', + ru: 'Установить приложение', + zh: '安装应用', + fa: 'نصب برنامه', + fr: "Installer l'application", + }, + addSubscription: { + en: 'Add Subscription', + ru: 'Добавить подписку', + zh: '添加订阅', + fa: 'اضافه کردن اشتراک', + fr: 'Ajouter un abonnement', + }, + connectAndUse: { + en: 'Connect and Use', + ru: 'Подключиться и использовать', + zh: '连接并使用', + fa: 'اتصال و استفاده', + fr: 'Connecter et utiliser', + }, + copyLink: { + en: 'Copy Link', + ru: 'Скопировать ссылку', + zh: '复制链接', + fa: 'کپی لینک', + fr: 'Copier le lien', + }, + openApp: { + en: 'Open App', + ru: 'Открыть приложение', + zh: '打开应用', + fa: 'باز کردن برنامه', + fr: "Ouvrir l'application", + }, tutorial: { en: 'Tutorial', ru: 'Инструкция', zh: '教程', fa: 'آموزش', fr: 'Tutoriel' }, close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' }, }, - brandingSettings: branding ? { - name: branding.name, - logoUrl: branding.logoUrl, - supportUrl: branding.supportUrl, - } : undefined, - } -} + brandingSettings: branding + ? { + name: branding.name, + logoUrl: branding.logoUrl, + supportUrl: branding.supportUrl, + } + : undefined, + }; +}; // Import apps from RemnaWave format to cabinet export const importFromRemnawaveFormat = ( - config: RemnawaveConfig + config: RemnawaveConfig, ): { platformApps: Record; branding?: AppConfigBranding } => { - const platformApps: Record = {} + const platformApps: Record = {}; for (const [platform, platformData] of Object.entries(config.platforms || {})) { - platformApps[platform] = (platformData.apps || []).map(app => remnawaveAppToCabinet(app, platform)) + platformApps[platform] = (platformData.apps || []).map((app) => + remnawaveAppToCabinet(app, platform), + ); } - const branding = config.brandingSettings ? { - name: config.brandingSettings.name, - logoUrl: config.brandingSettings.logoUrl, - supportUrl: config.brandingSettings.supportUrl, - } : undefined + const branding = config.brandingSettings + ? { + name: config.brandingSettings.name, + logoUrl: config.brandingSettings.logoUrl, + supportUrl: config.brandingSettings.supportUrl, + } + : undefined; - return { platformApps, branding } -} + return { platformApps, branding }; +}; diff --git a/src/api/adminBroadcasts.ts b/src/api/adminBroadcasts.ts index 43a7df4..6f60fc0 100644 --- a/src/api/adminBroadcasts.ts +++ b/src/api/adminBroadcasts.ts @@ -1,168 +1,187 @@ -import apiClient from './client' +import apiClient from './client'; // Types export interface BroadcastFilter { - key: string - label: string - count: number | null - group: string | null + key: string; + label: string; + count: number | null; + group: string | null; } export interface TariffFilter { - key: string - label: string - tariff_id: number - count: number + key: string; + label: string; + tariff_id: number; + count: number; } export interface BroadcastFiltersResponse { - filters: BroadcastFilter[] - tariff_filters: TariffFilter[] - custom_filters: BroadcastFilter[] + filters: BroadcastFilter[]; + tariff_filters: TariffFilter[]; + custom_filters: BroadcastFilter[]; } export interface TariffForBroadcast { - id: number - name: string - filter_key: string - active_users_count: number + id: number; + name: string; + filter_key: string; + active_users_count: number; } export interface BroadcastTariffsResponse { - tariffs: TariffForBroadcast[] + tariffs: TariffForBroadcast[]; } export interface BroadcastButton { - key: string - label: string - default: boolean + key: string; + label: string; + default: boolean; } export interface BroadcastButtonsResponse { - buttons: BroadcastButton[] + buttons: BroadcastButton[]; } export interface BroadcastMedia { - type: 'photo' | 'video' | 'document' - file_id: string - caption?: string + type: 'photo' | 'video' | 'document'; + file_id: string; + caption?: string; } export interface BroadcastCreateRequest { - target: string - message_text: string - selected_buttons: string[] - media?: BroadcastMedia + target: string; + message_text: string; + selected_buttons: string[]; + media?: BroadcastMedia; } export interface Broadcast { - id: number - target_type: string - message_text: string - has_media: boolean - media_type: string | null - media_file_id: string | null - media_caption: string | null - total_count: number - sent_count: number - failed_count: number - status: 'queued' | 'in_progress' | 'completed' | 'partial' | 'failed' | 'cancelled' | 'cancelling' - admin_id: number | null - admin_name: string | null - created_at: string - completed_at: string | null - progress_percent: number + id: number; + target_type: string; + message_text: string; + has_media: boolean; + media_type: string | null; + media_file_id: string | null; + media_caption: string | null; + total_count: number; + sent_count: number; + failed_count: number; + status: + | 'queued' + | 'in_progress' + | 'completed' + | 'partial' + | 'failed' + | 'cancelled' + | 'cancelling'; + admin_id: number | null; + admin_name: string | null; + created_at: string; + completed_at: string | null; + progress_percent: number; } export interface BroadcastListResponse { - items: Broadcast[] - total: number - limit: number - offset: number + items: Broadcast[]; + total: number; + limit: number; + offset: number; } export interface BroadcastPreviewRequest { - target: string + target: string; } export interface BroadcastPreviewResponse { - target: string - count: number + target: string; + count: number; } export interface MediaUploadResponse { - media_type: string - file_id: string - file_unique_id: string | null - media_url: string + media_type: string; + file_id: string; + file_unique_id: string | null; + media_url: string; } export const adminBroadcastsApi = { // Get all available filters with counts getFilters: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/broadcasts/filters') - return response.data + const response = await apiClient.get( + '/cabinet/admin/broadcasts/filters', + ); + return response.data; }, // Get tariffs for filtering getTariffs: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/broadcasts/tariffs') - return response.data + const response = await apiClient.get( + '/cabinet/admin/broadcasts/tariffs', + ); + return response.data; }, // Get available buttons getButtons: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/broadcasts/buttons') - return response.data + const response = await apiClient.get( + '/cabinet/admin/broadcasts/buttons', + ); + return response.data; }, // Preview broadcast (get recipients count) preview: async (target: string): Promise => { - const response = await apiClient.post('/cabinet/admin/broadcasts/preview', { - target, - }) - return response.data + const response = await apiClient.post( + '/cabinet/admin/broadcasts/preview', + { + target, + }, + ); + return response.data; }, // Create and start broadcast create: async (data: BroadcastCreateRequest): Promise => { - const response = await apiClient.post('/cabinet/admin/broadcasts', data) - return response.data + const response = await apiClient.post('/cabinet/admin/broadcasts', data); + return response.data; }, // Get list of broadcasts list: async (limit = 20, offset = 0): Promise => { const response = await apiClient.get('/cabinet/admin/broadcasts', { params: { limit, offset }, - }) - return response.data + }); + return response.data; }, // Get broadcast details get: async (id: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/broadcasts/${id}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/broadcasts/${id}`); + return response.data; }, // Stop broadcast stop: async (id: number): Promise => { - const response = await apiClient.post(`/cabinet/admin/broadcasts/${id}/stop`) - return response.data + const response = await apiClient.post(`/cabinet/admin/broadcasts/${id}/stop`); + return response.data; }, // Upload media (uses existing media endpoint) - uploadMedia: async (file: File, mediaType: 'photo' | 'video' | 'document'): Promise => { - const formData = new FormData() - formData.append('file', file) - formData.append('media_type', mediaType) + uploadMedia: async ( + file: File, + mediaType: 'photo' | 'video' | 'document', + ): Promise => { + const formData = new FormData(); + formData.append('file', file); + formData.append('media_type', mediaType); const response = await apiClient.post('/cabinet/media/upload', formData, { headers: { 'Content-Type': 'multipart/form-data', }, - }) - return response.data + }); + return response.data; }, -} +}; -export default adminBroadcastsApi +export default adminBroadcastsApi; diff --git a/src/api/adminEmailTemplates.ts b/src/api/adminEmailTemplates.ts index 4c0b225..0a0164d 100644 --- a/src/api/adminEmailTemplates.ts +++ b/src/api/adminEmailTemplates.ts @@ -1,85 +1,105 @@ -import apiClient from './client' +import apiClient from './client'; export interface EmailTemplateLanguageStatus { - has_custom: boolean + has_custom: boolean; } export interface EmailTemplateType { - type: string - label: Record - description: Record - context_vars: string[] - languages: Record + type: string; + label: Record; + description: Record; + context_vars: string[]; + languages: Record; } export interface EmailTemplateListResponse { - items: EmailTemplateType[] - available_languages: string[] + items: EmailTemplateType[]; + available_languages: string[]; } export interface EmailTemplateLanguageData { - subject: string - body_html: string - is_default: boolean - default_subject: string - default_body_html: string + subject: string; + body_html: string; + is_default: boolean; + default_subject: string; + default_body_html: string; } export interface EmailTemplateDetail { - notification_type: string - label: Record - description: Record - context_vars: string[] - languages: Record + notification_type: string; + label: Record; + description: Record; + context_vars: string[]; + languages: Record; } export interface EmailTemplateUpdateRequest { - subject: string - body_html: string + subject: string; + body_html: string; } export interface EmailTemplatePreviewRequest { - language: string - subject?: string - body_html?: string + language: string; + subject?: string; + body_html?: string; } export interface EmailTemplatePreviewResponse { - subject: string - body_html: string + subject: string; + body_html: string; } export interface EmailTemplateSendTestRequest { - language: string - email?: string + language: string; + email?: string; } export const adminEmailTemplatesApi = { getTemplateTypes: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/email-templates') - return response.data + const response = await apiClient.get( + '/cabinet/admin/email-templates', + ); + return response.data; }, getTemplate: async (notificationType: string): Promise => { - const response = await apiClient.get(`/cabinet/admin/email-templates/${notificationType}`) - return response.data + const response = await apiClient.get( + `/cabinet/admin/email-templates/${notificationType}`, + ); + return response.data; }, - updateTemplate: async (notificationType: string, language: string, data: EmailTemplateUpdateRequest): Promise => { - await apiClient.put(`/cabinet/admin/email-templates/${notificationType}/${language}`, data) + updateTemplate: async ( + notificationType: string, + language: string, + data: EmailTemplateUpdateRequest, + ): Promise => { + await apiClient.put(`/cabinet/admin/email-templates/${notificationType}/${language}`, data); }, deleteTemplate: async (notificationType: string, language: string): Promise => { - await apiClient.delete(`/cabinet/admin/email-templates/${notificationType}/${language}`) + await apiClient.delete(`/cabinet/admin/email-templates/${notificationType}/${language}`); }, - previewTemplate: async (notificationType: string, data: EmailTemplatePreviewRequest): Promise => { - const response = await apiClient.post(`/cabinet/admin/email-templates/${notificationType}/preview`, data) - return response.data + previewTemplate: async ( + notificationType: string, + data: EmailTemplatePreviewRequest, + ): Promise => { + const response = await apiClient.post( + `/cabinet/admin/email-templates/${notificationType}/preview`, + data, + ); + return response.data; }, - sendTestEmail: async (notificationType: string, data: EmailTemplateSendTestRequest): Promise<{ sent_to: string }> => { - const response = await apiClient.post<{ status: string; sent_to: string }>(`/cabinet/admin/email-templates/${notificationType}/test`, data) - return response.data + sendTestEmail: async ( + notificationType: string, + data: EmailTemplateSendTestRequest, + ): Promise<{ sent_to: string }> => { + const response = await apiClient.post<{ status: string; sent_to: string }>( + `/cabinet/admin/email-templates/${notificationType}/test`, + data, + ); + return response.data; }, -} +}; diff --git a/src/api/adminPaymentMethods.ts b/src/api/adminPaymentMethods.ts index 0a866d4..b8f83fc 100644 --- a/src/api/adminPaymentMethods.ts +++ b/src/api/adminPaymentMethods.ts @@ -1,31 +1,35 @@ -import apiClient from './client' -import type { PaymentMethodConfig, PromoGroupSimple } from '../types' +import apiClient from './client'; +import type { PaymentMethodConfig, PromoGroupSimple } from '../types'; export const adminPaymentMethodsApi = { getAll: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/payment-methods') - return response.data + const response = await apiClient.get('/cabinet/admin/payment-methods'); + return response.data; }, getOne: async (methodId: string): Promise => { - const response = await apiClient.get(`/cabinet/admin/payment-methods/${methodId}`) - return response.data + const response = await apiClient.get( + `/cabinet/admin/payment-methods/${methodId}`, + ); + return response.data; }, update: async (methodId: string, data: Record): Promise => { const response = await apiClient.put( `/cabinet/admin/payment-methods/${methodId}`, - data - ) - return response.data + data, + ); + return response.data; }, updateOrder: async (methodIds: string[]): Promise => { - await apiClient.put('/cabinet/admin/payment-methods/order', { method_ids: methodIds }) + await apiClient.put('/cabinet/admin/payment-methods/order', { method_ids: methodIds }); }, getPromoGroups: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/payment-methods/promo-groups') - return response.data + const response = await apiClient.get( + '/cabinet/admin/payment-methods/promo-groups', + ); + return response.data; }, -} +}; diff --git a/src/api/adminPayments.ts b/src/api/adminPayments.ts index a169517..091e867 100644 --- a/src/api/adminPayments.ts +++ b/src/api/adminPayments.ts @@ -1,39 +1,46 @@ -import apiClient from './client' -import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types' +import apiClient from './client'; +import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'; export interface PaymentsStats { - total_pending: number - by_method: Record + total_pending: number; + by_method: Record; } export const adminPaymentsApi = { // Get all pending payments (admin) getPendingPayments: async (params?: { - page?: number - per_page?: number - method_filter?: string + page?: number; + per_page?: number; + method_filter?: string; }): Promise> => { - const response = await apiClient.get>('/cabinet/admin/payments', { - params, - }) - return response.data + const response = await apiClient.get>( + '/cabinet/admin/payments', + { + params, + }, + ); + return response.data; }, // Get payments statistics getStats: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/payments/stats') - return response.data + const response = await apiClient.get('/cabinet/admin/payments/stats'); + return response.data; }, // Get specific payment details getPayment: async (method: string, paymentId: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/payments/${method}/${paymentId}`) - return response.data + const response = await apiClient.get( + `/cabinet/admin/payments/${method}/${paymentId}`, + ); + return response.data; }, // Manually check payment status checkPaymentStatus: async (method: string, paymentId: number): Promise => { - const response = await apiClient.post(`/cabinet/admin/payments/${method}/${paymentId}/check`) - return response.data + const response = await apiClient.post( + `/cabinet/admin/payments/${method}/${paymentId}/check`, + ); + return response.data; }, -} +}; diff --git a/src/api/adminRemnawave.ts b/src/api/adminRemnawave.ts index a044e03..2043925 100644 --- a/src/api/adminRemnawave.ts +++ b/src/api/adminRemnawave.ts @@ -1,227 +1,227 @@ -import { apiClient } from './client' +import { apiClient } from './client'; // ============ Types ============ // Status & Connection export interface ConnectionStatus { - status: string - message: string - api_url?: string - status_code?: number - system_info?: Record + status: string; + message: string; + api_url?: string; + status_code?: number; + system_info?: Record; } export interface RemnaWaveStatusResponse { - is_configured: boolean - configuration_error?: string - connection?: ConnectionStatus + is_configured: boolean; + configuration_error?: string; + connection?: ConnectionStatus; } // System Statistics export interface SystemSummary { - users_online: number - total_users: number - active_connections: number - nodes_online: number - users_last_day: number - users_last_week: number - users_never_online: number - total_user_traffic: number + users_online: number; + total_users: number; + active_connections: number; + nodes_online: number; + users_last_day: number; + users_last_week: number; + users_never_online: number; + total_user_traffic: number; } export interface ServerInfo { - cpu_cores: number - cpu_physical_cores: number - memory_total: number - memory_used: number - memory_free: number - memory_available: number - uptime_seconds: number + cpu_cores: number; + cpu_physical_cores: number; + memory_total: number; + memory_used: number; + memory_free: number; + memory_available: number; + uptime_seconds: number; } export interface Bandwidth { - realtime_download: number - realtime_upload: number - realtime_total: number + realtime_download: number; + realtime_upload: number; + realtime_total: number; } export interface TrafficPeriod { - current: number - previous: number - difference?: string + current: number; + previous: number; + difference?: string; } export interface TrafficPeriods { - last_2_days: TrafficPeriod - last_7_days: TrafficPeriod - last_30_days: TrafficPeriod - current_month: TrafficPeriod - current_year: TrafficPeriod + last_2_days: TrafficPeriod; + last_7_days: TrafficPeriod; + last_30_days: TrafficPeriod; + current_month: TrafficPeriod; + current_year: TrafficPeriod; } export interface SystemStatsResponse { - system: SystemSummary - users_by_status: Record - server_info: ServerInfo - bandwidth: Bandwidth - traffic_periods: TrafficPeriods - nodes_realtime: Record[] - nodes_weekly: Record[] - last_updated?: string + system: SystemSummary; + users_by_status: Record; + server_info: ServerInfo; + bandwidth: Bandwidth; + traffic_periods: TrafficPeriods; + nodes_realtime: Record[]; + nodes_weekly: Record[]; + last_updated?: string; } // Nodes export interface NodeInfo { - uuid: string - name: string - address: string - country_code?: string - is_connected: boolean - is_disabled: boolean - is_node_online: boolean - is_xray_running: boolean - users_online?: number - traffic_used_bytes?: number - traffic_limit_bytes?: number - last_status_change?: string - last_status_message?: string - xray_uptime?: string - is_traffic_tracking_active: boolean - traffic_reset_day?: number - notify_percent?: number - consumption_multiplier: number - cpu_count?: number - cpu_model?: string - total_ram?: string - created_at?: string - updated_at?: string - provider_uuid?: string + uuid: string; + name: string; + address: string; + country_code?: string; + is_connected: boolean; + is_disabled: boolean; + is_node_online: boolean; + is_xray_running: boolean; + users_online?: number; + traffic_used_bytes?: number; + traffic_limit_bytes?: number; + last_status_change?: string; + last_status_message?: string; + xray_uptime?: string; + is_traffic_tracking_active: boolean; + traffic_reset_day?: number; + notify_percent?: number; + consumption_multiplier: number; + cpu_count?: number; + cpu_model?: string; + total_ram?: string; + created_at?: string; + updated_at?: string; + provider_uuid?: string; } export interface NodesListResponse { - items: NodeInfo[] - total: number + items: NodeInfo[]; + total: number; } export interface NodesOverview { - total: number - online: number - offline: number - disabled: number - total_users_online: number - nodes: NodeInfo[] + total: number; + online: number; + offline: number; + disabled: number; + total_users_online: number; + nodes: NodeInfo[]; } export interface NodeStatisticsResponse { - node: NodeInfo - realtime?: Record - usage_history: Record[] - last_updated?: string + node: NodeInfo; + realtime?: Record; + usage_history: Record[]; + last_updated?: string; } export interface NodeActionResponse { - success: boolean - message?: string - is_disabled?: boolean + success: boolean; + message?: string; + is_disabled?: boolean; } // Squads export interface SquadWithLocalInfo { - uuid: string - name: string - members_count: number - inbounds_count: number - inbounds: Record[] - local_id?: number - display_name?: string - country_code?: string - is_available?: boolean - is_trial_eligible?: boolean - price_kopeks?: number - max_users?: number - current_users?: number - is_synced: boolean + uuid: string; + name: string; + members_count: number; + inbounds_count: number; + inbounds: Record[]; + local_id?: number; + display_name?: string; + country_code?: string; + is_available?: boolean; + is_trial_eligible?: boolean; + price_kopeks?: number; + max_users?: number; + current_users?: number; + is_synced: boolean; } export interface SquadsListResponse { - items: SquadWithLocalInfo[] - total: number + items: SquadWithLocalInfo[]; + total: number; } export interface SquadDetailResponse extends SquadWithLocalInfo { - description?: string - sort_order?: number - active_subscriptions: number + description?: string; + sort_order?: number; + active_subscriptions: number; } export interface SquadOperationResponse { - success: boolean - message?: string - data?: Record + success: boolean; + message?: string; + data?: Record; } // Migration export interface MigrationPreviewResponse { - squad_uuid: string - squad_name: string - current_users: number - max_users?: number - users_to_migrate: number + squad_uuid: string; + squad_name: string; + current_users: number; + max_users?: number; + users_to_migrate: number; } export interface MigrationStats { - source_uuid: string - target_uuid: string - total: number - updated: number - panel_updated: number - panel_failed: number - source_removed: number - target_added: number + source_uuid: string; + target_uuid: string; + total: number; + updated: number; + panel_updated: number; + panel_failed: number; + source_removed: number; + target_added: number; } export interface MigrationResponse { - success: boolean - message?: string - error?: string - data?: MigrationStats + success: boolean; + message?: string; + error?: string; + data?: MigrationStats; } // Inbounds export interface InboundsListResponse { - items: Record[] - total: number + items: Record[]; + total: number; } // Auto Sync export interface AutoSyncStatus { - enabled: boolean - times: string[] - next_run?: string - is_running: boolean - last_run_started_at?: string - last_run_finished_at?: string - last_run_success?: boolean - last_run_reason?: string - last_run_error?: string - last_user_stats?: Record - last_server_stats?: Record + enabled: boolean; + times: string[]; + next_run?: string; + is_running: boolean; + last_run_started_at?: string; + last_run_finished_at?: string; + last_run_success?: boolean; + last_run_reason?: string; + last_run_error?: string; + last_user_stats?: Record; + last_server_stats?: Record; } export interface AutoSyncRunResponse { - started: boolean - success?: boolean - error?: string - user_stats?: Record - server_stats?: Record - reason?: string + started: boolean; + success?: boolean; + error?: string; + user_stats?: Record; + server_stats?: Record; + reason?: string; } // Sync export interface SyncResponse { - success: boolean - message?: string - data?: Record + success: boolean; + message?: string; + data?: Record; } // ============ API ============ @@ -229,158 +229,176 @@ export interface SyncResponse { export const adminRemnawaveApi = { // Status & Connection getStatus: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/remnawave/status') - return response.data + const response = await apiClient.get('/cabinet/admin/remnawave/status'); + return response.data; }, // System Statistics getSystemStats: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/remnawave/system') - return response.data + const response = await apiClient.get('/cabinet/admin/remnawave/system'); + return response.data; }, // Nodes getNodes: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/remnawave/nodes') - return response.data + const response = await apiClient.get('/cabinet/admin/remnawave/nodes'); + return response.data; }, getNodesOverview: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview') - return response.data + const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview'); + return response.data; }, getNodesRealtime: async (): Promise[]> => { - const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime') - return response.data + const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime'); + return response.data; }, getNode: async (uuid: string): Promise => { - const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`); + return response.data; }, getNodeStatistics: async (uuid: string): Promise => { - const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`) - return response.data + const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`); + return response.data; }, - nodeAction: async (uuid: string, action: 'enable' | 'disable' | 'restart'): Promise => { - const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, { action }) - return response.data + nodeAction: async ( + uuid: string, + action: 'enable' | 'disable' | 'restart', + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, { + action, + }); + return response.data; }, restartAllNodes: async (): Promise => { - const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all') - return response.data + const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all'); + return response.data; }, // Squads getSquads: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/remnawave/squads') - return response.data + const response = await apiClient.get('/cabinet/admin/remnawave/squads'); + return response.data; }, getSquad: async (uuid: string): Promise => { - const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`); + return response.data; }, - createSquad: async (data: { name: string; inbound_uuids?: string[] }): Promise => { - const response = await apiClient.post('/cabinet/admin/remnawave/squads', data) - return response.data + createSquad: async (data: { + name: string; + inbound_uuids?: string[]; + }): Promise => { + const response = await apiClient.post('/cabinet/admin/remnawave/squads', data); + return response.data; }, - updateSquad: async (uuid: string, data: { name?: string; inbound_uuids?: string[] }): Promise => { - const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data) - return response.data + updateSquad: async ( + uuid: string, + data: { name?: string; inbound_uuids?: string[] }, + ): Promise => { + const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data); + return response.data; }, deleteSquad: async (uuid: string): Promise => { - const response = await apiClient.delete(`/cabinet/admin/remnawave/squads/${uuid}`) - return response.data + const response = await apiClient.delete(`/cabinet/admin/remnawave/squads/${uuid}`); + return response.data; }, - squadAction: async (uuid: string, data: { - action: 'add_all_users' | 'remove_all_users' | 'delete' | 'rename' | 'update_inbounds' - name?: string - inbound_uuids?: string[] - }): Promise => { - const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data) - return response.data + squadAction: async ( + uuid: string, + data: { + action: 'add_all_users' | 'remove_all_users' | 'delete' | 'rename' | 'update_inbounds'; + name?: string; + inbound_uuids?: string[]; + }, + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data); + return response.data; }, // Migration getMigrationPreview: async (uuid: string): Promise => { - const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}/migration-preview`) - return response.data + const response = await apiClient.get( + `/cabinet/admin/remnawave/squads/${uuid}/migration-preview`, + ); + return response.data; }, migrateSquad: async (sourceUuid: string, targetUuid: string): Promise => { const response = await apiClient.post('/cabinet/admin/remnawave/squads/migrate', { source_uuid: sourceUuid, target_uuid: targetUuid, - }) - return response.data + }); + return response.data; }, // Inbounds getInbounds: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/remnawave/inbounds') - return response.data + const response = await apiClient.get('/cabinet/admin/remnawave/inbounds'); + return response.data; }, // Auto Sync getAutoSyncStatus: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status') - return response.data + const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status'); + return response.data; }, toggleAutoSync: async (enabled: boolean): Promise => { - const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled }) - return response.data + const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled }); + return response.data; }, runAutoSync: async (): Promise => { - const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/run') - return response.data + const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/run'); + return response.data; }, // Manual Sync - syncFromPanel: async (mode: 'all' | 'new_only' | 'update_only' = 'all'): Promise => { - const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode }) - return response.data + syncFromPanel: async ( + mode: 'all' | 'new_only' | 'update_only' = 'all', + ): Promise => { + const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode }); + return response.data; }, syncToPanel: async (): Promise => { - const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel') - return response.data + const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel'); + return response.data; }, syncServers: async (): Promise => { - const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers') - return response.data + const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers'); + return response.data; }, validateSubscriptions: async (): Promise => { - const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate') - return response.data + const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate'); + return response.data; }, cleanupSubscriptions: async (): Promise => { - const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup') - return response.data + const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup'); + return response.data; }, syncSubscriptionStatuses: async (): Promise => { - const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses') - return response.data + const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses'); + return response.data; }, getSyncRecommendations: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations') - return response.data + const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations'); + return response.data; }, -} +}; -export default adminRemnawaveApi +export default adminRemnawaveApi; diff --git a/src/api/adminSettings.ts b/src/api/adminSettings.ts index 391dca3..84d3932 100644 --- a/src/api/adminSettings.ts +++ b/src/api/adminSettings.ts @@ -1,73 +1,79 @@ -import apiClient from './client' +import apiClient from './client'; export interface SettingCategoryRef { - key: string - label: string + key: string; + label: string; } export interface SettingCategorySummary { - key: string - label: string - description: string - items: number + key: string; + label: string; + description: string; + items: number; } export interface SettingChoice { - value: unknown - label: string - description?: string | null + value: unknown; + label: string; + description?: string | null; } export interface SettingHint { - description: string - format: string - example: string - warning: string + description: string; + format: string; + example: string; + warning: string; } export interface SettingDefinition { - key: string - name: string - category: SettingCategoryRef - type: string - is_optional: boolean - current: unknown - original: unknown - has_override: boolean - read_only: boolean - choices: SettingChoice[] - hint?: SettingHint | null + key: string; + name: string; + category: SettingCategoryRef; + type: string; + is_optional: boolean; + current: unknown; + original: unknown; + has_override: boolean; + read_only: boolean; + choices: SettingChoice[]; + hint?: SettingHint | null; } export const adminSettingsApi = { // Get list of setting categories getCategories: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/settings/categories') - return response.data + const response = await apiClient.get( + '/cabinet/admin/settings/categories', + ); + return response.data; }, // Get all settings or settings for a specific category getSettings: async (categoryKey?: string): Promise => { - const params = categoryKey ? { category_key: categoryKey } : {} - const response = await apiClient.get('/cabinet/admin/settings', { params }) - return response.data + const params = categoryKey ? { category_key: categoryKey } : {}; + const response = await apiClient.get('/cabinet/admin/settings', { + params, + }); + return response.data; }, // Get a specific setting by key getSetting: async (key: string): Promise => { - const response = await apiClient.get(`/cabinet/admin/settings/${key}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/settings/${key}`); + return response.data; }, // Update a setting value updateSetting: async (key: string, value: unknown): Promise => { - const response = await apiClient.put(`/cabinet/admin/settings/${key}`, { value }) - return response.data + const response = await apiClient.put(`/cabinet/admin/settings/${key}`, { + value, + }); + return response.data; }, // Reset a setting to default resetSetting: async (key: string): Promise => { - const response = await apiClient.delete(`/cabinet/admin/settings/${key}`) - return response.data + const response = await apiClient.delete(`/cabinet/admin/settings/${key}`); + return response.data; }, -} +}; diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index b0e48be..88e204d 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -1,418 +1,478 @@ -import apiClient from './client' +import apiClient from './client'; // ============ Types ============ export interface UserSubscriptionInfo { - id: number - status: string - is_trial: boolean - start_date: string | null - end_date: string | null - traffic_limit_gb: number - traffic_used_gb: number - device_limit: number - tariff_id: number | null - tariff_name: string | null - autopay_enabled: boolean - is_active: boolean - days_remaining: number + id: number; + status: string; + is_trial: boolean; + start_date: string | null; + end_date: string | null; + traffic_limit_gb: number; + traffic_used_gb: number; + device_limit: number; + tariff_id: number | null; + tariff_name: string | null; + autopay_enabled: boolean; + is_active: boolean; + days_remaining: number; } export interface UserPromoGroupInfo { - id: number - name: string - is_default: boolean + id: number; + name: string; + is_default: boolean; } export interface UserListItem { - id: number - telegram_id: number - username: string | null - first_name: string | null - last_name: string | null - full_name: string - status: string - balance_kopeks: number - balance_rubles: number - created_at: string - last_activity: string | null - has_subscription: boolean - subscription_status: string | null - subscription_is_trial: boolean - subscription_end_date: string | null - promo_group_id: number | null - promo_group_name: string | null - total_spent_kopeks: number - purchase_count: number - has_restrictions: boolean - restriction_topup: boolean - restriction_subscription: boolean + id: number; + telegram_id: number; + username: string | null; + first_name: string | null; + last_name: string | null; + full_name: string; + status: string; + balance_kopeks: number; + balance_rubles: number; + created_at: string; + last_activity: string | null; + has_subscription: boolean; + subscription_status: string | null; + subscription_is_trial: boolean; + subscription_end_date: string | null; + promo_group_id: number | null; + promo_group_name: string | null; + total_spent_kopeks: number; + purchase_count: number; + has_restrictions: boolean; + restriction_topup: boolean; + restriction_subscription: boolean; } export interface UsersListResponse { - users: UserListItem[] - total: number - offset: number - limit: number + users: UserListItem[]; + total: number; + offset: number; + limit: number; } export interface UserTransactionItem { - id: number - type: string - amount_kopeks: number - amount_rubles: number - description: string | null - payment_method: string | null - is_completed: boolean - created_at: string + id: number; + type: string; + amount_kopeks: number; + amount_rubles: number; + description: string | null; + payment_method: string | null; + is_completed: boolean; + created_at: string; } export interface UserReferralInfo { - referral_code: string - referrals_count: number - total_earnings_kopeks: number - commission_percent: number | null - referred_by_id: number | null - referred_by_username: string | null + referral_code: string; + referrals_count: number; + total_earnings_kopeks: number; + commission_percent: number | null; + referred_by_id: number | null; + referred_by_username: string | null; } export interface UserDetailResponse { - id: number - telegram_id: number - username: string | null - first_name: string | null - last_name: string | null - full_name: string - status: string - language: string - balance_kopeks: number - balance_rubles: number - email: string | null - email_verified: boolean - created_at: string - updated_at: string | null - last_activity: string | null - cabinet_last_login: string | null - subscription: UserSubscriptionInfo | null - promo_group: UserPromoGroupInfo | null - referral: UserReferralInfo - total_spent_kopeks: number - purchase_count: number - used_promocodes: number - has_had_paid_subscription: boolean - lifetime_used_traffic_bytes: number - restriction_topup: boolean - restriction_subscription: boolean - restriction_reason: string | null - promo_offer_discount_percent: number - promo_offer_discount_source: string | null - promo_offer_discount_expires_at: string | null - recent_transactions: UserTransactionItem[] - remnawave_uuid: string | null + id: number; + telegram_id: number; + username: string | null; + first_name: string | null; + last_name: string | null; + full_name: string; + status: string; + language: string; + balance_kopeks: number; + balance_rubles: number; + email: string | null; + email_verified: boolean; + created_at: string; + updated_at: string | null; + last_activity: string | null; + cabinet_last_login: string | null; + subscription: UserSubscriptionInfo | null; + promo_group: UserPromoGroupInfo | null; + referral: UserReferralInfo; + total_spent_kopeks: number; + purchase_count: number; + used_promocodes: number; + has_had_paid_subscription: boolean; + lifetime_used_traffic_bytes: number; + restriction_topup: boolean; + restriction_subscription: boolean; + restriction_reason: string | null; + promo_offer_discount_percent: number; + promo_offer_discount_source: string | null; + promo_offer_discount_expires_at: string | null; + recent_transactions: UserTransactionItem[]; + remnawave_uuid: string | null; } export interface UsersStatsResponse { - total_users: number - active_users: number - blocked_users: number - deleted_users: number - new_today: number - new_week: number - new_month: number - users_with_subscription: number - users_with_active_subscription: number - users_with_trial: number - users_with_expired_subscription: number - total_balance_kopeks: number - total_balance_rubles: number - avg_balance_kopeks: number - active_today: number - active_week: number - active_month: number + total_users: number; + active_users: number; + blocked_users: number; + deleted_users: number; + new_today: number; + new_week: number; + new_month: number; + users_with_subscription: number; + users_with_active_subscription: number; + users_with_trial: number; + users_with_expired_subscription: number; + total_balance_kopeks: number; + total_balance_rubles: number; + avg_balance_kopeks: number; + active_today: number; + active_week: number; + active_month: number; } // Available tariffs export interface PeriodPriceInfo { - days: number - price_kopeks: number - price_rubles: number + days: number; + price_kopeks: number; + price_rubles: number; } export interface UserAvailableTariff { - id: number - name: string - description: string | null - is_active: boolean - is_trial_available: boolean - traffic_limit_gb: number - device_limit: number - tier_level: number - display_order: number - period_prices: PeriodPriceInfo[] - is_daily: boolean - daily_price_kopeks: number - custom_days_enabled: boolean - price_per_day_kopeks: number - min_days: number - max_days: number - is_available: boolean - requires_promo_group: boolean + id: number; + name: string; + description: string | null; + is_active: boolean; + is_trial_available: boolean; + traffic_limit_gb: number; + device_limit: number; + tier_level: number; + display_order: number; + period_prices: PeriodPriceInfo[]; + is_daily: boolean; + daily_price_kopeks: number; + custom_days_enabled: boolean; + price_per_day_kopeks: number; + min_days: number; + max_days: number; + is_available: boolean; + requires_promo_group: boolean; } export interface UserAvailableTariffsResponse { - user_id: number - promo_group_id: number | null - promo_group_name: string | null - tariffs: UserAvailableTariff[] - total: number - current_tariff_id: number | null - current_tariff_name: string | null + user_id: number; + promo_group_id: number | null; + promo_group_name: string | null; + tariffs: UserAvailableTariff[]; + total: number; + current_tariff_id: number | null; + current_tariff_name: string | null; } // Sync types export interface PanelUserInfo { - uuid: string | null - short_uuid: string | null - username: string | null - status: string | null - expire_at: string | null - traffic_limit_gb: number - traffic_used_gb: number - device_limit: number - subscription_url: string | null - active_squads: string[] + uuid: string | null; + short_uuid: string | null; + username: string | null; + status: string | null; + expire_at: string | null; + traffic_limit_gb: number; + traffic_used_gb: number; + device_limit: number; + subscription_url: string | null; + active_squads: string[]; } export interface SyncFromPanelResponse { - success: boolean - message: string - panel_user: PanelUserInfo | null - changes: Record - errors: string[] + success: boolean; + message: string; + panel_user: PanelUserInfo | null; + changes: Record; + errors: string[]; } export interface SyncToPanelResponse { - success: boolean - message: string - action: string - panel_uuid: string | null - changes: Record - errors: string[] + success: boolean; + message: string; + action: string; + panel_uuid: string | null; + changes: Record; + errors: string[]; } export interface PanelSyncStatusResponse { - user_id: number - telegram_id: number - remnawave_uuid: string | null - last_sync: string | null - bot_subscription_status: string | null - bot_subscription_end_date: string | null - bot_traffic_limit_gb: number - bot_traffic_used_gb: number - bot_device_limit: number - bot_squads: string[] - panel_found: boolean - panel_status: string | null - panel_expire_at: string | null - panel_traffic_limit_gb: number - panel_traffic_used_gb: number - panel_device_limit: number - panel_squads: string[] - has_differences: boolean - differences: string[] + user_id: number; + telegram_id: number; + remnawave_uuid: string | null; + last_sync: string | null; + bot_subscription_status: string | null; + bot_subscription_end_date: string | null; + bot_traffic_limit_gb: number; + bot_traffic_used_gb: number; + bot_device_limit: number; + bot_squads: string[]; + panel_found: boolean; + panel_status: string | null; + panel_expire_at: string | null; + panel_traffic_limit_gb: number; + panel_traffic_used_gb: number; + panel_device_limit: number; + panel_squads: string[]; + has_differences: boolean; + differences: string[]; } // Update types export interface UpdateBalanceRequest { - amount_kopeks: number - description?: string - create_transaction?: boolean + amount_kopeks: number; + description?: string; + create_transaction?: boolean; } export interface UpdateBalanceResponse { - success: boolean - old_balance_kopeks: number - new_balance_kopeks: number - message: string + success: boolean; + old_balance_kopeks: number; + new_balance_kopeks: number; + message: string; } export interface UpdateSubscriptionRequest { - action: 'extend' | 'set_end_date' | 'change_tariff' | 'set_traffic' | 'toggle_autopay' | 'cancel' | 'activate' | 'create' - days?: number - end_date?: string - tariff_id?: number - traffic_limit_gb?: number - traffic_used_gb?: number - autopay_enabled?: boolean - is_trial?: boolean - device_limit?: number + action: + | 'extend' + | 'set_end_date' + | 'change_tariff' + | 'set_traffic' + | 'toggle_autopay' + | 'cancel' + | 'activate' + | 'create'; + days?: number; + end_date?: string; + tariff_id?: number; + traffic_limit_gb?: number; + traffic_used_gb?: number; + autopay_enabled?: boolean; + is_trial?: boolean; + device_limit?: number; } export interface UpdateSubscriptionResponse { - success: boolean - message: string - subscription: UserSubscriptionInfo | null + success: boolean; + message: string; + subscription: UserSubscriptionInfo | null; } export interface UpdateUserStatusResponse { - success: boolean - old_status: string - new_status: string - message: string + success: boolean; + old_status: string; + new_status: string; + message: string; } export interface UpdateRestrictionsRequest { - restriction_topup?: boolean - restriction_subscription?: boolean - restriction_reason?: string + restriction_topup?: boolean; + restriction_subscription?: boolean; + restriction_reason?: string; } export interface UpdateRestrictionsResponse { - success: boolean - restriction_topup: boolean - restriction_subscription: boolean - restriction_reason: string | null - message: string + success: boolean; + restriction_topup: boolean; + restriction_subscription: boolean; + restriction_reason: string | null; + message: string; } export interface SyncFromPanelRequest { - update_subscription?: boolean - update_traffic?: boolean - create_if_missing?: boolean + update_subscription?: boolean; + update_traffic?: boolean; + create_if_missing?: boolean; } export interface SyncToPanelRequest { - create_if_missing?: boolean - update_status?: boolean - update_traffic_limit?: boolean - update_expire_date?: boolean - update_squads?: boolean + create_if_missing?: boolean; + update_status?: boolean; + update_traffic_limit?: boolean; + update_expire_date?: boolean; + update_squads?: boolean; } // ============ API ============ export const adminUsersApi = { // List users - getUsers: async (params: { - offset?: number - limit?: number - search?: string - status?: 'active' | 'blocked' | 'deleted' - sort_by?: 'created_at' | 'balance' | 'traffic' | 'last_activity' | 'total_spent' | 'purchase_count' - } = {}): Promise => { - const response = await apiClient.get('/cabinet/admin/users', { params }) - return response.data + getUsers: async ( + params: { + offset?: number; + limit?: number; + search?: string; + status?: 'active' | 'blocked' | 'deleted'; + sort_by?: + | 'created_at' + | 'balance' + | 'traffic' + | 'last_activity' + | 'total_spent' + | 'purchase_count'; + } = {}, + ): Promise => { + const response = await apiClient.get('/cabinet/admin/users', { params }); + return response.data; }, // Get users stats getStats: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/users/stats') - return response.data + const response = await apiClient.get('/cabinet/admin/users/stats'); + return response.data; }, // Get user detail getUser: async (userId: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/users/${userId}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/users/${userId}`); + return response.data; }, // Get user by telegram ID getUserByTelegram: async (telegramId: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`); + return response.data; }, // Get available tariffs for user - getAvailableTariffs: async (userId: number, includeInactive = false): Promise => { + getAvailableTariffs: async ( + userId: number, + includeInactive = false, + ): Promise => { const response = await apiClient.get(`/cabinet/admin/users/${userId}/available-tariffs`, { - params: { include_inactive: includeInactive } - }) - return response.data + params: { include_inactive: includeInactive }, + }); + return response.data; }, // Update balance - updateBalance: async (userId: number, data: UpdateBalanceRequest): Promise => { - const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data) - return response.data + updateBalance: async ( + userId: number, + data: UpdateBalanceRequest, + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data); + return response.data; }, // Update subscription - updateSubscription: async (userId: number, data: UpdateSubscriptionRequest): Promise => { - const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data) - return response.data + updateSubscription: async ( + userId: number, + data: UpdateSubscriptionRequest, + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data); + return response.data; }, // Update status - updateStatus: async (userId: number, status: 'active' | 'blocked' | 'deleted', reason?: string): Promise => { - const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, { status, reason }) - return response.data + updateStatus: async ( + userId: number, + status: 'active' | 'blocked' | 'deleted', + reason?: string, + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, { + status, + reason, + }); + return response.data; }, // Block user blockUser: async (userId: number, reason?: string): Promise => { - const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, { params: { reason } }) - return response.data + const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, { + params: { reason }, + }); + return response.data; }, // Unblock user unblockUser: async (userId: number): Promise => { - const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`) - return response.data + const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`); + return response.data; }, // Update restrictions - updateRestrictions: async (userId: number, data: UpdateRestrictionsRequest): Promise => { - const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data) - return response.data + updateRestrictions: async ( + userId: number, + data: UpdateRestrictionsRequest, + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data); + return response.data; }, // Update promo group - updatePromoGroup: async (userId: number, promoGroupId: number | null): Promise<{ success: boolean; message: string }> => { - const response = await apiClient.post(`/cabinet/admin/users/${userId}/promo-group`, { promo_group_id: promoGroupId }) - return response.data + updatePromoGroup: async ( + userId: number, + promoGroupId: number | null, + ): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/promo-group`, { + promo_group_id: promoGroupId, + }); + return response.data; }, // Delete user - deleteUser: async (userId: number, softDelete = true, reason?: string): Promise<{ success: boolean; message: string }> => { + deleteUser: async ( + userId: number, + softDelete = true, + reason?: string, + ): Promise<{ success: boolean; message: string }> => { const response = await apiClient.delete(`/cabinet/admin/users/${userId}`, { - data: { soft_delete: softDelete, reason } - }) - return response.data + data: { soft_delete: softDelete, reason }, + }); + return response.data; }, // Get referrals getReferrals: async (userId: number, offset = 0, limit = 50): Promise => { const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, { - params: { offset, limit } - }) - return response.data + params: { offset, limit }, + }); + return response.data; }, // Get transactions - getTransactions: async (userId: number, params: { - offset?: number - limit?: number - transaction_type?: string - } = {}): Promise<{ transactions: UserTransactionItem[]; total: number; offset: number; limit: number }> => { - const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params }) - return response.data + getTransactions: async ( + userId: number, + params: { + offset?: number; + limit?: number; + transaction_type?: string; + } = {}, + ): Promise<{ + transactions: UserTransactionItem[]; + total: number; + offset: number; + limit: number; + }> => { + const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params }); + return response.data; }, // Sync status getSyncStatus: async (userId: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`) - return response.data + const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`); + return response.data; }, // Sync from panel - syncFromPanel: async (userId: number, data: SyncFromPanelRequest = {}): Promise => { - const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data) - return response.data + syncFromPanel: async ( + userId: number, + data: SyncFromPanelRequest = {}, + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data); + return response.data; }, // Sync to panel - syncToPanel: async (userId: number, data: SyncToPanelRequest = {}): Promise => { - const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data) - return response.data + syncToPanel: async ( + userId: number, + data: SyncToPanelRequest = {}, + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data); + return response.data; }, -} +}; diff --git a/src/api/auth.ts b/src/api/auth.ts index a5697e2..d7104e9 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,27 +1,27 @@ -import apiClient from './client' -import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types' +import apiClient from './client'; +import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types'; export const authApi = { // Telegram WebApp authentication loginTelegram: async (initData: string): Promise => { const response = await apiClient.post('/cabinet/auth/telegram', { init_data: initData, - }) - return response.data + }); + return response.data; }, // Telegram Login Widget authentication loginTelegramWidget: async (data: { - id: number - first_name: string - last_name?: string - username?: string - photo_url?: string - auth_date: number - hash: string + id: number; + first_name: string; + last_name?: string; + username?: string; + photo_url?: string; + auth_date: number; + hash: string; }): Promise => { - const response = await apiClient.post('/cabinet/auth/telegram/widget', data) - return response.data + const response = await apiClient.post('/cabinet/auth/telegram/widget', data); + return response.data; }, // Email login @@ -29,63 +29,69 @@ export const authApi = { const response = await apiClient.post('/cabinet/auth/email/login', { email, password, - }) - return response.data + }); + return response.data; }, // Register email (link to existing Telegram account) - registerEmail: async (email: string, password: string): Promise<{ message: string; email: string }> => { + registerEmail: async ( + email: string, + password: string, + ): Promise<{ message: string; email: string }> => { const response = await apiClient.post('/cabinet/auth/email/register', { email, password, - }) - return response.data + }); + return response.data; }, // Register standalone email account (no Telegram required) // Returns message - user must verify email before login registerEmailStandalone: async (data: { - email: string - password: string - first_name?: string - language?: string - referral_code?: string + email: string; + password: string; + first_name?: string; + language?: string; + referral_code?: string; }): Promise => { - const response = await apiClient.post('/cabinet/auth/email/register/standalone', data) - return response.data + const response = await apiClient.post( + '/cabinet/auth/email/register/standalone', + data, + ); + return response.data; }, // Verify email and get auth tokens verifyEmail: async (token: string): Promise => { - const response = await apiClient.post('/cabinet/auth/email/verify', { token }) - return response.data + const response = await apiClient.post('/cabinet/auth/email/verify', { token }); + return response.data; }, // Resend verification email resendVerification: async (): Promise<{ message: string }> => { - const response = await apiClient.post('/cabinet/auth/email/resend') - return response.data + const response = await apiClient.post('/cabinet/auth/email/resend'); + return response.data; }, // Refresh token refreshToken: async (refreshToken: string): Promise => { const response = await apiClient.post('/cabinet/auth/refresh', { refresh_token: refreshToken, - }) - return response.data + }); + return response.data; }, // Logout logout: async (refreshToken: string): Promise => { await apiClient.post('/cabinet/auth/logout', { refresh_token: refreshToken, - }) + }); }, // Forgot password forgotPassword: async (email: string): Promise<{ message: string }> => { - const response = await apiClient.post('/cabinet/auth/password/forgot', { email }) - return response.data + const response = await apiClient.post('/cabinet/auth/password/forgot', { email }); + return response.data; }, // Reset password @@ -93,13 +99,13 @@ export const authApi = { const response = await apiClient.post('/cabinet/auth/password/reset', { token, password, - }) - return response.data + }); + return response.data; }, // Get current user getMe: async (): Promise => { - const response = await apiClient.get('/cabinet/auth/me') - return response.data + const response = await apiClient.get('/cabinet/auth/me'); + return response.data; }, -} +}; diff --git a/src/api/balance.ts b/src/api/balance.ts index d2aeef4..1772485 100644 --- a/src/api/balance.ts +++ b/src/api/balance.ts @@ -1,100 +1,124 @@ -import apiClient from './client' -import type { Balance, Transaction, PaymentMethod, PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types' +import apiClient from './client'; +import type { + Balance, + Transaction, + PaymentMethod, + PaginatedResponse, + PendingPayment, + ManualCheckResponse, +} from '../types'; export const balanceApi = { // Get current balance getBalance: async (): Promise => { - const response = await apiClient.get('/cabinet/balance') - return response.data + const response = await apiClient.get('/cabinet/balance'); + return response.data; }, // Get transaction history getTransactions: async (params?: { - page?: number - per_page?: number - type?: string + page?: number; + per_page?: number; + type?: string; }): Promise> => { - const response = await apiClient.get>('/cabinet/balance/transactions', { - params, - }) - return response.data + const response = await apiClient.get>( + '/cabinet/balance/transactions', + { + params, + }, + ); + return response.data; }, // Get available payment methods getPaymentMethods: async (): Promise => { - const response = await apiClient.get('/cabinet/balance/payment-methods') - return response.data + const response = await apiClient.get('/cabinet/balance/payment-methods'); + return response.data; }, // Create top-up payment - createTopUp: async (amountKopeks: number, paymentMethod: string, paymentOption?: string): Promise<{ - payment_id: string - payment_url: string - amount_kopeks: number - amount_rubles: number - status: string - expires_at: string | null + createTopUp: async ( + amountKopeks: number, + paymentMethod: string, + paymentOption?: string, + ): Promise<{ + payment_id: string; + payment_url: string; + amount_kopeks: number; + amount_rubles: number; + status: string; + expires_at: string | null; }> => { const payload: { - amount_kopeks: number - payment_method: string - payment_option?: string + amount_kopeks: number; + payment_method: string; + payment_option?: string; } = { amount_kopeks: amountKopeks, payment_method: paymentMethod, - } + }; if (paymentOption) { - payload.payment_option = paymentOption + payload.payment_option = paymentOption; } - const response = await apiClient.post('/cabinet/balance/topup', payload) - return response.data + const response = await apiClient.post('/cabinet/balance/topup', payload); + return response.data; }, // Activate promo code - activatePromocode: async (code: string): Promise<{ - success: boolean - message: string - balance_before: number - balance_after: number - bonus_description: string | null + activatePromocode: async ( + code: string, + ): Promise<{ + success: boolean; + message: string; + balance_before: number; + balance_after: number; + bonus_description: string | null; }> => { - const response = await apiClient.post('/cabinet/promocode/activate', { code }) - return response.data + const response = await apiClient.post('/cabinet/promocode/activate', { code }); + return response.data; }, // Create Telegram Stars invoice for Mini App balance top-up - createStarsInvoice: async (amountKopeks: number): Promise<{ - invoice_url: string - stars_amount?: number - amount_kopeks?: number + createStarsInvoice: async ( + amountKopeks: number, + ): Promise<{ + invoice_url: string; + stars_amount?: number; + amount_kopeks?: number; }> => { const response = await apiClient.post('/cabinet/balance/stars-invoice', { amount_kopeks: amountKopeks, - }) - return response.data + }); + return response.data; }, // Get pending payments for manual verification getPendingPayments: async (params?: { - page?: number - per_page?: number + page?: number; + per_page?: number; }): Promise> => { - const response = await apiClient.get>('/cabinet/balance/pending-payments', { - params, - }) - return response.data + const response = await apiClient.get>( + '/cabinet/balance/pending-payments', + { + params, + }, + ); + return response.data; }, // Get specific pending payment details getPendingPayment: async (method: string, paymentId: number): Promise => { - const response = await apiClient.get(`/cabinet/balance/pending-payments/${method}/${paymentId}`) - return response.data + const response = await apiClient.get( + `/cabinet/balance/pending-payments/${method}/${paymentId}`, + ); + return response.data; }, // Manually check payment status checkPaymentStatus: async (method: string, paymentId: number): Promise => { - const response = await apiClient.post(`/cabinet/balance/pending-payments/${method}/${paymentId}/check`) - return response.data + const response = await apiClient.post( + `/cabinet/balance/pending-payments/${method}/${paymentId}/check`, + ); + return response.data; }, -} - +}; diff --git a/src/api/banSystem.ts b/src/api/banSystem.ts index af6e98d..ee62b55 100644 --- a/src/api/banSystem.ts +++ b/src/api/banSystem.ts @@ -1,270 +1,270 @@ -import apiClient from './client' +import apiClient from './client'; // === Types === export interface BanSystemStatus { - enabled: boolean - configured: boolean + enabled: boolean; + configured: boolean; } export interface BanSystemStats { - total_users: number - active_users: number - users_over_limit: number - total_requests: number - total_punishments: number - active_punishments: number - nodes_online: number - nodes_total: number - agents_online: number - agents_total: number - panel_connected: boolean - uptime_seconds: number | null + total_users: number; + active_users: number; + users_over_limit: number; + total_requests: number; + total_punishments: number; + active_punishments: number; + nodes_online: number; + nodes_total: number; + agents_online: number; + agents_total: number; + panel_connected: boolean; + uptime_seconds: number | null; } export interface BanUserIPInfo { - ip: string - first_seen: string | null - last_seen: string | null - node: string | null - request_count: number - country_code: string | null - country_name: string | null - city: string | null + ip: string; + first_seen: string | null; + last_seen: string | null; + node: string | null; + request_count: number; + country_code: string | null; + country_name: string | null; + city: string | null; } export interface BanUserRequestLog { - timestamp: string - source_ip: string - destination: string | null - dest_port: number | null - protocol: string | null - action: string | null - node: string | null + timestamp: string; + source_ip: string; + destination: string | null; + dest_port: number | null; + protocol: string | null; + action: string | null; + node: string | null; } export interface BanUserListItem { - email: string - unique_ip_count: number - total_requests: number - limit: number | null - is_over_limit: boolean - blocked_count: number - last_seen: string | null + email: string; + unique_ip_count: number; + total_requests: number; + limit: number | null; + is_over_limit: boolean; + blocked_count: number; + last_seen: string | null; } export interface BanUsersListResponse { - users: BanUserListItem[] - total: number - offset: number - limit: number + users: BanUserListItem[]; + total: number; + offset: number; + limit: number; } export interface BanUserDetailResponse { - email: string - unique_ip_count: number - total_requests: number - limit: number | null - is_over_limit: boolean - blocked_count: number - ips: BanUserIPInfo[] - recent_requests: BanUserRequestLog[] - network_type: string | null + email: string; + unique_ip_count: number; + total_requests: number; + limit: number | null; + is_over_limit: boolean; + blocked_count: number; + ips: BanUserIPInfo[]; + recent_requests: BanUserRequestLog[]; + network_type: string | null; } export interface BanPunishmentItem { - id: number | null - user_id: string - uuid: string | null - username: string - reason: string | null - punished_at: string - enable_at: string | null - ip_count: number - limit: number - enabled: boolean - enabled_at: string | null - node_name: string | null + id: number | null; + user_id: string; + uuid: string | null; + username: string; + reason: string | null; + punished_at: string; + enable_at: string | null; + ip_count: number; + limit: number; + enabled: boolean; + enabled_at: string | null; + node_name: string | null; } export interface BanPunishmentsListResponse { - punishments: BanPunishmentItem[] - total: number + punishments: BanPunishmentItem[]; + total: number; } export interface BanHistoryResponse { - items: BanPunishmentItem[] - total: number + items: BanPunishmentItem[]; + total: number; } export interface BanUserRequest { - username: string - minutes: number - reason?: string + username: string; + minutes: number; + reason?: string; } export interface UnbanResponse { - success: boolean - message: string + success: boolean; + message: string; } export interface BanNodeItem { - name: string - address: string | null - is_connected: boolean - last_seen: string | null - users_count: number - agent_stats: Record | null + name: string; + address: string | null; + is_connected: boolean; + last_seen: string | null; + users_count: number; + agent_stats: Record | null; } export interface BanNodesListResponse { - nodes: BanNodeItem[] - total: number - online: number + nodes: BanNodeItem[]; + total: number; + online: number; } export interface BanAgentItem { - node_name: string - sent_total: number - dropped_total: number - batches_total: number - reconnects: number - failures: number - queue_size: number - queue_max: number - dedup_checked: number - dedup_skipped: number - filter_checked: number - filter_filtered: number - health: string - is_online: boolean - last_report: string | null + node_name: string; + sent_total: number; + dropped_total: number; + batches_total: number; + reconnects: number; + failures: number; + queue_size: number; + queue_max: number; + dedup_checked: number; + dedup_skipped: number; + filter_checked: number; + filter_filtered: number; + health: string; + is_online: boolean; + last_report: string | null; } export interface BanAgentsSummary { - total_agents: number - online_agents: number - total_sent: number - total_dropped: number - avg_queue_size: number - healthy_count: number - warning_count: number - critical_count: number + total_agents: number; + online_agents: number; + total_sent: number; + total_dropped: number; + avg_queue_size: number; + healthy_count: number; + warning_count: number; + critical_count: number; } export interface BanAgentsListResponse { - agents: BanAgentItem[] - summary: BanAgentsSummary | null - total: number - online: number + agents: BanAgentItem[]; + summary: BanAgentsSummary | null; + total: number; + online: number; } export interface BanTrafficViolationItem { - id: number | null - username: string - email: string | null - violation_type: string - description: string | null - bytes_used: number - bytes_limit: number - detected_at: string - resolved: boolean + id: number | null; + username: string; + email: string | null; + violation_type: string; + description: string | null; + bytes_used: number; + bytes_limit: number; + detected_at: string; + resolved: boolean; } export interface BanTrafficViolationsResponse { - violations: BanTrafficViolationItem[] - total: number + violations: BanTrafficViolationItem[]; + total: number; } export interface BanTrafficTopItem { - username: string - bytes_total: number - bytes_limit: number | null - over_limit: boolean + username: string; + bytes_total: number; + bytes_limit: number | null; + over_limit: boolean; } export interface BanTrafficResponse { - enabled: boolean - stats: Record | null - top_users: BanTrafficTopItem[] - recent_violations: BanTrafficViolationItem[] + enabled: boolean; + stats: Record | null; + top_users: BanTrafficTopItem[]; + recent_violations: BanTrafficViolationItem[]; } // === Settings Types === export interface BanSettingDefinition { - key: string - value: unknown - type: string - min_value: number | null - max_value: number | null - editable: boolean - description: string | null - category: string | null + key: string; + value: unknown; + type: string; + min_value: number | null; + max_value: number | null; + editable: boolean; + description: string | null; + category: string | null; } export interface BanSettingsResponse { - settings: BanSettingDefinition[] + settings: BanSettingDefinition[]; } export interface BanWhitelistRequest { - username: string + username: string; } // === Report Types === export interface BanReportTopViolator { - username: string - count: number + username: string; + count: number; } export interface BanReportResponse { - period_hours: number - current_users: number - current_ips: number - punishment_stats: Record | null - top_violators: BanReportTopViolator[] + period_hours: number; + current_users: number; + current_ips: number; + punishment_stats: Record | null; + top_violators: BanReportTopViolator[]; } // === Health Types === export interface BanHealthComponent { - name: string - status: string - message: string | null - details: Record | null + name: string; + status: string; + message: string | null; + details: Record | null; } export interface BanHealthResponse { - status: string - uptime: number | null - components: BanHealthComponent[] + status: string; + uptime: number | null; + components: BanHealthComponent[]; } export interface BanHealthDetailedResponse { - status: string - uptime: number | null - components: Record + status: string; + uptime: number | null; + components: Record; } // === Agent History Types === export interface BanAgentHistoryItem { - timestamp: string - sent_total: number - dropped_total: number - queue_size: number - batches_total: number + timestamp: string; + sent_total: number; + dropped_total: number; + queue_size: number; + batches_total: number; } export interface BanAgentHistoryResponse { - node: string - hours: number - records: number - delta: Record | null - first: Record | null - last: Record | null - history: BanAgentHistoryItem[] + node: string; + hours: number; + records: number; + delta: Record | null; + first: Record | null; + last: Record | null; + history: BanAgentHistoryItem[]; } // === API === @@ -272,174 +272,198 @@ export interface BanAgentHistoryResponse { export const banSystemApi = { // Status getStatus: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/ban-system/status') - return response.data + const response = await apiClient.get('/cabinet/admin/ban-system/status'); + return response.data; }, // Stats getStats: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/ban-system/stats') - return response.data + const response = await apiClient.get('/cabinet/admin/ban-system/stats'); + return response.data; }, // Users - getUsers: async (params: { - offset?: number - limit?: number - status?: string - } = {}): Promise => { - const response = await apiClient.get('/cabinet/admin/ban-system/users', { params }) - return response.data + getUsers: async ( + params: { + offset?: number; + limit?: number; + status?: string; + } = {}, + ): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/users', { params }); + return response.data; }, getUsersOverLimit: async (limit: number = 50): Promise => { const response = await apiClient.get('/cabinet/admin/ban-system/users/over-limit', { - params: { limit } - }) - return response.data + params: { limit }, + }); + return response.data; }, searchUsers: async (query: string): Promise => { - const response = await apiClient.get(`/cabinet/admin/ban-system/users/search/${encodeURIComponent(query)}`) - return response.data + const response = await apiClient.get( + `/cabinet/admin/ban-system/users/search/${encodeURIComponent(query)}`, + ); + return response.data; }, getUser: async (email: string): Promise => { - const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}`) - return response.data + const response = await apiClient.get( + `/cabinet/admin/ban-system/users/${encodeURIComponent(email)}`, + ); + return response.data; }, // Punishments getPunishments: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/ban-system/punishments') - return response.data + const response = await apiClient.get('/cabinet/admin/ban-system/punishments'); + return response.data; }, unbanUser: async (userId: string): Promise => { - const response = await apiClient.post(`/cabinet/admin/ban-system/punishments/${userId}/unban`) - return response.data + const response = await apiClient.post(`/cabinet/admin/ban-system/punishments/${userId}/unban`); + return response.data; }, banUser: async (data: BanUserRequest): Promise => { - const response = await apiClient.post('/cabinet/admin/ban-system/ban', data) - return response.data + const response = await apiClient.post('/cabinet/admin/ban-system/ban', data); + return response.data; }, getPunishmentHistory: async (query: string, limit: number = 20): Promise => { - const response = await apiClient.get(`/cabinet/admin/ban-system/history/${encodeURIComponent(query)}`, { - params: { limit } - }) - return response.data + const response = await apiClient.get( + `/cabinet/admin/ban-system/history/${encodeURIComponent(query)}`, + { + params: { limit }, + }, + ); + return response.data; }, // Nodes getNodes: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/ban-system/nodes') - return response.data + const response = await apiClient.get('/cabinet/admin/ban-system/nodes'); + return response.data; }, // Agents - getAgents: async (params: { - search?: string - health?: string - status?: string - } = {}): Promise => { - const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params }) - return response.data + getAgents: async ( + params: { + search?: string; + health?: string; + status?: string; + } = {}, + ): Promise => { + const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params }); + return response.data; }, getAgentsSummary: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/ban-system/agents/summary') - return response.data + const response = await apiClient.get('/cabinet/admin/ban-system/agents/summary'); + return response.data; }, // Traffic violations getTrafficViolations: async (limit: number = 50): Promise => { const response = await apiClient.get('/cabinet/admin/ban-system/traffic/violations', { - params: { limit } - }) - return response.data + params: { limit }, + }); + return response.data; }, // Full Traffic getTraffic: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/ban-system/traffic') - return response.data + const response = await apiClient.get('/cabinet/admin/ban-system/traffic'); + return response.data; }, getTrafficTop: async (limit: number = 20): Promise => { const response = await apiClient.get('/cabinet/admin/ban-system/traffic/top', { - params: { limit } - }) - return response.data + params: { limit }, + }); + return response.data; }, // Settings getSettings: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/ban-system/settings') - return response.data + const response = await apiClient.get('/cabinet/admin/ban-system/settings'); + return response.data; }, getSetting: async (key: string): Promise => { - const response = await apiClient.get(`/cabinet/admin/ban-system/settings/${key}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/ban-system/settings/${key}`); + return response.data; }, setSetting: async (key: string, value: string): Promise => { const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}`, null, { - params: { value } - }) - return response.data + params: { value }, + }); + return response.data; }, toggleSetting: async (key: string): Promise => { - const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}/toggle`) - return response.data + const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}/toggle`); + return response.data; }, // Whitelist whitelistAdd: async (username: string): Promise => { - const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/add', { username }) - return response.data + const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/add', { + username, + }); + return response.data; }, whitelistRemove: async (username: string): Promise => { - const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/remove', { username }) - return response.data + const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/remove', { + username, + }); + return response.data; }, // Reports getReport: async (hours: number = 24): Promise => { const response = await apiClient.get('/cabinet/admin/ban-system/report', { - params: { hours } - }) - return response.data + params: { hours }, + }); + return response.data; }, // Health getHealth: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/ban-system/health') - return response.data + const response = await apiClient.get('/cabinet/admin/ban-system/health'); + return response.data; }, getHealthDetailed: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/ban-system/health/detailed') - return response.data + const response = await apiClient.get('/cabinet/admin/ban-system/health/detailed'); + return response.data; }, // Agent History - getAgentHistory: async (nodeName: string, hours: number = 24): Promise => { - const response = await apiClient.get(`/cabinet/admin/ban-system/agents/${encodeURIComponent(nodeName)}/history`, { - params: { hours } - }) - return response.data + getAgentHistory: async ( + nodeName: string, + hours: number = 24, + ): Promise => { + const response = await apiClient.get( + `/cabinet/admin/ban-system/agents/${encodeURIComponent(nodeName)}/history`, + { + params: { hours }, + }, + ); + return response.data; }, // User Punishment History getUserHistory: async (email: string, limit: number = 20): Promise => { - const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}/history`, { - params: { limit } - }) - return response.data + const response = await apiClient.get( + `/cabinet/admin/ban-system/users/${encodeURIComponent(email)}/history`, + { + params: { limit }, + }, + ); + return response.data; }, -} +}; diff --git a/src/api/branding.ts b/src/api/branding.ts index f9701f2..e3e50f4 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -1,203 +1,209 @@ -import apiClient from './client' +import apiClient from './client'; export interface BrandingInfo { - name: string - logo_url: string | null - logo_letter: string - has_custom_logo: boolean + name: string; + logo_url: string | null; + logo_letter: string; + has_custom_logo: boolean; } export interface AnimationEnabled { - enabled: boolean + enabled: boolean; } export interface FullscreenEnabled { - enabled: boolean + enabled: boolean; } export interface EmailAuthEnabled { - enabled: boolean + enabled: boolean; } export interface AnalyticsCounters { - yandex_metrika_id: string - google_ads_id: string - google_ads_label: string + yandex_metrika_id: string; + google_ads_id: string; + google_ads_label: string; } -const BRANDING_CACHE_KEY = 'cabinet_branding' -const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded' +const BRANDING_CACHE_KEY = 'cabinet_branding'; +const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'; // Check if logo was already preloaded in this session export const isLogoPreloaded = (): boolean => { try { - const cached = getCachedBranding() + const cached = getCachedBranding(); if (!cached?.has_custom_logo || !cached?.logo_url) { - return false + return false; } - const logoUrl = `${import.meta.env.VITE_API_URL || ''}${cached.logo_url}` - const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY) - return preloaded === logoUrl + const logoUrl = `${import.meta.env.VITE_API_URL || ''}${cached.logo_url}`; + const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY); + return preloaded === logoUrl; } catch { - return false + return false; } -} +}; // Get cached branding from localStorage export const getCachedBranding = (): BrandingInfo | null => { try { - const cached = localStorage.getItem(BRANDING_CACHE_KEY) + const cached = localStorage.getItem(BRANDING_CACHE_KEY); if (cached) { - return JSON.parse(cached) + return JSON.parse(cached); } } catch { // localStorage not available or invalid JSON } - return null -} + return null; +}; // Update branding cache in localStorage export const setCachedBranding = (branding: BrandingInfo) => { try { - localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding)) + localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding)); } catch { // localStorage not available } -} +}; // 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 + resolve(); + return; } - const logoUrl = `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}` + 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) + const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY); if (preloaded === logoUrl) { - resolve() - return + resolve(); + return; } - const img = new Image() + const img = new Image(); img.onload = () => { - sessionStorage.setItem(LOGO_PRELOADED_KEY, logoUrl) - resolve() - } - img.onerror = () => resolve() - img.src = logoUrl - }) -} + 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() + const cached = getCachedBranding(); if (cached) { - preloadLogo(cached) + preloadLogo(cached); } -} +}; export const brandingApi = { // Get current branding (public, no auth required) getBranding: async (): Promise => { - const response = await apiClient.get('/cabinet/branding') - return response.data + const response = await apiClient.get('/cabinet/branding'); + return response.data; }, // Update project name (admin only) updateName: async (name: string): Promise => { - const response = await apiClient.put('/cabinet/branding/name', { name }) - return response.data + const response = await apiClient.put('/cabinet/branding/name', { name }); + return response.data; }, // Upload custom logo (admin only) uploadLogo: async (file: File): Promise => { - const formData = new FormData() - formData.append('file', file) + const formData = new FormData(); + formData.append('file', file); const response = await apiClient.post('/cabinet/branding/logo', formData, { headers: { 'Content-Type': 'multipart/form-data', }, - }) - return response.data + }); + return response.data; }, // Delete custom logo (admin only) deleteLogo: async (): Promise => { - const response = await apiClient.delete('/cabinet/branding/logo') - return response.data + const response = await apiClient.delete('/cabinet/branding/logo'); + return response.data; }, // Get logo URL (without cache busting - server handles caching via Cache-Control headers) getLogoUrl: (branding: BrandingInfo): string | null => { if (!branding.has_custom_logo || !branding.logo_url) { - return null + return null; } - return `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}` + 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 + 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 + const response = await apiClient.patch('/cabinet/branding/animation', { + enabled, + }); + return response.data; }, // Get fullscreen enabled (public, no auth required) getFullscreenEnabled: async (): Promise => { try { - const response = await apiClient.get('/cabinet/branding/fullscreen') - return response.data + const response = await apiClient.get('/cabinet/branding/fullscreen'); + return response.data; } catch { // If endpoint doesn't exist, default to disabled - return { enabled: false } + return { enabled: false }; } }, // Update fullscreen enabled (admin only) updateFullscreenEnabled: async (enabled: boolean): Promise => { - const response = await apiClient.patch('/cabinet/branding/fullscreen', { enabled }) - return response.data + const response = await apiClient.patch('/cabinet/branding/fullscreen', { + enabled, + }); + return response.data; }, // Get email auth enabled (public, no auth required) getEmailAuthEnabled: async (): Promise => { try { - const response = await apiClient.get('/cabinet/branding/email-auth') - return response.data + const response = await apiClient.get('/cabinet/branding/email-auth'); + return response.data; } catch { // If endpoint doesn't exist, default to enabled - return { enabled: true } + return { enabled: true }; } }, // Update email auth enabled (admin only) updateEmailAuthEnabled: async (enabled: boolean): Promise => { - const response = await apiClient.patch('/cabinet/branding/email-auth', { enabled }) - return response.data + const response = await apiClient.patch('/cabinet/branding/email-auth', { + enabled, + }); + return response.data; }, // Get analytics counters (public, no auth required) getAnalyticsCounters: async (): Promise => { try { - const response = await apiClient.get('/cabinet/branding/analytics') - return response.data + const response = await apiClient.get('/cabinet/branding/analytics'); + return response.data; } catch { - return { yandex_metrika_id: '', google_ads_id: '', google_ads_label: '' } + return { yandex_metrika_id: '', google_ads_id: '', google_ads_label: '' }; } }, // Update analytics counters (admin only) updateAnalyticsCounters: async (data: Partial): Promise => { - const response = await apiClient.patch('/cabinet/branding/analytics', data) - return response.data + const response = await apiClient.patch('/cabinet/branding/analytics', data); + return response.data; }, -} +}; diff --git a/src/api/campaigns.ts b/src/api/campaigns.ts index 7e381d8..422878a 100644 --- a/src/api/campaigns.ts +++ b/src/api/campaigns.ts @@ -1,230 +1,241 @@ -import apiClient from './client' +import apiClient from './client'; // Types -export type CampaignBonusType = 'balance' | 'subscription' | 'none' | 'tariff' +export type CampaignBonusType = 'balance' | 'subscription' | 'none' | 'tariff'; export interface TariffInfo { - id: number - name: string + id: number; + name: string; } export interface CampaignListItem { - id: number - name: string - start_parameter: string - bonus_type: CampaignBonusType - is_active: boolean - registrations_count: number - total_revenue_kopeks: number - conversion_rate: number - created_at: string + id: number; + name: string; + start_parameter: string; + bonus_type: CampaignBonusType; + is_active: boolean; + registrations_count: number; + total_revenue_kopeks: number; + conversion_rate: number; + created_at: string; } export interface CampaignListResponse { - campaigns: CampaignListItem[] - total: number + campaigns: CampaignListItem[]; + total: number; } export interface CampaignDetail { - id: number - name: string - start_parameter: string - bonus_type: CampaignBonusType - is_active: boolean - balance_bonus_kopeks: number - balance_bonus_rubles: number - subscription_duration_days: number | null - subscription_traffic_gb: number | null - subscription_device_limit: number | null - subscription_squads: string[] - tariff_id: number | null - tariff_duration_days: number | null - tariff: TariffInfo | null - created_by: number | null - created_at: string - updated_at: string | null - deep_link: string | null + id: number; + name: string; + start_parameter: string; + bonus_type: CampaignBonusType; + is_active: boolean; + balance_bonus_kopeks: number; + balance_bonus_rubles: number; + subscription_duration_days: number | null; + subscription_traffic_gb: number | null; + subscription_device_limit: number | null; + subscription_squads: string[]; + tariff_id: number | null; + tariff_duration_days: number | null; + tariff: TariffInfo | null; + created_by: number | null; + created_at: string; + updated_at: string | null; + deep_link: string | null; } export interface CampaignCreateRequest { - name: string - start_parameter: string - bonus_type: CampaignBonusType - is_active?: boolean - balance_bonus_kopeks?: number - subscription_duration_days?: number - subscription_traffic_gb?: number - subscription_device_limit?: number - subscription_squads?: string[] - tariff_id?: number - tariff_duration_days?: number + name: string; + start_parameter: string; + bonus_type: CampaignBonusType; + is_active?: boolean; + balance_bonus_kopeks?: number; + subscription_duration_days?: number; + subscription_traffic_gb?: number; + subscription_device_limit?: number; + subscription_squads?: string[]; + tariff_id?: number; + tariff_duration_days?: number; } export interface CampaignUpdateRequest { - name?: string - start_parameter?: string - bonus_type?: CampaignBonusType - is_active?: boolean - balance_bonus_kopeks?: number - subscription_duration_days?: number - subscription_traffic_gb?: number - subscription_device_limit?: number - subscription_squads?: string[] - tariff_id?: number - tariff_duration_days?: number + name?: string; + start_parameter?: string; + bonus_type?: CampaignBonusType; + is_active?: boolean; + balance_bonus_kopeks?: number; + subscription_duration_days?: number; + subscription_traffic_gb?: number; + subscription_device_limit?: number; + subscription_squads?: string[]; + tariff_id?: number; + tariff_duration_days?: number; } export interface CampaignToggleResponse { - id: number - is_active: boolean - message: string + id: number; + is_active: boolean; + message: string; } export interface CampaignStatistics { - id: number - name: string - start_parameter: string - bonus_type: CampaignBonusType - is_active: boolean - registrations: number - balance_issued_kopeks: number - balance_issued_rubles: number - subscription_issued: number - last_registration: string | null - total_revenue_kopeks: number - total_revenue_rubles: number - avg_revenue_per_user_kopeks: number - avg_revenue_per_user_rubles: number - avg_first_payment_kopeks: number - avg_first_payment_rubles: number - trial_users_count: number - active_trials_count: number - conversion_count: number - paid_users_count: number - conversion_rate: number - trial_conversion_rate: number - deep_link: string | null + id: number; + name: string; + start_parameter: string; + bonus_type: CampaignBonusType; + is_active: boolean; + registrations: number; + balance_issued_kopeks: number; + balance_issued_rubles: number; + subscription_issued: number; + last_registration: string | null; + total_revenue_kopeks: number; + total_revenue_rubles: number; + avg_revenue_per_user_kopeks: number; + avg_revenue_per_user_rubles: number; + avg_first_payment_kopeks: number; + avg_first_payment_rubles: number; + trial_users_count: number; + active_trials_count: number; + conversion_count: number; + paid_users_count: number; + conversion_rate: number; + trial_conversion_rate: number; + deep_link: string | null; } export interface CampaignRegistrationItem { - id: number - user_id: number - telegram_id: number - username: string | null - first_name: string | null - bonus_type: string - balance_bonus_kopeks: number - subscription_duration_days: number | null - tariff_id: number | null - tariff_duration_days: number | null - created_at: string - user_balance_kopeks: number - has_subscription: boolean - has_paid: boolean + id: number; + user_id: number; + telegram_id: number; + username: string | null; + first_name: string | null; + bonus_type: string; + balance_bonus_kopeks: number; + subscription_duration_days: number | null; + tariff_id: number | null; + tariff_duration_days: number | null; + created_at: string; + user_balance_kopeks: number; + has_subscription: boolean; + has_paid: boolean; } export interface CampaignRegistrationsResponse { - registrations: CampaignRegistrationItem[] - total: number - page: number - per_page: number + registrations: CampaignRegistrationItem[]; + total: number; + page: number; + per_page: number; } export interface CampaignsOverview { - total: number - active: number - inactive: number - total_registrations: number - total_balance_issued_kopeks: number - total_balance_issued_rubles: number - total_subscription_issued: number - total_tariff_issued: number + total: number; + active: number; + inactive: number; + total_registrations: number; + total_balance_issued_kopeks: number; + total_balance_issued_rubles: number; + total_subscription_issued: number; + total_tariff_issued: number; } export interface ServerSquadInfo { - id: number - squad_uuid: string - display_name: string - country_code: string | null + id: number; + squad_uuid: string; + display_name: string; + country_code: string | null; } export interface TariffListItem { - id: number - name: string - description: string | null - is_active: boolean - traffic_limit_gb: number - device_limit: number + id: number; + name: string; + description: string | null; + is_active: boolean; + traffic_limit_gb: number; + device_limit: number; } export const campaignsApi = { // Get campaigns overview getOverview: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/campaigns/overview') - return response.data + const response = await apiClient.get('/cabinet/admin/campaigns/overview'); + return response.data; }, // Get all campaigns - getCampaigns: async (includeInactive = true, offset = 0, limit = 50): Promise => { + getCampaigns: async ( + includeInactive = true, + offset = 0, + limit = 50, + ): Promise => { const response = await apiClient.get('/cabinet/admin/campaigns', { - params: { include_inactive: includeInactive, offset, limit } - }) - return response.data + params: { include_inactive: includeInactive, offset, limit }, + }); + return response.data; }, // Get single campaign getCampaign: async (campaignId: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}`); + return response.data; }, // Get campaign statistics getCampaignStats: async (campaignId: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/stats`) - return response.data + const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/stats`); + return response.data; }, // Get campaign registrations - getCampaignRegistrations: async (campaignId: number, page = 1, perPage = 50): Promise => { + getCampaignRegistrations: async ( + campaignId: number, + page = 1, + perPage = 50, + ): Promise => { const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/registrations`, { - params: { page, per_page: perPage } - }) - return response.data + params: { page, per_page: perPage }, + }); + return response.data; }, // Create campaign createCampaign: async (data: CampaignCreateRequest): Promise => { - const response = await apiClient.post('/cabinet/admin/campaigns', data) - return response.data + const response = await apiClient.post('/cabinet/admin/campaigns', data); + return response.data; }, // Update campaign - updateCampaign: async (campaignId: number, data: CampaignUpdateRequest): Promise => { - const response = await apiClient.put(`/cabinet/admin/campaigns/${campaignId}`, data) - return response.data + updateCampaign: async ( + campaignId: number, + data: CampaignUpdateRequest, + ): Promise => { + const response = await apiClient.put(`/cabinet/admin/campaigns/${campaignId}`, data); + return response.data; }, // Delete campaign deleteCampaign: async (campaignId: number): Promise<{ message: string }> => { - const response = await apiClient.delete(`/cabinet/admin/campaigns/${campaignId}`) - return response.data + const response = await apiClient.delete(`/cabinet/admin/campaigns/${campaignId}`); + return response.data; }, // Toggle campaign active status toggleCampaign: async (campaignId: number): Promise => { - const response = await apiClient.post(`/cabinet/admin/campaigns/${campaignId}/toggle`) - return response.data + const response = await apiClient.post(`/cabinet/admin/campaigns/${campaignId}/toggle`); + return response.data; }, // Get available servers for subscription bonus getAvailableServers: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/campaigns/available-servers') - return response.data + const response = await apiClient.get('/cabinet/admin/campaigns/available-servers'); + return response.data; }, // Get available tariffs for tariff bonus getAvailableTariffs: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs') - return response.data + const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs'); + return response.data; }, -} +}; diff --git a/src/api/client.ts b/src/api/client.ts index e65dc3f..53c1f83 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,163 +1,175 @@ -import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' -import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token' -import { useBlockingStore } from '../store/blocking' +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { + tokenStorage, + isTokenExpired, + tokenRefreshManager, + safeRedirectToLogin, +} from '../utils/token'; +import { useBlockingStore } from '../store/blocking'; -const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; // Настраиваем endpoint для refresh -tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`) +tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`); // CSRF token management -const CSRF_COOKIE_NAME = 'csrf_token' -const CSRF_HEADER_NAME = 'X-CSRF-Token' +const CSRF_COOKIE_NAME = 'csrf_token'; +const CSRF_HEADER_NAME = 'X-CSRF-Token'; function getCsrfToken(): string | null { - if (typeof document === 'undefined') return null - const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`)) - return match ? match[2] : null + if (typeof document === 'undefined') return null; + const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`)); + return match ? match[2] : null; } function generateCsrfToken(): string { - const array = new Uint8Array(32) - crypto.getRandomValues(array) - return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('') + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); } function ensureCsrfToken(): string { - let token = getCsrfToken() + let token = getCsrfToken(); if (!token) { - token = generateCsrfToken() + token = generateCsrfToken(); // Set cookie with SameSite=Strict for CSRF protection - document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure` + document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`; } - return token + return token; } const getTelegramInitData = (): string | null => { - if (typeof window === 'undefined') return null + if (typeof window === 'undefined') return null; - const initData = window.Telegram?.WebApp?.initData + const initData = window.Telegram?.WebApp?.initData; if (initData) { - tokenStorage.setTelegramInitData(initData) - return initData + tokenStorage.setTelegramInitData(initData); + return initData; } - return tokenStorage.getTelegramInitData() -} + return tokenStorage.getTelegramInitData(); +}; export const apiClient = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, -}) +}); // Request interceptor - add auth token with expiration check apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { - let token = tokenStorage.getAccessToken() + let token = tokenStorage.getAccessToken(); // Проверяем срок действия токена перед запросом if (token && isTokenExpired(token)) { // Используем централизованный менеджер для refresh - const newToken = await tokenRefreshManager.refreshAccessToken() + const newToken = await tokenRefreshManager.refreshAccessToken(); if (newToken) { - token = newToken + token = newToken; } else { // Refresh не удался - редирект на логин - tokenStorage.clearTokens() - safeRedirectToLogin() - return config + tokenStorage.clearTokens(); + safeRedirectToLogin(); + return config; } } if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}` + config.headers.Authorization = `Bearer ${token}`; } - const telegramInitData = getTelegramInitData() + const telegramInitData = getTelegramInitData(); if (telegramInitData && config.headers) { - config.headers['X-Telegram-Init-Data'] = telegramInitData + config.headers['X-Telegram-Init-Data'] = telegramInitData; } // Add CSRF token for state-changing methods - const method = config.method?.toUpperCase() + const method = config.method?.toUpperCase(); if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) { - config.headers[CSRF_HEADER_NAME] = ensureCsrfToken() + config.headers[CSRF_HEADER_NAME] = ensureCsrfToken(); } - return config -}) + return config; +}); // Custom error types for special handling export interface MaintenanceError { - code: 'maintenance' - message: string - reason?: string + code: 'maintenance'; + message: string; + reason?: string; } export interface ChannelSubscriptionError { - code: 'channel_subscription_required' - message: string - channel_link?: string + code: 'channel_subscription_required'; + message: string; + channel_link?: string; } -export function isMaintenanceError(error: unknown): error is { response: { status: 503, data: { detail: MaintenanceError } } } { - if (!error || typeof error !== 'object') return false - const err = error as AxiosError<{ detail: MaintenanceError }> - return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance' +export function isMaintenanceError( + error: unknown, +): error is { response: { status: 503; data: { detail: MaintenanceError } } } { + if (!error || typeof error !== 'object') return false; + const err = error as AxiosError<{ detail: MaintenanceError }>; + return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance'; } -export function isChannelSubscriptionError(error: unknown): error is { response: { status: 403, data: { detail: ChannelSubscriptionError } } } { - if (!error || typeof error !== 'object') return false - const err = error as AxiosError<{ detail: ChannelSubscriptionError }> - return err.response?.status === 403 && err.response?.data?.detail?.code === 'channel_subscription_required' +export function isChannelSubscriptionError( + error: unknown, +): error is { response: { status: 403; data: { detail: ChannelSubscriptionError } } } { + if (!error || typeof error !== 'object') return false; + const err = error as AxiosError<{ detail: ChannelSubscriptionError }>; + return ( + err.response?.status === 403 && + err.response?.data?.detail?.code === 'channel_subscription_required' + ); } // Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription) apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { - const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; // Handle maintenance mode (503) if (isMaintenanceError(error)) { - const detail = (error.response?.data as { detail: MaintenanceError }).detail + const detail = (error.response?.data as { detail: MaintenanceError }).detail; useBlockingStore.getState().setMaintenance({ message: detail.message, reason: detail.reason, - }) - return Promise.reject(error) + }); + return Promise.reject(error); } // Handle channel subscription required (403) if (isChannelSubscriptionError(error)) { - const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail + const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail; useBlockingStore.getState().setChannelSubscription({ message: detail.message, channel_link: detail.channel_link, - }) - return Promise.reject(error) + }); + return Promise.reject(error); } // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) if (error.response?.status === 401 && !originalRequest._retry) { - originalRequest._retry = true + originalRequest._retry = true; - const newToken = await tokenRefreshManager.refreshAccessToken() + const newToken = await tokenRefreshManager.refreshAccessToken(); if (newToken) { if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${newToken}` + originalRequest.headers.Authorization = `Bearer ${newToken}`; } - return apiClient(originalRequest) + return apiClient(originalRequest); } else { // Refresh не удался - tokenStorage.clearTokens() - safeRedirectToLogin() + tokenStorage.clearTokens(); + safeRedirectToLogin(); } } - return Promise.reject(error) - } -) + return Promise.reject(error); + }, +); -export default apiClient +export default apiClient; diff --git a/src/api/contests.ts b/src/api/contests.ts index 1ef4e55..75f7054 100644 --- a/src/api/contests.ts +++ b/src/api/contests.ts @@ -1,49 +1,50 @@ -import apiClient from './client' +import apiClient from './client'; export interface ContestInfo { - id: number - slug: string - name: string - description: string | null - prize_days: number - is_available: boolean - already_played: boolean + id: number; + slug: string; + name: string; + description: string | null; + prize_days: number; + is_available: boolean; + already_played: boolean; } export interface ContestGameData { - round_id: number - game_type: string - game_data: Record - instructions: string + round_id: number; + game_type: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + game_data: Record; + instructions: string; } export interface ContestResult { - is_winner: boolean - message: string - prize_days?: number + is_winner: boolean; + message: string; + prize_days?: number; } export interface ContestsCountResponse { - count: number + count: number; } export const contestsApi = { // Get count of available contests getCount: async (): Promise => { - const response = await apiClient.get('/cabinet/contests/count') - return response.data + const response = await apiClient.get('/cabinet/contests/count'); + return response.data; }, // Get list of available contests getContests: async (): Promise => { - const response = await apiClient.get('/cabinet/contests') - return response.data + const response = await apiClient.get('/cabinet/contests'); + return response.data; }, // Get game data for a specific contest getContestGame: async (roundId: number): Promise => { - const response = await apiClient.get(`/cabinet/contests/${roundId}`) - return response.data + const response = await apiClient.get(`/cabinet/contests/${roundId}`); + return response.data; }, // Submit answer for a contest @@ -51,7 +52,7 @@ export const contestsApi = { const response = await apiClient.post(`/cabinet/contests/${roundId}/answer`, { round_id: roundId, answer, - }) - return response.data + }); + return response.data; }, -} +}; diff --git a/src/api/currency.ts b/src/api/currency.ts index e5aa9f3..9d66813 100644 --- a/src/api/currency.ts +++ b/src/api/currency.ts @@ -1,46 +1,46 @@ // Currency exchange rate API // Uses free exchangerate.host API -import axios from 'axios' +import axios from 'axios'; interface ExchangeRates { - USD: number - CNY: number - IRR: number + USD: number; + CNY: number; + IRR: number; } interface CachedRates { - rates: ExchangeRates - timestamp: number + rates: ExchangeRates; + timestamp: number; } -const CACHE_DURATION = 60 * 60 * 1000 // 1 hour in milliseconds +const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds // Fallback rates if API fails (approximate) const FALLBACK_RATES: ExchangeRates = { - USD: 100, // 1 USD = 100 RUB - CNY: 14, // 1 CNY = 14 RUB - IRR: 0.0024, // 1 IRR = 0.0024 RUB (or 1 RUB = ~420 IRR) -} + USD: 100, // 1 USD = 100 RUB + CNY: 14, // 1 CNY = 14 RUB + IRR: 0.0024, // 1 IRR = 0.0024 RUB (or 1 RUB = ~420 IRR) +}; -let cachedRates: CachedRates | null = null +let cachedRates: CachedRates | null = null; export const currencyApi = { // Get all exchange rates (RUB to other currencies) getExchangeRates: async (): Promise => { // Check cache first if (cachedRates && Date.now() - cachedRates.timestamp < CACHE_DURATION) { - return cachedRates.rates + return cachedRates.rates; } try { // Try primary API (exchangerate.host) - get RUB based rates const response = await axios.get<{ - success?: boolean - rates?: { USD?: number; CNY?: number; IRR?: number } + success?: boolean; + rates?: { USD?: number; CNY?: number; IRR?: number }; }>('https://api.exchangerate.host/latest', { params: { base: 'RUB', symbols: 'USD,CNY,IRR' }, - }) + }); if (response.data.success && response.data.rates) { // API returns how much of each currency equals 1 RUB @@ -49,51 +49,65 @@ export const currencyApi = { USD: response.data.rates.USD ? 1 / response.data.rates.USD : FALLBACK_RATES.USD, CNY: response.data.rates.CNY ? 1 / response.data.rates.CNY : FALLBACK_RATES.CNY, IRR: response.data.rates.IRR ? 1 / response.data.rates.IRR : FALLBACK_RATES.IRR, - } - cachedRates = { rates, timestamp: Date.now() } - return rates + }; + cachedRates = { rates, timestamp: Date.now() }; + return rates; } // Try backup API (open.er-api.com) const backupResponse = await axios.get<{ - rates?: { USD?: number; CNY?: number; IRR?: number } - }>('https://open.er-api.com/v6/latest/RUB') + rates?: { USD?: number; CNY?: number; IRR?: number }; + }>('https://open.er-api.com/v6/latest/RUB'); if (backupResponse.data.rates) { const rates: ExchangeRates = { - USD: backupResponse.data.rates.USD ? 1 / backupResponse.data.rates.USD : FALLBACK_RATES.USD, - CNY: backupResponse.data.rates.CNY ? 1 / backupResponse.data.rates.CNY : FALLBACK_RATES.CNY, - IRR: backupResponse.data.rates.IRR ? 1 / backupResponse.data.rates.IRR : FALLBACK_RATES.IRR, - } - cachedRates = { rates, timestamp: Date.now() } - return rates + USD: backupResponse.data.rates.USD + ? 1 / backupResponse.data.rates.USD + : FALLBACK_RATES.USD, + CNY: backupResponse.data.rates.CNY + ? 1 / backupResponse.data.rates.CNY + : FALLBACK_RATES.CNY, + IRR: backupResponse.data.rates.IRR + ? 1 / backupResponse.data.rates.IRR + : FALLBACK_RATES.IRR, + }; + cachedRates = { rates, timestamp: Date.now() }; + return rates; } // Return fallback rates if both APIs fail - return FALLBACK_RATES + return FALLBACK_RATES; } catch (error) { - console.warn('Failed to fetch exchange rates, using fallback:', error) - return FALLBACK_RATES + console.warn('Failed to fetch exchange rates, using fallback:', error); + return FALLBACK_RATES; } }, // Convert RUB to target currency - convertFromRub: (rubAmount: number, targetCurrency: keyof ExchangeRates, rates: ExchangeRates): number => { - const rate = rates[targetCurrency] + convertFromRub: ( + rubAmount: number, + targetCurrency: keyof ExchangeRates, + rates: ExchangeRates, + ): number => { + const rate = rates[targetCurrency]; if (!rate || rate <= 0) { - return rubAmount / FALLBACK_RATES[targetCurrency] + return rubAmount / FALLBACK_RATES[targetCurrency]; } - return rubAmount / rate + return rubAmount / rate; }, // Convert from target currency to RUB - convertToRub: (amount: number, sourceCurrency: keyof ExchangeRates, rates: ExchangeRates): number => { - const rate = rates[sourceCurrency] + convertToRub: ( + amount: number, + sourceCurrency: keyof ExchangeRates, + rates: ExchangeRates, + ): number => { + const rate = rates[sourceCurrency]; if (!rate || rate <= 0) { - return amount * FALLBACK_RATES[sourceCurrency] + return amount * FALLBACK_RATES[sourceCurrency]; } - return amount * rate + return amount * rate; }, -} +}; -export type { ExchangeRates } +export type { ExchangeRates }; diff --git a/src/api/index.ts b/src/api/index.ts index 9af91c2..404dcce 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,12 +1,12 @@ -export { apiClient } from './client' -export { authApi } from './auth' -export { subscriptionApi } from './subscription' -export { balanceApi } from './balance' -export { referralApi } from './referral' -export { ticketsApi } from './tickets' -export { contestsApi } from './contests' -export { pollsApi } from './polls' -export { promoApi } from './promo' -export { notificationsApi } from './notifications' -export { infoApi } from './info' -export { adminSettingsApi } from './adminSettings' +export { apiClient } from './client'; +export { authApi } from './auth'; +export { subscriptionApi } from './subscription'; +export { balanceApi } from './balance'; +export { referralApi } from './referral'; +export { ticketsApi } from './tickets'; +export { contestsApi } from './contests'; +export { pollsApi } from './polls'; +export { promoApi } from './promo'; +export { notificationsApi } from './notifications'; +export { infoApi } from './info'; +export { adminSettingsApi } from './adminSettings'; diff --git a/src/api/info.ts b/src/api/info.ts index 0e28bf9..7ccf50d 100644 --- a/src/api/info.ts +++ b/src/api/info.ts @@ -1,102 +1,102 @@ -import apiClient from './client' -import type { SupportConfig } from '../types' +import apiClient from './client'; +import type { SupportConfig } from '../types'; export interface FaqPage { - id: number - title: string - content: string - order: number + id: number; + title: string; + content: string; + order: number; } export interface RulesResponse { - content: string - updated_at: string | null + content: string; + updated_at: string | null; } export interface PrivacyPolicyResponse { - content: string - updated_at: string | null + content: string; + updated_at: string | null; } export interface PublicOfferResponse { - content: string - updated_at: string | null + content: string; + updated_at: string | null; } export interface ServiceInfo { - name: string - description: string | null - support_email: string | null - support_telegram: string | null - website: string | null + name: string; + description: string | null; + support_email: string | null; + support_telegram: string | null; + website: string | null; } export interface LanguageInfo { - code: string - name: string - flag: string + code: string; + name: string; + flag: string; } export const infoApi = { // Get FAQ pages list getFaqPages: async (): Promise => { - const response = await apiClient.get('/cabinet/info/faq') - return response.data + const response = await apiClient.get('/cabinet/info/faq'); + return response.data; }, // Get specific FAQ page getFaqPage: async (pageId: number): Promise => { - const response = await apiClient.get(`/cabinet/info/faq/${pageId}`) - return response.data + const response = await apiClient.get(`/cabinet/info/faq/${pageId}`); + return response.data; }, // Get service rules getRules: async (): Promise => { - const response = await apiClient.get('/cabinet/info/rules') - return response.data + const response = await apiClient.get('/cabinet/info/rules'); + return response.data; }, // Get privacy policy getPrivacyPolicy: async (): Promise => { - const response = await apiClient.get('/cabinet/info/privacy-policy') - return response.data + const response = await apiClient.get('/cabinet/info/privacy-policy'); + return response.data; }, // Get public offer getPublicOffer: async (): Promise => { - const response = await apiClient.get('/cabinet/info/public-offer') - return response.data + const response = await apiClient.get('/cabinet/info/public-offer'); + return response.data; }, // Get service info getServiceInfo: async (): Promise => { - const response = await apiClient.get('/cabinet/info/service') - return response.data + const response = await apiClient.get('/cabinet/info/service'); + return response.data; }, // Get available languages getLanguages: async (): Promise<{ languages: LanguageInfo[]; default: string }> => { - const response = await apiClient.get('/cabinet/info/languages') - return response.data + const response = await apiClient.get('/cabinet/info/languages'); + return response.data; }, // Get user language getUserLanguage: async (): Promise<{ language: string }> => { - const response = await apiClient.get<{ language: string }>('/cabinet/info/user/language') - return response.data + const response = await apiClient.get<{ language: string }>('/cabinet/info/user/language'); + return response.data; }, // Update user language updateUserLanguage: async (language: string): Promise<{ language: string }> => { const response = await apiClient.patch<{ language: string }>('/cabinet/info/user/language', { language, - }) - return response.data + }); + return response.data; }, // Get support configuration getSupportConfig: async (): Promise => { - const response = await apiClient.get('/cabinet/info/support-config') - return response.data + const response = await apiClient.get('/cabinet/info/support-config'); + return response.data; }, -} +}; diff --git a/src/api/miniapp.ts b/src/api/miniapp.ts index 0801c30..6346b33 100644 --- a/src/api/miniapp.ts +++ b/src/api/miniapp.ts @@ -1,22 +1,22 @@ -import axios, { AxiosError } from 'axios' +import axios, { AxiosError } from 'axios'; export interface MiniappCreatePaymentPayload { - initData: string - method: string - amountKopeks?: number | null - option?: string | null + initData: string; + method: string; + amountKopeks?: number | null; + option?: string | null; } export interface MiniappCreatePaymentResponse { - payment_url: string - amount_kopeks?: number - extra?: Record + payment_url: string; + amount_kopeks?: number; + extra?: Record; } export const miniappApi = { // Create payment inside Telegram Mini App (same flow as miniapp/index.html) createPayment: async ( - payload: MiniappCreatePaymentPayload + payload: MiniappCreatePaymentPayload, ): Promise => { try { const response = await axios.post( @@ -29,15 +29,16 @@ export const miniappApi = { }, { headers: { 'Content-Type': 'application/json' }, - } - ) - return response.data + }, + ); + return response.data; } catch (error) { - const axiosError = error as AxiosError<{ detail?: string; message?: string }> - const message = axiosError.response?.data?.detail - || axiosError.response?.data?.message - || 'Failed to create payment' - throw new Error(message) + const axiosError = error as AxiosError<{ detail?: string; message?: string }>; + const message = + axiosError.response?.data?.detail || + axiosError.response?.data?.message || + 'Failed to create payment'; + throw new Error(message); } }, -} +}; diff --git a/src/api/notifications.ts b/src/api/notifications.ts index b6306b8..93a326c 100644 --- a/src/api/notifications.ts +++ b/src/api/notifications.ts @@ -1,58 +1,64 @@ -import apiClient from './client' +import apiClient from './client'; export interface NotificationSettings { - subscription_expiry_enabled: boolean - subscription_expiry_days: number - traffic_warning_enabled: boolean - traffic_warning_percent: number - balance_low_enabled: boolean - balance_low_threshold: number - news_enabled: boolean - promo_offers_enabled: boolean + subscription_expiry_enabled: boolean; + subscription_expiry_days: number; + traffic_warning_enabled: boolean; + traffic_warning_percent: number; + balance_low_enabled: boolean; + balance_low_threshold: number; + news_enabled: boolean; + promo_offers_enabled: boolean; } export interface NotificationSettingsUpdate { - subscription_expiry_enabled?: boolean - subscription_expiry_days?: number - traffic_warning_enabled?: boolean - traffic_warning_percent?: number - balance_low_enabled?: boolean - balance_low_threshold?: number - news_enabled?: boolean - promo_offers_enabled?: boolean + subscription_expiry_enabled?: boolean; + subscription_expiry_days?: number; + traffic_warning_enabled?: boolean; + traffic_warning_percent?: number; + balance_low_enabled?: boolean; + balance_low_threshold?: number; + news_enabled?: boolean; + promo_offers_enabled?: boolean; } export const notificationsApi = { // Get notification settings getSettings: async (): Promise => { - const response = await apiClient.get('/cabinet/notifications') - return response.data + const response = await apiClient.get('/cabinet/notifications'); + return response.data; }, // Update notification settings updateSettings: async (settings: NotificationSettingsUpdate): Promise => { - const response = await apiClient.patch('/cabinet/notifications', settings) - return response.data + const response = await apiClient.patch( + '/cabinet/notifications', + settings, + ); + return response.data; }, // Send test notification sendTestNotification: async (): Promise<{ success: boolean; message: string }> => { const response = await apiClient.post<{ success: boolean; message: string }>( - '/cabinet/notifications/test' - ) - return response.data + '/cabinet/notifications/test', + ); + return response.data; }, // Get notification history - getHistory: async (limit = 20, offset = 0): Promise<{ - notifications: any[] - total: number - limit: number - offset: number + getHistory: async ( + limit = 20, + offset = 0, + ): Promise<{ + notifications: unknown[]; + total: number; + limit: number; + offset: number; }> => { const response = await apiClient.get('/cabinet/notifications/history', { params: { limit, offset }, - }) - return response.data + }); + return response.data; }, -} +}; diff --git a/src/api/polls.ts b/src/api/polls.ts index e942265..af2bd9a 100644 --- a/src/api/polls.ts +++ b/src/api/polls.ts @@ -1,85 +1,85 @@ -import apiClient from './client' +import apiClient from './client'; export interface PollOption { - id: number - text: string - order: number + id: number; + text: string; + order: number; } export interface PollQuestion { - id: number - text: string - order: number - options: PollOption[] + id: number; + text: string; + order: number; + options: PollOption[]; } export interface PollInfo { - id: number - response_id: number - title: string - description: string | null - total_questions: number - answered_questions: number - is_completed: boolean - reward_amount: number | null + id: number; + response_id: number; + title: string; + description: string | null; + total_questions: number; + answered_questions: number; + is_completed: boolean; + reward_amount: number | null; } export interface PollStartResponse { - response_id: number - current_question_index: number - total_questions: number - question: PollQuestion + response_id: number; + current_question_index: number; + total_questions: number; + question: PollQuestion; } export interface PollAnswerResponse { - success: boolean - is_completed: boolean - next_question: PollQuestion | null - current_question_index: number | null - total_questions: number - reward_granted: number | null - message: string | null + success: boolean; + is_completed: boolean; + next_question: PollQuestion | null; + current_question_index: number | null; + total_questions: number; + reward_granted: number | null; + message: string | null; } export interface PollsCountResponse { - count: number + count: number; } export const pollsApi = { // Get count of available polls getCount: async (): Promise => { - const response = await apiClient.get('/cabinet/polls/count') - return response.data + const response = await apiClient.get('/cabinet/polls/count'); + return response.data; }, // Get available polls getPolls: async (): Promise => { - const response = await apiClient.get('/cabinet/polls') - return response.data + const response = await apiClient.get('/cabinet/polls'); + return response.data; }, // Get poll details getPollDetails: async (responseId: number): Promise => { - const response = await apiClient.get(`/cabinet/polls/${responseId}`) - return response.data + const response = await apiClient.get(`/cabinet/polls/${responseId}`); + return response.data; }, // Start or continue poll startPoll: async (responseId: number): Promise => { - const response = await apiClient.post(`/cabinet/polls/${responseId}/start`) - return response.data + const response = await apiClient.post(`/cabinet/polls/${responseId}/start`); + return response.data; }, // Answer a question answerQuestion: async ( responseId: number, questionId: number, - optionId: number + optionId: number, ): Promise => { const response = await apiClient.post( `/cabinet/polls/${responseId}/questions/${questionId}/answer`, - { option_id: optionId } - ) - return response.data + { option_id: optionId }, + ); + return response.data; }, -} +}; diff --git a/src/api/promo.ts b/src/api/promo.ts index 9512378..9e82625 100644 --- a/src/api/promo.ts +++ b/src/api/promo.ts @@ -1,96 +1,97 @@ -import apiClient from './client' +import apiClient from './client'; export interface PromoOffer { - id: number - notification_type: string - discount_percent: number | null - effect_type: string - expires_at: string - is_active: boolean - is_claimed: boolean - claimed_at: string | null - extra_data: Record | null + id: number; + notification_type: string; + discount_percent: number | null; + effect_type: string; + expires_at: string; + is_active: boolean; + is_claimed: boolean; + claimed_at: string | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extra_data: Record | null; } export interface ActiveDiscount { - discount_percent: number - source: string | null - expires_at: string | null - is_active: boolean + discount_percent: number; + source: string | null; + expires_at: string | null; + is_active: boolean; } export interface PromoGroupDiscounts { - group_name: string | null - server_discount_percent: number - traffic_discount_percent: number - device_discount_percent: number - period_discounts: Record + group_name: string | null; + server_discount_percent: number; + traffic_discount_percent: number; + device_discount_percent: number; + period_discounts: Record; } export interface ClaimOfferResponse { - success: boolean - message: string - discount_percent: number | null - expires_at: string | null + success: boolean; + message: string; + discount_percent: number | null; + expires_at: string | null; } export interface LoyaltyTierInfo { - id: number - name: string - threshold_rubles: number - server_discount_percent: number - traffic_discount_percent: number - device_discount_percent: number - period_discounts: Record - is_current: boolean - is_achieved: boolean + id: number; + name: string; + threshold_rubles: number; + server_discount_percent: number; + traffic_discount_percent: number; + device_discount_percent: number; + period_discounts: Record; + is_current: boolean; + is_achieved: boolean; } export interface LoyaltyTiersResponse { - tiers: LoyaltyTierInfo[] - current_spent_rubles: number - current_tier_name: string | null - next_tier_name: string | null - next_tier_threshold_rubles: number | null - progress_percent: number + tiers: LoyaltyTierInfo[]; + current_spent_rubles: number; + current_tier_name: string | null; + next_tier_name: string | null; + next_tier_threshold_rubles: number | null; + progress_percent: number; } export const promoApi = { // Get available promo offers getOffers: async (): Promise => { - const response = await apiClient.get('/cabinet/promo/offers') - return response.data + const response = await apiClient.get('/cabinet/promo/offers'); + return response.data; }, // Get active discount getActiveDiscount: async (): Promise => { - const response = await apiClient.get('/cabinet/promo/active-discount') - return response.data + const response = await apiClient.get('/cabinet/promo/active-discount'); + return response.data; }, // Get promo group discounts getGroupDiscounts: async (): Promise => { - const response = await apiClient.get('/cabinet/promo/group-discounts') - return response.data + const response = await apiClient.get('/cabinet/promo/group-discounts'); + return response.data; }, // Get loyalty tiers (promo groups with spending thresholds) getLoyaltyTiers: async (): Promise => { - const response = await apiClient.get('/cabinet/promo/loyalty-tiers') - return response.data + const response = await apiClient.get('/cabinet/promo/loyalty-tiers'); + return response.data; }, // Claim a promo offer claimOffer: async (offerId: number): Promise => { const response = await apiClient.post('/cabinet/promo/claim', { offer_id: offerId, - }) - return response.data + }); + return response.data; }, // Clear active discount clearActiveDiscount: async (): Promise<{ message: string }> => { - const response = await apiClient.delete<{ message: string }>('/cabinet/promo/active-discount') - return response.data + const response = await apiClient.delete<{ message: string }>('/cabinet/promo/active-discount'); + return response.data; }, -} +}; diff --git a/src/api/promoOffers.ts b/src/api/promoOffers.ts index 7952426..a4a8088 100644 --- a/src/api/promoOffers.ts +++ b/src/api/promoOffers.ts @@ -1,139 +1,140 @@ -import apiClient from './client' +import apiClient from './client'; // ============== Types ============== export interface PromoOfferUserInfo { - id: number - telegram_id: number - username: string | null - first_name: string | null - last_name: string | null - full_name: string | null + id: number; + telegram_id: number; + username: string | null; + first_name: string | null; + last_name: string | null; + full_name: string | null; } export interface PromoOfferSubscriptionInfo { - id: number - status: string - is_trial: boolean - start_date: string - end_date: string - autopay_enabled: boolean + id: number; + status: string; + is_trial: boolean; + start_date: string; + end_date: string; + autopay_enabled: boolean; } export interface PromoOffer { - id: number - user_id: number - subscription_id: number | null - notification_type: string - discount_percent: number - bonus_amount_kopeks: number - expires_at: string - claimed_at: string | null - is_active: boolean - effect_type: string - extra_data: Record - created_at: string - updated_at: string - user: PromoOfferUserInfo | null - subscription: PromoOfferSubscriptionInfo | null + id: number; + user_id: number; + subscription_id: number | null; + notification_type: string; + discount_percent: number; + bonus_amount_kopeks: number; + expires_at: string; + claimed_at: string | null; + is_active: boolean; + effect_type: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extra_data: Record; + created_at: string; + updated_at: string; + user: PromoOfferUserInfo | null; + subscription: PromoOfferSubscriptionInfo | null; } export interface PromoOfferListResponse { - items: PromoOffer[] - total: number - limit: number - offset: number + items: PromoOffer[]; + total: number; + limit: number; + offset: number; } export interface PromoOfferBroadcastRequest { - notification_type: string - valid_hours: number - discount_percent?: number - bonus_amount_kopeks?: number - effect_type?: string - extra_data?: Record - target?: string - user_id?: number - telegram_id?: number + notification_type: string; + valid_hours: number; + discount_percent?: number; + bonus_amount_kopeks?: number; + effect_type?: string; + extra_data?: Record; + target?: string; + user_id?: number; + telegram_id?: number; // Telegram notification options - send_notification?: boolean - message_text?: string - button_text?: string + send_notification?: boolean; + message_text?: string; + button_text?: string; } export interface PromoOfferBroadcastResponse { - created_offers: number - user_ids: number[] - target: string | null - notifications_sent: number - notifications_failed: number + created_offers: number; + user_ids: number[]; + target: string | null; + notifications_sent: number; + notifications_failed: number; } export interface PromoOfferTemplate { - id: number - name: string - offer_type: string - message_text: string - button_text: string - valid_hours: number - discount_percent: number - bonus_amount_kopeks: number - active_discount_hours: number | null - test_duration_hours: number | null - test_squad_uuids: string[] - is_active: boolean - created_by: number | null - created_at: string - updated_at: string + id: number; + name: string; + offer_type: string; + message_text: string; + button_text: string; + valid_hours: number; + discount_percent: number; + bonus_amount_kopeks: number; + active_discount_hours: number | null; + test_duration_hours: number | null; + test_squad_uuids: string[]; + is_active: boolean; + created_by: number | null; + created_at: string; + updated_at: string; } export interface PromoOfferTemplateListResponse { - items: PromoOfferTemplate[] + items: PromoOfferTemplate[]; } export interface PromoOfferTemplateUpdateRequest { - name?: string - message_text?: string - button_text?: string - valid_hours?: number - discount_percent?: number - bonus_amount_kopeks?: number - active_discount_hours?: number - test_duration_hours?: number - test_squad_uuids?: string[] - is_active?: boolean + name?: string; + message_text?: string; + button_text?: string; + valid_hours?: number; + discount_percent?: number; + bonus_amount_kopeks?: number; + active_discount_hours?: number; + test_duration_hours?: number; + test_squad_uuids?: string[]; + is_active?: boolean; } export interface PromoOfferLogOfferInfo { - id: number - notification_type: string | null - discount_percent: number | null - bonus_amount_kopeks: number | null - effect_type: string | null - expires_at: string | null - claimed_at: string | null - is_active: boolean | null + id: number; + notification_type: string | null; + discount_percent: number | null; + bonus_amount_kopeks: number | null; + effect_type: string | null; + expires_at: string | null; + claimed_at: string | null; + is_active: boolean | null; } export interface PromoOfferLog { - id: number - user_id: number | null - offer_id: number | null - action: string - source: string | null - percent: number | null - effect_type: string | null - details: Record - created_at: string - user: PromoOfferUserInfo | null - offer: PromoOfferLogOfferInfo | null + id: number; + user_id: number | null; + offer_id: number | null; + action: string; + source: string | null; + percent: number | null; + effect_type: string | null; + details: Record; + created_at: string; + user: PromoOfferUserInfo | null; + offer: PromoOfferLogOfferInfo | null; } export interface PromoOfferLogListResponse { - items: PromoOfferLog[] - total: number - limit: number - offset: number + items: PromoOfferLog[]; + total: number; + limit: number; + offset: number; } // Target segments for broadcast @@ -154,9 +155,9 @@ export const TARGET_SEGMENTS = { custom_week: 'Зарегистрированы за неделю', custom_month: 'Зарегистрированы за месяц', custom_active_today: 'Активны сегодня', -} as const +} as const; -export type TargetSegment = keyof typeof TARGET_SEGMENTS +export type TargetSegment = keyof typeof TARGET_SEGMENTS; // Offer type configurations export const OFFER_TYPE_CONFIG = { @@ -178,56 +179,61 @@ export const OFFER_TYPE_CONFIG = { effect: 'percent_discount', description: 'Скидка для новых пользователей', }, -} as const +} as const; -export type OfferType = keyof typeof OFFER_TYPE_CONFIG +export type OfferType = keyof typeof OFFER_TYPE_CONFIG; // ============== API ============== export const promoOffersApi = { // Get list of promo offers getOffers: async (params?: { - limit?: number - offset?: number - user_id?: number - is_active?: boolean + limit?: number; + offset?: number; + user_id?: number; + is_active?: boolean; }): Promise => { - const response = await apiClient.get('/cabinet/admin/promo-offers', { params }) - return response.data + const response = await apiClient.get('/cabinet/admin/promo-offers', { params }); + return response.data; }, // Broadcast offer to multiple users - broadcastOffer: async (data: PromoOfferBroadcastRequest): Promise => { - const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data) - return response.data + broadcastOffer: async ( + data: PromoOfferBroadcastRequest, + ): Promise => { + const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data); + return response.data; }, // Get promo offer logs getLogs: async (params?: { - limit?: number - offset?: number - user_id?: number - action?: string + limit?: number; + offset?: number; + user_id?: number; + action?: string; }): Promise => { - const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params }) - return response.data + const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params }); + return response.data; }, // Get all templates getTemplates: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/promo-offers/templates') - return response.data + const response = await apiClient.get('/cabinet/admin/promo-offers/templates'); + return response.data; }, // Get single template getTemplate: async (id: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`); + return response.data; }, // Update template - updateTemplate: async (id: number, data: PromoOfferTemplateUpdateRequest): Promise => { - const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data) - return response.data + updateTemplate: async ( + id: number, + data: PromoOfferTemplateUpdateRequest, + ): Promise => { + const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data); + return response.data; }, -} +}; diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts index b1ecb2c..83acb23 100644 --- a/src/api/promocodes.ts +++ b/src/api/promocodes.ts @@ -1,122 +1,127 @@ -import apiClient from './client' +import apiClient from './client'; // ============== Types ============== -export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group' | 'discount' +export type PromoCodeType = + | 'balance' + | 'subscription_days' + | 'trial_subscription' + | 'promo_group' + | 'discount'; export interface PromoCode { - id: number - code: string - type: PromoCodeType - balance_bonus_kopeks: number - balance_bonus_rubles: number - subscription_days: number - max_uses: number - current_uses: number - uses_left: number - is_active: boolean - is_valid: boolean - first_purchase_only: boolean - valid_from: string - valid_until: string | null - promo_group_id: number | null - created_by: number | null - created_at: string - updated_at: string + id: number; + code: string; + type: PromoCodeType; + balance_bonus_kopeks: number; + balance_bonus_rubles: number; + subscription_days: number; + max_uses: number; + current_uses: number; + uses_left: number; + is_active: boolean; + is_valid: boolean; + first_purchase_only: boolean; + valid_from: string; + valid_until: string | null; + promo_group_id: number | null; + created_by: number | null; + created_at: string; + updated_at: string; } export interface PromoCodeRecentUse { - id: number - user_id: number - user_username: string | null - user_full_name: string | null - user_telegram_id: number | null - used_at: string + id: number; + user_id: number; + user_username: string | null; + user_full_name: string | null; + user_telegram_id: number | null; + used_at: string; } export interface PromoCodeDetail extends PromoCode { - total_uses: number - today_uses: number - recent_uses: PromoCodeRecentUse[] + total_uses: number; + today_uses: number; + recent_uses: PromoCodeRecentUse[]; } export interface PromoCodeListResponse { - items: PromoCode[] - total: number - limit: number - offset: number + items: PromoCode[]; + total: number; + limit: number; + offset: number; } export interface PromoCodeCreateRequest { - code: string - type: PromoCodeType - balance_bonus_kopeks?: number - subscription_days?: number - max_uses?: number - valid_from?: string - valid_until?: string | null - is_active?: boolean - first_purchase_only?: boolean - promo_group_id?: number | null + code: string; + type: PromoCodeType; + balance_bonus_kopeks?: number; + subscription_days?: number; + max_uses?: number; + valid_from?: string; + valid_until?: string | null; + is_active?: boolean; + first_purchase_only?: boolean; + promo_group_id?: number | null; } export interface PromoCodeUpdateRequest { - code?: string - type?: PromoCodeType - balance_bonus_kopeks?: number - subscription_days?: number - max_uses?: number - valid_from?: string - valid_until?: string | null - is_active?: boolean - first_purchase_only?: boolean - promo_group_id?: number | null + code?: string; + type?: PromoCodeType; + balance_bonus_kopeks?: number; + subscription_days?: number; + max_uses?: number; + valid_from?: string; + valid_until?: string | null; + is_active?: boolean; + first_purchase_only?: boolean; + promo_group_id?: number | null; } // ============== PromoGroup Types ============== export interface PromoGroup { - id: number - name: string - server_discount_percent: number - traffic_discount_percent: number - device_discount_percent: number - period_discounts: Record - auto_assign_total_spent_kopeks: number | null - apply_discounts_to_addons: boolean - is_default: boolean - members_count: number - created_at: string | null - updated_at: string | null + id: number; + name: string; + server_discount_percent: number; + traffic_discount_percent: number; + device_discount_percent: number; + period_discounts: Record; + auto_assign_total_spent_kopeks: number | null; + apply_discounts_to_addons: boolean; + is_default: boolean; + members_count: number; + created_at: string | null; + updated_at: string | null; } export interface PromoGroupListResponse { - items: PromoGroup[] - total: number - limit: number - offset: number + items: PromoGroup[]; + total: number; + limit: number; + offset: number; } export interface PromoGroupCreateRequest { - name: string - server_discount_percent?: number - traffic_discount_percent?: number - device_discount_percent?: number - period_discounts?: Record - auto_assign_total_spent_kopeks?: number | null - apply_discounts_to_addons?: boolean - is_default?: boolean + name: string; + server_discount_percent?: number; + traffic_discount_percent?: number; + device_discount_percent?: number; + period_discounts?: Record; + auto_assign_total_spent_kopeks?: number | null; + apply_discounts_to_addons?: boolean; + is_default?: boolean; } export interface PromoGroupUpdateRequest { - name?: string - server_discount_percent?: number - traffic_discount_percent?: number - device_discount_percent?: number - period_discounts?: Record - auto_assign_total_spent_kopeks?: number | null - apply_discounts_to_addons?: boolean - is_default?: boolean + name?: string; + server_discount_percent?: number; + traffic_discount_percent?: number; + device_discount_percent?: number; + period_discounts?: Record; + auto_assign_total_spent_kopeks?: number | null; + apply_discounts_to_addons?: boolean; + is_default?: boolean; } // ============== API ============== @@ -124,58 +129,58 @@ export interface PromoGroupUpdateRequest { export const promocodesApi = { // Promocodes getPromocodes: async (params?: { - limit?: number - offset?: number - is_active?: boolean + limit?: number; + offset?: number; + is_active?: boolean; }): Promise => { - const response = await apiClient.get('/cabinet/admin/promocodes', { params }) - return response.data + const response = await apiClient.get('/cabinet/admin/promocodes', { params }); + return response.data; }, getPromocode: async (id: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`); + return response.data; }, createPromocode: async (data: PromoCodeCreateRequest): Promise => { - const response = await apiClient.post('/cabinet/admin/promocodes', data) - return response.data + const response = await apiClient.post('/cabinet/admin/promocodes', data); + return response.data; }, updatePromocode: async (id: number, data: PromoCodeUpdateRequest): Promise => { - const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data) - return response.data + const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data); + return response.data; }, deletePromocode: async (id: number): Promise => { - await apiClient.delete(`/cabinet/admin/promocodes/${id}`) + await apiClient.delete(`/cabinet/admin/promocodes/${id}`); }, // Promo Groups getPromoGroups: async (params?: { - limit?: number - offset?: number + limit?: number; + offset?: number; }): Promise => { - const response = await apiClient.get('/cabinet/admin/promo-groups', { params }) - return response.data + const response = await apiClient.get('/cabinet/admin/promo-groups', { params }); + return response.data; }, getPromoGroup: async (id: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`); + return response.data; }, createPromoGroup: async (data: PromoGroupCreateRequest): Promise => { - const response = await apiClient.post('/cabinet/admin/promo-groups', data) - return response.data + const response = await apiClient.post('/cabinet/admin/promo-groups', data); + return response.data; }, updatePromoGroup: async (id: number, data: PromoGroupUpdateRequest): Promise => { - const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data) - return response.data + const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data); + return response.data; }, deletePromoGroup: async (id: number): Promise => { - await apiClient.delete(`/cabinet/admin/promo-groups/${id}`) + await apiClient.delete(`/cabinet/admin/promo-groups/${id}`); }, -} +}; diff --git a/src/api/referral.ts b/src/api/referral.ts index 083f7b7..112c014 100644 --- a/src/api/referral.ts +++ b/src/api/referral.ts @@ -1,62 +1,65 @@ -import apiClient from './client' -import type { ReferralInfo, ReferralTerms, PaginatedResponse } from '../types' +import apiClient from './client'; +import type { ReferralInfo, ReferralTerms, PaginatedResponse } from '../types'; interface ReferralItem { - id: number - username: string | null - first_name: string | null - created_at: string - has_subscription: boolean - has_paid: boolean + id: number; + username: string | null; + first_name: string | null; + created_at: string; + has_subscription: boolean; + has_paid: boolean; } interface ReferralEarning { - id: number - amount_kopeks: number - amount_rubles: number - reason: string - referral_username: string | null - referral_first_name: string | null - created_at: string + id: number; + amount_kopeks: number; + amount_rubles: number; + reason: string; + referral_username: string | null; + referral_first_name: string | null; + created_at: string; } interface ReferralEarningsList extends PaginatedResponse { - total_amount_kopeks: number - total_amount_rubles: number + total_amount_kopeks: number; + total_amount_rubles: number; } export const referralApi = { // Get referral info getReferralInfo: async (): Promise => { - const response = await apiClient.get('/cabinet/referral') - return response.data + const response = await apiClient.get('/cabinet/referral'); + return response.data; }, // Get referral list getReferralList: async (params?: { - page?: number - per_page?: number + page?: number; + per_page?: number; }): Promise> => { - const response = await apiClient.get>('/cabinet/referral/list', { - params, - }) - return response.data + const response = await apiClient.get>( + '/cabinet/referral/list', + { + params, + }, + ); + return response.data; }, // Get referral earnings getReferralEarnings: async (params?: { - page?: number - per_page?: number + page?: number; + per_page?: number; }): Promise => { const response = await apiClient.get('/cabinet/referral/earnings', { params, - }) - return response.data + }); + return response.data; }, // Get referral terms getReferralTerms: async (): Promise => { - const response = await apiClient.get('/cabinet/referral/terms') - return response.data + const response = await apiClient.get('/cabinet/referral/terms'); + return response.data; }, -} +}; diff --git a/src/api/servers.ts b/src/api/servers.ts index e175bf2..02fae00 100644 --- a/src/api/servers.ts +++ b/src/api/servers.ts @@ -1,142 +1,142 @@ -import apiClient from './client' +import apiClient from './client'; // Types export interface PromoGroupInfo { - id: number - name: string - is_selected: boolean + id: number; + name: string; + is_selected: boolean; } export interface ServerListItem { - id: number - squad_uuid: string - display_name: string - original_name: string | null - country_code: string | null - is_available: boolean - is_trial_eligible: boolean - price_kopeks: number - price_rubles: number - max_users: number | null - current_users: number - sort_order: number - is_full: boolean - availability_status: string - created_at: string + id: number; + squad_uuid: string; + display_name: string; + original_name: string | null; + country_code: string | null; + is_available: boolean; + is_trial_eligible: boolean; + price_kopeks: number; + price_rubles: number; + max_users: number | null; + current_users: number; + sort_order: number; + is_full: boolean; + availability_status: string; + created_at: string; } export interface ServerListResponse { - servers: ServerListItem[] - total: number + servers: ServerListItem[]; + total: number; } export interface ServerDetail { - id: number - squad_uuid: string - display_name: string - original_name: string | null - country_code: string | null - description: string | null - is_available: boolean - is_trial_eligible: boolean - price_kopeks: number - price_rubles: number - max_users: number | null - current_users: number - sort_order: number - is_full: boolean - availability_status: string - promo_groups: PromoGroupInfo[] - active_subscriptions: number - tariffs_using: string[] - created_at: string - updated_at: string | null + id: number; + squad_uuid: string; + display_name: string; + original_name: string | null; + country_code: string | null; + description: string | null; + is_available: boolean; + is_trial_eligible: boolean; + price_kopeks: number; + price_rubles: number; + max_users: number | null; + current_users: number; + sort_order: number; + is_full: boolean; + availability_status: string; + promo_groups: PromoGroupInfo[]; + active_subscriptions: number; + tariffs_using: string[]; + created_at: string; + updated_at: string | null; } export interface ServerUpdateRequest { - display_name?: string - description?: string - country_code?: string - is_available?: boolean - is_trial_eligible?: boolean - price_kopeks?: number - max_users?: number - sort_order?: number - promo_group_ids?: number[] + display_name?: string; + description?: string; + country_code?: string; + is_available?: boolean; + is_trial_eligible?: boolean; + price_kopeks?: number; + max_users?: number; + sort_order?: number; + promo_group_ids?: number[]; } export interface ServerToggleResponse { - id: number - is_available: boolean - message: string + id: number; + is_available: boolean; + message: string; } export interface ServerTrialToggleResponse { - id: number - is_trial_eligible: boolean - message: string + id: number; + is_trial_eligible: boolean; + message: string; } export interface ServerStats { - id: number - display_name: string - squad_uuid: string - current_users: number - max_users: number | null - active_subscriptions: number - trial_subscriptions: number - usage_percent: number | null + id: number; + display_name: string; + squad_uuid: string; + current_users: number; + max_users: number | null; + active_subscriptions: number; + trial_subscriptions: number; + usage_percent: number | null; } export interface ServerSyncResponse { - created: number - updated: number - removed: number - message: string + created: number; + updated: number; + removed: number; + message: string; } export const serversApi = { // Get all servers getServers: async (includeUnavailable = true): Promise => { const response = await apiClient.get('/cabinet/admin/servers', { - params: { include_unavailable: includeUnavailable } - }) - return response.data + params: { include_unavailable: includeUnavailable }, + }); + return response.data; }, // Get single server getServer: async (serverId: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/servers/${serverId}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/servers/${serverId}`); + return response.data; }, // Update server updateServer: async (serverId: number, data: ServerUpdateRequest): Promise => { - const response = await apiClient.put(`/cabinet/admin/servers/${serverId}`, data) - return response.data + const response = await apiClient.put(`/cabinet/admin/servers/${serverId}`, data); + return response.data; }, // Toggle server availability toggleServer: async (serverId: number): Promise => { - const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/toggle`) - return response.data + const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/toggle`); + return response.data; }, // Toggle trial eligibility toggleTrial: async (serverId: number): Promise => { - const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/trial`) - return response.data + const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/trial`); + return response.data; }, // Get server stats getServerStats: async (serverId: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/servers/${serverId}/stats`) - return response.data + const response = await apiClient.get(`/cabinet/admin/servers/${serverId}/stats`); + return response.data; }, // Sync servers with RemnaWave syncServers: async (): Promise => { - const response = await apiClient.post('/cabinet/admin/servers/sync') - return response.data + const response = await apiClient.post('/cabinet/admin/servers/sync'); + return response.data; }, -} +}; diff --git a/src/api/subscription.ts b/src/api/subscription.ts index cc723c1..ceab2b0 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -1,218 +1,256 @@ -import apiClient from './client' -import type { Subscription, RenewalOption, TrafficPackage, TrialInfo, PurchaseOptions, PurchaseSelection, PurchasePreview, AppConfig } from '../types' +import apiClient from './client'; +import type { + Subscription, + RenewalOption, + TrafficPackage, + TrialInfo, + PurchaseOptions, + PurchaseSelection, + PurchasePreview, + AppConfig, +} from '../types'; export const subscriptionApi = { // Get current subscription getSubscription: async (): Promise => { - const response = await apiClient.get('/cabinet/subscription') - return response.data + const response = await apiClient.get('/cabinet/subscription'); + return response.data; }, // Get renewal options getRenewalOptions: async (): Promise => { - const response = await apiClient.get('/cabinet/subscription/renewal-options') - return response.data + const response = await apiClient.get('/cabinet/subscription/renewal-options'); + return response.data; }, // Renew subscription - renewSubscription: async (periodDays: number): Promise<{ - message: string - new_end_date: string - amount_paid_kopeks: number + renewSubscription: async ( + periodDays: number, + ): Promise<{ + message: string; + new_end_date: string; + amount_paid_kopeks: number; }> => { const response = await apiClient.post('/cabinet/subscription/renew', { period_days: periodDays, - }) - return response.data + }); + return response.data; }, // Get traffic packages getTrafficPackages: async (): Promise => { - const response = await apiClient.get('/cabinet/subscription/traffic-packages') - return response.data + const response = await apiClient.get( + '/cabinet/subscription/traffic-packages', + ); + return response.data; }, // Purchase traffic - purchaseTraffic: async (gb: number): Promise<{ - message: string - gb_added: number - amount_paid_kopeks: number + purchaseTraffic: async ( + gb: number, + ): Promise<{ + message: string; + gb_added: number; + amount_paid_kopeks: number; }> => { - const response = await apiClient.post('/cabinet/subscription/traffic', { gb }) - return response.data + const response = await apiClient.post('/cabinet/subscription/traffic', { gb }); + return response.data; }, // Purchase devices - purchaseDevices: async (devices: number): Promise<{ - success: boolean - message: string - devices_added: number - new_device_limit: number - price_kopeks: number - price_label: string - balance_kopeks: number - balance_label: string + purchaseDevices: async ( + devices: number, + ): Promise<{ + success: boolean; + message: string; + devices_added: number; + new_device_limit: number; + price_kopeks: number; + price_label: string; + balance_kopeks: number; + balance_label: string; }> => { - const response = await apiClient.post('/cabinet/subscription/devices/purchase', { devices }) - return response.data + const response = await apiClient.post('/cabinet/subscription/devices/purchase', { devices }); + return response.data; }, // Get device purchase price - getDevicePrice: async (devices: number = 1): Promise<{ - available: boolean - reason?: string - devices?: number - price_per_device_kopeks?: number - price_per_device_label?: string - total_price_kopeks?: number - total_price_label?: string - current_device_limit?: number - days_left?: number - base_device_price_kopeks?: number + getDevicePrice: async ( + devices: number = 1, + ): Promise<{ + available: boolean; + reason?: string; + devices?: number; + price_per_device_kopeks?: number; + price_per_device_label?: string; + total_price_kopeks?: number; + total_price_label?: string; + current_device_limit?: number; + days_left?: number; + base_device_price_kopeks?: number; }> => { - const response = await apiClient.get('/cabinet/subscription/devices/price', { params: { devices } }) - return response.data + const response = await apiClient.get('/cabinet/subscription/devices/price', { + params: { devices }, + }); + return response.data; }, // Update autopay settings - updateAutopay: async (enabled: boolean, daysBefore?: number): Promise<{ - message: string - autopay_enabled: boolean - autopay_days_before: number + updateAutopay: async ( + enabled: boolean, + daysBefore?: number, + ): Promise<{ + message: string; + autopay_enabled: boolean; + autopay_days_before: number; }> => { const response = await apiClient.patch('/cabinet/subscription/autopay', { enabled, days_before: daysBefore, - }) - return response.data + }); + return response.data; }, // Get trial info getTrialInfo: async (): Promise => { - const response = await apiClient.get('/cabinet/subscription/trial') - return response.data + const response = await apiClient.get('/cabinet/subscription/trial'); + return response.data; }, // Activate trial activateTrial: async (): Promise => { - const response = await apiClient.post('/cabinet/subscription/trial') - return response.data + const response = await apiClient.post('/cabinet/subscription/trial'); + return response.data; }, // Get purchase options (periods, servers, traffic, devices) getPurchaseOptions: async (): Promise => { - const response = await apiClient.get('/cabinet/subscription/purchase-options') - return response.data + const response = await apiClient.get('/cabinet/subscription/purchase-options'); + return response.data; }, // Preview purchase price previewPurchase: async (selection: PurchaseSelection): Promise => { - const response = await apiClient.post('/cabinet/subscription/purchase-preview', { - selection, - }) - return response.data + const response = await apiClient.post( + '/cabinet/subscription/purchase-preview', + { + selection, + }, + ); + return response.data; }, // Submit purchase - submitPurchase: async (selection: PurchaseSelection): Promise<{ - success: boolean - message: string - subscription: Subscription - was_trial_conversion: boolean + submitPurchase: async ( + selection: PurchaseSelection, + ): Promise<{ + success: boolean; + message: string; + subscription: Subscription; + was_trial_conversion: boolean; }> => { const response = await apiClient.post('/cabinet/subscription/purchase', { selection, - }) - return response.data + }); + return response.data; }, // Purchase tariff (for tariffs mode) - purchaseTariff: async (tariffId: number, periodDays: number, trafficGb?: number): Promise<{ - success: boolean - message: string - subscription: Subscription - tariff_id: number - tariff_name: string - balance_kopeks: number - balance_label: string + purchaseTariff: async ( + tariffId: number, + periodDays: number, + trafficGb?: number, + ): Promise<{ + success: boolean; + message: string; + subscription: Subscription; + tariff_id: number; + tariff_name: string; + balance_kopeks: number; + balance_label: string; }> => { const response = await apiClient.post('/cabinet/subscription/purchase-tariff', { tariff_id: tariffId, period_days: periodDays, traffic_gb: trafficGb, - }) - return response.data + }); + return response.data; }, // Get app config for connection getAppConfig: async (): Promise => { - const response = await apiClient.get('/cabinet/subscription/app-config') - return response.data + const response = await apiClient.get('/cabinet/subscription/app-config'); + return response.data; }, // Get available countries/servers getCountries: async (): Promise<{ countries: Array<{ - uuid: string - name: string - country_code: string | null - base_price_kopeks: number - price_kopeks: number - price_per_month_kopeks: number - price_rubles: number - is_available: boolean - is_connected: boolean - has_discount: boolean - discount_percent: number - }> - connected_count: number - has_subscription: boolean - days_left: number - discount_percent: number + uuid: string; + name: string; + country_code: string | null; + base_price_kopeks: number; + price_kopeks: number; + price_per_month_kopeks: number; + price_rubles: number; + is_available: boolean; + is_connected: boolean; + has_discount: boolean; + discount_percent: number; + }>; + connected_count: number; + has_subscription: boolean; + days_left: number; + discount_percent: number; }> => { - const response = await apiClient.get('/cabinet/subscription/countries') - return response.data + const response = await apiClient.get('/cabinet/subscription/countries'); + return response.data; }, // Update countries/servers - updateCountries: async (countries: string[]): Promise<{ - message: string - added: string[] - removed: string[] - amount_paid_kopeks: number - connected_squads: string[] + updateCountries: async ( + countries: string[], + ): Promise<{ + message: string; + added: string[]; + removed: string[]; + amount_paid_kopeks: number; + connected_squads: string[]; }> => { - const response = await apiClient.post('/cabinet/subscription/countries', { countries }) - return response.data + const response = await apiClient.post('/cabinet/subscription/countries', { countries }); + return response.data; }, // Get connection link and instructions getConnectionLink: async (): Promise<{ - subscription_url: string | null - display_link: string | null - happ_redirect_link: string | null - happ_scheme_link: string | null - connect_mode: string - hide_link: boolean + subscription_url: string | null; + display_link: string | null; + happ_redirect_link: string | null; + happ_scheme_link: string | null; + connect_mode: string; + hide_link: boolean; instructions: { - steps: string[] - } + steps: string[]; + }; }> => { - const response = await apiClient.get('/cabinet/subscription/connection-link') - return response.data + const response = await apiClient.get('/cabinet/subscription/connection-link'); + return response.data; }, // Get hApp download links getHappDownloads: async (): Promise<{ - platforms: Record - happ_enabled: boolean + platforms: Record< + string, + { + name: string; + icon: string; + link: string; + } + >; + happ_enabled: boolean; }> => { - const response = await apiClient.get('/cabinet/subscription/happ-downloads') - return response.data + const response = await apiClient.get('/cabinet/subscription/happ-downloads'); + return response.data; }, // ============ Device Management ============ @@ -220,130 +258,140 @@ export const subscriptionApi = { // Get connected devices getDevices: async (): Promise<{ devices: Array<{ - hwid: string - platform: string - device_model: string - created_at: string | null - }> - total: number - device_limit: number + hwid: string; + platform: string; + device_model: string; + created_at: string | null; + }>; + total: number; + device_limit: number; }> => { - const response = await apiClient.get('/cabinet/subscription/devices') - return response.data + const response = await apiClient.get('/cabinet/subscription/devices'); + return response.data; }, // Delete a specific device - deleteDevice: async (hwid: string): Promise<{ - success: boolean - message: string - deleted_hwid: string + deleteDevice: async ( + hwid: string, + ): Promise<{ + success: boolean; + message: string; + deleted_hwid: string; }> => { - const response = await apiClient.delete(`/cabinet/subscription/devices/${encodeURIComponent(hwid)}`) - return response.data + const response = await apiClient.delete( + `/cabinet/subscription/devices/${encodeURIComponent(hwid)}`, + ); + return response.data; }, // Delete all devices deleteAllDevices: async (): Promise<{ - success: boolean - message: string - deleted_count: number + success: boolean; + message: string; + deleted_count: number; }> => { - const response = await apiClient.delete('/cabinet/subscription/devices') - return response.data + const response = await apiClient.delete('/cabinet/subscription/devices'); + return response.data; }, // ============ Tariff Switch ============ // Preview tariff switch cost - previewTariffSwitch: async (tariffId: number): Promise<{ - can_switch: boolean - current_tariff_id: number | null - current_tariff_name: string | null - new_tariff_id: number - new_tariff_name: string - remaining_days: number - upgrade_cost_kopeks: number - upgrade_cost_label: string - balance_kopeks: number - balance_label: string - has_enough_balance: boolean - missing_amount_kopeks: number - missing_amount_label: string - is_upgrade: boolean + previewTariffSwitch: async ( + tariffId: number, + ): Promise<{ + can_switch: boolean; + current_tariff_id: number | null; + current_tariff_name: string | null; + new_tariff_id: number; + new_tariff_name: string; + remaining_days: number; + upgrade_cost_kopeks: number; + upgrade_cost_label: string; + balance_kopeks: number; + balance_label: string; + has_enough_balance: boolean; + missing_amount_kopeks: number; + missing_amount_label: string; + is_upgrade: boolean; }> => { const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', { tariff_id: tariffId, period_days: 30, // Default period for switch - }) - return response.data + }); + return response.data; }, // Switch to a different tariff - switchTariff: async (tariffId: number): Promise<{ - success: boolean - message: string - subscription: Subscription - old_tariff_name: string - new_tariff_id: number - new_tariff_name: string - charged_kopeks: number - balance_kopeks: number - balance_label: string + switchTariff: async ( + tariffId: number, + ): Promise<{ + success: boolean; + message: string; + subscription: Subscription; + old_tariff_name: string; + new_tariff_id: number; + new_tariff_name: string; + charged_kopeks: number; + balance_kopeks: number; + balance_label: string; }> => { const response = await apiClient.post('/cabinet/subscription/tariff/switch', { tariff_id: tariffId, period_days: 30, - }) - return response.data + }); + return response.data; }, // ============ Subscription Pause (Daily Tariffs) ============ // Toggle pause/resume for daily subscription togglePause: async (): Promise<{ - success: boolean - message: string - is_paused: boolean - balance_kopeks: number - balance_label: string + success: boolean; + message: string; + is_paused: boolean; + balance_kopeks: number; + balance_label: string; }> => { - const response = await apiClient.post('/cabinet/subscription/pause') - return response.data + const response = await apiClient.post('/cabinet/subscription/pause'); + return response.data; }, // ============ Traffic Switch ============ // Switch to a different traffic package - switchTraffic: async (gb: number): Promise<{ - success: boolean - message: string - old_traffic_gb: number - new_traffic_gb: number - charged_kopeks: number - balance_kopeks: number - balance_label: string + switchTraffic: async ( + gb: number, + ): Promise<{ + success: boolean; + message: string; + old_traffic_gb: number; + new_traffic_gb: number; + charged_kopeks: number; + balance_kopeks: number; + balance_label: string; }> => { - const response = await apiClient.put('/cabinet/subscription/traffic', { gb }) - return response.data + const response = await apiClient.put('/cabinet/subscription/traffic', { gb }); + return response.data; }, // Refresh traffic usage from RemnaWave (rate limited: 1 per 60 seconds) refreshTraffic: async (): Promise<{ - success: boolean - cached: boolean - rate_limited?: boolean - retry_after_seconds?: number - source?: string - traffic_used_bytes: number - traffic_used_gb: number - traffic_limit_bytes: number - traffic_limit_gb: number - traffic_used_percent: number - is_unlimited: boolean - lifetime_used_bytes?: number - lifetime_used_gb?: number + success: boolean; + cached: boolean; + rate_limited?: boolean; + retry_after_seconds?: number; + source?: string; + traffic_used_bytes: number; + traffic_used_gb: number; + traffic_limit_bytes: number; + traffic_limit_gb: number; + traffic_used_percent: number; + is_unlimited: boolean; + lifetime_used_bytes?: number; + lifetime_used_gb?: number; }> => { - const response = await apiClient.post('/cabinet/subscription/refresh-traffic') - return response.data + const response = await apiClient.post('/cabinet/subscription/refresh-traffic'); + return response.data; }, -} +}; diff --git a/src/api/tariffs.ts b/src/api/tariffs.ts index 00a6255..a58224b 100644 --- a/src/api/tariffs.ts +++ b/src/api/tariffs.ts @@ -1,239 +1,239 @@ -import apiClient from './client' +import apiClient from './client'; // Types export interface PeriodPrice { - days: number - price_kopeks: number - price_rubles?: number + days: number; + price_kopeks: number; + price_rubles?: number; } export interface ServerTrafficLimit { - traffic_limit_gb: number + traffic_limit_gb: number; } export interface ServerInfo { - id: number - squad_uuid: string - display_name: string - country_code: string | null - is_selected: boolean - traffic_limit_gb?: number | null + id: number; + squad_uuid: string; + display_name: string; + country_code: string | null; + is_selected: boolean; + traffic_limit_gb?: number | null; } export interface PromoGroupInfo { - id: number - name: string - is_selected: boolean + id: number; + name: string; + is_selected: boolean; } export interface TariffListItem { - id: number - name: string - description: string | null - is_active: boolean - is_trial_available: boolean - is_daily: boolean - daily_price_kopeks: number - traffic_limit_gb: number - device_limit: number - tier_level: number - display_order: number - servers_count: number - subscriptions_count: number - created_at: string + id: number; + name: string; + description: string | null; + is_active: boolean; + is_trial_available: boolean; + is_daily: boolean; + daily_price_kopeks: number; + traffic_limit_gb: number; + device_limit: number; + tier_level: number; + display_order: number; + servers_count: number; + subscriptions_count: number; + created_at: string; } export interface TariffListResponse { - tariffs: TariffListItem[] - total: number + tariffs: TariffListItem[]; + total: number; } export interface TariffDetail { - id: number - name: string - description: string | null - is_active: boolean - is_trial_available: boolean - traffic_limit_gb: number - device_limit: number - device_price_kopeks: number | null - max_device_limit: number | null - tier_level: number - display_order: number - period_prices: PeriodPrice[] - allowed_squads: string[] - server_traffic_limits: Record - servers: ServerInfo[] - promo_groups: PromoGroupInfo[] - subscriptions_count: number + id: number; + name: string; + description: string | null; + is_active: boolean; + is_trial_available: boolean; + traffic_limit_gb: number; + device_limit: number; + device_price_kopeks: number | null; + max_device_limit: number | null; + tier_level: number; + display_order: number; + period_prices: PeriodPrice[]; + allowed_squads: string[]; + server_traffic_limits: Record; + servers: ServerInfo[]; + promo_groups: PromoGroupInfo[]; + subscriptions_count: number; // Произвольное количество дней - custom_days_enabled: boolean - price_per_day_kopeks: number - min_days: number - max_days: number + custom_days_enabled: boolean; + price_per_day_kopeks: number; + min_days: number; + max_days: number; // Произвольный трафик при покупке - custom_traffic_enabled: boolean - traffic_price_per_gb_kopeks: number - min_traffic_gb: number - max_traffic_gb: number + custom_traffic_enabled: boolean; + traffic_price_per_gb_kopeks: number; + min_traffic_gb: number; + max_traffic_gb: number; // Докупка трафика - traffic_topup_enabled: boolean - traffic_topup_packages: Record - max_topup_traffic_gb: number + traffic_topup_enabled: boolean; + traffic_topup_packages: Record; + max_topup_traffic_gb: number; // Дневной тариф - is_daily: boolean - daily_price_kopeks: number + is_daily: boolean; + daily_price_kopeks: number; // Режим сброса трафика - traffic_reset_mode: string | null // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка - created_at: string - updated_at: string | null + traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка + created_at: string; + updated_at: string | null; } export interface TariffCreateRequest { - name: string - description?: string - is_active?: boolean - traffic_limit_gb?: number - device_limit?: number - device_price_kopeks?: number - max_device_limit?: number - tier_level?: number - period_prices?: PeriodPrice[] - allowed_squads?: string[] - server_traffic_limits?: Record - promo_group_ids?: number[] + name: string; + description?: string; + is_active?: boolean; + traffic_limit_gb?: number; + device_limit?: number; + device_price_kopeks?: number; + max_device_limit?: number; + tier_level?: number; + period_prices?: PeriodPrice[]; + allowed_squads?: string[]; + server_traffic_limits?: Record; + promo_group_ids?: number[]; // Произвольное количество дней - custom_days_enabled?: boolean - price_per_day_kopeks?: number - min_days?: number - max_days?: number + custom_days_enabled?: boolean; + price_per_day_kopeks?: number; + min_days?: number; + max_days?: number; // Произвольный трафик при покупке - custom_traffic_enabled?: boolean - traffic_price_per_gb_kopeks?: number - min_traffic_gb?: number - max_traffic_gb?: number + custom_traffic_enabled?: boolean; + traffic_price_per_gb_kopeks?: number; + min_traffic_gb?: number; + max_traffic_gb?: number; // Докупка трафика - traffic_topup_enabled?: boolean - traffic_topup_packages?: Record - max_topup_traffic_gb?: number + traffic_topup_enabled?: boolean; + traffic_topup_packages?: Record; + max_topup_traffic_gb?: number; // Дневной тариф - is_daily?: boolean - daily_price_kopeks?: number + is_daily?: boolean; + daily_price_kopeks?: number; // Режим сброса трафика - traffic_reset_mode?: string | null + traffic_reset_mode?: string | null; } export interface TariffUpdateRequest { - name?: string - description?: string - is_active?: boolean - traffic_limit_gb?: number - device_limit?: number - device_price_kopeks?: number - max_device_limit?: number - tier_level?: number - display_order?: number - period_prices?: PeriodPrice[] - allowed_squads?: string[] - server_traffic_limits?: Record - promo_group_ids?: number[] + name?: string; + description?: string; + is_active?: boolean; + traffic_limit_gb?: number; + device_limit?: number; + device_price_kopeks?: number; + max_device_limit?: number; + tier_level?: number; + display_order?: number; + period_prices?: PeriodPrice[]; + allowed_squads?: string[]; + server_traffic_limits?: Record; + promo_group_ids?: number[]; // Произвольное количество дней - custom_days_enabled?: boolean - price_per_day_kopeks?: number - min_days?: number - max_days?: number + custom_days_enabled?: boolean; + price_per_day_kopeks?: number; + min_days?: number; + max_days?: number; // Произвольный трафик при покупке - custom_traffic_enabled?: boolean - traffic_price_per_gb_kopeks?: number - min_traffic_gb?: number - max_traffic_gb?: number + custom_traffic_enabled?: boolean; + traffic_price_per_gb_kopeks?: number; + min_traffic_gb?: number; + max_traffic_gb?: number; // Докупка трафика - traffic_topup_enabled?: boolean - traffic_topup_packages?: Record - max_topup_traffic_gb?: number + traffic_topup_enabled?: boolean; + traffic_topup_packages?: Record; + max_topup_traffic_gb?: number; // Дневной тариф - is_daily?: boolean - daily_price_kopeks?: number + is_daily?: boolean; + daily_price_kopeks?: number; // Режим сброса трафика - traffic_reset_mode?: string | null + traffic_reset_mode?: string | null; } export interface TariffToggleResponse { - id: number - is_active: boolean - message: string + id: number; + is_active: boolean; + message: string; } export interface TariffTrialResponse { - id: number - is_trial_available: boolean - message: string + id: number; + is_trial_available: boolean; + message: string; } export interface TariffStats { - id: number - name: string - subscriptions_count: number - active_subscriptions: number - trial_subscriptions: number - revenue_kopeks: number - revenue_rubles: number + id: number; + name: string; + subscriptions_count: number; + active_subscriptions: number; + trial_subscriptions: number; + revenue_kopeks: number; + revenue_rubles: number; } export const tariffsApi = { // Get all tariffs getTariffs: async (includeInactive = true): Promise => { const response = await apiClient.get('/cabinet/admin/tariffs', { - params: { include_inactive: includeInactive } - }) - return response.data + params: { include_inactive: includeInactive }, + }); + return response.data; }, // Get single tariff getTariff: async (tariffId: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}`) - return response.data + const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}`); + return response.data; }, // Create new tariff createTariff: async (data: TariffCreateRequest): Promise => { - const response = await apiClient.post('/cabinet/admin/tariffs', data) - return response.data + const response = await apiClient.post('/cabinet/admin/tariffs', data); + return response.data; }, // Update tariff updateTariff: async (tariffId: number, data: TariffUpdateRequest): Promise => { - const response = await apiClient.put(`/cabinet/admin/tariffs/${tariffId}`, data) - return response.data + const response = await apiClient.put(`/cabinet/admin/tariffs/${tariffId}`, data); + return response.data; }, // Delete tariff deleteTariff: async (tariffId: number): Promise<{ message: string }> => { - const response = await apiClient.delete(`/cabinet/admin/tariffs/${tariffId}`) - return response.data + const response = await apiClient.delete(`/cabinet/admin/tariffs/${tariffId}`); + return response.data; }, // Toggle tariff active status toggleTariff: async (tariffId: number): Promise => { - const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/toggle`) - return response.data + const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/toggle`); + return response.data; }, // Toggle trial status toggleTrial: async (tariffId: number): Promise => { - const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/trial`) - return response.data + const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/trial`); + return response.data; }, // Get tariff stats getTariffStats: async (tariffId: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}/stats`) - return response.data + const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}/stats`); + return response.data; }, // Get available servers for selection getAvailableServers: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/tariffs/available-servers') - return response.data + const response = await apiClient.get('/cabinet/admin/tariffs/available-servers'); + return response.data; }, -} +}; diff --git a/src/api/themeColors.ts b/src/api/themeColors.ts index e02afdf..c991362 100644 --- a/src/api/themeColors.ts +++ b/src/api/themeColors.ts @@ -1,43 +1,48 @@ -import apiClient from './client' -import { ThemeSettings, DEFAULT_THEME_COLORS, EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme' +import apiClient from './client'; +import { + ThemeSettings, + DEFAULT_THEME_COLORS, + EnabledThemes, + DEFAULT_ENABLED_THEMES, +} from '../types/theme'; export const themeColorsApi = { // Get current theme colors (public, no auth required) getColors: async (): Promise => { try { - const response = await apiClient.get('/cabinet/branding/colors') - return response.data + const response = await apiClient.get('/cabinet/branding/colors'); + return response.data; } catch { // Return default colors if endpoint not available - return DEFAULT_THEME_COLORS + return DEFAULT_THEME_COLORS; } }, // Update theme colors (admin only) updateColors: async (colors: Partial): Promise => { - const response = await apiClient.patch('/cabinet/branding/colors', colors) - return response.data + const response = await apiClient.patch('/cabinet/branding/colors', colors); + return response.data; }, // Reset to default colors (admin only) resetColors: async (): Promise => { - const response = await apiClient.post('/cabinet/branding/colors/reset') - return response.data + const response = await apiClient.post('/cabinet/branding/colors/reset'); + return response.data; }, // Get enabled themes (public, no auth required) getEnabledThemes: async (): Promise => { try { - const response = await apiClient.get('/cabinet/branding/themes') - return response.data + const response = await apiClient.get('/cabinet/branding/themes'); + return response.data; } catch { - return DEFAULT_ENABLED_THEMES + return DEFAULT_ENABLED_THEMES; } }, // Update enabled themes (admin only) updateEnabledThemes: async (themes: Partial): Promise => { - const response = await apiClient.patch('/cabinet/branding/themes', themes) - return response.data + const response = await apiClient.patch('/cabinet/branding/themes', themes); + return response.data; }, -} +}; diff --git a/src/api/ticketNotifications.ts b/src/api/ticketNotifications.ts index 36a30b3..0b8b059 100644 --- a/src/api/ticketNotifications.ts +++ b/src/api/ticketNotifications.ts @@ -1,64 +1,80 @@ -import apiClient from './client' -import type { TicketNotificationList, UnreadCountResponse } from '../types' +import apiClient from './client'; +import type { TicketNotificationList, UnreadCountResponse } from '../types'; export const ticketNotificationsApi = { // User notifications - getNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise => { + getNotifications: async ( + unreadOnly = false, + limit = 50, + offset = 0, + ): Promise => { const response = await apiClient.get('/cabinet/tickets/notifications', { - params: { unread_only: unreadOnly, limit, offset } - }) - return response.data + params: { unread_only: unreadOnly, limit, offset }, + }); + return response.data; }, getUnreadCount: async (): Promise => { - const response = await apiClient.get('/cabinet/tickets/notifications/unread-count') - return response.data + const response = await apiClient.get('/cabinet/tickets/notifications/unread-count'); + return response.data; }, markAsRead: async (notificationId: number): Promise<{ success: boolean }> => { - const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`) - return response.data + const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`); + return response.data; }, markAllAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { - const response = await apiClient.post('/cabinet/tickets/notifications/read-all') - return response.data + const response = await apiClient.post('/cabinet/tickets/notifications/read-all'); + return response.data; }, - markTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { - const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`) - return response.data + markTicketAsRead: async ( + ticketId: number, + ): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`); + return response.data; }, // Admin notifications - getAdminNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise => { - const params: Record = { limit, offset } + getAdminNotifications: async ( + unreadOnly = false, + limit = 50, + offset = 0, + ): Promise => { + const params: Record = { limit, offset }; if (unreadOnly) { - params.unread_only = true + params.unread_only = true; } - const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params }) - return response.data + const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params }); + return response.data; }, getAdminUnreadCount: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count') - return response.data + const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count'); + return response.data; }, markAdminAsRead: async (notificationId: number): Promise<{ success: boolean }> => { - const response = await apiClient.post(`/cabinet/admin/tickets/notifications/${notificationId}/read`) - return response.data + const response = await apiClient.post( + `/cabinet/admin/tickets/notifications/${notificationId}/read`, + ); + return response.data; }, markAllAdminAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { - const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all') - return response.data + const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all'); + return response.data; }, - markAdminTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { - const response = await apiClient.post(`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`) - return response.data + markAdminTicketAsRead: async ( + ticketId: number, + ): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post( + `/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`, + ); + return response.data; }, -} +}; -export default ticketNotificationsApi +export default ticketNotificationsApi; diff --git a/src/api/tickets.ts b/src/api/tickets.ts index 5205963..df3b3b2 100644 --- a/src/api/tickets.ts +++ b/src/api/tickets.ts @@ -1,84 +1,84 @@ -import apiClient from './client' -import type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types' +import apiClient from './client'; +import type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types'; // Media upload response type interface MediaUploadResponse { - media_type: string - file_id: string - file_unique_id: string | null - media_url: string + media_type: string; + file_id: string; + file_unique_id: string | null; + media_url: string; } // Media parameters for ticket messages interface MediaParams { - media_type?: string - media_file_id?: string - media_caption?: string + media_type?: string; + media_file_id?: string; + media_caption?: string; } export const ticketsApi = { // Get tickets list getTickets: async (params?: { - page?: number - per_page?: number - status?: string + page?: number; + per_page?: number; + status?: string; }): Promise> => { const response = await apiClient.get>('/cabinet/tickets', { params, - }) - return response.data + }); + return response.data; }, // Create ticket with optional media createTicket: async ( title: string, message: string, - media?: MediaParams + media?: MediaParams, ): Promise => { const response = await apiClient.post('/cabinet/tickets', { title, message, ...media, - }) - return response.data + }); + return response.data; }, // Get ticket detail getTicket: async (ticketId: number): Promise => { - const response = await apiClient.get(`/cabinet/tickets/${ticketId}`) - return response.data + const response = await apiClient.get(`/cabinet/tickets/${ticketId}`); + return response.data; }, // Add message to ticket with optional media addMessage: async ( ticketId: number, message: string, - media?: MediaParams + media?: MediaParams, ): Promise => { const response = await apiClient.post(`/cabinet/tickets/${ticketId}/messages`, { message, ...media, - }) - return response.data + }); + return response.data; }, // Upload media file for tickets uploadMedia: async (file: File, mediaType: string = 'photo'): Promise => { - const formData = new FormData() - formData.append('file', file) - formData.append('media_type', mediaType) + const formData = new FormData(); + formData.append('file', file); + formData.append('media_type', mediaType); const response = await apiClient.post('/cabinet/media/upload', formData, { headers: { 'Content-Type': 'multipart/form-data', }, - }) - return response.data + }); + return response.data; }, // Get media URL for display getMediaUrl: (fileId: string): string => { - const baseUrl = import.meta.env.VITE_API_URL || '' - return `${baseUrl}/cabinet/media/${fileId}` + const baseUrl = import.meta.env.VITE_API_URL || ''; + return `${baseUrl}/cabinet/media/${fileId}`; }, -} +}; diff --git a/src/api/wheel.ts b/src/api/wheel.ts index 7511f94..a507b77 100644 --- a/src/api/wheel.ts +++ b/src/api/wheel.ts @@ -1,182 +1,182 @@ -import apiClient from './client' +import apiClient from './client'; // ==================== TYPES ==================== export interface WheelPrize { - id: number - display_name: string - emoji: string - color: string - prize_type: string + id: number; + display_name: string; + emoji: string; + color: string; + prize_type: string; } export interface WheelConfig { - is_enabled: boolean - name: string - spin_cost_stars: number | null - spin_cost_days: number | null - spin_cost_stars_enabled: boolean - spin_cost_days_enabled: boolean - prizes: WheelPrize[] - daily_limit: number - user_spins_today: number - can_spin: boolean - can_spin_reason: string | null - can_pay_stars: boolean - can_pay_days: boolean - user_balance_kopeks: number - required_balance_kopeks: number + is_enabled: boolean; + name: string; + spin_cost_stars: number | null; + spin_cost_days: number | null; + spin_cost_stars_enabled: boolean; + spin_cost_days_enabled: boolean; + prizes: WheelPrize[]; + daily_limit: number; + user_spins_today: number; + can_spin: boolean; + can_spin_reason: string | null; + can_pay_stars: boolean; + can_pay_days: boolean; + user_balance_kopeks: number; + required_balance_kopeks: number; } export interface SpinAvailability { - can_spin: boolean - reason: string | null - spins_remaining_today: number - can_pay_stars: boolean - can_pay_days: boolean - min_subscription_days: number - user_subscription_days: number + can_spin: boolean; + reason: string | null; + spins_remaining_today: number; + can_pay_stars: boolean; + can_pay_days: boolean; + min_subscription_days: number; + user_subscription_days: number; } export interface SpinResult { - success: boolean - prize_id: number | null - prize_type: string | null - prize_value: number - prize_display_name: string - emoji: string - color: string - rotation_degrees: number - message: string - promocode: string | null - error: string | null + success: boolean; + prize_id: number | null; + prize_type: string | null; + prize_value: number; + prize_display_name: string; + emoji: string; + color: string; + rotation_degrees: number; + message: string; + promocode: string | null; + error: string | null; } export interface SpinHistoryItem { - id: number - payment_type: string - payment_amount: number - prize_type: string - prize_value: number - prize_display_name: string - emoji: string - color: string - prize_value_kopeks: number - created_at: string + id: number; + payment_type: string; + payment_amount: number; + prize_type: string; + prize_value: number; + prize_display_name: string; + emoji: string; + color: string; + prize_value_kopeks: number; + created_at: string; } export interface SpinHistoryResponse { - items: SpinHistoryItem[] - total: number - page: number - per_page: number - pages: number + items: SpinHistoryItem[]; + total: number; + page: number; + per_page: number; + pages: number; } export interface StarsInvoiceResponse { - invoice_url: string - stars_amount: number + invoice_url: string; + stars_amount: number; } // Admin types export interface WheelPrizeAdmin { - id: number - config_id: number - 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 - created_at: string | null - updated_at: string | null + id: number; + config_id: number; + 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; + created_at: string | null; + 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 + 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 - name: string - spin_cost_stars: number - spin_cost_days: number - spin_cost_stars_enabled: boolean - spin_cost_days_enabled: boolean - rtp_percent: number - daily_spin_limit: number - min_subscription_days_for_day_payment: number - promo_prefix: string - promo_validity_days: number - prizes: WheelPrizeAdmin[] - created_at: string | null - updated_at: string | null + id: number; + is_enabled: boolean; + name: string; + spin_cost_stars: number; + spin_cost_days: number; + spin_cost_stars_enabled: boolean; + spin_cost_days_enabled: boolean; + rtp_percent: number; + daily_spin_limit: number; + min_subscription_days_for_day_payment: number; + promo_prefix: string; + promo_validity_days: number; + prizes: WheelPrizeAdmin[]; + created_at: string | null; + updated_at: string | null; } export interface WheelStatistics { - total_spins: number - total_revenue_kopeks: number - total_payout_kopeks: number - actual_rtp_percent: number - configured_rtp_percent: number - spins_by_payment_type: Record + total_spins: number; + total_revenue_kopeks: number; + total_payout_kopeks: number; + actual_rtp_percent: number; + configured_rtp_percent: number; + spins_by_payment_type: Record; prizes_distribution: Array<{ - prize_type: string - display_name: string - count: number - total_kopeks: number - }> + prize_type: string; + display_name: string; + count: number; + total_kopeks: number; + }>; top_wins: Array<{ - user_id: number - username: string | null - prize_display_name: string - prize_value_kopeks: number - created_at: string | null - }> - period_from: string | null - period_to: string | null + user_id: number; + username: string | null; + prize_display_name: string; + prize_value_kopeks: number; + created_at: string | null; + }>; + period_from: string | null; + period_to: string | null; } export interface AdminSpinItem { - id: number - user_id: number - username: string | null - payment_type: string - payment_amount: number - payment_value_kopeks: number - prize_type: string - prize_value: number - prize_display_name: string - prize_value_kopeks: number - is_applied: boolean - created_at: string + id: number; + user_id: number; + username: string | null; + payment_type: string; + payment_amount: number; + payment_value_kopeks: number; + prize_type: string; + prize_value: number; + prize_display_name: string; + prize_value_kopeks: number; + is_applied: boolean; + created_at: string; } export interface AdminSpinsResponse { - items: AdminSpinItem[] - total: number - page: number - per_page: number - pages: number + items: AdminSpinItem[]; + total: number; + page: number; + per_page: number; + pages: number; } // ==================== USER API ==================== @@ -184,101 +184,107 @@ export interface AdminSpinsResponse { export const wheelApi = { // Get wheel config getConfig: async (): Promise => { - const response = await apiClient.get('/cabinet/wheel/config') - return response.data + const response = await apiClient.get('/cabinet/wheel/config'); + return response.data; }, // Check spin availability checkAvailability: async (): Promise => { - const response = await apiClient.get('/cabinet/wheel/availability') - return response.data + const response = await apiClient.get('/cabinet/wheel/availability'); + return response.data; }, // Spin the wheel spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise => { const response = await apiClient.post('/cabinet/wheel/spin', { payment_type: paymentType, - }) - return response.data + }); + return response.data; }, // Get spin history getHistory: async (page = 1, perPage = 20): Promise => { const response = await apiClient.get('/cabinet/wheel/history', { params: { page, per_page: perPage }, - }) - return response.data + }); + return response.data; }, // Create Stars invoice for Mini App payment createStarsInvoice: async (): Promise => { - const response = await apiClient.post('/cabinet/wheel/stars-invoice') - return response.data + const response = await apiClient.post('/cabinet/wheel/stars-invoice'); + return response.data; }, -} +}; // ==================== ADMIN API ==================== export const adminWheelApi = { // Get full config getConfig: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/wheel/config') - return response.data + const response = await apiClient.get('/cabinet/admin/wheel/config'); + return response.data; }, // Update config updateConfig: async (data: Partial): Promise => { - const response = await apiClient.put('/cabinet/admin/wheel/config', data) - return response.data + const response = await apiClient.put('/cabinet/admin/wheel/config', data); + return response.data; }, // Get prizes getPrizes: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/wheel/prizes') - return response.data + const response = await apiClient.get('/cabinet/admin/wheel/prizes'); + return response.data; }, // Create prize createPrize: async (data: CreateWheelPrizeData): Promise => { - const response = await apiClient.post('/cabinet/admin/wheel/prizes', data) - return response.data + const response = await apiClient.post('/cabinet/admin/wheel/prizes', data); + return response.data; }, // Update prize - updatePrize: async (prizeId: number, data: Partial): Promise => { - const response = await apiClient.put(`/cabinet/admin/wheel/prizes/${prizeId}`, data) - return response.data + updatePrize: async ( + prizeId: number, + data: Partial, + ): Promise => { + const response = await apiClient.put( + `/cabinet/admin/wheel/prizes/${prizeId}`, + data, + ); + return response.data; }, // Delete prize deletePrize: async (prizeId: number): Promise => { - await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`) + await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`); }, // Reorder prizes reorderPrizes: async (prizeIds: number[]): Promise => { - await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds }) + await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds }); }, // Get statistics getStatistics: async (dateFrom?: string, dateTo?: string): Promise => { const response = await apiClient.get('/cabinet/admin/wheel/statistics', { params: { date_from: dateFrom, date_to: dateTo }, - }) - return response.data + }); + return response.data; }, // Get all spins getSpins: async (params?: { - user_id?: number - date_from?: string - date_to?: string - page?: number - per_page?: number + user_id?: number; + date_from?: string; + date_to?: string; + page?: number; + per_page?: number; }): Promise => { const response = await apiClient.get('/cabinet/admin/wheel/spins', { params, - }) - return response.data + }); + return response.data; }, -} +}; diff --git a/src/components/AnimatedBackground.tsx b/src/components/AnimatedBackground.tsx index 0557e2a..9892ecf 100644 --- a/src/components/AnimatedBackground.tsx +++ b/src/components/AnimatedBackground.tsx @@ -1,43 +1,44 @@ -import { useEffect, useState, memo } from 'react' -import { useQuery } from '@tanstack/react-query' -import { brandingApi } from '../api/branding' +import { useEffect, useState, memo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { brandingApi } from '../api/branding'; -const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled' +const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled'; // Detect if user prefers reduced motion const isLowPerformance = (): boolean => { // Only check for reduced motion preference - let animation run everywhere else - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches - return prefersReducedMotion -} + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + return prefersReducedMotion; +}; // Get cached value from localStorage const getCachedAnimationEnabled = (): boolean | null => { try { - const cached = localStorage.getItem(ANIMATION_CACHE_KEY) + const cached = localStorage.getItem(ANIMATION_CACHE_KEY); if (cached !== null) { - return cached === 'true' + return cached === 'true'; } } catch { // localStorage not available } - return null -} + return null; +}; // Update cache in localStorage +// eslint-disable-next-line react-refresh/only-export-components export const setCachedAnimationEnabled = (enabled: boolean) => { try { - localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled)) + localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled)); } catch { // localStorage not available } -} +}; // Memoized background component to prevent re-renders const AnimatedBackground = memo(function AnimatedBackground() { // Start with cached value (null means unknown yet) - const [isEnabled, setIsEnabled] = useState(() => getCachedAnimationEnabled()) - const [isLowPerf] = useState(() => isLowPerformance()) + const [isEnabled, setIsEnabled] = useState(() => getCachedAnimationEnabled()); + const [isLowPerf] = useState(() => isLowPerformance()); const { data: animationSettings } = useQuery({ queryKey: ['animation-enabled'], @@ -45,24 +46,24 @@ const AnimatedBackground = memo(function AnimatedBackground() { staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls refetchOnWindowFocus: false, // Don't refetch on focus - save resources retry: false, - }) + }); // Update state and cache when data arrives useEffect(() => { if (animationSettings !== undefined) { - const enabled = animationSettings.enabled - setIsEnabled(enabled) - setCachedAnimationEnabled(enabled) + const enabled = animationSettings.enabled; + setIsEnabled(enabled); + setCachedAnimationEnabled(enabled); } - }, [animationSettings]) + }, [animationSettings]); // Don't render if disabled or on low-performance devices if (isEnabled !== true || isLowPerf) { - return null + return null; } // Render only 2 blobs on mobile for better performance - const isMobile = window.innerWidth < 768 + const isMobile = window.innerWidth < 768; return (
@@ -75,7 +76,7 @@ const AnimatedBackground = memo(function AnimatedBackground() { )}
- ) -}) + ); +}); -export default AnimatedBackground +export default AnimatedBackground; diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 585bc7f..69de3e5 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,238 +1,286 @@ -import { useState, useRef, useEffect, useMemo, useCallback } from 'react' -import { createPortal } from 'react-dom' +import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { createPortal } from 'react-dom'; interface ColorPickerProps { - value: string - onChange: (color: string) => void - label: string - description?: string - disabled?: boolean + value: string; + onChange: (color: string) => void; + label: string; + description?: string; + disabled?: boolean; } // Check if running in Telegram WebApp const isTelegramWebApp = (): boolean => { - return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp -} + 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) + 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 } -} + : { 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('') -} + return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join(''); +}; // Convert RGB to HSL const rgbToHsl = (r: number, g: number, b: number): { h: number; s: number; l: number } => { - r /= 255; g /= 255; b /= 255 - const max = Math.max(r, g, b), min = Math.min(r, g, b) - let h = 0, s = 0 - const l = (max + min) / 2 + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b), + min = Math.min(r, g, b); + let h = 0, + s = 0; + const l = (max + min) / 2; if (max !== min) { - const d = max - min - s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { - case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break - case g: h = ((b - r) / d + 2) / 6; break - case b: h = ((r - g) / d + 4) / 6; break + case r: + h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + break; + case g: + h = ((b - r) / d + 2) / 6; + break; + case b: + h = ((r - g) / d + 4) / 6; + break; } } - return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) } -} + return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }; +}; // Convert HSL to RGB const hslToRgb = (h: number, s: number, l: number): { r: number; g: number; b: number } => { - h /= 360; s /= 100; l /= 100 - let r, g, b + h /= 360; + s /= 100; + l /= 100; + let r, g, b; if (s === 0) { - r = g = b = l + r = g = b = l; } else { const hue2rgb = (p: number, q: number, t: number) => { - if (t < 0) t += 1 - if (t > 1) t -= 1 - if (t < 1/6) return p + (q - p) * 6 * t - if (t < 1/2) return q - if (t < 2/3) return p + (q - p) * (2/3 - t) * 6 - return p - } - const q = l < 0.5 ? l * (1 + s) : l + s - l * s - const p = 2 * l - q - r = hue2rgb(p, q, h + 1/3) - g = hue2rgb(p, q, h) - b = hue2rgb(p, q, h - 1/3) + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); } - return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) } -} + return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }; +}; const PRESET_COLORS = [ - '#3b82f6', '#ef4444', '#22c55e', '#f59e0b', - '#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6', - '#84cc16', '#f97316', '#6366f1', '#a855f7', -] + '#3b82f6', + '#ef4444', + '#22c55e', + '#f59e0b', + '#8b5cf6', + '#ec4899', + '#06b6d4', + '#14b8a6', + '#84cc16', + '#f97316', + '#6366f1', + '#a855f7', +]; export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) { - const [isOpen, setIsOpen] = useState(false) - const [localValue, setLocalValue] = useState(value) + const [isOpen, setIsOpen] = useState(false); + const [localValue, setLocalValue] = useState(value); const [hsl, setHsl] = useState(() => { - const rgb = hexToRgb(value) - return rgbToHsl(rgb.r, rgb.g, rgb.b) - }) - const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number; openUp: boolean }>({ top: 0, left: 0, openUp: false }) + const rgb = hexToRgb(value); + return rgbToHsl(rgb.r, rgb.g, rgb.b); + }); + const [pickerPosition, setPickerPosition] = useState<{ + top: number; + left: number; + openUp: boolean; + }>({ top: 0, left: 0, openUp: false }); - const buttonRef = useRef(null) - const pickerRef = useRef(null) - const colorInputRef = useRef(null) + const buttonRef = useRef(null); + const pickerRef = useRef(null); + const colorInputRef = useRef(null); - const isTelegram = useMemo(() => isTelegramWebApp(), []) + const isTelegram = useMemo(() => isTelegramWebApp(), []); // Sync with external value useEffect(() => { - setLocalValue(value) - const rgb = hexToRgb(value) - setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) - }, [value]) + setLocalValue(value); + const rgb = hexToRgb(value); + setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)); + }, [value]); // Calculate picker position const updatePosition = useCallback(() => { - if (!buttonRef.current) return + if (!buttonRef.current) return; - const rect = buttonRef.current.getBoundingClientRect() - const pickerHeight = 320 - const pickerWidth = 280 - const padding = 12 + const rect = buttonRef.current.getBoundingClientRect(); + const pickerHeight = 320; + const pickerWidth = 280; + const padding = 12; // Check if there's space below - const spaceBelow = window.innerHeight - rect.bottom - const spaceAbove = rect.top - const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow + const spaceBelow = window.innerHeight - rect.bottom; + const spaceAbove = rect.top; + const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow; // Calculate left position (ensure it stays in viewport) - let left = rect.left + let left = rect.left; if (left + pickerWidth > window.innerWidth - padding) { - left = window.innerWidth - pickerWidth - padding + left = window.innerWidth - pickerWidth - padding; } - if (left < padding) left = padding + if (left < padding) left = padding; setPickerPosition({ top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8, left, - openUp - }) - }, []) + openUp, + }); + }, []); // Open picker const handleOpen = useCallback(() => { - if (disabled) return - updatePosition() - setIsOpen(true) - }, [disabled, updatePosition]) + if (disabled) return; + updatePosition(); + setIsOpen(true); + }, [disabled, updatePosition]); // Close picker const handleClose = useCallback(() => { - setIsOpen(false) - }, []) + setIsOpen(false); + }, []); // Handle click outside useEffect(() => { - if (!isOpen) return + if (!isOpen) return; const handleClickOutside = (e: MouseEvent) => { if ( - pickerRef.current && !pickerRef.current.contains(e.target as Node) && - buttonRef.current && !buttonRef.current.contains(e.target as Node) + pickerRef.current && + !pickerRef.current.contains(e.target as Node) && + buttonRef.current && + !buttonRef.current.contains(e.target as Node) ) { - handleClose() + handleClose(); } - } + }; - const handleScroll = () => handleClose() - const handleResize = () => updatePosition() + const handleScroll = () => handleClose(); + const handleResize = () => updatePosition(); - document.addEventListener('mousedown', handleClickOutside) - document.addEventListener('touchstart', handleClickOutside as EventListener) - window.addEventListener('scroll', handleScroll, true) - window.addEventListener('resize', handleResize) + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchstart', handleClickOutside as EventListener); + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('resize', handleResize); return () => { - document.removeEventListener('mousedown', handleClickOutside) - document.removeEventListener('touchstart', handleClickOutside as EventListener) - window.removeEventListener('scroll', handleScroll, true) - window.removeEventListener('resize', handleResize) - } - }, [isOpen, handleClose, updatePosition]) + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside as EventListener); + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('resize', handleResize); + }; + }, [isOpen, handleClose, updatePosition]); // Update color from HSL - const updateColorFromHsl = useCallback((newHsl: { h: number; s: number; l: number }) => { - const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l) - const hex = rgbToHex(rgb.r, rgb.g, rgb.b) - setHsl(newHsl) - setLocalValue(hex) - onChange(hex) - }, [onChange]) + const updateColorFromHsl = useCallback( + (newHsl: { h: number; s: number; l: number }) => { + const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l); + const hex = rgbToHex(rgb.r, rgb.g, rgb.b); + setHsl(newHsl); + setLocalValue(hex); + onChange(hex); + }, + [onChange], + ); // Handle hue change - const handleHueChange = useCallback((e: React.ChangeEvent) => { - updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) }) - }, [hsl, updateColorFromHsl]) + const handleHueChange = useCallback( + (e: React.ChangeEvent) => { + updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) }); + }, + [hsl, updateColorFromHsl], + ); // Handle saturation change - const handleSaturationChange = useCallback((e: React.ChangeEvent) => { - updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) }) - }, [hsl, updateColorFromHsl]) + const handleSaturationChange = useCallback( + (e: React.ChangeEvent) => { + updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) }); + }, + [hsl, updateColorFromHsl], + ); // Handle lightness change - const handleLightnessChange = useCallback((e: React.ChangeEvent) => { - updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) }) - }, [hsl, updateColorFromHsl]) + const handleLightnessChange = useCallback( + (e: React.ChangeEvent) => { + updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) }); + }, + [hsl, updateColorFromHsl], + ); // Handle native color input - const handleColorInputChange = useCallback((e: React.ChangeEvent) => { - const newColor = e.target.value - setLocalValue(newColor) - const rgb = hexToRgb(newColor) - setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) - onChange(newColor) - }, [onChange]) + const handleColorInputChange = useCallback( + (e: React.ChangeEvent) => { + const newColor = e.target.value; + setLocalValue(newColor); + const rgb = hexToRgb(newColor); + setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)); + onChange(newColor); + }, + [onChange], + ); // Handle hex input - const handleHexInputChange = useCallback((e: React.ChangeEvent) => { - let newValue = e.target.value - if (newValue && !newValue.startsWith('#')) { - newValue = '#' + newValue - } - if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) { - setLocalValue(newValue) - if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) { - const rgb = hexToRgb(newValue) - setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) - onChange(newValue) + const handleHexInputChange = useCallback( + (e: React.ChangeEvent) => { + let newValue = e.target.value; + if (newValue && !newValue.startsWith('#')) { + newValue = '#' + newValue; } - } - }, [onChange]) + if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) { + setLocalValue(newValue); + if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) { + const rgb = hexToRgb(newValue); + setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)); + onChange(newValue); + } + } + }, + [onChange], + ); // Handle preset click - const handlePresetClick = useCallback((color: string) => { - setLocalValue(color) - const rgb = hexToRgb(color) - setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) - onChange(color) - handleClose() - }, [onChange, handleClose]) + const handlePresetClick = useCallback( + (color: string) => { + setLocalValue(color); + const rgb = hexToRgb(color); + setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)); + onChange(color); + handleClose(); + }, + [onChange, handleClose], + ); // Picker content const pickerContent = isOpen ? (
e.stopPropagation()} > {/* Color preview header */} -
+
{/* Controls */} -
+
{/* Hue slider */}
@@ -259,9 +304,10 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C max="360" value={hsl.h} onChange={handleHueChange} - className="w-full h-3 rounded-full appearance-none cursor-pointer" + className="h-3 w-full cursor-pointer appearance-none rounded-full" style={{ - background: 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)', + background: + 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)', }} />
@@ -278,7 +324,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C max="100" value={hsl.s} onChange={handleSaturationChange} - className="w-full h-3 rounded-full appearance-none cursor-pointer" + className="h-3 w-full cursor-pointer appearance-none rounded-full" style={{ background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`, }} @@ -297,7 +343,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C max="100" value={hsl.l} onChange={handleLightnessChange} - className="w-full h-3 rounded-full appearance-none cursor-pointer" + className="h-3 w-full cursor-pointer appearance-none rounded-full" style={{ background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`, }} @@ -311,21 +357,21 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C type="text" value={localValue} onChange={handleHexInputChange} - className="flex-1 h-9 px-3 text-sm font-mono uppercase bg-dark-800 border border-dark-700 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + className="h-9 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 font-mono text-sm uppercase text-dark-100 focus:border-accent-500 focus:outline-none" placeholder="#000000" maxLength={7} />
{/* Presets */} -
- Presets +
+ Presets
{PRESET_COLORS.map((preset) => (
- ) : null + ) : null; return (
- - {description &&

{description}

} + + {description &&

{description}

}
{/* Color preview button */} @@ -352,7 +398,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C type="button" onClick={handleOpen} disabled={disabled} - className="w-10 h-10 rounded-xl border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0" + className="h-10 w-10 flex-shrink-0 rounded-xl border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:cursor-not-allowed disabled:opacity-50" style={{ backgroundColor: localValue || '#000000' }} title={localValue} /> @@ -363,7 +409,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C value={localValue} onChange={handleHexInputChange} disabled={disabled} - className="flex-1 min-w-0 h-10 px-2 font-mono text-sm uppercase bg-dark-800 border border-dark-700 rounded-xl text-dark-100 focus:outline-none focus:border-accent-500 disabled:opacity-50" + className="h-10 min-w-0 flex-1 rounded-xl border border-dark-700 bg-dark-800 px-2 font-mono text-sm uppercase text-dark-100 focus:border-accent-500 focus:outline-none disabled:opacity-50" placeholder="#000000" maxLength={7} /> @@ -383,11 +429,21 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C type="button" onClick={() => colorInputRef.current?.click()} disabled={disabled} - className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 flex-shrink-0" + className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50" title="System color picker" > - - + + @@ -397,5 +453,5 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C {/* Render picker in portal */} {typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
- ) + ); } diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 0b4d363..73037f9 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -1,465 +1,537 @@ -import { useState, useMemo, useEffect, useCallback, useRef } from 'react' -import { createPortal } from 'react-dom' -import { useTranslation } from 'react-i18next' -import { useQuery } from '@tanstack/react-query' -import { subscriptionApi } from '../api/subscription' -import { useTelegramWebApp } from '../hooks/useTelegramWebApp' -import type { AppInfo, AppConfig, LocalizedText } from '../types' +import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { subscriptionApi } from '../api/subscription'; +import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; +import type { AppInfo, AppConfig, LocalizedText } from '../types'; interface ConnectionModalProps { - onClose: () => void + onClose: () => void; } // Icons const CloseIcon = () => ( - + -) +); const CopyIcon = () => ( - - + + -) +); const CheckIcon = () => ( - + -) +); const LinkIcon = () => ( - - + + -) +); const ChevronIcon = () => ( - + -) +); const BackIcon = () => ( - + -) +); // App icons const HappIcon = () => ( - - - - - - - + + + + + + + -) +); const ClashMetaIcon = () => ( - - + + -) +); const ShadowrocketIcon = () => ( - - + + -) +); const StreisandIcon = () => ( - - + + -) +); const getAppIcon = (appName: string): React.ReactNode => { - const name = appName.toLowerCase() - if (name.includes('happ')) return - if (name.includes('shadowrocket') || name.includes('rocket')) return - if (name.includes('streisand')) return - if (name.includes('clash') || name.includes('meta') || name.includes('verge')) return - return 📦 -} + const name = appName.toLowerCase(); + if (name.includes('happ')) return ; + if (name.includes('shadowrocket') || name.includes('rocket')) return ; + if (name.includes('streisand')) return ; + if (name.includes('clash') || name.includes('meta') || name.includes('verge')) + return ; + return 📦; +}; -const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] -const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] +const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; +// eslint-disable-next-line no-script-url -- listing dangerous schemes to block them +const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']; function isValidExternalUrl(url: string | undefined): boolean { - if (!url) return false - const lowerUrl = url.toLowerCase().trim() - if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false - return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://') + if (!url) return false; + const lowerUrl = url.toLowerCase().trim(); + if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false; + return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://'); } function isValidDeepLink(url: string | undefined): boolean { - if (!url) return false - const lowerUrl = url.toLowerCase().trim() - if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false - return lowerUrl.includes('://') + if (!url) return false; + const lowerUrl = url.toLowerCase().trim(); + if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false; + return lowerUrl.includes('://'); } function detectPlatform(): string | null { - if (typeof window === 'undefined' || !navigator?.userAgent) return null - const ua = navigator.userAgent.toLowerCase() - if (/iphone|ipad|ipod/.test(ua)) return 'ios' - if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android' - if (/macintosh|mac os x/.test(ua)) return 'macos' - if (/windows/.test(ua)) return 'windows' - if (/linux/.test(ua)) return 'linux' - return null + if (typeof window === 'undefined' || !navigator?.userAgent) return null; + const ua = navigator.userAgent.toLowerCase(); + if (/iphone|ipad|ipod/.test(ua)) return 'ios'; + if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android'; + if (/macintosh|mac os x/.test(ua)) return 'macos'; + if (/windows/.test(ua)) return 'windows'; + if (/linux/.test(ua)) return 'linux'; + return null; } function useIsMobile() { const [isMobile, setIsMobile] = useState(() => { - if (typeof window === 'undefined') return false - return window.innerWidth < 768 - }) + if (typeof window === 'undefined') return false; + return window.innerWidth < 768; + }); useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 768) - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) - return isMobile + const check = () => setIsMobile(window.innerWidth < 768); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); + return isMobile; } export default function ConnectionModal({ onClose }: ConnectionModalProps) { - const { t, i18n } = useTranslation() - const [selectedApp, setSelectedApp] = useState(null) - const [copied, setCopied] = useState(false) - const [showAppSelector, setShowAppSelector] = useState(false) - const [selectedPlatform, setSelectedPlatform] = useState(null) + const { t, i18n } = useTranslation(); + const [selectedApp, setSelectedApp] = useState(null); + const [copied, setCopied] = useState(false); + const [showAppSelector, setShowAppSelector] = useState(false); + const [selectedPlatform, setSelectedPlatform] = useState(null); - const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp() - const isMobileScreen = useIsMobile() - const isMobile = isMobileScreen - const scrollContainerRef = useRef(null) + const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = + useTelegramWebApp(); + const isMobileScreen = useIsMobile(); + const isMobile = isMobileScreen; + const scrollContainerRef = useRef(null); // Ref для хранения актуального обработчика BackButton (фикс мигания) - const backButtonHandlerRef = useRef<() => void>(() => {}) + const backButtonHandlerRef = useRef<() => void>(() => {}); // Prevent scroll events from bubbling to parent/Telegram const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => { - const container = e.currentTarget - const { scrollTop, scrollHeight, clientHeight } = container - const isAtTop = scrollTop === 0 - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1 + const container = e.currentTarget; + const { scrollTop, scrollHeight, clientHeight } = container; + const isAtTop = scrollTop === 0; + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1; // Prevent scroll propagation when not at boundaries, or when scrolling away from boundary - if ((!isAtTop && !isAtBottom) || - (isAtTop && e.deltaY > 0) || - (isAtBottom && e.deltaY < 0)) { - e.stopPropagation() + if ((!isAtTop && !isAtBottom) || (isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) { + e.stopPropagation(); } - }, []) + }, []); - const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 - const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0 + const safeBottom = isTelegramWebApp + ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) + : 0; + const safeTop = isTelegramWebApp + ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) + : 0; - const { data: appConfig, isLoading, error } = useQuery({ + const { + data: appConfig, + isLoading, + error, + } = useQuery({ queryKey: ['appConfig'], queryFn: () => subscriptionApi.getAppConfig(), - }) + }); - const detectedPlatform = useMemo(() => detectPlatform(), []) + const detectedPlatform = useMemo(() => detectPlatform(), []); useEffect(() => { - if (!appConfig?.platforms || selectedApp) return - let platform = detectedPlatform + if (!appConfig?.platforms || selectedApp) return; + let platform = detectedPlatform; if (!platform || !appConfig.platforms[platform]?.length) { - platform = platformOrder.find(p => appConfig.platforms[p]?.length > 0) || null + platform = platformOrder.find((p) => appConfig.platforms[p]?.length > 0) || null; } - if (!platform || !appConfig.platforms[platform]?.length) return - const apps = appConfig.platforms[platform] - const app = apps.find(a => a.isFeatured) || apps[0] - if (app) setSelectedApp(app) - }, [appConfig, detectedPlatform, selectedApp]) + if (!platform || !appConfig.platforms[platform]?.length) return; + const apps = appConfig.platforms[platform]; + const app = apps.find((a) => a.isFeatured) || apps[0]; + if (app) setSelectedApp(app); + }, [appConfig, detectedPlatform, selectedApp]); const handleClose = useCallback(() => { - onClose() - }, [onClose]) + onClose(); + }, [onClose]); const handleBack = useCallback(() => { if (selectedPlatform) { - setSelectedPlatform(null) + setSelectedPlatform(null); } else { - setShowAppSelector(false) + setShowAppSelector(false); } - }, [selectedPlatform]) + }, [selectedPlatform]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { - e.preventDefault() - if (showAppSelector) handleBack() - else handleClose() + e.preventDefault(); + if (showAppSelector) handleBack(); + else handleClose(); } - } - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [handleClose, handleBack, showAppSelector]) + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleClose, handleBack, showAppSelector]); // Обновляем ref при изменении логики (без перезапуска эффекта BackButton) useEffect(() => { - backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose - }, [showAppSelector, handleBack, handleClose]) + backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose; + }, [showAppSelector, handleBack, handleClose]); // Управление BackButton — эффект запускается только при mount/unmount // Используем стабильный обработчик через ref, чтобы избежать мигания useEffect(() => { - if (!webApp?.BackButton) return + if (!webApp?.BackButton) return; const stableHandler = () => { - backButtonHandlerRef.current() - } + backButtonHandlerRef.current(); + }; - webApp.BackButton.show() - webApp.BackButton.onClick(stableHandler) + webApp.BackButton.show(); + webApp.BackButton.onClick(stableHandler); return () => { - webApp.BackButton.offClick(stableHandler) - webApp.BackButton.hide() - } - }, [webApp]) // Только webApp в зависимостях! + webApp.BackButton.offClick(stableHandler); + webApp.BackButton.hide(); + }; + }, [webApp]); // Только webApp в зависимостях! useEffect(() => { - document.body.style.overflow = 'hidden' - return () => { document.body.style.overflow = '' } - }, []) + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = ''; + }; + }, []); const getLocalizedText = (text: LocalizedText | undefined): string => { - if (!text) return '' - const lang = i18n.language || 'en' - return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '' - } + if (!text) return ''; + const lang = i18n.language || 'en'; + return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''; + }; const availablePlatforms = useMemo(() => { - if (!appConfig?.platforms) return [] - const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0) + if (!appConfig?.platforms) return []; + const available = platformOrder.filter((key) => appConfig.platforms[key]?.length > 0); if (detectedPlatform && available.includes(detectedPlatform)) { - return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)] + return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)]; } - return available - }, [appConfig, detectedPlatform]) + return available; + }, [appConfig, detectedPlatform]); const copySubscriptionLink = async () => { - if (!appConfig?.subscriptionUrl) return + if (!appConfig?.subscriptionUrl) return; try { - await navigator.clipboard.writeText(appConfig.subscriptionUrl) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + await navigator.clipboard.writeText(appConfig.subscriptionUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); } catch { - const textarea = document.createElement('textarea') - textarea.value = appConfig.subscriptionUrl - document.body.appendChild(textarea) - textarea.select() - document.execCommand('copy') - document.body.removeChild(textarea) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + const textarea = document.createElement('textarea'); + textarea.value = appConfig.subscriptionUrl; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); } - } + }; const handleConnect = (app: AppInfo) => { - if (!app.deepLink || !isValidDeepLink(app.deepLink)) return - const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en' - const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}` - const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp + if (!app.deepLink || !isValidDeepLink(app.deepLink)) return; + const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'; + const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}`; + const tg = ( + window as unknown as { + Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } }; + } + ).Telegram?.WebApp; if (tg?.openLink) { try { - tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) - return - } catch { /* fallback */ } + tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }); + return; + } catch { + /* fallback */ + } } - window.location.href = redirectUrl - } + window.location.href = redirectUrl; + }; // Wrapper component const Wrapper = ({ children }: { children: React.ReactNode }) => { if (isMobile) { const content = (
{children}
- ) - if (typeof document !== 'undefined') return createPortal(content, document.body) - return content + ); + if (typeof document !== 'undefined') return createPortal(content, document.body); + return content; } // Desktop centered - positioned higher return ( -
+
e.stopPropagation()} + className="relative flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900 shadow-2xl" + onClick={(e) => e.stopPropagation()} > {children}
- ) - } + ); + }; // Loading if (isLoading) { return ( -
-
+
+
- ) + ); } // Error if (error || !appConfig) { return ( -
-

{t('common.error')}

- +
+

{t('common.error')}

+
- ) + ); } // No subscription if (!appConfig.hasSubscription) { return ( -
-

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

-

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

- +
+

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

+

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

+
- ) + ); } // App selector if (showAppSelector) { const platformNames: Record = { - ios: 'iOS', android: 'Android', windows: 'Windows', - macos: 'macOS', linux: 'Linux', androidTV: 'Android TV', appleTV: 'Apple TV' - } + ios: 'iOS', + android: 'Android', + windows: 'Windows', + macos: 'macOS', + linux: 'Linux', + androidTV: 'Android TV', + appleTV: 'Apple TV', + }; const platformIcons: Record = { ios: ( - - + + ), android: ( - - + + ), windows: ( - - + + ), macos: ( - - + + ), linux: ( - - + + ), androidTV: ( - - + + ), appleTV: ( - - + + ), - } + }; // Step 1: Platform selection if (!selectedPlatform) { return ( -
- -

{t('subscription.connection.selectPlatform') || 'Выберите платформу'}

+

+ {t('subscription.connection.selectPlatform') || 'Выберите платформу'} +

-
- {availablePlatforms.map(platform => { - const apps = appConfig.platforms[platform] - if (!apps?.length) return null - const isCurrentPlatform = platform === detectedPlatform - const appCount = apps.length +
+ {availablePlatforms.map((platform) => { + const apps = appConfig.platforms[platform]; + if (!apps?.length) return null; + const isCurrentPlatform = platform === detectedPlatform; + const appCount = apps.length; return ( - ) + ); })}
- ) + ); } // Step 2: App selection for chosen platform - const apps = appConfig.platforms[selectedPlatform] || [] - const isCurrentPlatform = selectedPlatform === detectedPlatform + const apps = appConfig.platforms[selectedPlatform] || []; + const isCurrentPlatform = selectedPlatform === detectedPlatform; return ( -
-
-

{platformNames[selectedPlatform] || selectedPlatform}

+

+ {platformNames[selectedPlatform] || selectedPlatform} +

{isCurrentPlatform && ( - {t('subscription.connection.yourDevice')} + + {t('subscription.connection.yourDevice')} + )}
@@ -470,13 +542,17 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { onWheel={handleScrollContainerWheel} >
- {apps.map(app => { - const isSelected = selectedApp?.id === app.id + {apps.map((app) => { + const isSelected = selectedApp?.id === app.id; return ( - ) + ); })}
- ) + ); } // Main view return ( -
-
-

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

-
{selectedApp?.installationStep && (
- 1 - {t('subscription.connection.installApp')} + + 1 + + + {t('subscription.connection.installApp')} +
-

{getLocalizedText(selectedApp.installationStep.description)}

- {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( -
- {selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => ( - - {getLocalizedText(btn.buttonText)} - - ))} -
- )} +

+ {getLocalizedText(selectedApp.installationStep.description)} +

+ {selectedApp.installationStep.buttons && + selectedApp.installationStep.buttons.length > 0 && ( +
+ {selectedApp.installationStep.buttons + .filter((btn) => isValidExternalUrl(btn.buttonLink)) + .map((btn, idx) => ( + + {getLocalizedText(btn.buttonText)} + + ))} +
+ )}
)} {selectedApp?.addSubscriptionStep && (
- 2 - {t('subscription.connection.addSubscription')} + + 2 + + + {t('subscription.connection.addSubscription')} +
-

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

-
+

+ {getLocalizedText(selectedApp.addSubscriptionStep.description)} +

+
{selectedApp.deepLink && ( )}
@@ -610,13 +716,19 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { {selectedApp?.connectAndUseStep && (
- 3 - {t('subscription.connection.connectVpn')} + + 3 + + + {t('subscription.connection.connectVpn')} +
-

{getLocalizedText(selectedApp.connectAndUseStep.description)}

+

+ {getLocalizedText(selectedApp.connectAndUseStep.description)} +

)}
- ) + ); } diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index 9ed2ed5..a2802af 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -1,20 +1,20 @@ -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useQuery } from '@tanstack/react-query' -import { balanceApi } from '../api/balance' -import { useCurrency } from '../hooks/useCurrency' -import TopUpModal from './TopUpModal' -import type { PaymentMethod } from '../types' +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { balanceApi } from '../api/balance'; +import { useCurrency } from '../hooks/useCurrency'; +import TopUpModal from './TopUpModal'; +import type { PaymentMethod } from '../types'; interface InsufficientBalancePromptProps { /** Amount missing in kopeks */ - missingAmountKopeks: number + missingAmountKopeks: number; /** Optional custom message */ - message?: string + message?: string; /** Compact mode for inline use */ - compact?: boolean + compact?: boolean; /** Additional className */ - className?: string + className?: string; } export default function InsufficientBalancePrompt({ @@ -23,40 +23,55 @@ export default function InsufficientBalancePrompt({ compact = false, className = '', }: InsufficientBalancePromptProps) { - const { t } = useTranslation() - const { formatAmount, currencySymbol } = useCurrency() - const [showMethodSelect, setShowMethodSelect] = useState(false) - const [selectedMethod, setSelectedMethod] = useState(null) + const { t } = useTranslation(); + const { formatAmount, currencySymbol } = useCurrency(); + const [showMethodSelect, setShowMethodSelect] = useState(false); + const [selectedMethod, setSelectedMethod] = useState(null); const { data: paymentMethods } = useQuery({ queryKey: ['payment-methods'], queryFn: balanceApi.getPaymentMethods, enabled: showMethodSelect, - }) + }); - const missingRubles = missingAmountKopeks / 100 - const displayAmount = formatAmount(missingRubles) + const missingRubles = missingAmountKopeks / 100; + const displayAmount = formatAmount(missingRubles); const handleMethodSelect = (method: PaymentMethod) => { - setSelectedMethod(method) - setShowMethodSelect(false) - } + setSelectedMethod(method); + setShowMethodSelect(false); + }; if (compact) { return ( <> -
+
- - + + - {message || t('balance.insufficientFunds')}: {displayAmount} {currencySymbol} + {message || t('balance.insufficientFunds')}:{' '} + + {displayAmount} {currencySymbol} +
@@ -78,37 +93,54 @@ export default function InsufficientBalancePrompt({ /> )} - ) + ); } return ( <> -
+
-
- - +
+ +
-
-
- {t('balance.insufficientFunds')} -
-
- {message || t('balance.topUpToComplete')} -
+
+
{t('balance.insufficientFunds')}
+
{message || t('balance.topUpToComplete')}
- {t('balance.missing')}: {displayAmount} {currencySymbol} + {t('balance.missing')}:{' '} + + {displayAmount} {currencySymbol} +
{/* Content */} -
+
{!paymentMethods ? (
-
+
) : paymentMethods.length === 0 ? ( -
+
{t('balance.noPaymentMethods')}
) : ( paymentMethods.map((method) => { - const methodKey = method.id.toLowerCase().replace(/-/g, '_') - const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) + const methodKey = method.id.toLowerCase().replace(/-/g, '_'); + const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { + defaultValue: '', + }); return ( - ) + ); }) )}
- ) + ); } diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 47ad820..5d50c2d 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -1,53 +1,53 @@ -import { useTranslation } from 'react-i18next' -import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next'; +import { useState, useRef, useEffect } from 'react'; const languages = [ { code: 'ru', name: 'RU', flag: '🇷🇺', fullName: 'Русский' }, { code: 'en', name: 'EN', flag: '🇬🇧', fullName: 'English' }, { code: 'zh', name: 'ZH', flag: '🇨🇳', fullName: '中文' }, { code: 'fa', name: 'FA', flag: '🇮🇷', fullName: 'فارسی' }, -] +]; export default function LanguageSwitcher() { - const { i18n } = useTranslation() - const [isOpen, setIsOpen] = useState(false) - const dropdownRef = useRef(null) + const { i18n } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); - const currentLang = languages.find((l) => l.code === i18n.language) || languages[0] + const currentLang = languages.find((l) => l.code === i18n.language) || languages[0]; useEffect(() => { function handleClickOutside(event: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false) + setIsOpen(false); } } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); const changeLanguage = (code: string) => { - i18n.changeLanguage(code) + i18n.changeLanguage(code); // Set document direction for RTL languages - document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr' - setIsOpen(false) - } + document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr'; + setIsOpen(false); + }; // Set initial direction on mount useEffect(() => { - document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr' - }, [i18n.language]) + document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr'; + }, [i18n.language]); return (
)}
- ) + ); } diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx index cbbbf9e..03af869 100644 --- a/src/components/Onboarding.tsx +++ b/src/components/Onboarding.tsx @@ -1,146 +1,147 @@ -import { useState, useEffect, useCallback, useRef } from 'react' -import { createPortal } from 'react-dom' -import { useTranslation } from 'react-i18next' +import { useState, useEffect, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; interface OnboardingStep { - target: string // data-onboarding attribute value - title: string - description: string - placement: 'top' | 'bottom' | 'left' | 'right' + target: string; // data-onboarding attribute value + title: string; + description: string; + placement: 'top' | 'bottom' | 'left' | 'right'; } interface OnboardingProps { - steps: OnboardingStep[] - onComplete: () => void - onSkip: () => void + steps: OnboardingStep[]; + onComplete: () => void; + onSkip: () => void; } -const STORAGE_KEY = 'onboarding_completed' +const STORAGE_KEY = 'onboarding_completed'; +// eslint-disable-next-line react-refresh/only-export-components export function useOnboarding() { const [isCompleted, setIsCompleted] = useState(() => { - return localStorage.getItem(STORAGE_KEY) === 'true' - }) + return localStorage.getItem(STORAGE_KEY) === 'true'; + }); const complete = useCallback(() => { - localStorage.setItem(STORAGE_KEY, 'true') - setIsCompleted(true) - }, []) + localStorage.setItem(STORAGE_KEY, 'true'); + setIsCompleted(true); + }, []); const reset = useCallback(() => { - localStorage.removeItem(STORAGE_KEY) - setIsCompleted(false) - }, []) + localStorage.removeItem(STORAGE_KEY); + setIsCompleted(false); + }, []); - return { isCompleted, complete, reset } + return { isCompleted, complete, reset }; } export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProps) { - const { t } = useTranslation() - const [currentStep, setCurrentStep] = useState(0) - const [targetRect, setTargetRect] = useState(null) - const [isVisible, setIsVisible] = useState(false) - const tooltipRef = useRef(null) + const { t } = useTranslation(); + const [currentStep, setCurrentStep] = useState(0); + const [targetRect, setTargetRect] = useState(null); + const [isVisible, setIsVisible] = useState(false); + const tooltipRef = useRef(null); - const step = steps[currentStep] + const step = steps[currentStep]; // Find and highlight target element useEffect(() => { const findTarget = () => { - const target = document.querySelector(`[data-onboarding="${step.target}"]`) + const target = document.querySelector(`[data-onboarding="${step.target}"]`); if (target) { - const rect = target.getBoundingClientRect() - setTargetRect(rect) + const rect = target.getBoundingClientRect(); + setTargetRect(rect); // Scroll element into view if needed - target.scrollIntoView({ behavior: 'smooth', block: 'center' }) + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Delay visibility for smooth animation - setTimeout(() => setIsVisible(true), 100) + setTimeout(() => setIsVisible(true), 100); } - } + }; - setIsVisible(false) - const timer = setTimeout(findTarget, 300) - return () => clearTimeout(timer) - }, [step.target]) + setIsVisible(false); + const timer = setTimeout(findTarget, 300); + return () => clearTimeout(timer); + }, [step.target]); // Recalculate position on resize/scroll useEffect(() => { const updatePosition = () => { - const target = document.querySelector(`[data-onboarding="${step.target}"]`) + const target = document.querySelector(`[data-onboarding="${step.target}"]`); if (target) { - setTargetRect(target.getBoundingClientRect()) + setTargetRect(target.getBoundingClientRect()); } - } + }; - window.addEventListener('resize', updatePosition) - window.addEventListener('scroll', updatePosition, true) + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); return () => { - window.removeEventListener('resize', updatePosition) - window.removeEventListener('scroll', updatePosition, true) - } - }, [step.target]) + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [step.target]); const handleNext = () => { if (currentStep < steps.length - 1) { - setCurrentStep(currentStep + 1) + setCurrentStep(currentStep + 1); } else { - onComplete() + onComplete(); } - } + }; const handlePrev = () => { if (currentStep > 0) { - setCurrentStep(currentStep - 1) + setCurrentStep(currentStep - 1); } - } + }; const handleSkip = () => { - onSkip() - } + onSkip(); + }; // Calculate tooltip position const getTooltipStyle = (): React.CSSProperties => { - if (!targetRect) return { opacity: 0 } + if (!targetRect) return { opacity: 0 }; - const padding = 16 - const tooltipWidth = 320 - const tooltipHeight = tooltipRef.current?.offsetHeight || 150 + const padding = 16; + const tooltipWidth = 320; + const tooltipHeight = tooltipRef.current?.offsetHeight || 150; - let top = 0 - let left = 0 + let top = 0; + let left = 0; switch (step.placement) { case 'bottom': - top = targetRect.bottom + padding - left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2 - break + top = targetRect.bottom + padding; + left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2; + break; case 'top': - top = targetRect.top - tooltipHeight - padding - left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2 - break + top = targetRect.top - tooltipHeight - padding; + left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2; + break; case 'left': - top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2 - left = targetRect.left - tooltipWidth - padding - break + top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2; + left = targetRect.left - tooltipWidth - padding; + break; case 'right': - top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2 - left = targetRect.right + padding - break + top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2; + left = targetRect.right + padding; + break; } // Keep within viewport - const viewportWidth = window.innerWidth - const viewportHeight = window.innerHeight + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; - if (left < padding) left = padding + if (left < padding) left = padding; if (left + tooltipWidth > viewportWidth - padding) { - left = viewportWidth - tooltipWidth - padding + left = viewportWidth - tooltipWidth - padding; } - if (top < padding) top = padding + if (top < padding) top = padding; if (top + tooltipHeight > viewportHeight - padding) { - top = viewportHeight - tooltipHeight - padding + top = viewportHeight - tooltipHeight - padding; } return { @@ -149,22 +150,22 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp width: tooltipWidth, opacity: isVisible ? 1 : 0, transform: isVisible ? 'scale(1)' : 'scale(0.95)', - } - } + }; + }; // Spotlight style const getSpotlightStyle = (): React.CSSProperties => { - if (!targetRect) return { opacity: 0 } + if (!targetRect) return { opacity: 0 }; - const padding = 8 + const padding = 8; return { top: targetRect.top - padding, left: targetRect.left - padding, width: targetRect.width + padding * 2, height: targetRect.height + padding * 2, opacity: isVisible ? 1 : 0, - } - } + }; + }; return createPortal(
@@ -178,7 +179,7 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp style={getTooltipStyle()} > {/* Progress indicator */} -
+
{steps.map((s, index) => (
))}
{/* Content */} -

{step.title}

-

{step.description}

+

{step.title}

+

{step.description}

{/* Actions */}
{currentStep > 0 && ( - )} -
, - document.body - ) + document.body, + ); } diff --git a/src/components/PromoDiscountBadge.tsx b/src/components/PromoDiscountBadge.tsx index cf53f41..519eca9 100644 --- a/src/components/PromoDiscountBadge.tsx +++ b/src/components/PromoDiscountBadge.tsx @@ -1,97 +1,111 @@ -import { useState, useRef, useEffect } from 'react' -import { useQuery } from '@tanstack/react-query' -import { useNavigate } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import { promoApi } from '../api/promo' +import { useState, useRef, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { promoApi } from '../api/promo'; const SparklesIcon = () => ( - - + + -) +); const ClockIcon = () => ( - - + + -) +); const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => { - const now = new Date() + const now = new Date(); // Ensure UTC parsing - if no timezone specified, assume UTC - let expires: Date + let expires: Date; if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) { - expires = new Date(expiresAt) + expires = new Date(expiresAt); } else { // No timezone - treat as UTC - expires = new Date(expiresAt + 'Z') + expires = new Date(expiresAt + 'Z'); } - const diffMs = expires.getTime() - now.getTime() + const diffMs = expires.getTime() - now.getTime(); - if (diffMs <= 0) return '' + if (diffMs <= 0) return ''; - const hours = Math.floor(diffMs / (1000 * 60 * 60)) - const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); if (hours > 24) { - const days = Math.floor(hours / 24) - return `${days}${t('promo.time.days')}` + const days = Math.floor(hours / 24); + return `${days}${t('promo.time.days')}`; } if (hours > 0) { - return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}` + return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`; } - return `${minutes}${t('promo.time.minutes')}` -} + return `${minutes}${t('promo.time.minutes')}`; +}; export default function PromoDiscountBadge() { - const { t } = useTranslation() - const navigate = useNavigate() - const [isOpen, setIsOpen] = useState(false) - const dropdownRef = useRef(null) + const { t } = useTranslation(); + const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); const { data: activeDiscount } = useQuery({ queryKey: ['active-discount'], queryFn: promoApi.getActiveDiscount, staleTime: 30000, refetchInterval: 60000, // Refresh every minute - }) + }); // Close dropdown on click outside useEffect(() => { function handleClickOutside(event: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false) + setIsOpen(false); } } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); // Don't render if no active discount if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) { - return null + return null; } - const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null + const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null; const handleClick = () => { - setIsOpen(!isOpen) - } + setIsOpen(!isOpen); + }; const handleGoToSubscription = () => { - setIsOpen(false) - navigate('/subscription') - } + setIsOpen(false); + navigate('/subscription'); + }; return (
{/* Badge button */} @@ -101,21 +115,19 @@ export default function PromoDiscountBadge() { <> {/* Mobile backdrop */}
setIsOpen(false)} /> -
+
{/* Header */} -
+
-
+
-
- {t('promo.discountActive')} -
+
{t('promo.discountActive')}
-{activeDiscount.discount_percent}%
@@ -124,23 +136,24 @@ export default function PromoDiscountBadge() {
{/* Content */} -
-

- {t('promo.discountDescription')} -

+
+

{t('promo.discountDescription')}

{/* Time remaining */} {timeLeft && ( -
+
- {t('promo.expiresIn')}: {timeLeft} + + {t('promo.expiresIn')}:{' '} + {timeLeft} +
)} {/* CTA Button */} @@ -149,5 +162,5 @@ export default function PromoDiscountBadge() { )}
- ) + ); } diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx index d335bac..d8e9c9e 100644 --- a/src/components/PromoOffersSection.tsx +++ b/src/components/PromoOffersSection.tsx @@ -1,144 +1,165 @@ -import { useState } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { promoApi, PromoOffer } from '../api/promo' +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { promoApi, PromoOffer } from '../api/promo'; // Icons const GiftIcon = () => ( - - + + -) +); const ClockIcon = () => ( - - + + -) +); const SparklesIcon = () => ( - - + + -) +); const CheckIcon = () => ( - + -) +); const ServerIcon = () => ( - - + + -) +); // Helper functions const formatTimeLeft = (expiresAt: string): string => { - const now = new Date() + const now = new Date(); // Ensure UTC parsing - if no timezone specified, assume UTC - let expires: Date + let expires: Date; if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) { - expires = new Date(expiresAt) + expires = new Date(expiresAt); } else { // No timezone - treat as UTC - expires = new Date(expiresAt + 'Z') + expires = new Date(expiresAt + 'Z'); } - const diffMs = expires.getTime() - now.getTime() + const diffMs = expires.getTime() - now.getTime(); - if (diffMs <= 0) return 'Истекло' + if (diffMs <= 0) return 'Истекло'; - const hours = Math.floor(diffMs / (1000 * 60 * 60)) - const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); if (hours > 24) { - const days = Math.floor(hours / 24) - return `${days} дн.` + const days = Math.floor(hours / 24); + return `${days} дн.`; } if (hours > 0) { - return `${hours}ч ${minutes}м` + return `${hours}ч ${minutes}м`; } - return `${minutes}м` -} + return `${minutes}м`; +}; const getOfferIcon = (effectType: string) => { - if (effectType === 'test_access') return - return -} + if (effectType === 'test_access') return ; + return ; +}; const getOfferTitle = (offer: PromoOffer): string => { if (offer.effect_type === 'test_access') { - return 'Тестовый доступ' + return 'Тестовый доступ'; } if (offer.discount_percent) { - return `Скидка ${offer.discount_percent}%` + return `Скидка ${offer.discount_percent}%`; } - return 'Специальное предложение' -} + return 'Специальное предложение'; +}; const getOfferDescription = (offer: PromoOffer): string => { if (offer.effect_type === 'test_access') { - const squads = offer.extra_data?.test_squad_uuids?.length || 0 - return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам' + const squads = offer.extra_data?.test_squad_uuids?.length || 0; + return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам'; } - return 'Активируйте скидку на покупку подписки' -} + return 'Активируйте скидку на покупку подписки'; +}; interface PromoOffersSectionProps { - className?: string + className?: string; } export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) { - const queryClient = useQueryClient() - const [claimingId, setClaimingId] = useState(null) - const [successMessage, setSuccessMessage] = useState(null) - const [errorMessage, setErrorMessage] = useState(null) + const queryClient = useQueryClient(); + const [claimingId, setClaimingId] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); // Fetch available offers const { data: offers = [], isLoading: offersLoading } = useQuery({ queryKey: ['promo-offers'], queryFn: promoApi.getOffers, staleTime: 30000, - }) + }); // Fetch active discount const { data: activeDiscount } = useQuery({ queryKey: ['active-discount'], queryFn: promoApi.getActiveDiscount, staleTime: 30000, - }) + }); // Claim offer mutation const claimMutation = useMutation({ mutationFn: promoApi.claimOffer, onSuccess: (result) => { - queryClient.invalidateQueries({ queryKey: ['promo-offers'] }) - queryClient.invalidateQueries({ queryKey: ['active-discount'] }) - queryClient.invalidateQueries({ queryKey: ['subscription'] }) - setSuccessMessage(result.message) - setClaimingId(null) - setTimeout(() => setSuccessMessage(null), 5000) + queryClient.invalidateQueries({ queryKey: ['promo-offers'] }); + queryClient.invalidateQueries({ queryKey: ['active-discount'] }); + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + setSuccessMessage(result.message); + setClaimingId(null); + setTimeout(() => setSuccessMessage(null), 5000); }, - onError: (error: any) => { - setErrorMessage(error.response?.data?.detail || 'Не удалось активировать предложение') - setClaimingId(null) - setTimeout(() => setErrorMessage(null), 5000) + onError: (error: unknown) => { + const axiosErr = error as { response?: { data?: { detail?: string } } }; + setErrorMessage(axiosErr.response?.data?.detail || 'Не удалось активировать предложение'); + setClaimingId(null); + setTimeout(() => setErrorMessage(null), 5000); }, - }) + }); const handleClaim = (offerId: number) => { - setClaimingId(offerId) - setErrorMessage(null) - setSuccessMessage(null) - claimMutation.mutate(offerId) - } + setClaimingId(offerId); + setErrorMessage(null); + setSuccessMessage(null); + claimMutation.mutate(offerId); + }; // Filter unclaimed and active offers - const availableOffers = offers.filter(o => o.is_active && !o.is_claimed) + const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed); // Don't render if no offers and no active discount - if (!offersLoading && availableOffers.length === 0 && (!activeDiscount || !activeDiscount.is_active)) { - return null + if ( + !offersLoading && + availableOffers.length === 0 && + (!activeDiscount || !activeDiscount.is_active) + ) { + return null; } return ( @@ -147,15 +168,15 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio {activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
-
+
-
+

Скидка {activeDiscount.discount_percent}% активна

- + Действует
@@ -174,14 +195,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio {/* Success/Error Messages */} {successMessage && ( -
+
{successMessage}
)} {errorMessage && ( -
+
{errorMessage}
)} @@ -192,26 +213,22 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio {availableOffers.map((offer) => (
-
+
{getOfferIcon(offer.effect_type)}
-
-
-

- {getOfferTitle(offer)} -

+
+
+

{getOfferTitle(offer)}

{offer.effect_type === 'test_access' && ( - + Тест )}
-

- {getOfferDescription(offer)} -

+

{getOfferDescription(offer)}

@@ -220,11 +237,11 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio - ) + ); } function HSLSlider({ @@ -111,18 +127,18 @@ function HSLSlider({ gradient, suffix = '', }: { - label: string - value: number - onChange: (value: number) => void - max: number - gradient: string - suffix?: string + label: string; + value: number; + onChange: (value: number) => void; + max: number; + gradient: string; + suffix?: string; }) { return (
- + {value} {suffix} @@ -133,11 +149,11 @@ function HSLSlider({ max={max} value={value} onChange={(e) => onChange(parseInt(e.target.value))} - className="w-full h-2.5 rounded-full appearance-none cursor-pointer" + className="h-2.5 w-full cursor-pointer appearance-none rounded-full" style={{ background: gradient }} />
- ) + ); } function CompactColorInput({ @@ -145,45 +161,45 @@ function CompactColorInput({ value, onChange, }: { - label: string - value: string - onChange: (color: string) => void + label: string; + value: string; + onChange: (color: string) => void; }) { - const [localValue, setLocalValue] = useState(value) - const [isEditing, setIsEditing] = useState(false) + const [localValue, setLocalValue] = useState(value); + const [isEditing, setIsEditing] = useState(false); useEffect(() => { - setLocalValue(value) - }, [value]) + setLocalValue(value); + }, [value]); const handleChange = (newValue: string) => { - let formatted = newValue.toUpperCase() + let formatted = newValue.toUpperCase(); if (!formatted.startsWith('#')) { - formatted = '#' + formatted + formatted = '#' + formatted; } - setLocalValue(formatted) + setLocalValue(formatted); if (isValidHex(formatted)) { - onChange(formatted) + onChange(formatted); } - } + }; const handleBlur = () => { - setIsEditing(false) + setIsEditing(false); if (!isValidHex(localValue)) { - setLocalValue(value) + setLocalValue(value); } - } + }; return ( -
+
)}
- ) + ); } function CollapsibleSection({ @@ -219,24 +235,24 @@ function CollapsibleSection({ children, badge, }: { - title: string - icon: React.ReactNode - isOpen: boolean - onToggle: () => void - children: React.ReactNode - badge?: string + title: string; + icon: React.ReactNode; + isOpen: boolean; + onToggle: () => void; + children: React.ReactNode; + badge?: string; }) { return ( -
+
- ) + ); } export function ThemeBentoPicker({ @@ -267,67 +283,67 @@ export function ThemeBentoPicker({ onSave, isSaving, }: ThemeBentoPickerProps) { - const { t } = useTranslation() + const { t } = useTranslation(); - const [hsl, setHsl] = useState(() => hexToHsl(currentColors.accent)) - const [hexInput, setHexInput] = useState(currentColors.accent) - const [hasChanges, setHasChanges] = useState(false) + const [hsl, setHsl] = useState(() => hexToHsl(currentColors.accent)); + const [hexInput, setHexInput] = useState(currentColors.accent); + const [hasChanges, setHasChanges] = useState(false); - const [isPresetsOpen, setIsPresetsOpen] = useState(false) - const [isAccentOpen, setIsAccentOpen] = useState(false) - const [isDarkOpen, setIsDarkOpen] = useState(false) - const [isLightOpen, setIsLightOpen] = useState(false) - const [isStatusOpen, setIsStatusOpen] = useState(false) + const [isPresetsOpen, setIsPresetsOpen] = useState(false); + const [isAccentOpen, setIsAccentOpen] = useState(false); + const [isDarkOpen, setIsDarkOpen] = useState(false); + const [isLightOpen, setIsLightOpen] = useState(false); + const [isStatusOpen, setIsStatusOpen] = useState(false); const selectedPresetId = useMemo(() => { const match = COLOR_PRESETS.find( (p) => p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() && p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() && - p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase() - ) - return match?.id ?? null - }, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]) + p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(), + ); + return match?.id ?? null; + }, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]); useEffect(() => { - setHsl(hexToHsl(currentColors.accent)) - setHexInput(currentColors.accent) - }, [currentColors.accent]) + setHsl(hexToHsl(currentColors.accent)); + setHexInput(currentColors.accent); + }, [currentColors.accent]); const updateColor = useCallback( (key: keyof ThemeColors, value: string) => { - const newColors = { ...currentColors, [key]: value } - onColorsChange(newColors) - applyThemeColors(newColors) - setHasChanges(true) + const newColors = { ...currentColors, [key]: value }; + onColorsChange(newColors); + applyThemeColors(newColors); + setHasChanges(true); }, - [currentColors, onColorsChange] - ) + [currentColors, onColorsChange], + ); const updateAccentFromHsl = useCallback( (newHsl: HSLColor) => { - setHsl(newHsl) - const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l) - setHexInput(newHex) - updateColor('accent', newHex) + setHsl(newHsl); + const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l); + setHexInput(newHex); + updateColor('accent', newHex); }, - [updateColor] - ) + [updateColor], + ); const handleHexInputChange = (value: string) => { - setHexInput(value) + setHexInput(value); if (isValidHex(value)) { - const newHsl = hexToHsl(value) - setHsl(newHsl) - updateColor('accent', value) + const newHsl = hexToHsl(value); + setHsl(newHsl); + updateColor('accent', value); } - } + }; const handlePresetSelect = (preset: ColorPreset) => { - onColorsChange(preset.colors) - applyThemeColors(preset.colors) - setHasChanges(true) - } + onColorsChange(preset.colors); + applyThemeColors(preset.colors); + setHasChanges(true); + }; const hueGradient = useMemo(() => { return `linear-gradient(to right, @@ -338,23 +354,23 @@ export function ThemeBentoPicker({ hsl(240, ${hsl.s}%, ${hsl.l}%), hsl(300, ${hsl.s}%, ${hsl.l}%), hsl(360, ${hsl.s}%, ${hsl.l}%) - )` - }, [hsl.s, hsl.l]) + )`; + }, [hsl.s, hsl.l]); const saturationGradient = useMemo(() => { return `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%) - )` - }, [hsl.h, hsl.l]) + )`; + }, [hsl.h, hsl.l]); const lightnessGradient = useMemo(() => { return `linear-gradient(to right, hsl(${hsl.h}, ${hsl.s}%, 0%), hsl(${hsl.h}, ${hsl.s}%, 50%), hsl(${hsl.h}, ${hsl.s}%, 100%) - )` - }, [hsl.h, hsl.s]) + )`; + }, [hsl.h, hsl.s]); return (
@@ -365,9 +381,13 @@ export function ThemeBentoPicker({ isOpen={isPresetsOpen} onToggle={() => setIsPresetsOpen(!isPresetsOpen)} > -
+
{COLOR_PRESETS.map((preset, index) => ( -
+
-

+

{t('admin.theme.customizeColors', 'Customize Colors')}

@@ -392,13 +412,13 @@ export function ThemeBentoPicker({ >
-
+
{hexInput.toUpperCase()}
@@ -431,7 +451,7 @@ export function ThemeBentoPicker({ />
-
@@ -532,8 +552,8 @@ export function ThemeBentoPicker({
-
-

+
+

{t('theme.preview', 'Preview')}

@@ -548,12 +568,12 @@ export function ThemeBentoPicker({
{hasChanges && ( -
+
)}
- ) + ); } diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index f270530..5ab21a4 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -1,209 +1,252 @@ -import { useState, useRef, useEffect, useCallback } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useNavigate } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import { ticketNotificationsApi } from '../api/ticketNotifications' -import { useAuthStore } from '../store/auth' -import { useToast } from './Toast' -import { useWebSocket, WSMessage } from '../hooks/useWebSocket' -import { useTelegramWebApp } from '../hooks/useTelegramWebApp' -import type { TicketNotification } from '../types' +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ticketNotificationsApi } from '../api/ticketNotifications'; +import { useAuthStore } from '../store/auth'; +import { useToast } from './Toast'; +import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; +import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; +import type { TicketNotification } from '../types'; const BellIcon = () => ( - - + + -) +); const CheckIcon = () => ( - + -) +); interface TicketNotificationBellProps { - isAdmin?: boolean + isAdmin?: boolean; } export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) { - const { t } = useTranslation() - const navigate = useNavigate() - const queryClient = useQueryClient() - const { isAuthenticated } = useAuthStore() - const { showToast } = useToast() - const [isOpen, setIsOpen] = useState(false) - const dropdownRef = useRef(null) - const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { isAuthenticated } = useAuthStore(); + const { showToast } = useToast(); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp(); // Calculate dropdown top position (account for fullscreen safe area + TG buttons) const dropdownTop = isFullscreen ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header - : 64 // default header height + : 64; // default header height // Show toast for WebSocket notification - const showWSNotificationToast = useCallback((message: WSMessage) => { - const isNewTicket = message.type === 'ticket.new' - const isAdminReply = message.type === 'ticket.admin_reply' - const isUserReply = message.type === 'ticket.user_reply' + const showWSNotificationToast = useCallback( + (message: WSMessage) => { + const isNewTicket = message.type === 'ticket.new'; + const isAdminReply = message.type === 'ticket.admin_reply'; + const isUserReply = message.type === 'ticket.user_reply'; - const icon = isNewTicket ? ( - 🎫 - ) : isAdminReply ? ( - 💬 - ) : ( - 📨 - ) + const icon = isNewTicket ? ( + 🎫 + ) : isAdminReply ? ( + 💬 + ) : ( + 📨 + ); - const ticketTitle = message.title || '' + const ticketTitle = message.title || ''; - let toastTitle: string - let toastMessage: string + let toastTitle: string; + let toastMessage: string; - if (isNewTicket) { - toastTitle = t('notifications.newTicketTitle', 'New Ticket') - toastMessage = message.message || t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle }) - } else if (isUserReply) { - toastTitle = t('notifications.newUserReplyTitle', 'User Reply') - toastMessage = message.message || t('notifications.newUserReply', 'User replied in ticket: {{title}}', { title: ticketTitle }) - } else { - toastTitle = t('notifications.newReplyTitle', 'New Reply') - toastMessage = message.message || t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle }) - } + if (isNewTicket) { + toastTitle = t('notifications.newTicketTitle', 'New Ticket'); + toastMessage = + message.message || + t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle }); + } else if (isUserReply) { + toastTitle = t('notifications.newUserReplyTitle', 'User Reply'); + toastMessage = + message.message || + t('notifications.newUserReply', 'User replied in ticket: {{title}}', { + title: ticketTitle, + }); + } else { + toastTitle = t('notifications.newReplyTitle', 'New Reply'); + toastMessage = + message.message || + t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle }); + } - showToast({ - type: 'info', - title: toastTitle, - message: toastMessage, - icon, - onClick: () => { - navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`) - }, - duration: 8000, - }) - }, [showToast, navigate, isAdmin, t]) + showToast({ + type: 'info', + title: toastTitle, + message: toastMessage, + icon, + onClick: () => { + navigate( + isAdmin + ? `/admin/tickets?ticket=${message.ticket_id}` + : `/support?ticket=${message.ticket_id}`, + ); + }, + duration: 8000, + }); + }, + [showToast, navigate, isAdmin, t], + ); // Handle WebSocket message - const handleWSMessage = useCallback((message: WSMessage) => { - // Check if this notification is relevant for this user type - const isAdminNotification = message.type === 'ticket.new' || message.type === 'ticket.user_reply' - const isUserNotification = message.type === 'ticket.admin_reply' + const handleWSMessage = useCallback( + (message: WSMessage) => { + // Check if this notification is relevant for this user type + const isAdminNotification = + message.type === 'ticket.new' || message.type === 'ticket.user_reply'; + const isUserNotification = message.type === 'ticket.admin_reply'; - if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) { - // Show toast - showWSNotificationToast(message) + if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) { + // Show toast + showWSNotificationToast(message); - // Invalidate queries to refresh count and list - queryClient.invalidateQueries({ - queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] - }) - queryClient.invalidateQueries({ - queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] - }) - } - }, [isAdmin, showWSNotificationToast, queryClient]) + // Invalidate queries to refresh count and list + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'], + }); + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'], + }); + } + }, + [isAdmin, showWSNotificationToast, queryClient], + ); // WebSocket connection useWebSocket({ onMessage: handleWSMessage, - }) + }); // Fetch unread count (with slower polling as fallback when WS disconnects) const { data: unreadData } = useQuery({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'], - queryFn: isAdmin ? ticketNotificationsApi.getAdminUnreadCount : ticketNotificationsApi.getUnreadCount, + queryFn: isAdmin + ? ticketNotificationsApi.getAdminUnreadCount + : ticketNotificationsApi.getUnreadCount, enabled: isAuthenticated, refetchInterval: 60000, // Poll every 60 seconds as fallback staleTime: 30000, - }) + }); // Fetch notifications when dropdown is open const { data: notificationsData, isLoading } = useQuery({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'], - queryFn: () => isAdmin - ? ticketNotificationsApi.getAdminNotifications(false, 10) - : ticketNotificationsApi.getNotifications(false, 10), + queryFn: () => + isAdmin + ? ticketNotificationsApi.getAdminNotifications(false, 10) + : ticketNotificationsApi.getNotifications(false, 10), enabled: isAuthenticated && isOpen, staleTime: 5000, - }) + }); // Mark all as read mutation const markAllReadMutation = useMutation({ - mutationFn: isAdmin ? ticketNotificationsApi.markAllAdminAsRead : ticketNotificationsApi.markAllAsRead, + mutationFn: isAdmin + ? ticketNotificationsApi.markAllAdminAsRead + : ticketNotificationsApi.markAllAsRead, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] }) - queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] }) + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'], + }); + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'], + }); }, - }) + }); // Mark single as read mutation const markReadMutation = useMutation({ - mutationFn: isAdmin ? ticketNotificationsApi.markAdminAsRead : ticketNotificationsApi.markAsRead, + mutationFn: isAdmin + ? ticketNotificationsApi.markAdminAsRead + : ticketNotificationsApi.markAsRead, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] }) - queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] }) + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'], + }); + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'], + }); }, - }) + }); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false) + setIsOpen(false); } - } + }; - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); const handleNotificationClick = (notification: TicketNotification) => { if (!notification.is_read) { - markReadMutation.mutate(notification.id) + markReadMutation.mutate(notification.id); } - setIsOpen(false) - navigate(isAdmin ? `/admin/tickets?ticket=${notification.ticket_id}` : `/support?ticket=${notification.ticket_id}`) - } + setIsOpen(false); + navigate( + isAdmin + ? `/admin/tickets?ticket=${notification.ticket_id}` + : `/support?ticket=${notification.ticket_id}`, + ); + }; const formatTime = (dateStr: string) => { - const date = new Date(dateStr) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffMins = Math.floor(diffMs / 60000) - const diffHours = Math.floor(diffMins / 60) - const diffDays = Math.floor(diffHours / 24) + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); - if (diffMins < 1) return t('notifications.justNow', 'Just now') - if (diffMins < 60) return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins }) - if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours }) - return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays }) - } + if (diffMins < 1) return t('notifications.justNow', 'Just now'); + if (diffMins < 60) + return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins }); + if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours }); + return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays }); + }; const getNotificationIcon = (type: string) => { switch (type) { case 'new_ticket': - return 🎫 + return 🎫; case 'admin_reply': - return 💬 + return 💬; case 'user_reply': - return 📨 + return 📨; default: - return 🔔 + return 🔔; } - } + }; - const unreadCount = unreadData?.unread_count || 0 + const unreadCount = unreadData?.unread_count || 0; return (
{/* Bell button */} @@ -296,5 +343,5 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
)}
- ) + ); } diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 950025a..15f6857 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -1,94 +1,99 @@ -import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react' +import { + createContext, + useContext, + useState, + useCallback, + useRef, + useEffect, + ReactNode, +} from 'react'; interface ToastOptions { - type?: 'success' | 'error' | 'info' | 'warning' - message: string - title?: string - icon?: ReactNode - duration?: number - onClick?: () => void + type?: 'success' | 'error' | 'info' | 'warning'; + message: string; + title?: string; + icon?: ReactNode; + duration?: number; + onClick?: () => void; } interface Toast extends ToastOptions { - id: number + id: number; } interface ToastContextType { - showToast: (options: ToastOptions) => void + showToast: (options: ToastOptions) => void; } -const ToastContext = createContext(null) +const ToastContext = createContext(null); +// eslint-disable-next-line react-refresh/only-export-components export function useToast() { - const context = useContext(ToastContext) + const context = useContext(ToastContext); if (!context) { - throw new Error('useToast must be used within ToastProvider') + throw new Error('useToast must be used within ToastProvider'); } - return context + return context; } export function ToastProvider({ children }: { children: ReactNode }) { - const [toasts, setToasts] = useState([]) - const timersRef = useRef>>(new Map()) + const [toasts, setToasts] = useState([]); + const timersRef = useRef>>(new Map()); const showToast = useCallback((options: ToastOptions) => { - const id = Date.now() + Math.random() // Avoid ID collision - const toast: Toast = { id, duration: 5000, type: 'info', ...options } + const id = Date.now() + Math.random(); // Avoid ID collision + const toast: Toast = { id, duration: 5000, type: 'info', ...options }; - setToasts(prev => [...prev, toast]) + setToasts((prev) => [...prev, toast]); const timer = setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== id)) - timersRef.current.delete(id) - }, toast.duration) + setToasts((prev) => prev.filter((t) => t.id !== id)); + timersRef.current.delete(id); + }, toast.duration); - timersRef.current.set(id, timer) - }, []) + timersRef.current.set(id, timer); + }, []); const removeToast = useCallback((id: number) => { // Clear timer when manually removing - const timer = timersRef.current.get(id) + const timer = timersRef.current.get(id); if (timer) { - clearTimeout(timer) - timersRef.current.delete(id) + clearTimeout(timer); + timersRef.current.delete(id); } - setToasts(prev => prev.filter(t => t.id !== id)) - }, []) + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); // Cleanup all timers on unmount useEffect(() => { - const timers = timersRef.current + const timers = timersRef.current; return () => { - timers.forEach(timer => clearTimeout(timer)) - timers.clear() - } - }, []) + timers.forEach((timer) => clearTimeout(timer)); + timers.clear(); + }; + }, []); return ( {children} {/* Toast Container */} -
+
{toasts.map((toast) => ( - removeToast(toast.id)} - /> + removeToast(toast.id)} /> ))}
- ) + ); } function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { const handleClick = () => { if (toast.onClick) { - toast.onClick() - onClose() + toast.onClick(); + onClose(); } - } + }; const typeStyles = { success: { @@ -115,81 +120,105 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { icon: 'text-accent-400', iconBg: 'bg-accent-500/20', }, - } + }; - const style = typeStyles[toast.type || 'info'] + const style = typeStyles[toast.type || 'info']; const defaultIcons = { success: ( - + ), error: ( - + ), warning: ( - - + + ), info: ( - - + + ), - } + }; return (
{/* Glow effect */} -
+
{/* Icon */} -
+
{toast.icon || defaultIcons[toast.type || 'info']}
{/* Content */} -
+
{toast.title && ( -

- {toast.title} -

+

{toast.title}

)} -

- {toast.message} -

+

{toast.message}

{/* Close button */} @@ -206,5 +235,5 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
- ) + ); } diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 3a95d5b..8576ca9 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -1,285 +1,345 @@ -import { useState, useRef, useEffect, useCallback } from 'react' -import { createPortal } from 'react-dom' -import { useTranslation } from 'react-i18next' -import { useMutation } from '@tanstack/react-query' -import { balanceApi } from '../api/balance' -import { useCurrency } from '../hooks/useCurrency' -import { useTelegramWebApp } from '../hooks/useTelegramWebApp' -import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit' -import type { PaymentMethod } from '../types' -import BentoCard from './ui/BentoCard' +import { useState, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@tanstack/react-query'; +import { balanceApi } from '../api/balance'; +import { useCurrency } from '../hooks/useCurrency'; +import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; +import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; +import type { PaymentMethod } from '../types'; +import BentoCard from './ui/BentoCard'; // Icons const CloseIcon = () => ( - + -) +); const WalletIcon = () => ( - - + + -) +); const StarIcon = () => ( - + -) +); const CardIcon = () => ( - - + + -) +); const CryptoIcon = () => ( - - + + -) +); const SparklesIcon = () => ( - + -) +); const ExternalLinkIcon = () => ( - - + + -) +); const CopyIcon = () => ( - - + + -) +); const CheckIcon = () => ( - + -) +); interface TopUpModalProps { - method: PaymentMethod - onClose: () => void - initialAmountRubles?: number + method: PaymentMethod; + onClose: () => void; + initialAmountRubles?: number; } function useIsMobile() { const [isMobile, setIsMobile] = useState(() => { - if (typeof window === 'undefined') return false - return window.innerWidth < 640 - }) + if (typeof window === 'undefined') return false; + return window.innerWidth < 640; + }); useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 640) - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) - return isMobile + const check = () => setIsMobile(window.innerWidth < 640); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); + return isMobile; } // Get method icon based on method type const getMethodIcon = (methodId: string) => { - const id = methodId.toLowerCase() - if (id.includes('stars')) return - if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return - return -} + const id = methodId.toLowerCase(); + if (id.includes('stars')) return ; + if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return ; + return ; +}; export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { - const { t } = useTranslation() - const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() - const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp() - const inputRef = useRef(null) - const isMobileScreen = useIsMobile() + const { t } = useTranslation(); + const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = + useCurrency(); + const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp(); + const inputRef = useRef(null); + const isMobileScreen = useIsMobile(); - const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 + const safeBottom = isTelegramWebApp + ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) + : 0; const getInitialAmount = (): string => { - if (!initialAmountRubles || initialAmountRubles <= 0) return '' - const converted = convertAmount(initialAmountRubles) - return (targetCurrency === 'IRR' || targetCurrency === 'RUB') + if (!initialAmountRubles || initialAmountRubles <= 0) return ''; + const converted = convertAmount(initialAmountRubles); + return targetCurrency === 'IRR' || targetCurrency === 'RUB' ? Math.ceil(converted).toString() - : converted.toFixed(2) - } + : converted.toFixed(2); + }; - const [amount, setAmount] = useState(getInitialAmount) - const [error, setError] = useState(null) + const [amount, setAmount] = useState(getInitialAmount); + const [error, setError] = useState(null); const [selectedOption, setSelectedOption] = useState( - method.options && method.options.length > 0 ? method.options[0].id : null - ) - const [paymentUrl, setPaymentUrl] = useState(null) - const [copied, setCopied] = useState(false) - const [isInputFocused, setIsInputFocused] = useState(false) + method.options && method.options.length > 0 ? method.options[0].id : null, + ); + const [paymentUrl, setPaymentUrl] = useState(null); + const [copied, setCopied] = useState(false); + const [isInputFocused, setIsInputFocused] = useState(false); const handleClose = useCallback(() => { - onClose() - }, [onClose]) + onClose(); + }, [onClose]); // Keyboard: Escape to close (PC) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { - e.preventDefault() - handleClose() + e.preventDefault(); + handleClose(); } - } - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [handleClose]) + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleClose]); // Telegram back button (Android) useEffect(() => { - if (!webApp?.BackButton) return - webApp.BackButton.show() - webApp.BackButton.onClick(handleClose) + if (!webApp?.BackButton) return; + webApp.BackButton.show(); + webApp.BackButton.onClick(handleClose); return () => { - webApp.BackButton.offClick(handleClose) - webApp.BackButton.hide() - } - }, [webApp, handleClose]) + webApp.BackButton.offClick(handleClose); + webApp.BackButton.hide(); + }; + }, [webApp, handleClose]); // Scroll lock useEffect(() => { - const scrollY = window.scrollY + const scrollY = window.scrollY; const preventScroll = (e: TouchEvent) => { - const target = e.target as HTMLElement - if (target.closest('[data-modal-content]')) return - e.preventDefault() - } + const target = e.target as HTMLElement; + if (target.closest('[data-modal-content]')) return; + e.preventDefault(); + }; const preventWheel = (e: WheelEvent) => { - const target = e.target as HTMLElement - if (target.closest('[data-modal-content]')) return - e.preventDefault() - } - document.addEventListener('touchmove', preventScroll, { passive: false }) - document.addEventListener('wheel', preventWheel, { passive: false }) - document.body.style.overflow = 'hidden' + const target = e.target as HTMLElement; + if (target.closest('[data-modal-content]')) return; + e.preventDefault(); + }; + document.addEventListener('touchmove', preventScroll, { passive: false }); + document.addEventListener('wheel', preventWheel, { passive: false }); + document.body.style.overflow = 'hidden'; return () => { - document.removeEventListener('touchmove', preventScroll) - document.removeEventListener('wheel', preventWheel) - document.body.style.overflow = '' - window.scrollTo(0, scrollY) - } - }, []) + document.removeEventListener('touchmove', preventScroll); + document.removeEventListener('wheel', preventWheel); + document.body.style.overflow = ''; + window.scrollTo(0, scrollY); + }; + }, []); - const hasOptions = method.options && method.options.length > 0 - const minRubles = method.min_amount_kopeks / 100 - const maxRubles = method.max_amount_kopeks / 100 - const methodKey = method.id.toLowerCase().replace(/-/g, '_') - const isStarsMethod = methodKey.includes('stars') - const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name + const hasOptions = method.options && method.options.length > 0; + const minRubles = method.min_amount_kopeks / 100; + const maxRubles = method.max_amount_kopeks / 100; + const methodKey = method.id.toLowerCase().replace(/-/g, '_'); + const isStarsMethod = methodKey.includes('stars'); + const methodName = + t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name; const starsPaymentMutation = useMutation({ mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), onSuccess: (data) => { - const webApp = window.Telegram?.WebApp - if (!data.invoice_url) { setError('Сервер не вернул ссылку на оплату'); return } - if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return } + const webApp = window.Telegram?.WebApp; + if (!data.invoice_url) { + setError('Сервер не вернул ссылку на оплату'); + return; + } + if (!webApp?.openInvoice) { + setError('Оплата Stars доступна только в Telegram Mini App'); + return; + } try { webApp.openInvoice(data.invoice_url, (status) => { - if (status === 'paid') { setError(null); onClose() } - else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) } - }) - } catch (e) { setError('Ошибка: ' + String(e)) } - }, - onError: (err: unknown) => { - const axiosError = err as { response?: { data?: { detail?: string }, status?: number } } - setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`) - }, - }) - - const topUpMutation = useMutation<{ - payment_id: string; payment_url?: string; invoice_url?: string - amount_kopeks: number; amount_rubles: number; status: string; expires_at: string | null - }, unknown, number>({ - mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined), - onSuccess: (data) => { - const redirectUrl = data.payment_url || (data as any).invoice_url - if (redirectUrl) { - // Always show the payment link for user to click manually - // This ensures it works on all platforms including iOS Safari - setPaymentUrl(redirectUrl) + if (status === 'paid') { + setError(null); + onClose(); + } else if (status === 'failed') { + setError(t('wheel.starsPaymentFailed')); + } + }); + } catch (e) { + setError('Ошибка: ' + String(e)); } }, onError: (err: unknown) => { - const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '' - setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error'))) + const axiosError = err as { response?: { data?: { detail?: string }; status?: number } }; + setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`); }, - }) + }); + + const topUpMutation = useMutation< + { + payment_id: string; + payment_url?: string; + invoice_url?: string; + amount_kopeks: number; + amount_rubles: number; + status: string; + expires_at: string | null; + }, + unknown, + number + >({ + mutationFn: (amountKopeks: number) => + balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined), + onSuccess: (data) => { + const redirectUrl = data.payment_url || data.invoice_url; + if (redirectUrl) { + // Always show the payment link for user to click manually + // This ensures it works on all platforms including iOS Safari + setPaymentUrl(redirectUrl); + } + }, + onError: (err: unknown) => { + const detail = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || ''; + setError( + detail.includes('not yet implemented') ? t('balance.useBot') : detail || t('common.error'), + ); + }, + }); const handleSubmit = () => { - setError(null) - setPaymentUrl(null) - inputRef.current?.blur() + setError(null); + setPaymentUrl(null); + inputRef.current?.blur(); if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) { - setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.') - return + setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.'); + return; } - if (hasOptions && !selectedOption) { setError('Выберите способ'); return } - const amountCurrency = parseFloat(amount) - if (isNaN(amountCurrency) || amountCurrency <= 0) { setError('Введите сумму'); return } - const amountRubles = convertToRub(amountCurrency) + if (hasOptions && !selectedOption) { + setError('Выберите способ'); + return; + } + const amountCurrency = parseFloat(amount); + if (isNaN(amountCurrency) || amountCurrency <= 0) { + setError('Введите сумму'); + return; + } + const amountRubles = convertToRub(amountCurrency); if (amountRubles < minRubles || amountRubles > maxRubles) { - setError(`Сумма: ${minRubles} – ${maxRubles} ₽`); return + setError(`Сумма: ${minRubles} – ${maxRubles} ₽`); + return; } - const amountKopeks = Math.round(amountRubles * 100) - if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks) } - else { topUpMutation.mutate(amountKopeks) } - } + const amountKopeks = Math.round(amountRubles * 100); + if (isStarsMethod) { + starsPaymentMutation.mutate(amountKopeks); + } else { + topUpMutation.mutate(amountKopeks); + } + }; - const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles) - const currencyDecimals = (targetCurrency === 'IRR' || targetCurrency === 'RUB') ? 0 : 2 - const getQuickValue = (rub: number) => (targetCurrency === 'IRR') - ? Math.round(convertAmount(rub)).toString() - : convertAmount(rub).toFixed(currencyDecimals) - const isPending = topUpMutation.isPending || starsPaymentMutation.isPending + const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles); + const currencyDecimals = targetCurrency === 'IRR' || targetCurrency === 'RUB' ? 0 : 2; + const getQuickValue = (rub: number) => + targetCurrency === 'IRR' + ? Math.round(convertAmount(rub)).toString() + : convertAmount(rub).toFixed(currencyDecimals); + const isPending = topUpMutation.isPending || starsPaymentMutation.isPending; const handleCopyUrl = async () => { - if (!paymentUrl) return + if (!paymentUrl) return; try { - await navigator.clipboard.writeText(paymentUrl) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + await navigator.clipboard.writeText(paymentUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); } catch (e) { - console.warn('Failed to copy:', e) + console.warn('Failed to copy:', e); } - } - + }; // Auto-focus input - works on mobile in Telegram WebApp useEffect(() => { const timer = setTimeout(() => { if (inputRef.current) { - inputRef.current.focus() + inputRef.current.focus(); if (isMobileScreen) { - inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) + inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } - }, 100) - return () => clearTimeout(timer) - }, []) + }, 100); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Content JSX - shared between mobile and desktop const contentJSX = (
{/* Header icon and method */}
-
-
- {getMethodIcon(method.id)} -
+
+
{getMethodIcon(method.id)}

{methodName}

@@ -299,16 +359,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top key={opt.id} type="button" onClick={() => setSelectedOption(opt.id)} - className={`relative py-3 px-4 rounded-xl text-sm font-semibold transition-all duration-200 ${ + className={`relative rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-200 ${ selectedOption === opt.id ? 'bg-accent-500/15 text-accent-400 ring-2 ring-accent-500/40' - : 'bg-dark-800/70 text-dark-300 hover:bg-dark-700/70 border border-dark-700/50' + : 'border border-dark-700/50 bg-dark-800/70 text-dark-300 hover:bg-dark-700/70' }`} > {opt.name} {selectedOption === opt.id && ( - - + + )} @@ -321,11 +381,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
-
+
setAmount(e.target.value)} onFocus={() => setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSubmit(); + } + }} placeholder="0" - className="w-full h-14 px-4 pr-12 text-xl font-bold bg-transparent text-dark-100 placeholder:text-dark-600 focus:outline-none" + className="h-14 w-full bg-transparent px-4 pr-12 text-xl font-bold text-dark-100 placeholder:text-dark-600 focus:outline-none" autoComplete="off" autoFocus /> @@ -349,16 +416,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top type="button" onClick={handleSubmit} disabled={isPending || !amount || parseFloat(amount) <= 0} - className={`shrink-0 h-14 px-6 rounded-2xl text-base font-bold transition-colors duration-200 overflow-hidden flex items-center justify-center gap-2 ${ + className={`flex h-14 shrink-0 items-center justify-center gap-2 overflow-hidden rounded-2xl px-6 text-base font-bold transition-colors duration-200 ${ isPending || !amount || parseFloat(amount) <= 0 - ? 'bg-dark-700 text-dark-500 cursor-not-allowed' + ? 'cursor-not-allowed bg-dark-700 text-dark-500' : isStarsMethod ? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:from-yellow-400 hover:to-orange-400 active:from-yellow-600 active:to-orange-600' : 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700' }`} > {isPending ? ( - + ) : ( <> @@ -373,39 +440,54 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top {quickAmounts.length > 0 && (
{quickAmounts.map((a) => { - const val = getQuickValue(a) - const isSelected = amount === val + const val = getQuickValue(a); + const isSelected = amount === val; return ( { setAmount(val); inputRef.current?.blur() }} + onClick={() => { + setAmount(val); + inputRef.current?.blur(); + }} hover glow={isSelected} - className={`flex flex-col items-center justify-center py-3 px-2 ${ - isSelected - ? 'border-accent-500/50 bg-accent-500/10' - : '' + className={`flex flex-col items-center justify-center px-2 py-3 ${ + isSelected ? 'border-accent-500/50 bg-accent-500/10' : '' }`} > - + {formatAmount(a, 0)} - + {currencySymbol} - ) + ); })}
)} {/* Error message */} {error && ( -
- - +
+ + {error}
@@ -413,14 +495,19 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top {/* Payment link display - shown when URL is received */} {paymentUrl && ( -
+
- {t('balance.paymentReady', 'Ссылка на оплату готова')} + + {t('balance.paymentReady', 'Ссылка на оплату готова')} +

- {t('balance.clickToOpenPayment', 'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке')} + {t( + 'balance.clickToOpenPayment', + 'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке', + )}

{/* Main open button - NO preventDefault, let work natively for iOS Safari */} @@ -428,7 +515,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top href={paymentUrl} target="_blank" rel="noopener noreferrer" - className="flex items-center justify-center gap-2 w-full h-12 rounded-xl bg-success-500 text-white font-bold hover:bg-success-400 active:bg-success-600 transition-colors" + className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-success-500 font-bold text-white transition-colors hover:bg-success-400 active:bg-success-600" > {t('balance.openPaymentPage', 'Открыть страницу оплаты')} @@ -436,13 +523,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top {/* Copy and link display */}
-
-

{paymentUrl}

+
+

{paymentUrl}

- ) + ); // Render modal based on screen size - NO nested components! const modalContent = isMobileScreen ? ( <> {/* Backdrop */} -
+
{/* Bottom sheet */}
e.stopPropagation()} > {/* Handle bar */} -
-
+
+
{/* Header */}
- {t('balance.topUp')} + {t('balance.topUp')}
{/* Divider */} -
+
{/* Content */} -
- {contentJSX} -
+
{contentJSX}
) : ( @@ -507,35 +593,33 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top >
e.stopPropagation()} > {/* Header */} -
+
-
+
- {t('balance.topUp')} + {t('balance.topUp')}
{/* Content */} -
- {contentJSX} -
+
{contentJSX}
- ) + ); if (typeof document !== 'undefined') { - return createPortal(modalContent, document.body) + return createPortal(modalContent, document.body); } - return modalContent + return modalContent; } diff --git a/src/components/admin/AnalyticsTab.tsx b/src/components/admin/AnalyticsTab.tsx index e63e9dd..033dbcd 100644 --- a/src/components/admin/AnalyticsTab.tsx +++ b/src/components/admin/AnalyticsTab.tsx @@ -1,97 +1,99 @@ -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { brandingApi } from '../../api/branding' -import { CheckIcon, CloseIcon } from './icons' +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { brandingApi } from '../../api/branding'; +import { CheckIcon, CloseIcon } from './icons'; export function AnalyticsTab() { - const { t } = useTranslation() - const queryClient = useQueryClient() + const { t } = useTranslation(); + const queryClient = useQueryClient(); // Editing states - const [editingYandex, setEditingYandex] = useState(false) - const [editingGoogleId, setEditingGoogleId] = useState(false) - const [editingGoogleLabel, setEditingGoogleLabel] = useState(false) - const [yandexValue, setYandexValue] = useState('') - const [googleIdValue, setGoogleIdValue] = useState('') - const [googleLabelValue, setGoogleLabelValue] = useState('') - const [error, setError] = useState(null) + const [editingYandex, setEditingYandex] = useState(false); + const [editingGoogleId, setEditingGoogleId] = useState(false); + const [editingGoogleLabel, setEditingGoogleLabel] = useState(false); + const [yandexValue, setYandexValue] = useState(''); + const [googleIdValue, setGoogleIdValue] = useState(''); + const [googleLabelValue, setGoogleLabelValue] = useState(''); + const [error, setError] = useState(null); // Query const { data: analytics } = useQuery({ queryKey: ['analytics-counters'], queryFn: brandingApi.getAnalyticsCounters, - }) + }); // Mutation const updateMutation = useMutation({ mutationFn: brandingApi.updateAnalyticsCounters, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['analytics-counters'] }) - setError(null) + queryClient.invalidateQueries({ queryKey: ['analytics-counters'] }); + setError(null); }, onError: (err: unknown) => { - const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail - setError(detail || t('common.error')) + const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail; + setError(detail || t('common.error')); }, - }) + }); const handleSaveYandex = () => { updateMutation.mutate( { yandex_metrika_id: yandexValue.trim() }, { onSuccess: () => setEditingYandex(false) }, - ) - } + ); + }; const handleSaveGoogleId = () => { updateMutation.mutate( { google_ads_id: googleIdValue.trim() }, { onSuccess: () => setEditingGoogleId(false) }, - ) - } + ); + }; const handleSaveGoogleLabel = () => { updateMutation.mutate( { google_ads_label: googleLabelValue.trim() }, { onSuccess: () => setEditingGoogleLabel(false) }, - ) - } + ); + }; - const yandexActive = Boolean(analytics?.yandex_metrika_id) - const googleActive = Boolean(analytics?.google_ads_id) + const yandexActive = Boolean(analytics?.yandex_metrika_id); + const googleActive = Boolean(analytics?.google_ads_id); return (
{/* Error message */} {error && ( -
+
{error}
)} {/* Yandex Metrika */} -
-
+
+
-
- - +
+ +

{t('admin.settings.yandexMetrika')}

- - + + {yandexActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
-

+

{t('admin.settings.yandexMetrikaDesc')}

@@ -106,73 +108,84 @@ export function AnalyticsTab() { value={yandexValue} onChange={(e) => setYandexValue(e.target.value.replace(/\D/g, ''))} placeholder={t('admin.settings.yandexIdPlaceholder')} - className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors" + className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none" autoFocus />
) : (
- + {analytics?.yandex_metrika_id || t('admin.settings.notConfigured')}
)} -

- {t('admin.settings.yandexIdHint')} -

+

{t('admin.settings.yandexIdHint')}

{/* Google Ads */} -
-
+
+
-
- - +
+ +
-

- {t('admin.settings.googleAds')} -

+

{t('admin.settings.googleAds')}

- - + + {googleActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
-

- {t('admin.settings.googleAdsDesc')} -

+

{t('admin.settings.googleAdsDesc')}

{/* Conversion ID */} @@ -187,45 +200,58 @@ export function AnalyticsTab() { value={googleIdValue} onChange={(e) => setGoogleIdValue(e.target.value)} placeholder={t('admin.settings.googleIdPlaceholder')} - className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors" + className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none" autoFocus />
) : (
- + {analytics?.google_ads_id || t('admin.settings.notConfigured')}
)} -

- {t('admin.settings.googleIdHint')} -

+

{t('admin.settings.googleIdHint')}

{/* Conversion Label */} @@ -240,55 +266,66 @@ export function AnalyticsTab() { value={googleLabelValue} onChange={(e) => setGoogleLabelValue(e.target.value)} placeholder={t('admin.settings.googleLabelPlaceholder')} - className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors" + className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none" autoFocus />
) : (
- + {analytics?.google_ads_label || t('admin.settings.notConfigured')}
)} -

- {t('admin.settings.googleLabelHint')} -

+

{t('admin.settings.googleLabelHint')}

{/* Info block */} -
-

- {t('admin.settings.analyticsHint')} -

+
+

{t('admin.settings.analyticsHint')}

- ) + ); } diff --git a/src/components/admin/BrandingTab.tsx b/src/components/admin/BrandingTab.tsx index 443deac..bcdd233 100644 --- a/src/components/admin/BrandingTab.tsx +++ b/src/components/admin/BrandingTab.tsx @@ -1,124 +1,130 @@ -import { useState, useRef } from 'react' -import { useTranslation } from 'react-i18next' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { brandingApi, setCachedBranding } from '../../api/branding' -import { setCachedAnimationEnabled } from '../AnimatedBackground' -import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp' -import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons' -import { Toggle } from './Toggle' +import { useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { brandingApi, setCachedBranding } from '../../api/branding'; +import { setCachedAnimationEnabled } from '../AnimatedBackground'; +import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp'; +import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons'; +import { Toggle } from './Toggle'; interface BrandingTabProps { - accentColor?: string + accentColor?: string; } export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { - const { t } = useTranslation() - const queryClient = useQueryClient() - const fileInputRef = useRef(null) + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); - const [editingName, setEditingName] = useState(false) - const [newName, setNewName] = useState('') + const [editingName, setEditingName] = useState(false); + const [newName, setNewName] = useState(''); // Queries const { data: branding } = useQuery({ queryKey: ['branding'], queryFn: brandingApi.getBranding, - }) + }); const { data: animationSettings } = useQuery({ queryKey: ['animation-enabled'], queryFn: brandingApi.getAnimationEnabled, - }) + }); const { data: fullscreenSettings } = useQuery({ queryKey: ['fullscreen-enabled'], queryFn: brandingApi.getFullscreenEnabled, - }) + }); const { data: emailAuthSettings } = useQuery({ queryKey: ['email-auth-enabled'], queryFn: brandingApi.getEmailAuthEnabled, - }) + }); // Mutations const updateBrandingMutation = useMutation({ mutationFn: brandingApi.updateName, onSuccess: (data) => { - setCachedBranding(data) - queryClient.invalidateQueries({ queryKey: ['branding'] }) - setEditingName(false) + setCachedBranding(data); + queryClient.invalidateQueries({ queryKey: ['branding'] }); + setEditingName(false); }, - }) + }); const uploadLogoMutation = useMutation({ mutationFn: brandingApi.uploadLogo, onSuccess: (data) => { - setCachedBranding(data) - queryClient.invalidateQueries({ queryKey: ['branding'] }) + setCachedBranding(data); + queryClient.invalidateQueries({ queryKey: ['branding'] }); }, - }) + }); const deleteLogoMutation = useMutation({ mutationFn: brandingApi.deleteLogo, onSuccess: (data) => { - setCachedBranding(data) - queryClient.invalidateQueries({ queryKey: ['branding'] }) + setCachedBranding(data); + queryClient.invalidateQueries({ queryKey: ['branding'] }); }, - }) + }); const updateAnimationMutation = useMutation({ mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled), onSuccess: (data) => { - setCachedAnimationEnabled(data.enabled) - queryClient.invalidateQueries({ queryKey: ['animation-enabled'] }) + setCachedAnimationEnabled(data.enabled); + queryClient.invalidateQueries({ queryKey: ['animation-enabled'] }); }, - }) + }); const updateFullscreenMutation = useMutation({ mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled), onSuccess: (data) => { - setCachedFullscreenEnabled(data.enabled) - queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] }) + setCachedFullscreenEnabled(data.enabled); + queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] }); }, - }) + }); const updateEmailAuthMutation = useMutation({ mutationFn: (enabled: boolean) => brandingApi.updateEmailAuthEnabled(enabled), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] }) + queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] }); }, - }) + }); const handleLogoUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] + const file = e.target.files?.[0]; if (file) { - uploadLogoMutation.mutate(file) + uploadLogoMutation.mutate(file); } - } + }; return (
{/* Logo & Name */} -
-

{t('admin.settings.logoAndName')}

+
+

+ {t('admin.settings.logoAndName')} +

{/* Logo */}
{branding?.has_custom_logo ? ( - Logo + Logo ) : ( branding?.logo_letter || 'V' )}
-
+
fileInputRef.current?.click()} disabled={uploadLogoMutation.isPending} - className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-xl bg-dark-700 hover:bg-dark-600 text-dark-200 text-sm transition-colors disabled:opacity-50" + className="flex flex-1 items-center justify-center gap-1 rounded-xl bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50" > @@ -137,7 +143,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { @@ -147,39 +153,43 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { {/* Name */}
- + {editingName ? (
setNewName(e.target.value)} - className="flex-1 px-4 py-2 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 focus:outline-none focus:border-accent-500" + className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" maxLength={50} />
) : (
- {branding?.name || t('admin.settings.notSpecified')} + + {branding?.name || t('admin.settings.notSpecified')} + @@ -190,13 +200,17 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
{/* Animation & Fullscreen toggles */} -
-

{t('admin.settings.interfaceOptions')}

+
+

+ {t('admin.settings.interfaceOptions')} +

-
+
- {t('admin.settings.animatedBackground')} + + {t('admin.settings.animatedBackground')} +

{t('admin.settings.animatedBackgroundDesc')}

-
+
- {t('admin.settings.autoFullscreen')} + + {t('admin.settings.autoFullscreen')} +

{t('admin.settings.autoFullscreenDesc')}

updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))} + onChange={() => + updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false)) + } disabled={updateFullscreenMutation.isPending} />
-
+
{t('admin.settings.emailAuth')}

{t('admin.settings.emailAuthDesc')}

@@ -232,5 +250,5 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
- ) + ); } diff --git a/src/components/admin/FavoritesTab.tsx b/src/components/admin/FavoritesTab.tsx index e3f34d8..d7c223d 100644 --- a/src/components/admin/FavoritesTab.tsx +++ b/src/components/admin/FavoritesTab.tsx @@ -1,47 +1,48 @@ -import { useTranslation } from 'react-i18next' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings' -import { StarIcon } from './icons' -import { SettingRow } from './SettingRow' +import { useTranslation } from 'react-i18next'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'; +import { StarIcon } from './icons'; +import { SettingRow } from './SettingRow'; interface FavoritesTabProps { - settings: SettingDefinition[] - isFavorite: (key: string) => boolean - toggleFavorite: (key: string) => void + settings: SettingDefinition[]; + isFavorite: (key: string) => boolean; + toggleFavorite: (key: string) => void; } export function FavoritesTab({ settings, isFavorite, toggleFavorite }: FavoritesTabProps) { - const { t } = useTranslation() - const queryClient = useQueryClient() + const { t } = useTranslation(); + const queryClient = useQueryClient(); const updateSettingMutation = useMutation({ - mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value), + mutationFn: ({ key, value }: { key: string; value: string }) => + adminSettingsApi.updateSetting(key, value), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); }, - }) + }); const resetSettingMutation = useMutation({ mutationFn: (key: string) => adminSettingsApi.resetSetting(key), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); }, - }) + }); if (settings.length === 0) { return ( -
-
+
+

{t('admin.settings.favoritesEmpty')}

-

{t('admin.settings.favoritesHint')}

+

{t('admin.settings.favoritesHint')}

- ) + ); } return ( -
+
{settings.map((setting) => ( ))}
- ) + ); } diff --git a/src/components/admin/SettingInput.tsx b/src/components/admin/SettingInput.tsx index 1fe68ae..5604d3d 100644 --- a/src/components/admin/SettingInput.tsx +++ b/src/components/admin/SettingInput.tsx @@ -1,23 +1,23 @@ -import { useState, useRef, useEffect } from 'react' -import { SettingDefinition } from '../../api/adminSettings' -import { CheckIcon, CloseIcon, EditIcon } from './icons' +import { useState, useRef, useEffect } from 'react'; +import { SettingDefinition } from '../../api/adminSettings'; +import { CheckIcon, CloseIcon, EditIcon } from './icons'; interface SettingInputProps { - setting: SettingDefinition - onUpdate: (value: string) => void - disabled?: boolean + setting: SettingDefinition; + onUpdate: (value: string) => void; + disabled?: boolean; } // Check if value is likely JSON or multi-line function isLongValue(value: string | null | undefined): boolean { - if (!value) return false - const str = String(value) - return str.length > 50 || str.includes('\n') || str.startsWith('[') || str.startsWith('{') + if (!value) return false; + const str = String(value); + return str.length > 50 || str.includes('\n') || str.startsWith('[') || str.startsWith('{'); } // Check if key suggests it's a list or JSON config function isListOrJsonKey(key: string): boolean { - const lowerKey = key.toLowerCase() + const lowerKey = key.toLowerCase(); return ( lowerKey.includes('_items') || lowerKey.includes('_config') || @@ -28,40 +28,40 @@ function isListOrJsonKey(key: string): boolean { lowerKey.includes('_periods') || lowerKey.includes('_discounts') || lowerKey.includes('_packages') - ) + ); } export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) { - const [isEditing, setIsEditing] = useState(false) - const [value, setValue] = useState('') - const textareaRef = useRef(null) - const inputRef = useRef(null) + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(''); + const textareaRef = useRef(null); + const inputRef = useRef(null); - const currentValue = String(setting.current ?? '') - const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key) + const currentValue = String(setting.current ?? ''); + const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key); // Auto-resize textarea useEffect(() => { if (textareaRef.current && isEditing) { - textareaRef.current.style.height = 'auto' - textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px' + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px'; } - }, [value, isEditing]) + }, [value, isEditing]); const handleStart = () => { - setValue(currentValue) - setIsEditing(true) - } + setValue(currentValue); + setIsEditing(true); + }; const handleSave = () => { - onUpdate(value) - setIsEditing(false) - } + onUpdate(value); + setIsEditing(false); + }; const handleCancel = () => { - setIsEditing(false) - setValue('') - } + setIsEditing(false); + setValue(''); + }; // Dropdown for choices if (setting.choices && setting.choices.length > 0) { @@ -70,7 +70,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) value={currentValue} onChange={(e) => onUpdate(e.target.value)} disabled={disabled} - className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50 min-w-[140px] cursor-pointer" + className="min-w-[140px] cursor-pointer rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50" > {setting.choices.map((choice, idx) => ( ))} - ) + ); } // Editing mode - Textarea for long values @@ -90,26 +90,26 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) value={value} onChange={(e) => setValue(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Escape') handleCancel() + if (e.key === 'Escape') handleCancel(); // Ctrl+Enter to save - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave() + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave(); }} autoFocus placeholder="Введите значение..." - className="w-full bg-dark-700 border border-accent-500 rounded-xl px-4 py-3 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 font-mono resize-none min-h-[100px]" + className="min-h-[100px] w-full resize-none rounded-xl border border-accent-500 bg-dark-700 px-4 py-3 font-mono text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30" />
Ctrl+Enter для сохранения
- ) + ); } // Editing mode - Regular input @@ -130,50 +130,51 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) value={value} onChange={(e) => setValue(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter') handleSave() - if (e.key === 'Escape') handleCancel() + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') handleCancel(); }} autoFocus placeholder="Введите значение..." - className="bg-dark-700 border border-accent-500 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 w-48 sm:w-56" + className="w-48 rounded-lg border border-accent-500 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 sm:w-56" />
- ) + ); } // Display mode - Long value preview if (needsTextarea) { - const displayValue = currentValue || '-' - const previewValue = displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue + const displayValue = currentValue || '-'; + const previewValue = + displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue; return ( - ) + ); } // Display mode - Short value @@ -181,12 +182,12 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) - ) + ); } diff --git a/src/components/admin/SettingRow.tsx b/src/components/admin/SettingRow.tsx index d2cbe7d..5a19450 100644 --- a/src/components/admin/SettingRow.tsx +++ b/src/components/admin/SettingRow.tsx @@ -1,18 +1,18 @@ -import { useTranslation } from 'react-i18next' -import { SettingDefinition } from '../../api/adminSettings' -import { StarIcon, LockIcon, RefreshIcon } from './icons' -import { SettingInput } from './SettingInput' -import { Toggle } from './Toggle' -import { formatSettingKey, stripHtml } from './utils' +import { useTranslation } from 'react-i18next'; +import { SettingDefinition } from '../../api/adminSettings'; +import { StarIcon, LockIcon, RefreshIcon } from './icons'; +import { SettingInput } from './SettingInput'; +import { Toggle } from './Toggle'; +import { formatSettingKey, stripHtml } from './utils'; interface SettingRowProps { - setting: SettingDefinition - isFavorite: boolean - onToggleFavorite: () => void - onUpdate: (value: string) => void - onReset: () => void - isUpdating?: boolean - isResetting?: boolean + setting: SettingDefinition; + isFavorite: boolean; + onToggleFavorite: () => void; + onUpdate: (value: string) => void; + onReset: () => void; + isUpdating?: boolean; + isResetting?: boolean; } export function SettingRow({ @@ -22,18 +22,18 @@ export function SettingRow({ onUpdate, onReset, isUpdating, - isResetting + isResetting, }: SettingRowProps) { - const { t } = useTranslation() + const { t } = useTranslation(); - const formattedKey = formatSettingKey(setting.name || setting.key) - const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey) - const description = setting.hint?.description ? stripHtml(setting.hint.description) : null + const formattedKey = formatSettingKey(setting.name || setting.key); + const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey); + const description = setting.hint?.description ? stripHtml(setting.hint.description) : null; // Check if this is a long/complex value const isLongValue = (() => { - const val = String(setting.current ?? '') - const key = setting.key.toLowerCase() + const val = String(setting.current ?? ''); + const key = setting.key.toLowerCase(); return ( val.length > 50 || val.includes('\n') || @@ -44,40 +44,40 @@ export function SettingRow({ key.includes('_keywords') || key.includes('_template') || key.includes('_packages') - ) - })() + ); + })(); return ( -
+
{/* Header row - name, badges, favorite */} -
-
-
-

{displayName}

+
+
+
+

{displayName}

{setting.has_override && ( - + {t('admin.settings.modified')} )} {setting.read_only && ( - + {t('admin.settings.readOnly')} )}
{description && ( -

{description}

+

{description}

)}
{/* Favorite button */}
) : ( // Input field -
- +
+ {/* Reset button for non-long values */} {!isLongValue && setting.has_override && (
)}
- ) + ); } diff --git a/src/components/admin/SettingsSearch.tsx b/src/components/admin/SettingsSearch.tsx index b59685b..2206917 100644 --- a/src/components/admin/SettingsSearch.tsx +++ b/src/components/admin/SettingsSearch.tsx @@ -1,14 +1,14 @@ -import { useTranslation } from 'react-i18next' -import { SearchIcon, CloseIcon } from './icons' +import { useTranslation } from 'react-i18next'; +import { SearchIcon, CloseIcon } from './icons'; interface SettingsSearchProps { - searchQuery: string - setSearchQuery: (query: string) => void - resultsCount?: number + searchQuery: string; + setSearchQuery: (query: string) => void; + resultsCount?: number; } export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) { - const { t } = useTranslation() + const { t } = useTranslation(); return ( <> @@ -19,7 +19,7 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder={t('admin.settings.searchPlaceholder')} - className="w-48 lg:w-64 pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm" + className="w-48 rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none lg:w-64" />
@@ -27,18 +27,21 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr {searchQuery && ( )}
- ) + ); } -export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit) { - const { t } = useTranslation() +export function SettingsSearchMobile({ + searchQuery, + setSearchQuery, +}: Omit) { + const { t } = useTranslation(); return (
@@ -47,7 +50,7 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit setSearchQuery(e.target.value)} placeholder={t('admin.settings.searchPlaceholder')} - className="w-full pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm" + className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none" />
@@ -55,26 +58,30 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit setSearchQuery('')} - className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-dark-300 transition-colors" + className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300" > )}
- ) + ); } -export function SettingsSearchResults({ searchQuery, resultsCount }: { searchQuery: string; resultsCount: number }) { - if (!searchQuery.trim()) return null +export function SettingsSearchResults({ + searchQuery, + resultsCount, +}: { + searchQuery: string; + resultsCount: number; +}) { + if (!searchQuery.trim()) return null; return (
{resultsCount > 0 ? `Найдено: ${resultsCount}` : 'Ничего не найдено'} - {resultsCount > 0 && ( - по запросу «{searchQuery}» - )} + {resultsCount > 0 && по запросу «{searchQuery}»}
- ) + ); } diff --git a/src/components/admin/SettingsSidebar.tsx b/src/components/admin/SettingsSidebar.tsx index 5da961a..b30e2ff 100644 --- a/src/components/admin/SettingsSidebar.tsx +++ b/src/components/admin/SettingsSidebar.tsx @@ -1,13 +1,13 @@ -import { useTranslation } from 'react-i18next' -import { Link } from 'react-router-dom' -import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index' +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index'; interface SettingsSidebarProps { - activeSection: string - setActiveSection: (section: string) => void - mobileMenuOpen: boolean - setMobileMenuOpen: (open: boolean) => void - favoritesCount: number + activeSection: string; + setActiveSection: (section: string) => void; + mobileMenuOpen: boolean; + setMobileMenuOpen: (open: boolean) => void; + favoritesCount: number; } export function SettingsSidebar({ @@ -15,27 +15,27 @@ export function SettingsSidebar({ setActiveSection, mobileMenuOpen, setMobileMenuOpen, - favoritesCount + favoritesCount, }: SettingsSidebarProps) { - const { t } = useTranslation() + const { t } = useTranslation(); return ( -