Add files via upload

This commit is contained in:
Egor
2026-01-15 19:18:17 +03:00
committed by GitHub
parent b118008cdc
commit 7be6b5c0ae
70 changed files with 21835 additions and 0 deletions

227
src/App.tsx Normal file
View File

@@ -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 <PageLoader variant="dark" />
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <Layout>{children}</Layout>
}
function AdminRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading, isAdmin } = useAuthStore()
if (isLoading) {
return <PageLoader variant="light" />
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
if (!isAdmin) {
return <Navigate to="/" replace />
}
return <Layout>{children}</Layout>
}
function App() {
return (
<Routes>
{/* Public routes */}
<Route path="/login" element={<Login />} />
<Route path="/auth/telegram/callback" element={<TelegramCallback />} />
<Route path="/auth/telegram" element={<TelegramRedirect />} />
<Route path="/tg" element={<TelegramRedirect />} />
<Route path="/connect" element={<DeepLinkRedirect />} />
<Route path="/add" element={<DeepLinkRedirect />} />
<Route path="/verify-email" element={<VerifyEmail />} />
{/* Protected routes */}
<Route
path="/"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/subscription"
element={
<ProtectedRoute>
<Subscription />
</ProtectedRoute>
}
/>
<Route
path="/balance"
element={
<ProtectedRoute>
<Balance />
</ProtectedRoute>
}
/>
<Route
path="/referral"
element={
<ProtectedRoute>
<Referral />
</ProtectedRoute>
}
/>
<Route
path="/support"
element={
<ProtectedRoute>
<Support />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
<Route
path="/contests"
element={
<ProtectedRoute>
<Contests />
</ProtectedRoute>
}
/>
<Route
path="/polls"
element={
<ProtectedRoute>
<Polls />
</ProtectedRoute>
}
/>
<Route
path="/info"
element={
<ProtectedRoute>
<Info />
</ProtectedRoute>
}
/>
<Route
path="/wheel"
element={
<ProtectedRoute>
<Wheel />
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route
path="/admin"
element={
<AdminRoute>
<AdminPanel />
</AdminRoute>
}
/>
<Route
path="/admin/tickets"
element={
<AdminRoute>
<AdminTickets />
</AdminRoute>
}
/>
<Route
path="/admin/settings"
element={
<AdminRoute>
<AdminSettings />
</AdminRoute>
}
/>
<Route
path="/admin/apps"
element={
<AdminRoute>
<AdminApps />
</AdminRoute>
}
/>
<Route
path="/admin/wheel"
element={
<AdminRoute>
<AdminWheel />
</AdminRoute>
}
/>
<Route
path="/admin/tariffs"
element={
<AdminRoute>
<AdminTariffs />
</AdminRoute>
}
/>
<Route
path="/admin/servers"
element={
<AdminRoute>
<AdminServers />
</AdminRoute>
}
/>
<Route
path="/admin/dashboard"
element={
<AdminRoute>
<AdminDashboard />
</AdminRoute>
}
/>
{/* Catch all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default App

206
src/api/admin.ts Normal file
View File

@@ -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<AdminTicketStats> => {
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<AdminTicketListResponse> => {
const response = await apiClient.get('/cabinet/admin/tickets', { params })
return response.data
},
// Get single ticket with messages
getTicket: async (ticketId: number): Promise<AdminTicketDetail> => {
const response = await apiClient.get(`/cabinet/admin/tickets/${ticketId}`)
return response.data
},
// Reply to ticket
replyToTicket: async (ticketId: number, message: string): Promise<AdminTicketMessage> => {
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message })
return response.data
},
// Update ticket status
updateTicketStatus: async (ticketId: number, status: string): Promise<AdminTicketDetail> => {
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/status`, { status })
return response.data
},
// Update ticket priority
updateTicketPriority: async (ticketId: number, priority: string): Promise<AdminTicketDetail> => {
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<DashboardStats> => {
const response = await apiClient.get('/cabinet/admin/stats/dashboard')
return response.data
},
// Get nodes status
getNodesStatus: async (): Promise<NodesOverview> => {
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
},
}

119
src/api/adminApps.ts Normal file
View File

@@ -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<string, AppDefinition[]>
}
export const adminAppsApi = {
// Get full app config
getConfig: async (): Promise<AppConfigResponse> => {
const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps')
return response.data
},
// Get available platforms
getPlatforms: async (): Promise<string[]> => {
const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms')
return response.data
},
// Get apps for a platform
getPlatformApps: async (platform: string): Promise<AppDefinition[]> => {
const response = await apiClient.get<AppDefinition[]>(`/cabinet/admin/apps/platforms/${platform}`)
return response.data
},
// Create a new app
createApp: async (platform: string, app: AppDefinition): Promise<AppDefinition> => {
const response = await apiClient.post<AppDefinition>(`/cabinet/admin/apps/platforms/${platform}`, {
platform,
app,
})
return response.data
},
// Update an app
updateApp: async (platform: string, appId: string, app: AppDefinition): Promise<AppDefinition> => {
const response = await apiClient.put<AppDefinition>(`/cabinet/admin/apps/platforms/${platform}/${appId}`, {
app,
})
return response.data
},
// Delete an app
deleteApp: async (platform: string, appId: string): Promise<void> => {
await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`)
},
// Reorder apps
reorderApps: async (platform: string, appIds: string[]): Promise<void> => {
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<AppConfigBranding> => {
const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding')
return response.data
},
// Update branding
updateBranding: async (branding: AppConfigBranding): Promise<AppConfigBranding> => {
const response = await apiClient.put<AppConfigBranding>('/cabinet/admin/apps/branding', {
branding,
})
return response.data
},
}

73
src/api/adminSettings.ts Normal file
View File

@@ -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<SettingCategorySummary[]> => {
const response = await apiClient.get<SettingCategorySummary[]>('/cabinet/admin/settings/categories')
return response.data
},
// Get all settings or settings for a specific category
getSettings: async (categoryKey?: string): Promise<SettingDefinition[]> => {
const params = categoryKey ? { category_key: categoryKey } : {}
const response = await apiClient.get<SettingDefinition[]>('/cabinet/admin/settings', { params })
return response.data
},
// Get a specific setting by key
getSetting: async (key: string): Promise<SettingDefinition> => {
const response = await apiClient.get<SettingDefinition>(`/cabinet/admin/settings/${key}`)
return response.data
},
// Update a setting value
updateSetting: async (key: string, value: unknown): Promise<SettingDefinition> => {
const response = await apiClient.put<SettingDefinition>(`/cabinet/admin/settings/${key}`, { value })
return response.data
},
// Reset a setting to default
resetSetting: async (key: string): Promise<SettingDefinition> => {
const response = await apiClient.delete<SettingDefinition>(`/cabinet/admin/settings/${key}`)
return response.data
},
}

92
src/api/auth.ts Normal file
View File

@@ -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<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/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<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', data)
return response.data
},
// Email login
loginEmail: async (email: string, password: string): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/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<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/cabinet/auth/refresh', {
refresh_token: refreshToken,
})
return response.data
},
// Logout
logout: async (refreshToken: string): Promise<void> => {
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<User> => {
const response = await apiClient.get<User>('/cabinet/auth/me')
return response.data
},
}

69
src/api/balance.ts Normal file
View File

@@ -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<Balance> => {
const response = await apiClient.get<Balance>('/cabinet/balance')
return response.data
},
// Get transaction history
getTransactions: async (params?: {
page?: number
per_page?: number
type?: string
}): Promise<PaginatedResponse<Transaction>> => {
const response = await apiClient.get<PaginatedResponse<Transaction>>('/cabinet/balance/transactions', {
params,
})
return response.data
},
// Get available payment methods
getPaymentMethods: async (): Promise<PaymentMethod[]> => {
const response = await apiClient.get<PaymentMethod[]>('/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
},
}

48
src/api/branding.ts Normal file
View File

@@ -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<BrandingInfo> => {
const response = await apiClient.get<BrandingInfo>('/cabinet/branding')
return response.data
},
// Update project name (admin only)
updateName: async (name: string): Promise<BrandingInfo> => {
const response = await apiClient.put<BrandingInfo>('/cabinet/branding/name', { name })
return response.data
},
// Upload custom logo (admin only)
uploadLogo: async (file: File): Promise<BrandingInfo> => {
const formData = new FormData()
formData.append('file', file)
const response = await apiClient.post<BrandingInfo>('/cabinet/branding/logo', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
},
// Delete custom logo (admin only)
deleteLogo: async (): Promise<BrandingInfo> => {
const response = await apiClient.delete<BrandingInfo>('/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}`
},
}

88
src/api/client.ts Normal file
View File

@@ -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

57
src/api/contests.ts Normal file
View File

@@ -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<string, any>
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<ContestsCountResponse> => {
const response = await apiClient.get<ContestsCountResponse>('/cabinet/contests/count')
return response.data
},
// Get list of available contests
getContests: async (): Promise<ContestInfo[]> => {
const response = await apiClient.get<ContestInfo[]>('/cabinet/contests')
return response.data
},
// Get game data for a specific contest
getContestGame: async (roundId: number): Promise<ContestGameData> => {
const response = await apiClient.get<ContestGameData>(`/cabinet/contests/${roundId}`)
return response.data
},
// Submit answer for a contest
submitAnswer: async (roundId: number, answer: string): Promise<ContestResult> => {
const response = await apiClient.post<ContestResult>(`/cabinet/contests/${roundId}/answer`, {
round_id: roundId,
answer,
})
return response.data
},
}

98
src/api/currency.ts Normal file
View File

@@ -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<ExchangeRates> => {
// 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 }

12
src/api/index.ts Normal file
View File

@@ -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'

102
src/api/info.ts Normal file
View File

@@ -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<FaqPage[]> => {
const response = await apiClient.get<FaqPage[]>('/cabinet/info/faq')
return response.data
},
// Get specific FAQ page
getFaqPage: async (pageId: number): Promise<FaqPage> => {
const response = await apiClient.get<FaqPage>(`/cabinet/info/faq/${pageId}`)
return response.data
},
// Get service rules
getRules: async (): Promise<RulesResponse> => {
const response = await apiClient.get<RulesResponse>('/cabinet/info/rules')
return response.data
},
// Get privacy policy
getPrivacyPolicy: async (): Promise<PrivacyPolicyResponse> => {
const response = await apiClient.get<PrivacyPolicyResponse>('/cabinet/info/privacy-policy')
return response.data
},
// Get public offer
getPublicOffer: async (): Promise<PublicOfferResponse> => {
const response = await apiClient.get<PublicOfferResponse>('/cabinet/info/public-offer')
return response.data
},
// Get service info
getServiceInfo: async (): Promise<ServiceInfo> => {
const response = await apiClient.get<ServiceInfo>('/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<SupportConfig> => {
const response = await apiClient.get<SupportConfig>('/cabinet/info/support-config')
return response.data
},
}

36
src/api/miniapp.ts Normal file
View File

@@ -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<string, unknown>
}
export const miniappApi = {
// Create payment inside Telegram Mini App (same flow as miniapp/index.html)
createPayment: async (
payload: MiniappCreatePaymentPayload
): Promise<MiniappCreatePaymentResponse> => {
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
},
}

58
src/api/notifications.ts Normal file
View File

@@ -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<NotificationSettings> => {
const response = await apiClient.get<NotificationSettings>('/cabinet/notifications')
return response.data
},
// Update notification settings
updateSettings: async (settings: NotificationSettingsUpdate): Promise<NotificationSettings> => {
const response = await apiClient.patch<NotificationSettings>('/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
},
}

85
src/api/polls.ts Normal file
View File

@@ -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<PollsCountResponse> => {
const response = await apiClient.get<PollsCountResponse>('/cabinet/polls/count')
return response.data
},
// Get available polls
getPolls: async (): Promise<PollInfo[]> => {
const response = await apiClient.get<PollInfo[]>('/cabinet/polls')
return response.data
},
// Get poll details
getPollDetails: async (responseId: number): Promise<PollInfo> => {
const response = await apiClient.get<PollInfo>(`/cabinet/polls/${responseId}`)
return response.data
},
// Start or continue poll
startPoll: async (responseId: number): Promise<PollStartResponse> => {
const response = await apiClient.post<PollStartResponse>(`/cabinet/polls/${responseId}/start`)
return response.data
},
// Answer a question
answerQuestion: async (
responseId: number,
questionId: number,
optionId: number
): Promise<PollAnswerResponse> => {
const response = await apiClient.post<PollAnswerResponse>(
`/cabinet/polls/${responseId}/questions/${questionId}/answer`,
{ option_id: optionId }
)
return response.data
},
}

69
src/api/promo.ts Normal file
View File

@@ -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<string, any> | 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<string, number>
}
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<PromoOffer[]> => {
const response = await apiClient.get<PromoOffer[]>('/cabinet/promo/offers')
return response.data
},
// Get active discount
getActiveDiscount: async (): Promise<ActiveDiscount> => {
const response = await apiClient.get<ActiveDiscount>('/cabinet/promo/active-discount')
return response.data
},
// Get promo group discounts
getGroupDiscounts: async (): Promise<PromoGroupDiscounts> => {
const response = await apiClient.get<PromoGroupDiscounts>('/cabinet/promo/group-discounts')
return response.data
},
// Claim a promo offer
claimOffer: async (offerId: number): Promise<ClaimOfferResponse> => {
const response = await apiClient.post<ClaimOfferResponse>('/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
},
}

62
src/api/referral.ts Normal file
View File

@@ -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<ReferralEarning> {
total_amount_kopeks: number
total_amount_rubles: number
}
export const referralApi = {
// Get referral info
getReferralInfo: async (): Promise<ReferralInfo> => {
const response = await apiClient.get<ReferralInfo>('/cabinet/referral')
return response.data
},
// Get referral list
getReferralList: async (params?: {
page?: number
per_page?: number
}): Promise<PaginatedResponse<ReferralItem>> => {
const response = await apiClient.get<PaginatedResponse<ReferralItem>>('/cabinet/referral/list', {
params,
})
return response.data
},
// Get referral earnings
getReferralEarnings: async (params?: {
page?: number
per_page?: number
}): Promise<ReferralEarningsList> => {
const response = await apiClient.get<ReferralEarningsList>('/cabinet/referral/earnings', {
params,
})
return response.data
},
// Get referral terms
getReferralTerms: async (): Promise<ReferralTerms> => {
const response = await apiClient.get<ReferralTerms>('/cabinet/referral/terms')
return response.data
},
}

142
src/api/servers.ts Normal file
View File

@@ -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<ServerListResponse> => {
const response = await apiClient.get('/cabinet/admin/servers', {
params: { include_unavailable: includeUnavailable }
})
return response.data
},
// Get single server
getServer: async (serverId: number): Promise<ServerDetail> => {
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}`)
return response.data
},
// Update server
updateServer: async (serverId: number, data: ServerUpdateRequest): Promise<ServerDetail> => {
const response = await apiClient.put(`/cabinet/admin/servers/${serverId}`, data)
return response.data
},
// Toggle server availability
toggleServer: async (serverId: number): Promise<ServerToggleResponse> => {
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/toggle`)
return response.data
},
// Toggle trial eligibility
toggleTrial: async (serverId: number): Promise<ServerTrialToggleResponse> => {
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/trial`)
return response.data
},
// Get server stats
getServerStats: async (serverId: number): Promise<ServerStats> => {
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}/stats`)
return response.data
},
// Sync servers with RemnaWave
syncServers: async (): Promise<ServerSyncResponse> => {
const response = await apiClient.post('/cabinet/admin/servers/sync')
return response.data
},
}

324
src/api/subscription.ts Normal file
View File

@@ -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<Subscription> => {
const response = await apiClient.get<Subscription>('/cabinet/subscription')
return response.data
},
// Get renewal options
getRenewalOptions: async (): Promise<RenewalOption[]> => {
const response = await apiClient.get<RenewalOption[]>('/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<TrafficPackage[]> => {
const response = await apiClient.get<TrafficPackage[]>('/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<TrialInfo> => {
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial')
return response.data
},
// Activate trial
activateTrial: async (): Promise<Subscription> => {
const response = await apiClient.post<Subscription>('/cabinet/subscription/trial')
return response.data
},
// Get purchase options (periods, servers, traffic, devices)
getPurchaseOptions: async (): Promise<PurchaseOptions> => {
const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options')
return response.data
},
// Preview purchase price
previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => {
const response = await apiClient.post<PurchasePreview>('/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<AppConfig> => {
const response = await apiClient.get<AppConfig>('/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<string, {
name: string
icon: string
link: string
}>
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
},
}

228
src/api/tariffs.ts Normal file
View File

@@ -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<string, ServerTrafficLimit>
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<string, number>
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<string, ServerTrafficLimit>
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<string, number>
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<string, ServerTrafficLimit>
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<string, number>
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<TariffListResponse> => {
const response = await apiClient.get('/cabinet/admin/tariffs', {
params: { include_inactive: includeInactive }
})
return response.data
},
// Get single tariff
getTariff: async (tariffId: number): Promise<TariffDetail> => {
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}`)
return response.data
},
// Create new tariff
createTariff: async (data: TariffCreateRequest): Promise<TariffDetail> => {
const response = await apiClient.post('/cabinet/admin/tariffs', data)
return response.data
},
// Update tariff
updateTariff: async (tariffId: number, data: TariffUpdateRequest): Promise<TariffDetail> => {
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<TariffToggleResponse> => {
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/toggle`)
return response.data
},
// Toggle trial status
toggleTrial: async (tariffId: number): Promise<TariffTrialResponse> => {
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/trial`)
return response.data
},
// Get tariff stats
getTariffStats: async (tariffId: number): Promise<TariffStats> => {
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}/stats`)
return response.data
},
// Get available servers for selection
getAvailableServers: async (): Promise<ServerInfo[]> => {
const response = await apiClient.get('/cabinet/admin/tariffs/available-servers')
return response.data
},
}

43
src/api/themeColors.ts Normal file
View File

@@ -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<ThemeSettings> => {
try {
const response = await apiClient.get<ThemeSettings>('/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<ThemeSettings>): Promise<ThemeSettings> => {
const response = await apiClient.patch<ThemeSettings>('/cabinet/branding/colors', colors)
return response.data
},
// Reset to default colors (admin only)
resetColors: async (): Promise<ThemeSettings> => {
const response = await apiClient.post<ThemeSettings>('/cabinet/branding/colors/reset')
return response.data
},
// Get enabled themes (public, no auth required)
getEnabledThemes: async (): Promise<EnabledThemes> => {
try {
const response = await apiClient.get<EnabledThemes>('/cabinet/branding/themes')
return response.data
} catch {
return DEFAULT_ENABLED_THEMES
}
},
// Update enabled themes (admin only)
updateEnabledThemes: async (themes: Partial<EnabledThemes>): Promise<EnabledThemes> => {
const response = await apiClient.patch<EnabledThemes>('/cabinet/branding/themes', themes)
return response.data
},
}

84
src/api/tickets.ts Normal file
View File

@@ -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<PaginatedResponse<Ticket>> => {
const response = await apiClient.get<PaginatedResponse<Ticket>>('/cabinet/tickets', {
params,
})
return response.data
},
// Create ticket with optional media
createTicket: async (
title: string,
message: string,
media?: MediaParams
): Promise<TicketDetail> => {
const response = await apiClient.post<TicketDetail>('/cabinet/tickets', {
title,
message,
...media,
})
return response.data
},
// Get ticket detail
getTicket: async (ticketId: number): Promise<TicketDetail> => {
const response = await apiClient.get<TicketDetail>(`/cabinet/tickets/${ticketId}`)
return response.data
},
// Add message to ticket with optional media
addMessage: async (
ticketId: number,
message: string,
media?: MediaParams
): Promise<TicketMessage> => {
const response = await apiClient.post<TicketMessage>(`/cabinet/tickets/${ticketId}/messages`, {
message,
...media,
})
return response.data
},
// Upload media file for tickets
uploadMedia: async (file: File, mediaType: string = 'photo'): Promise<MediaUploadResponse> => {
const formData = new FormData()
formData.append('file', file)
formData.append('media_type', mediaType)
const response = await apiClient.post<MediaUploadResponse>('/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}`
},
}

281
src/api/wheel.ts Normal file
View File

@@ -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<string, { count: number; total_kopeks: number }>
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<WheelConfig> => {
const response = await apiClient.get<WheelConfig>('/cabinet/wheel/config')
return response.data
},
// Check spin availability
checkAvailability: async (): Promise<SpinAvailability> => {
const response = await apiClient.get<SpinAvailability>('/cabinet/wheel/availability')
return response.data
},
// Spin the wheel
spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise<SpinResult> => {
const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', {
payment_type: paymentType,
})
return response.data
},
// Get spin history
getHistory: async (page = 1, perPage = 20): Promise<SpinHistoryResponse> => {
const response = await apiClient.get<SpinHistoryResponse>('/cabinet/wheel/history', {
params: { page, per_page: perPage },
})
return response.data
},
// Create Stars invoice for Mini App payment
createStarsInvoice: async (): Promise<StarsInvoiceResponse> => {
const response = await apiClient.post<StarsInvoiceResponse>('/cabinet/wheel/stars-invoice')
return response.data
},
}
// ==================== ADMIN API ====================
export const adminWheelApi = {
// Get full config
getConfig: async (): Promise<AdminWheelConfig> => {
const response = await apiClient.get<AdminWheelConfig>('/cabinet/admin/wheel/config')
return response.data
},
// Update config
updateConfig: async (data: Partial<AdminWheelConfig>): Promise<AdminWheelConfig> => {
const response = await apiClient.put<AdminWheelConfig>('/cabinet/admin/wheel/config', data)
return response.data
},
// Get prizes
getPrizes: async (): Promise<WheelPrizeAdmin[]> => {
const response = await apiClient.get<WheelPrizeAdmin[]>('/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<WheelPrizeAdmin> => {
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data)
return response.data
},
// Update prize
updatePrize: async (prizeId: number, data: Partial<WheelPrizeAdmin>): Promise<WheelPrizeAdmin> => {
const response = await apiClient.put<WheelPrizeAdmin>(`/cabinet/admin/wheel/prizes/${prizeId}`, data)
return response.data
},
// Delete prize
deletePrize: async (prizeId: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`)
},
// Reorder prizes
reorderPrizes: async (prizeIds: number[]): Promise<void> => {
await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds })
},
// Get statistics
getStatistics: async (dateFrom?: string, dateTo?: string): Promise<WheelStatistics> => {
const response = await apiClient.get<WheelStatistics>('/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<AdminSpinsResponse> => {
const response = await apiClient.get<AdminSpinsResponse>('/cabinet/admin/wheel/spins', {
params,
})
return response.data
},
}

View File

@@ -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<HTMLDivElement>(null)
const colorInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
const newColor = e.target.value
setLocalValue(newColor)
onChange(newColor)
}
const handleHexInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div ref={containerRef} className="relative">
<label className="block text-sm font-medium text-dark-200 mb-1">{label}</label>
{description && <p className="text-xs text-dark-500 mb-2">{description}</p>}
<div className="flex items-center gap-3">
{/* Color preview button */}
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className="w-10 h-10 rounded-lg border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
style={{ backgroundColor: localValue || '#000000' }}
title={localValue}
/>
{/* Hex input */}
<input
type="text"
value={localValue}
onChange={handleHexInputChange}
disabled={disabled}
className="input w-28 py-2 font-mono text-sm uppercase"
placeholder="#000000"
maxLength={7}
/>
{/* Hidden native color picker for direct access */}
<input
ref={colorInputRef}
type="color"
value={localValue || '#000000'}
onChange={handleColorInputChange}
disabled={disabled}
className="sr-only"
/>
{/* Native picker button */}
<button
type="button"
onClick={() => colorInputRef.current?.click()}
disabled={disabled}
className="btn-secondary p-2 disabled:opacity-50"
title="Open color picker"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
/>
</svg>
</button>
</div>
{/* Dropdown with presets */}
{isOpen && (
<div className="absolute z-50 mt-2 p-3 bg-dark-800 rounded-xl border border-dark-700 shadow-xl animate-fade-in">
<div className="text-xs text-dark-400 mb-2">Preset colors</div>
<div className="grid grid-cols-6 gap-2">
{PRESET_COLORS.map((preset) => (
<button
key={preset}
onClick={() => handlePresetClick(preset)}
className={`w-7 h-7 rounded-lg border-2 transition-all hover:scale-110 ${
localValue === preset ? 'border-white' : 'border-dark-600 hover:border-dark-500'
}`}
style={{ backgroundColor: preset }}
title={preset}
/>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -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 = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>
)
const AndroidIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24a11.463 11.463 0 00-8.94 0L5.65 5.67c-.19-.29-.58-.38-.87-.2-.28.18-.37.54-.22.83L6.4 9.48A10.78 10.78 0 003 18h18a10.78 10.78 0 00-3.4-8.52zM7 15.25a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5zm10 0a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5z"/>
</svg>
)
const WindowsIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm17 .25V22l-10-1.91V13.1l10 .15z"/>
</svg>
)
const MacosIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4h16a2 2 0 012 2v10a2 2 0 01-2 2h-6v2h2a1 1 0 110 2H8a1 1 0 110-2h2v-2H4a2 2 0 01-2-2V6a2 2 0 012-2zm0 2v10h16V6H4zm8 2.5a1 1 0 011 1v3a1 1 0 11-2 0v-3a1 1 0 011-1z"/>
</svg>
)
const LinuxIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C9.5 2 8 4.5 8 7c0 1.5.5 3 1 4-1.5 1-3 3-3 5 0 .5 0 1 .5 1.5-.5.5-1.5 1-1.5 2 0 1.5 2 2.5 4 2.5h6c2 0 4-1 4-2.5 0-1-1-1.5-1.5-2 .5-.5.5-1 .5-1.5 0-2-1.5-4-3-5 .5-1 1-2.5 1-4 0-2.5-1.5-5-4-5zm-2 5c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm4 0c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm-2 3c1 0 2 .5 2 1s-1 1-2 1-2-.5-2-1 1-1 2-1z"/>
</svg>
)
const TvIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="13" rx="2" ry="2"/>
<polyline points="8 21 12 17 16 21"/>
<line x1="12" y1="17" x2="12" y2="13"/>
</svg>
)
// Platform icon components map
const platformIconComponents: Record<string, React.FC> = {
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<string | null>(null)
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null)
const [copied, setCopied] = useState(false)
const [detectedPlatform, setDetectedPlatform] = useState<string | null>(null)
const { data: appConfig, isLoading, error } = useQuery<AppConfig>({
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 (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
</div>
</div>
</div>
)
}
if (error || !appConfig) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="text-center py-8">
<p className="text-error-400">{t('common.error')}</p>
<button onClick={onClose} className="btn-secondary mt-4">
{t('common.close')}
</button>
</div>
</div>
</div>
</div>
)
}
if (!appConfig.hasSubscription) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-dark-100">
{t('subscription.connection.title')}
</h2>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="text-center py-8">
<p className="text-dark-400">{t('subscription.connection.noSubscription')}</p>
</div>
</div>
</div>
</div>
)
}
// Step 1: Select platform
if (!selectedPlatform) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<h2 className="text-lg font-semibold text-dark-100">
{t('subscription.connection.title')}
</h2>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4">
<p className="text-dark-400 text-sm sm:text-base mb-3 sm:mb-4">{t('subscription.connection.selectDevice')}</p>
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{availablePlatforms.map((platform) => {
const IconComponent = platformIconComponents[platform]
return (
<button
key={platform}
onClick={() => setSelectedPlatform(platform)}
className={`p-3 sm:p-4 rounded-xl border transition-all text-left group ${
platform === detectedPlatform
? 'bg-accent-500/10 border-accent-500/50 hover:border-accent-500'
: 'bg-dark-800/50 border-dark-700 hover:border-accent-500/50 hover:bg-dark-700/50'
}`}
>
<div className="flex items-start justify-between">
<div className={`${
platform === detectedPlatform ? 'text-accent-400' : 'text-dark-400 group-hover:text-dark-200'
} transition-colors`}>
{IconComponent && <IconComponent />}
</div>
{platform === detectedPlatform && (
<span className="px-2 py-0.5 rounded-full text-xs bg-accent-500/20 text-accent-400">
{t('subscription.connection.yourDevice')}
</span>
)}
</div>
<p className="text-dark-200 font-medium mt-2">{getPlatformName(platform)}</p>
</button>
)
})}
</div>
</div>
{/* Footer - fixed with safe area */}
<div className="flex-shrink-0 p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6 border-t border-dark-700/50">
<button
onClick={copySubscriptionLink}
className="w-full p-2.5 sm:p-3 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 transition-all flex items-center justify-center gap-2 text-sm sm:text-base"
>
{copied ? (
<>
<svg className="w-5 h-5 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-success-400">{t('subscription.connection.copied')}</span>
</>
) : (
<>
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span className="text-dark-300">{t('subscription.connection.copyLink')}</span>
</>
)}
</button>
</div>
</div>
</div>
)
}
// Step 2: Select app (if not selected yet)
if (!selectedApp) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedPlatform(null)}
className="btn-icon"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<h2 className="text-lg font-semibold text-dark-100">
{getPlatformName(selectedPlatform)}
</h2>
</div>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6">
<p className="text-dark-400 text-sm sm:text-base mb-3 sm:mb-4">{t('subscription.connection.selectApp')}</p>
{platformApps.length === 0 ? (
<div className="text-center py-6 sm:py-8">
<p className="text-dark-500">{t('subscription.connection.noApps')}</p>
</div>
) : (
<div className="space-y-2 sm:space-y-3">
{platformApps.map((app) => (
<button
key={app.id}
onClick={() => setSelectedApp(app)}
className="w-full p-3 sm:p-4 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 hover:bg-dark-700/50 transition-all text-left flex items-center justify-between"
>
<div>
<div className="flex items-center gap-2">
<span className="text-dark-100 font-medium">{app.name}</span>
{app.isFeatured && (
<span className="px-2 py-0.5 rounded-full text-xs bg-accent-500/20 text-accent-400">
{t('subscription.connection.featured')}
</span>
)}
</div>
</div>
<svg className="w-5 h-5 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
))}
</div>
)}
</div>
</div>
</div>
)
}
// Step 3: Show app instructions and connect button
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedApp(null)}
className="btn-icon"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<h2 className="text-lg font-semibold text-dark-100">
{selectedApp.name}
</h2>
</div>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4">
<div className="space-y-4 sm:space-y-6">
{/* Step 1: Install */}
{selectedApp.installationStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.installApp')}
</h3>
<p className="text-sm text-dark-400">
{getLocalizedText(selectedApp.installationStep.description)}
</p>
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedApp.installationStep.buttons.map((btn, idx) => (
<a
key={idx}
href={btn.buttonLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700 text-dark-200 text-sm hover:bg-dark-600 transition-all"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
{getLocalizedText(btn.buttonText)}
</a>
))}
</div>
)}
</div>
)}
{/* Additional before add subscription */}
{selectedApp.additionalBeforeAddSubscriptionStep && (
<div className="space-y-2 p-3 rounded-xl bg-dark-800/50 border border-dark-700">
{selectedApp.additionalBeforeAddSubscriptionStep.title && (
<h4 className="text-sm font-medium text-dark-200">
{getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.title)}
</h4>
)}
<p className="text-xs text-dark-400">
{getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.description)}
</p>
</div>
)}
{/* Step 2: Add subscription */}
{selectedApp.addSubscriptionStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.addSubscription')}
</h3>
<p className="text-sm text-dark-400">
{getLocalizedText(selectedApp.addSubscriptionStep.description)}
</p>
{/* Connect button */}
{selectedApp.deepLink && (
<button
onClick={() => handleConnect(selectedApp)}
className="btn-primary w-full py-2.5 sm:py-3 flex items-center justify-center gap-2 text-sm sm:text-base"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
</button>
)}
{/* Copy link fallback */}
<button
onClick={copySubscriptionLink}
className="w-full p-2.5 sm:p-3 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 transition-all flex items-center justify-center gap-2 text-sm sm:text-base"
>
{copied ? (
<>
<svg className="w-5 h-5 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-success-400">{t('subscription.connection.copied')}</span>
</>
) : (
<>
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span className="text-dark-300">{t('subscription.connection.copyLink')}</span>
</>
)}
</button>
</div>
)}
{/* Additional after add subscription */}
{selectedApp.additionalAfterAddSubscriptionStep && (
<div className="space-y-2 p-3 rounded-xl bg-dark-800/50 border border-dark-700">
{selectedApp.additionalAfterAddSubscriptionStep.title && (
<h4 className="text-sm font-medium text-dark-200">
{getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.title)}
</h4>
)}
<p className="text-xs text-dark-400">
{getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.description)}
</p>
</div>
)}
{/* Step 3: Connect */}
{selectedApp.connectAndUseStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.connectVpn')}
</h3>
<p className="text-sm text-dark-400">
{getLocalizedText(selectedApp.connectAndUseStep.description)}
</p>
</div>
)}
</div>
</div>
{/* Footer - fixed with safe area */}
<div className="flex-shrink-0 p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6 border-t border-dark-700/50">
<button onClick={onClose} className="btn-secondary w-full text-sm sm:text-base">
{t('common.close')}
</button>
</div>
</div>
</div>
)
}

View File

@@ -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<HTMLDivElement>(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 (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-xl border border-dark-700/50 hover:border-dark-600 bg-dark-800/50 transition-all text-sm"
aria-label="Change language"
>
<span>{currentLang.flag}</span>
<span className="font-medium text-dark-200">{currentLang.name}</span>
<svg
className={`w-4 h-4 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-40 bg-dark-800 rounded-xl shadow-lg border border-dark-700/50 py-1 z-50 animate-fade-in">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
lang.code === i18n.language
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-300 hover:bg-dark-700/50'
}`}
>
<span>{lang.flag}</span>
<span>{lang.fullName}</span>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -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<DOMRect | null>(null)
const [isVisible, setIsVisible] = useState(false)
const tooltipRef = useRef<HTMLDivElement>(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(
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
{/* Spotlight */}
<div className="onboarding-spotlight" style={getSpotlightStyle()} />
{/* Tooltip */}
<div
ref={tooltipRef}
className={`onboarding-tooltip tooltip-${step.placement}`}
style={getTooltipStyle()}
>
{/* Progress indicator */}
<div className="flex items-center gap-1.5 mb-4">
{steps.map((_, index) => (
<div
key={index}
className={`h-1 rounded-full transition-all duration-300 ${
index === currentStep
? 'w-6 bg-accent-500'
: index < currentStep
? 'w-2 bg-accent-500/50'
: 'w-2 bg-dark-700'
}`}
/>
))}
</div>
{/* Content */}
<h3 className="text-lg font-semibold text-dark-50 mb-2">{step.title}</h3>
<p className="text-dark-400 text-sm mb-5">{step.description}</p>
{/* Actions */}
<div className="flex items-center justify-between">
<button
onClick={handleSkip}
className="text-dark-500 hover:text-dark-300 text-sm transition-colors"
>
{t('onboarding.skip', 'Skip')}
</button>
<div className="flex gap-2">
{currentStep > 0 && (
<button onClick={handlePrev} className="btn-ghost text-sm px-3 py-1.5">
{t('common.back', 'Back')}
</button>
)}
<button onClick={handleNext} className="btn-primary text-sm px-4 py-1.5">
{currentStep === steps.length - 1
? t('onboarding.finish', 'Finish')
: t('common.next', 'Next')}
</button>
</div>
</div>
</div>
{/* Click handler to advance on target click */}
{targetRect && (
<div
className="absolute cursor-pointer"
style={{
top: targetRect.top,
left: targetRect.left,
width: targetRect.width,
height: targetRect.height,
}}
onClick={handleNext}
/>
)}
</div>,
document.body
)
}

View File

@@ -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<HTMLDivElement>(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 (
<div className="text-center text-gray-500 text-sm py-4">
{t('auth.telegramNotConfigured')}
</div>
)
}
return (
<div className="flex flex-col items-center space-y-4">
{/* Telegram Widget will be inserted here */}
<div ref={containerRef} className="flex justify-center" />
{/* Fallback link for mobile */}
<div className="text-center">
<p className="text-xs text-gray-500 mb-2">{t('auth.orOpenInApp')}</p>
<a
href={`https://t.me/${botUsername}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-telegram-blue hover:underline"
>
<svg className="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
@{botUsername}
</a>
</div>
</div>
)
}

View File

@@ -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<string | null>(null)
const popupRef = useRef<Window | null>(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 (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="card max-w-md w-full animate-slide-up">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-dark-100">{t('balance.topUp')} - {methodName}</h2>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">{t('balance.amount')} ({currencySymbol})</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder={`${formatAmount(minRubles, currencyDecimals)} - ${formatAmount(maxRubles, currencyDecimals)}`}
min={minInputValue}
max={maxInputValue}
step={inputStep}
className="input"
/>
<div className="text-xs text-dark-500 mt-2">
{t('balance.minAmount')}: {formatAmount(minRubles, currencyDecimals)} {currencySymbol} | {t('balance.maxAmount')}: {formatAmount(maxRubles, currencyDecimals)} {currencySymbol}
</div>
</div>
{quickAmounts.length > 0 && (
<div className="flex flex-wrap gap-2">
{quickAmounts.map((a) => {
const quickValue = getQuickAmountValue(a)
return (
<button
key={a}
type="button"
onClick={() => setAmount(quickValue)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
amount === quickValue ? 'bg-accent-500 text-white' : 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{formatAmount(a, currencyDecimals)} {currencySymbol}
</button>
)
})}
</div>
)}
{error && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-3 rounded-xl text-sm">{error}</div>
)}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={topUpMutation.isPending || starsPaymentMutation.isPending || !amount} className="btn-primary flex-1">
{topUpMutation.isPending || starsPaymentMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('common.loading')}
</span>
) : (
t('balance.topUp')
)}
</button>
<button type="button" onClick={onClose} className="btn-secondary">{t('common.cancel')}</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -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 (
<div className={`min-h-screen flex items-center justify-center ${bgClass}`}>
<div className="w-48 max-w-full">
<DotLottieReact
src="https://lottie.host/14b9dc34-cdaf-408c-87ea-291c1b01e343/r2rZZVuahg.lottie"
loop
autoplay
/>
</div>
</div>
)
}

View File

@@ -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 = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
)
const SubscriptionIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
)
const WalletIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
</svg>
)
const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
const ChatIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
)
const UserIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
)
const LogoutIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
)
// Theme toggle icons
const SunIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)
const MoonIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
)
const MenuIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
)
const CloseIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const GamepadIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869" />
</svg>
)
const ClipboardIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
)
const InfoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
)
const CogIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
const WheelIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)
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<string | null>(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 (
<div className="min-h-screen flex flex-col">
{/* Header */}
<header className="sticky top-0 z-50 glass border-b border-dark-800/50">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="flex justify-between items-center h-16 lg:h-20">
{/* Logo */}
<Link to="/" className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
<div className="w-10 h-10 sm:w-12 sm:h-12 lg:w-14 lg:h-14 rounded-xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center overflow-hidden shadow-lg shadow-accent-500/20 flex-shrink-0">
{hasCustomLogo && logoUrl ? (
<img src={logoUrl} alt={appName || 'Logo'} className="w-full h-full object-contain" />
) : (
<span className="text-white font-bold text-lg sm:text-xl lg:text-2xl">{logoLetter}</span>
)}
</div>
{appName && (
<span className="text-base lg:text-lg font-semibold text-dark-100 whitespace-nowrap">
{appName}
</span>
)}
</Link>
{/* Desktop Navigation */}
<nav className="hidden lg:flex items-center gap-1">
{desktopNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
isActive(item.path)
? 'text-accent-400 bg-accent-500/10'
: 'text-dark-400 hover:text-dark-100 hover:bg-dark-800/50'
}`}
>
<item.icon />
{item.label}
</Link>
))}
{isAdmin && (
<>
<div className="w-px h-6 bg-dark-700 mx-2" />
{adminNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
isAdminActive()
? 'text-warning-400 bg-warning-500/10'
: 'text-warning-500/70 hover:text-warning-400 hover:bg-warning-500/10'
}`}
>
<item.icon />
{item.label}
</Link>
))}
</>
)}
</nav>
{/* Right side */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Theme toggle button - only show if both themes are enabled */}
{canToggle && (
<button
onClick={toggleTheme}
className="relative p-2.5 rounded-xl transition-all duration-300 hover:scale-110 active:scale-95
dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
>
<div className="relative w-5 h-5">
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'}`}>
<MoonIcon />
</div>
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-0 -rotate-90' : 'opacity-100 rotate-0'}`}>
<SunIcon />
</div>
</div>
</button>
)}
<LanguageSwitcher />
{/* Profile - Desktop */}
<div className="hidden sm:flex items-center gap-3">
<Link
to="/profile"
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-dark-800/50 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center">
<UserIcon />
</div>
<span className="text-sm text-dark-300">
{user?.first_name || user?.username || `#${user?.telegram_id}`}
</span>
</Link>
<button
onClick={logout}
className="btn-icon"
title={t('nav.logout')}
>
<LogoutIcon />
</button>
</div>
{/* Mobile menu button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="lg:hidden btn-icon"
>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
</button>
</div>
</div>
</div>
</header>
{/* Mobile menu - fixed overlay */}
{mobileMenuOpen && (
<div className="lg:hidden fixed inset-0 z-40 animate-fade-in" style={{ top: '64px' }}>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileMenuOpen(false)}
/>
{/* Menu content */}
<div className="absolute inset-x-0 top-0 bottom-0 bg-dark-900 border-t border-dark-800/50 overflow-y-auto pb-[calc(5rem+env(safe-area-inset-bottom,0px))]">
<div className="max-w-6xl mx-auto px-4 py-4">
{/* User info */}
<div className="flex items-center gap-3 pb-4 mb-4 border-b border-dark-800/50">
{userPhotoUrl ? (
<img
src={userPhotoUrl}
alt="Avatar"
className="w-10 h-10 rounded-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.nextElementSibling?.classList.remove('hidden')
}}
/>
) : null}
<div className={`w-10 h-10 rounded-full bg-dark-700 flex items-center justify-center ${userPhotoUrl ? 'hidden' : ''}`}>
<UserIcon />
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user?.first_name || user?.username}
</div>
<div className="text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`}
</div>
</div>
</div>
{/* Nav items */}
<nav className="space-y-1">
{desktopNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMobileMenuOpen(false)}
className={isActive(item.path) ? 'nav-item-active' : 'nav-item'}
>
<item.icon />
{item.label}
</Link>
))}
{isAdmin && (
<>
<div className="divider my-3" />
<div className="px-4 py-1 text-xs font-medium text-dark-500 uppercase tracking-wider">
{t('admin.nav.title')}
</div>
{adminNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMobileMenuOpen(false)}
className={`nav-item ${isAdminActive() ? 'text-warning-400 bg-warning-500/10' : 'text-warning-500/70'}`}
>
<item.icon />
{item.label}
</Link>
))}
</>
)}
<div className="divider my-3" />
<Link
to="/profile"
onClick={() => setMobileMenuOpen(false)}
className={isActive('/profile') ? 'nav-item-active' : 'nav-item'}
>
<UserIcon />
{t('nav.profile')}
</Link>
<button
onClick={() => {
setMobileMenuOpen(false)
logout()
}}
className="nav-item w-full text-error-400"
>
<LogoutIcon />
{t('nav.logout')}
</button>
</nav>
</div>
</div>
</div>
)}
{/* Main Content */}
<main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-6 pb-24 lg:pb-8">
<div className="animate-fade-in">
{children}
</div>
</main>
{/* Mobile Bottom Navigation - only core items */}
<nav className="bottom-nav lg:hidden">
<div className="flex justify-around">
{navItems.filter(item =>
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path)
).map((item) => (
<Link
key={item.path}
to={item.path}
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
>
<item.icon />
<span className="text-2xs mt-1 whitespace-nowrap">{item.label}</span>
</Link>
))}
</div>
</nav>
</div>
)
}

View File

@@ -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<SVGGElement>(null)
const [currentRotation, setCurrentRotation] = useState(0)
const [lightPattern, setLightPattern] = useState<boolean[]>([])
// 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 (
<div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center">
<p className="text-dark-400">No prizes configured</p>
</div>
)
}
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 (
<div className="relative w-full max-w-[380px] mx-auto select-none">
{/* Outer glow effect */}
<div
className={`absolute inset-[-30px] rounded-full transition-all duration-500 ${
isSpinning ? 'opacity-100 scale-105' : 'opacity-60'
}`}
style={{
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, rgba(236, 72, 153, 0.2) 40%, transparent 70%)',
filter: 'blur(25px)',
}}
/>
{/* Pointer */}
<div className="absolute top-[-12px] left-1/2 -translate-x-1/2 z-20">
<div className="relative">
<div
className={`absolute inset-[-10px] blur-lg transition-opacity ${isSpinning ? 'opacity-100' : 'opacity-70'}`}
style={{ background: 'radial-gradient(circle, rgba(251, 191, 36, 0.9) 0%, transparent 60%)' }}
/>
<svg width="44" height="56" viewBox="0 0 44 56" className="relative drop-shadow-2xl">
<defs>
<linearGradient id="pointerGold" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FDE68A" />
<stop offset="30%" stopColor="#FBBF24" />
<stop offset="70%" stopColor="#F59E0B" />
<stop offset="100%" stopColor="#D97706" />
</linearGradient>
<filter id="pointerGlow">
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#F59E0B" floodOpacity="0.6"/>
</filter>
</defs>
<polygon
points="22,56 2,14 22,0 42,14"
fill="url(#pointerGold)"
filter="url(#pointerGlow)"
/>
<polygon
points="22,50 6,16 22,4"
fill="rgba(255,255,255,0.3)"
/>
<circle cx="22" cy="24" r="8" fill="#FEF3C7"/>
<circle cx="22" cy="24" r="5" fill="#FBBF24"/>
<circle cx="19" cy="21" r="2" fill="white" opacity="0.8"/>
</svg>
</div>
</div>
{/* Main Wheel */}
<div className="relative aspect-square">
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-full">
<defs>
{/* Sector gradients */}
{prizes.map((prize, index) => {
const color = getSectorColors(index, prize.color)
return (
<linearGradient
key={`grad-${index}`}
id={`sectorGrad-${index}`}
x1="0%" y1="0%" x2="100%" y2="100%"
>
<stop offset="0%" stopColor={color} stopOpacity="1" />
<stop offset="50%" stopColor={color} stopOpacity="0.85" />
<stop offset="100%" stopColor={color} stopOpacity="0.7" />
</linearGradient>
)
})}
{/* Outer ring gradient */}
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#C084FC" />
<stop offset="25%" stopColor="#A855F7" />
<stop offset="50%" stopColor="#7C3AED" />
<stop offset="75%" stopColor="#A855F7" />
<stop offset="100%" stopColor="#C084FC" />
</linearGradient>
{/* Hub gradient */}
<radialGradient id="hubGrad" cx="30%" cy="30%" r="70%">
<stop offset="0%" stopColor="#818CF8" />
<stop offset="50%" stopColor="#6366F1" />
<stop offset="100%" stopColor="#4338CA" />
</radialGradient>
{/* Text shadow filter */}
<filter id="textShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="#000" floodOpacity="0.7"/>
</filter>
</defs>
{/* Background shadow */}
<circle cx={center} cy={center + 6} r={outerRadius + 5} fill="rgba(0,0,0,0.3)" />
{/* Outer decorative ring */}
<circle
cx={center}
cy={center}
r={outerRadius}
fill="none"
stroke="url(#ringGrad)"
strokeWidth="15"
/>
{/* Inner ring border */}
<circle
cx={center}
cy={center}
r={innerRadius}
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="2"
/>
{/* 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 (
<g key={`led-${i}`}>
{isLit && (
<circle
cx={dotX}
cy={dotY}
r={6}
fill="#FEF08A"
opacity={0.5}
style={{ filter: 'blur(3px)' }}
/>
)}
<circle
cx={dotX}
cy={dotY}
r={4}
fill={isLit ? '#FEF08A' : '#374151'}
stroke={isLit ? '#FDE047' : '#1F2937'}
strokeWidth="1"
/>
</g>
)
})}
{/* Rotating wheel group */}
<g
ref={wheelRef}
style={{
transformOrigin: `${center}px ${center}px`,
transform: `rotate(${currentRotation}deg)`,
transition: isSpinning
? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)'
: 'none',
}}
>
{/* Sectors */}
{prizes.map((prize, index) => (
<path
key={`sector-${prize.id}`}
d={createSectorPath(index)}
fill={`url(#sectorGrad-${index})`}
stroke="rgba(255,255,255,0.15)"
strokeWidth="2"
/>
))}
{/* 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 (
<line
key={`divider-${index}`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="rgba(255,255,255,0.25)"
strokeWidth="2"
/>
)
})}
{/* Prize content - Emoji */}
{prizes.map((prize, index) => {
const pos = getEmojiPosition(index)
return (
<text
key={`emoji-${prize.id}`}
x={pos.x}
y={pos.y}
textAnchor="middle"
dominantBaseline="middle"
fontSize={prizes.length <= 6 ? "32" : "26"}
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))' }}
>
{prize.emoji}
</text>
)
})}
{/* Prize content - Text */}
{prizes.map((prize, index) => {
const pos = getTextPosition(index)
const displayText = truncateText(prize.display_name, maxTextLength)
return (
<text
key={`text-${prize.id}`}
x={pos.x}
y={pos.y}
textAnchor="middle"
dominantBaseline="middle"
fontSize={prizes.length <= 4 ? "13" : prizes.length <= 6 ? "11" : "9"}
fontWeight="700"
fill="#FFFFFF"
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
filter="url(#textShadow)"
style={{ letterSpacing: '0.03em' }}
>
{displayText}
</text>
)
})}
</g>
{/* Center hub */}
<circle
cx={center}
cy={center}
r={hubRadius}
fill="url(#hubGrad)"
stroke="#A5B4FC"
strokeWidth="3"
/>
{/* Hub inner decoration */}
<circle
cx={center}
cy={center}
r={hubRadius - 8}
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="1"
/>
{/* Hub shine */}
<ellipse
cx={center - 10}
cy={center - 12}
rx={15}
ry={10}
fill="rgba(255,255,255,0.2)"
/>
{/* Center button */}
<circle
cx={center}
cy={center}
r={hubRadius - 12}
fill="#312E81"
stroke="#6366F1"
strokeWidth="2"
/>
{/* Center text */}
<text
x={center}
y={center + 1}
textAnchor="middle"
dominantBaseline="middle"
fontSize="11"
fontWeight="bold"
fill="#C7D2FE"
letterSpacing="0.15em"
>
{isSpinning ? '...' : 'SPIN'}
</text>
</svg>
{/* Spinning overlay glow */}
{isSpinning && (
<div
className="absolute inset-0 rounded-full pointer-events-none"
style={{
background: 'radial-gradient(circle, rgba(168, 85, 247, 0.25) 0%, transparent 50%)',
animation: 'pulse 0.5s ease-in-out infinite',
}}
/>
)}
</div>
{/* Sparkle effects when spinning */}
{isSpinning && (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={`sparkle-${i}`}
className="absolute w-2 h-2 bg-yellow-300 rounded-full"
style={{
top: `${15 + Math.random() * 70}%`,
left: `${15 + Math.random() * 70}%`,
animation: `ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite`,
animationDelay: `${i * 0.12}s`,
opacity: 0.8,
}}
/>
))}
</div>
)}
</div>
)
}

108
src/hooks/useCurrency.ts Normal file
View File

@@ -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<string, keyof ExchangeRates | 'RUB'> = {
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,
}
}

224
src/hooks/useTheme.ts Normal file
View File

@@ -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<EnabledThemes> {
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<EnabledThemes>(getCachedEnabledThemes)
const [isLoading, setIsLoading] = useState(true)
const [theme, setThemeState] = useState<Theme>(() => {
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<EnabledThemes>) => {
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,
}
}

256
src/hooks/useThemeColors.ts Normal file
View File

@@ -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<number, number> = {
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<ColorPalette> = {}
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<number, number> = {
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<ColorPalette> = {}
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<number, number> = {
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<ColorPalette> = {}
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,
}
}

40
src/i18n.ts Normal file
View File

@@ -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

788
src/locales/en.json Normal file
View File

@@ -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."
}
}
}
}

711
src/locales/fa.json Normal file
View File

@@ -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": "پیشوند کد تخفیف"

788
src/locales/ru.json Normal file
View File

@@ -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": "Используйте эти кнопки для быстрого доступа к основным функциям: пополнению баланса, продлению подписки и приглашению друзей."
}
}
}
}

712
src/locales/zh.json Normal file
View File

@@ -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": "使用这些按钮快速访问主要功能:充值余额、延长订阅和邀请好友。"
}
}
}
}

29
src/main.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ThemeColorsProvider>
<App />
</ThemeColorsProvider>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

629
src/pages/AdminApps.tsx Normal file
View File

@@ -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 = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
)
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)
const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
)
const TrashIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
)
const ChevronUpIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
)
const ChevronDownIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
)
const CopyIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
</svg>
)
const StarIcon = ({ filled }: { filled: boolean }) => (
<svg className={`w-4 h-4 ${filled ? 'fill-yellow-400 text-yellow-400' : 'text-dark-500'}`} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
)
const PLATFORM_LABELS: Record<string, string> = {
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<AppDefinition>({ ...app })
const [activeTab, setActiveTab] = useState<'basic' | 'installation' | 'subscription' | 'connect' | 'additional'>('basic')
const updateField = <K extends keyof AppDefinition>(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 (
<div className="space-y-2">
<label className="block text-sm font-medium text-dark-300">{label}</label>
{(['en', 'ru'] as const).map((lang) => (
<div key={lang} className="flex gap-2 items-center">
<span className="w-8 text-xs text-dark-500 uppercase">{lang}</span>
<textarea
value={value[lang] || ''}
onChange={(e) => updateLocalizedText(stepKey, field, lang, e.target.value)}
className="input flex-1 text-sm"
rows={2}
/>
</div>
))}
</div>
)
}
const renderButtonsEditor = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => {
const step = editedApp[stepKey]
const buttons = step?.buttons || []
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-dark-300">{t('admin.apps.buttons')}</label>
<button
type="button"
onClick={() => addButton(stepKey)}
className="text-xs text-accent-400 hover:text-accent-300"
>
+ {t('admin.apps.addButton')}
</button>
</div>
{buttons.map((button, index) => (
<div key={index} className="p-3 bg-dark-800/50 rounded-lg space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-dark-400">{t('admin.apps.button')} #{index + 1}</span>
<button
type="button"
onClick={() => removeButton(stepKey, index)}
className="text-error-400 hover:text-error-300"
>
<TrashIcon />
</button>
</div>
<input
type="url"
value={button.buttonLink}
onChange={(e) => updateButton(stepKey, index, 'buttonLink', e.target.value)}
placeholder="https://..."
className="input w-full text-sm"
/>
{(['en', 'ru'] as const).map((lang) => (
<div key={lang} className="flex gap-2 items-center">
<span className="w-8 text-xs text-dark-500 uppercase">{lang}</span>
<input
type="text"
value={button.buttonText[lang] || ''}
onChange={(e) => {
const newButtonText = { ...button.buttonText, [lang]: e.target.value }
updateButton(stepKey, index, 'buttonText', newButtonText)
}}
placeholder={t('admin.apps.buttonText')}
className="input flex-1 text-sm"
/>
</div>
))}
</div>
))}
</div>
)
}
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-dark-900 rounded-xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
<h2 className="text-lg font-semibold text-dark-100">
{isNew ? t('admin.apps.createApp') : t('admin.apps.editApp')} - {PLATFORM_LABELS[platform]}
</h2>
<button onClick={onClose} className="text-dark-400 hover:text-dark-200">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-dark-700 overflow-x-auto">
{(['basic', 'installation', 'subscription', 'connect', 'additional'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap ${
activeTab === tab
? 'text-accent-400 border-b-2 border-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
{t(`admin.apps.tabs.${tab}`)}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{activeTab === 'basic' && (
<>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.apps.appId')}</label>
<input
type="text"
value={editedApp.id}
onChange={(e) => updateField('id', e.target.value)}
className="input w-full"
disabled={!isNew}
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.apps.appName')}</label>
<input
type="text"
value={editedApp.name}
onChange={(e) => updateField('name', e.target.value)}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.apps.urlScheme')}</label>
<input
type="text"
value={editedApp.urlScheme}
onChange={(e) => updateField('urlScheme', e.target.value)}
className="input w-full"
placeholder="app://add/"
/>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editedApp.isFeatured}
onChange={(e) => updateField('isFeatured', e.target.checked)}
className="w-4 h-4 accent-accent-500"
/>
<span className="text-sm text-dark-300">{t('admin.apps.featured')}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editedApp.isNeedBase64Encoding || false}
onChange={(e) => updateField('isNeedBase64Encoding', e.target.checked || undefined)}
className="w-4 h-4 accent-accent-500"
/>
<span className="text-sm text-dark-300">{t('admin.apps.base64Encoding')}</span>
</label>
</div>
</>
)}
{activeTab === 'installation' && (
<>
{renderLocalizedTextInputs('installationStep', 'description', t('admin.apps.installDescription'))}
{renderButtonsEditor('installationStep')}
</>
)}
{activeTab === 'subscription' && (
<>
{renderLocalizedTextInputs('addSubscriptionStep', 'description', t('admin.apps.subscriptionDescription'))}
</>
)}
{activeTab === 'connect' && (
<>
{renderLocalizedTextInputs('connectAndUseStep', 'description', t('admin.apps.connectDescription'))}
</>
)}
{activeTab === 'additional' && (
<>
<div className="space-y-4">
<h3 className="text-sm font-semibold text-dark-200">{t('admin.apps.beforeSubscription')}</h3>
{renderLocalizedTextInputs('additionalBeforeAddSubscriptionStep', 'title', t('admin.apps.stepTitle'))}
{renderLocalizedTextInputs('additionalBeforeAddSubscriptionStep', 'description', t('admin.apps.stepDescription'))}
{renderButtonsEditor('additionalBeforeAddSubscriptionStep')}
</div>
<div className="border-t border-dark-700 pt-4 space-y-4">
<h3 className="text-sm font-semibold text-dark-200">{t('admin.apps.afterSubscription')}</h3>
{renderLocalizedTextInputs('additionalAfterAddSubscriptionStep', 'title', t('admin.apps.stepTitle'))}
{renderLocalizedTextInputs('additionalAfterAddSubscriptionStep', 'description', t('admin.apps.stepDescription'))}
{renderButtonsEditor('additionalAfterAddSubscriptionStep')}
</div>
</>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-dark-700 flex justify-end gap-3">
<button onClick={onClose} className="btn-secondary">
{t('common.cancel')}
</button>
<button onClick={() => onSave(editedApp)} className="btn-primary">
{t('common.save')}
</button>
</div>
</div>
</div>
)
}
export default function AdminApps() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selectedPlatform, setSelectedPlatform] = useState<string>('ios')
const [editingApp, setEditingApp] = useState<{ app: AppDefinition; isNew: boolean } | null>(null)
const [copyTarget, setCopyTarget] = useState<{ appId: string; platform: string } | null>(null)
const { data: apps, isLoading } = useQuery({
queryKey: ['admin-apps', selectedPlatform],
queryFn: () => adminAppsApi.getPlatformApps(selectedPlatform),
})
const createMutation = useMutation({
mutationFn: ({ platform, app }: { platform: string; app: AppDefinition }) =>
adminAppsApi.createApp(platform, app),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
setEditingApp(null)
},
})
const updateMutation = useMutation({
mutationFn: ({ platform, appId, app }: { platform: string; appId: string; app: AppDefinition }) =>
adminAppsApi.updateApp(platform, appId, app),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
setEditingApp(null)
},
})
const deleteMutation = useMutation({
mutationFn: ({ platform, appId }: { platform: string; appId: string }) =>
adminAppsApi.deleteApp(platform, appId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
},
})
const reorderMutation = useMutation({
mutationFn: ({ platform, appIds }: { platform: string; appIds: string[] }) =>
adminAppsApi.reorderApps(platform, appIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
},
})
const copyMutation = useMutation({
mutationFn: ({ platform, appId, targetPlatform }: { platform: string; appId: string; targetPlatform: string }) =>
adminAppsApi.copyApp(platform, appId, targetPlatform),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
setCopyTarget(null)
},
})
const handleMoveUp = (index: number) => {
if (!apps || index === 0) return
const newOrder = [...apps]
;[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]]
reorderMutation.mutate({ platform: selectedPlatform, appIds: newOrder.map((a) => a.id) })
}
const handleMoveDown = (index: number) => {
if (!apps || index === apps.length - 1) return
const newOrder = [...apps]
;[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]]
reorderMutation.mutate({ platform: selectedPlatform, appIds: newOrder.map((a) => a.id) })
}
const handleSave = (app: AppDefinition) => {
if (editingApp?.isNew) {
createMutation.mutate({ platform: selectedPlatform, app })
} else {
updateMutation.mutate({ platform: selectedPlatform, appId: app.id, app })
}
}
const handleDelete = (appId: string) => {
if (confirm(t('admin.apps.confirmDelete'))) {
deleteMutation.mutate({ platform: selectedPlatform, appId })
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<AppsIcon />
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('admin.apps.title')}</h1>
</div>
{/* Platform Tabs */}
<div className="flex flex-wrap gap-2">
{PLATFORMS.map((platform) => (
<button
key={platform}
onClick={() => setSelectedPlatform(platform)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPlatform === platform
? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{PLATFORM_LABELS[platform]}
</button>
))}
</div>
{/* Add App Button */}
<div className="flex justify-end">
<button
onClick={() => setEditingApp({ app: createEmptyApp(selectedPlatform), isNew: true })}
className="btn-primary flex items-center gap-2"
>
<PlusIcon />
{t('admin.apps.addApp')}
</button>
</div>
{/* Apps List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : apps && apps.length > 0 ? (
<div className="space-y-3">
{apps.map((app, index) => (
<div
key={app.id}
className="card flex items-center gap-4 p-4"
>
{/* Reorder buttons */}
<div className="flex flex-col gap-1">
<button
onClick={() => handleMoveUp(index)}
disabled={index === 0}
className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronUpIcon />
</button>
<button
onClick={() => handleMoveDown(index)}
disabled={index === apps.length - 1}
className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronDownIcon />
</button>
</div>
{/* App Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-dark-100">{app.name}</span>
{app.isFeatured && <StarIcon filled />}
{app.isNeedBase64Encoding && (
<span className="text-xs bg-dark-700 text-dark-400 px-2 py-0.5 rounded">Base64</span>
)}
</div>
<div className="text-xs text-dark-500 font-mono truncate">{app.id}</div>
<div className="text-xs text-dark-400 mt-1">{app.urlScheme}</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => setCopyTarget({ appId: app.id, platform: selectedPlatform })}
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded"
title={t('admin.apps.copyTo')}
>
<CopyIcon />
</button>
<button
onClick={() => setEditingApp({ app, isNew: false })}
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded"
title={t('common.edit')}
>
<EditIcon />
</button>
<button
onClick={() => handleDelete(app.id)}
className="p-2 text-error-400 hover:text-error-300 hover:bg-error-500/10 rounded"
title={t('common.delete')}
>
<TrashIcon />
</button>
</div>
</div>
))}
</div>
) : (
<div className="card text-center py-12">
<AppsIcon />
<p className="text-dark-400 mt-4">{t('admin.apps.noApps')}</p>
<button
onClick={() => setEditingApp({ app: createEmptyApp(selectedPlatform), isNew: true })}
className="btn-primary mt-4"
>
{t('admin.apps.addFirstApp')}
</button>
</div>
)}
{/* Edit Modal */}
{editingApp && (
<AppEditorModal
app={editingApp.app}
platform={selectedPlatform}
isNew={editingApp.isNew}
onSave={handleSave}
onClose={() => setEditingApp(null)}
/>
)}
{/* Copy Modal */}
{copyTarget && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-dark-900 rounded-xl p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.apps.copyTo')}</h3>
<div className="grid grid-cols-2 gap-2">
{PLATFORMS.filter((p) => p !== copyTarget.platform).map((platform) => (
<button
key={platform}
onClick={() => copyMutation.mutate({
platform: copyTarget.platform,
appId: copyTarget.appId,
targetPlatform: platform,
})}
className="px-4 py-2 bg-dark-800 hover:bg-dark-700 rounded-lg text-dark-200 text-sm"
>
{PLATFORM_LABELS[platform]}
</button>
))}
</div>
<button
onClick={() => setCopyTarget(null)}
className="btn-secondary w-full mt-4"
>
{t('common.cancel')}
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,502 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { statsApi, type DashboardStats, type NodeStatus } from '../api/admin'
import { useCurrency } from '../hooks/useCurrency'
// Icons
const ChartIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
)
const ServerIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
)
const UsersIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
const CurrencyIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
)
const SubscriptionIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
)
const RefreshIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
)
const PowerIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
)
const RestartIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
)
interface StatCardProps {
title: string
value: string | number
subtitle?: string
icon: React.ReactNode
color: 'accent' | 'success' | 'warning' | 'error' | 'info'
trend?: {
value: number
label: string
}
}
function StatCard({ title, value, subtitle, icon, color, trend }: StatCardProps) {
const colorClasses = {
accent: 'bg-accent-500/20 text-accent-400',
success: 'bg-success-500/20 text-success-400',
warning: 'bg-warning-500/20 text-warning-400',
error: 'bg-error-500/20 text-error-400',
info: 'bg-info-500/20 text-info-400',
}
return (
<div className="bg-dark-800/50 backdrop-blur rounded-xl border border-dark-700 p-5 hover:border-dark-600 transition-colors">
<div className="flex items-start justify-between mb-3">
<div className={`p-2.5 rounded-lg ${colorClasses[color]}`}>
{icon}
</div>
{trend && (
<div className={`text-xs px-2 py-1 rounded-full ${trend.value >= 0 ? 'bg-success-500/20 text-success-400' : 'bg-error-500/20 text-error-400'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value}% {trend.label}
</div>
)}
</div>
<div className="text-2xl font-bold text-dark-100 mb-1">{value}</div>
<div className="text-sm text-dark-400">{title}</div>
{subtitle && <div className="text-xs text-dark-500 mt-1">{subtitle}</div>}
</div>
)
}
interface NodeCardProps {
node: NodeStatus
onRestart: (uuid: string) => void
onToggle: (uuid: string) => void
isLoading: boolean
}
function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) {
const { t } = useTranslation()
const getStatusColor = () => {
if (node.is_disabled) return 'bg-dark-600 text-dark-400'
if (node.is_connected) return 'bg-success-500/20 text-success-400'
return 'bg-error-500/20 text-error-400'
}
const getStatusText = () => {
if (node.is_disabled) return t('adminDashboard.nodes.disabled')
if (node.is_connected) return t('adminDashboard.nodes.online')
return t('adminDashboard.nodes.offline')
}
const formatTraffic = (bytes?: number) => {
if (!bytes) return '-'
const gb = bytes / (1024 * 1024 * 1024)
if (gb >= 1000) return `${(gb / 1000).toFixed(1)} TB`
return `${gb.toFixed(1)} GB`
}
return (
<div className={`bg-dark-800/50 backdrop-blur rounded-xl border ${node.is_disabled ? 'border-dark-700' : node.is_connected ? 'border-success-500/30' : 'border-error-500/30'} p-4 hover:border-dark-600 transition-colors`}>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${node.is_disabled ? 'bg-dark-500' : node.is_connected ? 'bg-success-500 animate-pulse' : 'bg-error-500'}`} />
<div>
<div className="font-medium text-dark-100">{node.name}</div>
<div className="text-xs text-dark-500">{node.address}</div>
</div>
</div>
<span className={`text-xs px-2 py-1 rounded-full ${getStatusColor()}`}>
{getStatusText()}
</span>
</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="bg-dark-900/50 rounded-lg p-2.5">
<div className="text-xs text-dark-500 mb-0.5">{t('adminDashboard.nodes.usersOnline')}</div>
<div className="text-lg font-semibold text-dark-100">{node.users_online}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-2.5">
<div className="text-xs text-dark-500 mb-0.5">{t('adminDashboard.nodes.traffic')}</div>
<div className="text-lg font-semibold text-dark-100">{formatTraffic(node.traffic_used_bytes)}</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => onToggle(node.uuid)}
disabled={isLoading}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-sm font-medium transition-colors ${
node.is_disabled
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-warning-500/20 text-warning-400 hover:bg-warning-500/30'
} disabled:opacity-50`}
>
<PowerIcon />
{node.is_disabled ? t('adminDashboard.nodes.enable') : t('adminDashboard.nodes.disable')}
</button>
<button
onClick={() => onRestart(node.uuid)}
disabled={isLoading || node.is_disabled}
className="flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-sm font-medium bg-accent-500/20 text-accent-400 hover:bg-accent-500/30 transition-colors disabled:opacity-50"
>
<RestartIcon />
</button>
</div>
</div>
)
}
function RevenueChart({ data }: { data: { date: string; amount_rubles: number }[] }) {
const { t } = useTranslation()
const { formatAmount, currencySymbol } = useCurrency()
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-48 text-dark-500">
{t('common.noData')}
</div>
)
}
const maxValue = Math.max(...data.map(d => d.amount_rubles), 1)
const last7Days = data.slice(-7)
return (
<div className="h-48">
<div className="flex items-end justify-between h-36 gap-2">
{last7Days.map((item, index) => {
const height = (item.amount_rubles / maxValue) * 100
const date = new Date(item.date)
const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' })
return (
<div key={index} className="flex-1 flex flex-col items-center gap-2">
<div
className="w-full bg-accent-500/80 rounded-t-lg hover:bg-accent-500 transition-colors cursor-pointer group relative"
style={{ height: `${Math.max(height, 4)}%` }}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-dark-800 px-2 py-1 rounded text-xs text-dark-100 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 border border-dark-600">
{formatAmount(item.amount_rubles)} {currencySymbol}
</div>
</div>
<div className="text-xs text-dark-500">{dayName}</div>
</div>
)
})}
</div>
</div>
)
}
export default function AdminDashboard() {
const { t } = useTranslation()
const { formatAmount, currencySymbol } = useCurrency()
const [stats, setStats] = useState<DashboardStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const fetchStats = async () => {
try {
setLoading(true)
setError(null)
const data = await statsApi.getDashboardStats()
setStats(data)
} catch (err) {
setError(t('adminDashboard.loadError'))
console.error('Failed to load dashboard stats:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchStats()
// Refresh every 30 seconds
const interval = setInterval(fetchStats, 30000)
return () => clearInterval(interval)
}, [])
const handleRestartNode = async (uuid: string) => {
try {
setActionLoading(uuid)
await statsApi.restartNode(uuid)
// Refresh stats after action
setTimeout(fetchStats, 2000)
} catch (err) {
console.error('Failed to restart node:', err)
} finally {
setActionLoading(null)
}
}
const handleToggleNode = async (uuid: string) => {
try {
setActionLoading(uuid)
await statsApi.toggleNode(uuid)
await fetchStats()
} catch (err) {
console.error('Failed to toggle node:', err)
} finally {
setActionLoading(null)
}
}
if (loading && !stats) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error && !stats) {
return (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="text-error-400">{error}</div>
<button onClick={fetchStats} className="btn-primary">
{t('common.loading')}
</button>
</div>
)
}
return (
<div className="animate-fade-in space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-accent-500/20 rounded-xl">
<ChartIcon />
</div>
<div>
<h1 className="text-2xl font-bold text-dark-100">{t('adminDashboard.title')}</h1>
<p className="text-dark-400">{t('adminDashboard.subtitle')}</p>
</div>
</div>
<button
onClick={fetchStats}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-dark-800 rounded-lg text-dark-300 hover:text-dark-100 hover:bg-dark-700 transition-colors disabled:opacity-50"
>
<RefreshIcon />
{t('adminDashboard.refresh')}
</button>
</div>
{/* Main Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title={t('adminDashboard.stats.usersOnline')}
value={stats?.nodes.total_users_online || 0}
icon={<UsersIcon />}
color="success"
/>
<StatCard
title={t('adminDashboard.stats.activeSubscriptions')}
value={stats?.subscriptions.active || 0}
subtitle={`${t('adminDashboard.stats.total')}: ${stats?.subscriptions.total || 0}`}
icon={<SubscriptionIcon />}
color="accent"
/>
<StatCard
title={t('adminDashboard.stats.incomeToday')}
value={`${formatAmount(stats?.financial.income_today_rubles || 0)} ${currencySymbol}`}
icon={<CurrencyIcon />}
color="warning"
/>
<StatCard
title={t('adminDashboard.stats.incomeMonth')}
value={`${formatAmount(stats?.financial.income_month_rubles || 0)} ${currencySymbol}`}
icon={<CurrencyIcon />}
color="info"
/>
</div>
{/* Nodes Section */}
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<ServerIcon />
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.nodes.title')}</h2>
<p className="text-sm text-dark-400">
{stats?.nodes.online || 0} {t('adminDashboard.nodes.online').toLowerCase()} / {stats?.nodes.total || 0} {t('adminDashboard.stats.total').toLowerCase()}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 text-xs text-dark-400">
<span className="w-2 h-2 rounded-full bg-success-500"></span>
{stats?.nodes.online || 0}
</span>
<span className="flex items-center gap-1.5 text-xs text-dark-400">
<span className="w-2 h-2 rounded-full bg-error-500"></span>
{stats?.nodes.offline || 0}
</span>
<span className="flex items-center gap-1.5 text-xs text-dark-400">
<span className="w-2 h-2 rounded-full bg-dark-500"></span>
{stats?.nodes.disabled || 0}
</span>
</div>
</div>
{stats?.nodes.nodes && stats.nodes.nodes.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{stats.nodes.nodes.map((node) => (
<NodeCard
key={node.uuid}
node={node}
onRestart={handleRestartNode}
onToggle={handleToggleNode}
isLoading={actionLoading === node.uuid}
/>
))}
</div>
) : (
<div className="text-center py-8 text-dark-500">
{t('adminDashboard.nodes.noNodes')}
</div>
)}
</div>
{/* Revenue and Subscriptions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Chart */}
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center gap-3 mb-4">
<CurrencyIcon />
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.revenue.title')}</h2>
<p className="text-sm text-dark-400">{t('adminDashboard.revenue.last7Days')}</p>
</div>
</div>
<RevenueChart data={stats?.revenue_chart || []} />
<div className="grid grid-cols-2 gap-4 mt-4 pt-4 border-t border-dark-700">
<div>
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.stats.incomeTotal')}</div>
<div className="text-xl font-bold text-dark-100">{formatAmount(stats?.financial.income_total_rubles || 0)} {currencySymbol}</div>
</div>
<div>
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.stats.subscriptionIncome')}</div>
<div className="text-xl font-bold text-accent-400">{formatAmount(stats?.financial.subscription_income_rubles || 0)} {currencySymbol}</div>
</div>
</div>
</div>
{/* Subscription Stats */}
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center gap-3 mb-4">
<SubscriptionIcon />
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.subscriptions.title')}</h2>
<p className="text-sm text-dark-400">{t('adminDashboard.subscriptions.subtitle')}</p>
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.subscriptions.active')}</div>
<div className="text-2xl font-bold text-success-400">{stats?.subscriptions.active || 0}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.subscriptions.trial')}</div>
<div className="text-2xl font-bold text-warning-400">{stats?.subscriptions.trial || 0}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.subscriptions.paid')}</div>
<div className="text-2xl font-bold text-accent-400">{stats?.subscriptions.paid || 0}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.subscriptions.expired')}</div>
<div className="text-2xl font-bold text-error-400">{stats?.subscriptions.expired || 0}</div>
</div>
</div>
<div className="border-t border-dark-700 pt-4">
<div className="text-sm font-medium text-dark-300 mb-3">{t('adminDashboard.subscriptions.newSubscriptions')}</div>
<div className="grid grid-cols-3 gap-3">
<div className="text-center">
<div className="text-xl font-bold text-dark-100">{stats?.subscriptions.purchased_today || 0}</div>
<div className="text-xs text-dark-500">{t('adminDashboard.subscriptions.today')}</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-dark-100">{stats?.subscriptions.purchased_week || 0}</div>
<div className="text-xs text-dark-500">{t('adminDashboard.subscriptions.week')}</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-dark-100">{stats?.subscriptions.purchased_month || 0}</div>
<div className="text-xs text-dark-500">{t('adminDashboard.subscriptions.month')}</div>
</div>
</div>
</div>
{stats?.subscriptions.trial_to_paid_conversion !== undefined && (
<div className="bg-accent-500/10 rounded-lg p-4 border border-accent-500/20">
<div className="flex items-center justify-between">
<span className="text-sm text-dark-300">{t('adminDashboard.subscriptions.conversion')}</span>
<span className="text-lg font-bold text-accent-400">{stats.subscriptions.trial_to_paid_conversion.toFixed(1)}%</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* Server Stats */}
{stats?.servers && (
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center gap-3 mb-4">
<ServerIcon />
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.servers.title')}</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.total')}</div>
<div className="text-2xl font-bold text-dark-100">{stats.servers.total_servers}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.available')}</div>
<div className="text-2xl font-bold text-success-400">{stats.servers.available_servers}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.withConnections')}</div>
<div className="text-2xl font-bold text-accent-400">{stats.servers.servers_with_connections}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.revenue')}</div>
<div className="text-2xl font-bold text-warning-400">{formatAmount(stats.servers.total_revenue_rubles)} {currencySymbol}</div>
</div>
</div>
</div>
)}
</div>
)
}

155
src/pages/AdminPanel.tsx Normal file
View File

@@ -0,0 +1,155 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
// Icons
const TicketIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
</svg>
)
const CogIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
const PhoneIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
)
const WheelIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)
const TariffIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
)
const ServerIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
)
const AdminIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
)
const ChartIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
)
interface AdminCardProps {
to: string
icon: React.ReactNode
title: string
description: string
color: string
}
function AdminCard({ to, icon, title, description, color }: AdminCardProps) {
return (
<Link
to={to}
className={`block p-6 bg-dark-800 rounded-xl border border-dark-700 hover:border-${color}-500/50 hover:bg-dark-750 transition-all duration-200 group`}
>
<div className={`w-14 h-14 rounded-xl bg-${color}-500/20 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
<div className={`text-${color}-400`}>
{icon}
</div>
</div>
<h3 className="text-lg font-semibold text-dark-100 mb-1">{title}</h3>
<p className="text-sm text-dark-400">{description}</p>
</Link>
)
}
export default function AdminPanel() {
const { t } = useTranslation()
const adminSections = [
{
to: '/admin/dashboard',
icon: <ChartIcon />,
title: t('admin.nav.dashboard'),
description: t('admin.panel.dashboardDesc'),
color: 'success'
},
{
to: '/admin/tickets',
icon: <TicketIcon />,
title: t('admin.nav.tickets'),
description: t('admin.panel.ticketsDesc'),
color: 'warning'
},
{
to: '/admin/settings',
icon: <CogIcon />,
title: t('admin.nav.settings'),
description: t('admin.panel.settingsDesc'),
color: 'accent'
},
{
to: '/admin/apps',
icon: <PhoneIcon />,
title: t('admin.nav.apps'),
description: t('admin.panel.appsDesc'),
color: 'success'
},
{
to: '/admin/wheel',
icon: <WheelIcon />,
title: t('admin.nav.wheel'),
description: t('admin.panel.wheelDesc'),
color: 'error'
},
{
to: '/admin/tariffs',
icon: <TariffIcon />,
title: t('admin.nav.tariffs'),
description: t('admin.panel.tariffsDesc'),
color: 'info'
},
{
to: '/admin/servers',
icon: <ServerIcon />,
title: t('admin.nav.servers'),
description: t('admin.panel.serversDesc'),
color: 'purple'
},
]
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<div className="p-3 bg-warning-500/20 rounded-xl">
<AdminIcon />
</div>
<div>
<h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
<p className="text-dark-400">{t('admin.panel.subtitle')}</p>
</div>
</div>
{/* Grid of admin sections */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{adminSections.map((section) => (
<AdminCard key={section.to} {...section} />
))}
</div>
</div>
)
}

470
src/pages/AdminServers.tsx Normal file
View File

@@ -0,0 +1,470 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
serversApi,
ServerListItem,
ServerDetail,
ServerUpdateRequest
} from '../api/servers'
// Icons
const ServerIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
)
const SyncIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
)
const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
const XIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const UsersIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
// Country flags (simple emoji mapping)
const getCountryFlag = (code: string | null): string => {
if (!code) return ''
const codeMap: Record<string, string> = {
'RU': '🇷🇺', 'US': '🇺🇸', 'DE': '🇩🇪', 'NL': '🇳🇱', 'GB': '🇬🇧',
'FR': '🇫🇷', 'FI': '🇫🇮', 'SE': '🇸🇪', 'PL': '🇵🇱', 'CZ': '🇨🇿',
'AT': '🇦🇹', 'CH': '🇨🇭', 'UA': '🇺🇦', 'KZ': '🇰🇿', 'JP': '🇯🇵',
'KR': '🇰🇷', 'SG': '🇸🇬', 'HK': '🇭🇰', 'CA': '🇨🇦', 'AU': '🇦🇺',
'BR': '🇧🇷', 'IN': '🇮🇳', 'TR': '🇹🇷', 'IL': '🇮🇱', 'AE': '🇦🇪',
}
return codeMap[code.toUpperCase()] || code
}
interface ServerModalProps {
server: ServerDetail
onSave: (data: ServerUpdateRequest) => void
onClose: () => void
isLoading?: boolean
}
function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
const { t } = useTranslation()
const [displayName, setDisplayName] = useState(server.display_name)
const [description, setDescription] = useState(server.description || '')
const [countryCode, setCountryCode] = useState(server.country_code || '')
const [priceKopeks, setPriceKopeks] = useState(server.price_kopeks)
const [maxUsers, setMaxUsers] = useState<number | null>(server.max_users)
const [sortOrder, setSortOrder] = useState(server.sort_order)
const handleSubmit = () => {
const data: ServerUpdateRequest = {
display_name: displayName,
description: description || undefined,
country_code: countryCode || undefined,
price_kopeks: priceKopeks,
max_users: maxUsers || undefined,
sort_order: sortOrder,
}
onSave(data)
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center gap-2">
<span className="text-xl">{getCountryFlag(server.country_code)}</span>
<h2 className="text-lg font-semibold text-dark-100">
{t('admin.servers.edit')}
</h2>
</div>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Original Name (readonly) */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.originalName')}</label>
<div className="px-3 py-2 bg-dark-700/50 border border-dark-600 rounded-lg text-dark-400">
{server.original_name || server.squad_uuid}
</div>
</div>
{/* Display Name */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.displayName')}</label>
<input
type="text"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
placeholder={t('admin.servers.displayNamePlaceholder')}
/>
</div>
{/* Description */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.description')}</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none"
rows={2}
placeholder={t('admin.servers.descriptionPlaceholder')}
/>
</div>
{/* Country Code */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.countryCode')}</label>
<input
type="text"
value={countryCode}
onChange={e => setCountryCode(e.target.value.toUpperCase().slice(0, 2))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
placeholder="RU"
maxLength={2}
/>
{countryCode && (
<span className="ml-2 text-xl">{getCountryFlag(countryCode)}</span>
)}
</div>
{/* Price */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.price')}</label>
<div className="flex items-center gap-2">
<input
type="number"
value={priceKopeks / 100}
onChange={e => setPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={1}
/>
<span className="text-dark-400"></span>
</div>
<p className="text-xs text-dark-500 mt-1">{t('admin.servers.priceHint')}</p>
</div>
{/* Max Users */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.maxUsers')}</label>
<div className="flex items-center gap-2">
<input
type="number"
value={maxUsers || ''}
onChange={e => setMaxUsers(e.target.value ? Math.max(0, parseInt(e.target.value)) : null)}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
placeholder={t('admin.servers.unlimited')}
/>
{!maxUsers && (
<span className="text-sm text-dark-400">{t('admin.servers.unlimited')}</span>
)}
</div>
</div>
{/* Sort Order */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.sortOrder')}</label>
<input
type="number"
value={sortOrder}
onChange={e => setSortOrder(parseInt(e.target.value) || 0)}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
/>
</div>
{/* Stats */}
<div className="pt-4 border-t border-dark-700">
<h4 className="text-sm font-medium text-dark-300 mb-2">{t('admin.servers.stats')}</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="p-2 bg-dark-700/50 rounded-lg">
<span className="text-dark-400">{t('admin.servers.currentUsers')}:</span>
<span className="ml-2 text-dark-200">{server.current_users}</span>
</div>
<div className="p-2 bg-dark-700/50 rounded-lg">
<span className="text-dark-400">{t('admin.servers.activeSubscriptions')}:</span>
<span className="ml-2 text-dark-200">{server.active_subscriptions}</span>
</div>
</div>
{server.tariffs_using.length > 0 && (
<div className="mt-2 p-2 bg-dark-700/50 rounded-lg">
<span className="text-dark-400 text-sm">{t('admin.servers.usedByTariffs')}:</span>
<div className="flex flex-wrap gap-1 mt-1">
{server.tariffs_using.map(tariff => (
<span key={tariff} className="px-2 py-0.5 bg-dark-600 text-dark-300 text-xs rounded">
{tariff}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!displayName || isLoading}
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? t('common.loading') : t('common.save')}
</button>
</div>
</div>
</div>
)
}
export default function AdminServers() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [editingServer, setEditingServer] = useState<ServerDetail | null>(null)
const [loadingServerId, setLoadingServerId] = useState<number | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
// Queries
const { data: serversData, isLoading } = useQuery({
queryKey: ['admin-servers'],
queryFn: () => serversApi.getServers(true),
})
// Mutations
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: ServerUpdateRequest }) =>
serversApi.updateServer(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
setEditingServer(null)
},
})
const toggleMutation = useMutation({
mutationFn: serversApi.toggleServer,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
},
})
const toggleTrialMutation = useMutation({
mutationFn: serversApi.toggleTrial,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
},
})
const syncMutation = useMutation({
mutationFn: serversApi.syncServers,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
alert(result.message)
},
})
const handleEdit = async (serverId: number) => {
setLoadingServerId(serverId)
setLoadError(null)
try {
const detail = await serversApi.getServer(serverId)
setEditingServer(detail)
} catch (error: unknown) {
console.error('Failed to load server:', error)
const errorMessage = error instanceof Error ? error.message : t('admin.servers.loadError')
setLoadError(errorMessage)
} finally {
setLoadingServerId(null)
}
}
const handleSave = (data: ServerUpdateRequest) => {
if (editingServer) {
updateMutation.mutate({ id: editingServer.id, data })
}
}
const servers = serversData?.servers || []
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg">
<ServerIcon />
</div>
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.servers.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.servers.subtitle')}</p>
</div>
</div>
<button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50"
>
<SyncIcon />
{syncMutation.isPending ? t('admin.servers.syncing') : t('admin.servers.sync')}
</button>
</div>
{/* Servers List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : servers.length === 0 ? (
<div className="text-center py-12">
<p className="text-dark-400">{t('admin.servers.noServers')}</p>
<button
onClick={() => syncMutation.mutate()}
className="mt-4 text-accent-400 hover:text-accent-300"
>
{t('admin.servers.syncNow')}
</button>
</div>
) : (
<div className="space-y-3">
{servers.map((server: ServerListItem) => (
<div
key={server.id}
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
server.is_available ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{getCountryFlag(server.country_code)}</span>
<h3 className="font-medium text-dark-100 truncate">{server.display_name}</h3>
{server.is_trial_eligible && (
<span className="px-2 py-0.5 text-xs bg-success-500/20 text-success-400 rounded">
{t('admin.servers.trial')}
</span>
)}
{!server.is_available && (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
{t('admin.servers.unavailable')}
</span>
)}
{server.is_full && (
<span className="px-2 py-0.5 text-xs bg-warning-500/20 text-warning-400 rounded">
{t('admin.servers.full')}
</span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
<span className="flex items-center gap-1">
<UsersIcon />
{server.current_users}
{server.max_users ? ` / ${server.max_users}` : ''}
</span>
<span>{server.price_rubles} </span>
<span className="text-dark-500 text-xs font-mono truncate max-w-[200px]">{server.squad_uuid}</span>
</div>
</div>
<div className="flex items-center gap-2">
{/* Toggle Available */}
<button
onClick={() => toggleMutation.mutate(server.id)}
className={`p-2 rounded-lg transition-colors ${
server.is_available
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={server.is_available ? t('admin.servers.disable') : t('admin.servers.enable')}
>
{server.is_available ? <CheckIcon /> : <XIcon />}
</button>
{/* Toggle Trial */}
<button
onClick={() => toggleTrialMutation.mutate(server.id)}
className={`p-2 rounded-lg transition-colors ${
server.is_trial_eligible
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={t('admin.servers.toggleTrial')}
>
T
</button>
{/* Edit */}
<button
onClick={() => handleEdit(server.id)}
disabled={loadingServerId === server.id}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors disabled:opacity-50"
title={t('admin.servers.edit')}
>
{loadingServerId === server.id ? (
<div className="w-4 h-4 border-2 border-dark-300 border-t-transparent rounded-full animate-spin" />
) : (
<EditIcon />
)}
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Error Toast */}
{loadError && (
<div className="fixed bottom-4 right-4 bg-error-500/90 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 z-50">
<span>{loadError}</span>
<button
onClick={() => setLoadError(null)}
className="p-1 hover:bg-white/20 rounded"
>
<XIcon />
</button>
</div>
)}
{/* Edit Modal */}
{editingServer && (
<ServerModal
server={editingServer}
onSave={handleSave}
onClose={() => setEditingServer(null)}
isLoading={updateMutation.isPending}
/>
)}
</div>
)
}

1442
src/pages/AdminSettings.tsx Normal file

File diff suppressed because it is too large Load Diff

893
src/pages/AdminTariffs.tsx Normal file
View File

@@ -0,0 +1,893 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
tariffsApi,
TariffListItem,
TariffDetail,
TariffCreateRequest,
TariffUpdateRequest,
PeriodPrice,
ServerInfo,
ServerTrafficLimit
} from '../api/tariffs'
// Icons
const TariffIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
)
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)
const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
)
const TrashIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
const XIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const InfinityIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zm0 0c0 1.657 1.007 3 2.25 3S21 13.657 21 12a9 9 0 10-2.636 6.364M16.5 12V8.25" />
</svg>
)
// Default period options
const DEFAULT_PERIODS = [7, 14, 30, 90, 180, 365]
interface TariffModalProps {
tariff?: TariffDetail | null
servers: ServerInfo[]
onSave: (data: TariffCreateRequest | TariffUpdateRequest) => void
onClose: () => void
isLoading?: boolean
}
function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModalProps) {
const { t } = useTranslation()
const isEdit = !!tariff
const [name, setName] = useState(tariff?.name || '')
const [description, setDescription] = useState(tariff?.description || '')
const [trafficLimitGb, setTrafficLimitGb] = useState(tariff?.traffic_limit_gb || 0)
const [deviceLimit, setDeviceLimit] = useState(tariff?.device_limit || 1)
const [devicePriceKopeks, setDevicePriceKopeks] = useState(tariff?.device_price_kopeks || 0)
const [tierLevel, setTierLevel] = useState(tariff?.tier_level || 1)
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>(
tariff?.period_prices || DEFAULT_PERIODS.map(d => ({ days: d, price_kopeks: 0 }))
)
const [selectedSquads, setSelectedSquads] = useState<string[]>(tariff?.allowed_squads || [])
const [serverTrafficLimits, setServerTrafficLimits] = useState<Record<string, ServerTrafficLimit>>(
tariff?.server_traffic_limits || {}
)
// Произвольное количество дней
const [customDaysEnabled, setCustomDaysEnabled] = useState(tariff?.custom_days_enabled || false)
const [pricePerDayKopeks, setPricePerDayKopeks] = useState(tariff?.price_per_day_kopeks || 0)
const [minDays, setMinDays] = useState(tariff?.min_days || 1)
const [maxDays, setMaxDays] = useState(tariff?.max_days || 365)
// Произвольный трафик
const [customTrafficEnabled, setCustomTrafficEnabled] = useState(tariff?.custom_traffic_enabled || false)
const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState(tariff?.traffic_price_per_gb_kopeks || 0)
const [minTrafficGb, setMinTrafficGb] = useState(tariff?.min_traffic_gb || 1)
const [maxTrafficGb, setMaxTrafficGb] = useState(tariff?.max_traffic_gb || 1000)
// Докупка трафика
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0)
const [trafficTopupPackages, setTrafficTopupPackages] = useState<Record<string, number>>(
tariff?.traffic_topup_packages || {}
)
// Дневной тариф
const [isDaily, setIsDaily] = useState(tariff?.is_daily || false)
const [dailyPriceKopeks, setDailyPriceKopeks] = useState(tariff?.daily_price_kopeks || 0)
const [activeTab, setActiveTab] = useState<'basic' | 'prices' | 'servers' | 'custom'>('basic')
const handleSubmit = () => {
// Фильтруем лимиты только для выбранных серверов и только если они заданы (> 0)
const filteredLimits: Record<string, ServerTrafficLimit> = {}
for (const uuid of selectedSquads) {
if (serverTrafficLimits[uuid] && serverTrafficLimits[uuid].traffic_limit_gb > 0) {
filteredLimits[uuid] = serverTrafficLimits[uuid]
}
}
const data: TariffCreateRequest | TariffUpdateRequest = {
name,
description: description || undefined,
traffic_limit_gb: trafficLimitGb,
device_limit: deviceLimit,
device_price_kopeks: devicePriceKopeks > 0 ? devicePriceKopeks : undefined,
tier_level: tierLevel,
period_prices: periodPrices.filter(p => p.price_kopeks > 0),
allowed_squads: selectedSquads,
server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
// Произвольное количество дней
custom_days_enabled: customDaysEnabled,
price_per_day_kopeks: pricePerDayKopeks,
min_days: minDays,
max_days: maxDays,
// Произвольный трафик
custom_traffic_enabled: customTrafficEnabled,
traffic_price_per_gb_kopeks: trafficPricePerGbKopeks,
min_traffic_gb: minTrafficGb,
max_traffic_gb: maxTrafficGb,
// Докупка трафика
traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages,
max_topup_traffic_gb: maxTopupTrafficGb,
// Дневной тариф
is_daily: isDaily,
daily_price_kopeks: dailyPriceKopeks,
}
onSave(data)
}
const updateServerTrafficLimit = (uuid: string, limitGb: number) => {
setServerTrafficLimits(prev => ({
...prev,
[uuid]: { traffic_limit_gb: limitGb }
}))
}
const toggleServer = (uuid: string) => {
setSelectedSquads(prev =>
prev.includes(uuid)
? prev.filter(s => s !== uuid)
: [...prev, uuid]
)
}
const updatePeriodPrice = (days: number, priceKopeks: number) => {
setPeriodPrices(prev => {
const existing = prev.find(p => p.days === days)
if (existing) {
return prev.map(p => p.days === days ? { ...p, price_kopeks: priceKopeks } : p)
}
return [...prev, { days, price_kopeks: priceKopeks }]
})
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<h2 className="text-lg font-semibold text-dark-100">
{isEdit ? t('admin.tariffs.edit') : t('admin.tariffs.create')}
</h2>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-dark-700">
{(['basic', 'prices', 'servers', 'custom'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'text-accent-400 border-b-2 border-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
{tab === 'basic' && t('admin.tariffs.tabs.basic')}
{tab === 'prices' && t('admin.tariffs.tabs.prices')}
{tab === 'servers' && t('admin.tariffs.tabs.servers')}
{tab === 'custom' && 'Гибкие опции'}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'basic' && (
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.name')}</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
placeholder={t('admin.tariffs.namePlaceholder')}
/>
</div>
{/* Description */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.description')}</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none"
rows={3}
placeholder={t('admin.tariffs.descriptionPlaceholder')}
/>
</div>
{/* Traffic Limit */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.trafficLimit')}</label>
<div className="flex items-center gap-2">
<input
type="number"
value={trafficLimitGb}
onChange={e => setTrafficLimitGb(Math.max(0, parseInt(e.target.value) || 0))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
/>
<span className="text-dark-400">GB</span>
{trafficLimitGb === 0 && (
<span className="flex items-center gap-1 text-sm text-success-500">
<InfinityIcon />
{t('admin.tariffs.unlimited')}
</span>
)}
</div>
<p className="text-xs text-dark-500 mt-1">{t('admin.tariffs.trafficHint')}</p>
</div>
{/* Device Limit */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.deviceLimit')}</label>
<input
type="number"
value={deviceLimit}
onChange={e => setDeviceLimit(Math.max(1, parseInt(e.target.value) || 1))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
{/* Tier Level */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.tierLevel')}</label>
<input
type="number"
value={tierLevel}
onChange={e => setTierLevel(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
max={10}
/>
<p className="text-xs text-dark-500 mt-1">{t('admin.tariffs.tierHint')}</p>
</div>
</div>
)}
{activeTab === 'prices' && (
<div className="space-y-3">
<p className="text-sm text-dark-400 mb-4">{t('admin.tariffs.pricesHint')}</p>
{DEFAULT_PERIODS.map(days => {
const price = periodPrices.find(p => p.days === days)?.price_kopeks || 0
const isEnabled = price > 0
return (
<div
key={days}
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
isEnabled
? 'bg-dark-700/50'
: 'bg-dark-800/30 opacity-60'
}`}
>
{/* Toggle */}
<button
type="button"
onClick={() => {
if (isEnabled) {
updatePeriodPrice(days, 0)
} else {
updatePeriodPrice(days, 10000) // Default 100₽
}
}}
className={`w-10 h-6 rounded-full transition-colors relative ${
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
isEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
<span className="w-20 text-dark-300">{days} {t('admin.tariffs.days')}</span>
<input
type="number"
value={price / 100}
onChange={e => updatePeriodPrice(days, Math.max(0, parseFloat(e.target.value) || 0) * 100)}
disabled={!isEnabled}
className={`w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 ${
!isEnabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
min={0}
step={1}
/>
<span className="text-dark-400"></span>
{!isEnabled && (
<span className="text-xs text-dark-500">{t('admin.tariffs.periodDisabled')}</span>
)}
</div>
)
})}
</div>
)}
{activeTab === 'servers' && (
<div className="space-y-2">
<p className="text-sm text-dark-400 mb-4">{t('admin.tariffs.serversHint')}</p>
{servers.length === 0 ? (
<p className="text-dark-500 text-center py-4">{t('admin.tariffs.noServers')}</p>
) : (
servers.map(server => {
const isSelected = selectedSquads.includes(server.squad_uuid)
const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
return (
<div
key={server.id}
className={`p-3 rounded-lg transition-colors ${
isSelected
? 'bg-accent-500/20 border border-accent-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'
}`}
>
<div
onClick={() => toggleServer(server.squad_uuid)}
className="flex items-center gap-3 cursor-pointer"
>
<div className={`w-5 h-5 rounded flex items-center justify-center ${
isSelected
? 'bg-accent-500 text-white'
: 'bg-dark-600'
}`}>
{isSelected && <CheckIcon />}
</div>
<span className="text-dark-200 flex-1">{server.display_name}</span>
{server.country_code && (
<span className="text-xs text-dark-500">{server.country_code}</span>
)}
</div>
{/* Лимит трафика для сервера */}
{isSelected && (
<div className="mt-2 ml-8 flex items-center gap-2">
<span className="text-xs text-dark-400">{t('admin.tariffs.serverTrafficLimit')}:</span>
<input
type="number"
value={serverLimit}
onClick={e => e.stopPropagation()}
onChange={e => {
e.stopPropagation()
updateServerTrafficLimit(server.squad_uuid, Math.max(0, parseInt(e.target.value) || 0))
}}
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
placeholder="0"
/>
<span className="text-xs text-dark-400">GB</span>
{serverLimit === 0 && (
<span className="text-xs text-dark-500">({t('admin.tariffs.useDefault')})</span>
)}
</div>
)}
</div>
)
})
)}
</div>
)}
{activeTab === 'custom' && (
<div className="space-y-6">
{/* Дневной тариф */}
<div className="p-4 bg-dark-700/50 rounded-lg border border-amber-500/30">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-medium text-amber-400">🌙 Дневной тариф</h4>
<p className="text-xs text-dark-500 mt-1">Ежедневное списание с баланса. Можно ставить на паузу.</p>
</div>
<button
type="button"
onClick={() => setIsDaily(!isDaily)}
className={`w-10 h-6 rounded-full transition-colors relative ${
isDaily ? 'bg-amber-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
isDaily ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{isDaily && (
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Цена за день:</span>
<input
type="number"
value={dailyPriceKopeks / 100}
onChange={e => setDailyPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-amber-500"
min={0}
step={0.1}
/>
<span className="text-dark-400">/день</span>
</div>
)}
</div>
{/* Цена за доп. устройство */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<h4 className="text-sm font-medium text-dark-200 mb-3">Докупка устройств</h4>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400">Цена за устройство (30 дней):</span>
<input
type="number"
value={devicePriceKopeks / 100}
onChange={e => setDevicePriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={1}
/>
<span className="text-dark-400"></span>
</div>
<p className="text-xs text-dark-500 mt-1">0 = докупка недоступна</p>
</div>
{/* Произвольное количество дней */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-dark-200">Произвольное кол-во дней</h4>
<button
type="button"
onClick={() => setCustomDaysEnabled(!customDaysEnabled)}
className={`w-10 h-6 rounded-full transition-colors relative ${
customDaysEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
customDaysEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{customDaysEnabled && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Цена за день:</span>
<input
type="number"
value={pricePerDayKopeks / 100}
onChange={e => setPricePerDayKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={0.1}
/>
<span className="text-dark-400"></span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Мин. дней:</span>
<input
type="number"
value={minDays}
onChange={e => setMinDays(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Макс. дней:</span>
<input
type="number"
value={maxDays}
onChange={e => setMaxDays(Math.max(1, parseInt(e.target.value) || 365))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
</div>
)}
</div>
{/* Произвольный трафик */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-dark-200">Произвольный трафик при покупке</h4>
<button
type="button"
onClick={() => setCustomTrafficEnabled(!customTrafficEnabled)}
className={`w-10 h-6 rounded-full transition-colors relative ${
customTrafficEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
customTrafficEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{customTrafficEnabled && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Цена за 1 ГБ:</span>
<input
type="number"
value={trafficPricePerGbKopeks / 100}
onChange={e => setTrafficPricePerGbKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={0.1}
/>
<span className="text-dark-400"></span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Мин. ГБ:</span>
<input
type="number"
value={minTrafficGb}
onChange={e => setMinTrafficGb(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Макс. ГБ:</span>
<input
type="number"
value={maxTrafficGb}
onChange={e => setMaxTrafficGb(Math.max(1, parseInt(e.target.value) || 1000))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
</div>
)}
</div>
{/* Докупка трафика */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-dark-200">Докупка трафика (после покупки)</h4>
<button
type="button"
onClick={() => setTrafficTopupEnabled(!trafficTopupEnabled)}
className={`w-10 h-6 rounded-full transition-colors relative ${
trafficTopupEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
trafficTopupEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{trafficTopupEnabled && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Макс. лимит:</span>
<input
type="number"
value={maxTopupTrafficGb}
onChange={e => setMaxTopupTrafficGb(Math.max(0, parseInt(e.target.value) || 0))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
/>
<span className="text-dark-400">ГБ</span>
<span className="text-xs text-dark-500">(0 = без ограничений)</span>
</div>
<div className="mt-3">
<span className="text-sm text-dark-400">Пакеты трафика (ГБ: цена ):</span>
<div className="mt-2 grid grid-cols-2 gap-2">
{[5, 10, 20, 50].map(gb => (
<div key={gb} className="flex items-center gap-2">
<span className="text-sm text-dark-300 w-12">{gb} ГБ:</span>
<input
type="number"
value={(trafficTopupPackages[String(gb)] || 0) / 100}
onChange={e => {
const price = Math.max(0, parseFloat(e.target.value) || 0) * 100
setTrafficTopupPackages(prev => ({
...prev,
[String(gb)]: price
}))
}}
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={1}
/>
<span className="text-xs text-dark-400"></span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!name || isLoading}
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? t('common.loading') : t('common.save')}
</button>
</div>
</div>
</div>
)
}
export default function AdminTariffs() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [showModal, setShowModal] = useState(false)
const [editingTariff, setEditingTariff] = useState<TariffDetail | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
// Queries
const { data: tariffsData, isLoading } = useQuery({
queryKey: ['admin-tariffs'],
queryFn: () => tariffsApi.getTariffs(true),
})
const { data: servers = [] } = useQuery({
queryKey: ['admin-tariffs-servers'],
queryFn: () => tariffsApi.getAvailableServers(),
})
// Mutations
const createMutation = useMutation({
mutationFn: tariffsApi.createTariff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
setShowModal(false)
},
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: TariffUpdateRequest }) =>
tariffsApi.updateTariff(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
setShowModal(false)
setEditingTariff(null)
},
})
const deleteMutation = useMutation({
mutationFn: tariffsApi.deleteTariff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
setDeleteConfirm(null)
},
})
const toggleMutation = useMutation({
mutationFn: tariffsApi.toggleTariff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
},
})
const toggleTrialMutation = useMutation({
mutationFn: tariffsApi.toggleTrial,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
},
})
const handleEdit = async (tariffId: number) => {
try {
const detail = await tariffsApi.getTariff(tariffId)
setEditingTariff(detail)
setShowModal(true)
} catch (error) {
console.error('Failed to load tariff:', error)
}
}
const handleSave = (data: TariffCreateRequest | TariffUpdateRequest) => {
if (editingTariff) {
updateMutation.mutate({ id: editingTariff.id, data })
} else {
createMutation.mutate(data as TariffCreateRequest)
}
}
const tariffs = tariffsData?.tariffs || []
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg">
<TariffIcon />
</div>
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.tariffs.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.tariffs.subtitle')}</p>
</div>
</div>
<button
onClick={() => { setEditingTariff(null); setShowModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
>
<PlusIcon />
{t('admin.tariffs.create')}
</button>
</div>
{/* Tariffs List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : tariffs.length === 0 ? (
<div className="text-center py-12">
<p className="text-dark-400">{t('admin.tariffs.noTariffs')}</p>
</div>
) : (
<div className="space-y-3">
{tariffs.map((tariff: TariffListItem) => (
<div
key={tariff.id}
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
tariff.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-dark-100 truncate">{tariff.name}</h3>
{tariff.is_trial_available && (
<span className="px-2 py-0.5 text-xs bg-success-500/20 text-success-400 rounded">
{t('admin.tariffs.trial')}
</span>
)}
{!tariff.is_active && (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
{t('admin.tariffs.inactive')}
</span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
<span>
{tariff.traffic_limit_gb === 0
? t('admin.tariffs.unlimited')
: `${tariff.traffic_limit_gb} GB`
}
</span>
<span>{tariff.device_limit} {t('admin.tariffs.devices')}</span>
<span>{tariff.servers_count} {t('admin.tariffs.servers')}</span>
<span>{tariff.subscriptions_count} {t('admin.tariffs.subscriptions')}</span>
</div>
</div>
<div className="flex items-center gap-2">
{/* Toggle Active */}
<button
onClick={() => toggleMutation.mutate(tariff.id)}
className={`p-2 rounded-lg transition-colors ${
tariff.is_active
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={tariff.is_active ? t('admin.tariffs.deactivate') : t('admin.tariffs.activate')}
>
{tariff.is_active ? <CheckIcon /> : <XIcon />}
</button>
{/* Toggle Trial */}
<button
onClick={() => toggleTrialMutation.mutate(tariff.id)}
className={`p-2 rounded-lg transition-colors ${
tariff.is_trial_available
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={t('admin.tariffs.toggleTrial')}
>
T
</button>
{/* Edit */}
<button
onClick={() => handleEdit(tariff.id)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
title={t('admin.tariffs.edit')}
>
<EditIcon />
</button>
{/* Delete */}
<button
onClick={() => setDeleteConfirm(tariff.id)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-error-500/20 hover:text-error-400 transition-colors"
title={t('admin.tariffs.delete')}
disabled={tariff.subscriptions_count > 0}
>
<TrashIcon />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<TariffModal
tariff={editingTariff}
servers={servers}
onSave={handleSave}
onClose={() => { setShowModal(false); setEditingTariff(null) }}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
{/* Delete Confirmation */}
{deleteConfirm !== null && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-dark-100 mb-2">{t('admin.tariffs.confirmDelete')}</h3>
<p className="text-dark-400 mb-6">{t('admin.tariffs.confirmDeleteText')}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={() => deleteMutation.mutate(deleteConfirm)}
className="px-4 py-2 bg-error-500 text-white rounded-lg hover:bg-error-600 transition-colors"
>
{t('common.delete')}
</button>
</div>
</div>
</div>
)}
</div>
)
}

385
src/pages/AdminTickets.tsx Normal file
View File

@@ -0,0 +1,385 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin'
import { ticketsApi } from '../api/tickets'
function AdminMessageMedia({ message, t }: { message: AdminTicketMessage; t: (key: string) => string }) {
const [imageLoaded, setImageLoaded] = useState(false)
const [imageError, setImageError] = useState(false)
const [showFullImage, setShowFullImage] = useState(false)
if (!message.has_media || !message.media_file_id) {
return null
}
const mediaUrl = ticketsApi.getMediaUrl(message.media_file_id)
if (message.media_type === 'photo') {
return (
<div className="mt-3">
{!imageLoaded && !imageError && (
<div className="w-full h-40 bg-dark-800 rounded-lg animate-pulse flex items-center justify-center">
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{imageError ? (
<div className="w-full h-32 bg-dark-800 rounded-lg flex items-center justify-center text-dark-400 text-sm">
{t('support.imageLoadFailed')}
</div>
) : (
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className={`max-w-full max-h-64 rounded-lg cursor-pointer hover:opacity-90 transition-opacity ${
imageLoaded ? '' : 'hidden'
}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
onClick={() => setShowFullImage(true)}
/>
)}
{message.media_caption && (
<p className="text-xs text-dark-400 mt-1">{message.media_caption}</p>
)}
{showFullImage && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onClick={() => setShowFullImage(false)}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white"
onClick={() => setShowFullImage(false)}
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<img src={mediaUrl} alt={message.media_caption || 'Attached image'} className="max-w-full max-h-full object-contain" />
</div>
)}
</div>
)
}
return (
<div className="mt-3">
<a
href={mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm text-dark-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
{message.media_caption || `Download ${message.media_type}`}
</a>
</div>
)
}
export default function AdminTickets() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selectedTicketId, setSelectedTicketId] = useState<number | null>(null)
const [statusFilter, setStatusFilter] = useState<string>('')
const [replyText, setReplyText] = useState('')
const [page, setPage] = useState(1)
const { data: stats } = useQuery({
queryKey: ['admin-ticket-stats'],
queryFn: adminApi.getTicketStats,
})
const { data: ticketsData, isLoading: ticketsLoading } = useQuery({
queryKey: ['admin-tickets', page, statusFilter],
queryFn: () => adminApi.getTickets({
page,
per_page: 20,
status: statusFilter || undefined,
}),
})
const { data: selectedTicket, isLoading: ticketLoading } = useQuery({
queryKey: ['admin-ticket', selectedTicketId],
queryFn: () => adminApi.getTicket(selectedTicketId!),
enabled: !!selectedTicketId,
})
const replyMutation = useMutation({
mutationFn: ({ ticketId, message }: { ticketId: number; message: string }) =>
adminApi.replyToTicket(ticketId, message),
onSuccess: () => {
setReplyText('')
queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] })
queryClient.invalidateQueries({ queryKey: ['admin-tickets'] })
queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] })
},
})
const statusMutation = useMutation({
mutationFn: ({ ticketId, status }: { ticketId: number; status: string }) =>
adminApi.updateTicketStatus(ticketId, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] })
queryClient.invalidateQueries({ queryKey: ['admin-tickets'] })
queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] })
},
})
const handleReply = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedTicketId || !replyText.trim()) return
replyMutation.mutate({ ticketId: selectedTicketId, message: replyText })
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'open': return 'badge-info'
case 'pending': return 'badge-warning'
case 'answered': return 'badge-success'
case 'closed': return 'badge-neutral'
default: return 'badge-neutral'
}
}
const getPriorityBadge = (priority: string) => {
switch (priority) {
case 'urgent': return 'badge-error'
case 'high': return 'badge-warning'
default: return 'badge-neutral'
}
}
const formatUser = (ticket: AdminTicket | AdminTicketDetail) => {
if (!ticket.user) return 'Unknown'
const { first_name, last_name, username, telegram_id } = ticket.user
if (first_name || last_name) return `${first_name || ''} ${last_name || ''}`.trim()
if (username) return `@${username}`
return `ID: ${telegram_id}`
}
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('admin.tickets.title')}</h1>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
<div className="card text-center">
<div className="stat-value">{stats.total}</div>
<div className="stat-label">{t('admin.tickets.total')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-accent-400">{stats.open}</div>
<div className="stat-label">{t('admin.tickets.statusOpen')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-warning-400">{stats.pending}</div>
<div className="stat-label">{t('admin.tickets.statusPending')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-success-400">{stats.answered}</div>
<div className="stat-label">{t('admin.tickets.statusAnswered')}</div>
</div>
<div className="card text-center col-span-2 sm:col-span-1">
<div className="stat-value text-dark-400">{stats.closed}</div>
<div className="stat-label">{t('admin.tickets.statusClosed')}</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Ticket List */}
<div className="lg:col-span-1 card">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-dark-100">{t('admin.tickets.list')}</h2>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
className="input py-1.5 px-3 w-auto text-sm"
>
<option value="">{t('admin.tickets.allStatuses')}</option>
<option value="open">{t('admin.tickets.statusOpen')}</option>
<option value="pending">{t('admin.tickets.statusPending')}</option>
<option value="answered">{t('admin.tickets.statusAnswered')}</option>
<option value="closed">{t('admin.tickets.statusClosed')}</option>
</select>
</div>
{ticketsLoading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : ticketsData?.items.length === 0 ? (
<div className="text-center py-12 text-dark-500">{t('admin.tickets.noTickets')}</div>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto scrollbar-hide">
{ticketsData?.items.map((ticket) => (
<button
key={ticket.id}
onClick={() => setSelectedTicketId(ticket.id)}
className={`w-full text-left p-4 rounded-xl border transition-all ${
selectedTicketId === ticket.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
}`}
>
<div className="flex justify-between items-start gap-2 mb-2">
<span className="text-dark-100 font-medium truncate">
#{ticket.id} {ticket.title}
</span>
<span className={getStatusBadge(ticket.status)}>
{t(`admin.tickets.status${ticket.status.charAt(0).toUpperCase() + ticket.status.slice(1)}`)}
</span>
</div>
<div className="text-xs text-dark-500">
{formatUser(ticket)} | {new Date(ticket.updated_at).toLocaleDateString()}
</div>
{ticket.last_message && (
<div className="text-xs text-dark-600 mt-1 truncate">
{ticket.last_message.is_from_admin ? t('admin.tickets.you') : t('admin.tickets.user')}:{' '}
{ticket.last_message.message_text.substring(0, 50)}...
</div>
)}
</button>
))}
</div>
)}
{ticketsData && ticketsData.pages > 1 && (
<div className="flex justify-center items-center gap-3 mt-4 pt-4 border-t border-dark-800/50">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="btn-secondary text-sm py-1.5 px-3 disabled:opacity-50"
>
{t('common.back')}
</button>
<span className="text-sm text-dark-400">{page} / {ticketsData.pages}</span>
<button
onClick={() => setPage((p) => Math.min(ticketsData.pages, p + 1))}
disabled={page === ticketsData.pages}
className="btn-secondary text-sm py-1.5 px-3 disabled:opacity-50"
>
{t('common.next')}
</button>
</div>
)}
</div>
{/* Ticket Detail */}
<div className="lg:col-span-2 card">
{!selectedTicketId ? (
<div className="flex flex-col items-center justify-center h-64">
<div className="w-16 h-16 mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
</svg>
</div>
<div className="text-dark-400">{t('admin.tickets.selectTicket')}</div>
</div>
) : ticketLoading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : selectedTicket ? (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-dark-800/50 pb-4 mb-4">
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-semibold text-dark-100">
#{selectedTicket.id} {selectedTicket.title}
</h3>
<div className="flex gap-2">
<span className={getStatusBadge(selectedTicket.status)}>
{t(`admin.tickets.status${selectedTicket.status.charAt(0).toUpperCase() + selectedTicket.status.slice(1)}`)}
</span>
<span className={getPriorityBadge(selectedTicket.priority)}>
{selectedTicket.priority}
</span>
</div>
</div>
<div className="text-sm text-dark-500 mb-4">
{t('admin.tickets.from')}: {formatUser(selectedTicket)} |{' '}
{t('admin.tickets.created')}: {new Date(selectedTicket.created_at).toLocaleString()}
</div>
<div className="flex flex-wrap gap-2">
{['open', 'pending', 'answered', 'closed'].map((s) => (
<button
key={s}
onClick={() => statusMutation.mutate({ ticketId: selectedTicket.id, status: s })}
disabled={selectedTicket.status === s || statusMutation.isPending}
className={`px-3 py-1.5 text-xs rounded-lg border transition-all ${
selectedTicket.status === s
? 'bg-accent-500/20 border-accent-500/50 text-accent-400'
: 'border-dark-700/50 text-dark-400 hover:border-dark-600 hover:text-dark-200'
} disabled:opacity-50`}
>
{t(`admin.tickets.status${s.charAt(0).toUpperCase() + s.slice(1)}`)}
</button>
))}
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 max-h-[400px] mb-4 scrollbar-hide">
{selectedTicket.messages.map((msg) => (
<div
key={msg.id}
className={`p-4 rounded-xl ${
msg.is_from_admin
? 'bg-accent-500/10 border border-accent-500/20 ml-4'
: 'bg-dark-800/50 border border-dark-700/30 mr-4'
}`}
>
<div className="flex justify-between items-center mb-2">
<span className={`text-xs font-medium ${msg.is_from_admin ? 'text-accent-400' : 'text-dark-400'}`}>
{msg.is_from_admin ? t('admin.tickets.adminLabel') : t('admin.tickets.userLabel')}
</span>
<span className="text-xs text-dark-500">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<p className="text-dark-200 whitespace-pre-wrap">{msg.message_text}</p>
<AdminMessageMedia message={msg} t={t} />
</div>
))}
</div>
{/* Reply form */}
{selectedTicket.status !== 'closed' && (
<form onSubmit={handleReply} className="border-t border-dark-800/50 pt-4">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder={t('admin.tickets.replyPlaceholder')}
rows={3}
className="input resize-none"
/>
<div className="flex justify-end mt-3">
<button
type="submit"
disabled={!replyText.trim() || replyMutation.isPending}
className="btn-primary"
>
{replyMutation.isPending ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('common.loading')}
</span>
) : (
t('admin.tickets.sendReply')
)}
</button>
</div>
</form>
)}
</div>
) : null}
</div>
</div>
</div>
)
}

650
src/pages/AdminWheel.tsx Normal file
View File

@@ -0,0 +1,650 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { adminWheelApi, type WheelPrizeAdmin } from '../api/wheel'
// Icons
const CogIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
</svg>
)
const GiftIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
)
const ChartIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
)
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)
const TrashIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
)
const PRIZE_TYPE_KEYS = [
{ value: 'subscription_days', key: 'subscription_days', emoji: '📅' },
{ value: 'balance_bonus', key: 'balance_bonus', emoji: '💰' },
{ value: 'traffic_gb', key: 'traffic_gb', emoji: '📊' },
{ value: 'promocode', key: 'promocode', emoji: '🎟️' },
{ value: 'nothing', key: 'nothing', emoji: '😔' },
]
type Tab = 'settings' | 'prizes' | 'statistics'
export default function AdminWheel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<Tab>('settings')
const [editingPrize, setEditingPrize] = useState<WheelPrizeAdmin | null>(null)
const [isCreating, setIsCreating] = useState(false)
// Fetch config
const { data: config, isLoading } = useQuery({
queryKey: ['admin-wheel-config'],
queryFn: adminWheelApi.getConfig,
})
// Fetch statistics
const { data: stats } = useQuery({
queryKey: ['admin-wheel-stats'],
queryFn: () => adminWheelApi.getStatistics(),
enabled: activeTab === 'statistics',
})
// Update config mutation
const updateConfigMutation = useMutation({
mutationFn: adminWheelApi.updateConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] })
},
})
// Prize mutations
const createPrizeMutation = useMutation({
mutationFn: adminWheelApi.createPrize,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] })
setIsCreating(false)
},
})
const updatePrizeMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<WheelPrizeAdmin> }) =>
adminWheelApi.updatePrize(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] })
setEditingPrize(null)
},
})
const deletePrizeMutation = useMutation({
mutationFn: adminWheelApi.deletePrize,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] })
},
})
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!config) {
return <div className="text-center py-12 text-dark-400">{t('wheel.errors.loadFailed')}</div>
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">
{t('admin.wheel.title')}
</h1>
<div className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-sm ${
config.is_enabled ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
}`}>
{config.is_enabled ? t('admin.wheel.enabled') : t('admin.wheel.disabled')}
</span>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-dark-700 pb-2">
<button
onClick={() => setActiveTab('settings')}
className={`flex items-center gap-2 px-4 py-2 rounded-t-lg transition-colors ${
activeTab === 'settings'
? 'bg-dark-800 text-accent-400 border-b-2 border-accent-500'
: 'text-dark-400 hover:text-dark-200'
}`}
>
<CogIcon />
{t('admin.wheel.tabs.settings')}
</button>
<button
onClick={() => setActiveTab('prizes')}
className={`flex items-center gap-2 px-4 py-2 rounded-t-lg transition-colors ${
activeTab === 'prizes'
? 'bg-dark-800 text-accent-400 border-b-2 border-accent-500'
: 'text-dark-400 hover:text-dark-200'
}`}
>
<GiftIcon />
{t('admin.wheel.tabs.prizes')} ({config.prizes.length})
</button>
<button
onClick={() => setActiveTab('statistics')}
className={`flex items-center gap-2 px-4 py-2 rounded-t-lg transition-colors ${
activeTab === 'statistics'
? 'bg-dark-800 text-accent-400 border-b-2 border-accent-500'
: 'text-dark-400 hover:text-dark-200'
}`}
>
<ChartIcon />
{t('admin.wheel.tabs.statistics')}
</button>
</div>
{/* Settings Tab */}
{activeTab === 'settings' && (
<div className="card p-6 space-y-6">
{/* Enable toggle */}
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-dark-100">{t('admin.wheel.settings.enableWheel')}</h3>
<p className="text-sm text-dark-400">{t('admin.wheel.settings.allowSpins')}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.is_enabled}
onChange={(e) => updateConfigMutation.mutate({ is_enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-dark-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-accent-500"></div>
</label>
</div>
<hr className="border-dark-700" />
{/* Spin costs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.costInStars')}
</label>
<div className="flex gap-2">
<input
type="number"
value={config.spin_cost_stars}
onChange={(e) => updateConfigMutation.mutate({ spin_cost_stars: parseInt(e.target.value) || 1 })}
min={1}
max={1000}
className="input flex-1"
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.spin_cost_stars_enabled}
onChange={(e) => updateConfigMutation.mutate({ spin_cost_stars_enabled: e.target.checked })}
className="rounded border-dark-600"
/>
<span className="text-sm text-dark-400">{t('admin.wheel.enabled')}</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.costInDays')}
</label>
<div className="flex gap-2">
<input
type="number"
value={config.spin_cost_days}
onChange={(e) => updateConfigMutation.mutate({ spin_cost_days: parseInt(e.target.value) || 1 })}
min={1}
max={30}
className="input flex-1"
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.spin_cost_days_enabled}
onChange={(e) => updateConfigMutation.mutate({ spin_cost_days_enabled: e.target.checked })}
className="rounded border-dark-600"
/>
<span className="text-sm text-dark-400">{t('admin.wheel.enabled')}</span>
</label>
</div>
</div>
</div>
<hr className="border-dark-700" />
{/* RTP and limits */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.rtpPercent')}
</label>
<input
type="range"
min={0}
max={100}
value={config.rtp_percent}
onChange={(e) => updateConfigMutation.mutate({ rtp_percent: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-sm text-dark-400">
<span>0%</span>
<span className="font-bold text-accent-400">{config.rtp_percent}%</span>
<span>100%</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.dailyLimit')}
</label>
<input
type="number"
value={config.daily_spin_limit}
onChange={(e) => updateConfigMutation.mutate({ daily_spin_limit: parseInt(e.target.value) || 0 })}
min={0}
max={100}
className="input w-full"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.minSubDays')}
</label>
<input
type="number"
value={config.min_subscription_days_for_day_payment}
onChange={(e) => updateConfigMutation.mutate({ min_subscription_days_for_day_payment: parseInt(e.target.value) || 1 })}
min={1}
max={30}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.promoPrefix')}
</label>
<input
type="text"
value={config.promo_prefix}
onChange={(e) => updateConfigMutation.mutate({ promo_prefix: e.target.value })}
maxLength={20}
className="input w-full"
/>
</div>
</div>
</div>
)}
{/* Prizes Tab */}
{activeTab === 'prizes' && (
<div className="space-y-4">
<div className="flex justify-end">
<button
onClick={() => setIsCreating(true)}
className="btn-primary flex items-center gap-2"
>
<PlusIcon />
{t('admin.wheel.prizes.addPrize')}
</button>
</div>
{/* Prize list */}
<div className="space-y-3">
{config.prizes.map((prize) => (
<div
key={prize.id}
className={`card p-4 ${!prize.is_active ? 'opacity-50' : ''}`}
>
<div className="flex items-center gap-4">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center text-2xl"
style={{ backgroundColor: prize.color + '30' }}
>
{prize.emoji}
</div>
<div className="flex-1">
<div className="font-semibold text-dark-100">{prize.display_name}</div>
<div className="text-sm text-dark-400">
{t(`admin.wheel.prizes.types.${prize.prize_type}`)}
{t('admin.wheel.prizes.fields.value')}: {prize.prize_value}
{t('admin.wheel.prizes.fields.worth')}: {(prize.prize_value_kopeks / 100).toFixed(2)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setEditingPrize(prize)}
className="btn-ghost text-sm"
>
{t('common.edit')}
</button>
<button
onClick={() => {
if (confirm(t('admin.wheel.prizes.deletePrize'))) {
deletePrizeMutation.mutate(prize.id)
}
}}
className="btn-ghost text-red-400"
>
<TrashIcon />
</button>
</div>
</div>
</div>
))}
</div>
{config.prizes.length === 0 && (
<div className="text-center py-12 text-dark-400">
{t('admin.wheel.prizes.noPrizes')}
</div>
)}
</div>
)}
{/* Statistics Tab */}
{activeTab === 'statistics' && stats && (
<div className="space-y-4">
{/* Stats cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="card p-4 text-center">
<div className="text-3xl font-bold text-accent-400">{stats.total_spins}</div>
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.totalSpins')}</div>
</div>
<div className="card p-4 text-center">
<div className="text-3xl font-bold text-green-400">
{(stats.total_revenue_kopeks / 100).toFixed(0)}
</div>
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.revenue')}</div>
</div>
<div className="card p-4 text-center">
<div className="text-3xl font-bold text-yellow-400">
{(stats.total_payout_kopeks / 100).toFixed(0)}
</div>
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.payouts')}</div>
</div>
<div className="card p-4 text-center">
<div className={`text-3xl font-bold ${
stats.actual_rtp_percent <= stats.configured_rtp_percent ? 'text-green-400' : 'text-red-400'
}`}>
{stats.actual_rtp_percent.toFixed(1)}%
</div>
<div className="text-sm text-dark-400">
{t('admin.wheel.statistics.actualRtp')} ({t('admin.wheel.statistics.targetRtp')}: {stats.configured_rtp_percent}%)
</div>
</div>
</div>
{/* Prize distribution */}
{stats.prizes_distribution.length > 0 && (
<div className="card p-4">
<h3 className="font-semibold text-dark-100 mb-3">{t('admin.wheel.statistics.prizeDistribution')}</h3>
<div className="space-y-2">
{stats.prizes_distribution.map((prize, i) => (
<div key={i} className="flex items-center justify-between">
<span className="text-dark-300">{prize.display_name}</span>
<span className="text-dark-100">{prize.count} {t('admin.wheel.statistics.times')}</span>
</div>
))}
</div>
</div>
)}
{/* Top wins */}
{stats.top_wins.length > 0 && (
<div className="card p-4">
<h3 className="font-semibold text-dark-100 mb-3">{t('admin.wheel.statistics.topWins')}</h3>
<div className="space-y-2">
{stats.top_wins.slice(0, 5).map((win, i) => (
<div key={i} className="flex items-center justify-between">
<span className="text-dark-300">
{win.username || `User #${win.user_id}`}
</span>
<span className="text-dark-100">
{win.prize_display_name} ({(win.prize_value_kopeks / 100).toFixed(0)})
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Create/Edit Prize Modal */}
{(isCreating || editingPrize) && (
<PrizeModal
prize={editingPrize}
onClose={() => {
setIsCreating(false)
setEditingPrize(null)
}}
onSave={(data) => {
if (editingPrize) {
updatePrizeMutation.mutate({ id: editingPrize.id, data })
} else {
createPrizeMutation.mutate(data as any)
}
}}
/>
)}
</div>
)
}
// Prize Modal Component
function PrizeModal({
prize,
onClose,
onSave,
}: {
prize: WheelPrizeAdmin | null
onClose: () => void
onSave: (data: Partial<WheelPrizeAdmin>) => void
}) {
const { t } = useTranslation()
const [formData, setFormData] = useState({
prize_type: prize?.prize_type || 'balance_bonus',
prize_value: prize?.prize_value || 0,
display_name: prize?.display_name || '',
emoji: prize?.emoji || '🎁',
color: prize?.color || '#3B82F6',
prize_value_kopeks: prize?.prize_value_kopeks || 0,
is_active: prize?.is_active ?? true,
manual_probability: prize?.manual_probability || null,
promo_balance_bonus_kopeks: prize?.promo_balance_bonus_kopeks || 0,
promo_subscription_days: prize?.promo_subscription_days || 0,
promo_traffic_gb: prize?.promo_traffic_gb || 0,
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSave(formData)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="card p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-dark-50 mb-4">
{prize ? t('admin.wheel.prizes.editPrize') : t('admin.wheel.prizes.addPrize')}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Prize type */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.wheel.prizes.fields.type')}</label>
<select
value={formData.prize_type}
onChange={(e) => setFormData({ ...formData, prize_type: e.target.value })}
className="input w-full"
>
{PRIZE_TYPE_KEYS.map((type) => (
<option key={type.value} value={type.value}>
{type.emoji} {t(`admin.wheel.prizes.types.${type.key}`)}
</option>
))}
</select>
</div>
{/* Display name */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.wheel.prizes.fields.displayName')}</label>
<input
type="text"
value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
required
maxLength={100}
className="input w-full"
placeholder="e.g. 7 Days Free"
/>
</div>
{/* Prize value */}
{formData.prize_type !== 'nothing' && (
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.prizes.fields.value')} ({formData.prize_type === 'balance_bonus' ? 'kopeks' : formData.prize_type === 'subscription_days' ? 'days' : 'GB'})
</label>
<input
type="number"
value={formData.prize_value}
onChange={(e) => setFormData({ ...formData, prize_value: parseInt(e.target.value) || 0 })}
min={0}
className="input w-full"
/>
</div>
)}
{/* Prize value in kopeks (for RTP calculation) */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.prizes.fields.valueKopeks')}
</label>
<input
type="number"
value={formData.prize_value_kopeks}
onChange={(e) => setFormData({ ...formData, prize_value_kopeks: parseInt(e.target.value) || 0 })}
min={0}
className="input w-full"
/>
<p className="text-xs text-dark-500 mt-1">
= {(formData.prize_value_kopeks / 100).toFixed(2)} RUB
</p>
</div>
{/* Emoji and color */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.wheel.prizes.fields.emoji')}</label>
<input
type="text"
value={formData.emoji}
onChange={(e) => setFormData({ ...formData, emoji: e.target.value })}
maxLength={10}
className="input w-full text-center text-2xl"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.wheel.prizes.fields.color')}</label>
<div className="flex gap-2">
<input
type="color"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="w-12 h-10 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="input flex-1"
pattern="^#[0-9A-Fa-f]{6}$"
/>
</div>
</div>
</div>
{/* Active toggle */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="rounded border-dark-600"
/>
<label htmlFor="is_active" className="text-sm text-dark-300">{t('admin.wheel.prizes.fields.active')}</label>
</div>
{/* Promocode settings */}
{formData.prize_type === 'promocode' && (
<div className="p-3 bg-dark-800 rounded-lg space-y-3">
<h4 className="font-medium text-dark-200">{t('admin.wheel.prizes.promo.title')}</h4>
<div>
<label className="block text-sm text-dark-400 mb-1">{t('admin.wheel.prizes.promo.balanceBonus')}</label>
<input
type="number"
value={formData.promo_balance_bonus_kopeks}
onChange={(e) => setFormData({ ...formData, promo_balance_bonus_kopeks: parseInt(e.target.value) || 0 })}
min={0}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm text-dark-400 mb-1">{t('admin.wheel.prizes.promo.subscriptionDays')}</label>
<input
type="number"
value={formData.promo_subscription_days}
onChange={(e) => setFormData({ ...formData, promo_subscription_days: parseInt(e.target.value) || 0 })}
min={0}
className="input w-full"
/>
</div>
</div>
)}
{/* Buttons */}
<div className="flex gap-3 pt-4">
<button type="button" onClick={onClose} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" className="btn-primary flex-1">
{prize ? t('common.save') : t('common.confirm')}
</button>
</div>
</form>
</div>
</div>
)
}

292
src/pages/Balance.tsx Normal file
View File

@@ -0,0 +1,292 @@
import { useState, useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '../store/auth'
import { balanceApi } from '../api/balance'
import TopUpModal from '../components/TopUpModal'
import { useCurrency } from '../hooks/useCurrency'
import type { PaymentMethod, PaginatedResponse, Transaction } from '../types'
export default function Balance() {
const { t } = useTranslation()
const { refreshUser } = useAuthStore()
const queryClient = useQueryClient()
const { formatAmount, currencySymbol } = useCurrency()
// Fetch balance directly from API with no caching
const { data: balanceData, refetch: refetchBalance } = useQuery({
queryKey: ['balance'],
queryFn: balanceApi.getBalance,
staleTime: 0, // Always refetch
refetchOnMount: 'always',
})
// Refresh user data on mount to sync balance in store
useEffect(() => {
refreshUser()
}, [])
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null)
const [promocode, setPromocode] = useState('')
const [promocodeLoading, setPromocodeLoading] = useState(false)
const [promocodeError, setPromocodeError] = useState<string | null>(null)
const [promocodeSuccess, setPromocodeSuccess] =
useState<{ message: string; amount: number } | null>(null)
const [transactionsPage, setTransactionsPage] = useState(1)
const { data: transactions, isLoading } = useQuery<PaginatedResponse<Transaction>>({
queryKey: ['transactions', transactionsPage],
queryFn: () => balanceApi.getTransactions({ per_page: 20, page: transactionsPage }),
placeholderData: (previousData) => previousData,
})
const { data: paymentMethods } = useQuery({
queryKey: ['payment-methods'],
queryFn: balanceApi.getPaymentMethods,
})
const normalizeType = (type: string) => type?.toUpperCase?.() ?? type
const getTypeBadge = (type: string) => {
switch (normalizeType(type)) {
case 'DEPOSIT':
return 'badge-success'
case 'SUBSCRIPTION_PAYMENT':
return 'badge-info'
case 'REFERRAL_REWARD':
return 'badge-warning'
case 'WITHDRAWAL':
return 'badge-error'
default:
return 'badge-neutral'
}
}
const getTypeLabel = (type: string) => {
switch (normalizeType(type)) {
case 'DEPOSIT':
return t('balance.deposit')
case 'SUBSCRIPTION_PAYMENT':
return t('balance.subscriptionPayment')
case 'REFERRAL_REWARD':
return t('balance.referralReward')
case 'WITHDRAWAL':
return t('balance.withdrawal')
default:
return type
}
}
const handlePromocodeActivate = async () => {
if (!promocode.trim()) return
setPromocodeLoading(true)
setPromocodeError(null)
setPromocodeSuccess(null)
try {
const result = await balanceApi.activatePromocode(promocode.trim())
if (result.success) {
const bonusAmount = result.balance_after - result.balance_before
setPromocodeSuccess({
message: result.bonus_description || t('balance.promocode.success'),
amount: bonusAmount,
})
setTransactionsPage(1)
setPromocode('')
// Refresh balance and transactions
await refetchBalance()
await refreshUser()
queryClient.invalidateQueries({ queryKey: ['transactions'] })
}
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { detail?: string } } }
const errorDetail = axiosError.response?.data?.detail || 'server_error'
// Map backend error messages to translation keys
const errorKey = errorDetail.toLowerCase().includes('not found')
? 'not_found'
: errorDetail.toLowerCase().includes('expired')
? 'expired'
: errorDetail.toLowerCase().includes('fully used')
? 'used'
: errorDetail.toLowerCase().includes('already used')
? 'already_used_by_user'
: 'server_error'
setPromocodeError(t(`balance.promocode.errors.${errorKey}`))
} finally {
setPromocodeLoading(false)
}
}
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('balance.title')}</h1>
{/* Balance Card */}
<div className="card bg-gradient-to-br from-accent-500/10 to-transparent border-accent-500/20">
<div className="text-sm text-dark-400 mb-2">{t('balance.currentBalance')}</div>
<div className="text-4xl sm:text-5xl font-bold text-dark-50">
{formatAmount(balanceData?.balance_rubles || 0)}
<span className="text-2xl text-dark-400 ml-2">{currencySymbol}</span>
</div>
</div>
{/* Promo Code Section */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.promocode.title')}</h2>
<div className="flex gap-3">
<input
type="text"
value={promocode}
onChange={(e) => setPromocode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handlePromocodeActivate()}
placeholder={t('balance.promocode.placeholder')}
className="input flex-1"
disabled={promocodeLoading}
/>
<button
onClick={handlePromocodeActivate}
disabled={!promocode.trim() || promocodeLoading}
className="btn-primary px-6 whitespace-nowrap"
>
{promocodeLoading ? t('balance.promocode.activating') : t('balance.promocode.activate')}
</button>
</div>
{promocodeError && (
<div className="mt-3 p-3 rounded-lg bg-error-500/10 border border-error-500/30 text-error-400 text-sm">
{promocodeError}
</div>
)}
{promocodeSuccess && (
<div className="mt-3 p-3 rounded-lg bg-success-500/10 border border-success-500/30 text-success-400 text-sm">
<div className="font-medium">{promocodeSuccess.message}</div>
{promocodeSuccess.amount > 0 && (
<div className="mt-1">{t('balance.promocode.balanceAdded', { amount: promocodeSuccess.amount.toFixed(2) })}</div>
)}
</div>
)}
</div>
{/* Payment Methods */}
{paymentMethods && paymentMethods.length > 0 && (
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.topUpBalance')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{paymentMethods.map((method) => {
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' })
const translatedDesc = t(`balance.paymentMethods.${methodKey}.description`, { defaultValue: '' })
return (
<button
key={method.id}
disabled={!method.is_available}
onClick={() => method.is_available && setSelectedMethod(method)}
className={`p-4 rounded-xl border text-left transition-all ${
method.is_available
? 'border-dark-700/50 hover:border-accent-500/50 bg-dark-800/30 cursor-pointer'
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
}`}
>
<div className="font-semibold text-dark-100">{translatedName || method.name}</div>
{(translatedDesc || method.description) && (
<div className="text-sm text-dark-500 mt-1">{translatedDesc || method.description}</div>
)}
<div className="text-xs text-dark-600 mt-3">
{formatAmount(method.min_amount_kopeks / 100, 0)} {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
</div>
</button>
)
})}
</div>
<p className="mt-4 text-sm text-dark-500">{t('balance.useBot')}</p>
</div>
)}
{/* Transaction History */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.transactionHistory')}</h2>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : transactions?.items && transactions.items.length > 0 ? (
<div className="space-y-3">
{transactions.items.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<span className={getTypeBadge(tx.type)}>
{getTypeLabel(tx.type)}
</span>
<span className="text-xs text-dark-500">
{new Date(tx.created_at).toLocaleDateString()}
</span>
</div>
{tx.description && (
<div className="text-sm text-dark-400">{tx.description}</div>
)}
</div>
<div className={`text-lg font-semibold ${tx.amount_kopeks > 0 ? 'text-success-400' : 'text-error-400'}`}>
{tx.amount_kopeks > 0 ? '+' : ''}{formatAmount(tx.amount_rubles)} {currencySymbol}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
</div>
<div className="text-dark-400">{t('balance.noTransactions')}</div>
</div>
)}
{transactions && transactions.pages > 1 && (
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
<button
type="button"
onClick={() => setTransactionsPage((prev) => Math.max(1, prev - 1))}
disabled={transactions.page <= 1}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
transactions.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{t('common.back')}
</button>
<div className="flex-1 text-center">
{t('balance.page', { current: transactions.page, total: transactions.pages })}
</div>
<button
type="button"
onClick={() =>
setTransactionsPage((prev) =>
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1
)
}
disabled={transactions.page >= transactions.pages}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
transactions.page >= transactions.pages ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{t('common.next')}
</button>
</div>
)}
</div>
{/* TopUp Modal */}
{selectedMethod && (
<TopUpModal
method={selectedMethod}
onClose={() => setSelectedMethod(null)}
/>
)}
</div>
)
}

239
src/pages/Contests.tsx Normal file
View File

@@ -0,0 +1,239 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { contestsApi, ContestInfo, ContestGameData } from '../api/contests'
const GamepadIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869" />
</svg>
)
const TrophyIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 01-.982-3.172M9.497 14.25a7.454 7.454 0 00.981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 007.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 002.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 012.916.52 6.003 6.003 0 01-5.395 4.972m0 0a6.726 6.726 0 01-2.749 1.35m0 0a6.772 6.772 0 01-3.044 0" />
</svg>
)
export default function Contests() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selectedContest, setSelectedContest] = useState<ContestInfo | null>(null)
const [gameData, setGameData] = useState<ContestGameData | null>(null)
const [result, setResult] = useState<{ is_winner: boolean; message: string } | null>(null)
const { data: contests, isLoading, error } = useQuery({
queryKey: ['contests'],
queryFn: contestsApi.getContests,
})
const getGameMutation = useMutation({
mutationFn: contestsApi.getContestGame,
onSuccess: (data) => {
setGameData(data)
setResult(null)
},
})
const submitAnswerMutation = useMutation({
mutationFn: ({ roundId, answer }: { roundId: number; answer: string }) =>
contestsApi.submitAnswer(roundId, answer),
onSuccess: (data) => {
setResult(data)
queryClient.invalidateQueries({ queryKey: ['contests'] })
},
})
const handlePlayContest = async (contest: ContestInfo) => {
setSelectedContest(contest)
getGameMutation.mutate(contest.id)
}
const handleSubmitAnswer = (answer: string) => {
if (gameData) {
submitAnswerMutation.mutate({ roundId: gameData.round_id, answer })
}
}
const handleCloseGame = () => {
setSelectedContest(null)
setGameData(null)
setResult(null)
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-64">
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error) {
return (
<div className="card bg-red-500/10 border-red-500/20">
<p className="text-red-400">{t('contests.error')}</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<GamepadIcon />
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('contests.title')}</h1>
</div>
{/* Game Modal */}
{selectedContest && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="card max-w-lg w-full max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{selectedContest.name}</h2>
<button onClick={handleCloseGame} className="text-dark-400 hover:text-dark-200">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{getGameMutation.isPending && (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{result && (
<div className={`p-4 rounded-lg mb-4 ${result.is_winner ? 'bg-success-500/20 text-success-400' : 'bg-red-500/20 text-red-400'}`}>
<p className="font-medium">{result.message}</p>
</div>
)}
{gameData && !result && (
<div className="space-y-4">
<p className="text-dark-300">{gameData.instructions}</p>
{/* Render game based on type */}
{(gameData.game_type === 'quest' || gameData.game_type === 'locks') && (
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: gameData.game_data.total || gameData.game_data.grid_size || 9 }).map((_, i) => (
<button
key={i}
onClick={() => handleSubmitAnswer(`${i}_${gameData.game_data.secret}`)}
disabled={submitAnswerMutation.isPending}
className="aspect-square bg-dark-700 hover:bg-dark-600 rounded-lg flex items-center justify-center text-2xl transition-colors"
>
{gameData.game_type === 'locks' ? '🔒' : '🎛'}
</button>
))}
</div>
)}
{gameData.game_type === 'server' && (
<div className="grid grid-cols-5 gap-2">
{gameData.game_data.flags?.map((flag: string, i: number) => (
<button
key={i}
onClick={() => handleSubmitAnswer(flag)}
disabled={submitAnswerMutation.isPending}
className="p-3 bg-dark-700 hover:bg-dark-600 rounded-lg text-2xl transition-colors"
>
{flag}
</button>
))}
</div>
)}
{gameData.game_type === 'blitz' && (
<button
onClick={() => handleSubmitAnswer('blitz')}
disabled={submitAnswerMutation.isPending}
className="w-full py-4 bg-accent-500 hover:bg-accent-600 rounded-lg font-bold text-lg transition-colors"
>
{gameData.game_data.button_text || t('contests.imHere')}
</button>
)}
{['cipher', 'emoji', 'anagram'].includes(gameData.game_type) && (
<form
onSubmit={(e) => {
e.preventDefault()
const input = e.currentTarget.elements.namedItem('answer') as HTMLInputElement
handleSubmitAnswer(input.value)
}}
className="space-y-3"
>
<div className="text-center text-2xl font-mono bg-dark-700 p-4 rounded-lg">
{gameData.game_data.question || gameData.game_data.letters}
</div>
<input
name="answer"
type="text"
placeholder={t('contests.enterAnswer')}
className="w-full px-4 py-3 bg-dark-700 border border-dark-600 rounded-lg focus:border-accent-500 focus:outline-none"
/>
<button
type="submit"
disabled={submitAnswerMutation.isPending}
className="w-full btn-primary"
>
{t('contests.submit')}
</button>
</form>
)}
</div>
)}
{result && (
<button onClick={handleCloseGame} className="w-full btn-secondary mt-4">
{t('common.close')}
</button>
)}
</div>
</div>
)}
{/* Contests List */}
{contests && contests.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{contests.map((contest) => (
<div key={contest.id} className="card">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-lg">{contest.name}</h3>
{contest.description && (
<p className="text-dark-400 text-sm mt-1">{contest.description}</p>
)}
</div>
<div className="flex items-center gap-1 text-accent-400">
<TrophyIcon />
<span className="text-sm font-medium">+{contest.prize_days} {t('contests.days')}</span>
</div>
</div>
<div className="mt-4">
{contest.already_played ? (
<button disabled className="w-full btn-secondary opacity-50 cursor-not-allowed">
{t('contests.alreadyPlayed')}
</button>
) : (
<button
onClick={() => handlePlayContest(contest)}
className="w-full btn-primary"
>
{t('contests.play')}
</button>
)}
</div>
</div>
))}
</div>
) : (
<div className="card text-center py-12">
<GamepadIcon />
<p className="text-dark-400 mt-4">{t('contests.noContests')}</p>
</div>
)}
</div>
)
}

460
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,460 @@
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { DotLottieReact } from '@lottiefiles/dotlottie-react'
import { useAuthStore } from '../store/auth'
import { subscriptionApi } from '../api/subscription'
import { referralApi } from '../api/referral'
import { balanceApi } from '../api/balance'
import { wheelApi } from '../api/wheel'
import ConnectionModal from '../components/ConnectionModal'
import Onboarding, { useOnboarding } from '../components/Onboarding'
import { useCurrency } from '../hooks/useCurrency'
// Icons
const ArrowRightIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
)
const SparklesIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
)
const ChevronRightIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
)
const SupportLottieIcon = () => (
<div className="w-6 h-6">
<DotLottieReact
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie"
loop
autoplay
/>
</div>
)
export default function Dashboard() {
const { t } = useTranslation()
const { user, refreshUser } = useAuthStore()
const queryClient = useQueryClient()
const { formatAmount, currencySymbol, formatPositive } = useCurrency()
const [trialError, setTrialError] = useState<string | null>(null)
const [showConnectionModal, setShowConnectionModal] = useState(false)
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding()
const [showOnboarding, setShowOnboarding] = useState(false)
// Refresh user data on mount
useEffect(() => {
refreshUser()
}, [])
// Fetch balance from API with no caching
const { data: balanceData } = useQuery({
queryKey: ['balance'],
queryFn: balanceApi.getBalance,
staleTime: 0,
refetchOnMount: 'always',
})
const { data: subscription, isLoading: subLoading, error: subError } = useQuery({
queryKey: ['subscription'],
queryFn: subscriptionApi.getSubscription,
retry: false,
staleTime: 0,
refetchOnMount: 'always',
})
const { data: trialInfo, isLoading: trialLoading } = useQuery({
queryKey: ['trial-info'],
queryFn: subscriptionApi.getTrialInfo,
enabled: !subscription && !subLoading,
})
const { data: referralInfo, isLoading: refLoading } = useQuery({
queryKey: ['referral-info'],
queryFn: referralApi.getReferralInfo,
})
// Fetch wheel config to show banner if enabled
const { data: wheelConfig } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
staleTime: 60000, // 1 minute
retry: false,
})
const activateTrialMutation = useMutation({
mutationFn: subscriptionApi.activateTrial,
onSuccess: () => {
setTrialError(null)
queryClient.invalidateQueries({ queryKey: ['subscription'] })
queryClient.invalidateQueries({ queryKey: ['trial-info'] })
refreshUser()
},
onError: (error: { response?: { data?: { detail?: string } } }) => {
setTrialError(error.response?.data?.detail || t('common.error'))
},
})
const hasNoSubscription = !subscription && !subLoading && subError
// Show onboarding for new users after data loads
useEffect(() => {
if (!isOnboardingCompleted && !subLoading && !refLoading) {
// Small delay to ensure DOM is ready
const timer = setTimeout(() => setShowOnboarding(true), 500)
return () => clearTimeout(timer)
}
}, [isOnboardingCompleted, subLoading, refLoading])
// Define onboarding steps based on available data
const onboardingSteps = useMemo(() => {
type Placement = 'top' | 'bottom' | 'left' | 'right'
const steps: Array<{
target: string
title: string
description: string
placement: Placement
}> = [
{
target: 'welcome',
title: t('onboarding.steps.welcome.title'),
description: t('onboarding.steps.welcome.description'),
placement: 'bottom',
},
{
target: 'balance',
title: t('onboarding.steps.balance.title'),
description: t('onboarding.steps.balance.description'),
placement: 'bottom',
},
{
target: 'subscription-status',
title: t('onboarding.steps.subscription.title'),
description: t('onboarding.steps.subscription.description'),
placement: 'bottom',
},
]
// Add connect devices step only if subscription exists
if (subscription?.subscription_url) {
steps.splice(1, 0, {
target: 'connect-devices',
title: t('onboarding.steps.connectDevices.title'),
description: t('onboarding.steps.connectDevices.description'),
placement: 'bottom',
})
}
steps.push({
target: 'quick-actions',
title: t('onboarding.steps.quickActions.title'),
description: t('onboarding.steps.quickActions.description'),
placement: 'top',
})
return steps
}, [t, subscription])
const handleOnboardingComplete = () => {
setShowOnboarding(false)
completeOnboarding()
}
// Calculate traffic percentage color
const getTrafficColor = (percent: number) => {
if (percent > 90) return 'bg-error-500'
if (percent > 70) return 'bg-warning-500'
return 'bg-success-500'
}
return (
<div className="space-y-6">
{/* Header */}
<div data-onboarding="welcome">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">
{t('dashboard.welcome', { name: user?.first_name || user?.username || '' })}
</h1>
<p className="text-dark-400 mt-1">{t('dashboard.yourSubscription')}</p>
</div>
{/* Subscription Status - Main Card */}
{subscription && (
<div className="card">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.status')}</h2>
<span className={subscription.is_active ? 'badge-success' : 'badge-error'}>
{subscription.is_active ? t('subscription.active') : t('subscription.expired')}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<div className="text-sm text-dark-500 mb-1">{t('subscription.expiresAt')}</div>
<div className="text-dark-100 font-medium">
{new Date(subscription.end_date).toLocaleDateString()}
</div>
</div>
<div>
<div className="text-sm text-dark-500 mb-1">{t('subscription.traffic')}</div>
<div className="text-dark-100 font-medium">
{subscription.traffic_used_gb.toFixed(1)} / {subscription.traffic_limit_gb || '∞'} GB
</div>
</div>
<div>
<div className="text-sm text-dark-500 mb-1">{t('subscription.devices')}</div>
<div className="text-dark-100 font-medium">{subscription.device_limit}</div>
</div>
<div>
<div className="text-sm text-dark-500 mb-1">{t('subscription.timeLeft')}</div>
<div className="text-dark-100 font-medium">
{subscription.days_left > 0
? `${subscription.days_left} ${t('subscription.days')}`
: `${subscription.hours_left}${t('subscription.hours')} ${subscription.minutes_left}${t('subscription.minutes')}`
}
</div>
</div>
</div>
{/* Traffic Progress */}
{subscription.traffic_limit_gb > 0 && (
<div className="mt-6">
<div className="flex justify-between text-sm mb-2">
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
<span className="text-dark-300">{subscription.traffic_used_percent.toFixed(1)}%</span>
</div>
<div className="progress-bar">
<div
className={`progress-fill ${getTrafficColor(subscription.traffic_used_percent)}`}
style={{ width: `${Math.min(subscription.traffic_used_percent, 100)}%` }}
/>
</div>
</div>
)}
<div className={`mt-6 grid gap-3 ${subscription.subscription_url ? 'grid-cols-2' : 'grid-cols-1'}`}>
<Link to="/subscription" className="btn-primary text-center text-sm py-2.5">
{t('dashboard.viewSubscription')}
</Link>
{subscription.subscription_url && (
<button
onClick={() => setShowConnectionModal(true)}
className="btn-secondary text-sm py-2.5"
data-onboarding="connect-devices"
>
{t('subscription.getConfig')}
</button>
)}
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Balance */}
<Link to="/balance" className="card-hover group" data-onboarding="balance">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('balance.currentBalance')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
<ArrowRightIcon />
</span>
</div>
<div className="stat-value text-accent-400">
{formatAmount(balanceData?.balance_rubles || 0)}
<span className="text-lg ml-1 text-dark-400">{currencySymbol}</span>
</div>
</Link>
{/* Subscription */}
<Link to="/subscription" className="card-hover group" data-onboarding="subscription-status">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('subscription.title')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
<ArrowRightIcon />
</span>
</div>
{subLoading ? (
<div className="skeleton h-8 w-24" />
) : subscription ? (
<div className="stat-value">
{subscription.days_left > 0 ? (
<>
{subscription.days_left}
<span className="text-lg ml-1 text-dark-400">{t('subscription.days')}</span>
</>
) : subscription.hours_left > 0 ? (
<>
{subscription.hours_left}
<span className="text-lg ml-1 text-dark-400">{t('subscription.hours')}</span>
</>
) : subscription.minutes_left > 0 ? (
<>
{subscription.minutes_left}
<span className="text-lg ml-1 text-dark-400">{t('subscription.minutes')}</span>
</>
) : (
<span className="text-error-400">{t('subscription.expired')}</span>
)}
</div>
) : (
<div className="stat-value text-error-400">{t('subscription.inactive')}</div>
)}
</Link>
{/* Referrals */}
<Link to="/referral" className="card-hover group">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('referral.stats.totalReferrals')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
<ArrowRightIcon />
</span>
</div>
{refLoading ? (
<div className="skeleton h-8 w-16" />
) : (
<div className="stat-value">{referralInfo?.total_referrals || 0}</div>
)}
</Link>
{/* Earnings */}
<Link to="/referral" className="card-hover group">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('referral.stats.totalEarnings')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
<ArrowRightIcon />
</span>
</div>
{refLoading ? (
<div className="skeleton h-8 w-20" />
) : (
<div className="stat-value text-success-400">
{formatPositive(referralInfo?.total_earnings_rubles || 0)}
</div>
)}
</Link>
</div>
{/* Trial Activation */}
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0">
<SparklesIcon />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-dark-100 mb-2">
{t('subscription.trial.title', 'Free Trial')}
</h3>
<p className="text-dark-400 text-sm mb-4">
{t('subscription.trial.description', 'Try our VPN service for free!')}
</p>
<div className="flex gap-6 mb-6">
<div className="text-center">
<div className="text-2xl font-bold text-accent-400">{trialInfo.duration_days}</div>
<div className="text-xs text-dark-500">{t('subscription.trial.days', 'days')}</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-accent-400">{trialInfo.traffic_limit_gb}</div>
<div className="text-xs text-dark-500">GB</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-accent-400">{trialInfo.device_limit}</div>
<div className="text-xs text-dark-500">{t('subscription.trial.devices', 'devices')}</div>
</div>
</div>
{trialInfo.requires_payment && trialInfo.price_rubles > 0 && (
<p className="text-sm text-dark-400 mb-4">
{t('subscription.trial.price', 'Activation price')}: {trialInfo.price_rubles.toFixed(2)}
</p>
)}
{trialError && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 text-sm p-3 rounded-xl mb-4">
{trialError}
</div>
)}
<button
onClick={() => activateTrialMutation.mutate()}
disabled={activateTrialMutation.isPending}
className="btn-primary"
>
{activateTrialMutation.isPending
? t('common.loading', 'Loading...')
: t('subscription.trial.activate', 'Activate Free Trial')}
</button>
</div>
</div>
</div>
)}
{/* Fortune Wheel Banner */}
{wheelConfig?.is_enabled && (
<Link
to="/wheel"
className="group card-hover flex items-center justify-between"
>
<div className="flex items-center gap-4">
{/* Emoji */}
<span className="text-3xl">🎰</span>
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-dark-100">
{t('wheel.banner.title')}
</h3>
<p className="text-dark-400 text-sm">
{t('wheel.banner.description')}
</p>
</div>
</div>
<div className="text-dark-500 group-hover:text-accent-400 group-hover:translate-x-1 transition-all duration-300 flex-shrink-0">
<ChevronRightIcon />
</div>
</Link>
)}
{/* Quick Actions */}
<div className="card" data-onboarding="quick-actions">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('dashboard.quickActions')}</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Link to="/balance" className="btn-secondary justify-center text-center text-sm py-2.5">
{t('dashboard.topUpBalance')}
</Link>
<Link to="/subscription" state={{ scrollToExtend: true }} className="btn-secondary justify-center text-center text-sm py-2.5">
{t('subscription.renew')}
</Link>
<Link to="/referral" className="btn-secondary justify-center text-center text-sm py-2.5">
{t('dashboard.inviteFriends')}
</Link>
<Link to="/support" className="btn-secondary justify-center text-center text-sm py-2.5 flex items-center gap-2">
<SupportLottieIcon />
<span>{t('dashboard.getSupport')}</span>
</Link>
</div>
</div>
{/* Connection Modal */}
{showConnectionModal && (
<ConnectionModal onClose={() => setShowConnectionModal(false)} />
)}
{/* Onboarding Tutorial */}
{showOnboarding && (
<Onboarding
steps={onboardingSteps}
onComplete={handleOnboardingComplete}
onSkip={handleOnboardingComplete}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,315 @@
import { useEffect, useState, useCallback } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { brandingApi } from '../api/branding'
type Status = 'countdown' | 'fallback' | 'error'
// App schemes configuration - same as miniapp
const appSchemes = [
{ scheme: 'happ://', icon: 'H', name: 'Happ' },
{ scheme: 'flclash://', icon: 'F', name: 'FlClash' },
{ scheme: 'clash://', icon: 'C', name: 'Clash Meta' },
{ scheme: 'sing-box://', icon: 'S', name: 'sing-box' },
{ scheme: 'v2rayng://', icon: 'V', name: 'v2rayNG' },
{ scheme: 'sub://', icon: 'R', name: 'Shadowrocket' },
{ scheme: 'shadowrocket://', icon: 'R', name: 'Shadowrocket' },
{ scheme: 'hiddify://', icon: 'H', name: 'Hiddify' },
{ scheme: 'streisand://', icon: 'S', name: 'Streisand' },
{ scheme: 'quantumult://', icon: 'Q', name: 'Quantumult X' },
{ scheme: 'surge://', icon: 'S', name: 'Surge' },
{ scheme: 'loon://', icon: 'L', name: 'Loon' },
{ scheme: 'nekobox://', icon: 'N', name: 'NekoBox' },
{ scheme: 'v2box://', icon: 'V', name: 'V2Box' },
]
const COUNTDOWN_SECONDS = 5
export default function DeepLinkRedirect() {
const { i18n } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [status, setStatus] = useState<Status>('countdown')
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS)
const [copied, setCopied] = useState(false)
// Get branding
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
staleTime: 60000,
})
const projectName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN')
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
// Get parameters
const deepLink = searchParams.get('url') || searchParams.get('deeplink') || ''
const subscriptionUrl = searchParams.get('sub') || ''
const appParam = searchParams.get('app') || ''
// Detect app from deep link
const appInfo = deepLink
? appSchemes.find(a => deepLink.toLowerCase().startsWith(a.scheme))
: null
const appName = appInfo?.name || appParam || 'VPN'
const appIcon = appInfo?.icon || appName[0]?.toUpperCase() || 'V'
// Translations
const texts = {
en: {
connecting: 'Connecting to',
redirecting: 'Redirecting in',
seconds: 'seconds',
manual: 'If nothing happens, click the button below.',
openApp: 'Open App',
copyLink: 'Copy subscription link',
copied: 'Copied!',
tryAgain: 'Try again',
backToCabinet: 'Back to cabinet',
errorTitle: 'Error',
errorDesc: 'Connection link is missing',
goToSubscription: 'Go to subscription',
howToAdd: 'How to add manually:',
step1: 'Copy the link using the button above',
step2: 'Open the app',
step3: 'Find "+" or "Add subscription"',
step4: 'Select "From clipboard" or "Paste link"',
},
ru: {
connecting: 'Подключение к',
redirecting: 'Перенаправление через',
seconds: 'сек',
manual: 'Если ничего не происходит, нажмите кнопку ниже.',
openApp: 'Открыть приложение',
copyLink: 'Скопировать ссылку подписки',
copied: 'Скопировано!',
tryAgain: 'Попробовать снова',
backToCabinet: 'Вернуться в кабинет',
errorTitle: 'Ошибка',
errorDesc: 'Ссылка для подключения не найдена',
goToSubscription: 'Перейти к подписке',
howToAdd: 'Как добавить вручную:',
step1: 'Скопируйте ссылку кнопкой выше',
step2: 'Откройте приложение',
step3: 'Найдите "+" или "Добавить подписку"',
step4: 'Выберите "Из буфера" или "Вставить ссылку"',
}
}
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
const txt = texts[lang]
// Open deep link - same as miniapp, just window.location.href
const openDeepLink = useCallback(() => {
if (!deepLink) return
window.location.href = deepLink
}, [deepLink])
// Countdown timer effect
useEffect(() => {
if (!deepLink) {
setStatus('error')
return
}
if (status !== 'countdown') return
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(timer)
openDeepLink()
// Show fallback after a delay
setTimeout(() => setStatus('fallback'), 2000)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [deepLink, status, openDeepLink])
const handleCopyLink = async () => {
const linkToCopy = subscriptionUrl || deepLink
try {
await navigator.clipboard.writeText(linkToCopy)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
const textarea = document.createElement('textarea')
textarea.value = linkToCopy
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
// Progress percentage
const progress = ((COUNTDOWN_SECONDS - countdown) / COUNTDOWN_SECONDS) * 100
return (
<div className="min-h-screen flex items-center justify-center p-4">
{/* Background */}
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
<div className="relative text-center max-w-sm w-full">
{/* Logo with pulse animation */}
<div className="mx-auto w-20 h-20 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden animate-pulse">
{branding?.has_custom_logo && logoUrl ? (
<img src={logoUrl} alt={projectName || 'Logo'} className="w-full h-full object-cover" />
) : (
<span className="text-white font-bold text-3xl">{logoLetter}</span>
)}
</div>
<h1 className="text-2xl font-bold text-dark-50 mb-1">{projectName || 'VPN'}</h1>
{status !== 'error' && (
<p className="text-dark-400 mb-6">{txt.connecting} {appName}...</p>
)}
{/* Countdown State */}
{status === 'countdown' && (
<div className="card !bg-dark-800/80 backdrop-blur-sm p-6">
{/* App icon */}
<div className="w-16 h-16 rounded-2xl bg-accent-500/20 flex items-center justify-center mx-auto mb-4">
<span className="text-2xl font-bold text-accent-400">{appIcon}</span>
</div>
{/* Spinner */}
<div className="w-12 h-12 border-3 border-dark-700 border-t-accent-500 rounded-full animate-spin mx-auto mb-4" />
{/* Timer */}
<div className="mb-4">
<p className="text-sm text-dark-500 mb-2">{txt.redirecting}</p>
<div className="flex items-center justify-center gap-2">
<span className="text-4xl font-bold text-accent-400">{countdown}</span>
<span className="text-dark-400">{txt.seconds}</span>
</div>
</div>
{/* Progress bar */}
<div className="w-full h-1.5 bg-dark-700 rounded-full overflow-hidden mb-4">
<div
className="h-full bg-gradient-to-r from-accent-400 to-accent-600 rounded-full transition-all duration-1000 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-sm text-dark-500 mb-4">{txt.manual}</p>
{/* Open now button */}
<button
onClick={openDeepLink}
className="btn-primary w-full py-3 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
{txt.openApp}
</button>
</div>
)}
{/* Fallback State - App didn't open */}
{status === 'fallback' && (
<div className="card !bg-dark-800/80 backdrop-blur-sm p-6">
{/* App icon */}
<div className="w-16 h-16 rounded-2xl bg-accent-500/20 flex items-center justify-center mx-auto mb-4">
<span className="text-2xl font-bold text-accent-400">{appIcon}</span>
</div>
<div className="space-y-3">
{/* Copy subscription link */}
<button
onClick={handleCopyLink}
className="btn-primary w-full py-3 flex items-center justify-center gap-2"
>
{copied ? (
<>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{txt.copied}
</>
) : (
<>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{txt.copyLink}
</>
)}
</button>
{/* Try again button */}
<button
onClick={openDeepLink}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
{txt.tryAgain}
</button>
{/* Back to cabinet */}
<button
onClick={() => navigate('/subscription')}
className="w-full text-sm text-dark-500 hover:text-dark-300 transition-colors py-2"
>
{txt.backToCabinet}
</button>
</div>
{/* Instructions */}
<div className="mt-6 p-4 rounded-xl bg-dark-900/50 border border-dark-700 text-left">
<h3 className="text-sm font-medium text-dark-200 mb-2">{txt.howToAdd}</h3>
<ol className="text-xs text-dark-400 space-y-1.5 list-decimal list-inside">
<li>{txt.step1}</li>
<li>{txt.step2} {appName}</li>
<li>{txt.step3}</li>
<li>{txt.step4}</li>
</ol>
</div>
</div>
)}
{/* Error State */}
{status === 'error' && (
<div className="card !bg-dark-800/80 backdrop-blur-sm p-6">
<div className="w-16 h-16 rounded-full bg-error-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p className="text-dark-200 font-medium mb-2">{txt.errorTitle}</p>
<p className="text-sm text-dark-400 mb-6">{txt.errorDesc}</p>
<button
onClick={() => navigate('/subscription')}
className="btn-primary w-full"
>
{txt.goToSubscription}
</button>
</div>
)}
{/* Footer */}
<div className="mt-8 flex items-center justify-center gap-2 text-dark-600">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
<span className="text-xs">VPN Config Redirect</span>
</div>
</div>
</div>
)
}

295
src/pages/Info.tsx Normal file
View File

@@ -0,0 +1,295 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { infoApi, FaqPage } from '../api/info'
const InfoIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
)
const QuestionIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
)
const DocumentIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
)
const ShieldIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
)
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
<svg
className={`w-5 h-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
)
type TabType = 'faq' | 'rules' | 'privacy' | 'offer'
// Convert plain text to HTML with proper formatting
const formatContent = (content: string): string => {
if (!content) return ''
// Check if content already has HTML tags
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content)
if (hasHtmlTags) {
return content
}
// Convert plain text to formatted HTML
return content
.split(/\n\n+/) // Split by double newlines (paragraphs)
.map(paragraph => {
// Check if it's a header (starts with # or numeric like "1.")
if (/^#{1,4}\s/.test(paragraph)) {
const level = paragraph.match(/^(#{1,4})/)?.[1].length || 1
const text = paragraph.replace(/^#{1,4}\s*/, '')
return `<h${level}>${text}</h${level}>`
}
// Check for list items
if (/^[-•]\s/.test(paragraph) || /^\d+[.)]\s/.test(paragraph)) {
const lines = paragraph.split('\n')
const isOrdered = /^\d+[.)]\s/.test(lines[0])
const listItems = lines
.map(line => line.replace(/^[-•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
.filter(line => line.trim())
.map(line => `<li>${line}</li>`)
.join('')
return isOrdered ? `<ol>${listItems}</ol>` : `<ul>${listItems}</ul>`
}
// Regular paragraph - handle single line breaks within
const formattedParagraph = paragraph
.split('\n')
.join('<br/>')
return `<p>${formattedParagraph}</p>`
})
.join('')
}
export default function Info() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<TabType>('faq')
const [expandedFaq, setExpandedFaq] = useState<number | null>(null)
const { data: faqPages, isLoading: faqLoading } = useQuery({
queryKey: ['faq-pages'],
queryFn: infoApi.getFaqPages,
enabled: activeTab === 'faq',
staleTime: 0,
refetchOnMount: 'always',
})
const { data: rules, isLoading: rulesLoading } = useQuery({
queryKey: ['rules'],
queryFn: infoApi.getRules,
enabled: activeTab === 'rules',
staleTime: 0,
refetchOnMount: 'always',
})
const { data: privacy, isLoading: privacyLoading } = useQuery({
queryKey: ['privacy-policy'],
queryFn: infoApi.getPrivacyPolicy,
enabled: activeTab === 'privacy',
staleTime: 0,
refetchOnMount: 'always',
})
const { data: offer, isLoading: offerLoading } = useQuery({
queryKey: ['public-offer'],
queryFn: infoApi.getPublicOffer,
enabled: activeTab === 'offer',
staleTime: 0,
refetchOnMount: 'always',
})
const tabs = [
{ id: 'faq' as TabType, label: t('info.faq'), icon: QuestionIcon },
{ id: 'rules' as TabType, label: t('info.rules'), icon: DocumentIcon },
{ id: 'privacy' as TabType, label: t('info.privacy'), icon: ShieldIcon },
{ id: 'offer' as TabType, label: t('info.offer'), icon: DocumentIcon },
]
const toggleFaq = (id: number) => {
setExpandedFaq(expandedFaq === id ? null : id)
}
const renderContent = () => {
if (activeTab === 'faq') {
if (faqLoading) {
return (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!faqPages || faqPages.length === 0) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noFaq')}
</div>
)
}
return (
<div className="space-y-2">
{faqPages.map((faq: FaqPage) => (
<div key={faq.id} className="card p-0 overflow-hidden">
<button
onClick={() => toggleFaq(faq.id)}
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-dark-800/50 transition-colors"
>
<span className="font-medium">{faq.title}</span>
<ChevronIcon expanded={expandedFaq === faq.id} />
</button>
{expandedFaq === faq.id && (
<div className="px-4 pb-4 text-dark-300 prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: formatContent(faq.content) }} />
</div>
)}
</div>
))}
</div>
)
}
if (activeTab === 'rules') {
if (rulesLoading) {
return (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!rules?.content) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noContent')}
</div>
)
}
return (
<div className="card prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: formatContent(rules.content) }} />
{rules.updated_at && (
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
{t('info.updatedAt')}: {new Date(rules.updated_at).toLocaleDateString()}
</p>
)}
</div>
)
}
if (activeTab === 'privacy') {
if (privacyLoading) {
return (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!privacy?.content) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noContent')}
</div>
)
}
return (
<div className="card prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: formatContent(privacy.content) }} />
{privacy.updated_at && (
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
{t('info.updatedAt')}: {new Date(privacy.updated_at).toLocaleDateString()}
</p>
)}
</div>
)
}
if (activeTab === 'offer') {
if (offerLoading) {
return (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!offer?.content) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noContent')}
</div>
)
}
return (
<div className="card prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: formatContent(offer.content) }} />
{offer.updated_at && (
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
{t('info.updatedAt')}: {new Date(offer.updated_at).toLocaleDateString()}
</p>
)}
</div>
)
}
return null
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<InfoIcon />
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('info.title')}</h1>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
<tab.icon />
{tab.label}
</button>
))}
</div>
{/* Content */}
{renderContent()}
</div>
)
}

267
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,267 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth'
import { brandingApi, type BrandingInfo } from '../api/branding'
import LanguageSwitcher from '../components/LanguageSwitcher'
import TelegramLoginButton from '../components/TelegramLoginButton'
const BRANDING_CACHE_KEY = 'cabinet-branding-cache'
const BRANDING_CACHE_TTL = 1000 * 60 * 60 // 1 hour
const getCachedBranding = (): BrandingInfo | undefined => {
if (typeof window === 'undefined') {
return undefined
}
try {
const raw = localStorage.getItem(BRANDING_CACHE_KEY)
if (!raw) return undefined
const parsed = JSON.parse(raw) as { data?: BrandingInfo; timestamp?: number }
if (!parsed?.data || !parsed.timestamp) return undefined
if (Date.now() - parsed.timestamp > BRANDING_CACHE_TTL) {
localStorage.removeItem(BRANDING_CACHE_KEY)
return undefined
}
return parsed.data
} catch {
return undefined
}
}
const cacheBranding = (data: BrandingInfo) => {
if (typeof window === 'undefined') {
return
}
try {
localStorage.setItem(
BRANDING_CACHE_KEY,
JSON.stringify({ data, timestamp: Date.now() })
)
} catch {
// Ignore storage errors (e.g., private mode)
}
}
export default function Login() {
const { t } = useTranslation()
const navigate = useNavigate()
const { isAuthenticated, loginWithTelegram, loginWithEmail } = useAuthStore()
const [activeTab, setActiveTab] = useState<'telegram' | 'email'>('telegram')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
// Fetch branding
const cachedBranding = useMemo(() => getCachedBranding(), [])
const { data: branding } = useQuery<BrandingInfo>({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
staleTime: 60000,
placeholderData: cachedBranding,
})
useEffect(() => {
if (branding) {
cacheBranding(branding)
}
}, [branding])
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''
const appName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN')
const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
// Set document title
useEffect(() => {
document.title = appName || 'VPN'
}, [appName])
useEffect(() => {
if (isAuthenticated) {
navigate('/')
}
}, [isAuthenticated, navigate])
// Try Telegram WebApp authentication on mount
useEffect(() => {
const tryTelegramAuth = async () => {
const tg = window.Telegram?.WebApp
if (tg?.initData) {
setIsTelegramWebApp(true)
tg.ready()
tg.expand()
setIsLoading(true)
try {
await loginWithTelegram(tg.initData)
navigate('/')
} catch (err) {
console.error('Telegram auth failed:', err)
setError(t('auth.telegramRequired'))
} finally {
setIsLoading(false)
}
}
}
tryTelegramAuth()
}, [loginWithTelegram, navigate, t])
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
await loginWithEmail(email, password)
navigate('/')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('common.error'))
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center py-8 px-4 sm:py-12 sm:px-6 lg:px-8">
{/* Background gradient */}
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
{/* Language switcher */}
<div className="fixed top-4 right-4 z-50">
<LanguageSwitcher />
</div>
<div className="relative max-w-md w-full space-y-8">
{/* Logo */}
<div className="text-center">
<div className="mx-auto w-16 h-16 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden">
{branding?.has_custom_logo && logoUrl ? (
<img src={logoUrl} alt={appName || 'Logo'} className="w-full h-full object-cover" />
) : (
<span className="text-white font-bold text-2xl">{appLogo}</span>
)}
</div>
{appName && (
<h1 className="text-3xl font-bold text-dark-50">
{appName}
</h1>
)}
<p className="mt-2 text-dark-400">
{t('auth.loginSubtitle')}
</p>
</div>
{/* Card */}
<div className="card">
{/* Tabs */}
<div className="flex mb-6">
<button
className={`flex-1 py-3 text-sm font-medium transition-all border-b-2 ${
activeTab === 'telegram'
? 'border-accent-500 text-accent-400'
: 'border-transparent text-dark-500 hover:text-dark-300'
}`}
onClick={() => setActiveTab('telegram')}
>
Telegram
</button>
<button
className={`flex-1 py-3 text-sm font-medium transition-all border-b-2 ${
activeTab === 'email'
? 'border-accent-500 text-accent-400'
: 'border-transparent text-dark-500 hover:text-dark-300'
}`}
onClick={() => setActiveTab('email')}
>
Email
</button>
</div>
{error && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 px-4 py-3 rounded-xl text-sm mb-6">
{error}
</div>
)}
{activeTab === 'telegram' ? (
<div className="space-y-6">
<p className="text-center text-sm text-dark-400">
{t('auth.registerHint')}
</p>
{isLoading && isTelegramWebApp ? (
<div className="text-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-sm text-dark-400">{t('auth.authenticating')}</p>
</div>
) : (
<TelegramLoginButton botUsername={botUsername} />
)}
</div>
) : (
<form className="space-y-5" onSubmit={handleEmailLogin}>
<div>
<label htmlFor="email" className="label">
{t('auth.email')}
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="input"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="label">
{t('auth.password')}
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="input"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full py-3"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('common.loading')}
</span>
) : (
t('auth.login')
)}
</button>
<p className="text-center text-xs text-dark-500">
{t('auth.registerHint')}
</p>
</form>
)}
</div>
</div>
</div>
)
}

243
src/pages/Polls.tsx Normal file
View File

@@ -0,0 +1,243 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { pollsApi, PollInfo, PollQuestion } from '../api/polls'
const ClipboardIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
)
const GiftIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
)
const CheckIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
export default function Polls() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selectedPoll, setSelectedPoll] = useState<PollInfo | null>(null)
const [currentQuestion, setCurrentQuestion] = useState<PollQuestion | null>(null)
const [questionIndex, setQuestionIndex] = useState(0)
const [totalQuestions, setTotalQuestions] = useState(0)
const [completionMessage, setCompletionMessage] = useState<{ reward: number | null; message: string } | null>(null)
const { data: polls, isLoading, error } = useQuery({
queryKey: ['polls'],
queryFn: pollsApi.getPolls,
})
const startPollMutation = useMutation({
mutationFn: pollsApi.startPoll,
onSuccess: (data) => {
setCurrentQuestion(data.question)
setQuestionIndex(data.current_question_index)
setTotalQuestions(data.total_questions)
setCompletionMessage(null)
},
})
const answerMutation = useMutation({
mutationFn: ({
responseId,
questionId,
optionId,
}: {
responseId: number
questionId: number
optionId: number
}) => pollsApi.answerQuestion(responseId, questionId, optionId),
onSuccess: (data) => {
if (data.is_completed) {
setCurrentQuestion(null)
setCompletionMessage({
reward: data.reward_granted,
message: data.message || t('polls.completed'),
})
queryClient.invalidateQueries({ queryKey: ['polls'] })
} else if (data.next_question) {
setCurrentQuestion(data.next_question)
setQuestionIndex(data.current_question_index || 0)
setTotalQuestions(data.total_questions)
}
},
})
const handleStartPoll = (poll: PollInfo) => {
setSelectedPoll(poll)
startPollMutation.mutate(poll.response_id)
}
const handleAnswer = (optionId: number) => {
if (selectedPoll && currentQuestion) {
answerMutation.mutate({
responseId: selectedPoll.response_id,
questionId: currentQuestion.id,
optionId,
})
}
}
const handleClosePoll = () => {
setSelectedPoll(null)
setCurrentQuestion(null)
setCompletionMessage(null)
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-64">
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error) {
return (
<div className="card bg-red-500/10 border-red-500/20">
<p className="text-red-400">{t('polls.error')}</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<ClipboardIcon />
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('polls.title')}</h1>
</div>
{/* Poll Modal */}
{selectedPoll && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="card max-w-lg w-full max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{selectedPoll.title}</h2>
<button onClick={handleClosePoll} className="text-dark-400 hover:text-dark-200">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{startPollMutation.isPending && (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{completionMessage && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-success-500/20 text-success-400 text-center">
<CheckIcon />
<p className="font-medium mt-2">{completionMessage.message}</p>
{completionMessage.reward && (
<p className="text-sm mt-1">
+{completionMessage.reward} {t('polls.reward')}
</p>
)}
</div>
<button onClick={handleClosePoll} className="w-full btn-secondary">
{t('common.close')}
</button>
</div>
)}
{currentQuestion && !completionMessage && (
<div className="space-y-4">
<div className="text-sm text-dark-400">
{t('polls.question')} {questionIndex + 1} {t('polls.of')} {totalQuestions}
</div>
<div className="w-full bg-dark-700 rounded-full h-2">
<div
className="bg-accent-500 h-2 rounded-full transition-all"
style={{ width: `${((questionIndex + 1) / totalQuestions) * 100}%` }}
/>
</div>
<p className="text-lg font-medium">{currentQuestion.text}</p>
<div className="space-y-2">
{currentQuestion.options.map((option) => (
<button
key={option.id}
onClick={() => handleAnswer(option.id)}
disabled={answerMutation.isPending}
className="w-full p-4 text-left bg-dark-700 hover:bg-dark-600 rounded-lg transition-colors disabled:opacity-50"
>
{option.text}
</button>
))}
</div>
{answerMutation.isPending && (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Polls List */}
{polls && polls.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{polls.map((poll) => (
<div key={poll.id} className="card">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-lg">{poll.title}</h3>
{poll.description && (
<p className="text-dark-400 text-sm mt-1">{poll.description}</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm text-dark-400">
<span>
{poll.answered_questions}/{poll.total_questions} {t('polls.questions')}
</span>
</div>
</div>
{poll.reward_amount && (
<div className="flex items-center gap-1 text-accent-400">
<GiftIcon />
<span className="text-sm font-medium">+{poll.reward_amount}</span>
</div>
)}
</div>
<div className="mt-4">
{poll.is_completed ? (
<button disabled className="w-full btn-secondary opacity-50 cursor-not-allowed">
<CheckIcon />
<span className="ml-2">{t('polls.completed')}</span>
</button>
) : (
<button
onClick={() => handleStartPoll(poll)}
className="w-full btn-primary"
>
{poll.answered_questions > 0 ? t('polls.continue') : t('polls.start')}
</button>
)}
</div>
</div>
))}
</div>
) : (
<div className="card text-center py-12">
<ClipboardIcon />
<p className="text-dark-400 mt-4">{t('polls.noPolls')}</p>
</div>
)}
</div>
)
}

414
src/pages/Profile.tsx Normal file
View File

@@ -0,0 +1,414 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth'
import { authApi } from '../api/auth'
import { notificationsApi, NotificationSettings, NotificationSettingsUpdate } from '../api/notifications'
export default function Profile() {
const { t } = useTranslation()
const { user, setUser } = useAuthStore()
const queryClient = useQueryClient()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const registerEmailMutation = useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
authApi.registerEmail(email, password),
onSuccess: async () => {
setSuccess(t('profile.emailSent'))
setError(null)
setEmail('')
setPassword('')
setConfirmPassword('')
const updatedUser = await authApi.getMe()
setUser(updatedUser)
queryClient.invalidateQueries({ queryKey: ['user'] })
},
onError: (err: { response?: { data?: { detail?: string } } }) => {
setError(err.response?.data?.detail || t('common.error'))
setSuccess(null)
},
})
const resendVerificationMutation = useMutation({
mutationFn: authApi.resendVerification,
onSuccess: () => {
setSuccess(t('profile.verificationResent'))
setError(null)
},
onError: (err: { response?: { data?: { detail?: string } } }) => {
setError(err.response?.data?.detail || t('common.error'))
setSuccess(null)
},
})
const { data: notificationSettings, isLoading: notificationsLoading } = useQuery({
queryKey: ['notification-settings'],
queryFn: notificationsApi.getSettings,
})
const updateNotificationsMutation = useMutation({
mutationFn: notificationsApi.updateSettings,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-settings'] })
},
})
const handleNotificationToggle = (key: keyof NotificationSettings, value: boolean) => {
const update: NotificationSettingsUpdate = { [key]: value }
updateNotificationsMutation.mutate(update)
}
const handleNotificationValue = (key: keyof NotificationSettings, value: number) => {
const update: NotificationSettingsUpdate = { [key]: value }
updateNotificationsMutation.mutate(update)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSuccess(null)
if (!email.trim()) {
setError(t('profile.emailRequired'))
return
}
if (!password || password.length < 8) {
setError(t('profile.passwordMinLength'))
return
}
if (password !== confirmPassword) {
setError(t('profile.passwordsMismatch'))
return
}
registerEmailMutation.mutate({ email, password })
}
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('profile.title')}</h1>
{/* User Info Card */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.accountInfo')}</h2>
<div className="space-y-4">
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
<span className="text-dark-400">{t('profile.telegramId')}</span>
<span className="text-dark-100 font-medium">{user?.telegram_id}</span>
</div>
{user?.username && (
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
<span className="text-dark-400">{t('profile.username')}</span>
<span className="text-dark-100 font-medium">@{user.username}</span>
</div>
)}
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
<span className="text-dark-400">{t('profile.name')}</span>
<span className="text-dark-100 font-medium">
{user?.first_name} {user?.last_name}
</span>
</div>
<div className="flex justify-between items-center py-3">
<span className="text-dark-400">{t('profile.registeredAt')}</span>
<span className="text-dark-100 font-medium">
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
</span>
</div>
</div>
</div>
{/* Email Section */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.emailAuth')}</h2>
{user?.email ? (
<div className="space-y-4">
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
<span className="text-dark-400">Email</span>
<div className="flex items-center gap-3">
<span className="text-dark-100 font-medium">{user.email}</span>
{user.email_verified ? (
<span className="badge-success">{t('profile.verified')}</span>
) : (
<span className="badge-warning">{t('profile.notVerified')}</span>
)}
</div>
</div>
{!user.email_verified && (
<div className="bg-warning-500/10 border border-warning-500/30 rounded-xl p-4">
<p className="text-sm text-warning-400 mb-4">
{t('profile.verificationRequired')}
</p>
<button
onClick={() => resendVerificationMutation.mutate()}
disabled={resendVerificationMutation.isPending}
className="btn-primary"
>
{resendVerificationMutation.isPending
? t('common.loading')
: t('profile.resendVerification')}
</button>
</div>
)}
{user.email_verified && (
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
)}
</div>
) : (
<div>
<p className="text-sm text-dark-400 mb-6">{t('profile.linkEmailDescription')}</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className="input"
/>
</div>
<div>
<label className="label">{t('auth.password')}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('profile.passwordPlaceholder')}
className="input"
/>
<p className="text-xs text-dark-500 mt-2">{t('profile.passwordHint')}</p>
</div>
<div>
<label className="label">{t('auth.confirmPassword')}</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t('profile.confirmPasswordPlaceholder')}
className="input"
/>
</div>
{error && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-4 rounded-xl text-sm">
{error}
</div>
)}
{success && (
<div className="bg-success-500/10 border border-success-500/30 text-success-400 p-4 rounded-xl text-sm">
{success}
</div>
)}
<button
type="submit"
disabled={registerEmailMutation.isPending}
className="btn-primary w-full"
>
{registerEmailMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('common.loading')}
</span>
) : (
t('profile.linkEmail')
)}
</button>
</form>
</div>
)}
{(error || success) && user?.email && (
<div className="mt-4">
{error && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-4 rounded-xl text-sm">
{error}
</div>
)}
{success && (
<div className="bg-success-500/10 border border-success-500/30 text-success-400 p-4 rounded-xl text-sm">
{success}
</div>
)}
</div>
)}
</div>
{/* Notification Settings */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.notifications.title')}</h2>
{notificationsLoading ? (
<div className="flex justify-center py-4">
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : notificationSettings ? (
<div className="space-y-6">
{/* Subscription Expiry */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-dark-100">{t('profile.notifications.subscriptionExpiry')}</p>
<p className="text-sm text-dark-400">{t('profile.notifications.subscriptionExpiryDesc')}</p>
</div>
<button
onClick={() => handleNotificationToggle('subscription_expiry_enabled', !notificationSettings.subscription_expiry_enabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
notificationSettings.subscription_expiry_enabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
notificationSettings.subscription_expiry_enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{notificationSettings.subscription_expiry_enabled && (
<div className="flex items-center gap-3 pl-4">
<span className="text-sm text-dark-400">{t('profile.notifications.daysBeforeExpiry')}</span>
<select
value={notificationSettings.subscription_expiry_days}
onChange={(e) => handleNotificationValue('subscription_expiry_days', Number(e.target.value))}
className="input w-20 py-1"
>
{[1, 2, 3, 5, 7, 14].map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
)}
</div>
{/* Traffic Warning */}
<div className="space-y-3 border-t border-dark-800/50 pt-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-dark-100">{t('profile.notifications.trafficWarning')}</p>
<p className="text-sm text-dark-400">{t('profile.notifications.trafficWarningDesc')}</p>
</div>
<button
onClick={() => handleNotificationToggle('traffic_warning_enabled', !notificationSettings.traffic_warning_enabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
notificationSettings.traffic_warning_enabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
notificationSettings.traffic_warning_enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{notificationSettings.traffic_warning_enabled && (
<div className="flex items-center gap-3 pl-4">
<span className="text-sm text-dark-400">{t('profile.notifications.atPercent')}</span>
<select
value={notificationSettings.traffic_warning_percent}
onChange={(e) => handleNotificationValue('traffic_warning_percent', Number(e.target.value))}
className="input w-20 py-1"
>
{[50, 70, 80, 90, 95].map((p) => (
<option key={p} value={p}>{p}%</option>
))}
</select>
</div>
)}
</div>
{/* Balance Low */}
<div className="space-y-3 border-t border-dark-800/50 pt-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-dark-100">{t('profile.notifications.balanceLow')}</p>
<p className="text-sm text-dark-400">{t('profile.notifications.balanceLowDesc')}</p>
</div>
<button
onClick={() => handleNotificationToggle('balance_low_enabled', !notificationSettings.balance_low_enabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
notificationSettings.balance_low_enabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
notificationSettings.balance_low_enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{notificationSettings.balance_low_enabled && (
<div className="flex items-center gap-3 pl-4">
<span className="text-sm text-dark-400">{t('profile.notifications.threshold')}</span>
<input
type="number"
value={notificationSettings.balance_low_threshold}
onChange={(e) => handleNotificationValue('balance_low_threshold', Number(e.target.value))}
min={0}
className="input w-24 py-1"
/>
</div>
)}
</div>
{/* News */}
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
<div>
<p className="font-medium text-dark-100">{t('profile.notifications.news')}</p>
<p className="text-sm text-dark-400">{t('profile.notifications.newsDesc')}</p>
</div>
<button
onClick={() => handleNotificationToggle('news_enabled', !notificationSettings.news_enabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
notificationSettings.news_enabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
notificationSettings.news_enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{/* Promo Offers */}
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
<div>
<p className="font-medium text-dark-100">{t('profile.notifications.promoOffers')}</p>
<p className="text-sm text-dark-400">{t('profile.notifications.promoOffersDesc')}</p>
</div>
<button
onClick={() => handleNotificationToggle('promo_offers_enabled', !notificationSettings.promo_offers_enabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
notificationSettings.promo_offers_enabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
notificationSettings.promo_offers_enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
</div>
) : (
<p className="text-dark-400">{t('profile.notifications.unavailable')}</p>
)}
</div>
</div>
)
}

248
src/pages/Referral.tsx Normal file
View File

@@ -0,0 +1,248 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { referralApi } from '../api/referral'
import { useCurrency } from '../hooks/useCurrency'
const CopyIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
const ShareIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8l5-5m0 0l5 5m-5-5v12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3" />
</svg>
)
export default function Referral() {
const { t } = useTranslation()
const { formatAmount, currencySymbol, formatPositive } = useCurrency()
const [copied, setCopied] = useState(false)
const { data: info, isLoading } = useQuery({
queryKey: ['referral-info'],
queryFn: referralApi.getReferralInfo,
})
const { data: terms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
})
const { data: referralList } = useQuery({
queryKey: ['referral-list'],
queryFn: () => referralApi.getReferralList({ per_page: 10 }),
})
const { data: earnings } = useQuery({
queryKey: ['referral-earnings'],
queryFn: () => referralApi.getReferralEarnings({ per_page: 10 }),
})
const copyLink = () => {
if (info?.referral_link) {
navigator.clipboard.writeText(info.referral_link)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const shareLink = () => {
if (!info?.referral_link) return
const shareText = t('referral.shareMessage', {
percent: info?.commission_percent || 0,
})
if (navigator.share) {
navigator
.share({
title: t('referral.title'),
text: shareText,
url: info.referral_link,
})
.catch(() => {
// ignore cancellation errors
})
return
}
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
info.referral_link
)}&text=${encodeURIComponent(shareText)}`
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-64">
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('referral.title')}</h1>
{/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="card">
<div className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</div>
<div className="stat-value mt-1">{info?.total_referrals || 0}</div>
<div className="text-sm text-dark-500 mt-1">
{info?.active_referrals || 0} {t('referral.stats.activeReferrals').toLowerCase()}
</div>
</div>
<div className="card">
<div className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</div>
<div className="stat-value text-success-400 mt-1">
{formatPositive(info?.total_earnings_rubles || 0)}
</div>
</div>
<div className="card col-span-2 sm:col-span-1">
<div className="text-sm text-dark-400">{t('referral.stats.commissionRate')}</div>
<div className="stat-value text-accent-400 mt-1">
{info?.commission_percent || 0}%
</div>
</div>
</div>
{/* Referral Link */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourLink')}</h2>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="text"
readOnly
value={info?.referral_link || ''}
className="input flex-1"
/>
<div className="flex gap-2">
<button
onClick={copyLink}
disabled={!info?.referral_link}
className={`btn-primary px-5 ${
copied ? 'bg-success-500 hover:bg-success-500' : ''
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
<span className="ml-2">{copied ? t('referral.copied') : t('referral.copyLink')}</span>
</button>
<button
onClick={shareLink}
disabled={!info?.referral_link}
className={`btn-secondary px-5 flex items-center ${
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<ShareIcon />
<span className="ml-2">{t('referral.shareButton')}</span>
</button>
</div>
</div>
<p className="mt-3 text-sm text-dark-500">
{t('referral.shareHint', { percent: info?.commission_percent || 0 })}
</p>
</div>
{/* Program Terms */}
{terms && (
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.terms.title')}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.commission')}</div>
<div className="text-lg font-semibold text-dark-100 mt-1">{terms.commission_percent}%</div>
</div>
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.minTopup')}</div>
<div className="text-lg font-semibold text-dark-100 mt-1">{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}</div>
</div>
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.newUserBonus')}</div>
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.first_topup_bonus_rubles)}</div>
</div>
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.inviterBonus')}</div>
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.inviter_bonus_rubles)}</div>
</div>
</div>
</div>
)}
{/* Referrals List */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourReferrals')}</h2>
{referralList?.items && referralList.items.length > 0 ? (
<div className="space-y-3">
{referralList.items.map((ref) => (
<div
key={ref.id}
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30"
>
<div>
<div className="text-dark-100 font-medium">
{ref.first_name || ref.username || `User #${ref.id}`}
</div>
<div className="text-xs text-dark-500 mt-0.5">
{new Date(ref.created_at).toLocaleDateString()}
</div>
</div>
{ref.has_paid ? (
<span className="badge-success">Paid</span>
) : (
<span className="badge-neutral">Pending</span>
)}
</div>
))}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</div>
<div className="text-dark-400">{t('referral.noReferrals')}</div>
</div>
)}
</div>
{/* Earnings History */}
{earnings?.items && earnings.items.length > 0 && (
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.earningsHistory')}</h2>
<div className="space-y-3">
{earnings.items.map((earning) => (
<div
key={earning.id}
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30"
>
<div>
<div className="text-dark-100">
{earning.referral_first_name || earning.referral_username || 'Referral'}
</div>
<div className="text-xs text-dark-500 mt-0.5">
{earning.reason} {new Date(earning.created_at).toLocaleDateString()}
</div>
</div>
<div className="text-success-400 font-semibold">
{formatPositive(earning.amount_rubles)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

1732
src/pages/Subscription.tsx Normal file

File diff suppressed because it is too large Load Diff

682
src/pages/Support.tsx Normal file
View File

@@ -0,0 +1,682 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ticketsApi } from '../api/tickets'
import { infoApi } from '../api/info'
import type { TicketDetail, TicketMessage } from '../types'
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)
const SendIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
)
const ImageIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
)
const CloseIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
// Media attachment state
interface MediaAttachment {
file: File
preview: string
uploading: boolean
fileId?: string
error?: string
}
// Message media display component
function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string) => string }) {
const [imageLoaded, setImageLoaded] = useState(false)
const [imageError, setImageError] = useState(false)
const [showFullImage, setShowFullImage] = useState(false)
if (!message.has_media || !message.media_file_id) {
return null
}
const mediaUrl = ticketsApi.getMediaUrl(message.media_file_id)
if (message.media_type === 'photo') {
return (
<>
<div className="mt-3 relative">
{!imageLoaded && !imageError && (
<div className="w-full h-48 bg-dark-700 rounded-lg animate-pulse flex items-center justify-center">
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{imageError ? (
<div className="w-full h-32 bg-dark-700 rounded-lg flex items-center justify-center text-dark-400 text-sm">
{t('support.imageLoadFailed')}
</div>
) : (
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className={`max-w-full max-h-64 rounded-lg cursor-pointer hover:opacity-90 transition-opacity ${imageLoaded ? '' : 'hidden'}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
onClick={() => setShowFullImage(true)}
/>
)}
{message.media_caption && (
<p className="text-xs text-dark-400 mt-1">{message.media_caption}</p>
)}
</div>
{/* Full image modal */}
{showFullImage && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onClick={() => setShowFullImage(false)}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white"
onClick={() => setShowFullImage(false)}
>
<CloseIcon />
</button>
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className="max-w-full max-h-full object-contain"
/>
</div>
)}
</>
)
}
// For documents/videos - show download link
return (
<div className="mt-3">
<a
href={mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm text-dark-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
{message.media_caption || `Download ${message.media_type}`}
</a>
</div>
)
}
export default function Support() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null)
const [showCreateForm, setShowCreateForm] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newMessage, setNewMessage] = useState('')
const [replyMessage, setReplyMessage] = useState('')
// Media attachment states
const [createAttachment, setCreateAttachment] = useState<MediaAttachment | null>(null)
const [replyAttachment, setReplyAttachment] = useState<MediaAttachment | null>(null)
const createFileInputRef = useRef<HTMLInputElement>(null)
const replyFileInputRef = useRef<HTMLInputElement>(null)
// Get support configuration
const { data: supportConfig, isLoading: configLoading } = useQuery({
queryKey: ['support-config'],
queryFn: infoApi.getSupportConfig,
})
const { data: tickets, isLoading } = useQuery({
queryKey: ['tickets'],
queryFn: () => ticketsApi.getTickets({ per_page: 20 }),
enabled: supportConfig?.tickets_enabled === true,
})
const { data: ticketDetail, isLoading: detailLoading } = useQuery({
queryKey: ['ticket', selectedTicket?.id],
queryFn: () => ticketsApi.getTicket(selectedTicket!.id),
enabled: !!selectedTicket,
})
// Handle file selection
const handleFileSelect = async (
file: File,
setAttachment: (a: MediaAttachment | null) => void
) => {
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
if (!allowedTypes.includes(file.type)) {
setAttachment({
file,
preview: '',
uploading: false,
error: t('support.invalidFileType') || 'Invalid file type. Use JPEG, PNG, GIF, or WebP.',
})
return
}
// Validate file size (10MB)
if (file.size > 10 * 1024 * 1024) {
setAttachment({
file,
preview: '',
uploading: false,
error: t('support.fileTooLarge') || 'File is too large. Maximum size is 10MB.',
})
return
}
// Create preview
const preview = URL.createObjectURL(file)
setAttachment({ file, preview, uploading: true })
try {
const result = await ticketsApi.uploadMedia(file, 'photo')
setAttachment({
file,
preview,
uploading: false,
fileId: result.file_id,
})
} catch (error) {
setAttachment({
file,
preview,
uploading: false,
error: t('support.uploadFailed') || 'Failed to upload image',
})
}
}
const createMutation = useMutation({
mutationFn: async () => {
const media = createAttachment?.fileId
? {
media_type: 'photo',
media_file_id: createAttachment.fileId,
}
: undefined
return ticketsApi.createTicket(newTitle, newMessage, media)
},
onSuccess: (ticket) => {
queryClient.invalidateQueries({ queryKey: ['tickets'] })
setShowCreateForm(false)
setNewTitle('')
setNewMessage('')
setCreateAttachment(null)
setSelectedTicket(ticket)
},
})
const replyMutation = useMutation({
mutationFn: async () => {
const media = replyAttachment?.fileId
? {
media_type: 'photo',
media_file_id: replyAttachment.fileId,
}
: undefined
return ticketsApi.addMessage(selectedTicket!.id, replyMessage, media)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] })
setReplyMessage('')
setReplyAttachment(null)
},
})
const getStatusBadge = (status: string) => {
switch (status) {
case 'open':
return 'badge-info'
case 'answered':
return 'badge-success'
case 'pending':
return 'badge-warning'
case 'closed':
return 'badge-neutral'
default:
return 'badge-neutral'
}
}
const getStatusLabel = (status: string) => {
return t(`support.status.${status}`) || status
}
// Show loading while checking configuration
if (configLoading) {
return (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
// If tickets are disabled, show redirect message
if (supportConfig && !supportConfig.tickets_enabled) {
const getSupportMessage = () => {
if (supportConfig.support_type === 'profile') {
return {
title: t('support.ticketsDisabled'),
message: t('support.useProfile'),
buttonText: t('support.goToProfile'),
buttonAction: () => {
const webApp = window.Telegram?.WebApp
if (webApp?.close) {
webApp.close()
}
},
}
}
if (supportConfig.support_type === 'url' && supportConfig.support_url) {
return {
title: t('support.ticketsDisabled'),
message: t('support.useExternalLink'),
buttonText: t('support.openSupport'),
buttonAction: () => {
const webApp = window.Telegram?.WebApp
if (webApp?.openLink) {
webApp.openLink(supportConfig.support_url!)
} else {
window.open(supportConfig.support_url!, '_blank')
}
},
}
}
const supportUsername = supportConfig.support_username || '@support'
return {
title: t('support.ticketsDisabled'),
message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.contactUs'),
buttonAction: () => {
const webApp = window.Telegram?.WebApp
const url = supportUsername.startsWith('@')
? `https://t.me/${supportUsername.slice(1)}`
: `https://t.me/${supportUsername}`
if (webApp?.openTelegramLink) {
webApp.openTelegramLink(url)
} else {
window.open(url, '_blank')
}
},
}
}
const supportMessage = getSupportMessage()
return (
<div className="max-w-md mx-auto mt-12">
<div className="card text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
</div>
<h2 className="text-xl font-semibold text-dark-100 mb-2">{supportMessage.title}</h2>
<p className="text-dark-400 mb-6">{supportMessage.message}</p>
<button onClick={supportMessage.buttonAction} className="btn-primary w-full">
{supportMessage.buttonText}
</button>
</div>
</div>
)
}
// Attachment preview component
const AttachmentPreview = ({
attachment,
onRemove,
}: {
attachment: MediaAttachment
onRemove: () => void
}) => (
<div className="relative inline-block mt-2">
{attachment.preview && (
<img
src={attachment.preview}
alt="Attachment preview"
className="h-20 w-auto rounded-lg border border-dark-700"
/>
)}
{attachment.uploading && (
<div className="absolute inset-0 bg-dark-900/70 rounded-lg flex items-center justify-center">
<div className="w-5 h-5 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{attachment.error && (
<div className="text-xs text-red-400 mt-1">{attachment.error}</div>
)}
<button
type="button"
onClick={onRemove}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-white hover:bg-red-600"
>
<CloseIcon />
</button>
</div>
)
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('support.title')}</h1>
<button
onClick={() => {
setShowCreateForm(true)
setSelectedTicket(null)
setCreateAttachment(null)
}}
className="btn-primary"
>
<PlusIcon />
{t('support.newTicket')}
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Tickets List */}
<div className="lg:col-span-1 card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('support.yourTickets')}</h2>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : tickets?.items && tickets.items.length > 0 ? (
<div className="space-y-2">
{tickets.items.map((ticket) => (
<button
key={ticket.id}
onClick={() => {
setSelectedTicket(ticket as unknown as TicketDetail)
setShowCreateForm(false)
setReplyAttachment(null)
}}
className={`w-full text-left p-4 rounded-xl border transition-all ${
selectedTicket?.id === ticket.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
}`}
>
<div className="flex justify-between items-start gap-2 mb-2">
<div className="text-dark-100 font-medium truncate">{ticket.title}</div>
<span className={`${getStatusBadge(ticket.status)} flex-shrink-0`}>
{getStatusLabel(ticket.status)}
</span>
</div>
<div className="text-xs text-dark-500">
{new Date(ticket.updated_at).toLocaleDateString()}
</div>
</button>
))}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
</div>
<div className="text-dark-400">{t('support.noTickets')}</div>
</div>
)}
</div>
{/* Ticket Detail / Create Form */}
<div className="lg:col-span-2 card">
{showCreateForm ? (
<div>
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('support.createTicket')}</h2>
<form
onSubmit={(e) => {
e.preventDefault()
createMutation.mutate()
}}
className="space-y-4"
>
<div>
<label className="label">{t('support.subject')}</label>
<input
type="text"
className="input"
placeholder={t('support.subjectPlaceholder')}
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
required
minLength={3}
maxLength={255}
/>
</div>
<div>
<label className="label">{t('support.message')}</label>
<textarea
className="input min-h-[150px]"
placeholder={t('support.messagePlaceholder')}
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
required
minLength={10}
maxLength={4000}
/>
</div>
{/* Image attachment for create */}
<div>
<input
ref={createFileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFileSelect(file, setCreateAttachment)
e.target.value = ''
}}
/>
{createAttachment ? (
<AttachmentPreview
attachment={createAttachment}
onRemove={() => setCreateAttachment(null)}
/>
) : (
<button
type="button"
onClick={() => createFileInputRef.current?.click()}
className="flex items-center gap-2 text-sm text-dark-400 hover:text-dark-200 transition-colors"
>
<ImageIcon />
{t('support.attachImage')}
</button>
)}
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={createMutation.isPending || (createAttachment?.uploading)}
className="btn-primary"
>
{createMutation.isPending ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('support.creating')}
</span>
) : (
<>
<SendIcon />
{t('support.send')}
</>
)}
</button>
<button
type="button"
onClick={() => {
setShowCreateForm(false)
setCreateAttachment(null)
}}
className="btn-secondary"
>
{t('common.cancel')}
</button>
</div>
</form>
</div>
) : selectedTicket ? (
<div className="flex flex-col h-full">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2 mb-6 pb-4 border-b border-dark-800/50">
<div>
<h2 className="text-lg font-semibold text-dark-100">
{ticketDetail?.title || selectedTicket.title}
</h2>
<div className="flex items-center flex-wrap gap-2 mt-2">
<span className={getStatusBadge(ticketDetail?.status || selectedTicket.status)}>
{getStatusLabel(ticketDetail?.status || selectedTicket.status)}
</span>
<span className="text-xs text-dark-500">
{t('support.created')} {new Date(selectedTicket.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
{/* Messages */}
{detailLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : ticketDetail?.messages ? (
<div className="flex-1 space-y-4 mb-6 max-h-96 overflow-y-auto scrollbar-hide">
{ticketDetail.messages.map((msg) => (
<div
key={msg.id}
className={`p-4 rounded-xl ${
msg.is_from_admin
? 'bg-accent-500/10 border border-accent-500/20 ml-4'
: 'bg-dark-800/50 border border-dark-700/30 mr-4'
}`}
>
<div className="flex justify-between items-center mb-2">
<span className={`text-xs font-medium ${msg.is_from_admin ? 'text-accent-400' : 'text-dark-400'}`}>
{msg.is_from_admin ? t('support.supportTeam') : t('support.you')}
</span>
<span className="text-xs text-dark-500">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<div className="text-dark-200 whitespace-pre-wrap">
{msg.message_text}
</div>
{/* Display media if present */}
<MessageMedia message={msg} t={t} />
</div>
))}
</div>
) : null}
{/* Reply Form */}
{ticketDetail?.status !== 'closed' && !ticketDetail?.is_reply_blocked && (
<form
onSubmit={(e) => {
e.preventDefault()
replyMutation.mutate()
}}
className="pt-4 border-t border-dark-800/50"
>
<div className="space-y-3">
<div className="flex gap-3">
<textarea
className="input flex-1 min-h-[80px]"
placeholder={t('support.replyPlaceholder')}
value={replyMessage}
onChange={(e) => setReplyMessage(e.target.value)}
required
minLength={1}
maxLength={4000}
/>
</div>
{/* Image attachment for reply */}
<div className="flex items-center justify-between">
<div>
<input
ref={replyFileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFileSelect(file, setReplyAttachment)
e.target.value = ''
}}
/>
{replyAttachment ? (
<AttachmentPreview
attachment={replyAttachment}
onRemove={() => setReplyAttachment(null)}
/>
) : (
<button
type="button"
onClick={() => replyFileInputRef.current?.click()}
className="flex items-center gap-2 text-sm text-dark-400 hover:text-dark-200 transition-colors"
>
<ImageIcon />
{t('support.attachImage')}
</button>
)}
</div>
<button
type="submit"
disabled={replyMutation.isPending || !replyMessage.trim() || replyAttachment?.uploading}
className="btn-primary"
>
{replyMutation.isPending ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<SendIcon />
)}
</button>
</div>
</div>
</form>
)}
{ticketDetail?.is_reply_blocked && (
<div className="text-center py-4 text-sm text-dark-500 border-t border-dark-800/50">
{t('support.repliesDisabled')}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
</div>
<div className="text-dark-400">{t('support.selectTicket')}</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '../store/auth'
export default function TelegramCallback() {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [error, setError] = useState('')
const { loginWithTelegramWidget, isAuthenticated } = useAuthStore()
useEffect(() => {
if (isAuthenticated) {
navigate('/')
return
}
const authenticate = async () => {
// Get auth data from URL params
const id = searchParams.get('id')
const firstName = searchParams.get('first_name')
const lastName = searchParams.get('last_name')
const username = searchParams.get('username')
const photoUrl = searchParams.get('photo_url')
const authDate = searchParams.get('auth_date')
const hash = searchParams.get('hash')
// Validate required fields
if (!id || !firstName || !authDate || !hash) {
setError(t('auth.telegramRequired'))
return
}
try {
await loginWithTelegramWidget({
id: parseInt(id, 10),
first_name: firstName,
last_name: lastName || undefined,
username: username || undefined,
photo_url: photoUrl || undefined,
auth_date: parseInt(authDate, 10),
hash: hash,
})
navigate('/')
} catch (err: unknown) {
console.error('Telegram auth error:', err)
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('common.error'))
}
}
authenticate()
}, [searchParams, loginWithTelegramWidget, navigate, isAuthenticated, t])
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-8 px-4">
<div className="max-w-md w-full text-center">
<div className="text-red-500 text-5xl mb-4"></div>
<h2 className="text-lg font-semibold text-gray-900 mb-2">
{t('auth.loginFailed')}
</h2>
<p className="text-sm text-gray-500 mb-6">{error}</p>
<button
onClick={() => navigate('/login')}
className="btn-primary"
>
{t('auth.tryAgain')}
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
<h2 className="text-lg font-semibold text-gray-900">
{t('auth.authenticating')}
</h2>
<p className="text-sm text-gray-500 mt-2">{t('common.loading')}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth'
import { brandingApi } from '../api/branding'
export default function TelegramRedirect() {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore()
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading')
const [errorMessage, setErrorMessage] = useState('')
// Get branding for nice display
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
staleTime: 60000,
})
const appName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN')
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
// Get redirect target from URL params
const redirectTo = searchParams.get('redirect') || '/'
useEffect(() => {
// If already authenticated, redirect immediately
if (isAuthenticated && !authLoading) {
setStatus('success')
setTimeout(() => navigate(redirectTo), 500)
return
}
const initTelegram = async () => {
// Check if running in Telegram WebApp
const tg = window.Telegram?.WebApp
if (!tg?.initData) {
// Not in Telegram, show message and redirect to login
setStatus('not-telegram')
setTimeout(() => navigate('/login'), 2000)
return
}
// Initialize Telegram WebApp
tg.ready()
tg.expand()
// Set theme colors if available
if (tg.themeParams) {
document.documentElement.style.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#1a1a2e')
document.documentElement.style.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#ffffff')
}
try {
await loginWithTelegram(tg.initData)
setStatus('success')
// Small delay for nice UX
setTimeout(() => {
navigate(redirectTo)
}, 800)
} catch (err: unknown) {
console.error('Telegram auth failed:', err)
const error = err as { response?: { data?: { detail?: string } } }
setErrorMessage(error.response?.data?.detail || t('auth.telegramRequired'))
setStatus('error')
}
}
// Small delay to show loading screen
setTimeout(initTelegram, 300)
}, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t])
// Handle retry
const handleRetry = () => {
setStatus('loading')
setErrorMessage('')
window.location.reload()
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
{/* Background */}
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
<div className="relative text-center max-w-sm w-full">
{/* Logo */}
<div className="mx-auto w-20 h-20 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden">
{branding?.has_custom_logo && logoUrl ? (
<img src={logoUrl} alt={appName} className="w-full h-full object-cover" />
) : (
<span className="text-white font-bold text-3xl">{logoLetter}</span>
)}
</div>
<h1 className="text-2xl font-bold text-dark-50 mb-2">{appName}</h1>
{/* Loading State */}
{status === 'loading' && (
<div className="mt-8">
<div className="w-10 h-10 border-3 border-accent-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-dark-400">{t('auth.authenticating')}</p>
<p className="text-sm text-dark-500 mt-2">{t('common.loading')}</p>
</div>
)}
{/* Success State */}
{status === 'success' && (
<div className="mt-8">
<div className="w-16 h-16 rounded-full bg-success-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<p className="text-dark-200">{t('auth.loginSuccess')}</p>
<p className="text-sm text-dark-500 mt-2">Перенаправление...</p>
</div>
)}
{/* Error State */}
{status === 'error' && (
<div className="mt-8">
<div className="w-16 h-16 rounded-full bg-error-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-dark-200 mb-2">{t('auth.loginFailed')}</p>
<p className="text-sm text-error-400 mb-6">{errorMessage}</p>
<div className="flex flex-col gap-3">
<button onClick={handleRetry} className="btn-primary w-full">
{t('auth.tryAgain')}
</button>
<button onClick={() => navigate('/login')} className="btn-secondary w-full">
Войти другим способом
</button>
</div>
</div>
)}
{/* Not in Telegram State */}
{status === 'not-telegram' && (
<div className="mt-8">
<div className="w-16 h-16 rounded-full bg-warning-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-warning-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p className="text-dark-200 mb-2">Откройте в Telegram</p>
<p className="text-sm text-dark-400 mb-6">
Для автоматического входа откройте это приложение через бота в Telegram
</p>
<p className="text-sm text-dark-500">Перенаправление на страницу входа...</p>
</div>
)}
{/* Telegram branding */}
<div className="mt-12 flex items-center justify-center gap-2 text-dark-600">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
<span className="text-xs">Telegram Mini App</span>
</div>
</div>
</div>
)
}

82
src/pages/VerifyEmail.tsx Normal file
View File

@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { authApi } from '../api/auth'
import LanguageSwitcher from '../components/LanguageSwitcher'
export default function VerifyEmail() {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [error, setError] = useState('')
useEffect(() => {
const token = searchParams.get('token')
if (!token) {
setStatus('error')
setError(t('common.error'))
return
}
const verify = async () => {
try {
await authApi.verifyEmail(token)
setStatus('success')
} catch (err: unknown) {
setStatus('error')
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('emailVerification.failed'))
}
}
verify()
}, [searchParams, t])
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-8 px-4 sm:py-12">
{/* Language switcher in corner */}
<div className="fixed top-4 right-4 z-50">
<LanguageSwitcher />
</div>
<div className="max-w-md w-full text-center">
{status === 'loading' && (
<div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{t('emailVerification.verifying')}</h2>
<p className="text-sm sm:text-base text-gray-500 mt-2">{t('emailVerification.pleaseWait')}</p>
</div>
)}
{status === 'success' && (
<div>
<div className="text-green-500 text-5xl sm:text-6xl mb-4"></div>
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{t('emailVerification.success')}</h2>
<p className="text-sm sm:text-base text-gray-500 mt-2">
{t('emailVerification.successMessage')}
</p>
<div className="mt-6">
<Link to="/login" className="btn-primary">
{t('emailVerification.goToLogin')}
</Link>
</div>
</div>
)}
{status === 'error' && (
<div>
<div className="text-red-500 text-5xl sm:text-6xl mb-4"></div>
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{t('emailVerification.failed')}</h2>
<p className="text-sm sm:text-base text-gray-500 mt-2">{error}</p>
<div className="mt-6">
<Link to="/login" className="btn-secondary">
{t('emailVerification.goToLogin')}
</Link>
</div>
</div>
)}
</div>
</div>
)
}

749
src/pages/Wheel.tsx Normal file
View File

@@ -0,0 +1,749 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel'
import FortuneWheel from '../components/wheel/FortuneWheel'
import { useCurrency } from '../hooks/useCurrency'
// Icons
const StarIcon = () => (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
const CalendarIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
)
const HistoryIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
const CloseIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const SparklesIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
)
const TrophyIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 01-.982-3.172M9.497 14.25a7.454 7.454 0 00.981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 007.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 002.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 012.916.52 6.003 6.003 0 01-5.395 4.972m0 0a6.726 6.726 0 01-2.749 1.35m0 0a6.772 6.772 0 01-3.044 0" />
</svg>
)
export default function Wheel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { formatAmount, currencySymbol } = useCurrency()
const [isSpinning, setIsSpinning] = useState(false)
const [targetRotation, setTargetRotation] = useState<number | null>(null)
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
const [showResultModal, setShowResultModal] = useState(false)
const [paymentType, setPaymentType] = useState<'telegram_stars' | 'subscription_days'>('telegram_stars')
const [showHistory, setShowHistory] = useState(false)
const [isPayingStars, setIsPayingStars] = useState(false)
const isTelegramMiniApp = useMemo(() => {
// Check if we're in Telegram Mini App environment
const webApp = window.Telegram?.WebApp
return !!(webApp && typeof webApp.initData === 'string')
}, [])
const { data: config, isLoading, error } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
})
const { data: history } = useQuery({
queryKey: ['wheel-history'],
queryFn: () => wheelApi.getHistory(1, 10),
enabled: showHistory,
})
// Function to poll for new spin result after Stars payment
const pollForSpinResult = useCallback(async (maxAttempts = 15, delayMs = 800) => {
// Wait a bit before first poll to give the bot time to process the payment
await new Promise(resolve => setTimeout(resolve, 1500))
// Get current history to find the latest spin ID
let historyBefore
try {
historyBefore = await wheelApi.getHistory(1, 1)
} catch {
historyBefore = { items: [], total: 0 }
}
const lastSpinIdBefore = historyBefore.items.length > 0 ? historyBefore.items[0].id : 0
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await new Promise(resolve => setTimeout(resolve, delayMs))
try {
const historyAfter = await wheelApi.getHistory(1, 1)
// Check if we have a new spin (either new item or higher ID)
if (historyAfter.items.length > 0) {
const latestSpin = historyAfter.items[0]
// If we had no spins before, or this spin has a higher ID
if (lastSpinIdBefore === 0 || latestSpin.id > lastSpinIdBefore) {
// Found a new spin! Return it as SpinResult
return {
success: true,
prize_id: latestSpin.id,
prize_type: latestSpin.prize_type,
prize_value: latestSpin.prize_value,
prize_display_name: latestSpin.prize_display_name,
emoji: latestSpin.emoji,
color: latestSpin.color,
rotation_degrees: 0, // Not needed for result display
message: latestSpin.prize_type === 'nothing'
? t('wheel.noPrize')
: `${t('wheel.youWon')} ${latestSpin.prize_display_name}!`,
promocode: null, // Promocode is sent to bot chat
error: null,
} as SpinResult
}
}
} catch {
// Continue polling on error
}
}
// Timeout - couldn't find new spin
return null
}, [t])
// Ref to store pending Stars payment result
const pendingStarsResultRef = useRef<SpinResult | null>(null)
const isStarsSpinRef = useRef(false)
const starsInvoiceMutation = useMutation({
mutationFn: wheelApi.createStarsInvoice,
onSuccess: (data) => {
if (window.Telegram?.WebApp?.openInvoice) {
window.Telegram.WebApp.openInvoice(data.invoice_url, async (status) => {
if (status === 'paid') {
// Mark this as a Stars spin so handleSpinComplete knows to use the pending result
isStarsSpinRef.current = true
pendingStarsResultRef.current = null
// Payment done - reset paying state immediately
setIsPayingStars(false)
// Start spinning animation (5 seconds duration in FortuneWheel)
setIsSpinning(true)
setTargetRotation(360 * 5 + Math.random() * 360)
// Poll for the result in the background - don't await here!
// The result will be stored and shown when animation completes
pollForSpinResult().then((result) => {
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
if (result) {
pendingStarsResultRef.current = result
} else {
// Fallback: couldn't get result
pendingStarsResultRef.current = {
success: true,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '🎰',
color: '#8B5CF6',
rotation_degrees: 0,
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
}
}
}).catch(() => {
// Error polling, show generic success
pendingStarsResultRef.current = {
success: true,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '🎰',
color: '#8B5CF6',
rotation_degrees: 0,
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
}
})
} else if (status !== 'cancelled') {
setIsPayingStars(false)
setSpinResult({
success: false,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '😔',
color: '#EF4444',
rotation_degrees: 0,
message: t('wheel.starsPaymentFailed'),
promocode: null,
error: 'payment_failed',
})
setShowResultModal(true)
} else {
setIsPayingStars(false)
}
})
} else {
// openInvoice not available - show error
setIsPayingStars(false)
setSpinResult({
success: false,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '😔',
color: '#EF4444',
rotation_degrees: 0,
message: t('wheel.errors.starsNotAvailable'),
promocode: null,
error: 'stars_not_available',
})
setShowResultModal(true)
}
},
onError: () => {
setIsPayingStars(false)
setSpinResult({
success: false,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '😔',
color: '#EF4444',
rotation_degrees: 0,
message: t('wheel.errors.networkError'),
promocode: null,
error: 'network_error',
})
setShowResultModal(true)
},
})
const handleDirectStarsPay = () => {
setIsPayingStars(true)
starsInvoiceMutation.mutate()
}
const spinMutation = useMutation({
mutationFn: () => wheelApi.spin(paymentType),
onSuccess: (result) => {
if (result.success) {
setTargetRotation(result.rotation_degrees)
setSpinResult(result)
} else {
setIsSpinning(false)
setSpinResult(result)
setShowResultModal(true)
}
},
onError: () => {
setIsSpinning(false)
setSpinResult({
success: false,
message: t('wheel.errors.networkError'),
error: 'network_error',
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '',
color: '',
rotation_degrees: 0,
promocode: null,
})
setShowResultModal(true)
},
})
const handleSpin = () => {
if (!config?.can_spin || isSpinning) return
setIsSpinning(true)
spinMutation.mutate()
}
const handleSpinComplete = useCallback(() => {
setIsSpinning(false)
// Check if this was a Stars payment spin
if (isStarsSpinRef.current) {
isStarsSpinRef.current = false
// Use the pending result from polling, or show a fallback
if (pendingStarsResultRef.current) {
setSpinResult(pendingStarsResultRef.current)
pendingStarsResultRef.current = null
} else {
// Polling still in progress or failed - show fallback
setSpinResult({
success: true,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '🎰',
color: '#8B5CF6',
rotation_degrees: 0,
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
})
}
}
setShowResultModal(true)
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
}, [queryClient, t])
const closeResultModal = () => {
setShowResultModal(false)
setSpinResult(null)
setTargetRotation(null)
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="relative">
<div className="w-16 h-16 border-4 border-purple-500/30 border-t-purple-500 rounded-full animate-spin" />
<div className="absolute inset-0 flex items-center justify-center">
<SparklesIcon />
</div>
</div>
</div>
)
}
if (error || !config) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<div className="w-20 h-20 rounded-full bg-red-500/10 flex items-center justify-center">
<span className="text-4xl">😔</span>
</div>
<p className="text-dark-400 text-lg">{t('wheel.errors.loadFailed')}</p>
</div>
)
}
if (!config.is_enabled) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-6">
<div className="w-24 h-24 rounded-full bg-dark-800 flex items-center justify-center">
<span className="text-5xl">🎡</span>
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-dark-100 mb-2">{t('wheel.title')}</h1>
<p className="text-dark-400">{t('wheel.disabled')}</p>
</div>
</div>
)
}
const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars
const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days
const canPayBalance = starsEnabled && config.can_pay_stars // For web: pay with internal balance
const canPayDays = daysEnabled && config.can_pay_days
// Auto-select payment type based on availability
useEffect(() => {
if (isTelegramMiniApp) {
// In Mini App: prefer days if available, Stars payment is separate button
if (canPayDays) {
setPaymentType('subscription_days')
}
} else {
// In Web: prefer balance (Stars converted to rubles), fallback to days
if (canPayBalance) {
setPaymentType('telegram_stars')
} else if (canPayDays) {
setPaymentType('subscription_days')
}
}
}, [canPayBalance, canPayDays, isTelegramMiniApp])
return (
<div className="animate-fade-in pb-8">
{/* Hero Header */}
<div className="relative mb-8 overflow-hidden rounded-2xl bg-gradient-to-br from-purple-600/20 via-indigo-600/20 to-blue-600/20 p-6 sm:p-8">
{/* Background decorations */}
<div className="absolute top-0 right-0 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="absolute bottom-0 left-0 w-48 h-48 bg-blue-500/10 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2" />
<div className="relative flex items-center justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl shadow-lg shadow-purple-500/25">
<TrophyIcon />
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-white">
{t('wheel.title')}
</h1>
</div>
{config.daily_limit > 0 && (
<div className="flex items-center gap-2 text-dark-300">
<span>{t('wheel.spinsRemaining')}:</span>
<span className="px-3 py-1 bg-white/10 rounded-full font-bold text-purple-300">
{Math.max(0, config.daily_limit - config.user_spins_today)} / {config.daily_limit}
</span>
</div>
)}
</div>
<button
onClick={() => setShowHistory(!showHistory)}
className={`p-3 rounded-xl transition-all ${
showHistory
? 'bg-purple-500/20 text-purple-300 ring-2 ring-purple-500/50'
: 'bg-white/5 text-dark-300 hover:bg-white/10 hover:text-white'
}`}
>
<HistoryIcon />
</button>
</div>
</div>
{/* Main Content Grid */}
<div className="grid lg:grid-cols-[1fr,320px] gap-6">
{/* Wheel Section */}
<div className="relative">
{/* Wheel Container with glow background */}
<div className="relative p-6 sm:p-8 rounded-2xl bg-gradient-to-b from-dark-800/80 to-dark-900/80 backdrop-blur-sm border border-dark-700/50">
{/* Background glow */}
<div
className="absolute inset-0 rounded-2xl opacity-50 pointer-events-none"
style={{
background: 'radial-gradient(circle at center, rgba(139, 92, 246, 0.15) 0%, transparent 70%)',
}}
/>
{/* Wheel */}
<div className="relative">
<FortuneWheel
prizes={config.prizes}
isSpinning={isSpinning}
targetRotation={targetRotation}
onSpinComplete={handleSpinComplete}
/>
</div>
{/* Spin Button */}
<div className="mt-8 space-y-4">
{/* Payment Options - Different for Web vs Mini App */}
{isTelegramMiniApp ? (
// Mini App: Show Stars button (direct Telegram payment) and Days button
<div className="space-y-3">
{/* Direct Stars payment via Telegram */}
{starsEnabled && (
<button
onClick={handleDirectStarsPay}
disabled={isSpinning || isPayingStars || (config.daily_limit > 0 && config.user_spins_today >= config.daily_limit)}
className="w-full p-4 rounded-xl bg-gradient-to-r from-yellow-500 to-orange-500 text-black font-bold flex items-center justify-center gap-3 shadow-lg shadow-yellow-500/25 hover:shadow-yellow-500/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<StarIcon />
{isPayingStars ? t('wheel.processingPayment') : `${t('wheel.payWithStars')} (${config.spin_cost_stars} ⭐)`}
</button>
)}
{/* Days payment option */}
{daysEnabled && (
<button
onClick={() => canPayDays && setPaymentType('subscription_days')}
disabled={isSpinning || !canPayDays}
className={`w-full p-4 rounded-xl border-2 transition-all flex items-center justify-center gap-3 ${
paymentType === 'subscription_days' && canPayDays
? 'border-blue-500/50 bg-gradient-to-br from-blue-500/20 to-cyan-500/10 text-blue-400'
: !canPayDays
? 'border-dark-700 text-dark-500 opacity-50 cursor-not-allowed'
: 'border-dark-700 text-dark-300 hover:border-dark-600 hover:bg-dark-800'
}`}
>
<CalendarIcon />
<span className="font-semibold">
{t('wheel.payWithDays')} ({config.spin_cost_days} {config.spin_cost_days === 1 ? t('wheel.day') : t('wheel.days')})
</span>
</button>
)}
</div>
) : (
// Web: Show Balance button (Stars converted to rubles) and Days button
(starsEnabled || daysEnabled) && (
<div className="flex gap-3">
{starsEnabled && (
<button
onClick={() => canPayBalance && setPaymentType('telegram_stars')}
disabled={isSpinning || !canPayBalance}
className={`flex-1 p-4 rounded-xl border-2 transition-all flex flex-col items-center justify-center gap-1 ${
paymentType === 'telegram_stars' && canPayBalance
? 'border-accent-500/50 bg-gradient-to-br from-accent-500/20 to-blue-500/10 text-accent-400'
: !canPayBalance
? 'border-dark-700 text-dark-500 opacity-50 cursor-not-allowed'
: 'border-dark-700 text-dark-300 hover:border-dark-600 hover:bg-dark-800'
}`}
>
<span className="font-semibold">{formatAmount(config.required_balance_kopeks / 100)} {currencySymbol}</span>
<span className="text-xs opacity-70">({config.spin_cost_stars} )</span>
</button>
)}
{daysEnabled && (
<button
onClick={() => canPayDays && setPaymentType('subscription_days')}
disabled={isSpinning || !canPayDays}
className={`flex-1 p-4 rounded-xl border-2 transition-all flex items-center justify-center gap-3 ${
paymentType === 'subscription_days' && canPayDays
? 'border-blue-500/50 bg-gradient-to-br from-blue-500/20 to-cyan-500/10 text-blue-400'
: !canPayDays
? 'border-dark-700 text-dark-500 opacity-50 cursor-not-allowed'
: 'border-dark-700 text-dark-300 hover:border-dark-600 hover:bg-dark-800'
}`}
>
<CalendarIcon />
<span className="font-semibold">
{config.spin_cost_days} {config.spin_cost_days === 1 ? t('wheel.day') : t('wheel.days')}
</span>
</button>
)}
</div>
)
)}
{/* Main Spin Button - Show for web always, for mini app only if days available */}
{(!isTelegramMiniApp || canPayDays) && (
<button
onClick={handleSpin}
disabled={!config.can_spin || isSpinning || isPayingStars || (isTelegramMiniApp && !canPayDays)}
className={`w-full py-5 rounded-2xl text-xl font-bold transition-all relative overflow-hidden group ${
!config.can_spin || isSpinning || isPayingStars || (isTelegramMiniApp && !canPayDays)
? 'bg-dark-700 text-dark-500 cursor-not-allowed'
: 'bg-gradient-to-r from-purple-600 via-indigo-600 to-purple-600 text-white shadow-xl shadow-purple-500/30 hover:shadow-purple-500/50 hover:scale-[1.02] active:scale-[0.98]'
}`}
style={{
backgroundSize: '200% 100%',
animation: !isSpinning && config.can_spin ? 'shimmer 3s linear infinite' : 'none',
}}
>
{/* Button glow effect */}
{!isSpinning && config.can_spin && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
)}
{isSpinning ? (
<span className="flex items-center justify-center gap-3">
<div className="w-6 h-6 border-3 border-white/30 border-t-white rounded-full animate-spin" />
{t('wheel.spinning')}
</span>
) : (
<span className="flex items-center justify-center gap-2">
<SparklesIcon />
{t('wheel.spin')}
</span>
)}
</button>
)}
{/* Cannot spin hint */}
{!config.can_spin && !isSpinning && (
<div className="text-center p-4 rounded-xl bg-dark-800/50 border border-dark-700">
{config.can_spin_reason === 'daily_limit_reached' ? (
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
) : config.can_spin_reason === 'insufficient_balance' ? (
<div className="space-y-1">
<p className="text-warning-400 font-medium">{t('wheel.errors.insufficientBalance')}</p>
<p className="text-dark-500 text-sm">
{t('wheel.errors.topUpRequired')} ({formatAmount((config.required_balance_kopeks - config.user_balance_kopeks) / 100)} {currencySymbol})
</p>
</div>
) : (
<p className="text-dark-400">{t('wheel.errors.cannotSpin')}</p>
)}
</div>
)}
</div>
</div>
</div>
{/* History Sidebar */}
{showHistory && (
<div className="lg:sticky lg:top-24 h-fit">
<div className="rounded-2xl bg-dark-800/80 backdrop-blur-sm border border-dark-700/50 overflow-hidden">
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
<h3 className="font-semibold text-dark-100 flex items-center gap-2">
<HistoryIcon />
{t('wheel.recentSpins')}
</h3>
<button
onClick={() => setShowHistory(false)}
className="lg:hidden p-1 text-dark-400 hover:text-dark-200"
>
<CloseIcon />
</button>
</div>
{history && history.items.length > 0 ? (
<div className="max-h-[400px] overflow-y-auto">
{history.items.map((item: SpinHistoryItem, index: number) => (
<div
key={item.id}
className={`flex items-center gap-3 p-4 ${
index !== history.items.length - 1 ? 'border-b border-dark-700/50' : ''
}`}
>
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl"
style={{ backgroundColor: `${item.color}20` }}
>
{item.emoji}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-dark-100 truncate">
{item.prize_display_name}
</div>
<div className="text-xs text-dark-500">
{new Date(item.created_at).toLocaleDateString()}
</div>
</div>
<div className="text-xs text-dark-400 whitespace-nowrap">
-{item.payment_type === 'telegram_stars'
? `${item.payment_amount}`
: `${item.payment_amount}${t('wheel.days').charAt(0)}`}
</div>
</div>
))}
</div>
) : (
<div className="p-8 text-center text-dark-500">
<div className="text-4xl mb-2">🎰</div>
{t('wheel.noHistory')}
</div>
)}
</div>
</div>
)}
</div>
{/* Result Modal */}
{showResultModal && spinResult && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
<div
className={`relative w-full max-w-md rounded-3xl p-8 text-center overflow-hidden ${
spinResult.success
? 'bg-gradient-to-br from-purple-900/90 via-indigo-900/90 to-purple-900/90'
: 'bg-gradient-to-br from-dark-800 via-dark-900 to-dark-800'
} border ${spinResult.success ? 'border-purple-500/30' : 'border-dark-700'}`}
>
{/* Decorative elements */}
{spinResult.success && (
<>
<div className="absolute top-0 left-0 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-0 w-32 h-32 bg-indigo-500/20 rounded-full blur-3xl" />
{/* Confetti effect */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
className="absolute w-2 h-2 rounded-full animate-bounce"
style={{
background: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 2}s`,
animationDuration: `${1 + Math.random()}s`,
}}
/>
))}
</div>
</>
)}
{/* Close button */}
<button
onClick={closeResultModal}
className="absolute top-4 right-4 p-2 text-dark-400 hover:text-dark-200 transition-colors rounded-lg hover:bg-white/5"
>
<CloseIcon />
</button>
{/* Content */}
<div className="relative space-y-6">
{/* Prize icon */}
<div
className="w-28 h-28 mx-auto rounded-full flex items-center justify-center text-6xl shadow-2xl"
style={{
background: spinResult.success
? `linear-gradient(135deg, ${spinResult.color || '#8B5CF6'}40, ${spinResult.color || '#8B5CF6'}20)`
: 'rgba(239, 68, 68, 0.1)',
boxShadow: spinResult.success
? `0 0 60px ${spinResult.color || '#8B5CF6'}40`
: 'none',
}}
>
{spinResult.success ? (spinResult.emoji || '🎉') : '😔'}
</div>
{/* Title */}
<h2 className="text-3xl font-bold text-white">
{spinResult.success
? spinResult.prize_type === 'nothing'
? t('wheel.noLuck')
: t('wheel.congratulations')
: t('wheel.oops')}
</h2>
{/* Message */}
<p className="text-lg text-dark-200">{spinResult.message}</p>
{/* Promocode if won */}
{spinResult.promocode && (
<div className="p-5 bg-gradient-to-r from-purple-500/20 via-indigo-500/20 to-purple-500/20 border border-purple-500/30 rounded-2xl">
<p className="text-sm text-purple-300 mb-3">{t('wheel.yourPromoCode')}</p>
<p className="text-2xl font-mono font-bold text-white select-all tracking-wider">
{spinResult.promocode}
</p>
</div>
)}
{/* Close button */}
<button
onClick={closeResultModal}
className="w-full py-4 rounded-xl bg-white/10 hover:bg-white/20 text-white font-semibold transition-all"
>
{t('wheel.close')}
</button>
</div>
</div>
</div>
)}
<style>{`
@keyframes shimmer {
0% { background-position: 200% 50%; }
100% { background-position: -200% 50%; }
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { themeColorsApi } from '../api/themeColors'
import { DEFAULT_THEME_COLORS } from '../types/theme'
import { applyThemeColors } from '../hooks/useThemeColors'
interface ThemeColorsProviderProps {
children: React.ReactNode
}
export function ThemeColorsProvider({ children }: ThemeColorsProviderProps) {
const { data: colors } = useQuery({
queryKey: ['theme-colors'],
queryFn: themeColorsApi.getColors,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
retry: 1,
})
// Apply colors on mount and when they change
useEffect(() => {
applyThemeColors(colors || DEFAULT_THEME_COLORS)
}, [colors])
return <>{children}</>
}

207
src/store/auth.ts Normal file
View File

@@ -0,0 +1,207 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types'
import { authApi } from '../api/auth'
export interface TelegramWidgetData {
id: number
first_name: string
last_name?: string
username?: string
photo_url?: string
auth_date: number
hash: string
}
interface AuthState {
accessToken: string | null
refreshToken: string | null
user: User | null
isAuthenticated: boolean
isLoading: boolean
isAdmin: boolean
setTokens: (accessToken: string, refreshToken: string) => void
setUser: (user: User) => void
setIsAdmin: (isAdmin: boolean) => void
logout: () => void
initialize: () => Promise<void>
refreshUser: () => Promise<void>
checkAdminStatus: () => Promise<void>
loginWithTelegram: (initData: string) => Promise<void>
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>
loginWithEmail: (email: string, password: string) => Promise<void>
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: false,
isLoading: true,
isAdmin: false,
setTokens: (accessToken, refreshToken) => {
localStorage.setItem('access_token', accessToken)
localStorage.setItem('refresh_token', refreshToken)
set({
accessToken,
refreshToken,
isAuthenticated: true,
})
},
setUser: (user) => {
set({ user })
},
setIsAdmin: (isAdmin) => {
set({ isAdmin })
},
logout: () => {
const { refreshToken } = get()
if (refreshToken) {
authApi.logout(refreshToken).catch(console.error)
}
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
set({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: false,
isAdmin: false,
})
},
checkAdminStatus: async () => {
try {
const response = await fetch('/api/cabinet/auth/me/is-admin', {
headers: {
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
},
})
if (response.ok) {
const data = await response.json()
set({ isAdmin: data.is_admin })
}
} catch (error) {
console.error('Failed to check admin status:', error)
set({ isAdmin: false })
}
},
refreshUser: async () => {
try {
const user = await authApi.getMe()
set({ user })
} catch (error) {
console.error('Failed to refresh user:', error)
}
},
initialize: async () => {
set({ isLoading: true })
const accessToken = localStorage.getItem('access_token')
const refreshToken = localStorage.getItem('refresh_token')
if (!accessToken || !refreshToken) {
set({ isLoading: false, isAuthenticated: false })
return
}
try {
const user = await authApi.getMe()
set({
accessToken,
refreshToken,
user,
isAuthenticated: true,
isLoading: false,
})
// Check admin status
get().checkAdminStatus()
} catch (error) {
// Token might be expired, try to refresh
try {
const response = await authApi.refreshToken(refreshToken)
localStorage.setItem('access_token', response.access_token)
const user = await authApi.getMe()
set({
accessToken: response.access_token,
refreshToken,
user,
isAuthenticated: true,
isLoading: false,
})
// Check admin status
get().checkAdminStatus()
} catch {
// Refresh failed, logout
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
set({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: false,
isLoading: false,
})
}
}
},
loginWithTelegram: async (initData) => {
const response = await authApi.loginTelegram(initData)
localStorage.setItem('access_token', response.access_token)
localStorage.setItem('refresh_token', response.refresh_token)
set({
accessToken: response.access_token,
refreshToken: response.refresh_token,
user: response.user,
isAuthenticated: true,
})
get().checkAdminStatus()
},
loginWithTelegramWidget: async (data) => {
const response = await authApi.loginTelegramWidget(data)
localStorage.setItem('access_token', response.access_token)
localStorage.setItem('refresh_token', response.refresh_token)
set({
accessToken: response.access_token,
refreshToken: response.refresh_token,
user: response.user,
isAuthenticated: true,
})
get().checkAdminStatus()
},
loginWithEmail: async (email, password) => {
const response = await authApi.loginEmail(email, password)
localStorage.setItem('access_token', response.access_token)
localStorage.setItem('refresh_token', response.refresh_token)
set({
accessToken: response.access_token,
refreshToken: response.refresh_token,
user: response.user,
isAuthenticated: true,
})
get().checkAdminStatus()
},
}),
{
name: 'cabinet-auth',
partialize: (state) => ({
refreshToken: state.refreshToken,
user: state.user,
}),
}
)
)
// Initialize auth on app load
useAuthStore.getState().initialize()

1036
src/styles/globals.css Normal file

File diff suppressed because it is too large Load Diff

465
src/types/index.ts Normal file
View File

@@ -0,0 +1,465 @@
// User types
export interface User {
id: number
telegram_id: number
username: string | null
first_name: string | null
last_name: string | null
email: string | null
email_verified: boolean
balance_kopeks: number
balance_rubles: number
referral_code: string | null
language: string
created_at: string
}
// Auth types
export interface AuthResponse {
access_token: string
refresh_token: string
token_type: string
expires_in: number
user: User
}
export interface TokenResponse {
access_token: string
refresh_token: string
token_type: string
expires_in: number
}
// Subscription types
export interface ServerInfo {
uuid: string
name: string
country_code: string | null
}
export interface TrafficPurchase {
id: number
traffic_gb: number
expires_at: string
created_at: string
days_remaining: number
progress_percent: number
}
export interface Subscription {
id: number
status: string
is_trial: boolean
start_date: string
end_date: string
days_left: number
hours_left: number
minutes_left: number
time_left_display: string
traffic_limit_gb: number
traffic_used_gb: number
traffic_used_percent: number
device_limit: number
connected_squads: string[]
servers: ServerInfo[]
autopay_enabled: boolean
autopay_days_before: number
subscription_url: string | null
is_active: boolean
is_expired: boolean
traffic_purchases?: TrafficPurchase[]
// Daily tariff fields
is_daily?: boolean
is_daily_paused?: boolean
daily_price_kopeks?: number
next_daily_charge_at?: string // ISO datetime string
// Tariff info
tariff_id?: number
tariff_name?: string
}
// Device types
export interface Device {
hwid: string
platform: string
device_model: string
created_at: string | null
}
export interface DevicesResponse {
devices: Device[]
total: number
device_limit: number
}
// Tariff switch preview
export interface TariffSwitchPreview {
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
}
export interface RenewalOption {
period_days: number
price_kopeks: number
price_rubles: number
discount_percent: number
original_price_kopeks: number | null
}
export interface TrafficPackage {
gb: number
price_kopeks: number
price_rubles: number
is_unlimited: boolean
}
export interface TrialInfo {
is_available: boolean
duration_days: number
traffic_limit_gb: number
device_limit: number
requires_payment: boolean
price_kopeks: number
price_rubles: number
reason_unavailable: string | null
}
// Purchase options types
export interface TrafficOption {
value: number
label: string
price_kopeks: number
price_label: string
original_price_kopeks?: number
original_price_label?: string
discount_percent?: number
is_available: boolean
is_default?: boolean
}
export interface ServerOption {
uuid: string
name: string
price_kopeks: number
price_label: string
original_price_kopeks?: number
original_price_label?: string
discount_percent?: number
is_available: boolean
}
export interface DevicesConfig {
min: number
max: number
default: number
current: number
price_per_device_kopeks: number
price_per_device_label: string
price_per_device_original_kopeks?: number
discount_percent?: number
}
export interface TrafficConfig {
selectable: boolean
mode: string
options: TrafficOption[]
default?: number
current?: number
}
export interface ServersConfig {
options: ServerOption[]
min: number
max: number
default: string[]
selected: string[]
}
export interface PeriodOption {
id: string
period_days: number
months: number
label: string
price_kopeks: number
price_label: string
per_month_price_kopeks: number
per_month_price_label: string
discount_percent?: number
original_price_kopeks?: number
original_price_label?: string
is_available: boolean
traffic: TrafficConfig
servers: ServersConfig
devices: DevicesConfig
}
// Tariff types for tariffs mode
export interface TariffPeriod {
days: number
months: number
label: string
price_kopeks: number
price_label: string
price_per_month_kopeks: number
price_per_month_label: string
}
export interface TariffServer {
uuid: string
name: string
}
export interface Tariff {
id: number
name: string
description: string | null
tier_level: number
traffic_limit_gb: number
traffic_limit_label: string
is_unlimited_traffic: boolean
device_limit: number
servers_count: number
servers: TariffServer[]
periods: TariffPeriod[]
is_current: boolean
is_available: boolean
// Custom days options
custom_days_enabled?: boolean
price_per_day_kopeks?: number
min_days?: number
max_days?: number
// Custom traffic options
custom_traffic_enabled?: boolean
traffic_price_per_gb_kopeks?: number
min_traffic_gb?: number
max_traffic_gb?: number
// Device price
device_price_kopeks?: number
// Traffic topup options
traffic_topup_enabled?: boolean
traffic_topup_packages?: number[]
max_topup_traffic_gb?: number
// Daily tariff options
is_daily?: boolean
daily_price_kopeks?: number
}
export interface TariffsPurchaseOptions {
sales_mode: 'tariffs'
tariffs: Tariff[]
current_tariff_id: number | null
balance_kopeks: number
balance_label: string
}
export interface ClassicPurchaseOptions {
sales_mode: 'classic'
currency: string
balance_kopeks: number
balance_label: string
subscription_id: number | null
periods: PeriodOption[]
traffic: TrafficConfig
servers: ServersConfig
devices: DevicesConfig
selection: {
period_id: string
period_days: number
traffic_value: number
servers: string[]
devices: number
}
}
export type PurchaseOptions = TariffsPurchaseOptions | ClassicPurchaseOptions
// Legacy type for backward compatibility
export interface LegacyPurchaseOptions {
currency: string
balance_kopeks: number
balance_label: string
subscription_id: number | null
periods: PeriodOption[]
traffic: TrafficConfig
servers: ServersConfig
devices: DevicesConfig
selection: {
period_id: string
period_days: number
traffic_value: number
servers: string[]
devices: number
}
}
export interface PurchaseSelection {
period_id?: string
period_days?: number
traffic_value?: number
servers?: string[]
devices?: number
}
export interface PurchasePreview {
total_price_kopeks: number
total_price_label: string
original_price_kopeks?: number
original_price_label?: string
discount_percent?: number
discount_label?: string
per_month_price_kopeks: number
per_month_price_label: string
breakdown: { label: string; value: string }[]
balance_kopeks: number
balance_label: string
missing_amount_kopeks: number
missing_amount_label?: string
can_purchase: boolean
status_message?: string
}
// Balance types
export interface Balance {
balance_kopeks: number
balance_rubles: number
}
export interface Transaction {
id: number
type: string
amount_kopeks: number
amount_rubles: number
description: string | null
payment_method: string | null
is_completed: boolean
created_at: string
completed_at: string | null
}
export interface PaymentMethod {
id: string
name: string
description: string | null
min_amount_kopeks: number
max_amount_kopeks: number
is_available: boolean
}
// Referral types
export interface ReferralInfo {
referral_code: string
referral_link: string
total_referrals: number
active_referrals: number
total_earnings_kopeks: number
total_earnings_rubles: number
commission_percent: number
}
export interface ReferralTerms {
is_enabled: boolean
commission_percent: number
minimum_topup_kopeks: number
minimum_topup_rubles: number
first_topup_bonus_kopeks: number
first_topup_bonus_rubles: number
inviter_bonus_kopeks: number
inviter_bonus_rubles: number
}
// Ticket types
export interface TicketMessage {
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 Ticket {
id: number
title: string
status: string
priority: string
created_at: string
updated_at: string
closed_at: string | null
messages_count: number
last_message: TicketMessage | null
}
export interface TicketDetail extends Omit<Ticket, 'messages_count' | 'last_message'> {
is_reply_blocked: boolean
messages: TicketMessage[]
}
export interface SupportConfig {
tickets_enabled: boolean
support_type: 'tickets' | 'profile' | 'url'
support_url?: string | null
support_username?: string | null
}
// Paginated response
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
per_page: number
pages: number
}
// App config types (for connection setup)
export interface LocalizedText {
[key: string]: string
}
export interface AppButton {
buttonLink: string
buttonText: LocalizedText
}
export interface AppStep {
description: LocalizedText
buttons?: AppButton[]
title?: LocalizedText
}
export interface AppInfo {
id: string
name: string
isFeatured: boolean
deepLink?: string | null
installationStep?: AppStep
addSubscriptionStep?: AppStep
connectAndUseStep?: AppStep
additionalBeforeAddSubscriptionStep?: AppStep
additionalAfterAddSubscriptionStep?: AppStep
}
export interface AppConfig {
platforms: Record<string, AppInfo[]>
platformNames: Record<string, LocalizedText>
hasSubscription: boolean
subscriptionUrl: string | null
branding?: {
name?: string
logoUrl?: string
supportUrl?: string
}
}

68
src/types/theme.ts Normal file
View File

@@ -0,0 +1,68 @@
// Theme color settings interface
export interface ThemeColors {
// Main accent color
accent: string
// Dark theme
darkBackground: string
darkSurface: string
darkText: string
darkTextSecondary: string
// Light theme
lightBackground: string
lightSurface: string
lightText: string
lightTextSecondary: string
// Status colors
success: string
warning: string
error: string
}
export interface ThemeSettings extends ThemeColors {
id?: number
updated_at?: string
}
// Enabled themes settings
export interface EnabledThemes {
dark: boolean
light: boolean
}
export const DEFAULT_ENABLED_THEMES: EnabledThemes = {
dark: true,
light: true,
}
// Default theme colors
export const DEFAULT_THEME_COLORS: ThemeColors = {
accent: '#3b82f6',
darkBackground: '#0a0f1a',
darkSurface: '#0f172a',
darkText: '#f1f5f9',
darkTextSecondary: '#94a3b8',
lightBackground: '#F7E7CE',
lightSurface: '#FEF9F0',
lightText: '#1F1A12',
lightTextSecondary: '#7D6B48',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
}
// Color shade levels for palette generation
export const SHADE_LEVELS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const
// Extended shade levels including 850 for dark palette
export const EXTENDED_SHADE_LEVELS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 850, 900, 950] as const
export type ShadeLevel = (typeof SHADE_LEVELS)[number]
export type ExtendedShadeLevel = (typeof EXTENDED_SHADE_LEVELS)[number]
export type ColorPalette = Record<ShadeLevel | 850, string>

68
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_TELEGRAM_BOT_USERNAME?: string
readonly VITE_APP_NAME?: string
readonly VITE_APP_LOGO?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
// Telegram WebApp types
interface TelegramWebApp {
initData: string
initDataUnsafe: {
user?: {
id: number
first_name: string
last_name?: string
username?: string
language_code?: string
}
auth_date: number
hash: string
}
ready: () => void
expand: () => void
close: () => void
openLink: (url: string, options?: { try_instant_view?: boolean }) => void
openTelegramLink: (url: string) => void
openInvoice: (url: string, callback?: (status: 'paid' | 'cancelled' | 'failed' | 'pending') => void) => void
MainButton: {
text: string
color: string
textColor: string
isVisible: boolean
isActive: boolean
show: () => void
hide: () => void
enable: () => void
disable: () => void
onClick: (callback: () => void) => void
offClick: (callback: () => void) => void
}
BackButton: {
isVisible: boolean
show: () => void
hide: () => void
onClick: (callback: () => void) => void
offClick: (callback: () => void) => void
}
themeParams: {
bg_color?: string
text_color?: string
hint_color?: string
link_color?: string
button_color?: string
button_text_color?: string
}
}
interface Window {
Telegram?: {
WebApp: TelegramWebApp
}
}