mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
feat: custom broadcast buttons UI and fix stale mediaType bug
- Add inline custom button editor (no popups): label, type (callback/URL), action value with byte-level validation - Frontend validates callback_data in UTF-8 bytes via TextEncoder - URLs restricted to https:// and tg:// matching backend - Form wrapped in <form> for Enter key submit support - Fix pre-existing stale mediaType bug in file upload handler - Translations added for ru, en, zh, fa locales
This commit is contained in:
@@ -48,6 +48,12 @@ export interface BroadcastButtonsResponse {
|
|||||||
buttons: BroadcastButton[];
|
buttons: BroadcastButton[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomBroadcastButton {
|
||||||
|
label: string;
|
||||||
|
action_type: 'callback' | 'url';
|
||||||
|
action_value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BroadcastMedia {
|
export interface BroadcastMedia {
|
||||||
type: 'photo' | 'video' | 'document';
|
type: 'photo' | 'video' | 'document';
|
||||||
file_id: string;
|
file_id: string;
|
||||||
@@ -73,6 +79,7 @@ export interface CombinedBroadcastCreateRequest {
|
|||||||
// Telegram fields
|
// Telegram fields
|
||||||
message_text?: string;
|
message_text?: string;
|
||||||
selected_buttons?: string[];
|
selected_buttons?: string[];
|
||||||
|
custom_buttons?: CustomBroadcastButton[];
|
||||||
media?: BroadcastMedia;
|
media?: BroadcastMedia;
|
||||||
// Email fields
|
// Email fields
|
||||||
email_subject?: string;
|
email_subject?: string;
|
||||||
|
|||||||
@@ -1701,7 +1701,14 @@
|
|||||||
"enableEmail": "Email",
|
"enableEmail": "Email",
|
||||||
"sendingBoth": "2 broadcasts will be created",
|
"sendingBoth": "2 broadcasts will be created",
|
||||||
"bothCreated": "Broadcasts created",
|
"bothCreated": "Broadcasts created",
|
||||||
"atLeastOneChannel": "Select at least one channel"
|
"atLeastOneChannel": "Select at least one channel",
|
||||||
|
"customButtons": "Custom buttons",
|
||||||
|
"addCustomButton": "Add button",
|
||||||
|
"customButtonLabelPlaceholder": "Button text",
|
||||||
|
"customButtonTypeCallback": "Callback",
|
||||||
|
"customButtonTypeUrl": "URL",
|
||||||
|
"customButtonCallbackPlaceholder": "callback_data (e.g. back_to_menu)",
|
||||||
|
"customButtonUrlPlaceholder": "https://example.com"
|
||||||
},
|
},
|
||||||
"pinnedMessages": {
|
"pinnedMessages": {
|
||||||
"title": "Pinned Messages",
|
"title": "Pinned Messages",
|
||||||
|
|||||||
@@ -1336,7 +1336,14 @@
|
|||||||
"enableEmail": "Email",
|
"enableEmail": "Email",
|
||||||
"sendingBoth": "۲ پیامرسانی ایجاد خواهد شد",
|
"sendingBoth": "۲ پیامرسانی ایجاد خواهد شد",
|
||||||
"bothCreated": "پیامرسانیها ایجاد شدند",
|
"bothCreated": "پیامرسانیها ایجاد شدند",
|
||||||
"atLeastOneChannel": "حداقل یک کانال را انتخاب کنید"
|
"atLeastOneChannel": "حداقل یک کانال را انتخاب کنید",
|
||||||
|
"customButtons": "دکمههای سفارشی",
|
||||||
|
"addCustomButton": "افزودن دکمه",
|
||||||
|
"customButtonLabelPlaceholder": "متن دکمه",
|
||||||
|
"customButtonTypeCallback": "Callback",
|
||||||
|
"customButtonTypeUrl": "لینک",
|
||||||
|
"customButtonCallbackPlaceholder": "callback_data (مثلاً back_to_menu)",
|
||||||
|
"customButtonUrlPlaceholder": "https://example.com"
|
||||||
},
|
},
|
||||||
"pinnedMessages": {
|
"pinnedMessages": {
|
||||||
"title": "Pinned Messages",
|
"title": "Pinned Messages",
|
||||||
|
|||||||
@@ -1726,7 +1726,14 @@
|
|||||||
"enableEmail": "Email",
|
"enableEmail": "Email",
|
||||||
"sendingBoth": "Будет создано 2 рассылки",
|
"sendingBoth": "Будет создано 2 рассылки",
|
||||||
"bothCreated": "Рассылки созданы",
|
"bothCreated": "Рассылки созданы",
|
||||||
"atLeastOneChannel": "Выберите хотя бы один канал"
|
"atLeastOneChannel": "Выберите хотя бы один канал",
|
||||||
|
"customButtons": "Кастомные кнопки",
|
||||||
|
"addCustomButton": "Добавить кнопку",
|
||||||
|
"customButtonLabelPlaceholder": "Текст кнопки",
|
||||||
|
"customButtonTypeCallback": "Callback",
|
||||||
|
"customButtonTypeUrl": "Ссылка",
|
||||||
|
"customButtonCallbackPlaceholder": "callback_data (например, back_to_menu)",
|
||||||
|
"customButtonUrlPlaceholder": "https://example.com"
|
||||||
},
|
},
|
||||||
"pinnedMessages": {
|
"pinnedMessages": {
|
||||||
"title": "Закреплённые сообщения",
|
"title": "Закреплённые сообщения",
|
||||||
|
|||||||
@@ -1410,7 +1410,14 @@
|
|||||||
"enableEmail": "Email",
|
"enableEmail": "Email",
|
||||||
"sendingBoth": "将创建2个群发",
|
"sendingBoth": "将创建2个群发",
|
||||||
"bothCreated": "群发已创建",
|
"bothCreated": "群发已创建",
|
||||||
"atLeastOneChannel": "请至少选择一个渠道"
|
"atLeastOneChannel": "请至少选择一个渠道",
|
||||||
|
"customButtons": "自定义按钮",
|
||||||
|
"addCustomButton": "添加按钮",
|
||||||
|
"customButtonLabelPlaceholder": "按钮文字",
|
||||||
|
"customButtonTypeCallback": "回调",
|
||||||
|
"customButtonTypeUrl": "链接",
|
||||||
|
"customButtonCallbackPlaceholder": "callback_data(例如 back_to_menu)",
|
||||||
|
"customButtonUrlPlaceholder": "https://example.com"
|
||||||
},
|
},
|
||||||
"pinnedMessages": {
|
"pinnedMessages": {
|
||||||
"title": "置顶消息",
|
"title": "置顶消息",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
BroadcastFilter,
|
BroadcastFilter,
|
||||||
TariffFilter,
|
TariffFilter,
|
||||||
CombinedBroadcastCreateRequest,
|
CombinedBroadcastCreateRequest,
|
||||||
|
CustomBroadcastButton,
|
||||||
} from '../api/adminBroadcasts';
|
} from '../api/adminBroadcasts';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
|
|
||||||
@@ -130,6 +131,11 @@ export default function AdminBroadcastCreate() {
|
|||||||
// Telegram-specific state
|
// Telegram-specific state
|
||||||
const [messageText, setMessageText] = useState('');
|
const [messageText, setMessageText] = useState('');
|
||||||
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
|
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
|
||||||
|
const [customButtons, setCustomButtons] = useState<CustomBroadcastButton[]>([]);
|
||||||
|
const [isAddingCustomButton, setIsAddingCustomButton] = useState(false);
|
||||||
|
const [newButtonLabel, setNewButtonLabel] = useState('');
|
||||||
|
const [newButtonActionType, setNewButtonActionType] = useState<'callback' | 'url'>('callback');
|
||||||
|
const [newButtonActionValue, setNewButtonActionValue] = useState('');
|
||||||
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||||||
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
|
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
|
||||||
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
||||||
@@ -275,28 +281,33 @@ export default function AdminBroadcastCreate() {
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
setMediaFile(file);
|
// Determine type locally to avoid stale state in async call
|
||||||
|
let detectedType: 'photo' | 'video' | 'document';
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
setMediaType('photo');
|
detectedType = 'photo';
|
||||||
|
} else if (file.type.startsWith('video/')) {
|
||||||
|
detectedType = 'video';
|
||||||
|
} else {
|
||||||
|
detectedType = 'document';
|
||||||
|
}
|
||||||
|
|
||||||
|
setMediaFile(file);
|
||||||
|
setMediaType(detectedType);
|
||||||
|
|
||||||
|
if (detectedType === 'photo') {
|
||||||
if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
|
if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current);
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
mediaPreviewRef.current = url;
|
mediaPreviewRef.current = url;
|
||||||
setMediaPreview(url);
|
setMediaPreview(url);
|
||||||
} else if (file.type.startsWith('video/')) {
|
|
||||||
setMediaType('video');
|
|
||||||
setMediaPreview(null);
|
|
||||||
} else {
|
} else {
|
||||||
setMediaType('document');
|
|
||||||
setMediaPreview(null);
|
setMediaPreview(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
try {
|
try {
|
||||||
const result = await adminBroadcastsApi.uploadMedia(file, mediaType);
|
const result = await adminBroadcastsApi.uploadMedia(file, detectedType);
|
||||||
setUploadedFileId(result.file_id);
|
setUploadedFileId(result.file_id);
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('Upload failed:', err);
|
|
||||||
setMediaFile(null);
|
setMediaFile(null);
|
||||||
setMediaPreview(null);
|
setMediaPreview(null);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -325,6 +336,39 @@ export default function AdminBroadcastCreate() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Custom button validation
|
||||||
|
const isNewButtonValid = useMemo(() => {
|
||||||
|
if (!newButtonLabel.trim() || !newButtonActionValue.trim()) return false;
|
||||||
|
if (newButtonActionType === 'url') {
|
||||||
|
return /^https:\/\/|^tg:\/\//.test(newButtonActionValue.trim());
|
||||||
|
}
|
||||||
|
if (newButtonActionType === 'callback') {
|
||||||
|
return new TextEncoder().encode(newButtonActionValue.trim()).length <= 64;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [newButtonLabel, newButtonActionType, newButtonActionValue]);
|
||||||
|
|
||||||
|
// Custom button handlers
|
||||||
|
const addCustomButton = () => {
|
||||||
|
if (!isNewButtonValid) return;
|
||||||
|
setCustomButtons((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
label: newButtonLabel.trim(),
|
||||||
|
action_type: newButtonActionType,
|
||||||
|
action_value: newButtonActionValue.trim(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setNewButtonLabel('');
|
||||||
|
setNewButtonActionValue('');
|
||||||
|
setNewButtonActionType('callback');
|
||||||
|
setIsAddingCustomButton(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCustomButton = (index: number) => {
|
||||||
|
setCustomButtons((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
// Validate form
|
// Validate form
|
||||||
const isTelegramValid = telegramEnabled && telegramTarget && messageText.trim().length > 0;
|
const isTelegramValid = telegramEnabled && telegramTarget && messageText.trim().length > 0;
|
||||||
const isEmailValid =
|
const isEmailValid =
|
||||||
@@ -350,6 +394,7 @@ export default function AdminBroadcastCreate() {
|
|||||||
target: telegramTarget,
|
target: telegramTarget,
|
||||||
message_text: messageText,
|
message_text: messageText,
|
||||||
selected_buttons: selectedButtons,
|
selected_buttons: selectedButtons,
|
||||||
|
custom_buttons: customButtons.length > 0 ? customButtons : undefined,
|
||||||
};
|
};
|
||||||
if (uploadedFileId) {
|
if (uploadedFileId) {
|
||||||
data.media = { type: mediaType, file_id: uploadedFileId };
|
data.media = { type: mediaType, file_id: uploadedFileId };
|
||||||
@@ -377,6 +422,7 @@ export default function AdminBroadcastCreate() {
|
|||||||
target: telegramTarget,
|
target: telegramTarget,
|
||||||
message_text: messageText,
|
message_text: messageText,
|
||||||
selected_buttons: selectedButtons,
|
selected_buttons: selectedButtons,
|
||||||
|
custom_buttons: customButtons.length > 0 ? customButtons : undefined,
|
||||||
};
|
};
|
||||||
if (uploadedFileId) {
|
if (uploadedFileId) {
|
||||||
telegramData.media = { type: mediaType, file_id: uploadedFileId };
|
telegramData.media = { type: mediaType, file_id: uploadedFileId };
|
||||||
@@ -655,6 +701,123 @@ export default function AdminBroadcastCreate() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom buttons */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.broadcasts.customButtons')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Existing custom buttons */}
|
||||||
|
{customButtons.length > 0 && (
|
||||||
|
<div className="mb-3 space-y-2">
|
||||||
|
{customButtons.map((btn, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-dark-700 bg-dark-800 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
|
<span className="shrink-0 rounded bg-dark-700 px-1.5 py-0.5 text-xs text-dark-400">
|
||||||
|
{btn.action_type === 'url'
|
||||||
|
? t('admin.broadcasts.customButtonTypeUrl')
|
||||||
|
: t('admin.broadcasts.customButtonTypeCallback')}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm text-dark-100">{btn.label}</span>
|
||||||
|
<span className="truncate text-xs text-dark-500">{btn.action_value}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeCustomButton(index)}
|
||||||
|
className="ml-2 shrink-0 rounded p-1 text-dark-400 hover:bg-dark-700 hover:text-error-400"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline add form */}
|
||||||
|
{isAddingCustomButton ? (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addCustomButton();
|
||||||
|
}}
|
||||||
|
className="space-y-3 rounded-lg border border-dark-600 bg-dark-800/50 p-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newButtonLabel}
|
||||||
|
onChange={(e) => setNewButtonLabel(e.target.value)}
|
||||||
|
placeholder={t('admin.broadcasts.customButtonLabelPlaceholder')}
|
||||||
|
maxLength={64}
|
||||||
|
className="input"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNewButtonActionType('callback')}
|
||||||
|
className={`flex-1 rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||||
|
newButtonActionType === 'callback'
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('admin.broadcasts.customButtonTypeCallback')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNewButtonActionType('url')}
|
||||||
|
className={`flex-1 rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||||
|
newButtonActionType === 'url'
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('admin.broadcasts.customButtonTypeUrl')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newButtonActionValue}
|
||||||
|
onChange={(e) => setNewButtonActionValue(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
newButtonActionType === 'url'
|
||||||
|
? t('admin.broadcasts.customButtonUrlPlaceholder')
|
||||||
|
: t('admin.broadcasts.customButtonCallbackPlaceholder')
|
||||||
|
}
|
||||||
|
maxLength={newButtonActionType === 'callback' ? 64 : 256}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAddingCustomButton(false);
|
||||||
|
setNewButtonLabel('');
|
||||||
|
setNewButtonActionValue('');
|
||||||
|
}}
|
||||||
|
className="btn-secondary flex-1"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={!isNewButtonValid} className="btn-primary flex-1">
|
||||||
|
{t('common.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAddingCustomButton(true)}
|
||||||
|
disabled={customButtons.length >= 10}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-dark-600 bg-dark-800/50 px-4 py-3 text-sm text-dark-400 transition-colors hover:border-dark-500 hover:bg-dark-800 hover:text-dark-300"
|
||||||
|
>
|
||||||
|
<span>+</span>
|
||||||
|
<span>{t('admin.broadcasts.addCustomButton')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user