From aa5113b8e309babb530e849fac12ae87a4769e9f Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 12 Feb 2026 19:33:18 +0300 Subject: [PATCH] feat: add admin pinned messages section - API client with full CRUD, broadcast, activate/deactivate, unpin endpoints - List page with active/inactive badges, action buttons, pagination - Create/edit form with content editor, media upload, display settings toggles - Routes and navigation in admin marketing group - i18n translations for ru, en, fa, zh --- src/App.tsx | 32 ++ src/api/adminPinnedMessages.ts | 175 ++++++++++ src/locales/en.json | 48 ++- src/locales/fa.json | 48 ++- src/locales/ru.json | 48 ++- src/locales/zh.json | 48 ++- src/pages/AdminPanel.tsx | 17 + src/pages/AdminPinnedMessageCreate.tsx | 367 +++++++++++++++++++++ src/pages/AdminPinnedMessages.tsx | 428 +++++++++++++++++++++++++ 9 files changed, 1203 insertions(+), 8 deletions(-) create mode 100644 src/api/adminPinnedMessages.ts create mode 100644 src/pages/AdminPinnedMessageCreate.tsx create mode 100644 src/pages/AdminPinnedMessages.tsx diff --git a/src/App.tsx b/src/App.tsx index 121f49b..31eeb9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,6 +73,8 @@ const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage')); const AdminUpdates = lazy(() => import('./pages/AdminUpdates')); const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail')); +const AdminPinnedMessages = lazy(() => import('./pages/AdminPinnedMessages')); +const AdminPinnedMessageCreate = lazy(() => import('./pages/AdminPinnedMessageCreate')); const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview')); function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -687,6 +689,36 @@ function App() { } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> => { + const response = await apiClient.get( + '/cabinet/admin/pinned-messages', + { + params: { limit, offset, active_only: activeOnly }, + }, + ); + return response.data; + }, + + // Get current active message + getActive: async (): Promise => { + const response = await apiClient.get( + '/cabinet/admin/pinned-messages/active', + ); + return response.data; + }, + + // Get pinned message by ID + get: async (id: number): Promise => { + const response = await apiClient.get( + `/cabinet/admin/pinned-messages/${id}`, + ); + return response.data; + }, + + // Create pinned message + create: async (data: PinnedMessageCreateRequest): Promise => { + const response = await apiClient.post( + '/cabinet/admin/pinned-messages', + data, + ); + return response.data; + }, + + // Update pinned message + update: async (id: number, data: PinnedMessageUpdateRequest): Promise => { + const response = await apiClient.patch( + `/cabinet/admin/pinned-messages/${id}`, + data, + ); + return response.data; + }, + + // Update settings only + updateSettings: async ( + id: number, + data: Pick, + ): Promise => { + const response = await apiClient.patch( + `/cabinet/admin/pinned-messages/${id}/settings`, + data, + ); + return response.data; + }, + + // Deactivate active message + deactivate: async (): Promise => { + const response = await apiClient.post( + '/cabinet/admin/pinned-messages/active/deactivate', + ); + return response.data; + }, + + // Unpin from all users + deactivate + unpin: async (): Promise => { + const response = await apiClient.post( + '/cabinet/admin/pinned-messages/active/unpin', + ); + return response.data; + }, + + // Activate a message + activate: async (id: number, broadcast = false): Promise => { + const response = await apiClient.post( + `/cabinet/admin/pinned-messages/${id}/activate`, + null, + { params: { broadcast } }, + ); + return response.data; + }, + + // Broadcast to all users + broadcast: async (id: number): Promise => { + const response = await apiClient.post( + `/cabinet/admin/pinned-messages/${id}/broadcast`, + ); + return response.data; + }, + + // Delete pinned message (only inactive) + delete: async (id: number): Promise => { + await apiClient.delete(`/cabinet/admin/pinned-messages/${id}`); + }, + + // Upload media (uses existing media endpoint) + uploadMedia: async (file: File, mediaType: 'photo' | 'video'): 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 adminPinnedMessagesApi; diff --git a/src/locales/en.json b/src/locales/en.json index 6164f83..ab88e42 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -768,7 +768,8 @@ "promocodes": "Promo Codes", "promoGroups": "Discount Groups", "trafficUsage": "Traffic Usage", - "updates": "Updates" + "updates": "Updates", + "pinnedMessages": "Pinned Messages" }, "panel": { "title": "Admin Panel", @@ -792,7 +793,8 @@ "promocodesDesc": "Manage promo codes", "promoGroupsDesc": "Discount groups for users", "trafficUsageDesc": "Per-user traffic by nodes", - "updatesDesc": "Bot & cabinet versions" + "updatesDesc": "Bot & cabinet versions", + "pinnedMessagesDesc": "Manage pinned messages for users" }, "trafficUsage": { "title": "Traffic Usage", @@ -1157,6 +1159,48 @@ "bothCreated": "Broadcasts created", "atLeastOneChannel": "Select at least one channel" }, + "pinnedMessages": { + "title": "Pinned Messages", + "subtitle": "Manage pinned messages for users", + "create": "Create", + "empty": "No pinned messages", + "content": "Message text", + "contentPlaceholder": "Enter pinned message text...", + "noContent": "No text", + "media": "Media file", + "addMedia": "Add media", + "uploading": "Uploading...", + "settings": "Settings", + "sendBeforeMenu": "Send before menu", + "sendOnEveryStart": "Send on every start", + "broadcast": "Broadcast", + "broadcastOnCreate": "Broadcast on create", + "broadcastConfirm": "Broadcast pinned message to all active users?", + "broadcastToAll": "Broadcast to all", + "activate": "Activate", + "deactivate": "Deactivate", + "unpin": "Unpin", + "unpinAll": "Unpin for everyone", + "unpinConfirm": "Unpin message for all users?", + "delete": "Delete", + "deleteConfirm": "Delete this pinned message?", + "active": "Active", + "inactive": "Inactive", + "prev": "Previous", + "next": "Next", + "createSuccess": "Pinned message created", + "updateSuccess": "Message updated", + "deleteSuccess": "Message deleted", + "activateSuccess": "Message activated", + "deactivateSuccess": "Message deactivated", + "broadcastSuccess": "Broadcast completed", + "unpinSuccess": "Messages unpinned", + "sentCount": "Sent", + "failedCount": "Failed", + "unpinnedCount": "Unpinned", + "editMessage": "Edit", + "cantDeleteActive": "Cannot delete active message" + }, "settings": { "title": "System Settings", "allCategories": "All categories", diff --git a/src/locales/fa.json b/src/locales/fa.json index 0309f95..1490fcd 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -649,7 +649,8 @@ "remnawave": "RemnaWave", "users": "کاربران", "trafficUsage": "مصرف ترافیک", - "updates": "به‌روزرسانی‌ها" + "updates": "به‌روزرسانی‌ها", + "pinnedMessages": "پیام‌های سنجاق‌شده" }, "panel": { "title": "پنل مدیریت", @@ -673,7 +674,8 @@ "remnawaveDesc": "مدیریت پنل و آمار", "usersDesc": "مدیریت کاربران ربات", "trafficUsageDesc": "ترافیک هر کاربر بر اساس نود", - "updatesDesc": "نسخه‌های ربات و کابینت" + "updatesDesc": "نسخه‌های ربات و کابینت", + "pinnedMessagesDesc": "مدیریت پیام‌های سنجاق‌شده" }, "trafficUsage": { "title": "مصرف ترافیک", @@ -861,6 +863,48 @@ "bothCreated": "پیام‌رسانی‌ها ایجاد شدند", "atLeastOneChannel": "حداقل یک کانال را انتخاب کنید" }, + "pinnedMessages": { + "title": "Pinned Messages", + "subtitle": "Manage pinned messages for users", + "create": "Create", + "empty": "No pinned messages", + "content": "Message text", + "contentPlaceholder": "Enter pinned message text...", + "noContent": "No text", + "media": "Media file", + "addMedia": "Add media", + "uploading": "Uploading...", + "settings": "Settings", + "sendBeforeMenu": "Send before menu", + "sendOnEveryStart": "Send on every start", + "broadcast": "Broadcast", + "broadcastOnCreate": "Broadcast on create", + "broadcastConfirm": "Broadcast pinned message to all active users?", + "broadcastToAll": "Broadcast to all", + "activate": "Activate", + "deactivate": "Deactivate", + "unpin": "Unpin", + "unpinAll": "Unpin for everyone", + "unpinConfirm": "Unpin message for all users?", + "delete": "Delete", + "deleteConfirm": "Delete this pinned message?", + "active": "Active", + "inactive": "Inactive", + "prev": "Previous", + "next": "Next", + "createSuccess": "Pinned message created", + "updateSuccess": "Message updated", + "deleteSuccess": "Message deleted", + "activateSuccess": "Message activated", + "deactivateSuccess": "Message deactivated", + "broadcastSuccess": "Broadcast completed", + "unpinSuccess": "Messages unpinned", + "sentCount": "Sent", + "failedCount": "Failed", + "unpinnedCount": "Unpinned", + "editMessage": "Edit", + "cantDeleteActive": "Cannot delete active message" + }, "payments": { "title": "تأیید پرداخت", "description": "پرداخت‌های معلق در ۲۴ ساعت گذشته", diff --git a/src/locales/ru.json b/src/locales/ru.json index c7123fe..e567be5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -789,7 +789,8 @@ "promocodes": "Промокоды", "promoGroups": "Группы скидок", "trafficUsage": "Расход трафика", - "updates": "Обновления" + "updates": "Обновления", + "pinnedMessages": "Закреплённые" }, "panel": { "title": "Панель администратора", @@ -813,7 +814,8 @@ "promocodesDesc": "Управление промокодами", "promoGroupsDesc": "Группы скидок для пользователей", "trafficUsageDesc": "Трафик пользователей по нодам", - "updatesDesc": "Версии бота и кабинета" + "updatesDesc": "Версии бота и кабинета", + "pinnedMessagesDesc": "Управление закреплёнными сообщениями" }, "trafficUsage": { "title": "Расход трафика", @@ -1182,6 +1184,48 @@ "bothCreated": "Рассылки созданы", "atLeastOneChannel": "Выберите хотя бы один канал" }, + "pinnedMessages": { + "title": "Закреплённые сообщения", + "subtitle": "Управление закреплёнными сообщениями для пользователей", + "create": "Создать", + "empty": "Нет закреплённых сообщений", + "content": "Текст сообщения", + "contentPlaceholder": "Введите текст закреплённого сообщения...", + "noContent": "Без текста", + "media": "Медиафайл", + "addMedia": "Добавить медиа", + "uploading": "Загрузка...", + "settings": "Настройки", + "sendBeforeMenu": "Отправлять перед меню", + "sendOnEveryStart": "Отправлять при каждом старте", + "broadcast": "Разослать", + "broadcastOnCreate": "Разослать при создании", + "broadcastConfirm": "Разослать закреплённое сообщение всем активным пользователям?", + "broadcastToAll": "Разослать всем", + "activate": "Активировать", + "deactivate": "Деактивировать", + "unpin": "Открепить", + "unpinAll": "Открепить у всех", + "unpinConfirm": "Открепить сообщение у всех пользователей?", + "delete": "Удалить", + "deleteConfirm": "Удалить это закреплённое сообщение?", + "active": "Активно", + "inactive": "Неактивно", + "prev": "Назад", + "next": "Далее", + "createSuccess": "Закреплённое сообщение создано", + "updateSuccess": "Сообщение обновлено", + "deleteSuccess": "Сообщение удалено", + "activateSuccess": "Сообщение активировано", + "deactivateSuccess": "Сообщение деактивировано", + "broadcastSuccess": "Рассылка завершена", + "unpinSuccess": "Сообщения откреплены", + "sentCount": "Отправлено", + "failedCount": "Ошибки", + "unpinnedCount": "Откреплено", + "editMessage": "Редактировать", + "cantDeleteActive": "Нельзя удалить активное сообщение" + }, "settings": { "title": "Настройки системы", "allCategories": "Все категории", diff --git a/src/locales/zh.json b/src/locales/zh.json index 2dcae90..0b7eaa9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -649,7 +649,8 @@ "remnawave": "RemnaWave", "users": "用户", "trafficUsage": "流量使用", - "updates": "更新" + "updates": "更新", + "pinnedMessages": "置顶消息" }, "panel": { "title": "管理面板", @@ -673,7 +674,8 @@ "remnawaveDesc": "面板管理和统计", "usersDesc": "管理机器人用户", "trafficUsageDesc": "按节点统计用户流量", - "updatesDesc": "机器人和面板版本" + "updatesDesc": "机器人和面板版本", + "pinnedMessagesDesc": "管理用户置顶消息" }, "trafficUsage": { "title": "流量使用", @@ -913,6 +915,48 @@ "bothCreated": "群发已创建", "atLeastOneChannel": "请至少选择一个渠道" }, + "pinnedMessages": { + "title": "置顶消息", + "subtitle": "管理用户置顶消息", + "create": "创建", + "empty": "暂无置顶消息", + "content": "消息文本", + "contentPlaceholder": "输入置顶消息文本...", + "noContent": "无文本", + "media": "媒体文件", + "addMedia": "添加媒体", + "uploading": "上传中...", + "settings": "设置", + "sendBeforeMenu": "菜单前发送", + "sendOnEveryStart": "每次启动时发送", + "broadcast": "广播", + "broadcastOnCreate": "创建时广播", + "broadcastConfirm": "向所有活跃用户广播置顶消息?", + "broadcastToAll": "广播给所有人", + "activate": "激活", + "deactivate": "停用", + "unpin": "取消置顶", + "unpinAll": "取消所有人的置顶", + "unpinConfirm": "取消所有用户的置顶消息?", + "delete": "删除", + "deleteConfirm": "删除此置顶消息?", + "active": "活跃", + "inactive": "不活跃", + "prev": "上一页", + "next": "下一页", + "createSuccess": "置顶消息已创建", + "updateSuccess": "消息已更新", + "deleteSuccess": "消息已删除", + "activateSuccess": "消息已激活", + "deactivateSuccess": "消息已停用", + "broadcastSuccess": "广播完成", + "unpinSuccess": "消息已取消置顶", + "sentCount": "已发送", + "failedCount": "失败", + "unpinnedCount": "已取消置顶", + "editMessage": "编辑", + "cantDeleteActive": "无法删除活跃消息" + }, "settings": { "title": "系统设置", "allCategories": "所有分类", diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 5cbd872..b44517e 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -155,6 +155,17 @@ const MegaphoneIcon = () => ( ); +const PinnedMessageIcon = () => ( + + + + +); + const PaperAirplaneIcon = () => ( , + title: t('admin.nav.pinnedMessages'), + description: t('admin.panel.pinnedMessagesDesc'), + }, { to: '/admin/wheel', icon: , diff --git a/src/pages/AdminPinnedMessageCreate.tsx b/src/pages/AdminPinnedMessageCreate.tsx new file mode 100644 index 0000000..b405da7 --- /dev/null +++ b/src/pages/AdminPinnedMessageCreate.tsx @@ -0,0 +1,367 @@ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { + adminPinnedMessagesApi, + PinnedMessageCreateRequest, + PinnedMessageUpdateRequest, +} from '../api/adminPinnedMessages'; +import { AdminBackButton, Toggle } from '../components/admin'; + +// Icons +const PinIcon = () => ( + + + + +); + +const XIcon = () => ( + + + +); + +const RefreshIcon = () => ( + + + +); + +const PhotoIcon = () => ( + + + +); + +const VideoIcon = () => ( + + + +); + +const SaveIcon = () => ( + + + +); + +export default function AdminPinnedMessageCreate() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { id } = useParams<{ id: string }>(); + const fileInputRef = useRef(null); + + const isEditing = Boolean(id); + const messageId = id ? parseInt(id, 10) : null; + + // Form state + const [content, setContent] = useState(''); + const [sendBeforeMenu, setSendBeforeMenu] = useState(true); + const [sendOnEveryStart, setSendOnEveryStart] = useState(true); + const [broadcastOnCreate, setBroadcastOnCreate] = useState(false); + + // Media state + const [mediaFile, setMediaFile] = useState(null); + const [mediaType, setMediaType] = useState<'photo' | 'video'>('photo'); + const [mediaPreview, setMediaPreview] = useState(null); + const [uploadedFileId, setUploadedFileId] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [existingMediaType, setExistingMediaType] = useState<'photo' | 'video' | null>(null); + + // Load existing message for editing + const { data: existingMessage, isLoading: isLoadingMessage } = useQuery({ + queryKey: ['admin', 'pinned-messages', 'detail', messageId], + queryFn: () => adminPinnedMessagesApi.get(messageId!), + enabled: isEditing && messageId !== null, + }); + + // Pre-fill form when editing + useEffect(() => { + if (existingMessage) { + setContent(existingMessage.content || ''); + setSendBeforeMenu(existingMessage.send_before_menu); + setSendOnEveryStart(existingMessage.send_on_every_start); + if (existingMessage.media_file_id && existingMessage.media_type) { + setUploadedFileId(existingMessage.media_file_id); + setExistingMediaType(existingMessage.media_type); + setMediaType(existingMessage.media_type); + } + } + }, [existingMessage]); + + // Create mutation + const createMutation = useMutation({ + mutationFn: (data: PinnedMessageCreateRequest) => adminPinnedMessagesApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] }); + navigate('/admin/pinned-messages'); + }, + }); + + // Update mutation + const updateMutation = useMutation({ + mutationFn: (data: PinnedMessageUpdateRequest) => + adminPinnedMessagesApi.update(messageId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] }); + navigate('/admin/pinned-messages'); + }, + }); + + // Handle file selection + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setMediaFile(file); + setExistingMediaType(null); + + let detectedType: 'photo' | 'video' = 'photo'; + if (file.type.startsWith('image/')) { + detectedType = 'photo'; + setMediaPreview(URL.createObjectURL(file)); + } else if (file.type.startsWith('video/')) { + detectedType = 'video'; + setMediaPreview(null); + } + setMediaType(detectedType); + + setIsUploading(true); + try { + const result = await adminPinnedMessagesApi.uploadMedia(file, detectedType); + setUploadedFileId(result.file_id); + } catch { + setMediaFile(null); + setMediaPreview(null); + } finally { + setIsUploading(false); + } + }; + + // Remove media + const handleRemoveMedia = () => { + setMediaFile(null); + setMediaPreview(null); + setUploadedFileId(null); + setExistingMediaType(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + // Validate + const isValid = content.trim().length > 0 || uploadedFileId !== null; + + // Submit + const handleSubmit = () => { + if (!isValid) return; + + if (isEditing && messageId !== null) { + const data: PinnedMessageUpdateRequest = { + content, + send_before_menu: sendBeforeMenu, + send_on_every_start: sendOnEveryStart, + }; + if (uploadedFileId) { + data.media = { type: mediaType, file_id: uploadedFileId }; + } + updateMutation.mutate(data); + } else { + const data: PinnedMessageCreateRequest = { + content, + send_before_menu: sendBeforeMenu, + send_on_every_start: sendOnEveryStart, + broadcast: broadcastOnCreate, + }; + if (uploadedFileId) { + data.media = { type: mediaType, file_id: uploadedFileId }; + } + createMutation.mutate(data); + } + }; + + const isPending = createMutation.isPending || updateMutation.isPending; + + if (isEditing && isLoadingMessage) { + return ( +
+

{t('common.loading')}

+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+
+ +
+
+

+ {isEditing ? t('admin.pinnedMessages.editMessage') : t('admin.pinnedMessages.create')} +

+

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

+
+
+
+ + {/* Content */} +
+ {/* Message text */} +
+ +