From c9d31a01f00e9d7a1a93ddd63cb27ef6dde87c6d Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 26 Jan 2026 22:38:46 +0300 Subject: [PATCH 01/12] Update index.ts --- src/types/index.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/types/index.ts b/src/types/index.ts index a93e836..1668ae2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -556,3 +556,35 @@ export interface TicketSettings { cabinet_user_notifications_enabled: boolean cabinet_admin_notifications_enabled: boolean } + +// Payment method config types (admin) +export interface PaymentMethodSubOptionInfo { + id: string + name: string +} + +export interface PaymentMethodConfig { + method_id: string + sort_order: number + is_enabled: boolean + display_name: string | null + default_display_name: string + sub_options: Record | null + available_sub_options: PaymentMethodSubOptionInfo[] | null + min_amount_kopeks: number | null + max_amount_kopeks: number | null + default_min_amount_kopeks: number + default_max_amount_kopeks: number + user_type_filter: 'all' | 'telegram' | 'email' + first_topup_filter: 'any' | 'yes' | 'no' + promo_group_filter_mode: 'all' | 'selected' + allowed_promo_group_ids: number[] + is_provider_configured: boolean + created_at: string | null + updated_at: string | null +} + +export interface PromoGroupSimple { + id: number + name: string +} From de067673067cc1bad3d5e78bd1495b9ef409704c Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 26 Jan 2026 22:39:11 +0300 Subject: [PATCH 02/12] Add files via upload --- src/api/adminPaymentMethods.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/api/adminPaymentMethods.ts diff --git a/src/api/adminPaymentMethods.ts b/src/api/adminPaymentMethods.ts new file mode 100644 index 0000000..0a866d4 --- /dev/null +++ b/src/api/adminPaymentMethods.ts @@ -0,0 +1,31 @@ +import apiClient from './client' +import type { PaymentMethodConfig, PromoGroupSimple } from '../types' + +export const adminPaymentMethodsApi = { + getAll: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/payment-methods') + return response.data + }, + + getOne: async (methodId: string): Promise => { + const response = await apiClient.get(`/cabinet/admin/payment-methods/${methodId}`) + return response.data + }, + + update: async (methodId: string, data: Record): Promise => { + const response = await apiClient.put( + `/cabinet/admin/payment-methods/${methodId}`, + data + ) + return response.data + }, + + updateOrder: async (methodIds: string[]): Promise => { + await apiClient.put('/cabinet/admin/payment-methods/order', { method_ids: methodIds }) + }, + + getPromoGroups: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/payment-methods/promo-groups') + return response.data + }, +} From 0608c2fa7bb0396133aa058e5759213462384617 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 26 Jan 2026 22:39:31 +0300 Subject: [PATCH 03/12] Update App.tsx --- src/App.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 6bf60f8..6f371fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes')) const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns')) const AdminUsers = lazy(() => import('./pages/AdminUsers')) const AdminPayments = lazy(() => import('./pages/AdminPayments')) +const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods')) const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')) const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')) const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')) @@ -317,6 +318,14 @@ function App() { } /> + + + + } + /> Date: Mon, 26 Jan 2026 22:39:56 +0300 Subject: [PATCH 04/12] Add files via upload --- src/pages/AdminPanel.tsx | 6 + src/pages/AdminPaymentMethods.tsx | 740 ++++++++++++++++++++++++++++++ 2 files changed, 746 insertions(+) create mode 100644 src/pages/AdminPaymentMethods.tsx diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 28f0f6e..ebfdb69 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -286,6 +286,12 @@ export default function AdminPanel() { title: t('admin.nav.promoOffers', 'Промопредложения'), description: t('admin.panel.promoOffersDesc', 'Персональные скидки'), }, + { + to: '/admin/payment-methods', + icon: , + title: t('admin.nav.paymentMethods', 'Платёжные методы'), + description: t('admin.panel.paymentMethodsDesc', 'Настройка и порядок платежек'), + }, ], }, { diff --git a/src/pages/AdminPaymentMethods.tsx b/src/pages/AdminPaymentMethods.tsx new file mode 100644 index 0000000..ea2b61c --- /dev/null +++ b/src/pages/AdminPaymentMethods.tsx @@ -0,0 +1,740 @@ +import { useState, useCallback, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { adminPaymentMethodsApi } from '../api/adminPaymentMethods' +import type { PaymentMethodConfig, PromoGroupSimple } from '../types' + +// ============ Icons ============ + +const BackIcon = () => ( + + + +) + +const GripIcon = () => ( + + + +) + +const ChevronRightIcon = () => ( + + + +) + +const CloseIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +const SaveIcon = () => ( + + + +) + +// ============ Method icon by type ============ + +const METHOD_ICONS: Record = { + telegram_stars: '\u2B50', + tribute: '\uD83C\uDF81', + cryptobot: '\uD83E\uDE99', + heleket: '\u26A1', + yookassa: '\uD83C\uDFE6', + mulenpay: '\uD83D\uDCB3', + pal24: '\uD83D\uDCB8', + platega: '\uD83D\uDCB0', + wata: '\uD83D\uDCA7', + freekassa: '\uD83D\uDCB5', + cloudpayments: '\u2601\uFE0F', +} + +const METHOD_LABELS: Record = { + telegram_stars: 'Telegram Stars', + tribute: 'Tribute', + cryptobot: 'CryptoBot', + heleket: 'Heleket', + yookassa: 'YooKassa', + mulenpay: 'MulenPay', + pal24: 'PayPalych', + platega: 'Platega', + wata: 'WATA', + freekassa: 'Freekassa', + cloudpayments: 'CloudPayments', +} + +// ============ Sortable Card ============ + +interface SortableCardProps { + config: PaymentMethodConfig + onClick: () => void +} + +function SortablePaymentCard({ config, onClick }: SortableCardProps) { + const { t } = useTranslation() + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: config.method_id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 50 : undefined, + opacity: isDragging ? 0.85 : 1, + } + + const displayName = config.display_name || config.default_display_name + const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3' + + // Build condition summary chips + const chips: string[] = [] + if (config.user_type_filter === 'telegram') chips.push(t('admin.paymentMethods.userTypeTelegram', 'Telegram')) + if (config.user_type_filter === 'email') chips.push(t('admin.paymentMethods.userTypeEmail', 'Email')) + if (config.first_topup_filter === 'yes') chips.push(t('admin.paymentMethods.firstTopupYes', 'C пополнением')) + if (config.first_topup_filter === 'no') chips.push(t('admin.paymentMethods.firstTopupNo', 'Без пополнения')) + if (config.promo_group_filter_mode === 'selected' && config.allowed_promo_group_ids.length > 0) { + chips.push(`${config.allowed_promo_group_ids.length} ${t('admin.paymentMethods.promoGroupsShort', 'групп')}`) + } + + // Count enabled sub-options + let subOptionsInfo = '' + if (config.available_sub_options && config.sub_options) { + const enabledCount = config.available_sub_options.filter(o => config.sub_options?.[o.id] !== false).length + const totalCount = config.available_sub_options.length + if (enabledCount < totalCount) { + subOptionsInfo = `${enabledCount}/${totalCount}` + } + } + + return ( +
+ {/* Drag handle */} + + + {/* Method icon */} +
+ {icon} +
+ + {/* Content */} +
+
+ {displayName} + {config.is_enabled ? ( + + {t('admin.paymentMethods.enabled', 'Вкл')} + + ) : ( + + {t('admin.paymentMethods.disabled', 'Выкл')} + + )} + {!config.is_provider_configured && ( + + {t('admin.paymentMethods.notConfigured', 'Не настроен')} + + )} + {subOptionsInfo && ( + + {subOptionsInfo} + + )} +
+ + {/* Condition chips */} + {chips.length > 0 && ( +
+ {chips.map((chip, i) => ( + + {chip} + + ))} +
+ )} +
+ + {/* Chevron */} + +
+ ) +} + +// ============ Detail Modal ============ + +interface DetailModalProps { + config: PaymentMethodConfig + promoGroups: PromoGroupSimple[] + onClose: () => void + onSave: (methodId: string, data: Record) => void + isSaving: boolean +} + +function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSaving }: DetailModalProps) { + const { t } = useTranslation() + const displayName = config.display_name || config.default_display_name + const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3' + + // Local state for editing + const [isEnabled, setIsEnabled] = useState(config.is_enabled) + const [customName, setCustomName] = useState(config.display_name || '') + const [subOptions, setSubOptions] = useState>(config.sub_options || {}) + const [minAmount, setMinAmount] = useState(config.min_amount_kopeks?.toString() || '') + const [maxAmount, setMaxAmount] = useState(config.max_amount_kopeks?.toString() || '') + const [userTypeFilter, setUserTypeFilter] = useState(config.user_type_filter) + const [firstTopupFilter, setFirstTopupFilter] = useState(config.first_topup_filter) + const [promoGroupFilterMode, setPromoGroupFilterMode] = useState(config.promo_group_filter_mode) + const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState(config.allowed_promo_group_ids) + + // Escape to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + + // Scroll lock + useEffect(() => { + document.body.style.overflow = 'hidden' + return () => { document.body.style.overflow = '' } + }, []) + + const handleSave = () => { + const data: Record = { + is_enabled: isEnabled, + user_type_filter: userTypeFilter, + first_topup_filter: firstTopupFilter, + promo_group_filter_mode: promoGroupFilterMode, + allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [], + } + + // Display name + if (customName.trim()) { + data.display_name = customName.trim() + } else { + data.reset_display_name = true + } + + // Sub-options + if (config.available_sub_options) { + data.sub_options = subOptions + } + + // Amounts + if (minAmount.trim()) { + data.min_amount_kopeks = parseInt(minAmount, 10) || null + } else { + data.reset_min_amount = true + } + if (maxAmount.trim()) { + data.max_amount_kopeks = parseInt(maxAmount, 10) || null + } else { + data.reset_max_amount = true + } + + onSave(config.method_id, data) + } + + const togglePromoGroup = (id: number) => { + setSelectedPromoGroupIds(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] + ) + } + + return ( +
+
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ {icon} +
+
+

{displayName}

+

{METHOD_LABELS[config.method_id] || config.method_id}

+
+
+ +
+ +
+ {/* Enable toggle */} +
+
+
{t('admin.paymentMethods.methodEnabled', 'Метод включён')}
+ {!config.is_provider_configured && ( +
{t('admin.paymentMethods.providerNotConfigured', 'Провайдер не настроен в env')}
+ )} +
+ +
+ + {/* Display name */} +
+ + setCustomName(e.target.value)} + placeholder={config.default_display_name} + className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors" + /> +

+ {t('admin.paymentMethods.displayNameHint', 'Оставьте пустым для имени по умолчанию')}: {config.default_display_name} +

+
+ + {/* Sub-options */} + {config.available_sub_options && config.available_sub_options.length > 0 && ( +
+ +
+ {config.available_sub_options.map(opt => { + const enabled = subOptions[opt.id] !== false + return ( + + ) + })} +
+
+ )} + + {/* Min/Max amounts */} +
+
+ + setMinAmount(e.target.value)} + placeholder={config.default_min_amount_kopeks.toString()} + className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors" + /> +
+
+ + setMaxAmount(e.target.value)} + placeholder={config.default_max_amount_kopeks.toString()} + className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors" + /> +
+
+ + {/* Display conditions */} +
+

+ {t('admin.paymentMethods.conditions', 'Условия отображения')} +

+ + {/* User type filter */} +
+ +
+ {(['all', 'telegram', 'email'] as const).map(val => ( + + ))} +
+
+ + {/* First topup filter */} +
+ +
+ {(['any', 'yes', 'no'] as const).map(val => ( + + ))} +
+
+ + {/* Promo groups filter */} +
+ +
+ {(['all', 'selected'] as const).map(val => ( + + ))} +
+ + {promoGroupFilterMode === 'selected' && ( +
+ {promoGroups.length === 0 ? ( +

+ {t('admin.paymentMethods.noPromoGroups', 'Нет промо-групп')} +

+ ) : ( + promoGroups.map(group => { + const selected = selectedPromoGroupIds.includes(group.id) + return ( + + ) + }) + )} +
+ )} +
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} + +// ============ Toast ============ + +function Toast({ message, onClose }: { message: string; onClose: () => void }) { + useEffect(() => { + const timer = setTimeout(onClose, 3000) + return () => clearTimeout(timer) + }, [onClose]) + + return ( +
+ + {message} +
+ ) +} + +// ============ Main Page ============ + +export default function AdminPaymentMethods() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const [methods, setMethods] = useState([]) + const [selectedMethod, setSelectedMethod] = useState(null) + const [orderChanged, setOrderChanged] = useState(false) + const [toastMessage, setToastMessage] = useState(null) + + // Fetch payment methods + const { data: fetchedMethods, isLoading } = useQuery({ + queryKey: ['admin-payment-methods'], + queryFn: adminPaymentMethodsApi.getAll, + }) + + // Fetch promo groups + const { data: promoGroups = [] } = useQuery({ + queryKey: ['admin-payment-methods-promo-groups'], + queryFn: adminPaymentMethodsApi.getPromoGroups, + }) + + // Sync fetched data to local state + useEffect(() => { + if (fetchedMethods && !orderChanged) { + setMethods(fetchedMethods) + } + }, [fetchedMethods, orderChanged]) + + // Save order mutation + const saveOrderMutation = useMutation({ + mutationFn: (methodIds: string[]) => adminPaymentMethodsApi.updateOrder(methodIds), + onSuccess: () => { + setOrderChanged(false) + queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] }) + setToastMessage(t('admin.paymentMethods.orderSaved', 'Порядок сохранён')) + }, + }) + + // Update method mutation + const updateMethodMutation = useMutation({ + mutationFn: ({ methodId, data }: { methodId: string; data: Record }) => + adminPaymentMethodsApi.update(methodId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] }) + setSelectedMethod(null) + setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены')) + }, + }) + + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event + if (over && active.id !== over.id) { + setMethods(prev => { + const oldIndex = prev.findIndex(m => m.method_id === active.id) + const newIndex = prev.findIndex(m => m.method_id === over.id) + return arrayMove(prev, oldIndex, newIndex) + }) + setOrderChanged(true) + } + }, []) + + const handleSaveOrder = () => { + saveOrderMutation.mutate(methods.map(m => m.method_id)) + } + + const handleSaveMethod = (methodId: string, data: Record) => { + updateMethodMutation.mutate({ methodId, data }) + } + + const handleCloseToast = useCallback(() => setToastMessage(null), []) + + return ( +
+ {/* Header */} +
+
+ + + +
+

{t('admin.paymentMethods.title', 'Платёжные методы')}

+

{t('admin.paymentMethods.description', 'Настройка порядка и условий отображения')}

+
+
+ {orderChanged && ( + + )} +
+ + {/* Drag hint */} +
+ + {t('admin.paymentMethods.dragHint', 'Перетаскивайте карточки для изменения порядка. Нажмите для настройки.')} +
+ + {/* Methods list */} +
+ {isLoading ? ( +
+
+
+ ) : methods.length > 0 ? ( + + m.method_id)} strategy={verticalListSortingStrategy}> +
+ {methods.map(config => ( + setSelectedMethod(config)} + /> + ))} +
+
+
+ ) : ( +
+
+ {'\uD83D\uDCB3'} +
+
{t('admin.paymentMethods.noMethods', 'Нет настроенных платёжных методов')}
+
+ )} +
+ + {/* Detail Modal */} + {selectedMethod && ( + setSelectedMethod(null)} + onSave={handleSaveMethod} + isSaving={updateMethodMutation.isPending} + /> + )} + + {/* Toast */} + {toastMessage && ( + + )} +
+ ) +} From d23fc68730375595bd088dad4ca7351b718d8fe5 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 26 Jan 2026 22:40:20 +0300 Subject: [PATCH 05/12] Add files via upload --- src/locales/en.json | 34 ++++++++++++++++++++++++++++++++++ src/locales/fa.json | 40 ++++++++++++++++++++++++++++++++++++++-- src/locales/ru.json | 34 ++++++++++++++++++++++++++++++++++ src/locales/zh.json | 40 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 144 insertions(+), 4 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index ac661f4..864b2d5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -553,6 +553,40 @@ "checkStatus": "Check Status", "checking": "Checking..." }, + "paymentMethods": { + "title": "Payment Methods", + "description": "Configure display order and conditions", + "enabled": "On", + "disabled": "Off", + "notConfigured": "Not configured", + "dragToReorder": "Drag to reorder", + "dragHint": "Drag cards to change order. Click to configure.", + "saveOrder": "Save Order", + "orderSaved": "Order saved", + "saved": "Settings saved", + "noMethods": "No payment methods configured", + "methodEnabled": "Method enabled", + "providerNotConfigured": "Provider not configured in env", + "displayName": "Display name", + "displayNameHint": "Leave empty for default name", + "subOptions": "Payment options", + "minAmount": "Min amount (kopeks)", + "maxAmount": "Max amount (kopeks)", + "conditions": "Display conditions", + "userTypeFilter": "User type", + "userTypeAll": "All", + "userTypeTelegram": "Telegram", + "userTypeEmail": "Email", + "firstTopupFilter": "First top-up", + "firstTopupAny": "Any", + "firstTopupYes": "Made", + "firstTopupNo": "Not made", + "promoGroupFilter": "Promo groups", + "promoGroupAll": "All groups", + "promoGroupSelected": "Selected", + "promoGroupsShort": "groups", + "noPromoGroups": "No promo groups" + }, "remnawave": { "title": "RemnaWave", "subtitle": "Panel management and statistics", diff --git a/src/locales/fa.json b/src/locales/fa.json index ff875b6..61f5320 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -368,7 +368,8 @@ "tariffs": "تعرفه‌ها", "servers": "سرورها", "broadcasts": "پیام‌رسانی", - "emailTemplates": "قالب‌های ایمیل" + "emailTemplates": "قالب‌های ایمیل", + "paymentMethods": "روش‌های پرداخت" }, "panel": { "title": "پنل مدیریت", @@ -381,7 +382,8 @@ "tariffsDesc": "مدیریت طرح‌های تعرفه", "serversDesc": "تنظیم سرورهای VPN", "broadcastsDesc": "ارسال پیام گروهی به کاربران", - "emailTemplatesDesc": "مدیریت قالب‌های اعلان ایمیل" + "emailTemplatesDesc": "مدیریت قالب‌های اعلان ایمیل", + "paymentMethodsDesc": "تنظیم و ترتیب روش‌های پرداخت" }, "emailTemplates": { "title": "قالب‌های ایمیل", @@ -638,6 +640,40 @@ "enable": "فعال", "disable": "غیرفعال", "toggleTrial": "تغییر آزمایشی" + }, + "paymentMethods": { + "title": "روش‌های پرداخت", + "description": "تنظیم ترتیب نمایش و شرایط", + "enabled": "فعال", + "disabled": "غیرفعال", + "notConfigured": "تنظیم نشده", + "dragToReorder": "بکشید برای تغییر ترتیب", + "dragHint": "کارت‌ها را بکشید تا ترتیب تغییر کند. برای تنظیم کلیک کنید.", + "saveOrder": "ذخیره ترتیب", + "orderSaved": "ترتیب ذخیره شد", + "saved": "تنظیمات ذخیره شد", + "noMethods": "روش پرداختی تنظیم نشده", + "methodEnabled": "روش فعال شد", + "providerNotConfigured": "ارائه‌دهنده در env تنظیم نشده", + "displayName": "نام نمایشی", + "displayNameHint": "برای نام پیش‌فرض خالی بگذارید", + "subOptions": "گزینه‌های پرداخت", + "minAmount": "حداقل مبلغ (کوپک)", + "maxAmount": "حداکثر مبلغ (کوپک)", + "conditions": "شرایط نمایش", + "userTypeFilter": "نوع کاربر", + "userTypeAll": "همه", + "userTypeTelegram": "تلگرام", + "userTypeEmail": "ایمیل", + "firstTopupFilter": "اولین شارژ", + "firstTopupAny": "هر کدام", + "firstTopupYes": "انجام شده", + "firstTopupNo": "انجام نشده", + "promoGroupFilter": "گروه‌های تبلیغاتی", + "promoGroupAll": "همه گروه‌ها", + "promoGroupSelected": "انتخاب شده", + "promoGroupsShort": "گروه", + "noPromoGroups": "گروه تبلیغاتی نیست" } }, "adminDashboard": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 10ce003..22f86ce 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -566,6 +566,40 @@ "checkStatus": "Проверить статус", "checking": "Проверка..." }, + "paymentMethods": { + "title": "Платёжные методы", + "description": "Настройка порядка и условий отображения", + "enabled": "Вкл", + "disabled": "Выкл", + "notConfigured": "Не настроен", + "dragToReorder": "Перетащите для изменения порядка", + "dragHint": "Перетаскивайте карточки для изменения порядка. Нажмите для настройки.", + "saveOrder": "Сохранить порядок", + "orderSaved": "Порядок сохранён", + "saved": "Настройки сохранены", + "noMethods": "Нет настроенных платёжных методов", + "methodEnabled": "Метод включён", + "providerNotConfigured": "Провайдер не настроен в env", + "displayName": "Отображаемое имя", + "displayNameHint": "Оставьте пустым для имени по умолчанию", + "subOptions": "Варианты оплаты", + "minAmount": "Мин. сумма (коп.)", + "maxAmount": "Макс. сумма (коп.)", + "conditions": "Условия отображения", + "userTypeFilter": "Тип пользователя", + "userTypeAll": "Все", + "userTypeTelegram": "Telegram", + "userTypeEmail": "Email", + "firstTopupFilter": "Первое пополнение", + "firstTopupAny": "Не важно", + "firstTopupYes": "Было", + "firstTopupNo": "Не было", + "promoGroupFilter": "Промо-группы", + "promoGroupAll": "Все группы", + "promoGroupSelected": "Выбранные", + "promoGroupsShort": "групп", + "noPromoGroups": "Нет промо-групп" + }, "remnawave": { "title": "RemnaWave", "subtitle": "Управление панелью и статистика", diff --git a/src/locales/zh.json b/src/locales/zh.json index f600804..5422f61 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -369,7 +369,8 @@ "tariffs": "套餐", "servers": "服务器", "broadcasts": "群发", - "emailTemplates": "邮件模板" + "emailTemplates": "邮件模板", + "paymentMethods": "支付方式" }, "panel": { "title": "管理面板", @@ -382,7 +383,42 @@ "tariffsDesc": "管理套餐计划", "serversDesc": "配置VPN服务器", "broadcastsDesc": "向用户群发消息", - "emailTemplatesDesc": "管理邮件通知模板" + "emailTemplatesDesc": "管理邮件通知模板", + "paymentMethodsDesc": "配置支付方式排序和显示条件" + }, + "paymentMethods": { + "title": "支付方式", + "description": "配置显示顺序和条件", + "enabled": "开启", + "disabled": "关闭", + "notConfigured": "未配置", + "dragToReorder": "拖拽排序", + "dragHint": "拖拽卡片更改排序。点击进行配置。", + "saveOrder": "保存排序", + "orderSaved": "排序已保存", + "saved": "设置已保存", + "noMethods": "没有配置支付方式", + "methodEnabled": "启用方式", + "providerNotConfigured": "env中未配置提供商", + "displayName": "显示名称", + "displayNameHint": "留空使用默认名称", + "subOptions": "支付选项", + "minAmount": "最小金额(戈比)", + "maxAmount": "最大金额(戈比)", + "conditions": "显示条件", + "userTypeFilter": "用户类型", + "userTypeAll": "全部", + "userTypeTelegram": "Telegram", + "userTypeEmail": "Email", + "firstTopupFilter": "首次充值", + "firstTopupAny": "不限", + "firstTopupYes": "已充值", + "firstTopupNo": "未充值", + "promoGroupFilter": "促销组", + "promoGroupAll": "所有组", + "promoGroupSelected": "选定的", + "promoGroupsShort": "组", + "noPromoGroups": "没有促销组" }, "emailTemplates": { "title": "邮件模板", From ccbabc58f595c09b3f6e3615159a50d62eb02aec Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 26 Jan 2026 22:46:00 +0300 Subject: [PATCH 06/12] Add files via upload --- src/locales/en.json | 6 ++++-- src/locales/ru.json | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 864b2d5..1c6b08a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -502,7 +502,8 @@ "users": "Users", "payments": "Payments", "remnawave": "RemnaWave", - "emailTemplates": "Email Templates" + "emailTemplates": "Email Templates", + "paymentMethods": "Payment Methods" }, "panel": { "title": "Admin Panel", @@ -519,7 +520,8 @@ "usersDesc": "Manage bot users", "paymentsDesc": "Payment verification", "remnawaveDesc": "Panel management and statistics", - "emailTemplatesDesc": "Manage email notification templates" + "emailTemplatesDesc": "Manage email notification templates", + "paymentMethodsDesc": "Configure payment methods and order" }, "emailTemplates": { "title": "Email Templates", diff --git a/src/locales/ru.json b/src/locales/ru.json index 22f86ce..7f485ed 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -515,7 +515,8 @@ "users": "Пользователи", "payments": "Платежи", "remnawave": "RemnaWave", - "emailTemplates": "Email-шаблоны" + "emailTemplates": "Email-шаблоны", + "paymentMethods": "Платёжные методы" }, "panel": { "title": "Панель администратора", @@ -532,7 +533,8 @@ "usersDesc": "Управление пользователями бота", "paymentsDesc": "Проверка платежей", "remnawaveDesc": "Управление панелью и статистика", - "emailTemplatesDesc": "Шаблоны email-уведомлений" + "emailTemplatesDesc": "Шаблоны email-уведомлений", + "paymentMethodsDesc": "Настройка и порядок платежек" }, "emailTemplates": { "title": "Email-шаблоны", From 3ebff0c1f8b77285100f0dce341459f2daf8d8a3 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 26 Jan 2026 22:46:37 +0300 Subject: [PATCH 07/12] Update AdminPaymentMethods.tsx --- src/pages/AdminPaymentMethods.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pages/AdminPaymentMethods.tsx b/src/pages/AdminPaymentMethods.tsx index ea2b61c..d321579 100644 --- a/src/pages/AdminPaymentMethods.tsx +++ b/src/pages/AdminPaymentMethods.tsx @@ -610,6 +610,9 @@ export default function AdminPaymentMethods() { queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] }) setToastMessage(t('admin.paymentMethods.orderSaved', 'Порядок сохранён')) }, + onError: () => { + setToastMessage(t('common.error', 'Ошибка')) + }, }) // Update method mutation @@ -621,6 +624,9 @@ export default function AdminPaymentMethods() { setSelectedMethod(null) setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены')) }, + onError: () => { + setToastMessage(t('common.error', 'Ошибка')) + }, }) // DnD sensors @@ -636,6 +642,7 @@ export default function AdminPaymentMethods() { setMethods(prev => { const oldIndex = prev.findIndex(m => m.method_id === active.id) const newIndex = prev.findIndex(m => m.method_id === over.id) + if (oldIndex === -1 || newIndex === -1) return prev return arrayMove(prev, oldIndex, newIndex) }) setOrderChanged(true) From 5da1cb32284a5bb23ad3b6a4215143bfc20938c2 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 26 Jan 2026 22:48:47 +0300 Subject: [PATCH 08/12] Add files via upload --- package-lock.json | 63 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +++ 2 files changed, 66 insertions(+) diff --git a/package-lock.json b/package-lock.json index 4e69810..c2b6cba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "cabinet-frontend", "version": "1.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@lottiefiles/dotlottie-react": "^0.8.0", "@tanstack/react-query": "^5.8.0", "axios": "^1.6.0", @@ -362,6 +365,60 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -4375,6 +4432,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 7fb0d2c..3d4670a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@lottiefiles/dotlottie-react": "^0.8.0", "@tanstack/react-query": "^5.8.0", "axios": "^1.6.0", From b82c07bfbd1d44f5a023b9b8a3ad3e3e1934b5a1 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 26 Jan 2026 23:22:49 +0300 Subject: [PATCH 09/12] Add files via upload --- src/locales/en.json | 3 +++ src/locales/fa.json | 3 +++ src/locales/ru.json | 3 +++ src/locales/zh.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/src/locales/en.json b/src/locales/en.json index 1c6b08a..218bfa5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -163,6 +163,9 @@ "perDevice": "/ device", "devicesFree": "devices free", "perExtraDevice": "/ extra device", + "extraDevices": "Extra devices", + "extraDevicesIncluded": "Incl. {{count}} extra dev.", + "baseTariff": "Tariff", "perMonth": "/mo", "summary": "Summary", "total": "Total", diff --git a/src/locales/fa.json b/src/locales/fa.json index 61f5320..a9f2143 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -118,6 +118,9 @@ "expiresAt": "تاریخ انقضا", "daysLeft": "روز باقی‌مانده", "devices": "دستگاه‌ها", + "extraDevices": "دستگاه‌های اضافی", + "extraDevicesIncluded": "شامل {{count}} دستگاه اضافی", + "baseTariff": "تعرفه", "servers": "سرورها", "traffic": "ترافیک", "unlimited": "نامحدود", diff --git a/src/locales/ru.json b/src/locales/ru.json index 7f485ed..6211876 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -176,6 +176,9 @@ "perDevice": "/ устройство", "devicesFree": "устройств бесплатно", "perExtraDevice": "/ доп. устройство", + "extraDevices": "Доп. устройства", + "extraDevicesIncluded": "Вкл. {{count}} доп. устр.", + "baseTariff": "Тариф", "perMonth": "/мес", "summary": "Итого", "total": "К оплате", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5422f61..2010326 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -119,6 +119,9 @@ "expiresAt": "到期时间", "daysLeft": "剩余天数", "devices": "设备", + "extraDevices": "额外设备", + "extraDevicesIncluded": "含 {{count}} 个额外设备", + "baseTariff": "套餐", "servers": "服务器", "traffic": "流量", "unlimited": "无限", From c2c2399af1a58b68fa9c5b5485e32c3d91fa7e04 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 26 Jan 2026 23:23:38 +0300 Subject: [PATCH 10/12] Update Subscription.tsx --- src/pages/Subscription.tsx | 47 ++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 19d4109..dbbb7e3 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1657,7 +1657,14 @@ export default function Subscription() {
{t('subscription.devices')}: - {selectedTariff.device_limit} + + {selectedTariff.device_limit} + {selectedTariff.extra_devices_count > 0 && ( + + (+{selectedTariff.extra_devices_count}) + + )} +
{t('subscription.servers')}: @@ -1951,17 +1958,33 @@ export default function Subscription() {
) : selectedTariffPeriod && ( -
- Период: {selectedTariffPeriod.label} -
- {formatPrice(promoPeriod.price)} - {(hasExistingPeriodDiscount || promoPeriod.original) && ( - - {formatPrice(hasExistingPeriodDiscount ? selectedTariffPeriod.original_price_kopeks! : promoPeriod.original!)} - - )} -
-
+ <> + {/* Если есть доп. устройства - показываем разбивку */} + {selectedTariffPeriod.extra_devices_count > 0 && selectedTariffPeriod.base_tariff_price_kopeks ? ( + <> +
+ {t('subscription.baseTariff')}: {selectedTariffPeriod.label} + {formatPrice(selectedTariffPeriod.base_tariff_price_kopeks)} +
+
+ {t('subscription.extraDevices')} ({selectedTariffPeriod.extra_devices_count}) + +{formatPrice(selectedTariffPeriod.extra_devices_cost_kopeks)} +
+ + ) : ( +
+ Период: {selectedTariffPeriod.label} +
+ {formatPrice(promoPeriod.price)} + {(hasExistingPeriodDiscount || promoPeriod.original) && ( + + {formatPrice(hasExistingPeriodDiscount ? selectedTariffPeriod.original_price_kopeks! : promoPeriod.original!)} + + )} +
+
+ )} + )} {useCustomTraffic && selectedTariff.custom_traffic_enabled && (
From afcb4ab78eda86c4ea1524ef9cc5dae36d82e3fd Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 26 Jan 2026 23:24:09 +0300 Subject: [PATCH 11/12] Update index.ts --- src/types/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/types/index.ts b/src/types/index.ts index 1668ae2..9d99c5f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -230,6 +230,12 @@ export interface TariffPeriod { discount_percent?: number discount_amount_kopeks?: number discount_label?: string + // Extra devices info (additional devices beyond tariff base) + extra_devices_count?: number + extra_devices_cost_kopeks?: number + extra_devices_cost_label?: string + base_tariff_price_kopeks?: number + base_tariff_price_label?: string } export interface TariffServer { @@ -246,6 +252,8 @@ export interface Tariff { traffic_limit_label: string is_unlimited_traffic: boolean device_limit: number + base_device_limit?: number + extra_devices_count: number servers_count: number servers: TariffServer[] periods: TariffPeriod[] From 4fc7712bef250b1c2440a01240e66e198c042f1f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 26 Jan 2026 23:24:30 +0300 Subject: [PATCH 12/12] Update Subscription.tsx --- src/pages/Subscription.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index dbbb7e3..fdf6f30 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1960,7 +1960,7 @@ export default function Subscription() { ) : selectedTariffPeriod && ( <> {/* Если есть доп. устройства - показываем разбивку */} - {selectedTariffPeriod.extra_devices_count > 0 && selectedTariffPeriod.base_tariff_price_kopeks ? ( + {(selectedTariffPeriod.extra_devices_count ?? 0) > 0 && selectedTariffPeriod.base_tariff_price_kopeks ? ( <>
{t('subscription.baseTariff')}: {selectedTariffPeriod.label} @@ -1968,7 +1968,7 @@ export default function Subscription() {
{t('subscription.extraDevices')} ({selectedTariffPeriod.extra_devices_count}) - +{formatPrice(selectedTariffPeriod.extra_devices_cost_kopeks)} + +{formatPrice(selectedTariffPeriod.extra_devices_cost_kopeks ?? 0)}
) : (