diff --git a/src/App.tsx b/src/App.tsx index 3c010d6..bc7f948 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,10 @@ import AdminServers from './pages/AdminServers' import AdminPanel from './pages/AdminPanel' import AdminDashboard from './pages/AdminDashboard' import AdminBanSystem from './pages/AdminBanSystem' +import AdminBroadcasts from './pages/AdminBroadcasts' +import AdminPromocodes from './pages/AdminPromocodes' +import AdminCampaigns from './pages/AdminCampaigns' +import AdminUsers from './pages/AdminUsers' function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore() @@ -226,6 +230,38 @@ function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> {/* Catch all */} } /> diff --git a/src/api/adminBroadcasts.ts b/src/api/adminBroadcasts.ts new file mode 100644 index 0000000..43a7df4 --- /dev/null +++ b/src/api/adminBroadcasts.ts @@ -0,0 +1,168 @@ +import apiClient from './client' + +// Types +export interface BroadcastFilter { + key: string + label: string + count: number | null + group: string | null +} + +export interface TariffFilter { + key: string + label: string + tariff_id: number + count: number +} + +export interface BroadcastFiltersResponse { + filters: BroadcastFilter[] + tariff_filters: TariffFilter[] + custom_filters: BroadcastFilter[] +} + +export interface TariffForBroadcast { + id: number + name: string + filter_key: string + active_users_count: number +} + +export interface BroadcastTariffsResponse { + tariffs: TariffForBroadcast[] +} + +export interface BroadcastButton { + key: string + label: string + default: boolean +} + +export interface BroadcastButtonsResponse { + buttons: BroadcastButton[] +} + +export interface BroadcastMedia { + type: 'photo' | 'video' | 'document' + file_id: string + caption?: string +} + +export interface BroadcastCreateRequest { + target: string + message_text: string + selected_buttons: string[] + media?: BroadcastMedia +} + +export interface Broadcast { + id: number + target_type: string + message_text: string + has_media: boolean + media_type: string | null + media_file_id: string | null + media_caption: string | null + total_count: number + sent_count: number + failed_count: number + status: 'queued' | 'in_progress' | 'completed' | 'partial' | 'failed' | 'cancelled' | 'cancelling' + admin_id: number | null + admin_name: string | null + created_at: string + completed_at: string | null + progress_percent: number +} + +export interface BroadcastListResponse { + items: Broadcast[] + total: number + limit: number + offset: number +} + +export interface BroadcastPreviewRequest { + target: string +} + +export interface BroadcastPreviewResponse { + target: string + count: number +} + +export interface MediaUploadResponse { + media_type: string + file_id: string + file_unique_id: string | null + media_url: string +} + +export const adminBroadcastsApi = { + // Get all available filters with counts + getFilters: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/broadcasts/filters') + return response.data + }, + + // Get tariffs for filtering + getTariffs: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/broadcasts/tariffs') + return response.data + }, + + // Get available buttons + getButtons: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/broadcasts/buttons') + return response.data + }, + + // Preview broadcast (get recipients count) + preview: async (target: string): Promise => { + const response = await apiClient.post('/cabinet/admin/broadcasts/preview', { + target, + }) + return response.data + }, + + // Create and start broadcast + create: async (data: BroadcastCreateRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/broadcasts', data) + return response.data + }, + + // Get list of broadcasts + list: async (limit = 20, offset = 0): Promise => { + const response = await apiClient.get('/cabinet/admin/broadcasts', { + params: { limit, offset }, + }) + return response.data + }, + + // Get broadcast details + get: async (id: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/broadcasts/${id}`) + return response.data + }, + + // Stop broadcast + stop: async (id: number): Promise => { + const response = await apiClient.post(`/cabinet/admin/broadcasts/${id}/stop`) + return response.data + }, + + // Upload media (uses existing media endpoint) + uploadMedia: async (file: File, mediaType: 'photo' | 'video' | 'document'): Promise => { + const formData = new FormData() + formData.append('file', file) + formData.append('media_type', mediaType) + + const response = await apiClient.post('/cabinet/media/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + return response.data + }, +} + +export default adminBroadcastsApi diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts new file mode 100644 index 0000000..b0e48be --- /dev/null +++ b/src/api/adminUsers.ts @@ -0,0 +1,418 @@ +import apiClient from './client' + +// ============ Types ============ + +export interface UserSubscriptionInfo { + id: number + status: string + is_trial: boolean + start_date: string | null + end_date: string | null + traffic_limit_gb: number + traffic_used_gb: number + device_limit: number + tariff_id: number | null + tariff_name: string | null + autopay_enabled: boolean + is_active: boolean + days_remaining: number +} + +export interface UserPromoGroupInfo { + id: number + name: string + is_default: boolean +} + +export interface UserListItem { + id: number + telegram_id: number + username: string | null + first_name: string | null + last_name: string | null + full_name: string + status: string + balance_kopeks: number + balance_rubles: number + created_at: string + last_activity: string | null + has_subscription: boolean + subscription_status: string | null + subscription_is_trial: boolean + subscription_end_date: string | null + promo_group_id: number | null + promo_group_name: string | null + total_spent_kopeks: number + purchase_count: number + has_restrictions: boolean + restriction_topup: boolean + restriction_subscription: boolean +} + +export interface UsersListResponse { + users: UserListItem[] + total: number + offset: number + limit: number +} + +export interface UserTransactionItem { + id: number + type: string + amount_kopeks: number + amount_rubles: number + description: string | null + payment_method: string | null + is_completed: boolean + created_at: string +} + +export interface UserReferralInfo { + referral_code: string + referrals_count: number + total_earnings_kopeks: number + commission_percent: number | null + referred_by_id: number | null + referred_by_username: string | null +} + +export interface UserDetailResponse { + id: number + telegram_id: number + username: string | null + first_name: string | null + last_name: string | null + full_name: string + status: string + language: string + balance_kopeks: number + balance_rubles: number + email: string | null + email_verified: boolean + created_at: string + updated_at: string | null + last_activity: string | null + cabinet_last_login: string | null + subscription: UserSubscriptionInfo | null + promo_group: UserPromoGroupInfo | null + referral: UserReferralInfo + total_spent_kopeks: number + purchase_count: number + used_promocodes: number + has_had_paid_subscription: boolean + lifetime_used_traffic_bytes: number + restriction_topup: boolean + restriction_subscription: boolean + restriction_reason: string | null + promo_offer_discount_percent: number + promo_offer_discount_source: string | null + promo_offer_discount_expires_at: string | null + recent_transactions: UserTransactionItem[] + remnawave_uuid: string | null +} + +export interface UsersStatsResponse { + total_users: number + active_users: number + blocked_users: number + deleted_users: number + new_today: number + new_week: number + new_month: number + users_with_subscription: number + users_with_active_subscription: number + users_with_trial: number + users_with_expired_subscription: number + total_balance_kopeks: number + total_balance_rubles: number + avg_balance_kopeks: number + active_today: number + active_week: number + active_month: number +} + +// Available tariffs +export interface PeriodPriceInfo { + days: number + price_kopeks: number + price_rubles: number +} + +export interface UserAvailableTariff { + id: number + name: string + description: string | null + is_active: boolean + is_trial_available: boolean + traffic_limit_gb: number + device_limit: number + tier_level: number + display_order: number + period_prices: PeriodPriceInfo[] + is_daily: boolean + daily_price_kopeks: number + custom_days_enabled: boolean + price_per_day_kopeks: number + min_days: number + max_days: number + is_available: boolean + requires_promo_group: boolean +} + +export interface UserAvailableTariffsResponse { + user_id: number + promo_group_id: number | null + promo_group_name: string | null + tariffs: UserAvailableTariff[] + total: number + current_tariff_id: number | null + current_tariff_name: string | null +} + +// Sync types +export interface PanelUserInfo { + uuid: string | null + short_uuid: string | null + username: string | null + status: string | null + expire_at: string | null + traffic_limit_gb: number + traffic_used_gb: number + device_limit: number + subscription_url: string | null + active_squads: string[] +} + +export interface SyncFromPanelResponse { + success: boolean + message: string + panel_user: PanelUserInfo | null + changes: Record + errors: string[] +} + +export interface SyncToPanelResponse { + success: boolean + message: string + action: string + panel_uuid: string | null + changes: Record + errors: string[] +} + +export interface PanelSyncStatusResponse { + user_id: number + telegram_id: number + remnawave_uuid: string | null + last_sync: string | null + bot_subscription_status: string | null + bot_subscription_end_date: string | null + bot_traffic_limit_gb: number + bot_traffic_used_gb: number + bot_device_limit: number + bot_squads: string[] + panel_found: boolean + panel_status: string | null + panel_expire_at: string | null + panel_traffic_limit_gb: number + panel_traffic_used_gb: number + panel_device_limit: number + panel_squads: string[] + has_differences: boolean + differences: string[] +} + +// Update types +export interface UpdateBalanceRequest { + amount_kopeks: number + description?: string + create_transaction?: boolean +} + +export interface UpdateBalanceResponse { + success: boolean + old_balance_kopeks: number + new_balance_kopeks: number + message: string +} + +export interface UpdateSubscriptionRequest { + action: 'extend' | 'set_end_date' | 'change_tariff' | 'set_traffic' | 'toggle_autopay' | 'cancel' | 'activate' | 'create' + days?: number + end_date?: string + tariff_id?: number + traffic_limit_gb?: number + traffic_used_gb?: number + autopay_enabled?: boolean + is_trial?: boolean + device_limit?: number +} + +export interface UpdateSubscriptionResponse { + success: boolean + message: string + subscription: UserSubscriptionInfo | null +} + +export interface UpdateUserStatusResponse { + success: boolean + old_status: string + new_status: string + message: string +} + +export interface UpdateRestrictionsRequest { + restriction_topup?: boolean + restriction_subscription?: boolean + restriction_reason?: string +} + +export interface UpdateRestrictionsResponse { + success: boolean + restriction_topup: boolean + restriction_subscription: boolean + restriction_reason: string | null + message: string +} + +export interface SyncFromPanelRequest { + update_subscription?: boolean + update_traffic?: boolean + create_if_missing?: boolean +} + +export interface SyncToPanelRequest { + create_if_missing?: boolean + update_status?: boolean + update_traffic_limit?: boolean + update_expire_date?: boolean + update_squads?: boolean +} + +// ============ API ============ + +export const adminUsersApi = { + // List users + getUsers: async (params: { + offset?: number + limit?: number + search?: string + status?: 'active' | 'blocked' | 'deleted' + sort_by?: 'created_at' | 'balance' | 'traffic' | 'last_activity' | 'total_spent' | 'purchase_count' + } = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/users', { params }) + return response.data + }, + + // Get users stats + getStats: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/users/stats') + return response.data + }, + + // Get user detail + getUser: async (userId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/users/${userId}`) + return response.data + }, + + // Get user by telegram ID + getUserByTelegram: async (telegramId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`) + return response.data + }, + + // Get available tariffs for user + getAvailableTariffs: async (userId: number, includeInactive = false): Promise => { + const response = await apiClient.get(`/cabinet/admin/users/${userId}/available-tariffs`, { + params: { include_inactive: includeInactive } + }) + return response.data + }, + + // Update balance + updateBalance: async (userId: number, data: UpdateBalanceRequest): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data) + return response.data + }, + + // Update subscription + updateSubscription: async (userId: number, data: UpdateSubscriptionRequest): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data) + return response.data + }, + + // Update status + updateStatus: async (userId: number, status: 'active' | 'blocked' | 'deleted', reason?: string): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, { status, reason }) + return response.data + }, + + // Block user + blockUser: async (userId: number, reason?: string): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, { params: { reason } }) + return response.data + }, + + // Unblock user + unblockUser: async (userId: number): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`) + return response.data + }, + + // Update restrictions + updateRestrictions: async (userId: number, data: UpdateRestrictionsRequest): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data) + return response.data + }, + + // Update promo group + updatePromoGroup: async (userId: number, promoGroupId: number | null): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/promo-group`, { promo_group_id: promoGroupId }) + return response.data + }, + + // Delete user + deleteUser: async (userId: number, softDelete = true, reason?: string): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.delete(`/cabinet/admin/users/${userId}`, { + data: { soft_delete: softDelete, reason } + }) + return response.data + }, + + // Get referrals + getReferrals: async (userId: number, offset = 0, limit = 50): Promise => { + const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, { + params: { offset, limit } + }) + return response.data + }, + + // Get transactions + getTransactions: async (userId: number, params: { + offset?: number + limit?: number + transaction_type?: string + } = {}): Promise<{ transactions: UserTransactionItem[]; total: number; offset: number; limit: number }> => { + const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params }) + return response.data + }, + + // Sync status + getSyncStatus: async (userId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`) + return response.data + }, + + // Sync from panel + syncFromPanel: async (userId: number, data: SyncFromPanelRequest = {}): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data) + return response.data + }, + + // Sync to panel + syncToPanel: async (userId: number, data: SyncToPanelRequest = {}): Promise => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data) + return response.data + }, +} diff --git a/src/api/campaigns.ts b/src/api/campaigns.ts new file mode 100644 index 0000000..7e381d8 --- /dev/null +++ b/src/api/campaigns.ts @@ -0,0 +1,230 @@ +import apiClient from './client' + +// Types +export type CampaignBonusType = 'balance' | 'subscription' | 'none' | 'tariff' + +export interface TariffInfo { + id: number + name: string +} + +export interface CampaignListItem { + id: number + name: string + start_parameter: string + bonus_type: CampaignBonusType + is_active: boolean + registrations_count: number + total_revenue_kopeks: number + conversion_rate: number + created_at: string +} + +export interface CampaignListResponse { + campaigns: CampaignListItem[] + total: number +} + +export interface CampaignDetail { + id: number + name: string + start_parameter: string + bonus_type: CampaignBonusType + is_active: boolean + balance_bonus_kopeks: number + balance_bonus_rubles: number + subscription_duration_days: number | null + subscription_traffic_gb: number | null + subscription_device_limit: number | null + subscription_squads: string[] + tariff_id: number | null + tariff_duration_days: number | null + tariff: TariffInfo | null + created_by: number | null + created_at: string + updated_at: string | null + deep_link: string | null +} + +export interface CampaignCreateRequest { + name: string + start_parameter: string + bonus_type: CampaignBonusType + is_active?: boolean + balance_bonus_kopeks?: number + subscription_duration_days?: number + subscription_traffic_gb?: number + subscription_device_limit?: number + subscription_squads?: string[] + tariff_id?: number + tariff_duration_days?: number +} + +export interface CampaignUpdateRequest { + name?: string + start_parameter?: string + bonus_type?: CampaignBonusType + is_active?: boolean + balance_bonus_kopeks?: number + subscription_duration_days?: number + subscription_traffic_gb?: number + subscription_device_limit?: number + subscription_squads?: string[] + tariff_id?: number + tariff_duration_days?: number +} + +export interface CampaignToggleResponse { + id: number + is_active: boolean + message: string +} + +export interface CampaignStatistics { + id: number + name: string + start_parameter: string + bonus_type: CampaignBonusType + is_active: boolean + registrations: number + balance_issued_kopeks: number + balance_issued_rubles: number + subscription_issued: number + last_registration: string | null + total_revenue_kopeks: number + total_revenue_rubles: number + avg_revenue_per_user_kopeks: number + avg_revenue_per_user_rubles: number + avg_first_payment_kopeks: number + avg_first_payment_rubles: number + trial_users_count: number + active_trials_count: number + conversion_count: number + paid_users_count: number + conversion_rate: number + trial_conversion_rate: number + deep_link: string | null +} + +export interface CampaignRegistrationItem { + id: number + user_id: number + telegram_id: number + username: string | null + first_name: string | null + bonus_type: string + balance_bonus_kopeks: number + subscription_duration_days: number | null + tariff_id: number | null + tariff_duration_days: number | null + created_at: string + user_balance_kopeks: number + has_subscription: boolean + has_paid: boolean +} + +export interface CampaignRegistrationsResponse { + registrations: CampaignRegistrationItem[] + total: number + page: number + per_page: number +} + +export interface CampaignsOverview { + total: number + active: number + inactive: number + total_registrations: number + total_balance_issued_kopeks: number + total_balance_issued_rubles: number + total_subscription_issued: number + total_tariff_issued: number +} + +export interface ServerSquadInfo { + id: number + squad_uuid: string + display_name: string + country_code: string | null +} + +export interface TariffListItem { + id: number + name: string + description: string | null + is_active: boolean + traffic_limit_gb: number + device_limit: number +} + +export const campaignsApi = { + // Get campaigns overview + getOverview: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/campaigns/overview') + return response.data + }, + + // Get all campaigns + getCampaigns: async (includeInactive = true, offset = 0, limit = 50): Promise => { + const response = await apiClient.get('/cabinet/admin/campaigns', { + params: { include_inactive: includeInactive, offset, limit } + }) + return response.data + }, + + // Get single campaign + getCampaign: async (campaignId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}`) + return response.data + }, + + // Get campaign statistics + getCampaignStats: async (campaignId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/stats`) + return response.data + }, + + // Get campaign registrations + getCampaignRegistrations: async (campaignId: number, page = 1, perPage = 50): Promise => { + const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/registrations`, { + params: { page, per_page: perPage } + }) + return response.data + }, + + // Create campaign + createCampaign: async (data: CampaignCreateRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/campaigns', data) + return response.data + }, + + // Update campaign + updateCampaign: async (campaignId: number, data: CampaignUpdateRequest): Promise => { + const response = await apiClient.put(`/cabinet/admin/campaigns/${campaignId}`, data) + return response.data + }, + + // Delete campaign + deleteCampaign: async (campaignId: number): Promise<{ message: string }> => { + const response = await apiClient.delete(`/cabinet/admin/campaigns/${campaignId}`) + return response.data + }, + + // Toggle campaign active status + toggleCampaign: async (campaignId: number): Promise => { + const response = await apiClient.post(`/cabinet/admin/campaigns/${campaignId}/toggle`) + return response.data + }, + + // Get available servers for subscription bonus + getAvailableServers: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/campaigns/available-servers') + return response.data + }, + + // Get available tariffs for tariff bonus + getAvailableTariffs: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs') + return response.data + }, +} diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts new file mode 100644 index 0000000..f2589d9 --- /dev/null +++ b/src/api/promocodes.ts @@ -0,0 +1,181 @@ +import apiClient from './client' + +// ============== Types ============== + +export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group' + +export interface PromoCode { + id: number + code: string + type: PromoCodeType + balance_bonus_kopeks: number + balance_bonus_rubles: number + subscription_days: number + max_uses: number + current_uses: number + uses_left: number + is_active: boolean + is_valid: boolean + first_purchase_only: boolean + valid_from: string + valid_until: string | null + promo_group_id: number | null + created_by: number | null + created_at: string + updated_at: string +} + +export interface PromoCodeRecentUse { + id: number + user_id: number + user_username: string | null + user_full_name: string | null + user_telegram_id: number | null + used_at: string +} + +export interface PromoCodeDetail extends PromoCode { + total_uses: number + today_uses: number + recent_uses: PromoCodeRecentUse[] +} + +export interface PromoCodeListResponse { + items: PromoCode[] + total: number + limit: number + offset: number +} + +export interface PromoCodeCreateRequest { + code: string + type: PromoCodeType + balance_bonus_kopeks?: number + subscription_days?: number + max_uses?: number + valid_from?: string + valid_until?: string | null + is_active?: boolean + first_purchase_only?: boolean + promo_group_id?: number | null +} + +export interface PromoCodeUpdateRequest { + code?: string + type?: PromoCodeType + balance_bonus_kopeks?: number + subscription_days?: number + max_uses?: number + valid_from?: string + valid_until?: string | null + is_active?: boolean + first_purchase_only?: boolean + promo_group_id?: number | null +} + +// ============== PromoGroup Types ============== + +export interface PromoGroup { + id: number + name: string + server_discount_percent: number + traffic_discount_percent: number + device_discount_percent: number + period_discounts: Record + auto_assign_total_spent_kopeks: number | null + apply_discounts_to_addons: boolean + is_default: boolean + members_count: number + created_at: string | null + updated_at: string | null +} + +export interface PromoGroupListResponse { + items: PromoGroup[] + total: number + limit: number + offset: number +} + +export interface PromoGroupCreateRequest { + name: string + server_discount_percent?: number + traffic_discount_percent?: number + device_discount_percent?: number + period_discounts?: Record + auto_assign_total_spent_kopeks?: number | null + apply_discounts_to_addons?: boolean + is_default?: boolean +} + +export interface PromoGroupUpdateRequest { + name?: string + server_discount_percent?: number + traffic_discount_percent?: number + device_discount_percent?: number + period_discounts?: Record + auto_assign_total_spent_kopeks?: number | null + apply_discounts_to_addons?: boolean + is_default?: boolean +} + +// ============== API ============== + +export const promocodesApi = { + // Promocodes + getPromocodes: async (params?: { + limit?: number + offset?: number + is_active?: boolean + }): Promise => { + const response = await apiClient.get('/cabinet/admin/promocodes', { params }) + return response.data + }, + + getPromocode: async (id: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`) + return response.data + }, + + createPromocode: async (data: PromoCodeCreateRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/promocodes', data) + return response.data + }, + + updatePromocode: async (id: number, data: PromoCodeUpdateRequest): Promise => { + const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data) + return response.data + }, + + deletePromocode: async (id: number): Promise => { + await apiClient.delete(`/cabinet/admin/promocodes/${id}`) + }, + + // Promo Groups + getPromoGroups: async (params?: { + limit?: number + offset?: number + }): Promise => { + const response = await apiClient.get('/cabinet/admin/promo-groups', { params }) + return response.data + }, + + getPromoGroup: async (id: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`) + return response.data + }, + + createPromoGroup: async (data: PromoGroupCreateRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/promo-groups', data) + return response.data + }, + + updatePromoGroup: async (id: number, data: PromoGroupUpdateRequest): Promise => { + const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data) + return response.data + }, + + deletePromoGroup: async (id: number): Promise => { + await apiClient.delete(`/cabinet/admin/promo-groups/${id}`) + }, +} diff --git a/src/locales/en.json b/src/locales/en.json index 86f0998..20ec64e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -268,8 +268,8 @@ "selectPaymentOption": "Select payment option", "paymentMethods": { "yookassa": { - "name": "YooKassa (Bank Card)", - "description": "Pay with bank card via YooKassa" + "name": "YooKassa", + "description": "Pay via YooKassa" }, "cryptobot": { "name": "CryptoBot", @@ -386,7 +386,7 @@ "goToProfile": "Go to Profile", "useExternalLink": "Please use the external link to get support", "openSupport": "Open Support", - "contactSupport": "Please contact {username} for support", + "contactSupport": "Please contact {{username}} for support", "contactUs": "Contact Support" }, "wheel": { @@ -442,7 +442,8 @@ "wheel": "Wheel", "tariffs": "Tariffs", "servers": "Servers", - "banSystem": "Ban Monitoring" + "banSystem": "Ban Monitoring", + "broadcasts": "Broadcasts" }, "panel": { "title": "Admin Panel", @@ -454,7 +455,8 @@ "wheelDesc": "Configure fortune wheel and prizes", "tariffsDesc": "Manage tariff plans", "serversDesc": "Configure VPN servers", - "banSystemDesc": "Ban monitoring and violations" + "banSystemDesc": "Ban monitoring and violations", + "broadcastsDesc": "Mass messaging to users" }, "wheel": { "title": "Fortune Wheel Settings", @@ -514,6 +516,32 @@ "topWins": "Top Wins" } }, + "broadcasts": { + "title": "Broadcasts", + "subtitle": "Mass messaging to users", + "create": "Create Broadcast", + "empty": "No broadcasts yet", + "selectFilter": "Select audience", + "selectFilterPlaceholder": "Select filter...", + "recipients": "recip.", + "messageText": "Message text", + "messageTextPlaceholder": "Enter broadcast message...", + "media": "Media file", + "addMedia": "Add media", + "uploading": "Uploading...", + "buttons": "Buttons", + "willBeSent": "Will be sent to", + "users": "users", + "send": "Send", + "stop": "Stop", + "progress": "Progress", + "total": "Total", + "sent": "Sent", + "failed": "Failed", + "filter": "Filter", + "message": "Message", + "unknownAdmin": "Unknown admin" + }, "settings": { "title": "System Settings", "allCategories": "All categories", diff --git a/src/locales/fa.json b/src/locales/fa.json index ebed06f..d3777eb 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -167,8 +167,8 @@ "topUpBalance": "شارژ موجودی", "paymentMethods": { "yookassa": { - "name": "کارت بانکی", - "description": "پرداخت با کارت بانکی" + "name": "YooKassa", + "description": "پرداخت از طریق YooKassa" }, "cryptobot": { "name": "CryptoBot", @@ -334,7 +334,8 @@ "apps": "برنامه‌ها", "wheel": "چرخ", "tariffs": "تعرفه‌ها", - "servers": "سرورها" + "servers": "سرورها", + "broadcasts": "پیام‌رسانی" }, "panel": { "title": "پنل مدیریت", @@ -345,7 +346,8 @@ "appsDesc": "مدیریت برنامه‌های اتصال", "wheelDesc": "تنظیم چرخ شانس و جوایز", "tariffsDesc": "مدیریت طرح‌های تعرفه", - "serversDesc": "تنظیم سرورهای VPN" + "serversDesc": "تنظیم سرورهای VPN", + "broadcastsDesc": "ارسال پیام گروهی به کاربران" }, "wheel": { "title": "تنظیمات چرخ شانس", @@ -405,6 +407,32 @@ "topWins": "برترین برنده‌ها" } }, + "broadcasts": { + "title": "پیام‌رسانی", + "subtitle": "ارسال پیام گروهی به کاربران", + "create": "ایجاد پیام", + "empty": "هنوز پیامی ارسال نشده", + "selectFilter": "انتخاب مخاطبین", + "selectFilterPlaceholder": "انتخاب فیلتر...", + "recipients": "نفر", + "messageText": "متن پیام", + "messageTextPlaceholder": "متن پیام را وارد کنید...", + "media": "فایل رسانه", + "addMedia": "افزودن رسانه", + "uploading": "در حال آپلود...", + "buttons": "دکمه‌ها", + "willBeSent": "ارسال می‌شود به", + "users": "کاربر", + "send": "ارسال", + "stop": "توقف", + "progress": "پیشرفت", + "total": "کل", + "sent": "ارسال شده", + "failed": "ناموفق", + "filter": "فیلتر", + "message": "پیام", + "unknownAdmin": "مدیر ناشناس" + }, "settings": { "title": "تنظیمات سیستم", "allCategories": "همه دسته‌ها", diff --git a/src/locales/ru.json b/src/locales/ru.json index af72674..8a61240 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1,4 +1,4 @@ -{ +{ "common": { "loading": "Загрузка...", "error": "Ошибка", @@ -268,8 +268,8 @@ "selectPaymentOption": "Выберите способ оплаты", "paymentMethods": { "yookassa": { - "name": "ЮKassa (Банковская карта)", - "description": "Оплата банковской картой через ЮKassa" + "name": "ЮKassa", + "description": "Оплата через ЮKassa" }, "cryptobot": { "name": "CryptoBot", @@ -386,7 +386,7 @@ "goToProfile": "Перейти в профиль", "useExternalLink": "Для получения поддержки воспользуйтесь внешней ссылкой", "openSupport": "Открыть поддержку", - "contactSupport": "Для получения поддержки обратитесь к {username}", + "contactSupport": "Для получения поддержки обратитесь к {{username}}", "contactUs": "Связаться с поддержкой" }, "wheel": { @@ -442,7 +442,9 @@ "wheel": "Колесо", "tariffs": "Тарифы", "servers": "Серверы", - "banSystem": "Мониторинг банов" + "banSystem": "Мониторинг банов", + "broadcasts": "Рассылки", + "users": "Пользователи" }, "panel": { "title": "Панель администратора", @@ -454,7 +456,9 @@ "wheelDesc": "Настройка колеса удачи и призов", "tariffsDesc": "Управление тарифными планами", "serversDesc": "Настройка VPN серверов", - "banSystemDesc": "Мониторинг банов и нарушений" + "banSystemDesc": "Мониторинг банов и нарушений", + "broadcastsDesc": "Массовая отправка сообщений", + "usersDesc": "Управление пользователями бота" }, "wheel": { "title": "Настройки колеса удачи", @@ -514,6 +518,32 @@ "topWins": "Топ выигрышей" } }, + "broadcasts": { + "title": "Рассылки", + "subtitle": "Массовая отправка сообщений пользователям", + "create": "Создать рассылку", + "empty": "Рассылок пока нет", + "selectFilter": "Выберите аудиторию", + "selectFilterPlaceholder": "Выберите фильтр...", + "recipients": "получ.", + "messageText": "Текст сообщения", + "messageTextPlaceholder": "Введите текст рассылки...", + "media": "Медиафайл", + "addMedia": "Добавить медиа", + "uploading": "Загрузка...", + "buttons": "Кнопки", + "willBeSent": "Будет отправлено", + "users": "пользователям", + "send": "Отправить", + "stop": "Остановить", + "progress": "Прогресс", + "total": "Всего", + "sent": "Отправлено", + "failed": "Ошибки", + "filter": "Фильтр", + "message": "Сообщение", + "unknownAdmin": "Неизвестный админ" + }, "settings": { "title": "Настройки системы", "allCategories": "Все категории", @@ -1025,6 +1055,164 @@ "uptime": "Аптайм" } }, + "adminUsers": { + "title": "Пользователи", + "subtitle": "Управление пользователями", + "searchPlaceholder": "Поиск по ID, username, имени...", + "filters": { + "all": "Все", + "active": "Активные", + "blocked": "Заблокированные", + "deleted": "Удалённые" + }, + "sort": { + "created_at": "Дата регистрации", + "balance": "Баланс", + "traffic": "Трафик", + "last_activity": "Активность", + "total_spent": "Потрачено", + "purchase_count": "Покупки" + }, + "stats": { + "totalUsers": "Всего пользователей", + "activeUsers": "Активных", + "blockedUsers": "Заблокировано", + "withSubscription": "С подпиской", + "newToday": "Новых сегодня", + "newWeek": "За неделю", + "newMonth": "За месяц", + "totalBalance": "Общий баланс", + "activeToday": "Активны сегодня", + "activeWeek": "За неделю", + "activeMonth": "За месяц" + }, + "table": { + "user": "Пользователь", + "status": "Статус", + "balance": "Баланс", + "subscription": "Подписка", + "spent": "Потрачено", + "registered": "Регистрация", + "activity": "Активность", + "noUsers": "Пользователи не найдены" + }, + "userStatus": { + "active": "Активен", + "blocked": "Заблокирован", + "deleted": "Удалён" + }, + "subscriptionStatus": { + "active": "Активна", + "trial": "Триал", + "expired": "Истекла", + "none": "Нет подписки" + }, + "detail": { + "title": "Информация о пользователе", + "tabs": { + "info": "Инфо", + "subscription": "Подписка", + "balance": "Баланс", + "sync": "Синхронизация" + }, + "telegramId": "Telegram ID", + "username": "Username", + "fullName": "Имя", + "email": "Email", + "emailVerified": "Подтверждён", + "emailNotVerified": "Не подтверждён", + "language": "Язык", + "registeredAt": "Регистрация", + "lastActivity": "Последняя активность", + "cabinetLogin": "Вход в кабинет", + "referralCode": "Реферальный код", + "referralsCount": "Рефералов", + "referralEarnings": "Заработано", + "referredBy": "Приглашён", + "promoGroup": "Промо-группа", + "noPromoGroup": "Не задана", + "restrictions": "Ограничения", + "restrictionTopup": "Пополнение баланса", + "restrictionSubscription": "Покупка подписки", + "restrictionReason": "Причина", + "noRestrictions": "Нет ограничений", + "totalSpent": "Всего потрачено", + "purchaseCount": "Покупок", + "usedPromocodes": "Использовано промокодов", + "lifetimeTraffic": "Трафик за всё время", + "recentTransactions": "Последние транзакции", + "noTransactions": "Нет транзакций" + }, + "subscription": { + "noSubscription": "Подписка отсутствует", + "currentTariff": "Текущий тариф", + "status": "Статус", + "endDate": "Окончание", + "daysRemaining": "Осталось дней", + "trafficUsed": "Использовано трафика", + "deviceLimit": "Лимит устройств", + "autopay": "Автоплатёж", + "enabled": "Включён", + "disabled": "Отключён", + "actions": { + "extend": "Продлить", + "changeTariff": "Сменить тариф", + "setTraffic": "Установить трафик", + "toggleAutopay": "Вкл/выкл автоплатёж", + "cancel": "Отменить подписку", + "create": "Создать подписку" + }, + "extendDays": "Дней", + "selectTariff": "Выберите тариф", + "trafficLimitGb": "Лимит ГБ", + "trafficUsedGb": "Использовано ГБ", + "isTrial": "Пробный период" + }, + "balance": { + "currentBalance": "Текущий баланс", + "addFunds": "Пополнить", + "deductFunds": "Списать", + "amount": "Сумма", + "description": "Описание", + "createTransaction": "Создать транзакцию", + "transactionHistory": "История транзакций" + }, + "sync": { + "title": "Синхронизация с панелью", + "status": "Статус синхронизации", + "lastSync": "Последняя синхронизация", + "neverSynced": "Никогда", + "botData": "Данные бота", + "panelData": "Данные панели", + "notFound": "Не найден в панели", + "differences": "Различия", + "noDifferences": "Различий нет", + "syncFromPanel": "Синхронизировать из панели", + "syncToPanel": "Синхронизировать в панель", + "remawave_uuid": "Remnawave UUID", + "notLinked": "Не привязан" + }, + "actions": { + "block": "Заблокировать", + "unblock": "Разблокировать", + "delete": "Удалить", + "blockReason": "Причина блокировки", + "confirmBlock": "Подтвердить блокировку", + "confirmDelete": "Подтвердить удаление" + }, + "messages": { + "loadError": "Не удалось загрузить пользователей", + "userLoadError": "Не удалось загрузить пользователя", + "balanceUpdated": "Баланс обновлён", + "balanceError": "Ошибка обновления баланса", + "subscriptionUpdated": "Подписка обновлена", + "subscriptionError": "Ошибка обновления подписки", + "statusUpdated": "Статус обновлён", + "statusError": "Ошибка обновления статуса", + "syncSuccess": "Синхронизация выполнена", + "syncError": "Ошибка синхронизации" + } + }, "profile": { "title": "Профиль", "accountInfo": "Информация об аккаунте", diff --git a/src/locales/zh.json b/src/locales/zh.json index 730c70b..b25e45d 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -167,8 +167,8 @@ "topUpBalance": "充值余额", "paymentMethods": { "yookassa": { - "name": "银行卡", - "description": "通过银行卡支付" + "name": "YooKassa", + "description": "通过YooKassa支付" }, "cryptobot": { "name": "CryptoBot", @@ -334,7 +334,8 @@ "apps": "应用", "wheel": "转盘", "tariffs": "套餐", - "servers": "服务器" + "servers": "服务器", + "broadcasts": "群发" }, "panel": { "title": "管理面板", @@ -345,7 +346,8 @@ "appsDesc": "管理连接应用", "wheelDesc": "配置幸运转盘和奖品", "tariffsDesc": "管理套餐计划", - "serversDesc": "配置VPN服务器" + "serversDesc": "配置VPN服务器", + "broadcastsDesc": "向用户群发消息" }, "wheel": { "title": "幸运转盘设置", @@ -405,6 +407,32 @@ "topWins": "最高奖品" } }, + "broadcasts": { + "title": "群发", + "subtitle": "向用户群发消息", + "create": "创建群发", + "empty": "暂无群发", + "selectFilter": "选择受众", + "selectFilterPlaceholder": "选择筛选条件...", + "recipients": "人", + "messageText": "消息内容", + "messageTextPlaceholder": "输入群发消息...", + "media": "媒体文件", + "addMedia": "添加媒体", + "uploading": "上传中...", + "buttons": "按钮", + "willBeSent": "将发送给", + "users": "用户", + "send": "发送", + "stop": "停止", + "progress": "进度", + "total": "总计", + "sent": "已发送", + "failed": "失败", + "filter": "筛选", + "message": "消息", + "unknownAdmin": "未知管理员" + }, "settings": { "title": "系统设置", "allCategories": "所有分类", diff --git a/src/pages/AdminBroadcasts.tsx b/src/pages/AdminBroadcasts.tsx new file mode 100644 index 0000000..1451d5f --- /dev/null +++ b/src/pages/AdminBroadcasts.tsx @@ -0,0 +1,754 @@ +import { useState, useRef, useMemo } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { + adminBroadcastsApi, + Broadcast, + BroadcastFilter, + TariffFilter, + BroadcastCreateRequest, +} from '../api/adminBroadcasts' + +// Icons +const BroadcastIcon = () => ( + + + +) + +const PlusIcon = () => ( + + + +) + +const XIcon = () => ( + + + +) + +const RefreshIcon = () => ( + + + +) + +const StopIcon = () => ( + + + +) + +const PhotoIcon = () => ( + + + +) + +const VideoIcon = () => ( + + + +) + +const DocumentIcon = () => ( + + + +) + +const UsersIcon = () => ( + + + +) + +const ChevronDownIcon = () => ( + + + +) + +// Status badge component +function StatusBadge({ status }: { status: string }) { + const statusConfig: Record = { + queued: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'В очереди' }, + in_progress: { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Отправляется' }, + completed: { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Завершено' }, + partial: { bg: 'bg-orange-500/20', text: 'text-orange-400', label: 'Частично' }, + failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' }, + cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' }, + cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' }, + } + const config = statusConfig[status] || statusConfig.queued + return ( + + {config.label} + + ) +} + +// Filter labels +const FILTER_GROUP_LABELS: Record = { + basic: 'Основные', + subscription: 'По подписке', + traffic: 'По трафику', + registration: 'По регистрации', + activity: 'По активности', + source: 'По источнику', + tariff: 'По тарифу', +} + +// Create broadcast modal +interface CreateModalProps { + onClose: () => void + onSuccess: () => void +} + +function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + + const [target, setTarget] = useState('') + const [messageText, setMessageText] = useState('') + const [selectedButtons, setSelectedButtons] = useState(['home']) + const [mediaFile, setMediaFile] = useState(null) + const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo') + const [mediaPreview, setMediaPreview] = useState(null) + const [uploadedFileId, setUploadedFileId] = useState(null) + const [isUploading, setIsUploading] = useState(false) + const [showFilters, setShowFilters] = useState(false) + + // Fetch filters + const { data: filtersData, isLoading: filtersLoading } = useQuery({ + queryKey: ['admin', 'broadcasts', 'filters'], + queryFn: adminBroadcastsApi.getFilters, + }) + + // Fetch buttons + const { data: buttonsData } = useQuery({ + queryKey: ['admin', 'broadcasts', 'buttons'], + queryFn: adminBroadcastsApi.getButtons, + }) + + // Preview mutation + const previewMutation = useMutation({ + mutationFn: adminBroadcastsApi.preview, + }) + + // Create mutation + const createMutation = useMutation({ + mutationFn: adminBroadcastsApi.create, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }) + onSuccess() + onClose() + }, + }) + + // Group filters + const groupedFilters = useMemo(() => { + if (!filtersData) return {} + const groups: Record = {} + + // Basic filters + filtersData.filters.forEach(f => { + const group = f.group || 'basic' + if (!groups[group]) groups[group] = [] + groups[group].push(f) + }) + + // Tariff filters + if (filtersData.tariff_filters.length > 0) { + groups['tariff'] = filtersData.tariff_filters + } + + // Custom filters + filtersData.custom_filters.forEach(f => { + const group = f.group || 'custom' + if (!groups[group]) groups[group] = [] + groups[group].push(f) + }) + + return groups + }, [filtersData]) + + // Selected filter info + const selectedFilter = useMemo(() => { + if (!target || !filtersData) return null + const all = [ + ...filtersData.filters, + ...filtersData.tariff_filters, + ...filtersData.custom_filters, + ] + return all.find(f => f.key === target) + }, [target, filtersData]) + + // Handle file selection + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + setMediaFile(file) + + // Determine media type + if (file.type.startsWith('image/')) { + setMediaType('photo') + setMediaPreview(URL.createObjectURL(file)) + } else if (file.type.startsWith('video/')) { + setMediaType('video') + setMediaPreview(null) + } else { + setMediaType('document') + setMediaPreview(null) + } + + // Upload file + setIsUploading(true) + try { + const result = await adminBroadcastsApi.uploadMedia(file, mediaType) + setUploadedFileId(result.file_id) + } catch (err) { + console.error('Upload failed:', err) + setMediaFile(null) + setMediaPreview(null) + } finally { + setIsUploading(false) + } + } + + // Remove media + const handleRemoveMedia = () => { + setMediaFile(null) + setMediaPreview(null) + setUploadedFileId(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + // Toggle button + const toggleButton = (key: string) => { + setSelectedButtons(prev => + prev.includes(key) ? prev.filter(b => b !== key) : [...prev, key] + ) + } + + // Submit + const handleSubmit = () => { + if (!target || !messageText.trim()) return + + const data: BroadcastCreateRequest = { + target, + message_text: messageText, + selected_buttons: selectedButtons, + } + + if (uploadedFileId) { + data.media = { + type: mediaType, + file_id: uploadedFileId, + } + } + + createMutation.mutate(data) + } + + const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null + + return ( +
+
+ {/* Header */} +
+
+
+ +
+

{t('admin.broadcasts.create')}

+
+ +
+ + {/* Content */} +
+ {/* Filter selection */} +
+ +
+ + + {showFilters && ( +
+ {filtersLoading ? ( +
Loading...
+ ) : ( + Object.entries(groupedFilters).map(([group, filters]) => ( +
+
+ {FILTER_GROUP_LABELS[group] || group} +
+ {filters.map(filter => ( + + ))} +
+ )) + )} +
+ )} +
+
+ + {/* Message text */} +
+ +