diff --git a/src/api/menuLayout.ts b/src/api/menuLayout.ts new file mode 100644 index 0000000..40320d1 --- /dev/null +++ b/src/api/menuLayout.ts @@ -0,0 +1,88 @@ +import apiClient from './client'; + +export interface MenuButtonConfig { + id: string; + type: 'builtin' | 'custom'; + style: 'primary' | 'success' | 'danger' | 'default'; + icon_custom_emoji_id: string; + enabled: boolean; + labels: Record; + url: string | null; +} + +export interface MenuRowConfig { + id: string; + max_per_row: number; + buttons: MenuButtonConfig[]; +} + +export interface MenuConfig { + rows: MenuRowConfig[]; +} + +export const BOT_LOCALES = ['ru', 'en', 'ua', 'zh', 'fa'] as const; +export type BotLocale = (typeof BOT_LOCALES)[number]; + +export const BUILTIN_SECTIONS = [ + 'home', + 'subscription', + 'balance', + 'referral', + 'support', + 'info', + 'admin', + 'language', +] as const; + +export type BuiltinSection = (typeof BUILTIN_SECTIONS)[number]; + +export const STYLE_OPTIONS = [ + { value: 'default' as const, colorClass: 'bg-dark-500' }, + { value: 'primary' as const, colorClass: 'bg-blue-500' }, + { value: 'success' as const, colorClass: 'bg-success-500' }, + { value: 'danger' as const, colorClass: 'bg-red-500' }, +]; + +const DEFAULT_CONFIG: MenuConfig = { rows: [] }; + +const DEFAULT_BUTTON: Omit = { + style: 'primary', + icon_custom_emoji_id: '', + enabled: true, + labels: {}, + url: null, +}; + +function normalizeConfig(data: MenuConfig): MenuConfig { + if (!data || typeof data !== 'object') { + return DEFAULT_CONFIG; + } + return { + rows: (data.rows || []).map((row) => ({ + id: row.id, + max_per_row: row.max_per_row ?? 2, + buttons: (row.buttons || []).map((btn) => ({ + ...DEFAULT_BUTTON, + ...btn, + labels: { ...(btn.labels || {}) }, + })), + })), + }; +} + +export const menuLayoutApi = { + getConfig: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/menu-layout'); + return normalizeConfig(response.data); + }, + + updateConfig: async (config: MenuConfig): Promise => { + const response = await apiClient.put('/cabinet/admin/menu-layout', config); + return normalizeConfig(response.data); + }, + + resetConfig: async (): Promise => { + const response = await apiClient.post('/cabinet/admin/menu-layout/reset'); + return normalizeConfig(response.data); + }, +}; diff --git a/src/components/admin/MenuEditorTab.tsx b/src/components/admin/MenuEditorTab.tsx new file mode 100644 index 0000000..c40359d --- /dev/null +++ b/src/components/admin/MenuEditorTab.tsx @@ -0,0 +1,816 @@ +import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + closestCenter, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + menuLayoutApi, + type MenuConfig, + type MenuRowConfig, + type MenuButtonConfig, + BUILTIN_SECTIONS, + BOT_LOCALES, + STYLE_OPTIONS, +} from '../../api/menuLayout'; +import { Toggle } from './Toggle'; +import { useNotify } from '../../platform/hooks/useNotify'; + +// ============ Icons ============ + +const GripIcon = () => ( + + + +); + +const TrashIcon = () => ( + + + +); + +const PlusIcon = () => ( + + + +); + +const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( + + + +); + +const LinkIcon = () => ( + + + +); + +// ============ Helpers ============ + +function generateId(): string { + return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; +} + +function generateRowId(): string { + return `row_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; +} + +function configsEqual(a: MenuConfig, b: MenuConfig): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +const DEFAULT_CONFIG: MenuConfig = { rows: [] }; + +// ============ MaxPerRowSelector ============ + +interface MaxPerRowSelectorProps { + value: number; + onChange: (value: number) => void; +} + +function MaxPerRowSelector({ value, onChange }: MaxPerRowSelectorProps) { + return ( +
+ {[1, 2, 3].map((n) => ( + + ))} +
+ ); +} + +// ============ ButtonChip ============ + +interface ButtonChipProps { + button: MenuButtonConfig; + isExpanded: boolean; + onToggleExpand: () => void; + onUpdate: (updates: Partial) => void; + onRemove: () => void; + isBuiltin: boolean; +} + +function ButtonChip({ + button, + isExpanded, + onToggleExpand, + onUpdate, + onRemove, + isBuiltin, +}: ButtonChipProps) { + const { t } = useTranslation(); + + const displayName = + button.labels.ru || + button.labels.en || + (isBuiltin ? t(`admin.buttons.sections.${button.id}`) : button.id); + + const styleOption = STYLE_OPTIONS.find((s) => s.value === button.style); + const colorDotClass = styleOption?.colorClass || 'bg-dark-500'; + + return ( +
+ {/* Collapsed header */} +
+ + + {displayName} + + {!isBuiltin && ( + + + + )} + onUpdate({ enabled: !button.enabled })} /> + + {!isBuiltin && ( + + )} +
+ + {/* Expanded body */} + {isExpanded && ( +
+ {/* Color selector */} +
+ +
+ {STYLE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Emoji ID */} +
+ + onUpdate({ icon_custom_emoji_id: e.target.value })} + placeholder={t('admin.buttons.emojiPlaceholder')} + className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none" + /> +
+ + {/* URL input (custom buttons only) */} + {!isBuiltin && ( +
+ + onUpdate({ url: e.target.value || null })} + placeholder="https://..." + className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none" + /> +
+ )} + + {/* Localized labels */} +
+ +
+ {BOT_LOCALES.map((locale) => ( +
+ + {locale} + + + onUpdate({ + labels: { ...button.labels, [locale]: e.target.value }, + }) + } + placeholder={t('admin.menuEditor.buttonTextPlaceholder')} + maxLength={100} + className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-1.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none" + /> +
+ ))} +

{t('admin.menuEditor.customLabelsHint')}

+
+
+
+ )} +
+ ); +} + +// ============ SortableRow ============ + +interface SortableRowProps { + row: MenuRowConfig; + rowIndex: number; + expandedButtons: Set; + onToggleExpand: (buttonId: string) => void; + onUpdateRow: (rowId: string, updates: Partial) => void; + onRemoveRow: (rowId: string) => void; + onUpdateButton: (rowId: string, buttonId: string, updates: Partial) => void; + onRemoveButton: (rowId: string, buttonId: string) => void; + onAddButtonClick: (rowId: string) => void; +} + +function SortableRow({ + row, + rowIndex, + expandedButtons, + onToggleExpand, + onUpdateRow, + onRemoveRow, + onUpdateButton, + onRemoveButton, + onAddButtonClick, +}: SortableRowProps) { + const { t } = useTranslation(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: row.id, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 50 : undefined, + position: isDragging ? 'relative' : undefined, + }; + + const allBuiltin = row.buttons.every((b) => b.type === 'builtin'); + + return ( +
+ {/* Row header */} +
+ + + {t('admin.menuEditor.row')} {rowIndex + 1} + +
+ onUpdateRow(row.id, { max_per_row: value })} + /> + {!allBuiltin && ( + + )} +
+ + {/* Row body */} +
+ {row.buttons.map((button) => ( + onToggleExpand(button.id)} + onUpdate={(updates) => onUpdateButton(row.id, button.id, updates)} + onRemove={() => onRemoveButton(row.id, button.id)} + isBuiltin={button.type === 'builtin'} + /> + ))} + + {/* Add button */} + +
+
+ ); +} + +// ============ AddButtonDialog ============ + +interface AddButtonDialogProps { + usedBuiltinIds: Set; + onAddBuiltin: (sectionId: string) => void; + onAddCustom: () => void; + onClose: () => void; +} + +function AddButtonDialog({ + usedBuiltinIds, + onAddBuiltin, + onAddCustom, + onClose, +}: AddButtonDialogProps) { + const { t } = useTranslation(); + + const availableBuiltins = BUILTIN_SECTIONS.filter((id) => !usedBuiltinIds.has(id)); + + return ( +
+
e.stopPropagation()} + > +
+

{t('admin.menuEditor.addButton')}

+ +
+ +
+ {/* Available builtins */} + {availableBuiltins.length > 0 && ( + <> +

+ {t('admin.menuEditor.builtinButtons')} +

+ {availableBuiltins.map((id) => ( + + ))} +
+ + )} + + {/* Custom URL button */} + +
+
+
+ ); +} + +// ============ MenuEditorTab ============ + +export function MenuEditorTab() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const notify = useNotify(); + + // Fetch config + const { + data: serverConfig, + isLoading, + isError, + } = useQuery({ + queryKey: ['menu-layout'], + queryFn: menuLayoutApi.getConfig, + }); + + // Draft state + const [draftConfig, setDraftConfig] = useState(DEFAULT_CONFIG); + const [expandedButtons, setExpandedButtons] = useState>(new Set()); + const [addingToRow, setAddingToRow] = useState(null); + const savedConfigRef = useRef(DEFAULT_CONFIG); + const draftConfigRef = useRef(draftConfig); + draftConfigRef.current = draftConfig; + + // Sync server data to draft (same pattern as ButtonsTab) + useEffect(() => { + if (serverConfig) { + if ( + configsEqual(savedConfigRef.current, draftConfigRef.current) || + configsEqual(savedConfigRef.current, DEFAULT_CONFIG) + ) { + setDraftConfig(serverConfig); + savedConfigRef.current = serverConfig; + } + } + }, [serverConfig]); + + const hasUnsavedChanges = !configsEqual(draftConfig, savedConfigRef.current); + + // Mutations + const updateMutation = useMutation({ + mutationFn: menuLayoutApi.updateConfig, + onSuccess: (data) => { + savedConfigRef.current = data; + setDraftConfig(data); + queryClient.setQueryData(['menu-layout'], data); + }, + onError: () => { + notify.error(t('common.error')); + }, + }); + + const resetMutation = useMutation({ + mutationFn: menuLayoutApi.resetConfig, + onSuccess: (data) => { + savedConfigRef.current = data; + setDraftConfig(data); + queryClient.setQueryData(['menu-layout'], data); + }, + onError: () => { + notify.error(t('common.error')); + }, + }); + + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + setDraftConfig((prev) => { + const oldIndex = prev.rows.findIndex((r) => r.id === active.id); + const newIndex = prev.rows.findIndex((r) => r.id === over.id); + if (oldIndex === -1 || newIndex === -1) return prev; + return { ...prev, rows: arrayMove(prev.rows, oldIndex, newIndex) }; + }); + } + }, []); + + // Row CRUD + const updateRow = useCallback((rowId: string, updates: Partial) => { + setDraftConfig((prev) => ({ + ...prev, + rows: prev.rows.map((r) => (r.id === rowId ? { ...r, ...updates } : r)), + })); + }, []); + + const removeRow = useCallback((rowId: string) => { + setDraftConfig((prev) => ({ + ...prev, + rows: prev.rows.filter((r) => r.id !== rowId), + })); + }, []); + + const addRow = useCallback(() => { + setDraftConfig((prev) => ({ + ...prev, + rows: [ + ...prev.rows, + { + id: generateRowId(), + max_per_row: 2, + buttons: [], + }, + ], + })); + }, []); + + // Button CRUD + const updateButton = useCallback( + (rowId: string, buttonId: string, updates: Partial) => { + setDraftConfig((prev) => ({ + ...prev, + rows: prev.rows.map((r) => + r.id === rowId + ? { + ...r, + buttons: r.buttons.map((b) => (b.id === buttonId ? { ...b, ...updates } : b)), + } + : r, + ), + })); + }, + [], + ); + + const removeButton = useCallback((rowId: string, buttonId: string) => { + setDraftConfig((prev) => ({ + ...prev, + rows: prev.rows.map((r) => + r.id === rowId ? { ...r, buttons: r.buttons.filter((b) => b.id !== buttonId) } : r, + ), + })); + }, []); + + const addBuiltinButton = useCallback((rowId: string, sectionId: string) => { + const newButton: MenuButtonConfig = { + id: sectionId, + type: 'builtin', + style: 'default', + icon_custom_emoji_id: '', + enabled: true, + labels: {}, + url: null, + }; + setDraftConfig((prev) => ({ + ...prev, + rows: prev.rows.map((r) => + r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r, + ), + })); + }, []); + + const addCustomButton = useCallback((rowId: string) => { + const newButton: MenuButtonConfig = { + id: generateId(), + type: 'custom', + style: 'default', + icon_custom_emoji_id: '', + enabled: true, + labels: {}, + url: '', + }; + setDraftConfig((prev) => ({ + ...prev, + rows: prev.rows.map((r) => + r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r, + ), + })); + }, []); + + const handleAddButtonClick = useCallback((rowId: string) => { + setAddingToRow(rowId); + }, []); + + // Expand/collapse + const toggleExpand = useCallback((buttonId: string) => { + setExpandedButtons((prev) => { + const next = new Set(prev); + if (next.has(buttonId)) { + next.delete(buttonId); + } else { + next.add(buttonId); + } + return next; + }); + }, []); + + // Collect used builtin IDs across all rows + const usedBuiltinIds = useMemo( + () => + new Set( + draftConfig.rows.flatMap((r) => + r.buttons.filter((b) => b.type === 'builtin').map((b) => b.id), + ), + ), + [draftConfig.rows], + ); + + // Handlers + const handleSave = useCallback(() => { + // Validate custom buttons have valid URLs + const currentDraft = draftConfigRef.current; + for (const row of currentDraft.rows) { + for (const btn of row.buttons) { + if (btn.type === 'custom') { + if (!btn.url || (!btn.url.startsWith('http://') && !btn.url.startsWith('https://'))) { + notify.error(t('admin.menuEditor.invalidUrl')); + return; + } + } + } + } + updateMutation.mutate(currentDraft); + }, [updateMutation, notify, t]); + + const handleCancel = useCallback(() => { + setDraftConfig(savedConfigRef.current); + }, []); + + if (isLoading) { + return ( +
+ + + + + {t('common.loading')} +
+ ); + } + + if (isError) { + return ( +
+ {t('common.error')} +
+ ); + } + + return ( +
+ {/* Drag hint */} +
+ + {t('admin.menuEditor.dragHint')} +
+ + {/* Rows */} + + r.id)} + strategy={verticalListSortingStrategy} + > +
+ {draftConfig.rows.map((row, index) => ( + + ))} +
+
+
+ + {/* Add row */} + + + {/* Save / Cancel */} + {hasUnsavedChanges && ( +
+ + +
+ )} + + {/* Reset */} +
+ +
+ + {/* Add button dialog */} + {addingToRow && ( + addBuiltinButton(addingToRow, sectionId)} + onAddCustom={() => addCustomButton(addingToRow)} + onClose={() => setAddingToRow(null)} + /> + )} +
+ ); +} diff --git a/src/locales/en.json b/src/locales/en.json index 27a0f5e..fcbb9bc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1748,7 +1748,8 @@ "referral": "Referrals", "support": "Support", "info": "Info", - "admin": "Admin Panel" + "admin": "Admin Panel", + "language": "Language" }, "descriptions": { "home": "Personal cabinet button", @@ -1757,7 +1758,8 @@ "referral": "Referral program", "support": "Support tickets", "info": "Information section", - "admin": "Web admin panel" + "admin": "Web admin panel", + "language": "Language selection" }, "styles": { "default": "No color", @@ -1766,6 +1768,19 @@ "danger": "Red" } }, + "menuEditor": { + "dragHint": "Drag rows to reorder them", + "dragToReorder": "Drag to reorder", + "row": "Row", + "addRow": "Add row", + "addButton": "Add button", + "addUrlButton": "URL button (link)", + "builtinButtons": "Built-in buttons", + "resetConfirm": "Reset menu layout to defaults?", + "buttonTextPlaceholder": "Custom button text", + "customLabelsHint": "Empty = default label", + "invalidUrl": "Please provide a valid URL (http:// or https://) for all custom buttons" + }, "apps": { "title": "App Management", "addApp": "Add App", diff --git a/src/locales/fa.json b/src/locales/fa.json index 2004e50..2d335d9 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1410,7 +1410,8 @@ "referral": "معرفی", "support": "پشتیبانی", "info": "اطلاعات", - "admin": "پنل مدیریت" + "admin": "پنل مدیریت", + "language": "زبان" }, "descriptions": { "home": "دکمه کابینت شخصی", @@ -1419,7 +1420,8 @@ "referral": "برنامه معرفی", "support": "تیکت‌های پشتیبانی", "info": "بخش اطلاعات", - "admin": "پنل مدیریت وب" + "admin": "پنل مدیریت وب", + "language": "انتخاب زبان ربات" }, "styles": { "default": "بدون رنگ", @@ -1428,6 +1430,19 @@ "danger": "قرمز" } }, + "menuEditor": { + "dragHint": "ردیف‌ها را بکشید تا ترتیب تغییر کند", + "dragToReorder": "برای مرتب‌سازی بکشید", + "row": "ردیف", + "addRow": "افزودن ردیف", + "addButton": "افزودن دکمه", + "addUrlButton": "دکمه URL (لینک)", + "builtinButtons": "بخش‌های داخلی", + "resetConfirm": "منو به تنظیمات پیش‌فرض بازنشانی شود؟ تمام تغییرات از بین می‌رود.", + "buttonTextPlaceholder": "متن دکمه", + "customLabelsHint": "متن دکمه برای هر زبان ربات", + "invalidUrl": "لطفاً برای تمام دکمه‌های سفارشی یک URL معتبر وارد کنید (http:// یا https://)" + }, "apps": { "title": "مدیریت برنامه‌ها", "addApp": "افزودن برنامه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 0340eb4..85aa4a5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2259,7 +2259,8 @@ "referral": "Рефералы", "support": "Поддержка", "info": "Информация", - "admin": "Админка" + "admin": "Админка", + "language": "Язык" }, "descriptions": { "home": "Кнопка личного кабинета", @@ -2268,7 +2269,8 @@ "referral": "Реферальная программа", "support": "Обращения в поддержку", "info": "Информационный раздел", - "admin": "Веб-админка" + "admin": "Веб-админка", + "language": "Выбор языка" }, "styles": { "default": "Без цвета", @@ -2277,6 +2279,19 @@ "danger": "Красный" } }, + "menuEditor": { + "dragHint": "Перетаскивайте ряды для изменения порядка", + "dragToReorder": "Перетащите для сортировки", + "row": "Ряд", + "addRow": "Добавить ряд", + "addButton": "Добавить кнопку", + "addUrlButton": "URL-кнопка (ссылка)", + "builtinButtons": "Встроенные кнопки", + "resetConfirm": "Сбросить компоновку меню к значениям по умолчанию?", + "buttonTextPlaceholder": "Свой текст кнопки", + "customLabelsHint": "Пустое поле = название по умолчанию", + "invalidUrl": "Укажите корректный URL (http:// или https://) для всех пользовательских кнопок" + }, "apps": { "title": "Управление приложениями", "addApp": "Добавить приложение", diff --git a/src/locales/zh.json b/src/locales/zh.json index 99a20ff..53a7eba 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1448,7 +1448,8 @@ "referral": "推荐", "support": "支持", "info": "信息", - "admin": "管理面板" + "admin": "管理面板", + "language": "语言" }, "descriptions": { "home": "个人中心按钮", @@ -1457,7 +1458,8 @@ "referral": "推荐计划", "support": "支持工单", "info": "信息板块", - "admin": "网页管理面板" + "admin": "网页管理面板", + "language": "机器人语言选择" }, "styles": { "default": "无颜色", @@ -1466,6 +1468,19 @@ "danger": "红色" } }, + "menuEditor": { + "dragHint": "拖拽行以重新排序", + "dragToReorder": "拖拽排序", + "row": "行", + "addRow": "添加行", + "addButton": "添加按钮", + "addUrlButton": "URL按钮(链接)", + "builtinButtons": "内置栏目", + "resetConfirm": "将菜单重置为默认设置?所有更改将丢失。", + "buttonTextPlaceholder": "按钮文本", + "customLabelsHint": "每种语言的按钮文本", + "invalidUrl": "请为所有自定义按钮提供有效的URL(http:// 或 https://)" + }, "apps": { "title": "应用管理", "addApp": "添加应用", diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 9b429ed..7525a96 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -9,7 +9,7 @@ import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin'; import { usePlatform } from '../platform/hooks/usePlatform'; import { AnalyticsTab } from '../components/admin/AnalyticsTab'; import { BrandingTab } from '../components/admin/BrandingTab'; -import { ButtonsTab } from '../components/admin/ButtonsTab'; +import { MenuEditorTab } from '../components/admin/MenuEditorTab'; import { ThemeTab } from '../components/admin/ThemeTab'; import { FavoritesTab } from '../components/admin/FavoritesTab'; import { SettingsTab } from '../components/admin/SettingsTab'; @@ -176,7 +176,7 @@ export default function AdminSettings() { case 'theme': return ; case 'buttons': - return ; + return ; case 'favorites': return (