mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
Merge PR #492: кнопка «Отправить сообщение» в карточке пользователя
This commit is contained in:
@@ -535,6 +535,15 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Send direct Telegram message to user via bot (parity with bot's admin action)
|
||||||
|
sendMessage: async (
|
||||||
|
userId: number,
|
||||||
|
text: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/send-message`, { text });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Update restrictions
|
// Update restrictions
|
||||||
updateRestrictions: async (
|
updateRestrictions: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useNotify } from '../../../platform/hooks/useNotify';
|
||||||
import { useCurrency } from '../../../hooks/useCurrency';
|
import { useCurrency } from '../../../hooks/useCurrency';
|
||||||
import { createNumberInputHandler } from '../../../utils/inputHelpers';
|
import { createNumberInputHandler } from '../../../utils/inputHelpers';
|
||||||
import type {
|
import {
|
||||||
UserDetailResponse,
|
adminUsersApi,
|
||||||
UserListItem,
|
type UserDetailResponse,
|
||||||
UserPanelInfo,
|
type UserListItem,
|
||||||
UserSubscriptionInfo,
|
type UserPanelInfo,
|
||||||
|
type UserSubscriptionInfo,
|
||||||
} from '../../../api/adminUsers';
|
} from '../../../api/adminUsers';
|
||||||
import type { PromoGroup } from '../../../api/promocodes';
|
import type { PromoGroup } from '../../../api/promocodes';
|
||||||
import { ServerIcon } from '@/components/icons';
|
import { ServerIcon } from '@/components/icons';
|
||||||
@@ -92,6 +95,12 @@ export function InfoTab(props: InfoTabProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatWithCurrency } = useCurrency();
|
const { formatWithCurrency } = useCurrency();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
|
// «Отправить сообщение» — паритет с бот-кнопкой в карточке юзера
|
||||||
|
const [sendMsgOpen, setSendMsgOpen] = useState(false);
|
||||||
|
const [sendMsgText, setSendMsgText] = useState('');
|
||||||
|
const [sendMsgLoading, setSendMsgLoading] = useState(false);
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
@@ -124,6 +133,32 @@ export function InfoTab(props: InfoTabProps) {
|
|||||||
onFullDeleteUser,
|
onFullDeleteUser,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
const text = sendMsgText.trim();
|
||||||
|
if (!text || sendMsgLoading) return;
|
||||||
|
setSendMsgLoading(true);
|
||||||
|
try {
|
||||||
|
await adminUsersApi.sendMessage(user.id, text);
|
||||||
|
notify.success(t('admin.users.sendMessage.success'), t('common.success'));
|
||||||
|
setSendMsgOpen(false);
|
||||||
|
setSendMsgText('');
|
||||||
|
} catch (err) {
|
||||||
|
const detail = (
|
||||||
|
err as { response?: { data?: { detail?: { code?: string; message?: string } | string } } }
|
||||||
|
)?.response?.data?.detail;
|
||||||
|
const code = typeof detail === 'object' ? detail?.code : undefined;
|
||||||
|
const known = ['no_telegram_id', 'forbidden', 'bad_request'];
|
||||||
|
const message =
|
||||||
|
code && known.includes(code)
|
||||||
|
? t(`admin.users.sendMessage.errors.${code}`)
|
||||||
|
: (typeof detail === 'object' ? detail?.message : detail) ||
|
||||||
|
t('admin.users.userActions.error');
|
||||||
|
notify.error(message, t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setSendMsgLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
@@ -470,6 +505,16 @@ export function InfoTab(props: InfoTabProps) {
|
|||||||
{t('admin.users.detail.actions.title')}
|
{t('admin.users.detail.actions.title')}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{hasPermission('users:send_message') && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSendMsgOpen(true)}
|
||||||
|
disabled={actionLoading || !user.telegram_id}
|
||||||
|
title={!user.telegram_id ? t('admin.users.sendMessage.noTelegram') : undefined}
|
||||||
|
className="col-span-2 rounded-lg bg-accent-500/15 px-3 py-2 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/25 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('admin.users.sendMessage.button')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => onInlineConfirm('resetTrial', onResetTrial)}
|
onClick={() => onInlineConfirm('resetTrial', onResetTrial)}
|
||||||
disabled={actionLoading}
|
disabled={actionLoading}
|
||||||
@@ -524,6 +569,55 @@ export function InfoTab(props: InfoTabProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Send message modal */}
|
||||||
|
{sendMsgOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-dark-950/60"
|
||||||
|
onClick={() => !sendMsgLoading && setSendMsgOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="send-message-title"
|
||||||
|
className="relative w-full max-w-md rounded-xl border border-dark-700 bg-dark-800 p-5"
|
||||||
|
>
|
||||||
|
<h3 id="send-message-title" className="mb-3 text-base font-semibold text-dark-100">
|
||||||
|
{t('admin.users.sendMessage.title')}
|
||||||
|
</h3>
|
||||||
|
<textarea
|
||||||
|
value={sendMsgText}
|
||||||
|
onChange={(e) => setSendMsgText(e.target.value)}
|
||||||
|
maxLength={4096}
|
||||||
|
rows={5}
|
||||||
|
autoFocus
|
||||||
|
placeholder={t('admin.users.sendMessage.placeholder')}
|
||||||
|
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900/60 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-right text-xs text-dark-500">{sendMsgText.length}/4096</div>
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSendMsgOpen(false)}
|
||||||
|
disabled={sendMsgLoading}
|
||||||
|
className="rounded-lg bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={sendMsgLoading || !sendMsgText.trim()}
|
||||||
|
className="rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sendMsgLoading
|
||||||
|
? t('admin.users.sendMessage.sending')
|
||||||
|
: t('admin.users.sendMessage.send')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3353,6 +3353,20 @@
|
|||||||
"confirm": {
|
"confirm": {
|
||||||
"block": "Block this user?"
|
"block": "Block this user?"
|
||||||
},
|
},
|
||||||
|
"sendMessage": {
|
||||||
|
"button": "✉️ Send message",
|
||||||
|
"title": "Send message to user",
|
||||||
|
"placeholder": "Text the bot will send to the user (HTML supported)",
|
||||||
|
"send": "Send",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"success": "Message sent to user",
|
||||||
|
"noTelegram": "This user is registered by email only and cannot receive Telegram messages",
|
||||||
|
"errors": {
|
||||||
|
"no_telegram_id": "This user is registered by email only and cannot receive Telegram messages",
|
||||||
|
"forbidden": "The user has blocked the bot or cannot receive messages",
|
||||||
|
"bad_request": "Telegram rejected the message. Check the text (HTML markup) and try again"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userActions": {
|
"userActions": {
|
||||||
"delete": "Delete user",
|
"delete": "Delete user",
|
||||||
"resetTrial": "Reset trial",
|
"resetTrial": "Reset trial",
|
||||||
|
|||||||
@@ -3153,6 +3153,20 @@
|
|||||||
},
|
},
|
||||||
"notFound": "کاربر یافت نشد",
|
"notFound": "کاربر یافت نشد",
|
||||||
"purchaseCount": "تعداد خرید: {{count}}",
|
"purchaseCount": "تعداد خرید: {{count}}",
|
||||||
|
"sendMessage": {
|
||||||
|
"button": "✉️ ارسال پیام",
|
||||||
|
"title": "ارسال پیام به کاربر",
|
||||||
|
"placeholder": "متنی که ربات برای کاربر ارسال میکند (HTML پشتیبانی میشود)",
|
||||||
|
"send": "ارسال",
|
||||||
|
"sending": "در حال ارسال...",
|
||||||
|
"success": "پیام برای کاربر ارسال شد",
|
||||||
|
"noTelegram": "این کاربر فقط با ایمیل ثبتنام کرده و نمیتواند پیام تلگرام دریافت کند",
|
||||||
|
"errors": {
|
||||||
|
"no_telegram_id": "این کاربر فقط با ایمیل ثبتنام کرده و نمیتواند پیام تلگرام دریافت کند",
|
||||||
|
"forbidden": "کاربر ربات را مسدود کرده یا نمیتواند پیام دریافت کند",
|
||||||
|
"bad_request": "تلگرام پیام را رد کرد. متن (نشانهگذاری HTML) را بررسی کرده و دوباره تلاش کنید"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userActions": {
|
"userActions": {
|
||||||
"delete": "حذف کاربر",
|
"delete": "حذف کاربر",
|
||||||
"disable": "غیرفعالسازی",
|
"disable": "غیرفعالسازی",
|
||||||
|
|||||||
@@ -3750,6 +3750,20 @@
|
|||||||
"confirm": {
|
"confirm": {
|
||||||
"block": "Заблокировать пользователя?"
|
"block": "Заблокировать пользователя?"
|
||||||
},
|
},
|
||||||
|
"sendMessage": {
|
||||||
|
"button": "✉️ Отправить сообщение",
|
||||||
|
"title": "Отправка сообщения пользователю",
|
||||||
|
"placeholder": "Текст, который бот отправит пользователю (поддерживается HTML)",
|
||||||
|
"send": "Отправить",
|
||||||
|
"sending": "Отправка...",
|
||||||
|
"success": "Сообщение отправлено пользователю",
|
||||||
|
"noTelegram": "Пользователь зарегистрирован только по email и не может получать сообщения в Telegram",
|
||||||
|
"errors": {
|
||||||
|
"no_telegram_id": "Пользователь зарегистрирован только по email и не может получать сообщения в Telegram",
|
||||||
|
"forbidden": "Пользователь заблокировал бота или не может получать сообщения",
|
||||||
|
"bad_request": "Telegram отклонил сообщение. Проверьте текст (HTML-разметку) и попробуйте ещё раз"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userActions": {
|
"userActions": {
|
||||||
"delete": "Удалить пользователя",
|
"delete": "Удалить пользователя",
|
||||||
"resetTrial": "Сбросить триал",
|
"resetTrial": "Сбросить триал",
|
||||||
|
|||||||
@@ -3152,6 +3152,20 @@
|
|||||||
},
|
},
|
||||||
"notFound": "未找到用户",
|
"notFound": "未找到用户",
|
||||||
"purchaseCount": "购买次数:{{count}}",
|
"purchaseCount": "购买次数:{{count}}",
|
||||||
|
"sendMessage": {
|
||||||
|
"button": "✉️ 发送消息",
|
||||||
|
"title": "向用户发送消息",
|
||||||
|
"placeholder": "机器人将发送给用户的文本(支持 HTML)",
|
||||||
|
"send": "发送",
|
||||||
|
"sending": "发送中...",
|
||||||
|
"success": "消息已发送给用户",
|
||||||
|
"noTelegram": "该用户仅通过邮箱注册,无法接收 Telegram 消息",
|
||||||
|
"errors": {
|
||||||
|
"no_telegram_id": "该用户仅通过邮箱注册,无法接收 Telegram 消息",
|
||||||
|
"forbidden": "用户已屏蔽机器人或无法接收消息",
|
||||||
|
"bad_request": "Telegram 拒绝了该消息。请检查文本(HTML 标记)后重试"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userActions": {
|
"userActions": {
|
||||||
"delete": "删除用户",
|
"delete": "删除用户",
|
||||||
"disable": "禁用",
|
"disable": "禁用",
|
||||||
|
|||||||
Reference in New Issue
Block a user