From 7be6b5c0aee38231e691b9ca3e384f1123330975 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 15 Jan 2026 19:18:17 +0300 Subject: [PATCH] Add files via upload --- src/App.tsx | 227 ++++ src/api/admin.ts | 206 +++ src/api/adminApps.ts | 119 ++ src/api/adminSettings.ts | 73 + src/api/auth.ts | 92 ++ src/api/balance.ts | 69 + src/api/branding.ts | 48 + src/api/client.ts | 88 ++ src/api/contests.ts | 57 + src/api/currency.ts | 98 ++ src/api/index.ts | 12 + src/api/info.ts | 102 ++ src/api/miniapp.ts | 36 + src/api/notifications.ts | 58 + src/api/polls.ts | 85 ++ src/api/promo.ts | 69 + src/api/referral.ts | 62 + src/api/servers.ts | 142 ++ src/api/subscription.ts | 324 +++++ src/api/tariffs.ts | 228 ++++ src/api/themeColors.ts | 43 + src/api/tickets.ts | 84 ++ src/api/wheel.ts | 281 ++++ src/components/ColorPicker.tsx | 154 +++ src/components/ConnectionModal.tsx | 562 ++++++++ src/components/LanguageSwitcher.tsx | 79 ++ src/components/Onboarding.tsx | 240 ++++ src/components/TelegramLoginButton.tsx | 67 + src/components/TopUpModal.tsx | 242 ++++ src/components/common/PageLoader.tsx | 21 + src/components/layout/Layout.tsx | 498 +++++++ src/components/wheel/FortuneWheel.tsx | 439 ++++++ src/hooks/useCurrency.ts | 108 ++ src/hooks/useTheme.ts | 224 +++ src/hooks/useThemeColors.ts | 256 ++++ src/i18n.ts | 40 + src/locales/en.json | 788 +++++++++++ src/locales/fa.json | 711 ++++++++++ src/locales/ru.json | 788 +++++++++++ src/locales/zh.json | 712 ++++++++++ src/main.tsx | 29 + src/pages/AdminApps.tsx | 629 +++++++++ src/pages/AdminDashboard.tsx | 502 +++++++ src/pages/AdminPanel.tsx | 155 +++ src/pages/AdminServers.tsx | 470 +++++++ src/pages/AdminSettings.tsx | 1442 ++++++++++++++++++++ src/pages/AdminTariffs.tsx | 893 ++++++++++++ src/pages/AdminTickets.tsx | 385 ++++++ src/pages/AdminWheel.tsx | 650 +++++++++ src/pages/Balance.tsx | 292 ++++ src/pages/Contests.tsx | 239 ++++ src/pages/Dashboard.tsx | 460 +++++++ src/pages/DeepLinkRedirect.tsx | 315 +++++ src/pages/Info.tsx | 295 ++++ src/pages/Login.tsx | 267 ++++ src/pages/Polls.tsx | 243 ++++ src/pages/Profile.tsx | 414 ++++++ src/pages/Referral.tsx | 248 ++++ src/pages/Subscription.tsx | 1732 ++++++++++++++++++++++++ src/pages/Support.tsx | 682 ++++++++++ src/pages/TelegramCallback.tsx | 87 ++ src/pages/TelegramRedirect.tsx | 173 +++ src/pages/VerifyEmail.tsx | 82 ++ src/pages/Wheel.tsx | 749 ++++++++++ src/providers/ThemeColorsProvider.tsx | 26 + src/store/auth.ts | 207 +++ src/styles/globals.css | 1036 ++++++++++++++ src/types/index.ts | 465 +++++++ src/types/theme.ts | 68 + src/vite-env.d.ts | 68 + 70 files changed, 21835 insertions(+) create mode 100644 src/App.tsx create mode 100644 src/api/admin.ts create mode 100644 src/api/adminApps.ts create mode 100644 src/api/adminSettings.ts create mode 100644 src/api/auth.ts create mode 100644 src/api/balance.ts create mode 100644 src/api/branding.ts create mode 100644 src/api/client.ts create mode 100644 src/api/contests.ts create mode 100644 src/api/currency.ts create mode 100644 src/api/index.ts create mode 100644 src/api/info.ts create mode 100644 src/api/miniapp.ts create mode 100644 src/api/notifications.ts create mode 100644 src/api/polls.ts create mode 100644 src/api/promo.ts create mode 100644 src/api/referral.ts create mode 100644 src/api/servers.ts create mode 100644 src/api/subscription.ts create mode 100644 src/api/tariffs.ts create mode 100644 src/api/themeColors.ts create mode 100644 src/api/tickets.ts create mode 100644 src/api/wheel.ts create mode 100644 src/components/ColorPicker.tsx create mode 100644 src/components/ConnectionModal.tsx create mode 100644 src/components/LanguageSwitcher.tsx create mode 100644 src/components/Onboarding.tsx create mode 100644 src/components/TelegramLoginButton.tsx create mode 100644 src/components/TopUpModal.tsx create mode 100644 src/components/common/PageLoader.tsx create mode 100644 src/components/layout/Layout.tsx create mode 100644 src/components/wheel/FortuneWheel.tsx create mode 100644 src/hooks/useCurrency.ts create mode 100644 src/hooks/useTheme.ts create mode 100644 src/hooks/useThemeColors.ts create mode 100644 src/i18n.ts create mode 100644 src/locales/en.json create mode 100644 src/locales/fa.json create mode 100644 src/locales/ru.json create mode 100644 src/locales/zh.json create mode 100644 src/main.tsx create mode 100644 src/pages/AdminApps.tsx create mode 100644 src/pages/AdminDashboard.tsx create mode 100644 src/pages/AdminPanel.tsx create mode 100644 src/pages/AdminServers.tsx create mode 100644 src/pages/AdminSettings.tsx create mode 100644 src/pages/AdminTariffs.tsx create mode 100644 src/pages/AdminTickets.tsx create mode 100644 src/pages/AdminWheel.tsx create mode 100644 src/pages/Balance.tsx create mode 100644 src/pages/Contests.tsx create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/DeepLinkRedirect.tsx create mode 100644 src/pages/Info.tsx create mode 100644 src/pages/Login.tsx create mode 100644 src/pages/Polls.tsx create mode 100644 src/pages/Profile.tsx create mode 100644 src/pages/Referral.tsx create mode 100644 src/pages/Subscription.tsx create mode 100644 src/pages/Support.tsx create mode 100644 src/pages/TelegramCallback.tsx create mode 100644 src/pages/TelegramRedirect.tsx create mode 100644 src/pages/VerifyEmail.tsx create mode 100644 src/pages/Wheel.tsx create mode 100644 src/providers/ThemeColorsProvider.tsx create mode 100644 src/store/auth.ts create mode 100644 src/styles/globals.css create mode 100644 src/types/index.ts create mode 100644 src/types/theme.ts create mode 100644 src/vite-env.d.ts diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..ae5c905 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,227 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import { useAuthStore } from './store/auth' +import Layout from './components/layout/Layout' +import PageLoader from './components/common/PageLoader' +import Login from './pages/Login' +import TelegramCallback from './pages/TelegramCallback' +import TelegramRedirect from './pages/TelegramRedirect' +import DeepLinkRedirect from './pages/DeepLinkRedirect' +import Dashboard from './pages/Dashboard' +import Subscription from './pages/Subscription' +import Balance from './pages/Balance' +import Referral from './pages/Referral' +import Support from './pages/Support' +import Profile from './pages/Profile' +import AdminTickets from './pages/AdminTickets' +import AdminSettings from './pages/AdminSettings' +import AdminApps from './pages/AdminApps' +import VerifyEmail from './pages/VerifyEmail' +import Contests from './pages/Contests' +import Polls from './pages/Polls' +import Info from './pages/Info' +import Wheel from './pages/Wheel' +import AdminWheel from './pages/AdminWheel' +import AdminTariffs from './pages/AdminTariffs' +import AdminServers from './pages/AdminServers' +import AdminPanel from './pages/AdminPanel' +import AdminDashboard from './pages/AdminDashboard' + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuthStore() + + if (isLoading) { + return + } + + if (!isAuthenticated) { + return + } + + return {children} +} + +function AdminRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading, isAdmin } = useAuthStore() + + if (isLoading) { + return + } + + if (!isAuthenticated) { + return + } + + if (!isAdmin) { + return + } + + return {children} +} + +function App() { + return ( + + {/* Public routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Protected routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Admin routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Catch all */} + } /> + + ) +} + +export default App diff --git a/src/api/admin.ts b/src/api/admin.ts new file mode 100644 index 0000000..095103e --- /dev/null +++ b/src/api/admin.ts @@ -0,0 +1,206 @@ +import apiClient from './client' + +export interface AdminTicketUser { + 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 +} + +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 +} + +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[] +} + +export interface AdminTicketStats { + total: number + open: number + pending: number + answered: number + closed: number +} + +export interface AdminTicketListResponse { + 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 + }, + + // Get ticket statistics + getTicketStats: async (): Promise => { + 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 + }, + + // Get single ticket with messages + getTicket: async (ticketId: number): Promise => { + 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 + }, + + // Update ticket status + updateTicketStatus: async (ticketId: number, status: string): Promise => { + 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 + }, +} + +// ============ 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 +} + +export interface NodesOverview { + 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 +} + +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 +} + +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 +} + +export interface ServerStats { + total_servers: number + available_servers: number + servers_with_connections: number + total_revenue_kopeks: number + total_revenue_rubles: number +} + +export interface DashboardStats { + nodes: NodesOverview + subscriptions: SubscriptionStats + financial: FinancialStats + servers: ServerStats + revenue_chart: RevenueData[] +} + +// ============ Dashboard Stats API ============ + +export const statsApi = { + // Get complete dashboard stats + getDashboardStats: async (): Promise => { + 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 + }, + + // 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 + }, + + // 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 + }, +} diff --git a/src/api/adminApps.ts b/src/api/adminApps.ts new file mode 100644 index 0000000..837628d --- /dev/null +++ b/src/api/adminApps.ts @@ -0,0 +1,119 @@ +import apiClient from './client' + +export interface LocalizedText { + en: string + ru: string + zh?: string + fa?: string +} + +export interface AppButton { + buttonLink: string + buttonText: LocalizedText +} + +export interface AppStep { + 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 +} + +export interface AppConfigBranding { + name: string + logoUrl: string + supportUrl: string +} + +export interface AppConfigConfig { + additionalLocales: string[] + branding: AppConfigBranding +} + +export interface AppConfigResponse { + 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 + }, + + // Get available platforms + getPlatforms: async (): Promise => { + 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 + }, + + // 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 + }, + + // 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 + }, + + // Delete an app + deleteApp: async (platform: string, appId: string): Promise => { + 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 }> => { + const response = await apiClient.post<{ new_id: string; target_platform: string }>( + `/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 + }, + + // Update branding + updateBranding: async (branding: AppConfigBranding): Promise => { + const response = await apiClient.put('/cabinet/admin/apps/branding', { + branding, + }) + return response.data + }, +} diff --git a/src/api/adminSettings.ts b/src/api/adminSettings.ts new file mode 100644 index 0000000..391dca3 --- /dev/null +++ b/src/api/adminSettings.ts @@ -0,0 +1,73 @@ +import apiClient from './client' + +export interface SettingCategoryRef { + key: string + label: string +} + +export interface SettingCategorySummary { + key: string + label: string + description: string + items: number +} + +export interface SettingChoice { + value: unknown + label: string + description?: string | null +} + +export interface SettingHint { + 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 +} + +export const adminSettingsApi = { + // Get list of setting categories + getCategories: async (): Promise => { + 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 + }, + + // Get a specific setting by key + getSetting: async (key: string): Promise => { + 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 + }, + + // Reset a setting to default + resetSetting: async (key: string): Promise => { + const response = await apiClient.delete(`/cabinet/admin/settings/${key}`) + return response.data + }, +} diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..8a1b163 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,92 @@ +import apiClient from './client' +import type { AuthResponse, 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 + }, + + // 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 + }): Promise => { + const response = await apiClient.post('/cabinet/auth/telegram/widget', data) + return response.data + }, + + // Email login + loginEmail: async (email: string, password: string): Promise => { + const response = await apiClient.post('/cabinet/auth/email/login', { + email, + password, + }) + return response.data + }, + + // Register email (link to existing Telegram account) + 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 + }, + + // Verify email + verifyEmail: async (token: string): Promise<{ message: string }> => { + 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 + }, + + // Refresh token + refreshToken: async (refreshToken: string): Promise => { + const response = await apiClient.post('/cabinet/auth/refresh', { + refresh_token: refreshToken, + }) + 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 + }, + + // Reset password + resetPassword: async (token: string, password: string): Promise<{ message: string }> => { + const response = await apiClient.post('/cabinet/auth/password/reset', { + token, + password, + }) + return response.data + }, + + // Get current user + getMe: async (): Promise => { + const response = await apiClient.get('/cabinet/auth/me') + return response.data + }, +} diff --git a/src/api/balance.ts b/src/api/balance.ts new file mode 100644 index 0000000..93e60fb --- /dev/null +++ b/src/api/balance.ts @@ -0,0 +1,69 @@ +import apiClient from './client' +import type { Balance, Transaction, PaymentMethod, PaginatedResponse } from '../types' + +export const balanceApi = { + // Get current balance + getBalance: async (): Promise => { + const response = await apiClient.get('/cabinet/balance') + return response.data + }, + + // Get transaction history + getTransactions: async (params?: { + page?: number + per_page?: number + type?: string + }): Promise> => { + 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 + }, + + // Create top-up payment + createTopUp: async (amountKopeks: number, paymentMethod: string): Promise<{ + payment_id: string + payment_url: string + amount_kopeks: number + amount_rubles: number + status: string + expires_at: string | null + }> => { + const response = await apiClient.post('/cabinet/balance/topup', { + amount_kopeks: amountKopeks, + payment_method: paymentMethod, + }) + 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 + }> => { + 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 + }> => { + const response = await apiClient.post('/cabinet/balance/stars-invoice', { + amount_kopeks: amountKopeks, + }) + return response.data + }, +} + diff --git a/src/api/branding.ts b/src/api/branding.ts new file mode 100644 index 0000000..e948691 --- /dev/null +++ b/src/api/branding.ts @@ -0,0 +1,48 @@ +import apiClient from './client' + +export interface BrandingInfo { + name: string + logo_url: string | null + logo_letter: string + has_custom_logo: boolean +} + +export const brandingApi = { + // Get current branding (public, no auth required) + getBranding: async (): Promise => { + 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 + }, + + // Upload custom logo (admin only) + uploadLogo: async (file: File): Promise => { + 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 + }, + + // Delete custom logo (admin only) + deleteLogo: async (): Promise => { + 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 `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}` + }, +} diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..9a9221a --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,88 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' + +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' + +const TELEGRAM_INIT_STORAGE_KEY = 'telegram_init_data' + +const getTelegramInitData = (): string | null => { + if (typeof window === 'undefined') return null + + const initData = window.Telegram?.WebApp?.initData + if (initData) { + try { + localStorage.setItem(TELEGRAM_INIT_STORAGE_KEY, initData) + } catch { + /* ignore storage errors */ + } + return initData + } + + try { + return localStorage.getItem(TELEGRAM_INIT_STORAGE_KEY) + } catch { + return null + } +} + +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor - add auth token +apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = localStorage.getItem('access_token') + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}` + } + + const telegramInitData = getTelegramInitData() + if (telegramInitData && config.headers) { + config.headers['X-Telegram-Init-Data'] = telegramInitData + } + return config +}) + +// Response interceptor - handle 401, refresh token +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true + + const refreshToken = localStorage.getItem('refresh_token') + if (refreshToken) { + try { + const response = await axios.post(`${API_BASE_URL}/cabinet/auth/refresh`, { + refresh_token: refreshToken, + }) + + const { access_token } = response.data + localStorage.setItem('access_token', access_token) + + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${access_token}` + } + + return apiClient(originalRequest) + } catch (refreshError) { + // Refresh failed, clear tokens and redirect to login + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + localStorage.removeItem('user') + window.location.href = '/login' + return Promise.reject(refreshError) + } + } + } + + return Promise.reject(error) + } +) + +export default apiClient + diff --git a/src/api/contests.ts b/src/api/contests.ts new file mode 100644 index 0000000..1ef4e55 --- /dev/null +++ b/src/api/contests.ts @@ -0,0 +1,57 @@ +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 +} + +export interface ContestGameData { + round_id: number + game_type: string + game_data: Record + instructions: string +} + +export interface ContestResult { + is_winner: boolean + message: string + prize_days?: number +} + +export interface ContestsCountResponse { + count: number +} + +export const contestsApi = { + // Get count of available contests + getCount: async (): Promise => { + 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 + }, + + // Get game data for a specific contest + getContestGame: async (roundId: number): Promise => { + const response = await apiClient.get(`/cabinet/contests/${roundId}`) + return response.data + }, + + // Submit answer for a contest + submitAnswer: async (roundId: number, answer: string): Promise => { + const response = await apiClient.post(`/cabinet/contests/${roundId}/answer`, { + round_id: roundId, + answer, + }) + return response.data + }, +} diff --git a/src/api/currency.ts b/src/api/currency.ts new file mode 100644 index 0000000..3288dcf --- /dev/null +++ b/src/api/currency.ts @@ -0,0 +1,98 @@ +// Currency exchange rate API +// Uses free exchangerate.host API + +interface ExchangeRates { + USD: number + CNY: number + IRR: number +} + +interface CachedRates { + rates: ExchangeRates + timestamp: number +} + +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) +} + +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 + } + + try { + // Try primary API (exchangerate.host) - get RUB based rates + const response = await fetch( + 'https://api.exchangerate.host/latest?base=RUB&symbols=USD,CNY,IRR' + ) + + if (response.ok) { + const data = await response.json() + if (data.success && data.rates) { + // API returns how much of each currency equals 1 RUB + // We need the inverse: how many RUB equals 1 of each currency + const rates: ExchangeRates = { + USD: data.rates.USD ? 1 / data.rates.USD : FALLBACK_RATES.USD, + CNY: data.rates.CNY ? 1 / data.rates.CNY : FALLBACK_RATES.CNY, + IRR: data.rates.IRR ? 1 / data.rates.IRR : FALLBACK_RATES.IRR, + } + cachedRates = { rates, timestamp: Date.now() } + return rates + } + } + + // Try backup API (open.er-api.com) + const backupResponse = await fetch('https://open.er-api.com/v6/latest/RUB') + + if (backupResponse.ok) { + const backupData = await backupResponse.json() + if (backupData.rates) { + const rates: ExchangeRates = { + USD: backupData.rates.USD ? 1 / backupData.rates.USD : FALLBACK_RATES.USD, + CNY: backupData.rates.CNY ? 1 / backupData.rates.CNY : FALLBACK_RATES.CNY, + IRR: backupData.rates.IRR ? 1 / backupData.rates.IRR : FALLBACK_RATES.IRR, + } + cachedRates = { rates, timestamp: Date.now() } + return rates + } + } + + // Return fallback rates if both APIs fail + return FALLBACK_RATES + } catch (error) { + 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] + if (!rate || rate <= 0) { + return rubAmount / FALLBACK_RATES[targetCurrency] + } + return rubAmount / rate + }, + + // Convert from target currency to RUB + convertToRub: (amount: number, sourceCurrency: keyof ExchangeRates, rates: ExchangeRates): number => { + const rate = rates[sourceCurrency] + if (!rate || rate <= 0) { + return amount * FALLBACK_RATES[sourceCurrency] + } + return amount * rate + }, +} + +export type { ExchangeRates } diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..9af91c2 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +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' diff --git a/src/api/info.ts b/src/api/info.ts new file mode 100644 index 0000000..0e28bf9 --- /dev/null +++ b/src/api/info.ts @@ -0,0 +1,102 @@ +import apiClient from './client' +import type { SupportConfig } from '../types' + +export interface FaqPage { + id: number + title: string + content: string + order: number +} + +export interface RulesResponse { + content: string + updated_at: string | null +} + +export interface PrivacyPolicyResponse { + content: string + updated_at: string | null +} + +export interface PublicOfferResponse { + 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 +} + +export interface LanguageInfo { + 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 + }, + + // Get specific FAQ page + getFaqPage: async (pageId: number): Promise => { + 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 + }, + + // Get privacy policy + getPrivacyPolicy: async (): Promise => { + 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 + }, + + // Get service info + getServiceInfo: async (): Promise => { + 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 + }, + + // Get user language + getUserLanguage: async (): Promise<{ language: string }> => { + 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 + }, + + // Get support configuration + getSupportConfig: async (): Promise => { + const response = await apiClient.get('/cabinet/info/support-config') + return response.data + }, +} diff --git a/src/api/miniapp.ts b/src/api/miniapp.ts new file mode 100644 index 0000000..3fc2157 --- /dev/null +++ b/src/api/miniapp.ts @@ -0,0 +1,36 @@ +export interface MiniappCreatePaymentPayload { + initData: string + method: string + amountKopeks?: number | null + option?: string | null +} + +export interface MiniappCreatePaymentResponse { + 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 + ): Promise => { + const res = await fetch('/miniapp/payments/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + initData: payload.initData || '', + method: payload.method, + amountKopeks: payload.amountKopeks ?? null, + option: payload.option ?? null, + }), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + const message = (data && (data.detail || data.message)) || 'Failed to create payment' + throw new Error(String(message)) + } + return data as MiniappCreatePaymentResponse + }, +} diff --git a/src/api/notifications.ts b/src/api/notifications.ts new file mode 100644 index 0000000..b6306b8 --- /dev/null +++ b/src/api/notifications.ts @@ -0,0 +1,58 @@ +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 +} + +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 +} + +export const notificationsApi = { + // Get notification settings + getSettings: async (): Promise => { + 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 + }, + + // 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 + }, + + // Get notification history + getHistory: async (limit = 20, offset = 0): Promise<{ + notifications: any[] + total: number + limit: number + offset: number + }> => { + const response = await apiClient.get('/cabinet/notifications/history', { + params: { limit, offset }, + }) + return response.data + }, +} diff --git a/src/api/polls.ts b/src/api/polls.ts new file mode 100644 index 0000000..e942265 --- /dev/null +++ b/src/api/polls.ts @@ -0,0 +1,85 @@ +import apiClient from './client' + +export interface PollOption { + id: number + text: string + order: number +} + +export interface PollQuestion { + 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 +} + +export interface PollStartResponse { + 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 +} + +export interface PollsCountResponse { + count: number +} + +export const pollsApi = { + // Get count of available polls + getCount: async (): Promise => { + 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 + }, + + // Get poll details + getPollDetails: async (responseId: number): Promise => { + 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 + }, + + // Answer a question + answerQuestion: async ( + responseId: number, + questionId: number, + optionId: number + ): Promise => { + const response = await apiClient.post( + `/cabinet/polls/${responseId}/questions/${questionId}/answer`, + { option_id: optionId } + ) + return response.data + }, +} diff --git a/src/api/promo.ts b/src/api/promo.ts new file mode 100644 index 0000000..ba0a1e8 --- /dev/null +++ b/src/api/promo.ts @@ -0,0 +1,69 @@ +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 +} + +export interface ActiveDiscount { + 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 +} + +export interface ClaimOfferResponse { + success: boolean + message: string + discount_percent: number | null + expires_at: string | null +} + +export const promoApi = { + // Get available promo offers + getOffers: async (): Promise => { + 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 + }, + + // Get promo group discounts + getGroupDiscounts: async (): Promise => { + const response = await apiClient.get('/cabinet/promo/group-discounts') + 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 + }, + + // Clear active discount + clearActiveDiscount: async (): Promise<{ message: string }> => { + const response = await apiClient.delete<{ message: string }>('/cabinet/promo/active-discount') + return response.data + }, +} diff --git a/src/api/referral.ts b/src/api/referral.ts new file mode 100644 index 0000000..083f7b7 --- /dev/null +++ b/src/api/referral.ts @@ -0,0 +1,62 @@ +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 +} + +interface ReferralEarning { + 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 +} + +export const referralApi = { + // Get referral info + getReferralInfo: async (): Promise => { + const response = await apiClient.get('/cabinet/referral') + return response.data + }, + + // Get referral list + getReferralList: async (params?: { + page?: number + per_page?: number + }): Promise> => { + const response = await apiClient.get>('/cabinet/referral/list', { + params, + }) + return response.data + }, + + // Get referral earnings + getReferralEarnings: async (params?: { + page?: number + per_page?: number + }): Promise => { + const response = await apiClient.get('/cabinet/referral/earnings', { + params, + }) + return response.data + }, + + // Get referral terms + getReferralTerms: async (): Promise => { + const response = await apiClient.get('/cabinet/referral/terms') + return response.data + }, +} diff --git a/src/api/servers.ts b/src/api/servers.ts new file mode 100644 index 0000000..e175bf2 --- /dev/null +++ b/src/api/servers.ts @@ -0,0 +1,142 @@ +import apiClient from './client' + +// Types +export interface PromoGroupInfo { + 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 +} + +export interface ServerListResponse { + 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 +} + +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[] +} + +export interface ServerToggleResponse { + id: number + is_available: boolean + message: string +} + +export interface ServerTrialToggleResponse { + 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 +} + +export interface ServerSyncResponse { + 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 + }, + + // Get single server + getServer: async (serverId: number): Promise => { + 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 + }, + + // Toggle server availability + toggleServer: async (serverId: number): Promise => { + 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 + }, + + // Get server stats + getServerStats: async (serverId: number): Promise => { + 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 + }, +} diff --git a/src/api/subscription.ts b/src/api/subscription.ts new file mode 100644 index 0000000..4371ab0 --- /dev/null +++ b/src/api/subscription.ts @@ -0,0 +1,324 @@ +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 + }, + + // Get renewal options + getRenewalOptions: async (): Promise => { + 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 + }> => { + const response = await apiClient.post('/cabinet/subscription/renew', { + period_days: periodDays, + }) + return response.data + }, + + // Get traffic packages + getTrafficPackages: async (): Promise => { + 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 + }> => { + 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 + }> => { + 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 + }> => { + 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 + }> => { + const response = await apiClient.patch('/cabinet/subscription/autopay', { + enabled, + days_before: daysBefore, + }) + return response.data + }, + + // Get trial info + getTrialInfo: async (): Promise => { + 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 + }, + + // Get purchase options (periods, servers, traffic, devices) + getPurchaseOptions: async (): Promise => { + 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 + }, + + // Submit purchase + 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 + }, + + // 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 + }> => { + const response = await apiClient.post('/cabinet/subscription/purchase-tariff', { + tariff_id: tariffId, + period_days: periodDays, + traffic_gb: trafficGb, + }) + return response.data + }, + + // Get app config for connection + getAppConfig: async (): Promise => { + 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 + price_kopeks: number + price_rubles: number + is_available: boolean + is_connected: boolean + is_trial_eligible: boolean + }> + connected_count: number + has_subscription: boolean + }> => { + 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[] + }> => { + 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 + instructions: { + steps: string[] + } + }> => { + const response = await apiClient.get('/cabinet/subscription/connection-link') + return response.data + }, + + // Get hApp download links + getHappDownloads: async (): Promise<{ + platforms: Record + happ_enabled: boolean + }> => { + const response = await apiClient.get('/cabinet/subscription/happ-downloads') + return response.data + }, + + // ============ Device Management ============ + + // Get connected devices + getDevices: async (): Promise<{ + devices: Array<{ + 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 + }, + + // Delete a specific device + 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 + }, + + // Delete all devices + deleteAllDevices: async (): Promise<{ + success: boolean + message: string + deleted_count: number + }> => { + 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 + }> => { + const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', { + tariff_id: tariffId, + period_days: 30, // Default period for switch + }) + 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 + }> => { + const response = await apiClient.post('/cabinet/subscription/tariff/switch', { + tariff_id: tariffId, + period_days: 30, + }) + 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 + }> => { + 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 + }> => { + const response = await apiClient.put('/cabinet/subscription/traffic', { gb }) + return response.data + }, +} diff --git a/src/api/tariffs.ts b/src/api/tariffs.ts new file mode 100644 index 0000000..26c1a09 --- /dev/null +++ b/src/api/tariffs.ts @@ -0,0 +1,228 @@ +import apiClient from './client' + +// Types +export interface PeriodPrice { + days: number + price_kopeks: number + price_rubles?: number +} + +export interface ServerTrafficLimit { + 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 +} + +export interface PromoGroupInfo { + id: number + name: string + is_selected: boolean +} + +export interface TariffListItem { + 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 + servers_count: number + subscriptions_count: number + created_at: string +} + +export interface TariffListResponse { + 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 + 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_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 + // Дневной тариф + is_daily: boolean + daily_price_kopeks: number + 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 + 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_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 + // Дневной тариф + is_daily?: boolean + daily_price_kopeks?: number +} + +export interface TariffUpdateRequest { + name?: string + description?: string + is_active?: boolean + traffic_limit_gb?: number + device_limit?: number + device_price_kopeks?: 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_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 + // Дневной тариф + is_daily?: boolean + daily_price_kopeks?: number +} + +export interface TariffToggleResponse { + id: number + is_active: boolean + message: string +} + +export interface TariffTrialResponse { + 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 +} + +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 + }, + + // Get single tariff + getTariff: async (tariffId: number): Promise => { + 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 + }, + + // Update tariff + updateTariff: async (tariffId: number, data: TariffUpdateRequest): Promise => { + 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 + }, + + // Toggle tariff active status + toggleTariff: async (tariffId: number): Promise => { + 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 + }, + + // Get tariff stats + getTariffStats: async (tariffId: number): Promise => { + 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 + }, +} diff --git a/src/api/themeColors.ts b/src/api/themeColors.ts new file mode 100644 index 0000000..e02afdf --- /dev/null +++ b/src/api/themeColors.ts @@ -0,0 +1,43 @@ +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 + } catch { + // Return default colors if endpoint not available + 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 + }, + + // Reset to default colors (admin only) + resetColors: async (): Promise => { + 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 + } catch { + 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 + }, +} diff --git a/src/api/tickets.ts b/src/api/tickets.ts new file mode 100644 index 0000000..5205963 --- /dev/null +++ b/src/api/tickets.ts @@ -0,0 +1,84 @@ +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 parameters for ticket messages +interface MediaParams { + 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 + }): Promise> => { + const response = await apiClient.get>('/cabinet/tickets', { + params, + }) + return response.data + }, + + // Create ticket with optional media + createTicket: async ( + title: string, + message: string, + media?: MediaParams + ): Promise => { + const response = await apiClient.post('/cabinet/tickets', { + title, + message, + ...media, + }) + return response.data + }, + + // Get ticket detail + getTicket: async (ticketId: number): Promise => { + 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 + ): Promise => { + const response = await apiClient.post(`/cabinet/tickets/${ticketId}/messages`, { + message, + ...media, + }) + 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 response = await apiClient.post('/cabinet/media/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-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}` + }, +} diff --git a/src/api/wheel.ts b/src/api/wheel.ts new file mode 100644 index 0000000..c076e3b --- /dev/null +++ b/src/api/wheel.ts @@ -0,0 +1,281 @@ +import apiClient from './client' + +// ==================== TYPES ==================== + +export interface WheelPrize { + 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 +} + +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 +} + +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 +} + +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 +} + +export interface SpinHistoryResponse { + items: SpinHistoryItem[] + total: number + page: number + per_page: number + pages: number +} + +export interface StarsInvoiceResponse { + 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 +} + +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 +} + +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 + prizes_distribution: Array<{ + 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 +} + +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 +} + +export interface AdminSpinsResponse { + items: AdminSpinItem[] + total: number + page: number + per_page: number + pages: number +} + +// ==================== USER API ==================== + +export const wheelApi = { + // Get wheel config + getConfig: async (): Promise => { + 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 + }, + + // 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 + }, + + // 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 + }, + + // Create Stars invoice for Mini App payment + createStarsInvoice: async (): Promise => { + 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 + }, + + // Update config + updateConfig: async (data: Partial): Promise => { + 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 + }, + + // Create prize + createPrize: async (data: { + prize_type: string + prize_value: number + display_name: string + emoji?: string + color?: string + prize_value_kopeks: number + sort_order?: number + manual_probability?: number | null + is_active?: boolean + promo_balance_bonus_kopeks?: number + promo_subscription_days?: number + promo_traffic_gb?: number + }): Promise => { + 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 + }, + + // Delete prize + deletePrize: async (prizeId: number): Promise => { + 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 }) + }, + + // 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 + }, + + // Get all spins + getSpins: async (params?: { + 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 + }, +} diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx new file mode 100644 index 0000000..9889dd0 --- /dev/null +++ b/src/components/ColorPicker.tsx @@ -0,0 +1,154 @@ +import { useState, useRef, useEffect } from 'react' + +interface ColorPickerProps { + value: string + onChange: (color: string) => void + label: string + description?: string + disabled?: boolean +} + +const PRESET_COLORS = [ + '#3b82f6', // Blue + '#ef4444', // Red + '#22c55e', // Green + '#f59e0b', // Amber + '#8b5cf6', // Violet + '#ec4899', // Pink + '#06b6d4', // Cyan + '#14b8a6', // Teal + '#84cc16', // Lime + '#f97316', // Orange + '#6366f1', // Indigo + '#a855f7', // Purple +] + +export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) { + const [isOpen, setIsOpen] = useState(false) + const [localValue, setLocalValue] = useState(value) + const containerRef = useRef(null) + const colorInputRef = useRef(null) + + useEffect(() => { + setLocalValue(value) + }, [value]) + + // Close on outside click + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const handleColorInputChange = (e: React.ChangeEvent) => { + const newColor = e.target.value + setLocalValue(newColor) + onChange(newColor) + } + + const handleHexInputChange = (e: React.ChangeEvent) => { + let newValue = e.target.value + + // Add # if not present + if (newValue && !newValue.startsWith('#')) { + newValue = '#' + newValue + } + + // Validate hex format + if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) { + setLocalValue(newValue) + + // Only trigger onChange for valid complete hex + if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) { + onChange(newValue) + } + } + } + + const handlePresetClick = (color: string) => { + setLocalValue(color) + onChange(color) + setIsOpen(false) + } + + return ( +
+ + {description &&

{description}

} + +
+ {/* Color preview button */} + +
+ + {/* Dropdown with presets */} + {isOpen && ( +
+
Preset colors
+
+ {PRESET_COLORS.map((preset) => ( +
+
+ )} +
+ ) +} diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx new file mode 100644 index 0000000..032c77f --- /dev/null +++ b/src/components/ConnectionModal.tsx @@ -0,0 +1,562 @@ +import { useState, useMemo, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useQuery } from '@tanstack/react-query' +import { subscriptionApi } from '../api/subscription' +import type { AppInfo, AppConfig, LocalizedText } from '../types' + +interface ConnectionModalProps { + onClose: () => void +} + +// Platform SVG Icons +const IosIcon = () => ( + + + +) + +const AndroidIcon = () => ( + + + +) + +const WindowsIcon = () => ( + + + +) + +const MacosIcon = () => ( + + + +) + +const LinuxIcon = () => ( + + + +) + +const TvIcon = () => ( + + + + + +) + +// Platform icon components map +const platformIconComponents: Record = { + ios: IosIcon, + android: AndroidIcon, + macos: MacosIcon, + windows: WindowsIcon, + linux: LinuxIcon, + androidTV: TvIcon, + appleTV: TvIcon, +} + +// Platform order for display +const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] + +// Detect user's platform from user agent +function detectPlatform(): string | null { + if (typeof window === 'undefined' || !navigator?.userAgent) { + return null + } + + const ua = navigator.userAgent.toLowerCase() + + // Check for mobile devices first + if (/iphone|ipad|ipod/.test(ua)) { + return 'ios' + } + if (/android/.test(ua)) { + // Check if it's Android TV + if (/tv|television|smart-tv|smarttv/.test(ua)) { + return 'androidTV' + } + return 'android' + } + + // Desktop platforms + if (/macintosh|mac os x/.test(ua)) { + return 'macos' + } + if (/windows/.test(ua)) { + return 'windows' + } + if (/linux/.test(ua)) { + return 'linux' + } + + return null +} + +export default function ConnectionModal({ onClose }: ConnectionModalProps) { + const { t, i18n } = useTranslation() + const [selectedPlatform, setSelectedPlatform] = useState(null) + const [selectedApp, setSelectedApp] = useState(null) + const [copied, setCopied] = useState(false) + const [detectedPlatform, setDetectedPlatform] = useState(null) + + const { data: appConfig, isLoading, error } = useQuery({ + queryKey: ['appConfig'], + queryFn: () => subscriptionApi.getAppConfig(), + }) + + // Auto-detect platform on mount + useEffect(() => { + const detected = detectPlatform() + setDetectedPlatform(detected) + }, []) + + // Helper to get localized text + 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] || '' + } + + // Get platform name + const getPlatformName = (platformKey: string): string => { + if (!appConfig?.platformNames?.[platformKey]) { + return platformKey + } + return getLocalizedText(appConfig.platformNames[platformKey]) + } + + // Get available platforms sorted (detected platform first) + const availablePlatforms = useMemo(() => { + if (!appConfig?.platforms) return [] + const available = platformOrder.filter( + (key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0 + ) + // Move detected platform to the front + if (detectedPlatform && available.includes(detectedPlatform)) { + const filtered = available.filter(p => p !== detectedPlatform) + return [detectedPlatform, ...filtered] + } + return available + }, [appConfig, detectedPlatform]) + + // Get apps for selected platform + const platformApps = useMemo(() => { + if (!selectedPlatform || !appConfig?.platforms?.[selectedPlatform]) return [] + return appConfig.platforms[selectedPlatform] + }, [selectedPlatform, appConfig]) + + // Copy subscription link + const copySubscriptionLink = async () => { + if (!appConfig?.subscriptionUrl) return + try { + await navigator.clipboard.writeText(appConfig.subscriptionUrl) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Fallback for older browsers + 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) + } + } + + // Handle deep link click - use miniapp redirect page like in miniapp index.html + const handleConnect = (app: AppInfo) => { + if (!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}` + + // Check if it's a custom URL scheme (not http/https) + const isCustomScheme = !/^https?:\/\//i.test(app.deepLink) + const tg = (window as any).Telegram?.WebApp + + if (isCustomScheme && tg?.openLink) { + // For custom URL schemes - open redirect page in external browser via Telegram + try { + tg.openLink(redirectUrl, { try_instant_view: false }) + return + } catch (e) { + console.warn('tg.openLink failed:', e) + } + } + + // Fallback - direct navigation + window.location.href = redirectUrl + } + + // Modal wrapper classes - bottom sheet on mobile, centered on desktop + const modalOverlayClass = "fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center sm:justify-center" + const modalCardClass = "w-full sm:max-w-lg sm:mx-4 bg-dark-850 sm:rounded-2xl rounded-t-2xl rounded-b-none border-t border-x sm:border border-dark-700/50 shadow-2xl overflow-hidden" + const modalContentClass = "p-4 sm:p-6 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6" + // Allow the sheet to almost fill the viewport height on phones + const modalScrollableClass = "h-[calc(100vh-1rem)] sm:h-auto sm:max-h-[85vh]" + + if (isLoading) { + return ( +
+
e.stopPropagation()}> +
+
+
+
+
+
+
+ ) + } + + if (error || !appConfig) { + return ( +
+
e.stopPropagation()}> +
+
+

{t('common.error')}

+ +
+
+
+
+ ) + } + + if (!appConfig.hasSubscription) { + return ( +
+
e.stopPropagation()}> +
+
+

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

+ +
+
+

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

+
+
+
+
+ ) + } + + // Step 1: Select platform + if (!selectedPlatform) { + return ( +
+
e.stopPropagation()}> + {/* Header - fixed */} +
+

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

+ +
+ + {/* Scrollable content */} +
+

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

+ +
+ {availablePlatforms.map((platform) => { + const IconComponent = platformIconComponents[platform] + return ( + + ) + })} +
+
+ + {/* Footer - fixed with safe area */} +
+ +
+
+
+ ) + } + + // Step 2: Select app (if not selected yet) + if (!selectedApp) { + return ( +
+
e.stopPropagation()}> + {/* Header - fixed */} +
+
+ +

+ {getPlatformName(selectedPlatform)} +

+
+ +
+ + {/* Scrollable content */} +
+

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

+ + {platformApps.length === 0 ? ( +
+

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

+
+ ) : ( +
+ {platformApps.map((app) => ( + + ))} +
+ )} +
+
+
+ ) + } + + // Step 3: Show app instructions and connect button + return ( +
+
e.stopPropagation()}> + {/* Header - fixed */} +
+
+ +

+ {selectedApp.name} +

+
+ +
+ + {/* Scrollable content */} +
+
+ {/* Step 1: Install */} + {selectedApp.installationStep && ( +
+

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

+

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

+ {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( +
+ {selectedApp.installationStep.buttons.map((btn, idx) => ( + + + + + {getLocalizedText(btn.buttonText)} + + ))} +
+ )} +
+ )} + + {/* Additional before add subscription */} + {selectedApp.additionalBeforeAddSubscriptionStep && ( +
+ {selectedApp.additionalBeforeAddSubscriptionStep.title && ( +

+ {getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.title)} +

+ )} +

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

+
+ )} + + {/* Step 2: Add subscription */} + {selectedApp.addSubscriptionStep && ( +
+

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

+

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

+ + {/* Connect button */} + {selectedApp.deepLink && ( + + )} + + {/* Copy link fallback */} + +
+ )} + + {/* Additional after add subscription */} + {selectedApp.additionalAfterAddSubscriptionStep && ( +
+ {selectedApp.additionalAfterAddSubscriptionStep.title && ( +

+ {getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.title)} +

+ )} +

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

+
+ )} + + {/* Step 3: Connect */} + {selectedApp.connectAndUseStep && ( +
+

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

+

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

+
+ )} +
+
+ + {/* Footer - fixed with safe area */} +
+ +
+
+
+ ) +} diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..698a586 --- /dev/null +++ b/src/components/LanguageSwitcher.tsx @@ -0,0 +1,79 @@ +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 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) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const changeLanguage = (code: string) => { + i18n.changeLanguage(code) + // Set document direction for RTL languages + 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]) + + return ( +
+ + + {isOpen && ( +
+ {languages.map((lang) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx new file mode 100644 index 0000000..ff08551 --- /dev/null +++ b/src/components/Onboarding.tsx @@ -0,0 +1,240 @@ +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' +} + +interface OnboardingProps { + steps: OnboardingStep[] + onComplete: () => void + onSkip: () => void +} + +const STORAGE_KEY = 'onboarding_completed' + +export function useOnboarding() { + const [isCompleted, setIsCompleted] = useState(() => { + return localStorage.getItem(STORAGE_KEY) === 'true' + }) + + const complete = useCallback(() => { + localStorage.setItem(STORAGE_KEY, 'true') + setIsCompleted(true) + }, []) + + const reset = useCallback(() => { + localStorage.removeItem(STORAGE_KEY) + setIsCompleted(false) + }, []) + + 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 step = steps[currentStep] + + // Find and highlight target element + useEffect(() => { + const findTarget = () => { + const target = document.querySelector(`[data-onboarding="${step.target}"]`) + if (target) { + const rect = target.getBoundingClientRect() + setTargetRect(rect) + + // Scroll element into view if needed + target.scrollIntoView({ behavior: 'smooth', block: 'center' }) + + // Delay visibility for smooth animation + setTimeout(() => setIsVisible(true), 100) + } + } + + 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}"]`) + if (target) { + setTargetRect(target.getBoundingClientRect()) + } + } + + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + return () => { + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + } + }, [step.target]) + + const handleNext = () => { + if (currentStep < steps.length - 1) { + setCurrentStep(currentStep + 1) + } else { + onComplete() + } + } + + const handlePrev = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1) + } + } + + const handleSkip = () => { + onSkip() + } + + // Calculate tooltip position + const getTooltipStyle = (): React.CSSProperties => { + if (!targetRect) return { opacity: 0 } + + const padding = 16 + const tooltipWidth = 320 + const tooltipHeight = tooltipRef.current?.offsetHeight || 150 + + let top = 0 + let left = 0 + + switch (step.placement) { + case 'bottom': + 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 + case 'left': + 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 + } + + // Keep within viewport + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + + if (left < padding) left = padding + if (left + tooltipWidth > viewportWidth - padding) { + left = viewportWidth - tooltipWidth - padding + } + if (top < padding) top = padding + if (top + tooltipHeight > viewportHeight - padding) { + top = viewportHeight - tooltipHeight - padding + } + + return { + top, + left, + width: tooltipWidth, + opacity: isVisible ? 1 : 0, + transform: isVisible ? 'scale(1)' : 'scale(0.95)', + } + } + + // Spotlight style + const getSpotlightStyle = (): React.CSSProperties => { + if (!targetRect) return { opacity: 0 } + + 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( +
+ {/* Spotlight */} +
+ + {/* Tooltip */} +
+ {/* Progress indicator */} +
+ {steps.map((_, index) => ( +
+ ))} +
+ + {/* Content */} +

{step.title}

+

{step.description}

+ + {/* Actions */} +
+ + +
+ {currentStep > 0 && ( + + )} + +
+
+
+ + {/* Click handler to advance on target click */} + {targetRect && ( +
+ )} +
, + document.body + ) +} diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx new file mode 100644 index 0000000..c26ec5d --- /dev/null +++ b/src/components/TelegramLoginButton.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' + +interface TelegramLoginButtonProps { + botUsername: string +} + +export default function TelegramLoginButton({ + botUsername, +}: TelegramLoginButtonProps) { + const { t } = useTranslation() + const containerRef = useRef(null) + + // Load widget script + useEffect(() => { + if (!containerRef.current || !botUsername) return + + // Clear previous widget + containerRef.current.innerHTML = '' + + // Get current URL for redirect + const redirectUrl = `${window.location.origin}/auth/telegram/callback` + + // Create script element for Telegram Login Widget + const script = document.createElement('script') + script.src = 'https://telegram.org/js/telegram-widget.js?22' + script.setAttribute('data-telegram-login', botUsername) + script.setAttribute('data-size', 'large') + script.setAttribute('data-radius', '8') + script.setAttribute('data-auth-url', redirectUrl) + script.setAttribute('data-request-access', 'write') + script.async = true + + containerRef.current.appendChild(script) + }, [botUsername]) + + if (!botUsername || botUsername === 'your_bot') { + return ( +
+ {t('auth.telegramNotConfigured')} +
+ ) + } + + return ( +
+ {/* Telegram Widget will be inserted here */} +
+ + {/* Fallback link for mobile */} +
+

{t('auth.orOpenInApp')}

+ + + + + @{botUsername} + +
+
+ ) +} diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx new file mode 100644 index 0000000..efb3577 --- /dev/null +++ b/src/components/TopUpModal.tsx @@ -0,0 +1,242 @@ +import { useState, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { useMutation } from '@tanstack/react-query' +import { balanceApi } from '../api/balance' +import { useCurrency } from '../hooks/useCurrency' +import type { PaymentMethod } from '../types' + +const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i +const CRYPTOBOT_INVOICE_REGEX = /^(?:https?:\/\/)?(?:app\.cr\.bot|cr\.bot)\/invoices\/([A-Za-z0-9_-]+)/i + +const isTelegramPaymentLink = (url: string): boolean => TELEGRAM_LINK_REGEX.test(url) + +const buildCryptoBotDeepLink = (url: string): string | null => { + try { + const m = url.match(CRYPTOBOT_INVOICE_REGEX) + if (m && m[1]) return `tg://resolve?domain=CryptoBot&start=${m[1]}` + const parsed = new URL(url) + if (/^(?:www\.)?t\.me$/i.test(parsed.hostname) && /\/CryptoBot/i.test(parsed.pathname)) { + return `tg://resolve?domain=CryptoBot${parsed.search || ''}` + } + return null + } catch { return null } +} + +const openPaymentLink = (url: string, reservedWindow?: Window | null) => { + if (typeof window === 'undefined' || !url) return + const webApp = window.Telegram?.WebApp + + // If inside Telegram Mini App, let Telegram handle t.me links + if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) { + try { webApp.openTelegramLink(url); return } catch {} + } + + // Prefer Telegram deep link specifically for CryptoBot invoices, but only when + // the backend didn't already return a direct t.me link (those work fine). + const cb = buildCryptoBotDeepLink(url) + const target = cb && !isTelegramPaymentLink(url) ? cb : url + + if (reservedWindow && !reservedWindow.closed) { + try { reservedWindow.location.href = target; reservedWindow.focus?.() } catch {} + return + } + + const w2 = window.open(target, '_blank', 'noopener,noreferrer') + if (w2) { w2.opener = null; return } + window.location.href = target +} + +interface TopUpModalProps { method: PaymentMethod; onClose: () => void } + +export default function TopUpModal({ method, onClose }: TopUpModalProps) { + const { t } = useTranslation() + const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() + const [amount, setAmount] = useState('') + const [error, setError] = useState(null) + const popupRef = useRef(null) + + 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 isTelegramMiniApp = typeof window !== 'undefined' && Boolean(window.Telegram?.WebApp?.initData) + + // Stars payment using the same approach as Wheel.tsx + const starsPaymentMutation = useMutation({ + mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), + onSuccess: (data) => { + console.log('[Stars] API response:', data) + console.log('[Stars] invoice_url:', data.invoice_url) + const webApp = window.Telegram?.WebApp + console.log('[Stars] webApp:', webApp) + console.log('[Stars] openInvoice available:', !!webApp?.openInvoice) + + if (!data.invoice_url) { + console.error('[Stars] No invoice_url in response!') + setError('Сервер не вернул ссылку на оплату') + return + } + + if (!webApp?.openInvoice) { + console.error('[Stars] openInvoice not available - not in Telegram Mini App?') + setError('Оплата Stars доступна только в Telegram Mini App') + return + } + + console.log('[Stars] Calling openInvoice with:', data.invoice_url) + try { + webApp.openInvoice(data.invoice_url, (status) => { + console.log('[Stars] Invoice callback status:', status) + if (status === 'paid') { + setError(null) + onClose() + } else if (status === 'failed') { + setError(t('wheel.starsPaymentFailed')) + } else if (status === 'cancelled') { + setError(null) + } + }) + } catch (e) { + console.error('[Stars] openInvoice error:', e) + setError('Ошибка открытия окна оплаты: ' + String(e)) + } + }, + onError: (error: unknown) => { + console.error('[Stars] API error:', error) + const axiosError = error as { response?: { data?: { detail?: string }, status?: number } } + const detail = axiosError?.response?.data?.detail + const status = axiosError?.response?.status + setError(`Ошибка API (${status || 'network'}): ${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), + onSuccess: (data) => { + const redirectUrl = data.payment_url || (data as any).invoice_url + if (redirectUrl) { + openPaymentLink(redirectUrl, popupRef.current) + } + popupRef.current = null + onClose() + }, + onError: (error: unknown) => { + try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch {} + popupRef.current = null + const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '' + if (detail.includes('not yet implemented')) setError(t('balance.useBot')) + else setError(detail || t('common.error')) + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); setError(null) + const amountCurrency = parseFloat(amount) + if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Invalid amount')); return } + const amountRubles = convertToRub(amountCurrency) + if (amountRubles < minRubles) { setError(t('balance.minAmountError', { amount: minRubles })); return } + if (amountRubles > maxRubles) { setError(t('balance.maxAmountError', { amount: maxRubles })); return } + const amountKopeks = Math.round(amountRubles * 100) + // Pre-open popup window to avoid browser blocking (must happen in user click context) + if (!isTelegramMiniApp) { + try { popupRef.current = window.open('', '_blank') } catch { popupRef.current = null } + } + if (isStarsMethod) { + starsPaymentMutation.mutate(amountKopeks); return + } + topUpMutation.mutate(amountKopeks) + } + + const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles) + const currencyDecimals = targetCurrency === 'IRR' || targetCurrency === 'RUB' ? 0 : 2 + const getQuickAmountValue = (rubAmount: number): string => { + if (targetCurrency === 'IRR') return Math.round(convertAmount(rubAmount)).toString() + return convertAmount(rubAmount).toFixed(currencyDecimals) + } + const inputStep = currencyDecimals === 0 ? 1 : 0.01 + const minInputValue = targetCurrency === 'RUB' ? minRubles : targetCurrency === 'IRR' ? Math.round(convertAmount(minRubles)) : Number(convertAmount(minRubles).toFixed(currencyDecimals)) + const maxInputValue = targetCurrency === 'RUB' ? maxRubles : targetCurrency === 'IRR' ? Math.round(convertAmount(maxRubles)) : Number(convertAmount(maxRubles).toFixed(currencyDecimals)) + + return ( +
+
+
+

{t('balance.topUp')} - {methodName}

+ +
+ +
+
+ + setAmount(e.target.value)} + placeholder={`${formatAmount(minRubles, currencyDecimals)} - ${formatAmount(maxRubles, currencyDecimals)}`} + min={minInputValue} + max={maxInputValue} + step={inputStep} + className="input" + /> +
+ {t('balance.minAmount')}: {formatAmount(minRubles, currencyDecimals)} {currencySymbol} | {t('balance.maxAmount')}: {formatAmount(maxRubles, currencyDecimals)} {currencySymbol} +
+
+ + {quickAmounts.length > 0 && ( +
+ {quickAmounts.map((a) => { + const quickValue = getQuickAmountValue(a) + return ( + + ) + })} +
+ )} + + {error && ( +
{error}
+ )} + +
+ + +
+
+
+
+ ) +} + diff --git a/src/components/common/PageLoader.tsx b/src/components/common/PageLoader.tsx new file mode 100644 index 0000000..b5e7fb5 --- /dev/null +++ b/src/components/common/PageLoader.tsx @@ -0,0 +1,21 @@ +import { DotLottieReact } from '@lottiefiles/dotlottie-react' + +interface PageLoaderProps { + variant?: 'dark' | 'light' +} + +export default function PageLoader({ variant = 'dark' }: PageLoaderProps) { + const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50' + + return ( +
+
+ +
+
+ ) +} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx new file mode 100644 index 0000000..046a4ea --- /dev/null +++ b/src/components/layout/Layout.tsx @@ -0,0 +1,498 @@ +import { Link, useLocation } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { useState, useEffect, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useAuthStore } from '../../store/auth' +import LanguageSwitcher from '../LanguageSwitcher' +import { contestsApi } from '../../api/contests' +import { pollsApi } from '../../api/polls' +import { brandingApi } from '../../api/branding' +import { wheelApi } from '../../api/wheel' +import { themeColorsApi } from '../../api/themeColors' +import { useTheme } from '../../hooks/useTheme' + +// Fallback branding from environment variables +const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet' +const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V' + +interface LayoutProps { + children: React.ReactNode +} + +// Icons as simple SVG components +const HomeIcon = () => ( + + + +) + +const SubscriptionIcon = () => ( + + + +) + +const WalletIcon = () => ( + + + +) + +const UsersIcon = () => ( + + + +) + +const ChatIcon = () => ( + + + +) + +const UserIcon = () => ( + + + +) + +const LogoutIcon = () => ( + + + +) + +// Theme toggle icons +const SunIcon = () => ( + + + +) + +const MoonIcon = () => ( + + + +) + +const MenuIcon = () => ( + + + +) + +const CloseIcon = () => ( + + + +) + +const GamepadIcon = () => ( + + + +) + +const ClipboardIcon = () => ( + + + +) + +const InfoIcon = () => ( + + + +) + +const CogIcon = () => ( + + + + +) + +const WheelIcon = () => ( + + + +) + +export default function Layout({ children }: LayoutProps) { + const { t } = useTranslation() + const location = useLocation() + const { user, logout, isAdmin, isAuthenticated } = useAuthStore() + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const { toggleTheme, isDark } = useTheme() + const [userPhotoUrl, setUserPhotoUrl] = useState(null) + + // Fetch enabled themes from API - same source of truth as AdminSettings + const { data: enabledThemes } = useQuery({ + queryKey: ['enabled-themes'], + queryFn: themeColorsApi.getEnabledThemes, + staleTime: 1000 * 60 * 5, // 5 minutes + }) + + // Only show theme toggle if both themes are enabled + const canToggle = enabledThemes?.dark && enabledThemes?.light + + // Get user photo from Telegram WebApp + useEffect(() => { + try { + const tg = (window as any).Telegram?.WebApp + const photoUrl = tg?.initDataUnsafe?.user?.photo_url + if (photoUrl) { + setUserPhotoUrl(photoUrl) + } + } catch (e) { + console.warn('Failed to get Telegram user photo:', e) + } + }, []) + + // Lock body scroll when mobile menu is open + useEffect(() => { + if (mobileMenuOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + } + }, [mobileMenuOpen]) + + // Fetch branding settings + const { data: branding } = useQuery({ + queryKey: ['branding'], + queryFn: brandingApi.getBranding, + staleTime: 60000, // 1 minute + retry: 1, + }) + + // Computed branding values - use fallback only if branding not loaded yet + const appName = branding ? branding.name : FALLBACK_NAME // Empty string is valid (logo-only mode) + const logoLetter = branding?.logo_letter || FALLBACK_LOGO + const hasCustomLogo = branding?.has_custom_logo || false + const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null + + // Set document title + useEffect(() => { + document.title = appName || 'VPN' // Fallback title if name is empty + }, [appName]) + + // Fetch contests and polls counts to determine if they should be shown + const { data: contestsCount } = useQuery({ + queryKey: ['contests-count'], + queryFn: contestsApi.getCount, + enabled: isAuthenticated, + staleTime: 60000, // 1 minute + retry: false, + }) + + const { data: pollsCount } = useQuery({ + queryKey: ['polls-count'], + queryFn: pollsApi.getCount, + enabled: isAuthenticated, + staleTime: 60000, // 1 minute + retry: false, + }) + + // Fetch wheel config to check if enabled + const { data: wheelConfig } = useQuery({ + queryKey: ['wheel-config'], + queryFn: wheelApi.getConfig, + enabled: isAuthenticated, + staleTime: 60000, // 1 minute + retry: false, + }) + + const navItems = useMemo(() => { + const items = [ + { path: '/', label: t('nav.dashboard'), icon: HomeIcon }, + { path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon }, + { path: '/balance', label: t('nav.balance'), icon: WalletIcon }, + { path: '/referral', label: t('nav.referral'), icon: UsersIcon }, + { path: '/support', label: t('nav.support'), icon: ChatIcon }, + ] + + // Only show contests if there are available contests + if (contestsCount && contestsCount.count > 0) { + items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }) + } + + // Only show polls if there are available polls + if (pollsCount && pollsCount.count > 0) { + items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }) + } + + items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon }) + + return items + }, [t, contestsCount, pollsCount]) + + // Separate navItems for desktop that includes wheel (if enabled) + const desktopNavItems = useMemo(() => { + const items = [...navItems] + // Add wheel before info if enabled + if (wheelConfig?.is_enabled) { + const infoIndex = items.findIndex(item => item.path === '/info') + if (infoIndex !== -1) { + items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }) + } else { + items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }) + } + } + return items + }, [navItems, wheelConfig, t]) + + const adminNavItems = [ + { path: '/admin', label: t('admin.nav.title'), icon: CogIcon }, + ] + + const isActive = (path: string) => location.pathname === path + const isAdminActive = () => location.pathname.startsWith('/admin') + + return ( +
+ {/* Header */} +
+
+
+ {/* Logo */} + +
+ {hasCustomLogo && logoUrl ? ( + {appName + ) : ( + {logoLetter} + )} +
+ {appName && ( + + {appName} + + )} + + + {/* Desktop Navigation */} + + + {/* Right side */} +
+ {/* Theme toggle button - only show if both themes are enabled */} + {canToggle && ( + + )} + + + + {/* Profile - Desktop */} +
+ +
+ +
+ + {user?.first_name || user?.username || `#${user?.telegram_id}`} + + + +
+ + {/* Mobile menu button */} + +
+
+
+ +
+ + {/* Mobile menu - fixed overlay */} + {mobileMenuOpen && ( +
+ {/* Backdrop */} +
setMobileMenuOpen(false)} + /> + + {/* Menu content */} +
+
+ {/* User info */} +
+ {userPhotoUrl ? ( + Avatar { + e.currentTarget.style.display = 'none' + e.currentTarget.nextElementSibling?.classList.remove('hidden') + }} + /> + ) : null} +
+ +
+
+
+ {user?.first_name || user?.username} +
+
+ @{user?.username || `ID: ${user?.telegram_id}`} +
+
+
+ + {/* Nav items */} + +
+
+
+ )} + + {/* Main Content */} +
+
+ {children} +
+
+ + {/* Mobile Bottom Navigation - only core items */} + +
+ ) +} diff --git a/src/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx new file mode 100644 index 0000000..ed9b2fd --- /dev/null +++ b/src/components/wheel/FortuneWheel.tsx @@ -0,0 +1,439 @@ +import { useEffect, useRef, useState } from 'react' +import type { WheelPrize } from '../../api/wheel' + +interface FortuneWheelProps { + prizes: WheelPrize[] + isSpinning: boolean + targetRotation: number | null + onSpinComplete: () => void +} + +export default function FortuneWheel({ + prizes, + isSpinning, + targetRotation, + onSpinComplete, +}: FortuneWheelProps) { + const wheelRef = useRef(null) + const [currentRotation, setCurrentRotation] = useState(0) + const [lightPattern, setLightPattern] = useState([]) + + // Animated lights effect + useEffect(() => { + if (isSpinning) { + const interval = setInterval(() => { + setLightPattern(Array.from({ length: 20 }, () => Math.random() > 0.4)) + }, 100) + return () => clearInterval(interval) + } else { + setLightPattern(Array.from({ length: 20 }, (_, i) => i % 2 === 0)) + } + }, [isSpinning]) + + useEffect(() => { + if (isSpinning && targetRotation !== null && wheelRef.current) { + setCurrentRotation(targetRotation) + + const timeout = setTimeout(() => { + onSpinComplete() + }, 5000) + + return () => clearTimeout(timeout) + } + }, [isSpinning, targetRotation, onSpinComplete]) + + if (prizes.length === 0) { + return ( +
+

No prizes configured

+
+ ) + } + + const size = 400 + const center = size / 2 + const outerRadius = size / 2 - 20 + const innerRadius = outerRadius - 15 + const prizeRadius = innerRadius - 5 + const sectorAngle = 360 / prizes.length + const hubRadius = 45 + + const createSectorPath = (index: number) => { + const startAngle = (index * sectorAngle - 90) * (Math.PI / 180) + const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180) + + const x1 = center + prizeRadius * Math.cos(startAngle) + const y1 = center + prizeRadius * Math.sin(startAngle) + const x2 = center + prizeRadius * Math.cos(endAngle) + const y2 = center + prizeRadius * Math.sin(endAngle) + + const x1Inner = center + hubRadius * Math.cos(startAngle) + const y1Inner = center + hubRadius * Math.sin(startAngle) + const x2Inner = center + hubRadius * Math.cos(endAngle) + const y2Inner = center + hubRadius * Math.sin(endAngle) + + const largeArc = sectorAngle > 180 ? 1 : 0 + + return `M ${x1Inner} ${y1Inner} + L ${x1} ${y1} + A ${prizeRadius} ${prizeRadius} 0 ${largeArc} 1 ${x2} ${y2} + L ${x2Inner} ${y2Inner} + A ${hubRadius} ${hubRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner} Z` + } + + // Position for emoji - closer to outer edge + const getEmojiPosition = (index: number) => { + const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180) + const emojiRadius = prizeRadius * 0.75 + return { + x: center + emojiRadius * Math.cos(angle), + y: center + emojiRadius * Math.sin(angle), + rotation: index * sectorAngle + sectorAngle / 2, + } + } + + // Position for text - between hub and emoji + const getTextPosition = (index: number) => { + const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180) + const textRadius = prizeRadius * 0.45 + return { + x: center + textRadius * Math.cos(angle), + y: center + textRadius * Math.sin(angle), + rotation: index * sectorAngle + sectorAngle / 2, + } + } + + // Alternate colors for sectors + const getSectorColors = (index: number, baseColor?: string) => { + if (baseColor) return baseColor + const colors = [ + '#8B5CF6', '#EC4899', '#3B82F6', '#10B981', + '#F59E0B', '#EF4444', '#6366F1', '#14B8A6' + ] + return colors[index % colors.length] + } + + // Truncate text intelligently + const truncateText = (text: string, maxLen: number) => { + if (text.length <= maxLen) return text + return text.substring(0, maxLen - 1) + '..' + } + + // Calculate max text length based on number of sectors + const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8 + + return ( +
+ {/* Outer glow effect */} +
+ + {/* Pointer */} +
+
+
+ + + + + + + + + + + + + + + + + + +
+
+ + {/* Main Wheel */} +
+ + + {/* Sector gradients */} + {prizes.map((prize, index) => { + const color = getSectorColors(index, prize.color) + return ( + + + + + + ) + })} + + {/* Outer ring gradient */} + + + + + + + + + {/* Hub gradient */} + + + + + + + {/* Text shadow filter */} + + + + + + {/* Background shadow */} + + + {/* Outer decorative ring */} + + + {/* Inner ring border */} + + + {/* LED lights on outer ring */} + {Array.from({ length: 20 }).map((_, i) => { + const angle = (i * 18 - 90) * (Math.PI / 180) + const dotX = center + outerRadius * Math.cos(angle) + const dotY = center + outerRadius * Math.sin(angle) + const isLit = lightPattern[i] ?? (i % 2 === 0) + return ( + + {isLit && ( + + )} + + + ) + })} + + {/* Rotating wheel group */} + + {/* Sectors */} + {prizes.map((prize, index) => ( + + ))} + + {/* Sector dividers */} + {prizes.map((_, index) => { + const angle = (index * sectorAngle - 90) * (Math.PI / 180) + const x1 = center + hubRadius * Math.cos(angle) + const y1 = center + hubRadius * Math.sin(angle) + const x2 = center + prizeRadius * Math.cos(angle) + const y2 = center + prizeRadius * Math.sin(angle) + return ( + + ) + })} + + {/* Prize content - Emoji */} + {prizes.map((prize, index) => { + const pos = getEmojiPosition(index) + return ( + + {prize.emoji} + + ) + })} + + {/* Prize content - Text */} + {prizes.map((prize, index) => { + const pos = getTextPosition(index) + const displayText = truncateText(prize.display_name, maxTextLength) + return ( + + {displayText} + + ) + })} + + + {/* Center hub */} + + + {/* Hub inner decoration */} + + + {/* Hub shine */} + + + {/* Center button */} + + + {/* Center text */} + + {isSpinning ? '...' : 'SPIN'} + + + + {/* Spinning overlay glow */} + {isSpinning && ( +
+ )} +
+ + {/* Sparkle effects when spinning */} + {isSpinning && ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+ )} +
+ ) +} diff --git a/src/hooks/useCurrency.ts b/src/hooks/useCurrency.ts new file mode 100644 index 0000000..2de4c2b --- /dev/null +++ b/src/hooks/useCurrency.ts @@ -0,0 +1,108 @@ +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { currencyApi, type ExchangeRates } from '../api/currency' + +// Map language to currency +const LANGUAGE_CURRENCY_MAP: Record = { + ru: 'RUB', + en: 'USD', + zh: 'CNY', + fa: 'IRR', +} + +// Default rates for fallback +const DEFAULT_RATES: ExchangeRates = { + USD: 100, + CNY: 14, + IRR: 0.0024, +} + +export function useCurrency() { + const { i18n, t } = useTranslation() + + // Fetch exchange rates + const { data: exchangeRates = DEFAULT_RATES } = useQuery({ + queryKey: ['exchange-rates'], + queryFn: currencyApi.getExchangeRates, + staleTime: 60 * 60 * 1000, // 1 hour + refetchOnWindowFocus: false, + retry: 2, + }) + + // Get current language and currency + const currentLanguage = i18n.language + const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD' + + // Check if current language is Russian (no conversion needed) + const isRussian = currentLanguage === 'ru' + + // Get currency symbol from translations + const currencySymbol = t('common.currency') + + // Format amount with currency conversion + const formatAmount = (rubAmount: number, decimals: number = 2): string => { + if (isRussian) { + return rubAmount.toFixed(decimals) + } + + // Convert to target currency + const convertedAmount = currencyApi.convertFromRub( + rubAmount, + targetCurrency as keyof ExchangeRates, + exchangeRates + ) + + // For IRR (Iranian Toman), use no decimals as amounts are large + if (targetCurrency === 'IRR') { + return Math.round(convertedAmount).toLocaleString('fa-IR') + } + + return convertedAmount.toFixed(decimals) + } + + // Format amount with currency symbol + const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => { + return `${formatAmount(rubAmount, decimals)} ${currencySymbol}` + } + + // Format amount with + sign (for earnings/bonuses) + const formatPositive = (rubAmount: number, decimals: number = 2): string => { + return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}` + } + + // Get raw converted amount (for calculations) + const convertAmount = (rubAmount: number): number => { + if (isRussian) { + return rubAmount + } + return currencyApi.convertFromRub( + rubAmount, + targetCurrency as keyof ExchangeRates, + exchangeRates + ) + } + + // Convert from user's currency back to rubles + const convertToRub = (amount: number): number => { + if (isRussian) { + return amount + } + return currencyApi.convertToRub( + amount, + targetCurrency as keyof ExchangeRates, + exchangeRates + ) + } + + return { + exchangeRates, + targetCurrency, + isRussian, + currencySymbol, + formatAmount, + formatWithCurrency, + formatPositive, + convertAmount, + convertToRub, + } +} diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 0000000..82f2d75 --- /dev/null +++ b/src/hooks/useTheme.ts @@ -0,0 +1,224 @@ +import { useState, useEffect, useCallback } from 'react' +import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme' +import { themeColorsApi } from '../api/themeColors' + +type Theme = 'dark' | 'light' + +const THEME_KEY = 'cabinet-theme' +const ENABLED_THEMES_KEY = 'cabinet-enabled-themes' + +// Fetch enabled themes from API +async function fetchEnabledThemes(): Promise { + try { + const data = await themeColorsApi.getEnabledThemes() + // Cache in localStorage for faster subsequent loads + localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data)) + return data + } catch { + // Ignore errors, use cached or default + } + // Try to get from cache + const cached = localStorage.getItem(ENABLED_THEMES_KEY) + if (cached) { + try { + return JSON.parse(cached) + } catch { + // Ignore parse errors + } + } + return DEFAULT_ENABLED_THEMES +} + +// Get cached enabled themes synchronously +function getCachedEnabledThemes(): EnabledThemes { + const cached = localStorage.getItem(ENABLED_THEMES_KEY) + if (cached) { + try { + return JSON.parse(cached) + } catch { + // Ignore parse errors + } + } + return DEFAULT_ENABLED_THEMES +} + +// Custom event for same-tab updates +const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged' + +// Update cache (called from admin settings) +export function updateEnabledThemesCache(themes: EnabledThemes) { + localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes)) + // Dispatch custom event for same-tab updates + window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes })) +} + +export function useTheme() { + const [enabledThemes, setEnabledThemes] = useState(getCachedEnabledThemes) + const [isLoading, setIsLoading] = useState(true) + + const [theme, setThemeState] = useState(() => { + const enabled = getCachedEnabledThemes() + + // Check localStorage first + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(THEME_KEY) as Theme | null + if (stored === 'light' && enabled.light) { + return 'light' + } + if (stored === 'dark' && enabled.dark) { + return 'dark' + } + // If stored theme is disabled, use the enabled one + if (stored && !enabled[stored]) { + return enabled.dark ? 'dark' : 'light' + } + // Check system preference + if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) { + return 'light' + } + } + // Default to dark if enabled, otherwise light + return enabled.dark ? 'dark' : 'light' + }) + + // Fetch enabled themes on mount + useEffect(() => { + fetchEnabledThemes().then((data) => { + setEnabledThemes(data) + setIsLoading(false) + // If current theme is disabled, switch to enabled one + if (!data[theme]) { + const newTheme = data.dark ? 'dark' : 'light' + setThemeState(newTheme) + } + }) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // Listen for localStorage changes (when admin updates enabled themes from other tabs) + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === ENABLED_THEMES_KEY && e.newValue) { + try { + const data = JSON.parse(e.newValue) as EnabledThemes + setEnabledThemes(data) + // If current theme is now disabled, switch to enabled one + if (!data[theme]) { + const newTheme = data.dark ? 'dark' : 'light' + setThemeState(newTheme) + } + } catch { + // Ignore parse errors + } + } + } + + window.addEventListener('storage', handleStorageChange) + return () => window.removeEventListener('storage', handleStorageChange) + }, [theme]) + + // Listen for same-tab enabled themes changes (from admin settings) + useEffect(() => { + const handleEnabledThemesChange = (e: CustomEvent) => { + const data = e.detail + setEnabledThemes(data) + // If current theme is now disabled, switch to enabled one + if (!data[theme]) { + const newTheme = data.dark ? 'dark' : 'light' + setThemeState(newTheme) + } + } + + window.addEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener) + return () => window.removeEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener) + }, [theme]) + + // Apply theme to document - also check if theme is disabled and switch + useEffect(() => { + const root = document.documentElement + + // If current theme is disabled, switch to the enabled one + if (!enabledThemes[theme]) { + const newTheme = enabledThemes.dark ? 'dark' : 'light' + if (newTheme !== theme) { + setThemeState(newTheme) + return // Will re-run with correct theme + } + } + + if (theme === 'light') { + root.classList.remove('dark') + root.classList.add('light') + } else { + root.classList.remove('light') + root.classList.add('dark') + } + + localStorage.setItem(THEME_KEY, theme) + }, [theme, enabledThemes]) + + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: light)') + + const handleChange = (e: MediaQueryListEvent) => { + const stored = localStorage.getItem(THEME_KEY) + // Only auto-switch if user hasn't set a preference and theme is enabled + if (!stored) { + const newTheme = e.matches ? 'light' : 'dark' + if (enabledThemes[newTheme]) { + setThemeState(newTheme) + } + } + } + + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, [enabledThemes]) + + const setTheme = useCallback((newTheme: Theme) => { + // Only allow setting if theme is enabled + if (enabledThemes[newTheme]) { + setThemeState(newTheme) + } + }, [enabledThemes]) + + const toggleTheme = useCallback(() => { + setThemeState((prev) => { + const newTheme = prev === 'dark' ? 'light' : 'dark' + // Only toggle if the new theme is enabled + if (enabledThemes[newTheme]) { + return newTheme + } + return prev + }) + }, [enabledThemes]) + + const isDark = theme === 'dark' + const isLight = theme === 'light' + + // Check if theme switching is available (both themes enabled and loaded) + const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light + + // Refresh enabled themes from API + const refreshEnabledThemes = useCallback(() => { + fetchEnabledThemes().then((data) => { + setEnabledThemes(data) + if (!data[theme]) { + const newTheme = data.dark ? 'dark' : 'light' + setThemeState(newTheme) + } + }) + }, [theme]) + + return { + theme, + setTheme, + toggleTheme, + isDark, + isLight, + enabledThemes, + canToggle, + isLoading, + refreshEnabledThemes, + } +} diff --git a/src/hooks/useThemeColors.ts b/src/hooks/useThemeColors.ts new file mode 100644 index 0000000..f0fa4eb --- /dev/null +++ b/src/hooks/useThemeColors.ts @@ -0,0 +1,256 @@ +import { useEffect } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { themeColorsApi } from '../api/themeColors' +import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme' + +// Convert hex to RGB values +function hexToRgb(hex: string): { r: number; g: number; b: number } { + // Handle shorthand hex + if (hex.length === 4) { + hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3] + } + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return { r, g, b } +} + +// Convert hex to HSL +function hexToHsl(hex: string): { h: number; s: number; l: number } { + const { r, g, b } = hexToRgb(hex) + const rNorm = r / 255 + const gNorm = g / 255 + const bNorm = b / 255 + + const max = Math.max(rNorm, gNorm, bNorm) + const min = Math.min(rNorm, gNorm, bNorm) + let h = 0 + let 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) + + switch (max) { + case rNorm: + h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6 + break + case gNorm: + h = ((bNorm - rNorm) / d + 2) / 6 + break + case bNorm: + h = ((rNorm - gNorm) / d + 4) / 6 + break + } + } + + return { h: h * 360, s: s * 100, l: l * 100 } +} + +// Convert HSL to RGB values +function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { + s /= 100 + l /= 100 + + const a = s * Math.min(l, 1 - l) + const f = (n: number) => { + const k = (n + h / 30) % 12 + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) + return Math.round(255 * color) + } + + return { r: f(0), g: f(8), b: f(4) } +} + +// Convert RGB to string format for CSS variable +function rgbToString(r: number, g: number, b: number): string { + return `${r}, ${g}, ${b}` +} + +// Generate color palette from base color (returns RGB strings) +function generatePalette(baseHex: string): ColorPalette { + const { h, s } = hexToHsl(baseHex) + + // Lightness values for each shade level (from light to dark) + const lightnessMap: Record = { + 50: 97, + 100: 94, + 200: 86, + 300: 76, + 400: 64, + 500: 50, + 600: 42, + 700: 34, + 800: 26, + 900: 18, + 950: 10, + } + + const palette: Partial = {} + + for (const shade of SHADE_LEVELS) { + const lightness = lightnessMap[shade] + // Adjust saturation slightly for very light/dark shades + const adjustedS = shade <= 100 ? s * 0.7 : shade >= 900 ? s * 0.8 : s + const { r, g, b } = hslToRgb(h, adjustedS, lightness) + palette[shade] = rgbToString(r, g, b) + } + + return palette as ColorPalette +} + +// Generate neutral palette from dark background color (for dark theme) +function generateDarkPalette(darkBgHex: string): ColorPalette { + const { h, s } = hexToHsl(darkBgHex) + + // Use very low saturation for neutral colors + const neutralS = Math.min(s, 15) + + // Lightness values - from very light (50) to very dark (950) + const lightnessMap: Record = { + 50: 97, + 100: 96, + 200: 89, + 300: 80, + 400: 58, + 500: 40, + 600: 28, + 700: 20, + 800: 12, + 850: 10, + 900: 7, + 950: 4, + } + + const palette: Partial = {} + + for (const shade of [...SHADE_LEVELS, 850] as const) { + const lightness = lightnessMap[shade as keyof typeof lightnessMap] || 50 + const { r, g, b } = hslToRgb(h, neutralS, lightness) + palette[shade as keyof ColorPalette] = rgbToString(r, g, b) + } + + return palette as ColorPalette +} + +// Generate light theme palette (champagne-like) +function generateLightPalette(lightBgHex: string): ColorPalette { + const { h, s } = hexToHsl(lightBgHex) + + // Lightness values for light theme - inverse of dark + const lightnessMap: Record = { + 50: 100, + 100: 98, + 200: 91, + 300: 83, + 400: 74, + 500: 64, + 600: 55, + 700: 42, + 800: 31, + 900: 21, + 950: 10, + } + + const palette: Partial = {} + + for (const shade of SHADE_LEVELS) { + const lightness = lightnessMap[shade] + const { r, g, b } = hslToRgb(h, s, lightness) + palette[shade] = rgbToString(r, g, b) + } + + return palette as ColorPalette +} + +// Apply theme colors as CSS variables (RGB format for Tailwind opacity support) +export function applyThemeColors(colors: ThemeColors): void { + const root = document.documentElement + + // Generate palettes from base colors + const accentPalette = generatePalette(colors.accent) + const successPalette = generatePalette(colors.success) + const warningPalette = generatePalette(colors.warning) + const errorPalette = generatePalette(colors.error) + + // Generate dark/light palettes from background colors + const darkPalette = generateDarkPalette(colors.darkBackground) + const champagnePalette = generateLightPalette(colors.lightBackground) + + // Apply dark palette + for (const shade of [...SHADE_LEVELS, 850] as const) { + if (darkPalette[shade as keyof ColorPalette]) { + root.style.setProperty(`--color-dark-${shade}`, darkPalette[shade as keyof ColorPalette]) + } + } + + // Apply champagne/light palette + for (const shade of SHADE_LEVELS) { + root.style.setProperty(`--color-champagne-${shade}`, champagnePalette[shade]) + } + + // Apply accent palette + for (const shade of SHADE_LEVELS) { + root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]) + } + + // Apply success palette + for (const shade of SHADE_LEVELS) { + root.style.setProperty(`--color-success-${shade}`, successPalette[shade]) + } + + // Apply warning palette + for (const shade of SHADE_LEVELS) { + root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade]) + } + + // Apply error palette + for (const shade of SHADE_LEVELS) { + root.style.setProperty(`--color-error-${shade}`, errorPalette[shade]) + } + + // Apply semantic colors (hex for direct use in some places) + root.style.setProperty('--color-dark-bg', colors.darkBackground) + root.style.setProperty('--color-dark-surface', colors.darkSurface) + root.style.setProperty('--color-dark-text', colors.darkText) + root.style.setProperty('--color-dark-text-secondary', colors.darkTextSecondary) + + root.style.setProperty('--color-light-bg', colors.lightBackground) + root.style.setProperty('--color-light-surface', colors.lightSurface) + root.style.setProperty('--color-light-text', colors.lightText) + root.style.setProperty('--color-light-text-secondary', colors.lightTextSecondary) +} + +export function useThemeColors() { + const queryClient = useQueryClient() + + const { + data: colors, + isLoading, + error, + } = useQuery({ + queryKey: ['theme-colors'], + queryFn: themeColorsApi.getColors, + staleTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false, + retry: 1, + }) + + // Apply colors when loaded or changed + useEffect(() => { + const colorsToApply = colors || DEFAULT_THEME_COLORS + applyThemeColors(colorsToApply) + }, [colors]) + + const invalidateColors = () => { + queryClient.invalidateQueries({ queryKey: ['theme-colors'] }) + } + + return { + colors: colors || DEFAULT_THEME_COLORS, + isLoading, + error, + invalidateColors, + } +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..36fb55f --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,40 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import LanguageDetector from 'i18next-browser-languagedetector' + +import ru from './locales/ru.json' +import en from './locales/en.json' +import zh from './locales/zh.json' +import fa from './locales/fa.json' + +const resources = { + ru: { translation: ru }, + en: { translation: en }, + zh: { translation: zh }, + fa: { translation: fa }, +} + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'ru', + supportedLngs: ['ru', 'en', 'zh', 'fa'], + + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + lookupLocalStorage: 'cabinet_language', + }, + + interpolation: { + escapeValue: false, + }, + + react: { + useSuspense: false, + }, + }) + +export default i18n diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 0000000..a96ff17 --- /dev/null +++ b/src/locales/en.json @@ -0,0 +1,788 @@ +{ + "common": { + "loading": "Loading...", + "error": "Error", + "success": "Success", + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "back": "Back", + "next": "Next", + "close": "Close", + "search": "Search", + "noData": "No data", + "actions": "Actions", + "yes": "Yes", + "no": "No", + "or": "or", + "and": "and", + "edit": "Edit", + "delete": "Delete", + "currency": "$" + }, + "nav": { + "dashboard": "Dashboard", + "subscription": "Subscription", + "balance": "Balance", + "referral": "Referrals", + "support": "Support", + "profile": "Profile", + "logout": "Logout", + "contests": "Contests", + "polls": "Polls", + "info": "Info", + "wheel": "Fortune Wheel" + }, + "auth": { + "login": "Login", + "register": "Register", + "logout": "Logout", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm password", + "forgotPassword": "Forgot password?", + "resetPassword": "Reset password", + "loginWithTelegram": "Login with Telegram", + "loginWithEmail": "Login with Email", + "noAccount": "Don't have an account?", + "hasAccount": "Already have an account?", + "registerHint": "To register with email, first log in via Telegram, then link your email in settings.", + "telegramRequired": "Telegram authorization required", + "telegramNotConfigured": "Telegram bot is not configured", + "authenticating": "Authenticating...", + "orOpenInApp": "Or open the bot in the app", + "loginFailed": "Login Failed", + "tryAgain": "Try Again", + "welcomeBack": "Welcome back!", + "loginTitle": "Login to Cabinet", + "loginSubtitle": "Sign in with Telegram or use your email" + }, + "emailVerification": { + "title": "Email Verification", + "verifying": "Verifying email...", + "pleaseWait": "Please wait while we verify your email address.", + "success": "Email Verified!", + "successMessage": "Your email has been successfully verified. You can now log in with your email and password.", + "failed": "Verification Failed", + "goToLogin": "Go to Login" + }, + "dashboard": { + "title": "Dashboard", + "welcome": "Welcome, {{name}}!", + "yourSubscription": "Your Subscription", + "quickActions": "Quick Actions", + "viewSubscription": "Manage Subscription", + "topUpBalance": "Top Up Balance", + "inviteFriends": "Invite Friends", + "getSupport": "Get Support", + "currentBalance": "Current Balance", + "activeUntil": "Active until", + "noActiveSubscription": "No active subscription", + "devicesUsed": "Devices: {{used}} of {{total}}", + "trafficUsed": "Traffic: {{used}} of {{total}} GB", + "unlimitedTraffic": "Unlimited traffic" + }, + "subscription": { + "title": "Subscription", + "currentPlan": "Current Plan", + "status": "Status", + "active": "Active", + "inactive": "Inactive", + "trialStatus": "Trial", + "expired": "Expired", + "expiresAt": "Expires at", + "daysLeft": "Days left", + "devices": "Devices", + "servers": "Servers", + "traffic": "Traffic", + "unlimited": "Unlimited", + "used": "Used", + "of": "of", + "renew": "Renew", + "extend": "Extend Subscription", + "buyTraffic": "Buy Traffic", + "buyDevices": "Buy Devices", + "renewalOptions": "Renewal Options", + "days": "days", + "hours": "h", + "minutes": "m", + "price": "Price", + "noSubscription": "You don't have an active subscription", + "getSubscription": "Get Subscription", + "connectionInfo": "Connection Info", + "copyLink": "Copy Link", + "copied": "Copied!", + "showQR": "Show QR Code", + "downloadConfig": "Download Config", + "trafficUsed": "Traffic used", + "timeLeft": "Time left", + "getConfig": "Connect Devices", + "autoRenewal": "Auto-renewal", + "daysBeforeExpiry": "days before expiry", + "selectPeriod": "Select Period", + "selectTraffic": "Select Traffic", + "selectServers": "Select Servers", + "selectDevices": "Devices", + "perDevice": "/ device", + "summary": "Summary", + "total": "Total", + "purchase": "Purchase", + "step": "Step {{current}} of {{total}}", + "stepPeriod": "Period", + "stepTraffic": "Traffic", + "stepServers": "Servers", + "stepDevices": "Devices", + "stepConfirm": "Confirm", + "currentTariff": "Current", + "from": "from", + "month": "mo", + "insufficientBalance": "Insufficient balance. Missing {{missing}} ₽", + "trial": { + "title": "Free Trial", + "description": "Try our VPN service for free!", + "days": "days", + "devices": "devices", + "price": "Activation price", + "activate": "Activate Free Trial", + "unavailable": "Trial is not available" + }, + "connection": { + "title": "Connect VPN", + "selectDevice": "Select your device", + "selectApp": "Select app", + "installApp": "1. Install the app", + "addSubscription": "2. Add subscription", + "connectVpn": "3. Connect VPN", + "addToApp": "Add to {{appName}}", + "connect": "Connect", + "noApps": "No apps available for this platform", + "noSubscription": "You need an active subscription to connect", + "featured": "Recommended", + "yourDevice": "Your device", + "copyLink": "Copy subscription link", + "copied": "Link copied!", + "openLink": "Open link" + }, + "myDevices": "My Devices", + "noDevices": "No connected devices", + "deleteDevice": "Delete device", + "deleteAllDevices": "Delete all", + "confirmDeleteDevice": "Delete this device?", + "confirmDeleteAllDevices": "Delete all devices?", + "deviceDeleted": "Device deleted", + "allDevicesDeleted": "All devices deleted", + "platform": "Platform", + "model": "Model", + "connectedAt": "Connected", + "pause": { + "title": "Subscription Pause", + "paused": "Paused", + "active": "Active", + "pauseBtn": "Pause", + "resumeBtn": "Resume", + "pausedMessage": "Subscription paused", + "resumedMessage": "Subscription resumed", + "dailyOnly": "Pause is only available for daily tariffs", + "pausedInfo": "Subscription paused", + "pausedDescription": "Charges stopped. Subscription will be active until", + "nextCharge": "Until next charge", + "willBeCharged": "Will be charged", + "days_one": "day", + "days_few": "days", + "days_many": "days", + "hours": "h", + "minutes": "m" + }, + "dailyPurchase": { + "costPerDay": "Cost per day", + "chargedDaily": "Payment is charged daily from balance", + "canPause": "Can be paused at any time", + "pausedOnLowBalance": "Subscription will be paused if balance is insufficient", + "activate": "Activate for {{price}}" + }, + "switchTariff": { + "title": "Switch Tariff", + "preview": "Preview", + "currentTariff": "Current tariff", + "newTariff": "New tariff", + "remainingDays": "Days remaining", + "upgradeCost": "Upgrade cost", + "free": "Free", + "switch": "Switch", + "switched": "Tariff switched", + "notEnoughBalance": "Insufficient balance" + }, + "switchTraffic": { + "title": "Change Traffic", + "currentPackage": "Current package", + "newPackage": "New package", + "switch": "Change", + "switched": "Traffic changed" + } + }, + "balance": { + "title": "Balance", + "currentBalance": "Current Balance", + "topUp": "Top Up", + "topUpBalance": "Top Up Balance", + "paymentMethods": "Payment Methods", + "transactionHistory": "Transaction History", + "noTransactions": "No transactions", + "date": "Date", + "type": "Type", + "description": "Description", + "amount": "Amount", + "deposit": "Deposit", + "withdrawal": "Withdrawal", + "subscriptionPayment": "Subscription Payment", + "referralReward": "Referral Reward", + "minAmount": "Min.", + "maxAmount": "Max.", + "notAvailable": "Not available", + "useBot": "Please use the Telegram bot to top up your balance.", + "page": "Page {{current}} of {{total}}", + "invalidAmount": "Invalid amount", + "promocode": { + "title": "Promo Code", + "placeholder": "Enter promo code", + "activate": "Activate", + "activating": "Activating...", + "success": "Promo code activated successfully!", + "balanceAdded": "Balance added: {{amount}} ₽", + "errors": { + "not_found": "Promo code not found", + "expired": "Promo code has expired", + "used": "Promo code has already been used", + "already_used_by_user": "You have already used this promo code", + "user_not_found": "User not found", + "server_error": "Server error" + } + }, + "minAmountError": "Minimum amount: {{amount}} ₽", + "maxAmountError": "Maximum amount: {{amount}} ₽", + "paymentMethods": { + "yookassa": { + "name": "YooKassa (Bank Card)", + "description": "Pay with bank card via YooKassa" + }, + "cryptobot": { + "name": "CryptoBot", + "description": "Pay with cryptocurrency via CryptoBot" + }, + "telegram_stars": { + "name": "Telegram Stars", + "description": "Pay with Telegram Stars" + } + } + }, + "referral": { + "title": "Referral Program", + "yourLink": "Your Referral Link", + "copyLink": "Copy Link", + "copied": "Copied!", + "shareButton": "Share", + "shareMessage": "Join me on and earn up to {{percent}}% cashback!", + "shareHint": "Share this link with friends. When they sign up and make a purchase, you'll earn {{percent}}% commission!", + "stats": { + "totalReferrals": "Total Referrals", + "activeReferrals": "Active", + "totalEarnings": "Total Earnings", + "commissionRate": "Commission Rate" + }, + "terms": { + "title": "Program Terms", + "commission": "Commission", + "minTopup": "Min. Top-up", + "newUserBonus": "New User Bonus", + "inviterBonus": "Inviter Bonus" + }, + "yourReferrals": "Your Referrals", + "noReferrals": "No referrals yet. Share your link to invite friends!", + "user": "User", + "joined": "Joined", + "hasPaid": "Has Paid", + "earningsHistory": "Earnings History", + "reason": "Reason" + }, + "support": { + "title": "Support", + "newTicket": "New Ticket", + "yourTickets": "Your Tickets", + "noTickets": "No tickets yet", + "createTicket": "Create New Ticket", + "subject": "Subject", + "subjectPlaceholder": "Brief description of your issue", + "message": "Message", + "messagePlaceholder": "Describe your issue in detail...", + "send": "Send", + "sending": "Sending...", + "creating": "Creating...", + "reply": "Reply", + "replyPlaceholder": "Type your reply...", + "sendReply": "Send Reply", + "you": "You", + "supportTeam": "Support", + "selectTicket": "Select a ticket or create a new one", + "created": "Created", + "repliesDisabled": "Replies to this ticket are disabled", + "status": { + "open": "Open", + "answered": "Answered", + "pending": "Pending", + "closed": "Closed" + }, + "attachImage": "Attach image", + "invalidFileType": "Invalid file type. Use JPEG, PNG, GIF or WebP.", + "fileTooLarge": "File is too large. Maximum size is 10MB.", + "uploadFailed": "Failed to upload image", + "imageLoadFailed": "Failed to load image", + "ticketsDisabled": "Tickets Disabled", + "useProfile": "Please go to the bot profile to get support", + "goToProfile": "Go to Profile", + "useExternalLink": "Please use the external link to get support", + "openSupport": "Open Support", + "contactSupport": "Please contact {username} for support", + "contactUs": "Contact Support" + }, + "wheel": { + "title": "Fortune Wheel", + "disabled": "Fortune Wheel is currently unavailable", + "spinsRemaining": "Spins remaining today", + "choosePayment": "Choose payment method", + "stars": "Stars", + "telegramStars": "Telegram Stars", + "days": "days", + "subscriptionDays": "Subscription days", + "day": "Day", + "spin": "SPIN!", + "spinning": "Spinning...", + "history": "History", + "recentSpins": "Recent spins", + "congratulations": "Congratulations!", + "noLuck": "No luck!", + "oops": "Oops!", + "yourPromoCode": "Your promo code:", + "close": "Close", + "errors": { + "networkError": "A network error occurred", + "loadFailed": "Failed to load wheel configuration", + "dailyLimitReached": "Daily spin limit reached", + "cannotSpin": "Cannot spin right now", + "insufficientBalance": "Insufficient balance", + "topUpRequired": "Top up your balance to pay for spin", + "starsNotAvailable": "Stars payment not available. Open the app via Telegram." + }, + "payWithStars": "Pay with Stars", + "payWithDays": "Pay with days", + "processingPayment": "Processing...", + "starsPaymentSuccess": "Payment successful! Spin result sent to Telegram.", + "starsPaymentSuccessCheckHistory": "Payment successful! Check history or Telegram for the result.", + "starsPaymentFailed": "Payment failed. Please try again.", + "youWon": "You won", + "noPrize": "No luck this time...", + "noHistory": "No spin history yet", + "banner": { + "title": "Try your luck!", + "description": "Spin the wheel and win prizes: subscription days, balance, traffic and more!", + "button": "Go to wheel" + } + }, + "admin": { + "nav": { + "title": "Admin", + "dashboard": "Dashboard", + "tickets": "Tickets", + "settings": "Settings", + "apps": "Apps", + "wheel": "Wheel", + "tariffs": "Tariffs", + "servers": "Servers" + }, + "panel": { + "title": "Admin Panel", + "subtitle": "System management", + "dashboardDesc": "Statistics and system monitoring", + "ticketsDesc": "Handle user support tickets", + "settingsDesc": "System settings and parameters", + "appsDesc": "Manage connection apps", + "wheelDesc": "Configure fortune wheel and prizes", + "tariffsDesc": "Manage tariff plans", + "serversDesc": "Configure VPN servers" + }, + "wheel": { + "title": "Fortune Wheel Settings", + "enabled": "Enabled", + "disabled": "Disabled", + "tabs": { + "settings": "Settings", + "prizes": "Prizes", + "statistics": "Statistics" + }, + "settings": { + "enableWheel": "Enable Wheel", + "allowSpins": "Allow users to spin the wheel", + "costInStars": "Cost in Stars", + "costInDays": "Cost in Days", + "rtpPercent": "RTP (Return to Player) %", + "dailyLimit": "Daily Spin Limit (0 = unlimited)", + "minSubDays": "Min subscription days for day payment", + "promoPrefix": "Promo code prefix" + }, + "prizes": { + "addPrize": "Add Prize", + "editPrize": "Edit Prize", + "noPrizes": "No prizes configured. Add some prizes to enable the wheel.", + "deletePrize": "Delete this prize?", + "types": { + "subscription_days": "Subscription Days", + "balance_bonus": "Balance Bonus", + "traffic_gb": "Traffic GB", + "promocode": "Promo Code", + "nothing": "Nothing (Empty)" + }, + "fields": { + "type": "Type", + "displayName": "Display Name", + "value": "Value", + "valueKopeks": "Value in Kopeks (for RTP)", + "emoji": "Emoji", + "color": "Color", + "active": "Active", + "worth": "Worth" + }, + "promo": { + "title": "Promo Code Settings", + "balanceBonus": "Balance bonus (kopeks)", + "subscriptionDays": "Subscription days" + } + }, + "statistics": { + "totalSpins": "Total Spins", + "revenue": "Revenue", + "payouts": "Payouts", + "actualRtp": "Actual RTP", + "targetRtp": "target", + "prizeDistribution": "Prize Distribution", + "times": "times", + "topWins": "Top Wins" + } + }, + "settings": { + "title": "System Settings", + "allCategories": "All categories", + "noSettings": "No settings found", + "modified": "Modified", + "readOnly": "Read only", + "reset": "Reset", + "categoriesCount": "categories", + "settingsCount": "settings", + "expandAll": "Expand all", + "collapseAll": "Collapse all", + "searchPlaceholder": "Search settings...", + "searchCategoriesPlaceholder": "Search categories...", + "noSearchResults": "Nothing found", + "example": "Example" + }, + "apps": { + "title": "App Management", + "addApp": "Add App", + "addFirstApp": "Add First App", + "noApps": "No apps for this platform", + "createApp": "Create App", + "editApp": "Edit App", + "confirmDelete": "Are you sure you want to delete this app?", + "copyTo": "Copy to...", + "appId": "App ID", + "appName": "Name", + "urlScheme": "URL Scheme", + "featured": "Featured", + "base64Encoding": "Base64 Encoding", + "buttons": "Buttons", + "button": "Button", + "addButton": "Add Button", + "buttonText": "Button Text", + "installDescription": "Installation Description", + "subscriptionDescription": "Add Subscription Description", + "connectDescription": "Connect Description", + "beforeSubscription": "Before Add Subscription", + "afterSubscription": "After Add Subscription", + "stepTitle": "Title", + "stepDescription": "Description", + "tabs": { + "basic": "Basic", + "installation": "Installation", + "subscription": "Subscription", + "connect": "Connect", + "additional": "Additional" + } + }, + "tickets": { + "title": "Ticket Management", + "total": "Total", + "list": "Tickets", + "noTickets": "No tickets", + "selectTicket": "Select a ticket from the list", + "allStatuses": "All statuses", + "statusOpen": "Open", + "statusPending": "Pending", + "statusAnswered": "Answered", + "statusClosed": "Closed", + "from": "From", + "created": "Created", + "user": "User", + "you": "You", + "userLabel": "User", + "adminLabel": "Admin", + "replyPlaceholder": "Type your reply...", + "sendReply": "Send Reply" + }, + "tariffs": { + "title": "Tariff Management", + "subtitle": "Configure tariff plans", + "create": "Create Tariff", + "edit": "Edit Tariff", + "noTariffs": "No tariffs", + "name": "Name", + "namePlaceholder": "Enter tariff name", + "description": "Description", + "descriptionPlaceholder": "Enter tariff description", + "trafficLimit": "Traffic Limit", + "trafficHint": "0 = unlimited traffic", + "deviceLimit": "Device Limit", + "tierLevel": "Tier Level", + "tierHint": "1-10, affects priority during upgrade", + "unlimited": "Unlimited", + "devices": "devices", + "servers": "servers", + "subscriptions": "subscriptions", + "trial": "Trial", + "inactive": "Inactive", + "activate": "Activate", + "deactivate": "Deactivate", + "toggleTrial": "Toggle Trial", + "delete": "Delete", + "confirmDelete": "Delete tariff?", + "confirmDeleteText": "This action cannot be undone. The tariff will be permanently deleted.", + "days": "days", + "tabs": { + "basic": "Basic", + "prices": "Prices", + "servers": "Servers" + }, + "pricesHint": "Enable required periods and set prices (in rubles)", + "periodDisabled": "Disabled", + "serversHint": "Select servers available for this tariff. You can set individual traffic limits for each server.", + "noServers": "No servers available", + "serverTrafficLimit": "Traffic limit", + "useDefault": "default" + }, + "servers": { + "title": "Server Management", + "subtitle": "Configure VPN servers", + "sync": "Sync", + "syncing": "Syncing...", + "syncNow": "Sync now", + "noServers": "No servers. Sync with RemnaWave.", + "edit": "Edit Server", + "originalName": "Original Name", + "displayName": "Display Name", + "displayNamePlaceholder": "Enter display name", + "description": "Description", + "descriptionPlaceholder": "Enter server description", + "countryCode": "Country Code", + "price": "Price", + "priceHint": "Additional server cost", + "maxUsers": "Max Users", + "sortOrder": "Sort Order", + "unlimited": "Unlimited", + "stats": "Statistics", + "currentUsers": "Current Users", + "activeSubscriptions": "Active Subscriptions", + "usedByTariffs": "Used by Tariffs", + "trial": "Trial", + "unavailable": "Unavailable", + "full": "Full", + "enable": "Enable", + "disable": "Disable", + "toggleTrial": "Toggle Trial", + "loadError": "Failed to load server" + } + }, + "adminDashboard": { + "title": "Statistics Dashboard", + "subtitle": "Real-time system overview", + "refresh": "Refresh", + "loadError": "Failed to load statistics", + "stats": { + "usersOnline": "Users Online", + "activeSubscriptions": "Active Subscriptions", + "incomeToday": "Today's Income", + "incomeMonth": "Monthly Income", + "incomeTotal": "Total Income", + "subscriptionIncome": "Subscription Income", + "total": "total" + }, + "nodes": { + "title": "Nodes", + "online": "Online", + "offline": "Offline", + "disabled": "Disabled", + "noNodes": "No nodes available", + "usersOnline": "Online", + "traffic": "Traffic", + "enable": "Enable", + "disable": "Disable" + }, + "revenue": { + "title": "Revenue", + "last7Days": "Last 7 days" + }, + "subscriptions": { + "title": "Subscriptions", + "subtitle": "Subscription statistics", + "active": "Active", + "trial": "Trial", + "paid": "Paid", + "expired": "Expired", + "newSubscriptions": "New Subscriptions", + "today": "Today", + "week": "Week", + "month": "Month", + "conversion": "Trial to Paid Conversion" + }, + "servers": { + "title": "Servers", + "total": "Total Servers", + "available": "Available", + "withConnections": "With Connections", + "revenue": "Revenue" + } + }, + "profile": { + "title": "Profile", + "accountInfo": "Account Information", + "telegramId": "Telegram ID", + "username": "Username", + "name": "Name", + "registeredAt": "Registered", + "emailAuth": "Email Authentication", + "linkEmailDescription": "Link your email to log in without Telegram. After linking, you'll receive a verification email.", + "linkEmail": "Link Email", + "emailRequired": "Email is required", + "passwordMinLength": "Password must be at least 8 characters", + "passwordsMismatch": "Passwords do not match", + "passwordPlaceholder": "Enter password", + "confirmPasswordPlaceholder": "Confirm password", + "passwordHint": "Minimum 8 characters", + "emailSent": "Verification email sent! Please check your inbox.", + "verified": "Verified", + "notVerified": "Not verified", + "verificationRequired": "Please verify your email to use email login.", + "resendVerification": "Resend Verification Email", + "verificationResent": "Verification email resent!", + "canLoginWithEmail": "You can now log in using your email and password.", + "notifications": { + "title": "Notification Settings", + "subscriptionExpiry": "Subscription Expiry", + "subscriptionExpiryDesc": "Notify about expiring subscription", + "daysBeforeExpiry": "Days before expiry", + "trafficWarning": "Traffic Warning", + "trafficWarningDesc": "Notify when approaching traffic limit", + "atPercent": "At usage", + "balanceLow": "Low Balance", + "balanceLowDesc": "Notify about low balance", + "threshold": "Threshold", + "news": "News", + "newsDesc": "Receive news and service updates", + "promoOffers": "Promo Offers", + "promoOffersDesc": "Receive special offers and discounts", + "unavailable": "Notification settings unavailable" + } + }, + "theme": { + "colors": "Theme Colors", + "reset": "Reset", + "accent": "Accent Color", + "accentDescription": "Main brand color for buttons, links and interactive elements", + "darkTheme": "Dark Theme", + "lightTheme": "Light Theme", + "background": "Background", + "surface": "Surface", + "text": "Text", + "textSecondary": "Secondary Text", + "statusColors": "Status Colors", + "success": "Success", + "warning": "Warning", + "error": "Error", + "preview": "Preview", + "previewButton": "Primary Button", + "previewSecondary": "Secondary Button", + "light": "Light Theme", + "dark": "Dark Theme" + }, + "languages": { + "ru": "Русский", + "en": "English", + "zh": "中文", + "fa": "فارسی" + }, + "contests": { + "title": "Contests", + "error": "Failed to load contests", + "noContests": "No contests available", + "days": "days", + "play": "Play", + "alreadyPlayed": "Already played", + "enterAnswer": "Enter your answer", + "submit": "Submit", + "imHere": "I'm here!" + }, + "polls": { + "title": "Polls", + "error": "Failed to load polls", + "noPolls": "No polls available", + "questions": "questions", + "question": "Question", + "of": "of", + "start": "Start", + "continue": "Continue", + "completed": "Completed", + "reward": "$" + }, + "info": { + "title": "Information", + "faq": "FAQ", + "rules": "Rules", + "privacy": "Privacy Policy", + "offer": "Public Offer", + "noFaq": "No FAQ available", + "noContent": "No content available", + "updatedAt": "Updated" + }, + "onboarding": { + "skip": "Skip", + "finish": "Finish", + "steps": { + "welcome": { + "title": "Welcome!", + "description": "This is your personal cabinet. Here you can manage your subscription, top up balance and connect devices." + }, + "connectDevices": { + "title": "Connect Devices", + "description": "Click this button to get instructions on how to connect VPN on your devices." + }, + "balance": { + "title": "Your Balance", + "description": "Your current balance is displayed here. Click to top it up." + }, + "subscription": { + "title": "Subscription Status", + "description": "Here you can see how many days are left until your subscription expires." + }, + "quickActions": { + "title": "Quick Actions", + "description": "Use these buttons for quick access to main features: top up balance, extend subscription, and invite friends." + } + } + } +} diff --git a/src/locales/fa.json b/src/locales/fa.json new file mode 100644 index 0000000..263a921 --- /dev/null +++ b/src/locales/fa.json @@ -0,0 +1,711 @@ +{ + "common": { + "loading": "در حال بارگذاری...", + "error": "خطا", + "success": "موفق", + "save": "ذخیره", + "cancel": "لغو", + "confirm": "تایید", + "back": "بازگشت", + "next": "بعدی", + "close": "بستن", + "search": "جستجو", + "noData": "داده‌ای موجود نیست", + "actions": "عملیات", + "yes": "بله", + "no": "خیر", + "or": "یا", + "and": "و", + "edit": "ویرایش", + "delete": "حذف", + "currency": "تومان" + }, + "nav": { + "dashboard": "داشبورد", + "subscription": "اشتراک", + "balance": "موجودی", + "referral": "معرفی", + "support": "پشتیبانی", + "profile": "پروفایل", + "logout": "خروج", + "contests": "مسابقات", + "polls": "نظرسنجی", + "info": "اطلاعات", + "wheel": "چرخ شانس" + }, + "auth": { + "login": "ورود", + "register": "ثبت نام", + "logout": "خروج", + "email": "ایمیل", + "password": "رمز عبور", + "confirmPassword": "تایید رمز عبور", + "forgotPassword": "رمز عبور را فراموش کردید؟", + "resetPassword": "بازنشانی رمز عبور", + "loginWithTelegram": "ورود با تلگرام", + "loginWithEmail": "ورود با ایمیل", + "noAccount": "حساب کاربری ندارید؟", + "hasAccount": "قبلاً حساب دارید؟", + "registerHint": "برای ثبت نام با ایمیل، ابتدا با تلگرام وارد شوید، سپس ایمیل خود را در تنظیمات متصل کنید.", + "telegramRequired": "نیاز به تایید تلگرام", + "telegramNotConfigured": "ربات تلگرام پیکربندی نشده", + "authenticating": "در حال تایید هویت...", + "orOpenInApp": "یا ربات را در برنامه باز کنید", + "loginFailed": "ورود ناموفق", + "tryAgain": "تلاش مجدد", + "welcomeBack": "خوش آمدید!", + "loginTitle": "ورود به کابین", + "loginSubtitle": "با تلگرام یا ایمیل وارد شوید" + }, + "emailVerification": { + "title": "تایید ایمیل", + "verifying": "در حال تایید ایمیل...", + "pleaseWait": "لطفاً صبر کنید تا ایمیل شما تایید شود.", + "success": "ایمیل تایید شد!", + "successMessage": "ایمیل شما با موفقیت تایید شد. اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", + "failed": "تایید ناموفق", + "goToLogin": "رفتن به صفحه ورود" + }, + "dashboard": { + "title": "داشبورد", + "welcome": "خوش آمدید، {{name}}!", + "yourSubscription": "اشتراک شما", + "quickActions": "دسترسی سریع", + "viewSubscription": "مدیریت اشتراک", + "topUpBalance": "شارژ موجودی", + "inviteFriends": "دعوت دوستان", + "getSupport": "دریافت پشتیبانی", + "currentBalance": "موجودی فعلی", + "activeUntil": "فعال تا", + "noActiveSubscription": "اشتراک فعال ندارید", + "devicesUsed": "دستگاه‌ها: {{used}} از {{total}}", + "trafficUsed": "ترافیک: {{used}} از {{total}} گیگ", + "unlimitedTraffic": "ترافیک نامحدود" + }, + "subscription": { + "title": "اشتراک", + "currentPlan": "پلن فعلی", + "status": "وضعیت", + "active": "فعال", + "inactive": "غیرفعال", + "trialStatus": "دوره آزمایشی", + "expired": "منقضی شده", + "expiresAt": "تاریخ انقضا", + "daysLeft": "روز باقی‌مانده", + "devices": "دستگاه‌ها", + "servers": "سرورها", + "traffic": "ترافیک", + "unlimited": "نامحدود", + "used": "استفاده شده", + "of": "از", + "renew": "تمدید", + "extend": "تمدید اشتراک", + "buyTraffic": "خرید ترافیک", + "buyDevices": "خرید دستگاه", + "renewalOptions": "گزینه‌های تمدید", + "days": "روز", + "hours": "ساعت", + "minutes": "دقیقه", + "price": "قیمت", + "noSubscription": "اشتراک فعال ندارید", + "getSubscription": "خرید اشتراک", + "connectionInfo": "اطلاعات اتصال", + "copyLink": "کپی لینک", + "copied": "کپی شد!", + "showQR": "نمایش QR کد", + "downloadConfig": "دانلود تنظیمات", + "trafficUsed": "ترافیک استفاده شده", + "timeLeft": "زمان باقی‌مانده", + "getConfig": "اتصال دستگاه‌ها", + "autoRenewal": "تمدید خودکار", + "daysBeforeExpiry": "روز قبل از انقضا", + "selectPeriod": "انتخاب دوره", + "selectTraffic": "انتخاب ترافیک", + "selectServers": "انتخاب سرورها", + "selectDevices": "انتخاب دستگاه‌ها", + "perDevice": "/ دستگاه", + "summary": "خلاصه", + "total": "جمع کل", + "purchase": "خرید", + "step": "مرحله {{current}} از {{total}}", + "stepPeriod": "دوره", + "stepTraffic": "ترافیک", + "stepServers": "سرورها", + "stepDevices": "دستگاه‌ها", + "stepConfirm": "تایید", + "trial": { + "title": "دوره آزمایشی رایگان", + "description": "سرویس VPN ما را رایگان امتحان کنید!", + "days": "روز", + "devices": "دستگاه", + "price": "هزینه فعال‌سازی", + "activate": "فعال‌سازی دوره آزمایشی", + "unavailable": "دوره آزمایشی در دسترس نیست" + }, + "connection": { + "title": "اتصال VPN", + "selectDevice": "دستگاه خود را انتخاب کنید", + "selectApp": "برنامه را انتخاب کنید", + "installApp": "۱. نصب برنامه", + "addSubscription": "۲. افزودن اشتراک", + "connectVpn": "۳. اتصال VPN", + "addToApp": "افزودن به {{appName}}", + "connect": "اتصال", + "noApps": "برنامه‌ای برای این پلتفرم موجود نیست", + "noSubscription": "برای اتصال نیاز به اشتراک فعال دارید", + "featured": "پیشنهادی", + "yourDevice": "دستگاه شما", + "copyLink": "کپی لینک اشتراک", + "copied": "لینک کپی شد!", + "openLink": "باز کردن لینک" + } + }, + "balance": { + "title": "موجودی", + "currentBalance": "موجودی فعلی", + "topUp": "شارژ", + "topUpBalance": "شارژ موجودی", + "paymentMethods": { + "yookassa": { + "name": "کارت بانکی", + "description": "پرداخت با کارت بانکی" + }, + "cryptobot": { + "name": "CryptoBot", + "description": "پرداخت با ارز دیجیتال" + }, + "telegram_stars": { + "name": "Telegram Stars", + "description": "پرداخت با Telegram Stars" + } + }, + "transactionHistory": "تاریخچه تراکنش‌ها", + "noTransactions": "تراکنشی موجود نیست", + "date": "تاریخ", + "type": "نوع", + "description": "توضیحات", + "amount": "مبلغ", + "deposit": "واریز", + "withdrawal": "برداشت", + "subscriptionPayment": "پرداخت اشتراک", + "referralReward": "پاداش معرفی", + "minAmount": "حداقل", + "maxAmount": "حداکثر", + "notAvailable": "در دسترس نیست", + "useBot": "لطفاً برای شارژ از ربات تلگرام استفاده کنید.", + "page": "صفحه {{current}} از {{total}}", + "invalidAmount": "مبلغ نامعتبر", + "promocode": { + "title": "کد تخفیف", + "placeholder": "کد تخفیف را وارد کنید", + "activate": "فعال‌سازی", + "activating": "در حال فعال‌سازی...", + "success": "کد تخفیف با موفقیت فعال شد!", + "balanceAdded": "موجودی اضافه شده: {{amount}}", + "errors": { + "not_found": "کد تخفیف یافت نشد", + "expired": "کد تخفیف منقضی شده", + "used": "کد تخفیف قبلاً استفاده شده", + "already_used_by_user": "شما قبلاً از این کد استفاده کرده‌اید", + "user_not_found": "کاربر یافت نشد", + "server_error": "خطای سرور" + } + }, + "minAmountError": "حداقل مبلغ: {{amount}}", + "maxAmountError": "حداکثر مبلغ: {{amount}}" + }, + "referral": { + "title": "برنامه معرفی", + "yourLink": "لینک معرفی شما", + "copyLink": "کپی لینک", + "copied": "کپی شد!", + "shareButton": "اشتراک‌گذاری", + "shareMessage": "از طریق لینک من به Cabinet بپیوند و تا {{percent}}٪ پاداش بگیر!", + "shareHint": "این لینک را با دوستان به اشتراک بگذارید. وقتی ثبت نام کنند و خرید کنند، {{percent}}% کمیسیون دریافت می‌کنید!", + "stats": { + "totalReferrals": "کل معرفی‌ها", + "activeReferrals": "فعال", + "totalEarnings": "کل درآمد", + "commissionRate": "نرخ کمیسیون" + }, + "terms": { + "title": "شرایط برنامه", + "commission": "کمیسیون", + "minTopup": "حداقل شارژ", + "newUserBonus": "جایزه کاربر جدید", + "inviterBonus": "جایزه معرف" + }, + "yourReferrals": "معرفی‌های شما", + "noReferrals": "هنوز معرفی ندارید. لینک را به اشتراک بگذارید!", + "user": "کاربر", + "joined": "تاریخ عضویت", + "hasPaid": "پرداخت کرده", + "earningsHistory": "تاریخچه درآمد", + "reason": "دلیل" + }, + "support": { + "title": "پشتیبانی", + "newTicket": "تیکت جدید", + "yourTickets": "تیکت‌های شما", + "noTickets": "تیکتی موجود نیست", + "createTicket": "ایجاد تیکت", + "subject": "موضوع", + "subjectPlaceholder": "مشکل را به طور خلاصه توضیح دهید", + "message": "پیام", + "messagePlaceholder": "مشکل خود را به طور کامل توضیح دهید...", + "send": "ارسال", + "sending": "در حال ارسال...", + "creating": "در حال ایجاد...", + "reply": "پاسخ", + "replyPlaceholder": "پاسخ خود را بنویسید...", + "sendReply": "ارسال پاسخ", + "you": "شما", + "supportTeam": "پشتیبانی", + "selectTicket": "یک تیکت انتخاب کنید یا جدید ایجاد کنید", + "created": "ایجاد شده", + "repliesDisabled": "پاسخ‌گویی به این تیکت غیرفعال است", + "status": { + "open": "باز", + "answered": "پاسخ داده شده", + "pending": "در انتظار", + "closed": "بسته" + }, + "attachImage": "پیوست تصویر", + "invalidFileType": "نوع فایل نامعتبر. از JPEG، PNG، GIF یا WebP استفاده کنید.", + "fileTooLarge": "فایل خیلی بزرگ است. حداکثر 10MB.", + "uploadFailed": "آپلود تصویر ناموفق", + "imageLoadFailed": "بارگذاری تصویر ناموفق" + }, + "wheel": { + "title": "چرخ شانس", + "disabled": "چرخ شانس فعلاً در دسترس نیست", + "spinsRemaining": "تعداد چرخش باقی‌مانده امروز", + "choosePayment": "روش پرداخت را انتخاب کنید", + "stars": "Stars", + "telegramStars": "Telegram Stars", + "days": "روز", + "subscriptionDays": "روز اشتراک", + "day": "روز", + "spin": "بچرخان!", + "spinning": "در حال چرخش...", + "history": "تاریخچه", + "recentSpins": "چرخش‌های اخیر", + "congratulations": "تبریک!", + "noLuck": "شانس نیاوردید!", + "oops": "اوه!", + "yourPromoCode": "کد تخفیف شما:", + "close": "بستن", + "errors": { + "networkError": "خطای شبکه رخ داد", + "loadFailed": "بارگذاری تنظیمات چرخ ناموفق", + "dailyLimitReached": "به محدودیت روزانه رسیدید", + "cannotSpin": "فعلاً امکان چرخش نیست", + "insufficientBalance": "موجودی کافی نیست", + "topUpRequired": "برای پرداخت شارژ کنید" + }, + "payWithStars": "پرداخت {stars} Stars", + "processingPayment": "در حال پردازش...", + "starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.", + "starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.", + "noHistory": "هنوز تاریخچه‌ای نیست", + "banner": { + "title": "شانس خود را امتحان کنید!", + "description": "چرخ را بچرخانید و جایزه ببرید: روز اشتراک، موجودی، ترافیک و بیشتر!", + "button": "برو به چرخ" + } + }, + "admin": { + "nav": { + "title": "مدیریت", + "dashboard": "آمار", + "tickets": "تیکت‌ها", + "settings": "تنظیمات", + "apps": "برنامه‌ها", + "wheel": "چرخ", + "tariffs": "تعرفه‌ها", + "servers": "سرورها" + }, + "panel": { + "title": "پنل مدیریت", + "subtitle": "مدیریت سیستم", + "dashboardDesc": "آمار و مانیتورینگ سیستم", + "ticketsDesc": "مدیریت تیکت‌های کاربران", + "settingsDesc": "تنظیمات و پارامترهای سیستم", + "appsDesc": "مدیریت برنامه‌های اتصال", + "wheelDesc": "تنظیم چرخ شانس و جوایز", + "tariffsDesc": "مدیریت طرح‌های تعرفه", + "serversDesc": "تنظیم سرورهای VPN" + }, + "wheel": { + "title": "تنظیمات چرخ شانس", + "enabled": "فعال", + "disabled": "غیرفعال", + "tabs": { + "settings": "تنظیمات", + "prizes": "جوایز", + "statistics": "آمار" + }, + "settings": { + "enableWheel": "فعال‌سازی چرخ", + "allowSpins": "اجازه چرخش به کاربران", + "costInStars": "هزینه Stars", + "costInDays": "هزینه روز", + "rtpPercent": "درصد RTP", + "dailyLimit": "محدودیت روزانه (0 = نامحدود)", + "minSubDays": "حداقل روز اشتراک برای پرداخت روزانه", + "promoPrefix": "پیشوند کد تخفیف" + }, + "prizes": { + "addPrize": "افزودن جایزه", + "editPrize": "ویرایش جایزه", + "noPrizes": "جایزه‌ای تنظیم نشده. برای فعال‌سازی چرخ جایزه اضافه کنید.", + "deletePrize": "این جایزه حذف شود؟", + "types": { + "subscription_days": "روز اشتراک", + "balance_bonus": "جایزه موجودی", + "traffic_gb": "ترافیک GB", + "promocode": "کد تخفیف", + "nothing": "هیچی" + }, + "fields": { + "type": "نوع", + "displayName": "نام نمایشی", + "value": "مقدار", + "valueKopeks": "ارزش (کوپک، برای RTP)", + "emoji": "ایموجی", + "color": "رنگ", + "active": "فعال", + "worth": "ارزش" + }, + "promo": { + "title": "تنظیمات کد تخفیف", + "balanceBonus": "جایزه موجودی (کوپک)", + "subscriptionDays": "روز اشتراک" + } + }, + "statistics": { + "totalSpins": "کل چرخش‌ها", + "revenue": "درآمد", + "payouts": "پرداخت‌ها", + "actualRtp": "RTP واقعی", + "targetRtp": "هدف", + "prizeDistribution": "توزیع جوایز", + "times": "بار", + "topWins": "برترین برنده‌ها" + } + }, + "settings": { + "title": "تنظیمات سیستم", + "allCategories": "همه دسته‌ها", + "noSettings": "تنظیماتی یافت نشد", + "modified": "تغییر یافته", + "readOnly": "فقط خواندنی", + "reset": "بازنشانی", + "categoriesCount": "دسته", + "settingsCount": "تنظیم", + "expandAll": "باز کردن همه", + "collapseAll": "بستن همه", + "searchPlaceholder": "جستجوی تنظیمات...", + "searchCategoriesPlaceholder": "جستجوی دسته‌ها...", + "noSearchResults": "چیزی یافت نشد", + "example": "مثال" + }, + "apps": { + "title": "مدیریت برنامه‌ها", + "addApp": "افزودن برنامه", + "addFirstApp": "افزودن اولین برنامه", + "noApps": "برنامه‌ای برای این پلتفرم نیست", + "createApp": "ایجاد برنامه", + "editApp": "ویرایش برنامه", + "confirmDelete": "آیا مطمئن هستید که می‌خواهید این برنامه را حذف کنید؟", + "copyTo": "کپی به...", + "appId": "شناسه برنامه", + "appName": "نام", + "urlScheme": "URL Scheme", + "featured": "ویژه", + "base64Encoding": "کدگذاری Base64", + "buttons": "دکمه‌ها", + "button": "دکمه", + "addButton": "افزودن دکمه", + "buttonText": "متن دکمه", + "installDescription": "توضیحات نصب", + "subscriptionDescription": "توضیحات افزودن اشتراک", + "connectDescription": "توضیحات اتصال", + "beforeSubscription": "قبل از افزودن اشتراک", + "afterSubscription": "بعد از افزودن اشتراک", + "stepTitle": "عنوان", + "stepDescription": "توضیحات", + "tabs": { + "basic": "اصلی", + "installation": "نصب", + "subscription": "اشتراک", + "connect": "اتصال", + "additional": "اضافی" + } + }, + "tickets": { + "title": "مدیریت تیکت‌ها", + "total": "کل", + "list": "تیکت‌ها", + "noTickets": "تیکتی نیست", + "selectTicket": "یک تیکت از لیست انتخاب کنید", + "allStatuses": "همه وضعیت‌ها", + "statusOpen": "باز", + "statusPending": "در انتظار", + "statusAnswered": "پاسخ داده شده", + "statusClosed": "بسته", + "from": "از", + "created": "ایجاد شده", + "user": "کاربر", + "you": "شما", + "userLabel": "کاربر", + "adminLabel": "مدیر", + "replyPlaceholder": "پاسخ خود را بنویسید...", + "sendReply": "ارسال پاسخ" + }, + "tariffs": { + "title": "مدیریت تعرفه‌ها", + "subtitle": "پیکربندی پلن‌های تعرفه", + "create": "ایجاد تعرفه", + "edit": "ویرایش تعرفه", + "noTariffs": "تعرفه‌ای نیست", + "name": "نام", + "namePlaceholder": "نام تعرفه را وارد کنید", + "description": "توضیحات", + "descriptionPlaceholder": "توضیحات تعرفه را وارد کنید", + "trafficLimit": "محدودیت ترافیک", + "trafficHint": "0 = ترافیک نامحدود", + "deviceLimit": "محدودیت دستگاه", + "tierLevel": "سطح تعرفه", + "tierHint": "1-10، روی اولویت ارتقا تأثیر می‌گذارد", + "unlimited": "نامحدود", + "devices": "دستگاه", + "servers": "سرور", + "subscriptions": "اشتراک", + "trial": "آزمایشی", + "inactive": "غیرفعال", + "activate": "فعال‌سازی", + "deactivate": "غیرفعال‌سازی", + "toggleTrial": "تغییر آزمایشی", + "delete": "حذف", + "confirmDelete": "تعرفه حذف شود؟", + "confirmDeleteText": "این عمل قابل بازگشت نیست. تعرفه برای همیشه حذف می‌شود.", + "days": "روز", + "tabs": { + "basic": "اصلی", + "prices": "قیمت‌ها", + "servers": "سرورها" + }, + "pricesHint": "قیمت هر دوره را تنظیم کنید", + "serversHint": "سرورهای موجود برای این تعرفه را انتخاب کنید", + "noServers": "سرور موجود نیست" + }, + "servers": { + "title": "مدیریت سرورها", + "subtitle": "پیکربندی سرورهای VPN", + "sync": "همگام‌سازی", + "syncing": "در حال همگام‌سازی...", + "syncNow": "همگام‌سازی الان", + "noServers": "سروری نیست. با RemnaWave همگام‌سازی کنید.", + "edit": "ویرایش سرور", + "originalName": "نام اصلی", + "displayName": "نام نمایشی", + "displayNamePlaceholder": "نام نمایشی را وارد کنید", + "description": "توضیحات", + "descriptionPlaceholder": "توضیحات سرور را وارد کنید", + "countryCode": "کد کشور", + "price": "قیمت", + "priceHint": "هزینه اضافی سرور", + "maxUsers": "حداکثر کاربران", + "sortOrder": "ترتیب", + "unlimited": "نامحدود", + "stats": "آمار", + "currentUsers": "کاربران فعلی", + "activeSubscriptions": "اشتراک‌های فعال", + "usedByTariffs": "استفاده شده توسط تعرفه‌ها", + "trial": "آزمایشی", + "unavailable": "در دسترس نیست", + "full": "پر", + "enable": "فعال", + "disable": "غیرفعال", + "toggleTrial": "تغییر آزمایشی" + } + }, + "adminDashboard": { + "title": "پنل آمار", + "subtitle": "نمای کلی سیستم در لحظه", + "refresh": "بروزرسانی", + "loadError": "بارگیری آمار ناموفق بود", + "stats": { + "usersOnline": "کاربران آنلاین", + "activeSubscriptions": "اشتراک‌های فعال", + "incomeToday": "درآمد امروز", + "incomeMonth": "درآمد ماهانه", + "incomeTotal": "کل درآمد", + "subscriptionIncome": "درآمد اشتراک", + "total": "کل" + }, + "nodes": { + "title": "نودها", + "online": "آنلاین", + "offline": "آفلاین", + "disabled": "غیرفعال", + "noNodes": "نود موجود نیست", + "usersOnline": "آنلاین", + "traffic": "ترافیک", + "enable": "فعال", + "disable": "غیرفعال" + }, + "revenue": { + "title": "درآمد", + "last7Days": "۷ روز اخیر" + }, + "subscriptions": { + "title": "اشتراک‌ها", + "subtitle": "آمار اشتراک", + "active": "فعال", + "trial": "آزمایشی", + "paid": "پرداخت شده", + "expired": "منقضی شده", + "newSubscriptions": "اشتراک‌های جدید", + "today": "امروز", + "week": "هفته", + "month": "ماه", + "conversion": "تبدیل آزمایشی به پولی" + }, + "servers": { + "title": "سرورها", + "total": "کل سرورها", + "available": "در دسترس", + "withConnections": "با اتصال", + "revenue": "درآمد" + } + }, + "profile": { + "title": "پروفایل", + "accountInfo": "اطلاعات حساب", + "telegramId": "شناسه تلگرام", + "username": "نام کاربری", + "name": "نام", + "registeredAt": "تاریخ ثبت نام", + "emailAuth": "احراز هویت ایمیل", + "linkEmailDescription": "ایمیل خود را متصل کنید تا بدون تلگرام وارد شوید. پس از اتصال، ایمیل تایید دریافت خواهید کرد.", + "linkEmail": "اتصال ایمیل", + "emailRequired": "ایمیل الزامی است", + "passwordMinLength": "رمز عبور باید حداقل 8 کاراکتر باشد", + "passwordsMismatch": "رمزهای عبور مطابقت ندارند", + "passwordPlaceholder": "رمز عبور را وارد کنید", + "confirmPasswordPlaceholder": "رمز عبور را تایید کنید", + "passwordHint": "حداقل 8 کاراکتر", + "emailSent": "ایمیل تایید ارسال شد! صندوق خود را بررسی کنید.", + "verified": "تایید شده", + "notVerified": "تایید نشده", + "verificationRequired": "لطفاً ایمیل خود را تایید کنید تا از ورود با ایمیل استفاده کنید.", + "resendVerification": "ارسال مجدد ایمیل تایید", + "verificationResent": "ایمیل تایید مجدداً ارسال شد!", + "canLoginWithEmail": "اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", + "notifications": { + "title": "تنظیمات اعلان", + "subscriptionExpiry": "انقضای اشتراک", + "subscriptionExpiryDesc": "اعلان قبل از انقضای اشتراک", + "daysBeforeExpiry": "روز قبل از انقضا", + "trafficWarning": "هشدار ترافیک", + "trafficWarningDesc": "اعلان نزدیک شدن به محدودیت ترافیک", + "atPercent": "در درصد", + "balanceLow": "موجودی کم", + "balanceLowDesc": "اعلان موجودی کم", + "threshold": "آستانه", + "news": "اخبار", + "newsDesc": "دریافت اخبار و به‌روزرسانی‌ها", + "promoOffers": "پیشنهادات تبلیغاتی", + "promoOffersDesc": "دریافت پیشنهادات ویژه و تخفیف‌ها", + "unavailable": "تنظیمات اعلان در دسترس نیست" + } + }, + "theme": { + "colors": "رنگ‌های تم", + "reset": "بازنشانی", + "accent": "رنگ تأکیدی", + "accentDescription": "رنگ اصلی برند برای دکمه‌ها، لینک‌ها و عناصر تعاملی", + "darkTheme": "تم تیره", + "lightTheme": "تم روشن", + "background": "پس‌زمینه", + "surface": "سطح", + "text": "متن", + "textSecondary": "متن ثانویه", + "statusColors": "رنگ‌های وضعیت", + "success": "موفقیت", + "warning": "هشدار", + "error": "خطا", + "preview": "پیش‌نمایش", + "previewButton": "دکمه اصلی", + "previewSecondary": "دکمه ثانویه", + "light": "تم روشن", + "dark": "تم تیره" + }, + "languages": { + "ru": "Русский", + "en": "English", + "zh": "中文", + "fa": "فارسی" + }, + "contests": { + "title": "مسابقات", + "error": "بارگذاری مسابقات ناموفق", + "noContests": "مسابقه‌ای موجود نیست", + "days": "روز", + "play": "شرکت", + "alreadyPlayed": "قبلاً شرکت کردید", + "enterAnswer": "پاسخ خود را وارد کنید", + "submit": "ارسال", + "imHere": "من اینجام!" + }, + "polls": { + "title": "نظرسنجی‌ها", + "error": "بارگذاری نظرسنجی‌ها ناموفق", + "noPolls": "نظرسنجی موجود نیست", + "questions": "سوال", + "question": "سوال", + "of": "از", + "start": "شروع", + "continue": "ادامه", + "completed": "تکمیل شده", + "reward": "تومان" + }, + "info": { + "title": "اطلاعات", + "faq": "سوالات متداول", + "rules": "قوانین", + "privacy": "حریم خصوصی", + "offer": "شرایط خدمات", + "noFaq": "سوال متداولی موجود نیست", + "noContent": "محتوایی موجود نیست", + "updatedAt": "به‌روزرسانی" + }, + "onboarding": { + "skip": "رد کردن", + "finish": "پایان", + "steps": { + "welcome": { + "title": "خوش آمدید!", + "description": "این کابین شخصی شماست. اینجا می‌توانید اشتراک خود را مدیریت کنید، موجودی شارژ کنید و دستگاه‌ها را متصل کنید." + }, + "connectDevices": { + "title": "اتصال دستگاه‌ها", + "description": "این دکمه را بزنید تا راهنمای اتصال VPN روی دستگاه‌هایتان را دریافت کنید." + }, + "balance": { + "title": "موجودی شما", + "description": "موجودی فعلی شما اینجا نمایش داده می‌شود. برای شارژ کلیک کنید." + }, + "subscription": { + "title": "وضعیت اشتراک", + "description": "اینجا می‌توانید روزهای باقی‌مانده تا انقضای اشتراک را ببینید." + }, + "quickActions": { + "title": "دسترسی سریع", + "description": "از این دکمه‌ها برای دسترسی سریع به امکانات اصلی استفاده کنید: شارژ موجودی، تمدید اشتراک و دعوت دوستان." + } + } + } +} \ No newline at end of file diff --git a/src/locales/ru.json b/src/locales/ru.json new file mode 100644 index 0000000..47deb36 --- /dev/null +++ b/src/locales/ru.json @@ -0,0 +1,788 @@ +{ + "common": { + "loading": "Загрузка...", + "error": "Ошибка", + "success": "Успешно", + "save": "Сохранить", + "cancel": "Отмена", + "confirm": "Подтвердить", + "back": "Назад", + "next": "Далее", + "close": "Закрыть", + "search": "Поиск", + "noData": "Нет данных", + "actions": "Действия", + "yes": "Да", + "no": "Нет", + "or": "или", + "and": "и", + "edit": "Редактировать", + "delete": "Удалить", + "currency": "₽" + }, + "nav": { + "dashboard": "Главная", + "subscription": "Подписка", + "balance": "Баланс", + "referral": "Рефералы", + "support": "Поддержка", + "profile": "Профиль", + "logout": "Выйти", + "contests": "Конкурсы", + "polls": "Опросы", + "info": "Информация", + "wheel": "Колесо удачи" + }, + "auth": { + "login": "Вход", + "register": "Регистрация", + "logout": "Выход", + "email": "Email", + "password": "Пароль", + "confirmPassword": "Подтвердите пароль", + "forgotPassword": "Забыли пароль?", + "resetPassword": "Сбросить пароль", + "loginWithTelegram": "Войти через Telegram", + "loginWithEmail": "Войти по Email", + "noAccount": "Нет аккаунта?", + "hasAccount": "Уже есть аккаунт?", + "registerHint": "Для регистрации по email сначала авторизуйтесь через Telegram, затем привяжите email в настройках.", + "telegramRequired": "Требуется авторизация через Telegram", + "telegramNotConfigured": "Telegram бот не настроен", + "authenticating": "Авторизация...", + "orOpenInApp": "Или откройте бота в приложении", + "loginFailed": "Ошибка входа", + "tryAgain": "Попробовать снова", + "welcomeBack": "Добро пожаловать!", + "loginTitle": "Вход в личный кабинет", + "loginSubtitle": "Войдите через Telegram или используйте email" + }, + "emailVerification": { + "title": "Подтверждение email", + "verifying": "Проверяем email...", + "pleaseWait": "Пожалуйста, подождите пока мы проверяем ваш email.", + "success": "Email подтверждён!", + "successMessage": "Ваш email успешно подтверждён. Теперь вы можете входить с помощью email и пароля.", + "failed": "Ошибка подтверждения", + "goToLogin": "Перейти к входу" + }, + "dashboard": { + "title": "Личный кабинет", + "welcome": "Добро пожаловать, {{name}}!", + "yourSubscription": "Ваша подписка", + "quickActions": "Быстрые действия", + "viewSubscription": "Управление подпиской", + "topUpBalance": "Пополнить баланс", + "inviteFriends": "Пригласить друзей", + "getSupport": "Получить поддержку", + "currentBalance": "Текущий баланс", + "activeUntil": "Активна до", + "noActiveSubscription": "Нет активной подписки", + "devicesUsed": "Устройств: {{used}} из {{total}}", + "trafficUsed": "Трафик: {{used}} из {{total}} ГБ", + "unlimitedTraffic": "Безлимитный трафик" + }, + "subscription": { + "title": "Подписка", + "currentPlan": "Текущий тариф", + "status": "Статус", + "active": "Активна", + "inactive": "Неактивна", + "trialStatus": "Пробный период", + "expired": "Истекла", + "expiresAt": "Действует до", + "daysLeft": "Осталось дней", + "devices": "Устройства", + "servers": "Серверы", + "traffic": "Трафик", + "unlimited": "Безлимит", + "used": "Использовано", + "of": "из", + "renew": "Продлить", + "extend": "Продлить подписку", + "buyTraffic": "Докупить трафик", + "buyDevices": "Докупить устройства", + "renewalOptions": "Варианты продления", + "days": "дней", + "hours": "ч", + "minutes": "м", + "price": "Цена", + "noSubscription": "У вас нет активной подписки", + "getSubscription": "Оформить подписку", + "connectionInfo": "Данные для подключения", + "copyLink": "Копировать ссылку", + "copied": "Скопировано!", + "showQR": "Показать QR-код", + "downloadConfig": "Скачать конфиг", + "trafficUsed": "Использовано трафика", + "timeLeft": "Осталось времени", + "getConfig": "Подключить устройства", + "autoRenewal": "Автопродление", + "daysBeforeExpiry": "дней до окончания", + "selectPeriod": "Выберите период", + "selectTraffic": "Выберите трафик", + "selectServers": "Выберите серверы", + "selectDevices": "Устройства", + "perDevice": "/ устройство", + "summary": "Итого", + "total": "К оплате", + "purchase": "Купить", + "step": "Шаг {{current}} из {{total}}", + "stepPeriod": "Период", + "stepTraffic": "Трафик", + "stepServers": "Серверы", + "stepDevices": "Устройства", + "stepConfirm": "Подтверждение", + "currentTariff": "Текущий", + "from": "от", + "month": "мес", + "insufficientBalance": "Недостаточно средств. Не хватает {{missing}} ₽", + "trial": { + "title": "Бесплатный пробный период", + "description": "Попробуйте наш VPN сервис бесплатно!", + "days": "дней", + "devices": "устройств", + "price": "Стоимость активации", + "activate": "Активировать пробный период", + "unavailable": "Пробный период недоступен" + }, + "connection": { + "title": "Подключить VPN", + "selectDevice": "Выберите устройство", + "selectApp": "Выберите приложение", + "installApp": "1. Установите приложение", + "addSubscription": "2. Добавьте подписку", + "connectVpn": "3. Подключитесь к VPN", + "addToApp": "Добавить в {{appName}}", + "connect": "Подключить", + "noApps": "Нет приложений для этой платформы", + "noSubscription": "Для подключения нужна активная подписка", + "featured": "Рекомендуем", + "yourDevice": "Ваше устройство", + "copyLink": "Скопировать ссылку", + "copied": "Ссылка скопирована!", + "openLink": "Открыть ссылку" + }, + "myDevices": "Мои устройства", + "noDevices": "Нет подключенных устройств", + "deleteDevice": "Удалить устройство", + "deleteAllDevices": "Удалить все", + "confirmDeleteDevice": "Удалить это устройство?", + "confirmDeleteAllDevices": "Удалить все устройства?", + "deviceDeleted": "Устройство удалено", + "allDevicesDeleted": "Все устройства удалены", + "platform": "Платформа", + "model": "Модель", + "connectedAt": "Подключено", + "pause": { + "title": "Пауза подписки", + "paused": "На паузе", + "active": "Активна", + "pauseBtn": "Приостановить", + "resumeBtn": "Возобновить", + "pausedMessage": "Подписка приостановлена", + "resumedMessage": "Подписка возобновлена", + "dailyOnly": "Пауза доступна только для суточных тарифов", + "pausedInfo": "Подписка приостановлена", + "pausedDescription": "Списания остановлены. Подписка будет действовать до", + "nextCharge": "До следующего списания", + "willBeCharged": "Будет списано", + "days_one": "день", + "days_few": "дня", + "days_many": "дней", + "hours": "ч", + "minutes": "м" + }, + "dailyPurchase": { + "costPerDay": "Стоимость в день", + "chargedDaily": "Оплата списывается ежедневно с баланса", + "canPause": "Можно приостановить в любой момент", + "pausedOnLowBalance": "При недостатке баланса подписка будет приостановлена", + "activate": "Активировать за {{price}}" + }, + "switchTariff": { + "title": "Сменить тариф", + "preview": "Предпросмотр", + "currentTariff": "Текущий тариф", + "newTariff": "Новый тариф", + "remainingDays": "Осталось дней", + "upgradeCost": "Доплата", + "free": "Бесплатно", + "switch": "Перейти", + "switched": "Тариф изменён", + "notEnoughBalance": "Недостаточно средств" + }, + "switchTraffic": { + "title": "Изменить трафик", + "currentPackage": "Текущий пакет", + "newPackage": "Новый пакет", + "switch": "Изменить", + "switched": "Трафик изменён" + } + }, + "balance": { + "title": "Баланс", + "currentBalance": "Текущий баланс", + "topUp": "Пополнить", + "topUpBalance": "Пополнение баланса", + "paymentMethods": "Способы оплаты", + "transactionHistory": "История операций", + "noTransactions": "Нет операций", + "date": "Дата", + "type": "Тип", + "description": "Описание", + "amount": "Сумма", + "deposit": "Пополнение", + "withdrawal": "Списание", + "subscriptionPayment": "Оплата подписки", + "referralReward": "Реферальный бонус", + "minAmount": "Мин.", + "maxAmount": "Макс.", + "notAvailable": "Недоступно", + "useBot": "Для пополнения используйте Telegram бота.", + "page": "Страница {{current}} из {{total}}", + "invalidAmount": "Некорректная сумма", + "promocode": { + "title": "Промокод", + "placeholder": "Введите промокод", + "activate": "Активировать", + "activating": "Активация...", + "success": "Промокод успешно активирован!", + "balanceAdded": "На баланс начислено: {{amount}} ₽", + "errors": { + "not_found": "Промокод не найден", + "expired": "Срок действия промокода истёк", + "used": "Промокод уже использован", + "already_used_by_user": "Вы уже использовали этот промокод", + "user_not_found": "Пользователь не найден", + "server_error": "Ошибка сервера" + } + }, + "minAmountError": "Минимальная сумма: {{amount}} ₽", + "maxAmountError": "Максимальная сумма: {{amount}} ₽", + "paymentMethods": { + "yookassa": { + "name": "ЮKassa (Банковская карта)", + "description": "Оплата банковской картой через ЮKassa" + }, + "cryptobot": { + "name": "CryptoBot", + "description": "Оплата криптовалютой через CryptoBot" + }, + "telegram_stars": { + "name": "Telegram Stars", + "description": "Оплата через Telegram Stars" + } + } + }, + "referral": { + "title": "Реферальная программа", + "yourLink": "Ваша реферальная ссылка", + "copyLink": "Копировать ссылку", + "copied": "Скопировано!", + "shareButton": "Поделиться", + "shareMessage": "Заходи в Cabinet по моей ссылке и получай до {{percent}}% кешбэка!", + "shareHint": "Поделитесь ссылкой с друзьями. Когда они зарегистрируются и оплатят, вы получите {{percent}}% комиссии!", + "stats": { + "totalReferrals": "Всего рефералов", + "activeReferrals": "Активных", + "totalEarnings": "Общий заработок", + "commissionRate": "Комиссия" + }, + "terms": { + "title": "Условия программы", + "commission": "Комиссия", + "minTopup": "Мин. пополнение", + "newUserBonus": "Бонус новому пользователю", + "inviterBonus": "Бонус пригласившему" + }, + "yourReferrals": "Ваши рефералы", + "noReferrals": "Пока нет рефералов. Поделитесь ссылкой, чтобы пригласить друзей!", + "user": "Пользователь", + "joined": "Присоединился", + "hasPaid": "Оплатил", + "earningsHistory": "История начислений", + "reason": "Причина" + }, + "support": { + "title": "Поддержка", + "newTicket": "Новый тикет", + "yourTickets": "Ваши обращения", + "noTickets": "Нет обращений", + "createTicket": "Создать обращение", + "subject": "Тема", + "subjectPlaceholder": "Кратко опишите проблему", + "message": "Сообщение", + "messagePlaceholder": "Подробно опишите вашу проблему...", + "send": "Отправить", + "sending": "Отправка...", + "creating": "Создание...", + "reply": "Ответить", + "replyPlaceholder": "Введите ваш ответ...", + "sendReply": "Отправить ответ", + "you": "Вы", + "supportTeam": "Поддержка", + "selectTicket": "Выберите обращение или создайте новое", + "created": "Создано", + "repliesDisabled": "Ответы на это обращение отключены", + "status": { + "open": "Открыт", + "answered": "Отвечен", + "pending": "Ожидает", + "closed": "Закрыт" + }, + "attachImage": "Прикрепить изображение", + "invalidFileType": "Неверный тип файла. Используйте JPEG, PNG, GIF или WebP.", + "fileTooLarge": "Файл слишком большой. Максимальный размер 10МБ.", + "uploadFailed": "Не удалось загрузить изображение", + "imageLoadFailed": "Не удалось загрузить изображение", + "ticketsDisabled": "Тикеты отключены", + "useProfile": "Для получения поддержки перейдите в профиль бота", + "goToProfile": "Перейти в профиль", + "useExternalLink": "Для получения поддержки воспользуйтесь внешней ссылкой", + "openSupport": "Открыть поддержку", + "contactSupport": "Для получения поддержки обратитесь к {username}", + "contactUs": "Связаться с поддержкой" + }, + "wheel": { + "title": "Колесо удачи", + "disabled": "Колесо удачи временно недоступно", + "spinsRemaining": "Осталось вращений сегодня", + "choosePayment": "Выберите способ оплаты", + "stars": "Stars", + "telegramStars": "Telegram Stars", + "days": "дней", + "subscriptionDays": "Дни подписки", + "day": "День", + "spin": "КРУТИТЬ!", + "spinning": "Крутится...", + "history": "История", + "recentSpins": "Последние вращения", + "congratulations": "Поздравляем!", + "noLuck": "Не повезло!", + "oops": "Упс!", + "yourPromoCode": "Ваш промокод:", + "close": "Закрыть", + "errors": { + "networkError": "Произошла ошибка сети", + "loadFailed": "Не удалось загрузить конфигурацию колеса", + "dailyLimitReached": "Достигнут дневной лимит вращений", + "cannotSpin": "Сейчас нельзя крутить", + "insufficientBalance": "Недостаточно средств на балансе", + "topUpRequired": "Пополните баланс для оплаты спина", + "starsNotAvailable": "Оплата Stars недоступна. Откройте приложение через Telegram." + }, + "payWithStars": "Оплатить Stars", + "payWithDays": "Оплатить днями", + "processingPayment": "Обработка...", + "starsPaymentSuccess": "Оплата прошла! Результат спина отправлен в Telegram.", + "starsPaymentSuccessCheckHistory": "Оплата прошла! Проверьте историю или Telegram для результата.", + "starsPaymentFailed": "Ошибка оплаты. Попробуйте еще раз.", + "youWon": "Вы выиграли", + "noPrize": "В этот раз не повезло...", + "noHistory": "История вращений пуста", + "banner": { + "title": "Испытай удачу!", + "description": "Крути колесо и выигрывай призы: дни подписки, баланс, трафик и многое другое!", + "button": "Перейти к колесу" + } + }, + "admin": { + "nav": { + "title": "Админка", + "dashboard": "Статистика", + "tickets": "Тикеты", + "settings": "Настройки", + "apps": "Приложения", + "wheel": "Колесо", + "tariffs": "Тарифы", + "servers": "Серверы" + }, + "panel": { + "title": "Панель администратора", + "subtitle": "Управление системой", + "dashboardDesc": "Статистика и мониторинг системы", + "ticketsDesc": "Обработка обращений пользователей", + "settingsDesc": "Настройки системы и параметры", + "appsDesc": "Управление приложениями для подключения", + "wheelDesc": "Настройка колеса удачи и призов", + "tariffsDesc": "Управление тарифными планами", + "serversDesc": "Настройка VPN серверов" + }, + "wheel": { + "title": "Настройки колеса удачи", + "enabled": "Включено", + "disabled": "Выключено", + "tabs": { + "settings": "Настройки", + "prizes": "Призы", + "statistics": "Статистика" + }, + "settings": { + "enableWheel": "Включить колесо", + "allowSpins": "Разрешить пользователям крутить колесо", + "costInStars": "Стоимость в Stars", + "costInDays": "Стоимость в днях", + "rtpPercent": "RTP (Return to Player) %", + "dailyLimit": "Дневной лимит вращений (0 = безлимит)", + "minSubDays": "Мин. дней подписки для оплаты днями", + "promoPrefix": "Префикс промокодов" + }, + "prizes": { + "addPrize": "Добавить приз", + "editPrize": "Редактировать приз", + "noPrizes": "Призы не настроены. Добавьте призы для активации колеса.", + "deletePrize": "Удалить этот приз?", + "types": { + "subscription_days": "Дни подписки", + "balance_bonus": "Бонус баланса", + "traffic_gb": "Трафик ГБ", + "promocode": "Промокод", + "nothing": "Ничего (пусто)" + }, + "fields": { + "type": "Тип", + "displayName": "Название", + "value": "Значение", + "valueKopeks": "Стоимость в копейках (для RTP)", + "emoji": "Эмодзи", + "color": "Цвет", + "active": "Активен", + "worth": "Стоимость" + }, + "promo": { + "title": "Настройки промокода", + "balanceBonus": "Бонус баланса (копейки)", + "subscriptionDays": "Дни подписки" + } + }, + "statistics": { + "totalSpins": "Всего вращений", + "revenue": "Доход", + "payouts": "Выплаты", + "actualRtp": "Фактический RTP", + "targetRtp": "цель", + "prizeDistribution": "Распределение призов", + "times": "раз", + "topWins": "Топ выигрышей" + } + }, + "settings": { + "title": "Настройки системы", + "allCategories": "Все категории", + "noSettings": "Настройки не найдены", + "modified": "Изменено", + "readOnly": "Только чтение", + "reset": "Сбросить", + "categoriesCount": "категорий", + "settingsCount": "настроек", + "expandAll": "Развернуть все", + "collapseAll": "Свернуть все", + "searchPlaceholder": "Поиск настроек...", + "searchCategoriesPlaceholder": "Поиск категорий...", + "noSearchResults": "Ничего не найдено", + "example": "Пример" + }, + "apps": { + "title": "Управление приложениями", + "addApp": "Добавить приложение", + "addFirstApp": "Добавить первое приложение", + "noApps": "Нет приложений для этой платформы", + "createApp": "Создать приложение", + "editApp": "Редактировать приложение", + "confirmDelete": "Вы уверены, что хотите удалить это приложение?", + "copyTo": "Копировать в...", + "appId": "ID приложения", + "appName": "Название", + "urlScheme": "URL схема", + "featured": "Рекомендованное", + "base64Encoding": "Base64 кодирование", + "buttons": "Кнопки", + "button": "Кнопка", + "addButton": "Добавить кнопку", + "buttonText": "Текст кнопки", + "installDescription": "Описание установки", + "subscriptionDescription": "Описание добавления подписки", + "connectDescription": "Описание подключения", + "beforeSubscription": "До добавления подписки", + "afterSubscription": "После добавления подписки", + "stepTitle": "Заголовок", + "stepDescription": "Описание", + "tabs": { + "basic": "Основное", + "installation": "Установка", + "subscription": "Подписка", + "connect": "Подключение", + "additional": "Дополнительно" + } + }, + "tickets": { + "title": "Управление тикетами", + "total": "Всего", + "list": "Тикеты", + "noTickets": "Нет тикетов", + "selectTicket": "Выберите тикет из списка", + "allStatuses": "Все статусы", + "statusOpen": "Открыт", + "statusPending": "Ожидает", + "statusAnswered": "Отвечен", + "statusClosed": "Закрыт", + "from": "От", + "created": "Создан", + "user": "Пользователь", + "you": "Вы", + "userLabel": "Пользователь", + "adminLabel": "Админ", + "replyPlaceholder": "Введите ваш ответ...", + "sendReply": "Отправить" + }, + "tariffs": { + "title": "Управление тарифами", + "subtitle": "Настройка тарифных планов", + "create": "Создать тариф", + "edit": "Редактировать тариф", + "noTariffs": "Нет тарифов", + "name": "Название", + "namePlaceholder": "Введите название тарифа", + "description": "Описание", + "descriptionPlaceholder": "Введите описание тарифа", + "trafficLimit": "Лимит трафика", + "trafficHint": "0 = безлимитный трафик", + "deviceLimit": "Лимит устройств", + "tierLevel": "Уровень тарифа", + "tierHint": "1-10, влияет на приоритет при переходе", + "unlimited": "Безлимит", + "devices": "устр.", + "servers": "серверов", + "subscriptions": "подписок", + "trial": "Триал", + "inactive": "Неактивен", + "activate": "Активировать", + "deactivate": "Деактивировать", + "toggleTrial": "Переключить триал", + "delete": "Удалить", + "confirmDelete": "Удалить тариф?", + "confirmDeleteText": "Это действие нельзя отменить. Тариф будет удален навсегда.", + "days": "дней", + "tabs": { + "basic": "Основное", + "prices": "Цены", + "servers": "Серверы" + }, + "pricesHint": "Включите нужные периоды и установите цены (в рублях)", + "periodDisabled": "Отключено", + "serversHint": "Выберите серверы, доступные для этого тарифа. Для каждого сервера можно указать индивидуальный лимит трафика.", + "noServers": "Нет доступных серверов", + "serverTrafficLimit": "Лимит трафика", + "useDefault": "по умолчанию" + }, + "servers": { + "title": "Управление серверами", + "subtitle": "Настройка серверов VPN", + "sync": "Синхронизировать", + "syncing": "Синхронизация...", + "syncNow": "Синхронизировать сейчас", + "noServers": "Нет серверов. Синхронизируйте с RemnaWave.", + "edit": "Редактировать сервер", + "originalName": "Оригинальное название", + "displayName": "Отображаемое название", + "displayNamePlaceholder": "Введите название", + "description": "Описание", + "descriptionPlaceholder": "Введите описание сервера", + "countryCode": "Код страны", + "price": "Цена", + "priceHint": "Дополнительная стоимость сервера", + "maxUsers": "Максимум пользователей", + "sortOrder": "Порядок сортировки", + "unlimited": "Без лимита", + "stats": "Статистика", + "currentUsers": "Текущих пользователей", + "activeSubscriptions": "Активных подписок", + "usedByTariffs": "Используется в тарифах", + "trial": "Триал", + "unavailable": "Недоступен", + "full": "Заполнен", + "enable": "Включить", + "disable": "Отключить", + "toggleTrial": "Переключить триал", + "loadError": "Не удалось загрузить сервер" + } + }, + "adminDashboard": { + "title": "Панель статистики", + "subtitle": "Обзор системы в реальном времени", + "refresh": "Обновить", + "loadError": "Не удалось загрузить статистику", + "stats": { + "usersOnline": "Пользователей онлайн", + "activeSubscriptions": "Активных подписок", + "incomeToday": "Доход за сегодня", + "incomeMonth": "Доход за месяц", + "incomeTotal": "Общий доход", + "subscriptionIncome": "Доход от подписок", + "total": "всего" + }, + "nodes": { + "title": "Ноды", + "online": "Онлайн", + "offline": "Оффлайн", + "disabled": "Отключена", + "noNodes": "Нет доступных нод", + "usersOnline": "Онлайн", + "traffic": "Трафик", + "enable": "Включить", + "disable": "Отключить" + }, + "revenue": { + "title": "Доходы", + "last7Days": "За последние 7 дней" + }, + "subscriptions": { + "title": "Подписки", + "subtitle": "Статистика подписок", + "active": "Активных", + "trial": "Пробных", + "paid": "Оплаченных", + "expired": "Истекших", + "newSubscriptions": "Новые подписки", + "today": "Сегодня", + "week": "Неделя", + "month": "Месяц", + "conversion": "Конверсия триал→платные" + }, + "servers": { + "title": "Серверы", + "total": "Всего серверов", + "available": "Доступных", + "withConnections": "С подключениями", + "revenue": "Доход" + } + }, + "profile": { + "title": "Профиль", + "accountInfo": "Информация об аккаунте", + "telegramId": "Telegram ID", + "username": "Имя пользователя", + "name": "Имя", + "registeredAt": "Дата регистрации", + "emailAuth": "Авторизация по Email", + "linkEmailDescription": "Привяжите email для входа без Telegram. После привязки на ваш email придёт письмо для подтверждения.", + "linkEmail": "Привязать Email", + "emailRequired": "Введите email", + "passwordMinLength": "Пароль должен быть минимум 8 символов", + "passwordsMismatch": "Пароли не совпадают", + "passwordPlaceholder": "Введите пароль", + "confirmPasswordPlaceholder": "Подтвердите пароль", + "passwordHint": "Минимум 8 символов", + "emailSent": "Письмо для подтверждения отправлено! Проверьте почту.", + "verified": "Подтверждён", + "notVerified": "Не подтверждён", + "verificationRequired": "Подтвердите email для использования входа по почте.", + "resendVerification": "Отправить письмо повторно", + "verificationResent": "Письмо отправлено повторно!", + "canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.", + "notifications": { + "title": "Настройки уведомлений", + "subscriptionExpiry": "Окончание подписки", + "subscriptionExpiryDesc": "Уведомлять о скором истечении подписки", + "daysBeforeExpiry": "Дней до окончания", + "trafficWarning": "Предупреждение о трафике", + "trafficWarningDesc": "Уведомлять при достижении лимита трафика", + "atPercent": "При использовании", + "balanceLow": "Низкий баланс", + "balanceLowDesc": "Уведомлять о низком балансе", + "threshold": "Порог", + "news": "Новости", + "newsDesc": "Получать новости и обновления сервиса", + "promoOffers": "Промо-предложения", + "promoOffersDesc": "Получать специальные предложения и скидки", + "unavailable": "Настройки уведомлений недоступны" + } + }, + "theme": { + "colors": "Цвета темы", + "reset": "Сбросить", + "accent": "Акцентный цвет", + "accentDescription": "Основной цвет бренда для кнопок, ссылок и интерактивных элементов", + "darkTheme": "Тёмная тема", + "lightTheme": "Светлая тема", + "background": "Фон", + "surface": "Поверхность", + "text": "Текст", + "textSecondary": "Вторичный текст", + "statusColors": "Статусные цвета", + "success": "Успех", + "warning": "Предупреждение", + "error": "Ошибка", + "preview": "Предпросмотр", + "previewButton": "Основная кнопка", + "previewSecondary": "Вторичная кнопка", + "light": "Светлая тема", + "dark": "Тёмная тема" + }, + "languages": { + "ru": "Русский", + "en": "English", + "zh": "中文", + "fa": "فارسی" + }, + "contests": { + "title": "Конкурсы", + "error": "Ошибка загрузки конкурсов", + "noContests": "Нет доступных конкурсов", + "days": "дней", + "play": "Участвовать", + "alreadyPlayed": "Уже участвовали", + "enterAnswer": "Введите ответ", + "submit": "Отправить", + "imHere": "Я здесь!" + }, + "polls": { + "title": "Опросы", + "error": "Ошибка загрузки опросов", + "noPolls": "Нет доступных опросов", + "questions": "вопросов", + "question": "Вопрос", + "of": "из", + "start": "Начать", + "continue": "Продолжить", + "completed": "Завершён", + "reward": "₽" + }, + "info": { + "title": "Информация", + "faq": "FAQ", + "rules": "Правила", + "privacy": "Конфиденциальность", + "offer": "Оферта", + "noFaq": "Нет вопросов и ответов", + "noContent": "Нет контента", + "updatedAt": "Обновлено" + }, + "onboarding": { + "skip": "Пропустить", + "finish": "Готово", + "steps": { + "welcome": { + "title": "Добро пожаловать!", + "description": "Это ваш личный кабинет. Здесь вы можете управлять подпиской, пополнять баланс и подключать устройства." + }, + "connectDevices": { + "title": "Подключите устройства", + "description": "Нажмите эту кнопку, чтобы получить инструкции по подключению VPN на ваших устройствах." + }, + "balance": { + "title": "Ваш баланс", + "description": "Здесь отображается ваш текущий баланс. Нажмите, чтобы пополнить его." + }, + "subscription": { + "title": "Статус подписки", + "description": "Здесь вы видите сколько дней осталось до окончания подписки." + }, + "quickActions": { + "title": "Быстрые действия", + "description": "Используйте эти кнопки для быстрого доступа к основным функциям: пополнению баланса, продлению подписки и приглашению друзей." + } + } + } +} diff --git a/src/locales/zh.json b/src/locales/zh.json new file mode 100644 index 0000000..e7fc726 --- /dev/null +++ b/src/locales/zh.json @@ -0,0 +1,712 @@ +{ + "common": { + "loading": "加载中...", + "error": "错误", + "success": "成功", + "save": "保存", + "cancel": "取消", + "confirm": "确认", + "back": "返回", + "next": "下一步", + "close": "关闭", + "search": "搜索", + "noData": "暂无数据", + "actions": "操作", + "yes": "是", + "no": "否", + "or": "或", + "and": "和", + "edit": "编辑", + "delete": "删除", + "currency": "¥" + }, + "nav": { + "dashboard": "首页", + "subscription": "订阅", + "balance": "余额", + "referral": "推荐", + "support": "支持", + "profile": "个人资料", + "logout": "退出", + "contests": "活动", + "polls": "问卷", + "info": "信息", + "wheel": "幸运转盘" + }, + "auth": { + "login": "登录", + "register": "注册", + "logout": "退出", + "email": "邮箱", + "password": "密码", + "confirmPassword": "确认密码", + "forgotPassword": "忘记密码?", + "resetPassword": "重置密码", + "loginWithTelegram": "通过Telegram登录", + "loginWithEmail": "通过邮箱登录", + "noAccount": "没有账户?", + "hasAccount": "已有账户?", + "registerHint": "要通过邮箱注册,请先通过Telegram登录,然后在设置中绑定邮箱。", + "telegramRequired": "需要Telegram授权", + "telegramNotConfigured": "Telegram机器人未配置", + "authenticating": "正在验证...", + "orOpenInApp": "或在应用中打开机器人", + "loginFailed": "登录失败", + "tryAgain": "重试", + "welcomeBack": "欢迎回来!", + "loginTitle": "登录个人中心", + "loginSubtitle": "通过Telegram或邮箱登录" + }, + "emailVerification": { + "title": "邮箱验证", + "verifying": "正在验证邮箱...", + "pleaseWait": "请稍候,我们正在验证您的邮箱。", + "success": "邮箱已验证!", + "successMessage": "您的邮箱已成功验证。现在您可以使用邮箱和密码登录。", + "failed": "验证失败", + "goToLogin": "前往登录" + }, + "dashboard": { + "title": "个人中心", + "welcome": "欢迎,{{name}}!", + "yourSubscription": "您的订阅", + "quickActions": "快捷操作", + "viewSubscription": "管理订阅", + "topUpBalance": "充值余额", + "inviteFriends": "邀请好友", + "getSupport": "获取支持", + "currentBalance": "当前余额", + "activeUntil": "有效期至", + "noActiveSubscription": "无有效订阅", + "devicesUsed": "设备:{{used}} / {{total}}", + "trafficUsed": "流量:{{used}} / {{total}} GB", + "unlimitedTraffic": "无限流量" + }, + "subscription": { + "title": "订阅", + "currentPlan": "当前套餐", + "status": "状态", + "active": "有效", + "inactive": "无效", + "trialStatus": "试用期", + "expired": "已过期", + "expiresAt": "到期时间", + "daysLeft": "剩余天数", + "devices": "设备", + "servers": "服务器", + "traffic": "流量", + "unlimited": "无限", + "used": "已使用", + "of": "/", + "renew": "续费", + "extend": "延长订阅", + "buyTraffic": "购买流量", + "buyDevices": "增加设备", + "renewalOptions": "续费选项", + "days": "天", + "hours": "小时", + "minutes": "分钟", + "price": "价格", + "noSubscription": "您没有有效订阅", + "getSubscription": "获取订阅", + "connectionInfo": "连接信息", + "copyLink": "复制链接", + "copied": "已复制!", + "showQR": "显示二维码", + "downloadConfig": "下载配置", + "trafficUsed": "已用流量", + "timeLeft": "剩余时间", + "getConfig": "连接设备", + "autoRenewal": "自动续费", + "daysBeforeExpiry": "到期前天数", + "selectPeriod": "选择时长", + "selectTraffic": "选择流量", + "selectServers": "选择服务器", + "selectDevices": "选择设备", + "perDevice": "/ 设备", + "summary": "摘要", + "total": "总计", + "purchase": "购买", + "step": "第 {{current}} 步,共 {{total}} 步", + "stepPeriod": "时长", + "stepTraffic": "流量", + "stepServers": "服务器", + "stepDevices": "设备", + "stepConfirm": "确认", + "trial": { + "title": "免费试用", + "description": "免费试用我们的VPN服务!", + "days": "天", + "devices": "设备", + "price": "激活费用", + "activate": "激活免费试用", + "unavailable": "试用不可用" + }, + "connection": { + "title": "连接VPN", + "selectDevice": "选择您的设备", + "selectApp": "选择应用", + "installApp": "1. 安装应用", + "addSubscription": "2. 添加订阅", + "connectVpn": "3. 连接VPN", + "addToApp": "添加到 {{appName}}", + "connect": "连接", + "noApps": "此平台暂无可用应用", + "noSubscription": "需要有效订阅才能连接", + "featured": "推荐", + "yourDevice": "您的设备", + "copyLink": "复制订阅链接", + "copied": "链接已复制!", + "openLink": "打开链接" + } + }, + "balance": { + "title": "余额", + "currentBalance": "当前余额", + "topUp": "充值", + "topUpBalance": "充值余额", + "paymentMethods": "支付方式", + "transactionHistory": "交易记录", + "noTransactions": "暂无交易", + "date": "日期", + "type": "类型", + "description": "描述", + "amount": "金额", + "deposit": "充值", + "withdrawal": "支出", + "subscriptionPayment": "订阅付款", + "referralReward": "推荐奖励", + "minAmount": "最小", + "maxAmount": "最大", + "notAvailable": "不可用", + "useBot": "请使用Telegram机器人充值。", + "page": "第 {{current}} 页,共 {{total}} 页", + "invalidAmount": "金额无效", + "promocode": { + "title": "优惠码", + "placeholder": "输入优惠码", + "activate": "激活", + "activating": "激活中...", + "success": "优惠码激活成功!", + "balanceAdded": "已添加余额:{{amount}}", + "errors": { + "not_found": "优惠码不存在", + "expired": "优惠码已过期", + "used": "优惠码已被使用", + "already_used_by_user": "您已使用过此优惠码", + "user_not_found": "用户不存在", + "server_error": "服务器错误" + } + }, + "minAmountError": "最小金额:{{amount}}", + "maxAmountError": "最大金额:{{amount}}", + "paymentMethods": { + "yookassa": { + "name": "银行卡", + "description": "通过银行卡支付" + }, + "cryptobot": { + "name": "CryptoBot", + "description": "通过CryptoBot加密货币支付" + }, + "telegram_stars": { + "name": "Telegram Stars", + "description": "通过Telegram Stars支付" + } + } + }, + "referral": { + "title": "推荐计划", + "yourLink": "您的推荐链接", + "copyLink": "复制链接", + "copied": "已复制!", + "shareButton": "分享", + "shareMessage": "通过我的链接加入 Cabinet,获取高达 {{percent}}% 返现!", + "shareHint": "分享链接给朋友。当他们注册并付款后,您将获得 {{percent}}% 佣金!", + "stats": { + "totalReferrals": "总推荐数", + "activeReferrals": "活跃", + "totalEarnings": "总收益", + "commissionRate": "佣金比例" + }, + "terms": { + "title": "计划条款", + "commission": "佣金", + "minTopup": "最低充值", + "newUserBonus": "新用户奖励", + "inviterBonus": "邀请人奖励" + }, + "yourReferrals": "您的推荐", + "noReferrals": "暂无推荐。分享链接邀请好友!", + "user": "用户", + "joined": "加入时间", + "hasPaid": "已付款", + "earningsHistory": "收益记录", + "reason": "原因" + }, + "support": { + "title": "支持", + "newTicket": "新工单", + "yourTickets": "您的工单", + "noTickets": "暂无工单", + "createTicket": "创建工单", + "subject": "主题", + "subjectPlaceholder": "简要描述问题", + "message": "消息", + "messagePlaceholder": "详细描述您的问题...", + "send": "发送", + "sending": "发送中...", + "creating": "创建中...", + "reply": "回复", + "replyPlaceholder": "输入您的回复...", + "sendReply": "发送回复", + "you": "您", + "supportTeam": "客服", + "selectTicket": "选择工单或创建新工单", + "created": "创建时间", + "repliesDisabled": "此工单已禁止回复", + "status": { + "open": "待处理", + "answered": "已回复", + "pending": "等待中", + "closed": "已关闭" + }, + "attachImage": "附加图片", + "invalidFileType": "文件类型无效。请使用JPEG、PNG、GIF或WebP。", + "fileTooLarge": "文件过大。最大10MB。", + "uploadFailed": "图片上传失败", + "imageLoadFailed": "图片加载失败" + }, + "wheel": { + "title": "幸运转盘", + "disabled": "幸运转盘暂时不可用", + "spinsRemaining": "今日剩余次数", + "choosePayment": "选择支付方式", + "stars": "Stars", + "telegramStars": "Telegram Stars", + "days": "天", + "subscriptionDays": "订阅天数", + "day": "天", + "spin": "开始抽奖!", + "spinning": "抽奖中...", + "history": "历史", + "recentSpins": "最近抽奖", + "congratulations": "恭喜!", + "noLuck": "未中奖!", + "oops": "哎呀!", + "yourPromoCode": "您的优惠码:", + "close": "关闭", + "errors": { + "networkError": "网络错误", + "loadFailed": "加载转盘配置失败", + "dailyLimitReached": "已达每日限制", + "cannotSpin": "当前无法抽奖", + "insufficientBalance": "余额不足", + "topUpRequired": "请充值以支付抽奖费用" + }, + "payWithStars": "支付 {stars} Stars", + "processingPayment": "处理中...", + "starsPaymentSuccess": "支付成功!抽奖结果已发送到Telegram。", + "starsPaymentFailed": "支付失败。请重试。", + "noHistory": "暂无抽奖记录", + "banner": { + "title": "试试运气!", + "description": "转动转盘赢取奖品:订阅天数、余额、流量等!", + "button": "前往转盘" + } + }, + "admin": { + "nav": { + "title": "管理", + "dashboard": "统计", + "tickets": "工单", + "settings": "设置", + "apps": "应用", + "wheel": "转盘", + "tariffs": "套餐", + "servers": "服务器" + }, + "panel": { + "title": "管理面板", + "subtitle": "系统管理", + "dashboardDesc": "统计和系统监控", + "ticketsDesc": "处理用户工单", + "settingsDesc": "系统设置和参数", + "appsDesc": "管理连接应用", + "wheelDesc": "配置幸运转盘和奖品", + "tariffsDesc": "管理套餐计划", + "serversDesc": "配置VPN服务器" + }, + "wheel": { + "title": "幸运转盘设置", + "enabled": "已启用", + "disabled": "已禁用", + "tabs": { + "settings": "设置", + "prizes": "奖品", + "statistics": "统计" + }, + "settings": { + "enableWheel": "启用转盘", + "allowSpins": "允许用户抽奖", + "costInStars": "Stars费用", + "costInDays": "天数费用", + "rtpPercent": "RTP (返还率) %", + "dailyLimit": "每日限制 (0 = 无限)", + "minSubDays": "天数支付最低订阅天数", + "promoPrefix": "优惠码前缀" + }, + "prizes": { + "addPrize": "添加奖品", + "editPrize": "编辑奖品", + "noPrizes": "未配置奖品。添加奖品以启用转盘。", + "deletePrize": "删除此奖品?", + "types": { + "subscription_days": "订阅天数", + "balance_bonus": "余额奖励", + "traffic_gb": "流量 GB", + "promocode": "优惠码", + "nothing": "无奖品" + }, + "fields": { + "type": "类型", + "displayName": "显示名称", + "value": "数值", + "valueKopeks": "价值(戈比,用于RTP)", + "emoji": "表情", + "color": "颜色", + "active": "启用", + "worth": "价值" + }, + "promo": { + "title": "优惠码设置", + "balanceBonus": "余额奖励(戈比)", + "subscriptionDays": "订阅天数" + } + }, + "statistics": { + "totalSpins": "总抽奖次数", + "revenue": "收入", + "payouts": "支出", + "actualRtp": "实际RTP", + "targetRtp": "目标", + "prizeDistribution": "奖品分布", + "times": "次", + "topWins": "最高奖品" + } + }, + "settings": { + "title": "系统设置", + "allCategories": "所有分类", + "noSettings": "未找到设置", + "modified": "已修改", + "readOnly": "只读", + "reset": "重置", + "categoriesCount": "个分类", + "settingsCount": "个设置", + "expandAll": "展开全部", + "collapseAll": "折叠全部", + "searchPlaceholder": "搜索设置...", + "searchCategoriesPlaceholder": "搜索分类...", + "noSearchResults": "未找到结果", + "example": "示例" + }, + "apps": { + "title": "应用管理", + "addApp": "添加应用", + "addFirstApp": "添加第一个应用", + "noApps": "此平台暂无应用", + "createApp": "创建应用", + "editApp": "编辑应用", + "confirmDelete": "确定要删除此应用吗?", + "copyTo": "复制到...", + "appId": "应用ID", + "appName": "名称", + "urlScheme": "URL协议", + "featured": "推荐", + "base64Encoding": "Base64编码", + "buttons": "按钮", + "button": "按钮", + "addButton": "添加按钮", + "buttonText": "按钮文本", + "installDescription": "安装说明", + "subscriptionDescription": "添加订阅说明", + "connectDescription": "连接说明", + "beforeSubscription": "添加订阅前", + "afterSubscription": "添加订阅后", + "stepTitle": "标题", + "stepDescription": "描述", + "tabs": { + "basic": "基本", + "installation": "安装", + "subscription": "订阅", + "connect": "连接", + "additional": "附加" + } + }, + "tickets": { + "title": "工单管理", + "total": "总计", + "list": "工单", + "noTickets": "暂无工单", + "selectTicket": "从列表中选择工单", + "allStatuses": "所有状态", + "statusOpen": "待处理", + "statusPending": "等待中", + "statusAnswered": "已回复", + "statusClosed": "已关闭", + "from": "来自", + "created": "创建时间", + "user": "用户", + "you": "您", + "userLabel": "用户", + "adminLabel": "管理员", + "replyPlaceholder": "输入您的回复...", + "sendReply": "发送回复" + }, + "tariffs": { + "title": "套餐管理", + "subtitle": "配置套餐计划", + "create": "创建套餐", + "edit": "编辑套餐", + "noTariffs": "暂无套餐", + "name": "名称", + "namePlaceholder": "输入套餐名称", + "description": "描述", + "descriptionPlaceholder": "输入套餐描述", + "trafficLimit": "流量限制", + "trafficHint": "0 = 无限流量", + "deviceLimit": "设备限制", + "tierLevel": "套餐等级", + "tierHint": "1-10,影响升级优先级", + "unlimited": "无限", + "devices": "设备", + "servers": "服务器", + "subscriptions": "订阅", + "trial": "试用", + "inactive": "未激活", + "activate": "激活", + "deactivate": "停用", + "toggleTrial": "切换试用", + "delete": "删除", + "confirmDelete": "删除套餐?", + "confirmDeleteText": "此操作不可撤销。套餐将被永久删除。", + "days": "天", + "tabs": { + "basic": "基本", + "prices": "价格", + "servers": "服务器" + }, + "pricesHint": "设置每个周期的价格", + "serversHint": "选择此套餐可用的服务器", + "noServers": "暂无可用服务器" + }, + "servers": { + "title": "服务器管理", + "subtitle": "配置VPN服务器", + "sync": "同步", + "syncing": "同步中...", + "syncNow": "立即同步", + "noServers": "暂无服务器。请与RemnaWave同步。", + "edit": "编辑服务器", + "originalName": "原始名称", + "displayName": "显示名称", + "displayNamePlaceholder": "输入显示名称", + "description": "描述", + "descriptionPlaceholder": "输入服务器描述", + "countryCode": "国家代码", + "price": "价格", + "priceHint": "服务器附加费用", + "maxUsers": "最大用户数", + "sortOrder": "排序", + "unlimited": "无限制", + "stats": "统计", + "currentUsers": "当前用户", + "activeSubscriptions": "活跃订阅", + "usedByTariffs": "套餐使用", + "trial": "试用", + "unavailable": "不可用", + "full": "已满", + "enable": "启用", + "disable": "禁用", + "toggleTrial": "切换试用" + } + }, + "adminDashboard": { + "title": "统计面板", + "subtitle": "实时系统概览", + "refresh": "刷新", + "loadError": "加载统计失败", + "stats": { + "usersOnline": "在线用户", + "activeSubscriptions": "活跃订阅", + "incomeToday": "今日收入", + "incomeMonth": "本月收入", + "incomeTotal": "总收入", + "subscriptionIncome": "订阅收入", + "total": "总计" + }, + "nodes": { + "title": "节点", + "online": "在线", + "offline": "离线", + "disabled": "已禁用", + "noNodes": "暂无可用节点", + "usersOnline": "在线", + "traffic": "流量", + "enable": "启用", + "disable": "禁用" + }, + "revenue": { + "title": "收入", + "last7Days": "最近7天" + }, + "subscriptions": { + "title": "订阅", + "subtitle": "订阅统计", + "active": "活跃", + "trial": "试用", + "paid": "付费", + "expired": "过期", + "newSubscriptions": "新订阅", + "today": "今日", + "week": "本周", + "month": "本月", + "conversion": "试用转付费率" + }, + "servers": { + "title": "服务器", + "total": "服务器总数", + "available": "可用", + "withConnections": "有连接", + "revenue": "收入" + } + }, + "profile": { + "title": "个人资料", + "accountInfo": "账户信息", + "telegramId": "Telegram ID", + "username": "用户名", + "name": "姓名", + "registeredAt": "注册时间", + "emailAuth": "邮箱认证", + "linkEmailDescription": "绑定邮箱以便无需Telegram登录。绑定后将收到验证邮件。", + "linkEmail": "绑定邮箱", + "emailRequired": "请输入邮箱", + "passwordMinLength": "密码至少8个字符", + "passwordsMismatch": "密码不匹配", + "passwordPlaceholder": "输入密码", + "confirmPasswordPlaceholder": "确认密码", + "passwordHint": "至少8个字符", + "emailSent": "验证邮件已发送!请查收。", + "verified": "已验证", + "notVerified": "未验证", + "verificationRequired": "请验证邮箱以使用邮箱登录。", + "resendVerification": "重新发送验证邮件", + "verificationResent": "验证邮件已重新发送!", + "canLoginWithEmail": "现在您可以使用邮箱和密码登录。", + "notifications": { + "title": "通知设置", + "subscriptionExpiry": "订阅到期", + "subscriptionExpiryDesc": "订阅即将到期时通知", + "daysBeforeExpiry": "到期前天数", + "trafficWarning": "流量警告", + "trafficWarningDesc": "接近流量限制时通知", + "atPercent": "使用率", + "balanceLow": "余额不足", + "balanceLowDesc": "余额不足时通知", + "threshold": "阈值", + "news": "新闻", + "newsDesc": "接收新闻和服务更新", + "promoOffers": "促销活动", + "promoOffersDesc": "接收特别优惠和折扣", + "unavailable": "通知设置不可用" + } + }, + "theme": { + "colors": "主题颜色", + "reset": "重置", + "accent": "主题色", + "accentDescription": "用于按钮、链接和交互元素的主品牌颜色", + "darkTheme": "深色主题", + "lightTheme": "浅色主题", + "background": "背景", + "surface": "表面", + "text": "文字", + "textSecondary": "次要文字", + "statusColors": "状态颜色", + "success": "成功", + "warning": "警告", + "error": "错误", + "preview": "预览", + "previewButton": "主要按钮", + "previewSecondary": "次要按钮", + "light": "浅色主题", + "dark": "深色主题" + }, + "languages": { + "ru": "Русский", + "en": "English", + "zh": "中文", + "fa": "فارسی" + }, + "contests": { + "title": "活动", + "error": "加载活动失败", + "noContests": "暂无活动", + "days": "天", + "play": "参与", + "alreadyPlayed": "已参与", + "enterAnswer": "输入答案", + "submit": "提交", + "imHere": "我在这里!" + }, + "polls": { + "title": "问卷", + "error": "加载问卷失败", + "noPolls": "暂无问卷", + "questions": "个问题", + "question": "问题", + "of": "/", + "start": "开始", + "continue": "继续", + "completed": "已完成", + "reward": "¥" + }, + "info": { + "title": "信息", + "faq": "常见问题", + "rules": "规则", + "privacy": "隐私政策", + "offer": "服务条款", + "noFaq": "暂无常见问题", + "noContent": "暂无内容", + "updatedAt": "更新时间" + }, + "onboarding": { + "skip": "跳过", + "finish": "完成", + "steps": { + "welcome": { + "title": "欢迎!", + "description": "这是您的个人中心。在这里您可以管理订阅、充值余额和连接设备。" + }, + "connectDevices": { + "title": "连接设备", + "description": "点击此按钮获取在您的设备上连接VPN的说明。" + }, + "balance": { + "title": "您的余额", + "description": "这里显示您的当前余额。点击可充值。" + }, + "subscription": { + "title": "订阅状态", + "description": "在这里您可以看到订阅的剩余天数。" + }, + "quickActions": { + "title": "快捷操作", + "description": "使用这些按钮快速访问主要功能:充值余额、延长订阅和邀请好友。" + } + } + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..53f0e75 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import { ThemeColorsProvider } from './providers/ThemeColorsProvider' +import './i18n' +import './styles/globals.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + , +) diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx new file mode 100644 index 0000000..a84b54a --- /dev/null +++ b/src/pages/AdminApps.tsx @@ -0,0 +1,629 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { adminAppsApi, AppDefinition, LocalizedText, AppStep, AppButton } from '../api/adminApps' + +// Icons +const AppsIcon = () => ( + + + +) + +const PlusIcon = () => ( + + + +) + +const EditIcon = () => ( + + + +) + +const TrashIcon = () => ( + + + +) + +const ChevronUpIcon = () => ( + + + +) + +const ChevronDownIcon = () => ( + + + +) + +const CopyIcon = () => ( + + + +) + +const StarIcon = ({ filled }: { filled: boolean }) => ( + + + +) + +const PLATFORM_LABELS: Record = { + ios: 'iOS', + android: 'Android', + macos: 'macOS', + windows: 'Windows', + linux: 'Linux', + androidTV: 'Android TV', + appleTV: 'Apple TV', +} + +const PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV'] + +// Helper to create empty localized text +const emptyLocalizedText = (): LocalizedText => ({ + en: '', + ru: '', + zh: '', + fa: '', +}) + +// Helper to create empty app step +const emptyAppStep = (): AppStep => ({ + description: emptyLocalizedText(), + buttons: [], +}) + +// Helper to create empty app +const createEmptyApp = (platform: string): AppDefinition => ({ + id: `new-app-${platform}-${Date.now()}`, + name: '', + isFeatured: false, + urlScheme: '', + installationStep: emptyAppStep(), + addSubscriptionStep: emptyAppStep(), + connectAndUseStep: emptyAppStep(), +}) + +interface AppEditorModalProps { + app: AppDefinition + platform: string + isNew: boolean + onSave: (app: AppDefinition) => void + onClose: () => void +} + +function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModalProps) { + const { t } = useTranslation() + const [editedApp, setEditedApp] = useState({ ...app }) + const [activeTab, setActiveTab] = useState<'basic' | 'installation' | 'subscription' | 'connect' | 'additional'>('basic') + + const updateField = (field: K, value: AppDefinition[K]) => { + setEditedApp((prev) => ({ ...prev, [field]: value })) + } + + const updateLocalizedText = ( + stepKey: 'installationStep' | 'addSubscriptionStep' | 'connectAndUseStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', + field: 'description' | 'title', + lang: keyof LocalizedText, + value: string + ) => { + setEditedApp((prev) => { + const step = prev[stepKey] || emptyAppStep() + const fieldValue = step[field] || emptyLocalizedText() + return { + ...prev, + [stepKey]: { + ...step, + [field]: { ...fieldValue, [lang]: value }, + }, + } + }) + } + + const updateButtons = ( + stepKey: 'installationStep' | 'addSubscriptionStep' | 'connectAndUseStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', + buttons: AppButton[] + ) => { + setEditedApp((prev) => { + const step = prev[stepKey] || emptyAppStep() + return { + ...prev, + [stepKey]: { ...step, buttons }, + } + }) + } + + const addButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => { + const step = editedApp[stepKey] || emptyAppStep() + const buttons = step.buttons || [] + updateButtons(stepKey, [...buttons, { buttonLink: '', buttonText: emptyLocalizedText() }]) + } + + const removeButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', index: number) => { + const step = editedApp[stepKey] || emptyAppStep() + const buttons = step.buttons || [] + updateButtons(stepKey, buttons.filter((_, i) => i !== index)) + } + + const updateButton = ( + stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', + index: number, + field: 'buttonLink' | 'buttonText', + value: string | LocalizedText + ) => { + const step = editedApp[stepKey] || emptyAppStep() + const buttons = [...(step.buttons || [])] + buttons[index] = { ...buttons[index], [field]: value } + updateButtons(stepKey, buttons) + } + + const renderLocalizedTextInputs = ( + stepKey: 'installationStep' | 'addSubscriptionStep' | 'connectAndUseStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', + field: 'description' | 'title', + label: string + ) => { + const step = editedApp[stepKey] + const value = step?.[field] || emptyLocalizedText() + + return ( +
+ + {(['en', 'ru'] as const).map((lang) => ( +
+ {lang} +