mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Add files via upload
This commit is contained in:
227
src/App.tsx
Normal file
227
src/App.tsx
Normal 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
206
src/api/admin.ts
Normal 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
119
src/api/adminApps.ts
Normal 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
73
src/api/adminSettings.ts
Normal 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
92
src/api/auth.ts
Normal 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
69
src/api/balance.ts
Normal 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
48
src/api/branding.ts
Normal 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
88
src/api/client.ts
Normal 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
57
src/api/contests.ts
Normal 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
98
src/api/currency.ts
Normal 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
12
src/api/index.ts
Normal 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
102
src/api/info.ts
Normal 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
36
src/api/miniapp.ts
Normal 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
58
src/api/notifications.ts
Normal 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
85
src/api/polls.ts
Normal 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
69
src/api/promo.ts
Normal 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
62
src/api/referral.ts
Normal 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
142
src/api/servers.ts
Normal 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
324
src/api/subscription.ts
Normal 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
228
src/api/tariffs.ts
Normal 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
43
src/api/themeColors.ts
Normal 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
84
src/api/tickets.ts
Normal 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
281
src/api/wheel.ts
Normal 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
|
||||
},
|
||||
}
|
||||
154
src/components/ColorPicker.tsx
Normal file
154
src/components/ColorPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
562
src/components/ConnectionModal.tsx
Normal file
562
src/components/ConnectionModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
src/components/LanguageSwitcher.tsx
Normal file
79
src/components/LanguageSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
240
src/components/Onboarding.tsx
Normal file
240
src/components/Onboarding.tsx
Normal 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
|
||||
)
|
||||
}
|
||||
67
src/components/TelegramLoginButton.tsx
Normal file
67
src/components/TelegramLoginButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
src/components/TopUpModal.tsx
Normal file
242
src/components/TopUpModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
21
src/components/common/PageLoader.tsx
Normal file
21
src/components/common/PageLoader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
498
src/components/layout/Layout.tsx
Normal file
498
src/components/layout/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
439
src/components/wheel/FortuneWheel.tsx
Normal file
439
src/components/wheel/FortuneWheel.tsx
Normal 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
108
src/hooks/useCurrency.ts
Normal 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
224
src/hooks/useTheme.ts
Normal 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
256
src/hooks/useThemeColors.ts
Normal 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
40
src/i18n.ts
Normal 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
788
src/locales/en.json
Normal 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
711
src/locales/fa.json
Normal 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
788
src/locales/ru.json
Normal 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
712
src/locales/zh.json
Normal 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
29
src/main.tsx
Normal 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
629
src/pages/AdminApps.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
502
src/pages/AdminDashboard.tsx
Normal file
502
src/pages/AdminDashboard.tsx
Normal 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
155
src/pages/AdminPanel.tsx
Normal 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
470
src/pages/AdminServers.tsx
Normal 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
1442
src/pages/AdminSettings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
893
src/pages/AdminTariffs.tsx
Normal file
893
src/pages/AdminTariffs.tsx
Normal 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
385
src/pages/AdminTickets.tsx
Normal 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
650
src/pages/AdminWheel.tsx
Normal 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
292
src/pages/Balance.tsx
Normal 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
239
src/pages/Contests.tsx
Normal 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
460
src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
315
src/pages/DeepLinkRedirect.tsx
Normal file
315
src/pages/DeepLinkRedirect.tsx
Normal 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
295
src/pages/Info.tsx
Normal 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
267
src/pages/Login.tsx
Normal 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
243
src/pages/Polls.tsx
Normal 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
414
src/pages/Profile.tsx
Normal 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
248
src/pages/Referral.tsx
Normal 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
1732
src/pages/Subscription.tsx
Normal file
File diff suppressed because it is too large
Load Diff
682
src/pages/Support.tsx
Normal file
682
src/pages/Support.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
src/pages/TelegramCallback.tsx
Normal file
87
src/pages/TelegramCallback.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
src/pages/TelegramRedirect.tsx
Normal file
173
src/pages/TelegramRedirect.tsx
Normal 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
82
src/pages/VerifyEmail.tsx
Normal 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
749
src/pages/Wheel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/providers/ThemeColorsProvider.tsx
Normal file
26
src/providers/ThemeColorsProvider.tsx
Normal 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
207
src/store/auth.ts
Normal 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
1036
src/styles/globals.css
Normal file
File diff suppressed because it is too large
Load Diff
465
src/types/index.ts
Normal file
465
src/types/index.ts
Normal 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
68
src/types/theme.ts
Normal 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
68
src/vite-env.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user