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
This commit is contained in:
Fringg
2026-02-12 19:33:18 +03:00
parent a0b10e688c
commit aa5113b8e3
9 changed files with 1203 additions and 8 deletions

View File

@@ -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() {
</AdminRoute>
}
/>
<Route
path="/admin/pinned-messages"
element={
<AdminRoute>
<LazyPage>
<AdminPinnedMessages />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/pinned-messages/create"
element={
<AdminRoute>
<LazyPage>
<AdminPinnedMessageCreate />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/pinned-messages/:id/edit"
element={
<AdminRoute>
<LazyPage>
<AdminPinnedMessageCreate />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/email-templates/preview/:type/:lang"
element={

View File

@@ -0,0 +1,175 @@
import apiClient from './client';
// Types
export interface PinnedMessageMedia {
type: 'photo' | 'video';
file_id: string;
}
export interface PinnedMessageResponse {
id: number;
content: string;
media_type: 'photo' | 'video' | null;
media_file_id: string | null;
send_before_menu: boolean;
send_on_every_start: boolean;
is_active: boolean;
created_by: number | null;
created_at: string;
updated_at: string | null;
}
export interface PinnedMessageListResponse {
items: PinnedMessageResponse[];
total: number;
limit: number;
offset: number;
}
export interface PinnedMessageCreateRequest {
content: string;
media?: PinnedMessageMedia;
send_before_menu: boolean;
send_on_every_start: boolean;
broadcast: boolean;
}
export interface PinnedMessageUpdateRequest {
content?: string;
media?: PinnedMessageMedia;
send_before_menu?: boolean;
send_on_every_start?: boolean;
}
export interface PinnedMessageBroadcastResponse {
message: PinnedMessageResponse;
sent_count: number;
failed_count: number;
}
export interface PinnedMessageUnpinResponse {
unpinned_count: number;
failed_count: number;
was_active: boolean;
}
export interface MediaUploadResponse {
media_type: string;
file_id: string;
file_unique_id: string | null;
media_url: string;
}
export const adminPinnedMessagesApi = {
// Get list of pinned messages with pagination
list: async (limit = 20, offset = 0, activeOnly = false): Promise<PinnedMessageListResponse> => {
const response = await apiClient.get<PinnedMessageListResponse>(
'/cabinet/admin/pinned-messages',
{
params: { limit, offset, active_only: activeOnly },
},
);
return response.data;
},
// Get current active message
getActive: async (): Promise<PinnedMessageResponse> => {
const response = await apiClient.get<PinnedMessageResponse>(
'/cabinet/admin/pinned-messages/active',
);
return response.data;
},
// Get pinned message by ID
get: async (id: number): Promise<PinnedMessageResponse> => {
const response = await apiClient.get<PinnedMessageResponse>(
`/cabinet/admin/pinned-messages/${id}`,
);
return response.data;
},
// Create pinned message
create: async (data: PinnedMessageCreateRequest): Promise<PinnedMessageResponse> => {
const response = await apiClient.post<PinnedMessageResponse>(
'/cabinet/admin/pinned-messages',
data,
);
return response.data;
},
// Update pinned message
update: async (id: number, data: PinnedMessageUpdateRequest): Promise<PinnedMessageResponse> => {
const response = await apiClient.patch<PinnedMessageResponse>(
`/cabinet/admin/pinned-messages/${id}`,
data,
);
return response.data;
},
// Update settings only
updateSettings: async (
id: number,
data: Pick<PinnedMessageUpdateRequest, 'send_before_menu' | 'send_on_every_start'>,
): Promise<PinnedMessageResponse> => {
const response = await apiClient.patch<PinnedMessageResponse>(
`/cabinet/admin/pinned-messages/${id}/settings`,
data,
);
return response.data;
},
// Deactivate active message
deactivate: async (): Promise<PinnedMessageResponse> => {
const response = await apiClient.post<PinnedMessageResponse>(
'/cabinet/admin/pinned-messages/active/deactivate',
);
return response.data;
},
// Unpin from all users + deactivate
unpin: async (): Promise<PinnedMessageUnpinResponse> => {
const response = await apiClient.post<PinnedMessageUnpinResponse>(
'/cabinet/admin/pinned-messages/active/unpin',
);
return response.data;
},
// Activate a message
activate: async (id: number, broadcast = false): Promise<PinnedMessageResponse> => {
const response = await apiClient.post<PinnedMessageResponse>(
`/cabinet/admin/pinned-messages/${id}/activate`,
null,
{ params: { broadcast } },
);
return response.data;
},
// Broadcast to all users
broadcast: async (id: number): Promise<PinnedMessageBroadcastResponse> => {
const response = await apiClient.post<PinnedMessageBroadcastResponse>(
`/cabinet/admin/pinned-messages/${id}/broadcast`,
);
return response.data;
},
// Delete pinned message (only inactive)
delete: async (id: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/pinned-messages/${id}`);
},
// Upload media (uses existing media endpoint)
uploadMedia: async (file: File, mediaType: 'photo' | 'video'): Promise<MediaUploadResponse> => {
const formData = new FormData();
formData.append('file', file);
formData.append('media_type', mediaType);
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
};
export default adminPinnedMessagesApi;

View File

@@ -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",

View File

@@ -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": "پرداخت‌های معلق در ۲۴ ساعت گذشته",

View File

@@ -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": "Все категории",

View File

@@ -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": "所有分类",

View File

@@ -155,6 +155,17 @@ const MegaphoneIcon = () => (
</svg>
);
const PinnedMessageIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const PaperAirplaneIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
@@ -426,6 +437,12 @@ export default function AdminPanel() {
title: t('admin.nav.broadcasts'),
description: t('admin.panel.broadcastsDesc'),
},
{
to: '/admin/pinned-messages',
icon: <PinnedMessageIcon />,
title: t('admin.nav.pinnedMessages'),
description: t('admin.panel.pinnedMessagesDesc'),
},
{
to: '/admin/wheel',
icon: <SparklesIcon />,

View File

@@ -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 = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const XIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const RefreshIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const PhotoIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
);
const VideoIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
/>
</svg>
);
const SaveIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
export default function AdminPinnedMessageCreate() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { id } = useParams<{ id: string }>();
const fileInputRef = useRef<HTMLInputElement>(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<File | null>(null);
const [mediaType, setMediaType] = useState<'photo' | 'video'>('photo');
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
const [uploadedFileId, setUploadedFileId] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
<p>{t('common.loading')}</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<AdminBackButton to="/admin/pinned-messages" />
<div className="flex items-center gap-3">
<div className="rounded-lg bg-error-500/20 p-2 text-error-400">
<PinIcon />
</div>
<div>
<h1 className="text-xl font-bold text-dark-100">
{isEditing ? t('admin.pinnedMessages.editMessage') : t('admin.pinnedMessages.create')}
</h1>
<p className="text-sm text-dark-400">{t('admin.pinnedMessages.subtitle')}</p>
</div>
</div>
</div>
{/* Content */}
<div className="card space-y-6">
{/* Message text */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.pinnedMessages.content')}
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t('admin.pinnedMessages.contentPlaceholder')}
rows={6}
maxLength={4000}
className="input min-h-[150px] resize-y"
/>
<div className="mt-1 text-right text-xs text-dark-400">{content.length}/4000</div>
</div>
{/* Media upload */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.pinnedMessages.media')}
</label>
{mediaFile || existingMediaType ? (
<div className="rounded-lg border border-dark-700 bg-dark-800 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{(mediaType === 'photo' || existingMediaType === 'photo') && <PhotoIcon />}
{(mediaType === 'video' || existingMediaType === 'video') && <VideoIcon />}
<div>
<p className="text-sm text-dark-100">
{mediaFile
? mediaFile.name
: `${existingMediaType} (${t('admin.pinnedMessages.media')})`}
</p>
{mediaFile && (
<p className="text-xs text-dark-400">
{(mediaFile.size / 1024 / 1024).toFixed(2)} MB
</p>
)}
</div>
</div>
<button
onClick={handleRemoveMedia}
className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400"
disabled={isUploading}
>
<XIcon />
</button>
</div>
{mediaPreview && (
<img
src={mediaPreview}
alt="Preview"
className="mt-3 max-h-48 rounded-lg object-cover"
/>
)}
{isUploading && (
<div className="mt-2 flex items-center gap-2 text-sm text-accent-400">
<RefreshIcon />
{t('admin.pinnedMessages.uploading')}
</div>
)}
</div>
) : (
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-dark-600 bg-dark-800/50 p-6 text-dark-400 transition-colors hover:border-dark-500 hover:bg-dark-800 hover:text-dark-300"
>
<PhotoIcon />
<span>{t('admin.pinnedMessages.addMedia')}</span>
</button>
</div>
)}
</div>
</div>
{/* Settings */}
<div className="card space-y-4">
<h2 className="text-lg font-semibold text-dark-100">
{t('admin.pinnedMessages.settings')}
</h2>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-dark-200">
{t('admin.pinnedMessages.sendBeforeMenu')}
</p>
</div>
<Toggle checked={sendBeforeMenu} onChange={() => setSendBeforeMenu((p) => !p)} />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-dark-200">
{t('admin.pinnedMessages.sendOnEveryStart')}
</p>
</div>
<Toggle checked={sendOnEveryStart} onChange={() => setSendOnEveryStart((p) => !p)} />
</div>
{!isEditing && (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-dark-200">
{t('admin.pinnedMessages.broadcastOnCreate')}
</p>
</div>
<Toggle checked={broadcastOnCreate} onChange={() => setBroadcastOnCreate((p) => !p)} />
</div>
)}
</div>
{/* Footer */}
<div className="card flex items-center justify-between">
<button onClick={() => navigate('/admin/pinned-messages')} className="btn-secondary">
{t('common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!isValid || isPending || isUploading}
className="btn-primary flex items-center gap-2"
>
{isPending ? <RefreshIcon /> : <SaveIcon />}
{isEditing ? t('common.save') : t('admin.pinnedMessages.create')}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,428 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminPinnedMessagesApi, PinnedMessageResponse } from '../api/adminPinnedMessages';
import { AdminBackButton } from '../components/admin';
// Icons
const PinIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const PlusIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const RefreshIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const PhotoIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
);
const VideoIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
/>
</svg>
);
const MenuIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
);
const RepeatIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3"
/>
</svg>
);
const BroadcastIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
/>
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const EditIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const XIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const UnpinIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
// Message card component
function PinnedMessageCard({
message,
onActivate,
onDeactivate,
onBroadcast,
onDelete,
onEdit,
onUnpin,
}: {
message: PinnedMessageResponse;
onActivate: (id: number) => void;
onDeactivate: () => void;
onBroadcast: (id: number) => void;
onDelete: (id: number) => void;
onEdit: (id: number) => void;
onUnpin: () => void;
}) {
const { t } = useTranslation();
return (
<div
className={`rounded-xl border p-4 transition-all ${
message.is_active
? 'border-success-500/50 bg-success-500/5'
: 'border-dark-700 bg-dark-800/50'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{/* Status + ID + media indicator */}
<div className="mb-2 flex flex-wrap items-center gap-2">
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
message.is_active
? 'bg-success-500/20 text-success-400'
: 'bg-dark-500/20 text-dark-400'
}`}
>
{message.is_active
? t('admin.pinnedMessages.active')
: t('admin.pinnedMessages.inactive')}
</span>
<span className="text-xs text-dark-400">#{message.id}</span>
{message.media_type && (
<span className="text-dark-400">
{message.media_type === 'photo' ? <PhotoIcon /> : <VideoIcon />}
</span>
)}
{message.send_before_menu && (
<span className="text-dark-500" title={t('admin.pinnedMessages.sendBeforeMenu')}>
<MenuIcon />
</span>
)}
{message.send_on_every_start && (
<span className="text-dark-500" title={t('admin.pinnedMessages.sendOnEveryStart')}>
<RepeatIcon />
</span>
)}
</div>
{/* Content preview */}
<p className="line-clamp-3 text-sm text-dark-100">
{message.content || t('admin.pinnedMessages.noContent')}
</p>
{/* Date */}
<div className="mt-2 text-xs text-dark-400">
{new Date(message.created_at).toLocaleDateString()}
</div>
</div>
</div>
{/* Action buttons */}
<div className="mt-3 flex flex-wrap gap-2 border-t border-dark-700/50 pt-3">
<button
onClick={() => onEdit(message.id)}
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-xs text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
>
<EditIcon />
{t('admin.pinnedMessages.editMessage')}
</button>
{message.is_active ? (
<>
<button
onClick={onDeactivate}
className="flex items-center gap-1.5 rounded-lg bg-warning-500/20 px-3 py-1.5 text-xs text-warning-400 transition-colors hover:bg-warning-500/30"
>
<XIcon />
{t('admin.pinnedMessages.deactivate')}
</button>
<button
onClick={onUnpin}
className="flex items-center gap-1.5 rounded-lg bg-error-500/20 px-3 py-1.5 text-xs text-error-400 transition-colors hover:bg-error-500/30"
>
<UnpinIcon />
{t('admin.pinnedMessages.unpinAll')}
</button>
</>
) : (
<button
onClick={() => onActivate(message.id)}
className="flex items-center gap-1.5 rounded-lg bg-success-500/20 px-3 py-1.5 text-xs text-success-400 transition-colors hover:bg-success-500/30"
>
<CheckIcon />
{t('admin.pinnedMessages.activate')}
</button>
)}
<button
onClick={() => onBroadcast(message.id)}
className="flex items-center gap-1.5 rounded-lg bg-accent-500/20 px-3 py-1.5 text-xs text-accent-400 transition-colors hover:bg-accent-500/30"
>
<BroadcastIcon />
{t('admin.pinnedMessages.broadcastToAll')}
</button>
{!message.is_active && (
<button
onClick={() => onDelete(message.id)}
className="flex items-center gap-1.5 rounded-lg bg-error-500/20 px-3 py-1.5 text-xs text-error-400 transition-colors hover:bg-error-500/30"
>
<TrashIcon />
{t('admin.pinnedMessages.delete')}
</button>
)}
</div>
</div>
);
}
// Main component
export default function AdminPinnedMessages() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [page, setPage] = useState(0);
const limit = 20;
// Fetch pinned messages
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin', 'pinned-messages', 'list', page],
queryFn: () => adminPinnedMessagesApi.list(limit, page * limit),
});
const messages = data?.items || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / limit);
// Mutations
const activateMutation = useMutation({
mutationFn: (id: number) => adminPinnedMessagesApi.activate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
},
});
const deactivateMutation = useMutation({
mutationFn: () => adminPinnedMessagesApi.deactivate(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
},
});
const unpinMutation = useMutation({
mutationFn: () => adminPinnedMessagesApi.unpin(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
},
});
const broadcastMutation = useMutation({
mutationFn: (id: number) => adminPinnedMessagesApi.broadcast(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => adminPinnedMessagesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
},
});
const handleActivate = (id: number) => {
activateMutation.mutate(id);
};
const handleDeactivate = () => {
deactivateMutation.mutate();
};
const handleUnpin = () => {
if (window.confirm(t('admin.pinnedMessages.unpinConfirm'))) {
unpinMutation.mutate();
}
};
const handleBroadcast = (id: number) => {
if (window.confirm(t('admin.pinnedMessages.broadcastConfirm'))) {
broadcastMutation.mutate(id);
}
};
const handleDelete = (id: number) => {
if (window.confirm(t('admin.pinnedMessages.deleteConfirm'))) {
deleteMutation.mutate(id);
}
};
const handleEdit = (id: number) => {
navigate(`/admin/pinned-messages/${id}/edit`);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<AdminBackButton />
<div className="flex items-center gap-3">
<div className="rounded-lg bg-error-500/20 p-2 text-error-400">
<PinIcon />
</div>
<div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.pinnedMessages.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.pinnedMessages.subtitle')}</p>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => refetch()}
className="rounded-lg bg-dark-800 p-2 text-dark-400 transition-colors hover:text-dark-100"
>
<RefreshIcon />
</button>
<button
onClick={() => navigate('/admin/pinned-messages/create')}
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
>
<PlusIcon />
<span className="hidden sm:inline">{t('admin.pinnedMessages.create')}</span>
</button>
</div>
</div>
{/* Messages list */}
{isLoading ? (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
<RefreshIcon />
<p className="mt-2">{t('common.loading')}</p>
</div>
) : messages.length === 0 ? (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
<div className="mx-auto mb-2 w-fit">
<PinIcon />
</div>
<p>{t('admin.pinnedMessages.empty')}</p>
</div>
) : (
<div className="space-y-3">
{messages.map((message: PinnedMessageResponse) => (
<PinnedMessageCard
key={message.id}
message={message}
onActivate={handleActivate}
onDeactivate={handleDeactivate}
onBroadcast={handleBroadcast}
onDelete={handleDelete}
onEdit={handleEdit}
onUnpin={handleUnpin}
/>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('admin.pinnedMessages.prev')}
</button>
<span className="text-dark-400">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('admin.pinnedMessages.next')}
</button>
</div>
)}
</div>
);
}