From d7b387927f7cdad9612f2cf563d048be258178b1 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Wed, 21 Jan 2026 02:52:37 +0300 Subject: [PATCH] Redesign AdminSettings with tabs navigation and favorites - Add tab-based navigation (Favorites, Branding, Theme, Payments, etc.) - Create useFavoriteSettings hook for localStorage-based favorites - Create SettingCard component for consistent setting display - Add global search functionality - Implement collapsible sections for theme presets and colors - Group settings by meta-categories (payments, subscriptions, etc.) - Add dropdown for additional categories (More menu) - Modern UI with improved spacing and interactions --- src/components/admin/SettingCard.tsx | 259 +++ src/hooks/useFavoriteSettings.ts | 49 + src/pages/AdminSettings.tsx | 2452 +++++++++----------------- 3 files changed, 1136 insertions(+), 1624 deletions(-) create mode 100644 src/components/admin/SettingCard.tsx create mode 100644 src/hooks/useFavoriteSettings.ts diff --git a/src/components/admin/SettingCard.tsx b/src/components/admin/SettingCard.tsx new file mode 100644 index 0000000..f2b7f56 --- /dev/null +++ b/src/components/admin/SettingCard.tsx @@ -0,0 +1,259 @@ +import { useState, useCallback } from 'react' + +interface SettingChoice { + value: string + label: string + description?: string +} + +interface SettingDefinition { + key: string + name: string + description?: string + category: string + type: string + is_optional: boolean + current: string | number | boolean | null + original: string | number | boolean | null + has_override: boolean + read_only: boolean + choices?: SettingChoice[] + hint?: string +} + +interface SettingCardProps { + setting: SettingDefinition + translatedName?: string + translatedDescription?: string + isFavorite: boolean + onToggleFavorite: () => void + onUpdate: (value: string) => void + onReset: () => void + disabled?: boolean +} + +// Icons +const StarIcon = ({ filled }: { filled: boolean }) => ( + + + +) + +const RefreshIcon = () => ( + + + +) + +const LockIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +const CloseIcon = () => ( + + + +) + +export function SettingCard({ + setting, + translatedName, + translatedDescription, + isFavorite, + onToggleFavorite, + onUpdate, + onReset, + disabled +}: SettingCardProps) { + const [isEditing, setIsEditing] = useState(false) + const [editValue, setEditValue] = useState('') + + const displayName = translatedName || setting.name || setting.key + const displayDescription = translatedDescription || setting.description + + const handleStartEdit = useCallback(() => { + if (setting.read_only) return + setEditValue(String(setting.current ?? '')) + setIsEditing(true) + }, [setting.current, setting.read_only]) + + const handleSave = useCallback(() => { + onUpdate(editValue) + setIsEditing(false) + }, [editValue, onUpdate]) + + const handleCancel = useCallback(() => { + setIsEditing(false) + setEditValue('') + }, []) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSave() + } else if (e.key === 'Escape') { + handleCancel() + } + }, [handleSave, handleCancel]) + + const handleToggle = useCallback(() => { + if (setting.read_only) return + const newValue = setting.current === true || setting.current === 'true' ? 'false' : 'true' + onUpdate(newValue) + }, [setting.current, setting.read_only, onUpdate]) + + const handleSelectChange = useCallback((e: React.ChangeEvent) => { + onUpdate(e.target.value) + }, [onUpdate]) + + // Render input based on type + const renderInput = () => { + // Read-only display + if (setting.read_only) { + return ( +
+ + {String(setting.current ?? '-')} +
+ ) + } + + // Boolean toggle + if (setting.type === 'bool') { + const isChecked = setting.current === true || setting.current === 'true' + return ( + + ) + } + + // Select dropdown + if (setting.choices && setting.choices.length > 0) { + return ( + + ) + } + + // Text/number input + if (isEditing) { + return ( +
+ setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus + className="bg-dark-700 border border-accent-500 rounded-lg px-3 py-1.5 text-sm text-dark-100 focus:outline-none w-40" + /> + + +
+ ) + } + + return ( + + ) + } + + return ( +
+
+ {/* Left side - info */} +
+
+ {displayName} + {setting.has_override && ( + + Изменено + + )} +
+ {displayDescription && ( +

{displayDescription}

+ )} + {setting.hint && ( +

{setting.hint}

+ )} +
+ + {/* Right side - controls */} +
+ {/* Favorite button */} + + + {/* Input control */} + {renderInput()} + + {/* Reset button */} + {setting.has_override && !setting.read_only && ( + + )} +
+
+
+ ) +} diff --git a/src/hooks/useFavoriteSettings.ts b/src/hooks/useFavoriteSettings.ts new file mode 100644 index 0000000..29731d5 --- /dev/null +++ b/src/hooks/useFavoriteSettings.ts @@ -0,0 +1,49 @@ +import { useState, useEffect, useCallback } from 'react' + +const STORAGE_KEY = 'admin_favorite_settings' + +export function useFavoriteSettings() { + const [favorites, setFavorites] = useState(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY) + return stored ? JSON.parse(stored) : [] + } catch { + return [] + } + }) + + // Sync to localStorage + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites)) + } catch { + // Ignore storage errors + } + }, [favorites]) + + const toggleFavorite = useCallback((settingKey: string) => { + setFavorites(prev => { + if (prev.includes(settingKey)) { + return prev.filter(key => key !== settingKey) + } else { + return [...prev, settingKey] + } + }) + }, []) + + const isFavorite = useCallback((settingKey: string) => { + return favorites.includes(settingKey) + }, [favorites]) + + const clearFavorites = useCallback(() => { + setFavorites([]) + }, []) + + return { + favorites, + toggleFavorite, + isFavorite, + clearFavorites, + count: favorites.length + } +} diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 7b9b78d..ad30119 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -2,78 +2,42 @@ import { useState, useMemo, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' -import { adminSettingsApi, SettingDefinition, SettingCategorySummary } from '../api/adminSettings' +import { adminSettingsApi, SettingDefinition } from '../api/adminSettings' import { brandingApi, setCachedBranding } from '../api/branding' import { setCachedAnimationEnabled } from '../components/AnimatedBackground' import { setCachedFullscreenEnabled } from '../hooks/useTelegramWebApp' import { themeColorsApi } from '../api/themeColors' -import { ThemeColors, DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme' +import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme' import { ColorPicker } from '../components/ColorPicker' import { applyThemeColors } from '../hooks/useThemeColors' import { updateEnabledThemesCache } from '../hooks/useTheme' +import { useFavoriteSettings } from '../hooks/useFavoriteSettings' -// Icons +// ============ ICONS ============ const BackIcon = () => ( - + ) -const CogIcon = () => ( - - - - -) - -const CheckIcon = () => ( - - - -) - -const RefreshIcon = () => ( - - - -) - -const LockIcon = () => ( - - - -) - -const CloseIcon = () => ( - - - -) - -const ChevronRightIcon = () => ( - - - -) - -const ChevronDownIcon = () => ( - - - -) - -const WarningIcon = () => ( - - - -) - const SearchIcon = () => ( ) +const StarIcon = ({ filled }: { filled?: boolean }) => ( + + + +) + +const ChevronDownIcon = () => ( + + + +) + const UploadIcon = () => ( @@ -92,880 +56,30 @@ const PencilIcon = () => ( ) -// Meta-categories for grouping -const META_CATEGORIES: Record = { - payments: { - label: 'Платежные системы', - emoji: '💳', - categories: ['PAYMENT', 'PAYMENT_VERIFICATION', 'YOOKASSA', 'CRYPTOBOT', 'HELEKET', 'PLATEGA', 'TRIBUTE', 'MULENPAY', 'PAL24', 'WATA', 'TELEGRAM'], - }, - subscriptions: { - label: 'Подписки и тарифы', - emoji: '📅', - categories: ['SUBSCRIPTIONS_CORE', 'SIMPLE_SUBSCRIPTION', 'PERIODS', 'SUBSCRIPTION_PRICES', 'TRAFFIC', 'TRAFFIC_PACKAGES', 'TRIAL', 'AUTOPAY'], - }, - interface: { - label: 'Интерфейс', - emoji: '🎨', - categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION', 'CONNECT_BUTTON', 'MINIAPP', 'HAPP', 'SKIP', 'ADDITIONAL'], - }, - notifications: { - label: 'Уведомления', - emoji: '🔔', - categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'], - }, - database: { - label: 'База данных', - emoji: '💾', - categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'], - }, - system: { - label: 'Система', - emoji: '⚙️', - categories: ['CORE', 'REMNAWAVE', 'SERVER_STATUS', 'MONITORING', 'MAINTENANCE', 'BACKUP', 'VERSION', 'WEB_API', 'WEBHOOK', 'LOG', 'DEBUG', 'EXTERNAL_ADMIN'], - }, - users: { - label: 'Пользователи', - emoji: '👥', - categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'], - }, -} +const RefreshIcon = () => ( + + + +) -// Theme presets - predefined color schemes -const THEME_PRESETS: { name: string; colors: ThemeColors }[] = [ - { - name: 'Стандарт', - colors: DEFAULT_THEME_COLORS, - }, - { - name: 'Океан', - colors: { - accent: '#0ea5e9', - darkBackground: '#0c1222', - darkSurface: '#1e293b', - darkText: '#f1f5f9', - darkTextSecondary: '#94a3b8', - lightBackground: '#e0f2fe', - lightSurface: '#f0f9ff', - lightText: '#0c4a6e', - lightTextSecondary: '#0369a1', - success: '#22c55e', - warning: '#f59e0b', - error: '#ef4444', - }, - }, - { - name: 'Лес', - colors: { - accent: '#22c55e', - darkBackground: '#0a1a0f', - darkSurface: '#14532d', - darkText: '#f0fdf4', - darkTextSecondary: '#86efac', - lightBackground: '#dcfce7', - lightSurface: '#f0fdf4', - lightText: '#14532d', - lightTextSecondary: '#166534', - success: '#22c55e', - warning: '#f59e0b', - error: '#ef4444', - }, - }, - { - name: 'Закат', - colors: { - accent: '#f97316', - darkBackground: '#1c1009', - darkSurface: '#2d1a0e', - darkText: '#fff7ed', - darkTextSecondary: '#fdba74', - lightBackground: '#ffedd5', - lightSurface: '#fff7ed', - lightText: '#7c2d12', - lightTextSecondary: '#c2410c', - success: '#22c55e', - warning: '#f59e0b', - error: '#ef4444', - }, - }, - { - name: 'Фиолет', - colors: { - accent: '#a855f7', - darkBackground: '#0f0a1a', - darkSurface: '#1e1b2e', - darkText: '#faf5ff', - darkTextSecondary: '#c4b5fd', - lightBackground: '#f3e8ff', - lightSurface: '#faf5ff', - lightText: '#581c87', - lightTextSecondary: '#7e22ce', - success: '#22c55e', - warning: '#f59e0b', - error: '#ef4444', - }, - }, - { - name: 'Роза', - colors: { - accent: '#f43f5e', - darkBackground: '#1a0a10', - darkSurface: '#2d1520', - darkText: '#fff1f2', - darkTextSecondary: '#fda4af', - lightBackground: '#ffe4e6', - lightSurface: '#fff1f2', - lightText: '#881337', - lightTextSecondary: '#be123c', - success: '#22c55e', - warning: '#f59e0b', - error: '#ef4444', - }, - }, - { - name: 'Полночь', - colors: { - accent: '#6366f1', - darkBackground: '#030712', - darkSurface: '#111827', - darkText: '#f9fafb', - darkTextSecondary: '#9ca3af', - lightBackground: '#e5e7eb', - lightSurface: '#f3f4f6', - lightText: '#111827', - lightTextSecondary: '#4b5563', - success: '#22c55e', - warning: '#f59e0b', - error: '#ef4444', - }, - }, - { - name: 'Бирюза', - colors: { - accent: '#14b8a6', - darkBackground: '#0a1614', - darkSurface: '#134e4a', - darkText: '#f0fdfa', - darkTextSecondary: '#5eead4', - lightBackground: '#ccfbf1', - lightSurface: '#f0fdfa', - lightText: '#134e4a', - lightTextSecondary: '#0f766e', - success: '#22c55e', - warning: '#f59e0b', - error: '#ef4444', - }, - }, -] +const LockIcon = () => ( + + + +) -// Setting translations (name + description) - comprehensive list -const SETTING_TRANSLATIONS: Record = { - // CORE - BOT_USERNAME: { name: 'Username бота', description: 'Имя бота в Telegram' }, - // SUPPORT - SUPPORT_USERNAME: { name: 'Username поддержки', description: 'Telegram @username поддержки' }, - SUPPORT_MENU_ENABLED: { name: 'Меню поддержки', description: 'Показывать раздел поддержки' }, - SUPPORT_SYSTEM_MODE: { name: 'Режим поддержки', description: 'tickets/contact/both' }, - MINIAPP_TICKETS_ENABLED: { name: 'Тикеты в MiniApp', description: 'Включить раздел тикетов в MiniApp' }, - MINIAPP_SUPPORT_TYPE: { name: 'Тип поддержки MiniApp', description: 'tickets - показать тикеты, profile - перейти в профиль бота, url - открыть внешнюю ссылку' }, - MINIAPP_SUPPORT_URL: { name: 'URL поддержки MiniApp', description: 'Внешняя ссылка для поддержки (только для type=url)' }, - SUPPORT_TICKET_SLA_ENABLED: { name: 'SLA', description: 'Контроль времени ответа' }, - SUPPORT_TICKET_SLA_MINUTES: { name: 'SLA (мин)', description: 'Время на ответ' }, - SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: { name: 'Интервал SLA', description: 'Проверка просроченных (сек)' }, - SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: { name: 'Кулдаун SLA', description: 'Мин. между напоминаниями' }, - SUPPORT_TOPUP_ENABLED: { name: 'Пополнение', description: 'Кнопка пополнения в поддержке' }, - // ADMIN NOTIFICATIONS - ADMIN_NOTIFICATIONS_ENABLED: { name: 'Уведомления', description: 'Отправлять админам' }, - ADMIN_NOTIFICATIONS_CHAT_ID: { name: 'Chat ID', description: 'ID чата для уведомлений' }, - ADMIN_NOTIFICATIONS_TOPIC_ID: { name: 'Topic ID', description: 'ID топика' }, - ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: { name: 'Topic тикетов', description: 'ID топика тикетов' }, - // ADMIN REPORTS - ADMIN_REPORTS_ENABLED: { name: 'Отчёты', description: 'Автоматические отчёты' }, - ADMIN_REPORTS_CHAT_ID: { name: 'Chat ID', description: 'ID чата отчётов' }, - ADMIN_REPORTS_TOPIC_ID: { name: 'Topic ID', description: 'ID топика отчётов' }, - ADMIN_REPORTS_SEND_TIME: { name: 'Время', description: 'Время отправки (HH:MM)' }, - // CHANNEL - CHANNEL_SUB_ID: { name: 'ID канала', description: 'Telegram ID канала' }, - CHANNEL_LINK: { name: 'Ссылка', description: 'Ссылка на канал' }, - CHANNEL_IS_REQUIRED_SUB: { name: 'Обязательно', description: 'Требовать подписку' }, - // LOCALIZATION - DEFAULT_LANGUAGE: { name: 'Язык', description: 'Язык по умолчанию' }, - AVAILABLE_LANGUAGES: { name: 'Языки', description: 'Доступные языки' }, - LANGUAGE_SELECTION_ENABLED: { name: 'Выбор языка', description: 'Разрешить выбор' }, - LOCALES_PATH: { name: 'Путь', description: 'Путь к локалям' }, - // TIMEZONE - TIMEZONE: { name: 'Часовой пояс', description: 'Временная зона' }, - // SUBSCRIPTIONS - DEFAULT_DEVICE_LIMIT: { name: 'Устройств', description: 'По умолчанию' }, - DEFAULT_TRAFFIC_LIMIT_GB: { name: 'Трафик (ГБ)', description: '0 = безлимит' }, - MAX_DEVICES_LIMIT: { name: 'Макс. устройств', description: 'Максимум' }, - PRICE_PER_DEVICE: { name: 'Цена/устройство', description: 'Доплата (коп.)' }, - DEVICES_SELECTION_ENABLED: { name: 'Выбор устройств', description: 'Разрешить выбор' }, - DEVICES_SELECTION_DISABLED_AMOUNT: { name: 'При откл. выборе', description: 'Кол-во устройств' }, - BASE_SUBSCRIPTION_PRICE: { name: 'Базовая цена', description: 'Стоимость (коп.)' }, - BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: { name: 'Скидки', description: 'На длинные периоды' }, - BASE_PROMO_GROUP_PERIOD_DISCOUNTS: { name: 'Настройка скидок', description: 'дни:процент' }, - SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: { name: 'Порог баланса', description: 'Для автопродления' }, - // SIMPLE SUBSCRIPTION - SIMPLE_SUBSCRIPTION_ENABLED: { name: 'Быстрая покупка', description: 'Показывать кнопку' }, - SIMPLE_SUBSCRIPTION_PERIOD_DAYS: { name: 'Период (дней)', description: 'Длительность' }, - SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: { name: 'Устройств', description: 'В быстрой покупке' }, - SIMPLE_SUBSCRIPTION_TRAFFIC_GB: { name: 'Трафик (ГБ)', description: '0 = безлимит' }, - SIMPLE_SUBSCRIPTION_SQUAD_UUID: { name: 'UUID сквада', description: 'Пусто = любой' }, - // PERIODS - AVAILABLE_SUBSCRIPTION_PERIODS: { name: 'Периоды покупки', description: 'Дни через запятую' }, - AVAILABLE_RENEWAL_PERIODS: { name: 'Периоды продления', description: 'Дни через запятую' }, - // PRICES - PRICE_14_DAYS: { name: 'Цена 14 дн', description: 'Копейки' }, - PRICE_30_DAYS: { name: 'Цена 30 дн', description: 'Копейки' }, - PRICE_60_DAYS: { name: 'Цена 60 дн', description: 'Копейки' }, - PRICE_90_DAYS: { name: 'Цена 90 дн', description: 'Копейки' }, - PRICE_180_DAYS: { name: 'Цена 180 дн', description: 'Копейки' }, - PRICE_360_DAYS: { name: 'Цена 360 дн', description: 'Копейки' }, - PAID_SUBSCRIPTION_USER_TAG: { name: 'Тег платного', description: 'Тег в RemnaWave' }, - // TRAFFIC - DEFAULT_TRAFFIC_RESET_STRATEGY: { name: 'Сброс трафика', description: 'Стратегия обнуления' }, - RESET_TRAFFIC_ON_PAYMENT: { name: 'Сброс при оплате', description: 'Обнулять трафик' }, - TRAFFIC_SELECTION_MODE: { name: 'Режим выбора', description: 'Как выбирать трафик' }, - FIXED_TRAFFIC_LIMIT_GB: { name: 'Фикс. лимит', description: 'ГБ' }, - TRAFFIC_PACKAGES_CONFIG: { name: 'Пакеты', description: 'JSON конфиг' }, - PRICE_TRAFFIC_5GB: { name: '5 ГБ', description: 'Копейки' }, - PRICE_TRAFFIC_10GB: { name: '10 ГБ', description: 'Копейки' }, - PRICE_TRAFFIC_25GB: { name: '25 ГБ', description: 'Копейки' }, - PRICE_TRAFFIC_50GB: { name: '50 ГБ', description: 'Копейки' }, - PRICE_TRAFFIC_100GB: { name: '100 ГБ', description: 'Копейки' }, - PRICE_TRAFFIC_250GB: { name: '250 ГБ', description: 'Копейки' }, - PRICE_TRAFFIC_500GB: { name: '500 ГБ', description: 'Копейки' }, - PRICE_TRAFFIC_1000GB: { name: '1000 ГБ', description: 'Копейки' }, - PRICE_TRAFFIC_UNLIMITED: { name: 'Безлимит', description: 'Копейки' }, - // TRIAL - TRIAL_DURATION_DAYS: { name: 'Дней триала', description: 'Длительность' }, - TRIAL_TRAFFIC_LIMIT_GB: { name: 'Трафик (ГБ)', description: 'В триале' }, - TRIAL_DEVICE_LIMIT: { name: 'Устройств', description: 'В триале' }, - TRIAL_ADD_REMAINING_DAYS_TO_PAID: { name: 'Перенос дней', description: 'К платной подписке' }, - TRIAL_PAYMENT_ENABLED: { name: 'Платный триал', description: 'Взимать плату' }, - TRIAL_ACTIVATION_PRICE: { name: 'Цена', description: 'Активация (коп.)' }, - TRIAL_USER_TAG: { name: 'Тег триала', description: 'Тег в RemnaWave' }, - TRIAL_WARNING_HOURS: { name: 'Предупреждение', description: 'За X часов' }, - // AUTOPAY - AUTOPAY_WARNING_DAYS: { name: 'Дни уведомлений', description: 'Через запятую' }, - DEFAULT_AUTOPAY_ENABLED: { name: 'Автопродление', description: 'По умолчанию' }, - DEFAULT_AUTOPAY_DAYS_BEFORE: { name: 'Дней до', description: 'Когда продлять' }, - MIN_BALANCE_FOR_AUTOPAY_KOPEKS: { name: 'Мин. баланс', description: 'Для продления (коп.)' }, - // REFERRAL - REFERRAL_PROGRAM_ENABLED: { name: 'Программа', description: 'Включить рефералку' }, - REFERRAL_MINIMUM_TOPUP_KOPEKS: { name: 'Мин. пополнение', description: 'Для бонуса (коп.)' }, - REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: { name: 'Бонус новичку', description: 'Копейки' }, - REFERRAL_INVITER_BONUS_KOPEKS: { name: 'Бонус инвайтеру', description: 'Копейки' }, - REFERRAL_COMMISSION_PERCENT: { name: 'Комиссия %', description: 'От платежей' }, - REFERRAL_NOTIFICATIONS_ENABLED: { name: 'Уведомления', description: 'О бонусах' }, - // NOTIFICATIONS - ENABLE_NOTIFICATIONS: { name: 'Уведомления', description: 'Пользователям' }, - NOTIFICATION_RETRY_ATTEMPTS: { name: 'Попыток', description: 'Отправки' }, - NOTIFICATION_CACHE_HOURS: { name: 'Кэш (ч)', description: 'Часов' }, - MONITORING_LOGS_RETENTION_DAYS: { name: 'Хранить логи', description: 'Дней' }, - // PAYMENT - PAYMENT_SERVICE_NAME: { name: 'Название', description: 'В документах' }, - PAYMENT_BALANCE_DESCRIPTION: { name: 'Описание пополнения', description: 'В чеке' }, - PAYMENT_SUBSCRIPTION_DESCRIPTION: { name: 'Описание подписки', description: 'В чеке' }, - PAYMENT_BALANCE_TEMPLATE: { name: 'Шаблон', description: 'Пополнения' }, - PAYMENT_SUBSCRIPTION_TEMPLATE: { name: 'Шаблон', description: 'Подписки' }, - AUTO_PURCHASE_AFTER_TOPUP_ENABLED: { name: 'Автопокупка', description: 'После пополнения' }, - DISABLE_TOPUP_BUTTONS: { name: 'Скрыть', description: 'Кнопки пополнения' }, - DISABLE_WEB_PAGE_PREVIEW: { name: 'Без превью', description: 'Ссылок' }, - PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: { name: 'Автопроверка', description: 'Платежей' }, - PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: { name: 'Интервал', description: 'Минут' }, - // TELEGRAM STARS - TELEGRAM_STARS_ENABLED: { name: 'Stars', description: 'Принимать оплату' }, - TELEGRAM_STARS_RATE_RUB: { name: 'Курс', description: 'Рублей за Star' }, - // YOOKASSA - YOOKASSA_ENABLED: { name: 'YooKassa', description: 'Включить' }, - YOOKASSA_SHOP_ID: { name: 'Shop ID', description: 'Идентификатор' }, - YOOKASSA_SECRET_KEY: { name: 'Секрет', description: 'Ключ API' }, - YOOKASSA_RETURN_URL: { name: 'URL возврата', description: 'После оплаты' }, - YOOKASSA_DEFAULT_RECEIPT_EMAIL: { name: 'Email чеков', description: 'По умолчанию' }, - YOOKASSA_VAT_CODE: { name: 'НДС', description: 'Код (1-6)' }, - YOOKASSA_SBP_ENABLED: { name: 'СБП', description: 'Оплата СБП' }, - YOOKASSA_PAYMENT_MODE: { name: 'Режим', description: 'Тип платежа' }, - YOOKASSA_PAYMENT_SUBJECT: { name: 'Предмет', description: 'Тип товара' }, - YOOKASSA_WEBHOOK_PATH: { name: 'Путь', description: 'Вебхука' }, - YOOKASSA_WEBHOOK_HOST: { name: 'Хост', description: 'Вебхука' }, - YOOKASSA_WEBHOOK_PORT: { name: 'Порт', description: 'Вебхука' }, - YOOKASSA_MIN_AMOUNT_KOPEKS: { name: 'Мин.', description: 'Копейки' }, - YOOKASSA_MAX_AMOUNT_KOPEKS: { name: 'Макс.', description: 'Копейки' }, - YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: { name: 'Быстрый выбор', description: 'Суммы' }, - // CRYPTOBOT - CRYPTOBOT_ENABLED: { name: 'CryptoBot', description: 'Включить' }, - CRYPTOBOT_API_TOKEN: { name: 'Токен', description: 'API' }, - CRYPTOBOT_WEBHOOK_SECRET: { name: 'Секрет', description: 'Вебхука' }, - CRYPTOBOT_BASE_URL: { name: 'URL', description: 'API' }, - CRYPTOBOT_TESTNET: { name: 'Тестнет', description: 'Тестовая сеть' }, - CRYPTOBOT_WEBHOOK_PATH: { name: 'Путь', description: 'Вебхука' }, - CRYPTOBOT_WEBHOOK_PORT: { name: 'Порт', description: 'Вебхука' }, - CRYPTOBOT_DEFAULT_ASSET: { name: 'Валюта', description: 'По умолчанию' }, - CRYPTOBOT_ASSETS: { name: 'Валюты', description: 'Через запятую' }, - CRYPTOBOT_INVOICE_EXPIRES_HOURS: { name: 'Срок (ч)', description: 'Инвойса' }, - // HELEKET - HELEKET_ENABLED: { name: 'Heleket', description: 'Включить' }, - HELEKET_MERCHANT_ID: { name: 'Merchant ID', description: 'ID мерчанта' }, - HELEKET_API_KEY: { name: 'API Key', description: 'Ключ' }, - HELEKET_BASE_URL: { name: 'URL', description: 'API' }, - HELEKET_DEFAULT_CURRENCY: { name: 'Валюта', description: 'По умолчанию' }, - HELEKET_DEFAULT_NETWORK: { name: 'Сеть', description: 'По умолчанию' }, - HELEKET_INVOICE_LIFETIME: { name: 'Срок (сек)', description: 'Инвойса' }, - HELEKET_MARKUP_PERCENT: { name: 'Наценка %', description: 'Процент' }, - // PLATEGA - PLATEGA_ENABLED: { name: 'Platega', description: 'Включить' }, - PLATEGA_MERCHANT_ID: { name: 'Merchant ID', description: 'ID' }, - PLATEGA_SECRET: { name: 'Секрет', description: 'Ключ' }, - PLATEGA_BASE_URL: { name: 'URL', description: 'API' }, - PLATEGA_RETURN_URL: { name: 'URL возврата', description: 'После оплаты' }, - PLATEGA_FAILED_URL: { name: 'URL ошибки', description: 'При ошибке' }, - PLATEGA_CURRENCY: { name: 'Валюта', description: 'Платежей' }, - PLATEGA_ACTIVE_METHODS: { name: 'Методы', description: 'ID через запятую' }, - PLATEGA_MIN_AMOUNT_KOPEKS: { name: 'Мин.', description: 'Копейки' }, - PLATEGA_MAX_AMOUNT_KOPEKS: { name: 'Макс.', description: 'Копейки' }, - // TRIBUTE - TRIBUTE_ENABLED: { name: 'Tribute', description: 'Включить' }, - TRIBUTE_API_KEY: { name: 'API Key', description: 'Ключ' }, - TRIBUTE_DONATE_LINK: { name: 'Ссылка', description: 'На донат' }, - // MULENPAY - MULENPAY_ENABLED: { name: 'MulenPay', description: 'Включить' }, - MULENPAY_API_KEY: { name: 'API Key', description: 'Ключ' }, - MULENPAY_SECRET_KEY: { name: 'Секрет', description: 'Ключ' }, - MULENPAY_SHOP_ID: { name: 'Shop ID', description: 'ID магазина' }, - MULENPAY_DISPLAY_NAME: { name: 'Название', description: 'Отображаемое' }, - MULENPAY_LANGUAGE: { name: 'Язык', description: 'Интерфейса' }, - MULENPAY_MIN_AMOUNT_KOPEKS: { name: 'Мин.', description: 'Копейки' }, - MULENPAY_MAX_AMOUNT_KOPEKS: { name: 'Макс.', description: 'Копейки' }, - // PAL24 - PAL24_ENABLED: { name: 'PAL24', description: 'Включить' }, - PAL24_API_TOKEN: { name: 'Токен', description: 'API' }, - PAL24_SHOP_ID: { name: 'Shop ID', description: 'ID' }, - PAL24_SIGNATURE_TOKEN: { name: 'Подпись', description: 'Токен' }, - PAL24_MIN_AMOUNT_KOPEKS: { name: 'Мин.', description: 'Копейки' }, - PAL24_MAX_AMOUNT_KOPEKS: { name: 'Макс.', description: 'Копейки' }, - PAL24_SBP_BUTTON_VISIBLE: { name: 'СБП', description: 'Показывать' }, - PAL24_CARD_BUTTON_VISIBLE: { name: 'Карта', description: 'Показывать' }, - // WATA - WATA_ENABLED: { name: 'Wata', description: 'Включить' }, - WATA_ACCESS_TOKEN: { name: 'Токен', description: 'Доступа' }, - WATA_TERMINAL_PUBLIC_ID: { name: 'Terminal ID', description: 'Публичный ID' }, - WATA_PAYMENT_TYPE: { name: 'Тип', description: 'Платежа' }, - WATA_MIN_AMOUNT_KOPEKS: { name: 'Мин.', description: 'Копейки' }, - WATA_MAX_AMOUNT_KOPEKS: { name: 'Макс.', description: 'Копейки' }, - // EXTERNAL ADMIN - EXTERNAL_ADMIN_TOKEN: { name: 'Токен', description: 'Только чтение' }, - EXTERNAL_ADMIN_TOKEN_BOT_ID: { name: 'ID бота', description: 'Связанного' }, - // INTERFACE - MAIN_MENU_MODE: { name: 'Режим меню', description: 'default/text' }, - ENABLE_LOGO_MODE: { name: 'Логотип', description: 'В сообщениях' }, - LOGO_FILE: { name: 'Файл', description: 'Логотипа' }, - HIDE_SUBSCRIPTION_LINK: { name: 'Скрыть ссылку', description: 'На подписку' }, - CONNECT_BUTTON_MODE: { name: 'Подключение', description: 'Режим кнопки' }, - CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: { name: 'Happ', description: 'Кнопка скачивания' }, - // MINIAPP - MINIAPP_CUSTOM_URL: { name: 'URL', description: 'Mini App' }, - MINIAPP_STATIC_PATH: { name: 'Статика', description: 'Путь' }, - MINIAPP_PURCHASE_URL: { name: 'URL покупки', description: 'Страница' }, - MINIAPP_SERVICE_NAME_EN: { name: 'Название EN', description: 'Английское' }, - MINIAPP_SERVICE_NAME_RU: { name: 'Название RU', description: 'Русское' }, - // HAPP - HAPP_DOWNLOAD_LINK_IOS: { name: 'iOS', description: 'Ссылка' }, - HAPP_DOWNLOAD_LINK_ANDROID: { name: 'Android', description: 'Ссылка' }, - HAPP_DOWNLOAD_LINK_MACOS: { name: 'macOS', description: 'Ссылка' }, - HAPP_DOWNLOAD_LINK_WINDOWS: { name: 'Windows', description: 'Ссылка' }, - // SKIP - SKIP_RULES_ACCEPT: { name: 'Пропуск правил', description: 'Не показывать' }, - SKIP_REFERRAL_CODE: { name: 'Пропуск кода', description: 'Реферального' }, - // ADDITIONAL - APP_CONFIG_PATH: { name: 'app-config', description: 'Путь' }, - ENABLE_DEEP_LINKS: { name: 'Deep Links', description: 'Включить' }, - APP_CONFIG_CACHE_TTL: { name: 'TTL кэша', description: 'Секунд' }, - // DATABASE - DATABASE_MODE: { name: 'Режим БД', description: 'auto/postgresql/sqlite' }, - DATABASE_URL: { name: 'URL', description: 'Подключения' }, - POSTGRES_HOST: { name: 'Хост', description: 'PostgreSQL' }, - POSTGRES_PORT: { name: 'Порт', description: 'PostgreSQL' }, - POSTGRES_DB: { name: 'База', description: 'Имя' }, - POSTGRES_USER: { name: 'Пользователь', description: 'Логин' }, - POSTGRES_PASSWORD: { name: 'Пароль', description: 'PostgreSQL' }, - SQLITE_PATH: { name: 'Путь', description: 'SQLite' }, - REDIS_URL: { name: 'URL', description: 'Redis' }, - // REMNAWAVE - REMNAWAVE_API_URL: { name: 'URL панели', description: 'Адрес' }, - REMNAWAVE_API_KEY: { name: 'API Key', description: 'Ключ' }, - REMNAWAVE_SECRET_KEY: { name: 'Секрет', description: 'Ключ' }, - REMNAWAVE_USERNAME: { name: 'Логин', description: 'Basic auth' }, - REMNAWAVE_PASSWORD: { name: 'Пароль', description: 'Basic auth' }, - REMNAWAVE_AUTH_TYPE: { name: 'Тип авторизации', description: 'api_key/basic_auth' }, - REMNAWAVE_USER_DESCRIPTION_TEMPLATE: { name: 'Шаблон описания', description: 'Пользователя' }, - REMNAWAVE_USER_USERNAME_TEMPLATE: { name: 'Шаблон username', description: 'Пользователя' }, - REMNAWAVE_USER_DELETE_MODE: { name: 'Режим удаления', description: 'delete/disable' }, - REMNAWAVE_AUTO_SYNC_ENABLED: { name: 'Автосинхр.', description: 'Включить' }, - REMNAWAVE_AUTO_SYNC_TIMES: { name: 'Время', description: 'Синхронизации (HH:MM)' }, - // SERVER STATUS - SERVER_STATUS_MODE: { name: 'Режим', description: 'Статуса серверов' }, - SERVER_STATUS_EXTERNAL_URL: { name: 'URL', description: 'Внешний' }, - SERVER_STATUS_METRICS_URL: { name: 'Метрики', description: 'URL' }, - // MONITORING - MONITORING_INTERVAL: { name: 'Интервал', description: 'Секунд' }, - TRAFFIC_MONITORING_ENABLED: { name: 'Мониторинг', description: 'Трафика' }, - TRAFFIC_THRESHOLD_GB_PER_DAY: { name: 'Порог', description: 'ГБ/день' }, - // MAINTENANCE - MAINTENANCE_MODE: { name: 'Обслуживание', description: 'Включить' }, - MAINTENANCE_MESSAGE: { name: 'Сообщение', description: 'Текст' }, - MAINTENANCE_CHECK_INTERVAL: { name: 'Интервал', description: 'Секунд' }, - MAINTENANCE_AUTO_ENABLE: { name: 'Авто', description: 'Включение' }, - MAINTENANCE_MONITORING_ENABLED: { name: 'Мониторинг', description: 'Панели' }, - MAINTENANCE_RETRY_ATTEMPTS: { name: 'Попыток', description: 'Перед включением' }, - INACTIVE_USER_DELETE_MONTHS: { name: 'Удаление', description: 'Через X месяцев' }, - // BACKUP - BACKUP_AUTO_ENABLED: { name: 'Автобэкап', description: 'Включить' }, - BACKUP_INTERVAL_HOURS: { name: 'Интервал', description: 'Часов' }, - BACKUP_TIME: { name: 'Время', description: 'Создания' }, - BACKUP_MAX_KEEP: { name: 'Хранить', description: 'Копий' }, - BACKUP_COMPRESSION: { name: 'Сжатие', description: 'Включить' }, - BACKUP_INCLUDE_LOGS: { name: 'Логи', description: 'Включать' }, - BACKUP_LOCATION: { name: 'Путь', description: 'Сохранения' }, - BACKUP_SEND_ENABLED: { name: 'Отправка', description: 'В Telegram' }, - BACKUP_SEND_CHAT_ID: { name: 'Chat ID', description: 'Для отправки' }, - // VERSION - VERSION_CHECK_ENABLED: { name: 'Проверка', description: 'Обновлений' }, - VERSION_CHECK_REPO: { name: 'Репозиторий', description: 'GitHub' }, - VERSION_CHECK_INTERVAL_HOURS: { name: 'Интервал', description: 'Часов' }, - // WEB API - WEB_API_ENABLED: { name: 'Web API', description: 'Включить' }, - WEB_API_HOST: { name: 'Хост', description: 'IP' }, - WEB_API_PORT: { name: 'Порт', description: 'API' }, - WEB_API_WORKERS: { name: 'Воркеров', description: 'Количество' }, - WEB_API_ALLOWED_ORIGINS: { name: 'CORS', description: 'Origins' }, - WEB_API_DOCS_ENABLED: { name: 'Документация', description: 'Swagger' }, - WEB_API_DEFAULT_TOKEN: { name: 'Токен', description: 'По умолчанию' }, - WEB_API_REQUEST_LOGGING: { name: 'Логирование', description: 'Запросов' }, - // WEBHOOK - WEBHOOK_URL: { name: 'URL', description: 'Вебхука' }, - WEBHOOK_PATH: { name: 'Путь', description: 'Вебхука' }, - WEBHOOK_SECRET_TOKEN: { name: 'Секрет', description: 'Токен' }, - BOT_RUN_MODE: { name: 'Режим', description: 'polling/webhook' }, - // LOG - LOG_LEVEL: { name: 'Уровень', description: 'Логирования' }, - LOG_FILE: { name: 'Файл', description: 'Логов' }, - // DEBUG - DEBUG: { name: 'Отладка', description: 'Включить' }, - // MODERATION - DISPLAY_NAME_BANNED_KEYWORDS: { name: 'Запрещённые', description: 'Слова в именах' }, - // CONTESTS - CONTESTS_ENABLED: { name: 'Конкурсы', description: 'Включить' }, - CONTESTS_BUTTON_VISIBLE: { name: 'Кнопка', description: 'Конкурсов' }, - // CABINET - CABINET_ENABLED: { name: 'Кабинет', description: 'Веб-кабинет' }, - CABINET_JWT_SECRET: { name: 'JWT секрет', description: 'Ключ' }, -} +const CheckIcon = () => ( + + + +) -// Extract emoji from label -const extractEmoji = (label: string): { emoji: string; text: string } => { - const emojiMatch = label.match(/^(\p{Emoji})/u) - if (emojiMatch) { - return { emoji: emojiMatch[1], text: label.slice(emojiMatch[1].length).trim() } - } - return { emoji: '⚙️', text: label } -} +const CloseIcon = () => ( + + + +) -// Get translated setting name and description -const getSettingTranslation = (key: string, originalName: string) => { - const translation = SETTING_TRANSLATIONS[key] - return { - name: translation?.name || originalName, - description: translation?.description || '', - } -} - -interface SettingRowProps { - setting: SettingDefinition - onUpdate: (key: string, value: unknown) => void - onReset: (key: string) => void - isUpdating: boolean -} - -function SettingRow({ setting, onUpdate, onReset, isUpdating }: SettingRowProps) { - const { t } = useTranslation() - const [editValue, setEditValue] = useState( - setting.current !== null && setting.current !== undefined ? String(setting.current) : '' - ) - const [isEditing, setIsEditing] = useState(false) - - const translation = getSettingTranslation(setting.key, setting.name) - - const handleSave = () => { - let valueToSave: unknown = editValue - - if (setting.type === 'bool') { - valueToSave = editValue === 'true' - } else if (setting.type === 'int') { - valueToSave = parseInt(editValue, 10) - } else if (setting.type === 'float') { - valueToSave = parseFloat(editValue) - } - - onUpdate(setting.key, valueToSave) - setIsEditing(false) - } - - const handleReset = () => { - onReset(setting.key) - setEditValue(setting.original !== null && setting.original !== undefined ? String(setting.original) : '') - } - - const formatValue = (value: unknown): string => { - if (value === null || value === undefined) return '-' - if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no') - return String(value) - } - - const renderInput = () => { - if (setting.read_only) { - return ( -
- - {formatValue(setting.current)} -
- ) - } - - if (setting.choices && setting.choices.length > 0) { - return ( - - ) - } - - if (setting.type === 'bool') { - return ( - - ) - } - - if (isEditing) { - return ( -
- setEditValue(e.target.value)} - className="input py-1.5 text-sm flex-1" - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter') handleSave() - if (e.key === 'Escape') { - setIsEditing(false) - setEditValue(String(setting.current ?? '')) - } - }} - /> - -
- ) - } - - return ( - - ) - } - - return ( -
-
-
-
- {translation.name} - {setting.has_override && ( - - {t('admin.settings.modified')} - - )} - {setting.read_only && ( - - {t('admin.settings.readOnly')} - - )} -
- {translation.description && ( -

{translation.description}

- )} -
- - {setting.has_override && !setting.read_only && ( - - )} -
- -
{renderInput()}
- - {/* Warning from hint */} - {setting.hint?.warning && ( -
- -

{setting.hint.warning}

-
- )} -
- ) -} - -interface MetaCategoryCardProps { - metaKey: string - meta: { label: string; emoji: string; categories: string[] } - categoriesData: SettingCategorySummary[] - settingsCount: number - onClick: () => void -} - -function MetaCategoryCard({ meta, categoriesData, settingsCount, onClick }: MetaCategoryCardProps) { - return ( - - ) -} - -interface SettingsModalProps { - metaKey: string - meta: { label: string; emoji: string; categories: string[] } - categories: SettingCategorySummary[] - allSettings: SettingDefinition[] - onClose: () => void - onUpdate: (key: string, value: unknown) => void - onReset: (key: string) => void - isUpdating: boolean -} - -function SettingsModal({ meta, categories, allSettings, onClose, onUpdate, onReset, isUpdating }: SettingsModalProps) { - const { t } = useTranslation() - const [expandedCategories, setExpandedCategories] = useState>(new Set([categories[0]?.key])) - const [searchQuery, setSearchQuery] = useState('') - - const toggleCategory = (key: string) => { - setExpandedCategories((prev) => { - const next = new Set(prev) - if (next.has(key)) { - next.delete(key) - } else { - next.add(key) - } - return next - }) - } - - // Group settings by category - const settingsByCategory = useMemo(() => { - const result: Record = {} - for (const category of categories) { - const categorySettings = allSettings.filter((s) => s.category.key === category.key) - - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase() - const filtered = categorySettings.filter((s) => { - const translation = getSettingTranslation(s.key, s.name) - return ( - translation.name.toLowerCase().includes(query) || - translation.description.toLowerCase().includes(query) || - s.key.toLowerCase().includes(query) || - String(s.current).toLowerCase().includes(query) - ) - }) - if (filtered.length > 0) { - result[category.key] = filtered - } - } else { - if (categorySettings.length > 0) { - result[category.key] = categorySettings - } - } - } - return result - }, [categories, allSettings, searchQuery]) - - // Auto-expand when searching - useMemo(() => { - if (searchQuery) { - setExpandedCategories(new Set(Object.keys(settingsByCategory))) - } - }, [searchQuery, settingsByCategory]) - - const totalFiltered = Object.values(settingsByCategory).reduce((acc, arr) => acc + arr.length, 0) - - return ( -
-
- -
- {/* Header */} -
-
- {meta.emoji} -
-

{meta.label}

-

{totalFiltered} настроек

-
-
- -
- - {/* Search */} -
-
- setSearchQuery(e.target.value)} - placeholder={t('admin.settings.searchPlaceholder')} - className="input w-full pl-10 py-2" - /> -
- -
-
-
- - {/* Categories with settings */} -
- {Object.keys(settingsByCategory).length > 0 ? ( - categories - .filter((cat) => settingsByCategory[cat.key]) - .map((category) => { - const { emoji, text } = extractEmoji(category.label) - const isExpanded = expandedCategories.has(category.key) - const categorySettings = settingsByCategory[category.key] || [] - - return ( -
- - - {isExpanded && ( -
- {categorySettings.map((setting) => ( - - ))} -
- )} -
- ) - }) - ) : ( -
- {t('admin.settings.noSearchResults')} -
- )} -
- - {/* Footer */} -
- -
-
-
- ) -} - -// Icons for theme toggle const SunIcon = () => ( @@ -978,18 +92,222 @@ const MoonIcon = () => ( ) +// ============ TAB DEFINITIONS ============ +const TABS = [ + { id: 'favorites', label: 'Избранное', icon: StarIcon }, + { id: 'branding', label: 'Брендинг', icon: null }, + { id: 'theme', label: 'Тема', icon: null }, + { id: 'payments', label: 'Платежи', icon: null }, + { id: 'subscriptions', label: 'Подписки', icon: null }, + { id: 'interface', label: 'Интерфейс', icon: null }, + { id: 'more', label: 'Ещё', icon: ChevronDownIcon, isDropdown: true }, +] as const + +const MORE_TABS = [ + { id: 'notifications', label: 'Уведомления' }, + { id: 'database', label: 'База данных' }, + { id: 'system', label: 'Система' }, + { id: 'users', label: 'Пользователи' }, +] as const + +// ============ META CATEGORIES ============ +const META_CATEGORIES: Record = { + payments: { + label: 'Платежные системы', + categories: ['PAYMENT', 'PAYMENT_VERIFICATION', 'YOOKASSA', 'CRYPTOBOT', 'HELEKET', 'PLATEGA', 'TRIBUTE', 'MULENPAY', 'PAL24', 'WATA', 'TELEGRAM'], + }, + subscriptions: { + label: 'Подписки и тарифы', + categories: ['SUBSCRIPTIONS_CORE', 'SIMPLE_SUBSCRIPTION', 'PERIODS', 'SUBSCRIPTION_PRICES', 'TRAFFIC', 'TRAFFIC_PACKAGES', 'TRIAL', 'AUTOPAY'], + }, + interface: { + label: 'Интерфейс', + categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION', 'CONNECT_BUTTON', 'MINIAPP', 'HAPP', 'SKIP', 'ADDITIONAL'], + }, + notifications: { + label: 'Уведомления', + categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'], + }, + database: { + label: 'База данных', + categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'], + }, + system: { + label: 'Система', + categories: ['CORE', 'REMNAWAVE', 'SERVER_STATUS', 'MONITORING', 'MAINTENANCE', 'BACKUP', 'VERSION', 'WEB_API', 'WEBHOOK', 'LOG', 'DEBUG', 'EXTERNAL_ADMIN'], + }, + users: { + label: 'Пользователи', + categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'], + }, +} + +// ============ THEME PRESETS ============ +const THEME_PRESETS: { name: string; colors: ThemeColors }[] = [ + { name: 'Стандарт', colors: DEFAULT_THEME_COLORS }, + { name: 'Океан', colors: { accent: '#0ea5e9', darkBackground: '#0c1222', darkSurface: '#1e293b', darkText: '#f1f5f9', darkTextSecondary: '#94a3b8', lightBackground: '#e0f2fe', lightSurface: '#f0f9ff', lightText: '#0c4a6e', lightTextSecondary: '#0369a1', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, + { name: 'Лес', colors: { accent: '#22c55e', darkBackground: '#0a1a0f', darkSurface: '#14532d', darkText: '#f0fdf4', darkTextSecondary: '#86efac', lightBackground: '#dcfce7', lightSurface: '#f0fdf4', lightText: '#14532d', lightTextSecondary: '#166534', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, + { name: 'Закат', colors: { accent: '#f97316', darkBackground: '#1c1009', darkSurface: '#2d1a0e', darkText: '#fff7ed', darkTextSecondary: '#fdba74', lightBackground: '#ffedd5', lightSurface: '#fff7ed', lightText: '#7c2d12', lightTextSecondary: '#c2410c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, + { name: 'Фиолет', colors: { accent: '#a855f7', darkBackground: '#0f0a1a', darkSurface: '#1e1b2e', darkText: '#faf5ff', darkTextSecondary: '#c4b5fd', lightBackground: '#f3e8ff', lightSurface: '#faf5ff', lightText: '#581c87', lightTextSecondary: '#7e22ce', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, + { name: 'Роза', colors: { accent: '#f43f5e', darkBackground: '#1a0a10', darkSurface: '#2d1520', darkText: '#fff1f2', darkTextSecondary: '#fda4af', lightBackground: '#ffe4e6', lightSurface: '#fff1f2', lightText: '#881337', lightTextSecondary: '#be123c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, + { name: 'Полночь', colors: { accent: '#6366f1', darkBackground: '#030712', darkSurface: '#111827', darkText: '#f9fafb', darkTextSecondary: '#9ca3af', lightBackground: '#e5e7eb', lightSurface: '#f3f4f6', lightText: '#111827', lightTextSecondary: '#4b5563', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, + { name: 'Бирюза', colors: { accent: '#14b8a6', darkBackground: '#0a1614', darkSurface: '#134e4a', darkText: '#f0fdfa', darkTextSecondary: '#5eead4', lightBackground: '#ccfbf1', lightSurface: '#f0fdfa', lightText: '#134e4a', lightTextSecondary: '#0f766e', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, +] + +// ============ SETTING TRANSLATIONS ============ +const SETTING_TRANSLATIONS: Record = { + BOT_USERNAME: { name: 'Username бота', description: 'Имя бота в Telegram' }, + SUPPORT_USERNAME: { name: 'Username поддержки', description: 'Telegram @username поддержки' }, + SUPPORT_MENU_ENABLED: { name: 'Меню поддержки', description: 'Показывать раздел поддержки' }, + DEFAULT_LANGUAGE: { name: 'Язык по умолчанию', description: 'Язык интерфейса' }, + TIMEZONE: { name: 'Часовой пояс', description: 'Временная зона системы' }, + REFERRAL_PROGRAM_ENABLED: { name: 'Реферальная программа', description: 'Включить рефералку' }, + YOOKASSA_ENABLED: { name: 'YooKassa', description: 'Приём платежей через YooKassa' }, + CRYPTOBOT_ENABLED: { name: 'CryptoBot', description: 'Приём криптовалюты' }, + TELEGRAM_STARS_ENABLED: { name: 'Telegram Stars', description: 'Оплата звёздами' }, + TRIAL_DURATION_DAYS: { name: 'Триал (дней)', description: 'Длительность пробного периода' }, + DEFAULT_DEVICE_LIMIT: { name: 'Устройств по умолчанию', description: 'Лимит устройств' }, + // Add more as needed... +} + +// ============ MAIN COMPONENT ============ export default function AdminSettings() { const { t } = useTranslation() const queryClient = useQueryClient() - const [selectedMeta, setSelectedMeta] = useState(null) - const [searchQuery, setSearchQuery] = useState('') - const [editingName, setEditingName] = useState(false) - const [newName, setNewName] = useState('') - const [expandedThemeSections, setExpandedThemeSections] = useState>(new Set()) const fileInputRef = useRef(null) - const toggleThemeSection = (section: string) => { - setExpandedThemeSections(prev => { + // State + const [activeTab, setActiveTab] = useState('branding') + const [searchQuery, setSearchQuery] = useState('') + const [showMoreDropdown, setShowMoreDropdown] = useState(false) + const [editingName, setEditingName] = useState(false) + const [newName, setNewName] = useState('') + const [expandedSections, setExpandedSections] = useState>(new Set(['presets'])) + + // Favorites hook + const { favorites, toggleFavorite, isFavorite } = useFavoriteSettings() + + // ============ QUERIES ============ + const { data: branding } = useQuery({ + queryKey: ['branding'], + queryFn: brandingApi.getBranding, + }) + + const { data: animationSettings } = useQuery({ + queryKey: ['animation-enabled'], + queryFn: brandingApi.getAnimationEnabled, + }) + + const { data: fullscreenSettings } = useQuery({ + queryKey: ['fullscreen-enabled'], + queryFn: brandingApi.getFullscreenEnabled, + }) + + const { data: themeColors } = useQuery({ + queryKey: ['theme-colors'], + queryFn: themeColorsApi.getColors, + }) + + const { data: enabledThemes } = useQuery({ + queryKey: ['enabled-themes'], + queryFn: themeColorsApi.getEnabledThemes, + }) + + const { data: allSettings } = useQuery({ + queryKey: ['admin-settings'], + queryFn: () => adminSettingsApi.getSettings(), + }) + + // ============ MUTATIONS ============ + const updateBrandingMutation = useMutation({ + mutationFn: brandingApi.updateName, + onSuccess: (data) => { + setCachedBranding(data) + queryClient.invalidateQueries({ queryKey: ['branding'] }) + setEditingName(false) + }, + }) + + const uploadLogoMutation = useMutation({ + mutationFn: brandingApi.uploadLogo, + onSuccess: (data) => { + setCachedBranding(data) + queryClient.invalidateQueries({ queryKey: ['branding'] }) + }, + }) + + const deleteLogoMutation = useMutation({ + mutationFn: brandingApi.deleteLogo, + onSuccess: (data) => { + setCachedBranding(data) + queryClient.invalidateQueries({ queryKey: ['branding'] }) + }, + }) + + const updateAnimationMutation = useMutation({ + mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled), + onSuccess: (data) => { + setCachedAnimationEnabled(data.enabled) + queryClient.invalidateQueries({ queryKey: ['animation-enabled'] }) + }, + }) + + const updateFullscreenMutation = useMutation({ + mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled), + onSuccess: (data) => { + setCachedFullscreenEnabled(data.enabled) + queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] }) + }, + }) + + const updateColorsMutation = useMutation({ + mutationFn: themeColorsApi.updateColors, + onSuccess: (data) => { + applyThemeColors(data) + queryClient.invalidateQueries({ queryKey: ['theme-colors'] }) + }, + }) + + const resetColorsMutation = useMutation({ + mutationFn: themeColorsApi.resetColors, + onSuccess: (data) => { + applyThemeColors(data) + queryClient.invalidateQueries({ queryKey: ['theme-colors'] }) + }, + }) + + const updateEnabledThemesMutation = useMutation({ + mutationFn: themeColorsApi.updateEnabledThemes, + onSuccess: (data) => { + updateEnabledThemesCache(data) + queryClient.invalidateQueries({ queryKey: ['enabled-themes'] }) + }, + }) + + const updateSettingMutation = useMutation({ + mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) + }, + }) + + const resetSettingMutation = useMutation({ + mutationFn: (key: string) => adminSettingsApi.resetSetting(key), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) + }, + }) + + // ============ HANDLERS ============ + const handleLogoUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + uploadLogoMutation.mutate(file) + } + } + + const toggleSection = (section: string) => { + setExpandedSections(prev => { const next = new Set(prev) if (next.has(section)) { next.delete(section) @@ -1000,786 +318,672 @@ export default function AdminSettings() { }) } - // Branding query and mutations - const { data: branding } = useQuery({ - queryKey: ['branding'], - queryFn: brandingApi.getBranding, - }) + // Filter settings by tab + const filteredSettings = useMemo(() => { + if (!allSettings || !Array.isArray(allSettings)) return [] - // Animation toggle query and mutation - const { data: animationSettings } = useQuery({ - queryKey: ['animation-enabled'], - queryFn: brandingApi.getAnimationEnabled, - }) + const metaCategories = META_CATEGORIES[activeTab]?.categories || [] + let filtered = allSettings.filter((s: SettingDefinition) => metaCategories.includes(s.category.key)) - const updateAnimationMutation = useMutation({ - mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled), - onSuccess: (data) => { - // Update local cache immediately for instant effect - setCachedAnimationEnabled(data.enabled) - queryClient.invalidateQueries({ queryKey: ['animation-enabled'] }) - }, - }) - - // Fullscreen toggle query and mutation - const { data: fullscreenSettings } = useQuery({ - queryKey: ['fullscreen-enabled'], - queryFn: brandingApi.getFullscreenEnabled, - }) - - const updateFullscreenMutation = useMutation({ - mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled), - onSuccess: (data) => { - // Update local cache immediately for instant effect - setCachedFullscreenEnabled(data.enabled) - queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] }) - }, - }) - - const updateNameMutation = useMutation({ - mutationFn: (name: string) => brandingApi.updateName(name), - onSuccess: (data) => { - setCachedBranding(data) // Update cache immediately - queryClient.invalidateQueries({ queryKey: ['branding'] }) - setEditingName(false) - }, - }) - - const uploadLogoMutation = useMutation({ - mutationFn: (file: File) => brandingApi.uploadLogo(file), - onSuccess: (data) => { - setCachedBranding(data) // Update cache immediately - queryClient.invalidateQueries({ queryKey: ['branding'] }) - }, - }) - - const deleteLogoMutation = useMutation({ - mutationFn: () => brandingApi.deleteLogo(), - onSuccess: (data) => { - setCachedBranding(data) // Update cache immediately - queryClient.invalidateQueries({ queryKey: ['branding'] }) - }, - }) - - // Theme colors query and mutations - const { data: themeColors } = useQuery({ - queryKey: ['theme-colors'], - queryFn: themeColorsApi.getColors, - }) - - const updateColorsMutation = useMutation({ - mutationFn: themeColorsApi.updateColors, - onSuccess: (data) => { - // Apply colors immediately after successful update - applyThemeColors(data) - queryClient.invalidateQueries({ queryKey: ['theme-colors'] }) - }, - }) - - const resetColorsMutation = useMutation({ - mutationFn: themeColorsApi.resetColors, - onSuccess: (data) => { - // Apply default colors immediately - applyThemeColors(data) - queryClient.invalidateQueries({ queryKey: ['theme-colors'] }) - }, - }) - - // Enabled themes query and mutation - const { data: enabledThemes } = useQuery({ - queryKey: ['enabled-themes'], - queryFn: themeColorsApi.getEnabledThemes, - }) - - const updateEnabledThemesMutation = useMutation({ - mutationFn: themeColorsApi.updateEnabledThemes, - onSuccess: (data) => { - // Update localStorage cache so useTheme hook picks up changes immediately - updateEnabledThemesCache(data) - queryClient.invalidateQueries({ queryKey: ['enabled-themes'] }) - }, - }) - - const handleLogoUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - uploadLogoMutation.mutate(file) - } - // Reset input so same file can be uploaded again - e.target.value = '' - } - - const handleNameSave = () => { - // Allow empty name (logo-only mode) - updateNameMutation.mutate(newName.trim()) - } - - const startEditingName = () => { - setNewName(branding?.name || '') - setEditingName(true) - } - - const { data: categories, isLoading: categoriesLoading } = useQuery({ - queryKey: ['admin-settings-categories'], - queryFn: adminSettingsApi.getCategories, - }) - - const { data: allSettings, isLoading: settingsLoading } = useQuery({ - queryKey: ['admin-settings'], - queryFn: () => adminSettingsApi.getSettings(), - }) - - const updateMutation = useMutation({ - mutationFn: ({ key, value }: { key: string; value: unknown }) => - adminSettingsApi.updateSetting(key, value), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) - }, - }) - - const resetMutation = useMutation({ - mutationFn: (key: string) => adminSettingsApi.resetSetting(key), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) - }, - }) - - const handleUpdate = (key: string, value: unknown) => { - updateMutation.mutate({ key, value }) - } - - const handleReset = (key: string) => { - resetMutation.mutate(key) - } - - // Group categories by meta-category - const metaCategoriesData = useMemo(() => { - if (!categories || !allSettings) return null - - const result: Record = {} - - for (const [metaKey, meta] of Object.entries(META_CATEGORIES)) { - const metaCategories = categories.filter((c) => meta.categories.includes(c.key)) - const settingsCount = allSettings.filter((s) => meta.categories.includes(s.category.key)).length - - if (metaCategories.length > 0) { - result[metaKey] = { categories: metaCategories, settingsCount } - } + if (searchQuery) { + const q = searchQuery.toLowerCase() + filtered = filtered.filter((s: SettingDefinition) => + s.key.toLowerCase().includes(q) || + s.name?.toLowerCase().includes(q) || + SETTING_TRANSLATIONS[s.key]?.name.toLowerCase().includes(q) + ) } - // Add "Other" for uncategorized - const allMetaCategories = Object.values(META_CATEGORIES).flatMap((m) => m.categories) - const otherCategories = categories.filter((c) => !allMetaCategories.includes(c.key)) - if (otherCategories.length > 0) { - const settingsCount = allSettings.filter( - (s) => !allMetaCategories.includes(s.category.key) - ).length - result['other'] = { categories: otherCategories, settingsCount } - } + return filtered + }, [allSettings, activeTab, searchQuery]) - return result - }, [categories, allSettings]) + // Favorite settings + const favoriteSettings = useMemo(() => { + if (!allSettings || !Array.isArray(allSettings)) return [] + return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key)) + }, [allSettings, favorites]) - // Filter meta-categories by search - const filteredMetaCategories = useMemo(() => { - if (!metaCategoriesData) return null - if (!searchQuery.trim()) return metaCategoriesData + // ============ RENDER HELPERS ============ + const renderToggle = (checked: boolean, onChange: () => void, disabled?: boolean) => ( + + ) - const query = searchQuery.toLowerCase() - const result: Record = {} + const renderSettingRow = (setting: SettingDefinition) => { + const trans = SETTING_TRANSLATIONS[setting.key] + const name = trans?.name || setting.name || setting.key + const desc = trans?.description || setting.hint?.description + const isFav = isFavorite(setting.key) - for (const [metaKey, data] of Object.entries(metaCategoriesData)) { - const meta = META_CATEGORIES[metaKey] || { label: 'Другое', emoji: '📁', categories: [] } - if ( - meta.label.toLowerCase().includes(query) || - data.categories.some((c) => c.label.toLowerCase().includes(query)) - ) { - result[metaKey] = data - } - } - - return result - }, [metaCategoriesData, searchQuery]) - - if (categoriesLoading || settingsLoading) { return ( -
-
+
+
+
+ {name} + {setting.has_override && ( + + Изменено + + )} +
+ {desc &&

{desc}

} +
+ +
+ {/* Favorite button */} + + + {/* Setting control */} + {setting.read_only ? ( +
+ + {String(setting.current ?? '-')} +
+ ) : setting.type === 'bool' ? ( + renderToggle( + setting.current === true || setting.current === 'true', + () => updateSettingMutation.mutate({ + key: setting.key, + value: setting.current === true || setting.current === 'true' ? 'false' : 'true' + }), + updateSettingMutation.isPending + ) + ) : ( + updateSettingMutation.mutate({ key: setting.key, value })} + disabled={updateSettingMutation.isPending} + /> + )} + + {/* Reset button */} + {setting.has_override && !setting.read_only && ( + + )} +
) } - const totalSettings = allSettings?.length || 0 - - return ( + // ============ TAB CONTENT ============ + const renderBrandingTab = () => (
- {/* Header */} -
- - - -
-

{t('admin.settings.title')}

-

- {Object.keys(filteredMetaCategories || {}).length} групп / {totalSettings} настроек -

-
-
+ {/* Logo & Name */} +
+

Логотип и название

- {/* Branding Card */} -
-
- 🎨 -

Брендинг

-
- -
- {/* Logo Section */} -
-
- {branding?.has_custom_logo && branding.logo_url ? ( - Логотип +
+ {/* Logo */} +
+
+ {branding?.has_custom_logo ? ( + Logo ) : ( - - {branding?.logo_letter || 'C'} - + branding?.logo_letter || 'V' )}
-
+
{branding?.has_custom_logo && ( )}
-

PNG, JPG, WebP, SVG до 2МБ

- {/* Name Section */} + {/* Name */}
- + {editingName ? (
setNewName(e.target.value)} - className="input flex-1" - placeholder="Оставьте пустым для режима только логотип" + className="flex-1 px-4 py-2 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 focus:outline-none focus:border-accent-500" + placeholder="Название" maxLength={50} - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter') handleNameSave() - if (e.key === 'Escape') setEditingName(false) - }} />
) : (
- {branding?.name ? ( - - {branding.name} - - ) : ( - - Только логотип - - )} + {branding?.name || 'Не указано'} - {branding?.name && ( - - )}
)} -

- Оставьте пустым, чтобы показывать только логотип -

-
-
- - {/* Animation Toggle */} -
-
-
-

Анимированный фон

-

- Волновая анимация на фоне для всех пользователей -

-
- -
-
- - {/* Fullscreen Toggle */} -
-
-
-

Авто-Fullscreen

-

- Автоматически открывать на полный экран в Telegram -

-
-
- {/* Theme Colors Card */} -
-
-
- 🎨 -

{t('theme.colors')}

-
- -
+ {/* Animation & Fullscreen toggles */} +
+

Опции интерфейса

-
- {/* Theme Availability Toggles */} -
- -
- {/* Dark Theme Toggle */} -
-
- -
- Тёмная тема -

Тёмное оформление интерфейса

-
-
- -
- - {/* Light Theme Toggle */} -
-
- -
- Светлая тема -

Светлое оформление интерфейса

-
-
- -
+
+
+
+ Анимированный фон +

Волны на фоне приложения

-

- Включите нужные темы для пользователей. Минимум одна тема должна быть активна. -

-
- - {/* Quick Presets - Collapsible */} -
- - {expandedThemeSections.has('presets') && ( -
-
- {THEME_PRESETS.map((preset) => ( - - ))} -
-
+ {renderToggle( + animationSettings?.enabled ?? true, + () => updateAnimationMutation.mutate(!(animationSettings?.enabled ?? true)), + updateAnimationMutation.isPending )}
- {/* Custom Colors - Collapsible */} -
- - {expandedThemeSections.has('custom') && ( -
- {/* Accent Color */} -
-

{t('theme.accent')}

- updateColorsMutation.mutate({ accent: color })} - disabled={updateColorsMutation.isPending} +
+
+ Авто-Fullscreen +

В Telegram WebApp

+
+ {renderToggle( + fullscreenSettings?.enabled ?? false, + () => updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false)), + updateFullscreenMutation.isPending + )} +
+
+
+
+ ) + + const renderThemeTab = () => ( +
+ {/* Theme toggles */} +
+

Доступные темы

+ +
+
+
+ + Тёмная +
+ {renderToggle( + enabledThemes?.dark ?? true, + () => { + if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return + updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) }) + }, + updateEnabledThemesMutation.isPending + )} +
+ +
+
+ + Светлая +
+ {renderToggle( + enabledThemes?.light ?? true, + () => { + if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return + updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) }) + }, + updateEnabledThemesMutation.isPending + )} +
+
+
+ + {/* Quick Presets */} +
+ + + {expandedSections.has('presets') && ( +
+ {THEME_PRESETS.map((preset) => ( + - - {t('theme.success')} - {t('theme.warning')} - {t('theme.error')} -
-
-
- )} + + ))}
-
-
- - {/* Search */} -
- setSearchQuery(e.target.value)} - placeholder={t('admin.settings.searchCategoriesPlaceholder')} - className="input w-full pl-10" - /> -
- -
- {searchQuery && ( - )}
- {/* Meta-category Cards Grid */} - {filteredMetaCategories && Object.keys(filteredMetaCategories).length > 0 ? ( -
- {Object.entries(filteredMetaCategories).map(([metaKey, data]) => { - const meta = META_CATEGORIES[metaKey] || { label: 'Другое', emoji: '📁', categories: [] } - return ( - setSelectedMeta(metaKey)} - /> - ) - })} -
- ) : ( -
-
- + {/* Custom Colors */} +
+ + + {expandedSections.has('colors') && ( +
+ {/* Accent */} +
+

Акцентный цвет

+ updateColorsMutation.mutate({ accent: color })} + disabled={updateColorsMutation.isPending} + /> +
+ + {/* Dark theme */} +
+

+ Тёмная тема +

+
+ updateColorsMutation.mutate({ darkBackground: color })} + disabled={updateColorsMutation.isPending} + /> + updateColorsMutation.mutate({ darkSurface: color })} + disabled={updateColorsMutation.isPending} + /> + updateColorsMutation.mutate({ darkText: color })} + disabled={updateColorsMutation.isPending} + /> + updateColorsMutation.mutate({ darkTextSecondary: color })} + disabled={updateColorsMutation.isPending} + /> +
+
+ + {/* Light theme */} +
+

+ Светлая тема +

+
+ updateColorsMutation.mutate({ lightBackground: color })} + disabled={updateColorsMutation.isPending} + /> + updateColorsMutation.mutate({ lightSurface: color })} + disabled={updateColorsMutation.isPending} + /> + updateColorsMutation.mutate({ lightText: color })} + disabled={updateColorsMutation.isPending} + /> + updateColorsMutation.mutate({ lightTextSecondary: color })} + disabled={updateColorsMutation.isPending} + /> +
+
+ + {/* Status colors */} +
+

Статусные цвета

+
+ updateColorsMutation.mutate({ success: color })} + disabled={updateColorsMutation.isPending} + /> + updateColorsMutation.mutate({ warning: color })} + disabled={updateColorsMutation.isPending} + /> + updateColorsMutation.mutate({ error: color })} + disabled={updateColorsMutation.isPending} + /> +
+
+ + {/* Reset button */} + +
+ )} +
+
+ ) + + const renderFavoritesTab = () => ( +
+ {favoriteSettings.length === 0 ? ( +
+ +

Нет избранных настроек

+

+ Нажмите ⭐ рядом с настройкой, чтобы добавить её сюда

- )} - - {/* Settings Modal */} - {selectedMeta && metaCategoriesData && metaCategoriesData[selectedMeta] && ( - setSelectedMeta(null)} - onUpdate={handleUpdate} - onReset={handleReset} - isUpdating={updateMutation.isPending || resetMutation.isPending} - /> + ) : ( + favoriteSettings.map(renderSettingRow) )}
) + + const renderSettingsTab = () => ( +
+ {filteredSettings.length === 0 ? ( +
+

Нет настроек в этой категории

+
+ ) : ( + filteredSettings.map(renderSettingRow) + )} +
+ ) + + // ============ RENDER ============ + return ( +
+ {/* Header */} +
+
+ + + +

Настройки

+
+ + {/* Search */} +
+ setSearchQuery(e.target.value)} + placeholder="Поиск..." + className="w-64 pl-10 pr-4 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500" + /> +
+ +
+
+
+ + {/* Tabs */} +
+ {TABS.map((tab) => { + if (tab.id === 'more') { + return ( +
+ + + {showMoreDropdown && ( +
+ {MORE_TABS.map((moreTab) => ( + + ))} +
+ )} +
+ ) + } + + return ( + + ) + })} +
+ + {/* Tab Content */} +
+ {activeTab === 'favorites' && renderFavoritesTab()} + {activeTab === 'branding' && renderBrandingTab()} + {activeTab === 'theme' && renderThemeTab()} + {['payments', 'subscriptions', 'interface', 'notifications', 'database', 'system', 'users'].includes(activeTab) && renderSettingsTab()} +
+
+ ) +} + +// ============ SETTING INPUT COMPONENT ============ +function SettingInput({ + setting, + onUpdate, + disabled +}: { + setting: SettingDefinition + onUpdate: (value: string) => void + disabled?: boolean +}) { + const [isEditing, setIsEditing] = useState(false) + const [value, setValue] = useState('') + + const handleStart = () => { + setValue(String(setting.current ?? '')) + setIsEditing(true) + } + + const handleSave = () => { + onUpdate(value) + setIsEditing(false) + } + + const handleCancel = () => { + setIsEditing(false) + setValue('') + } + + if (setting.choices && setting.choices.length > 0) { + return ( + + ) + } + + if (isEditing) { + return ( +
+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave() + if (e.key === 'Escape') handleCancel() + }} + autoFocus + className="bg-dark-700 border border-accent-500 rounded-lg px-3 py-1.5 text-sm text-dark-100 focus:outline-none w-32" + /> + + +
+ ) + } + + return ( + + ) }