From 94295b21799f0710f5e85ac7e743b8c4499c77e6 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 16 Jan 2026 18:13:59 +0300 Subject: [PATCH 01/28] Add files via upload --- src/locales/en.json | 4 ++-- src/locales/fa.json | 4 ++-- src/locales/ru.json | 4 ++-- src/locales/zh.json | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 3c1449a..6587f27 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -266,8 +266,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", diff --git a/src/locales/fa.json b/src/locales/fa.json index ebed06f..7950f63 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", diff --git a/src/locales/ru.json b/src/locales/ru.json index c070a8b..4325d94 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -266,8 +266,8 @@ "selectPaymentOption": "Выберите способ оплаты", "paymentMethods": { "yookassa": { - "name": "ЮKassa (Банковская карта)", - "description": "Оплата банковской картой через ЮKassa" + "name": "ЮKassa", + "description": "Оплата через ЮKassa" }, "cryptobot": { "name": "CryptoBot", diff --git a/src/locales/zh.json b/src/locales/zh.json index 730c70b..56497da 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", From 03cbe3d742e2a0b06de7731c9a7087a57953f529 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 01:24:44 +0300 Subject: [PATCH 02/28] Add files via upload --- src/locales/en.json | 2 +- src/locales/ru.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 6587f27..202ca1e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -384,7 +384,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": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 4325d94..eb54446 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -384,7 +384,7 @@ "goToProfile": "Перейти в профиль", "useExternalLink": "Для получения поддержки воспользуйтесь внешней ссылкой", "openSupport": "Открыть поддержку", - "contactSupport": "Для получения поддержки обратитесь к {username}", + "contactSupport": "Для получения поддержки обратитесь к {{username}}", "contactUs": "Связаться с поддержкой" }, "wheel": { From 9cb17fdc15550ff04d89d68346443cabc4c105cc Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 01:30:10 +0300 Subject: [PATCH 03/28] Update AdminPanel.tsx --- src/pages/AdminPanel.tsx | 140 +++++++++++++++++++++++++++------------ 1 file changed, 99 insertions(+), 41 deletions(-) diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 98fba07..df05ab8 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -1,77 +1,107 @@ import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' -// Icons -const TicketIcon = () => ( - +// Icons - smaller versions for mobile +const TicketIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + ) -const CogIcon = () => ( - +const CogIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + ) -const PhoneIcon = () => ( - +const PhoneIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + ) -const WheelIcon = () => ( - +const WheelIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + ) -const TariffIcon = () => ( - +const TariffIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + ) -const ServerIcon = () => ( - +const ServerIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + ) -const AdminIcon = () => ( - +const AdminIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + ) -const ChartIcon = () => ( - +const ChartIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + ) -interface AdminCardProps { +const ChevronRightIcon = () => ( + + + +) + +interface AdminSection { to: string icon: React.ReactNode + mobileIcon: React.ReactNode title: string description: string color: string + bgColor: string + textColor: string } -function AdminCard({ to, icon, title, description, color }: AdminCardProps) { +// Mobile compact card +function MobileAdminCard({ to, mobileIcon, title, bgColor, textColor }: AdminSection) { return ( -
-
+
+
+ {mobileIcon} +
+
+ {title} + + ) +} + +// Desktop card +function DesktopAdminCard({ to, icon, title, description, bgColor, textColor }: AdminSection) { + return ( + +
+
{icon}
-

{title}

-

{description}

+
+

{title}

+

{description}

+
+ ) } @@ -79,75 +109,103 @@ function AdminCard({ to, icon, title, description, color }: AdminCardProps) { export default function AdminPanel() { const { t } = useTranslation() - const adminSections = [ + const adminSections: AdminSection[] = [ { to: '/admin/dashboard', icon: , + mobileIcon: , title: t('admin.nav.dashboard'), description: t('admin.panel.dashboardDesc'), - color: 'success' + color: 'success', + bgColor: 'bg-emerald-500/20', + textColor: 'text-emerald-400' }, { to: '/admin/tickets', icon: , + mobileIcon: , title: t('admin.nav.tickets'), description: t('admin.panel.ticketsDesc'), - color: 'warning' + color: 'warning', + bgColor: 'bg-amber-500/20', + textColor: 'text-amber-400' }, { to: '/admin/settings', icon: , + mobileIcon: , title: t('admin.nav.settings'), description: t('admin.panel.settingsDesc'), - color: 'accent' + color: 'accent', + bgColor: 'bg-blue-500/20', + textColor: 'text-blue-400' }, { to: '/admin/apps', icon: , + mobileIcon: , title: t('admin.nav.apps'), description: t('admin.panel.appsDesc'), - color: 'success' + color: 'success', + bgColor: 'bg-teal-500/20', + textColor: 'text-teal-400' }, { to: '/admin/wheel', icon: , + mobileIcon: , title: t('admin.nav.wheel'), description: t('admin.panel.wheelDesc'), - color: 'error' + color: 'error', + bgColor: 'bg-rose-500/20', + textColor: 'text-rose-400' }, { to: '/admin/tariffs', icon: , + mobileIcon: , title: t('admin.nav.tariffs'), description: t('admin.panel.tariffsDesc'), - color: 'info' + color: 'info', + bgColor: 'bg-cyan-500/20', + textColor: 'text-cyan-400' }, { to: '/admin/servers', icon: , + mobileIcon: , title: t('admin.nav.servers'), description: t('admin.panel.serversDesc'), - color: 'purple' + color: 'purple', + bgColor: 'bg-purple-500/20', + textColor: 'text-purple-400' }, ] return (
- {/* Header */} -
-
- + {/* Header - compact on mobile */} +
+
+
-

{t('admin.panel.title')}

-

{t('admin.panel.subtitle')}

+

{t('admin.panel.title')}

+

{t('admin.panel.subtitle')}

- {/* Grid of admin sections */} -
+ {/* Mobile: Compact 2-column grid */} +
{adminSections.map((section) => ( - + + ))} +
+ + {/* Tablet/Desktop: List style */} +
+ {adminSections.map((section) => ( + ))}
From 5f857b3053bb5f3767b55b6abb7708e0f9f9563e Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 03:09:11 +0300 Subject: [PATCH 04/28] Add files via upload --- src/api/adminBroadcasts.ts | 168 +++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/api/adminBroadcasts.ts 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 From 7278381b2135d2196fb9f3ec1fb820291a0c3036 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 03:09:36 +0300 Subject: [PATCH 05/28] Update App.tsx --- src/App.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index ae5c905..fb75db8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import AdminTariffs from './pages/AdminTariffs' import AdminServers from './pages/AdminServers' import AdminPanel from './pages/AdminPanel' import AdminDashboard from './pages/AdminDashboard' +import AdminBroadcasts from './pages/AdminBroadcasts' function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore() @@ -217,6 +218,14 @@ function App() { } /> + + + + } + /> {/* Catch all */} } /> From 970aaad81cb86925b5804d93bb61f3d99cb286b9 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 03:10:52 +0300 Subject: [PATCH 06/28] Add files via upload --- src/pages/AdminBroadcasts.tsx | 762 ++++++++++++++++++++++++++++++++++ src/pages/AdminPanel.tsx | 16 + 2 files changed, 778 insertions(+) create mode 100644 src/pages/AdminBroadcasts.tsx diff --git a/src/pages/AdminBroadcasts.tsx b/src/pages/AdminBroadcasts.tsx new file mode 100644 index 0000000..1203bb2 --- /dev/null +++ b/src/pages/AdminBroadcasts.tsx @@ -0,0 +1,762 @@ +import { useState, useRef, useMemo } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { + adminBroadcastsApi, + Broadcast, + BroadcastFilter, + TariffFilter, + BroadcastButton, + 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] + ) + } + + // Preview recipients count + const handlePreview = () => { + if (target) { + previewMutation.mutate(target) + } + } + + // 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 */} +
+ +