mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
Merge branch 'main' from bedolaga
This commit is contained in:
39
src/api/adminPayments.ts
Normal file
39
src/api/adminPayments.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import apiClient from './client'
|
||||
import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'
|
||||
|
||||
export interface PaymentsStats {
|
||||
total_pending: number
|
||||
by_method: Record<string, number>
|
||||
}
|
||||
|
||||
export const adminPaymentsApi = {
|
||||
// Get all pending payments (admin)
|
||||
getPendingPayments: async (params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
method_filter?: string
|
||||
}): Promise<PaginatedResponse<PendingPayment>> => {
|
||||
const response = await apiClient.get<PaginatedResponse<PendingPayment>>('/cabinet/admin/payments', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get payments statistics
|
||||
getStats: async (): Promise<PaymentsStats> => {
|
||||
const response = await apiClient.get<PaymentsStats>('/cabinet/admin/payments/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get specific payment details
|
||||
getPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
|
||||
const response = await apiClient.get<PendingPayment>(`/cabinet/admin/payments/${method}/${paymentId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Manually check payment status
|
||||
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
|
||||
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/admin/payments/${method}/${paymentId}/check`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
386
src/api/adminRemnawave.ts
Normal file
386
src/api/adminRemnawave.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
// Status & Connection
|
||||
export interface ConnectionStatus {
|
||||
status: string
|
||||
message: string
|
||||
api_url?: string
|
||||
status_code?: number
|
||||
system_info?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RemnaWaveStatusResponse {
|
||||
is_configured: boolean
|
||||
configuration_error?: string
|
||||
connection?: ConnectionStatus
|
||||
}
|
||||
|
||||
// System Statistics
|
||||
export interface SystemSummary {
|
||||
users_online: number
|
||||
total_users: number
|
||||
active_connections: number
|
||||
nodes_online: number
|
||||
users_last_day: number
|
||||
users_last_week: number
|
||||
users_never_online: number
|
||||
total_user_traffic: number
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
cpu_cores: number
|
||||
cpu_physical_cores: number
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
memory_free: number
|
||||
memory_available: number
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
export interface Bandwidth {
|
||||
realtime_download: number
|
||||
realtime_upload: number
|
||||
realtime_total: number
|
||||
}
|
||||
|
||||
export interface TrafficPeriod {
|
||||
current: number
|
||||
previous: number
|
||||
difference?: string
|
||||
}
|
||||
|
||||
export interface TrafficPeriods {
|
||||
last_2_days: TrafficPeriod
|
||||
last_7_days: TrafficPeriod
|
||||
last_30_days: TrafficPeriod
|
||||
current_month: TrafficPeriod
|
||||
current_year: TrafficPeriod
|
||||
}
|
||||
|
||||
export interface SystemStatsResponse {
|
||||
system: SystemSummary
|
||||
users_by_status: Record<string, number>
|
||||
server_info: ServerInfo
|
||||
bandwidth: Bandwidth
|
||||
traffic_periods: TrafficPeriods
|
||||
nodes_realtime: Record<string, unknown>[]
|
||||
nodes_weekly: Record<string, unknown>[]
|
||||
last_updated?: string
|
||||
}
|
||||
|
||||
// Nodes
|
||||
export interface NodeInfo {
|
||||
uuid: string
|
||||
name: string
|
||||
address: string
|
||||
country_code?: string
|
||||
is_connected: boolean
|
||||
is_disabled: boolean
|
||||
is_node_online: boolean
|
||||
is_xray_running: boolean
|
||||
users_online?: number
|
||||
traffic_used_bytes?: number
|
||||
traffic_limit_bytes?: number
|
||||
last_status_change?: string
|
||||
last_status_message?: string
|
||||
xray_uptime?: string
|
||||
is_traffic_tracking_active: boolean
|
||||
traffic_reset_day?: number
|
||||
notify_percent?: number
|
||||
consumption_multiplier: number
|
||||
cpu_count?: number
|
||||
cpu_model?: string
|
||||
total_ram?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
provider_uuid?: string
|
||||
}
|
||||
|
||||
export interface NodesListResponse {
|
||||
items: NodeInfo[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface NodesOverview {
|
||||
total: number
|
||||
online: number
|
||||
offline: number
|
||||
disabled: number
|
||||
total_users_online: number
|
||||
nodes: NodeInfo[]
|
||||
}
|
||||
|
||||
export interface NodeStatisticsResponse {
|
||||
node: NodeInfo
|
||||
realtime?: Record<string, unknown>
|
||||
usage_history: Record<string, unknown>[]
|
||||
last_updated?: string
|
||||
}
|
||||
|
||||
export interface NodeActionResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
is_disabled?: boolean
|
||||
}
|
||||
|
||||
// Squads
|
||||
export interface SquadWithLocalInfo {
|
||||
uuid: string
|
||||
name: string
|
||||
members_count: number
|
||||
inbounds_count: number
|
||||
inbounds: Record<string, unknown>[]
|
||||
local_id?: number
|
||||
display_name?: string
|
||||
country_code?: string
|
||||
is_available?: boolean
|
||||
is_trial_eligible?: boolean
|
||||
price_kopeks?: number
|
||||
max_users?: number
|
||||
current_users?: number
|
||||
is_synced: boolean
|
||||
}
|
||||
|
||||
export interface SquadsListResponse {
|
||||
items: SquadWithLocalInfo[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface SquadDetailResponse extends SquadWithLocalInfo {
|
||||
description?: string
|
||||
sort_order?: number
|
||||
active_subscriptions: number
|
||||
}
|
||||
|
||||
export interface SquadOperationResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Migration
|
||||
export interface MigrationPreviewResponse {
|
||||
squad_uuid: string
|
||||
squad_name: string
|
||||
current_users: number
|
||||
max_users?: number
|
||||
users_to_migrate: number
|
||||
}
|
||||
|
||||
export interface MigrationStats {
|
||||
source_uuid: string
|
||||
target_uuid: string
|
||||
total: number
|
||||
updated: number
|
||||
panel_updated: number
|
||||
panel_failed: number
|
||||
source_removed: number
|
||||
target_added: number
|
||||
}
|
||||
|
||||
export interface MigrationResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
data?: MigrationStats
|
||||
}
|
||||
|
||||
// Inbounds
|
||||
export interface InboundsListResponse {
|
||||
items: Record<string, unknown>[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// Auto Sync
|
||||
export interface AutoSyncStatus {
|
||||
enabled: boolean
|
||||
times: string[]
|
||||
next_run?: string
|
||||
is_running: boolean
|
||||
last_run_started_at?: string
|
||||
last_run_finished_at?: string
|
||||
last_run_success?: boolean
|
||||
last_run_reason?: string
|
||||
last_run_error?: string
|
||||
last_user_stats?: Record<string, unknown>
|
||||
last_server_stats?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AutoSyncRunResponse {
|
||||
started: boolean
|
||||
success?: boolean
|
||||
error?: string
|
||||
user_stats?: Record<string, unknown>
|
||||
server_stats?: Record<string, unknown>
|
||||
reason?: string
|
||||
}
|
||||
|
||||
// Sync
|
||||
export interface SyncResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ============ API ============
|
||||
|
||||
export const adminRemnawaveApi = {
|
||||
// Status & Connection
|
||||
getStatus: async (): Promise<RemnaWaveStatusResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/status')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// System Statistics
|
||||
getSystemStats: async (): Promise<SystemStatsResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/system')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Nodes
|
||||
getNodes: async (): Promise<NodesListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getNodesOverview: async (): Promise<NodesOverview> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getNodesRealtime: async (): Promise<Record<string, unknown>[]> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getNode: async (uuid: string): Promise<NodeInfo> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getNodeStatistics: async (uuid: string): Promise<NodeStatisticsResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
nodeAction: async (uuid: string, action: 'enable' | 'disable' | 'restart'): Promise<NodeActionResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, { action })
|
||||
return response.data
|
||||
},
|
||||
|
||||
restartAllNodes: async (): Promise<NodeActionResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Squads
|
||||
getSquads: async (): Promise<SquadsListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/squads')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getSquad: async (uuid: string): Promise<SquadDetailResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createSquad: async (data: { name: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/squads', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateSquad: async (uuid: string, data: { name?: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteSquad: async (uuid: string): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.delete(`/cabinet/admin/remnawave/squads/${uuid}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
squadAction: async (uuid: string, data: {
|
||||
action: 'add_all_users' | 'remove_all_users' | 'delete' | 'rename' | 'update_inbounds'
|
||||
name?: string
|
||||
inbound_uuids?: string[]
|
||||
}): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Migration
|
||||
getMigrationPreview: async (uuid: string): Promise<MigrationPreviewResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}/migration-preview`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
migrateSquad: async (sourceUuid: string, targetUuid: string): Promise<MigrationResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/squads/migrate', {
|
||||
source_uuid: sourceUuid,
|
||||
target_uuid: targetUuid,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Inbounds
|
||||
getInbounds: async (): Promise<InboundsListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/inbounds')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Auto Sync
|
||||
getAutoSyncStatus: async (): Promise<AutoSyncStatus> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status')
|
||||
return response.data
|
||||
},
|
||||
|
||||
toggleAutoSync: async (enabled: boolean): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled })
|
||||
return response.data
|
||||
},
|
||||
|
||||
runAutoSync: async (): Promise<AutoSyncRunResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/run')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Manual Sync
|
||||
syncFromPanel: async (mode: 'all' | 'new_only' | 'update_only' = 'all'): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode })
|
||||
return response.data
|
||||
},
|
||||
|
||||
syncToPanel: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel')
|
||||
return response.data
|
||||
},
|
||||
|
||||
syncServers: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers')
|
||||
return response.data
|
||||
},
|
||||
|
||||
validateSubscriptions: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate')
|
||||
return response.data
|
||||
},
|
||||
|
||||
cleanupSubscriptions: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup')
|
||||
return response.data
|
||||
},
|
||||
|
||||
syncSubscriptionStatuses: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getSyncRecommendations: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default adminRemnawaveApi
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { Balance, Transaction, PaymentMethod, PaginatedResponse } from '../types'
|
||||
import apiClient from './client'
|
||||
import type { Balance, Transaction, PaymentMethod, PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'
|
||||
|
||||
export const balanceApi = {
|
||||
// Get current balance
|
||||
@@ -73,5 +73,28 @@ export const balanceApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get pending payments for manual verification
|
||||
getPendingPayments: async (params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
}): Promise<PaginatedResponse<PendingPayment>> => {
|
||||
const response = await apiClient.get<PaginatedResponse<PendingPayment>>('/cabinet/balance/pending-payments', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get specific pending payment details
|
||||
getPendingPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
|
||||
const response = await apiClient.get<PendingPayment>(`/cabinet/balance/pending-payments/${method}/${paymentId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Manually check payment status
|
||||
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
|
||||
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/balance/pending-payments/${method}/${paymentId}/check`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,47 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
const TELEGRAM_INIT_STORAGE_KEY = 'telegram_init_data'
|
||||
// Настраиваем endpoint для refresh
|
||||
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`)
|
||||
|
||||
// CSRF token management
|
||||
const CSRF_COOKIE_NAME = 'csrf_token'
|
||||
const CSRF_HEADER_NAME = 'X-CSRF-Token'
|
||||
|
||||
function getCsrfToken(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`))
|
||||
return match ? match[2] : null
|
||||
}
|
||||
|
||||
function generateCsrfToken(): string {
|
||||
const array = new Uint8Array(32)
|
||||
crypto.getRandomValues(array)
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
function ensureCsrfToken(): string {
|
||||
let token = getCsrfToken()
|
||||
if (!token) {
|
||||
token = generateCsrfToken()
|
||||
// Set cookie with SameSite=Strict for CSRF protection
|
||||
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
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 */
|
||||
}
|
||||
tokenStorage.setTelegramInitData(initData)
|
||||
return initData
|
||||
}
|
||||
|
||||
try {
|
||||
return localStorage.getItem(TELEGRAM_INIT_STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return tokenStorage.getTelegramInitData()
|
||||
}
|
||||
|
||||
export const apiClient = axios.create({
|
||||
@@ -31,9 +51,24 @@ export const apiClient = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add auth token
|
||||
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
// Request interceptor - add auth token with expiration check
|
||||
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||
let token = tokenStorage.getAccessToken()
|
||||
|
||||
// Проверяем срок действия токена перед запросом
|
||||
if (token && isTokenExpired(token)) {
|
||||
// Используем централизованный менеджер для refresh
|
||||
const newToken = await tokenRefreshManager.refreshAccessToken()
|
||||
if (newToken) {
|
||||
token = newToken
|
||||
} else {
|
||||
// Refresh не удался - редирект на логин
|
||||
tokenStorage.clearTokens()
|
||||
safeRedirectToLogin()
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
@@ -42,41 +77,36 @@ apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
if (telegramInitData && config.headers) {
|
||||
config.headers['X-Telegram-Init-Data'] = telegramInitData
|
||||
}
|
||||
|
||||
// Add CSRF token for state-changing methods
|
||||
const method = config.method?.toUpperCase()
|
||||
if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) {
|
||||
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken()
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
// Response interceptor - handle 401, refresh token
|
||||
// Response interceptor - handle 401 as fallback
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
||||
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)
|
||||
const newToken = await tokenRefreshManager.refreshAccessToken()
|
||||
if (newToken) {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
} else {
|
||||
// Refresh не удался
|
||||
tokenStorage.clearTokens()
|
||||
safeRedirectToLogin()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,4 +115,3 @@ apiClient.interceptors.response.use(
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Currency exchange rate API
|
||||
// Uses free exchangerate.host API
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
interface ExchangeRates {
|
||||
USD: number
|
||||
CNY: number
|
||||
@@ -33,39 +35,38 @@ export const currencyApi = {
|
||||
|
||||
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'
|
||||
)
|
||||
const response = await axios.get<{
|
||||
success?: boolean
|
||||
rates?: { USD?: number; CNY?: number; IRR?: number }
|
||||
}>('https://api.exchangerate.host/latest', {
|
||||
params: { 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
|
||||
if (response.data.success && response.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: response.data.rates.USD ? 1 / response.data.rates.USD : FALLBACK_RATES.USD,
|
||||
CNY: response.data.rates.CNY ? 1 / response.data.rates.CNY : FALLBACK_RATES.CNY,
|
||||
IRR: response.data.rates.IRR ? 1 / response.data.rates.IRR : FALLBACK_RATES.IRR,
|
||||
}
|
||||
cachedRates = { rates, timestamp: Date.now() }
|
||||
return rates
|
||||
}
|
||||
|
||||
// Try backup API (open.er-api.com)
|
||||
const backupResponse = await fetch('https://open.er-api.com/v6/latest/RUB')
|
||||
const backupResponse = await axios.get<{
|
||||
rates?: { USD?: number; CNY?: number; IRR?: number }
|
||||
}>('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
|
||||
if (backupResponse.data.rates) {
|
||||
const rates: ExchangeRates = {
|
||||
USD: backupResponse.data.rates.USD ? 1 / backupResponse.data.rates.USD : FALLBACK_RATES.USD,
|
||||
CNY: backupResponse.data.rates.CNY ? 1 / backupResponse.data.rates.CNY : FALLBACK_RATES.CNY,
|
||||
IRR: backupResponse.data.rates.IRR ? 1 / backupResponse.data.rates.IRR : FALLBACK_RATES.IRR,
|
||||
}
|
||||
cachedRates = { rates, timestamp: Date.now() }
|
||||
return rates
|
||||
}
|
||||
|
||||
// Return fallback rates if both APIs fail
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface MiniappCreatePaymentPayload {
|
||||
import axios, { AxiosError } from 'axios'
|
||||
|
||||
export interface MiniappCreatePaymentPayload {
|
||||
initData: string
|
||||
method: string
|
||||
amountKopeks?: number | null
|
||||
@@ -16,21 +18,26 @@ export const miniappApi = {
|
||||
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))
|
||||
try {
|
||||
const response = await axios.post<MiniappCreatePaymentResponse>(
|
||||
'/miniapp/payments/create',
|
||||
{
|
||||
initData: payload.initData || '',
|
||||
method: payload.method,
|
||||
amountKopeks: payload.amountKopeks ?? null,
|
||||
option: payload.option ?? null,
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ detail?: string; message?: string }>
|
||||
const message = axiosError.response?.data?.detail
|
||||
|| axiosError.response?.data?.message
|
||||
|| 'Failed to create payment'
|
||||
throw new Error(message)
|
||||
}
|
||||
return data as MiniappCreatePaymentResponse
|
||||
},
|
||||
}
|
||||
|
||||
227
src/api/promoOffers.ts
Normal file
227
src/api/promoOffers.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// ============== Types ==============
|
||||
|
||||
export interface PromoOfferUserInfo {
|
||||
id: number
|
||||
telegram_id: number
|
||||
username: string | null
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
full_name: string | null
|
||||
}
|
||||
|
||||
export interface PromoOfferSubscriptionInfo {
|
||||
id: number
|
||||
status: string
|
||||
is_trial: boolean
|
||||
start_date: string
|
||||
end_date: string
|
||||
autopay_enabled: boolean
|
||||
}
|
||||
|
||||
export interface PromoOffer {
|
||||
id: number
|
||||
user_id: number
|
||||
subscription_id: number | null
|
||||
notification_type: string
|
||||
discount_percent: number
|
||||
bonus_amount_kopeks: number
|
||||
expires_at: string
|
||||
claimed_at: string | null
|
||||
is_active: boolean
|
||||
effect_type: string
|
||||
extra_data: Record<string, any>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user: PromoOfferUserInfo | null
|
||||
subscription: PromoOfferSubscriptionInfo | null
|
||||
}
|
||||
|
||||
export interface PromoOfferListResponse {
|
||||
items: PromoOffer[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface PromoOfferBroadcastRequest {
|
||||
notification_type: string
|
||||
valid_hours: number
|
||||
discount_percent?: number
|
||||
bonus_amount_kopeks?: number
|
||||
effect_type?: string
|
||||
extra_data?: Record<string, any>
|
||||
target?: string
|
||||
user_id?: number
|
||||
telegram_id?: number
|
||||
}
|
||||
|
||||
export interface PromoOfferBroadcastResponse {
|
||||
created_offers: number
|
||||
user_ids: number[]
|
||||
target: string | null
|
||||
}
|
||||
|
||||
export interface PromoOfferTemplate {
|
||||
id: number
|
||||
name: string
|
||||
offer_type: string
|
||||
message_text: string
|
||||
button_text: string
|
||||
valid_hours: number
|
||||
discount_percent: number
|
||||
bonus_amount_kopeks: number
|
||||
active_discount_hours: number | null
|
||||
test_duration_hours: number | null
|
||||
test_squad_uuids: string[]
|
||||
is_active: boolean
|
||||
created_by: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PromoOfferTemplateListResponse {
|
||||
items: PromoOfferTemplate[]
|
||||
}
|
||||
|
||||
export interface PromoOfferTemplateUpdateRequest {
|
||||
name?: string
|
||||
message_text?: string
|
||||
button_text?: string
|
||||
valid_hours?: number
|
||||
discount_percent?: number
|
||||
bonus_amount_kopeks?: number
|
||||
active_discount_hours?: number
|
||||
test_duration_hours?: number
|
||||
test_squad_uuids?: string[]
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface PromoOfferLogOfferInfo {
|
||||
id: number
|
||||
notification_type: string | null
|
||||
discount_percent: number | null
|
||||
bonus_amount_kopeks: number | null
|
||||
effect_type: string | null
|
||||
expires_at: string | null
|
||||
claimed_at: string | null
|
||||
is_active: boolean | null
|
||||
}
|
||||
|
||||
export interface PromoOfferLog {
|
||||
id: number
|
||||
user_id: number | null
|
||||
offer_id: number | null
|
||||
action: string
|
||||
source: string | null
|
||||
percent: number | null
|
||||
effect_type: string | null
|
||||
details: Record<string, any>
|
||||
created_at: string
|
||||
user: PromoOfferUserInfo | null
|
||||
offer: PromoOfferLogOfferInfo | null
|
||||
}
|
||||
|
||||
export interface PromoOfferLogListResponse {
|
||||
items: PromoOfferLog[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
// Target segments for broadcast
|
||||
export const TARGET_SEGMENTS = {
|
||||
all: 'Все пользователи',
|
||||
active: 'Активные подписчики',
|
||||
trial: 'Триал пользователи',
|
||||
trial_ending: 'Заканчивается триал',
|
||||
expiring: 'Заканчивается подписка',
|
||||
expired: 'Истекшая подписка',
|
||||
zero: 'Нулевой баланс',
|
||||
autopay_failed: 'Ошибка автоплатежа',
|
||||
low_balance: 'Низкий баланс',
|
||||
inactive_30d: 'Неактивны 30 дней',
|
||||
inactive_60d: 'Неактивны 60 дней',
|
||||
inactive_90d: 'Неактивны 90 дней',
|
||||
custom_today: 'Зарегистрированы сегодня',
|
||||
custom_week: 'Зарегистрированы за неделю',
|
||||
custom_month: 'Зарегистрированы за месяц',
|
||||
custom_active_today: 'Активны сегодня',
|
||||
} as const
|
||||
|
||||
export type TargetSegment = keyof typeof TARGET_SEGMENTS
|
||||
|
||||
// Offer type configurations
|
||||
export const OFFER_TYPE_CONFIG = {
|
||||
test_access: {
|
||||
icon: '🧪',
|
||||
label: 'Тестовый доступ',
|
||||
effect: 'test_access',
|
||||
description: 'Временный доступ к дополнительным серверам',
|
||||
},
|
||||
extend_discount: {
|
||||
icon: '💎',
|
||||
label: 'Скидка на продление',
|
||||
effect: 'percent_discount',
|
||||
description: 'Скидка для текущих подписчиков',
|
||||
},
|
||||
purchase_discount: {
|
||||
icon: '🎯',
|
||||
label: 'Скидка на покупку',
|
||||
effect: 'percent_discount',
|
||||
description: 'Скидка для новых пользователей',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type OfferType = keyof typeof OFFER_TYPE_CONFIG
|
||||
|
||||
// ============== API ==============
|
||||
|
||||
export const promoOffersApi = {
|
||||
// Get list of promo offers
|
||||
getOffers: async (params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
user_id?: number
|
||||
is_active?: boolean
|
||||
}): Promise<PromoOfferListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Broadcast offer to multiple users
|
||||
broadcastOffer: async (data: PromoOfferBroadcastRequest): Promise<PromoOfferBroadcastResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get promo offer logs
|
||||
getLogs: async (params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
user_id?: number
|
||||
action?: string
|
||||
}): Promise<PromoOfferLogListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get all templates
|
||||
getTemplates: async (): Promise<PromoOfferTemplateListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers/templates')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get single template
|
||||
getTemplate: async (id: number): Promise<PromoOfferTemplate> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Update template
|
||||
updateTemplate: async (id: number, data: PromoOfferTemplateUpdateRequest): Promise<PromoOfferTemplate> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user