Merge pull request #97 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-01-26 23:28:27 +03:00
committed by GitHub
12 changed files with 1098 additions and 20 deletions

63
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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() {
</AdminRoute>
}
/>
<Route
path="/admin/payment-methods"
element={
<AdminRoute>
<LazyPage><AdminPaymentMethods /></LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/promo-offers"
element={

View File

@@ -0,0 +1,31 @@
import apiClient from './client'
import type { PaymentMethodConfig, PromoGroupSimple } from '../types'
export const adminPaymentMethodsApi = {
getAll: async (): Promise<PaymentMethodConfig[]> => {
const response = await apiClient.get<PaymentMethodConfig[]>('/cabinet/admin/payment-methods')
return response.data
},
getOne: async (methodId: string): Promise<PaymentMethodConfig> => {
const response = await apiClient.get<PaymentMethodConfig>(`/cabinet/admin/payment-methods/${methodId}`)
return response.data
},
update: async (methodId: string, data: Record<string, unknown>): Promise<PaymentMethodConfig> => {
const response = await apiClient.put<PaymentMethodConfig>(
`/cabinet/admin/payment-methods/${methodId}`,
data
)
return response.data
},
updateOrder: async (methodIds: string[]): Promise<void> => {
await apiClient.put('/cabinet/admin/payment-methods/order', { method_ids: methodIds })
},
getPromoGroups: async (): Promise<PromoGroupSimple[]> => {
const response = await apiClient.get<PromoGroupSimple[]>('/cabinet/admin/payment-methods/promo-groups')
return response.data
},
}

View File

@@ -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",
@@ -502,7 +505,8 @@
"users": "Users",
"payments": "Payments",
"remnawave": "RemnaWave",
"emailTemplates": "Email Templates"
"emailTemplates": "Email Templates",
"paymentMethods": "Payment Methods"
},
"panel": {
"title": "Admin Panel",
@@ -519,7 +523,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",
@@ -553,6 +558,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",

View File

@@ -118,6 +118,9 @@
"expiresAt": "تاریخ انقضا",
"daysLeft": "روز باقی‌مانده",
"devices": "دستگاه‌ها",
"extraDevices": "دستگاه‌های اضافی",
"extraDevicesIncluded": "شامل {{count}} دستگاه اضافی",
"baseTariff": "تعرفه",
"servers": "سرورها",
"traffic": "ترافیک",
"unlimited": "نامحدود",
@@ -368,7 +371,8 @@
"tariffs": "تعرفه‌ها",
"servers": "سرورها",
"broadcasts": "پیام‌رسانی",
"emailTemplates": "قالب‌های ایمیل"
"emailTemplates": "قالب‌های ایمیل",
"paymentMethods": "روش‌های پرداخت"
},
"panel": {
"title": "پنل مدیریت",
@@ -381,7 +385,8 @@
"tariffsDesc": "مدیریت طرح‌های تعرفه",
"serversDesc": "تنظیم سرورهای VPN",
"broadcastsDesc": "ارسال پیام گروهی به کاربران",
"emailTemplatesDesc": "مدیریت قالب‌های اعلان ایمیل"
"emailTemplatesDesc": "مدیریت قالب‌های اعلان ایمیل",
"paymentMethodsDesc": "تنظیم و ترتیب روش‌های پرداخت"
},
"emailTemplates": {
"title": "قالب‌های ایمیل",
@@ -638,6 +643,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": {

View File

@@ -176,6 +176,9 @@
"perDevice": "/ устройство",
"devicesFree": "устройств бесплатно",
"perExtraDevice": "/ доп. устройство",
"extraDevices": "Доп. устройства",
"extraDevicesIncluded": "Вкл. {{count}} доп. устр.",
"baseTariff": "Тариф",
"perMonth": "/мес",
"summary": "Итого",
"total": "К оплате",
@@ -515,7 +518,8 @@
"users": "Пользователи",
"payments": "Платежи",
"remnawave": "RemnaWave",
"emailTemplates": "Email-шаблоны"
"emailTemplates": "Email-шаблоны",
"paymentMethods": "Платёжные методы"
},
"panel": {
"title": "Панель администратора",
@@ -532,7 +536,8 @@
"usersDesc": "Управление пользователями бота",
"paymentsDesc": "Проверка платежей",
"remnawaveDesc": "Управление панелью и статистика",
"emailTemplatesDesc": "Шаблоны email-уведомлений"
"emailTemplatesDesc": "Шаблоны email-уведомлений",
"paymentMethodsDesc": "Настройка и порядок платежек"
},
"emailTemplates": {
"title": "Email-шаблоны",
@@ -566,6 +571,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": "Управление панелью и статистика",

View File

@@ -119,6 +119,9 @@
"expiresAt": "到期时间",
"daysLeft": "剩余天数",
"devices": "设备",
"extraDevices": "额外设备",
"extraDevicesIncluded": "含 {{count}} 个额外设备",
"baseTariff": "套餐",
"servers": "服务器",
"traffic": "流量",
"unlimited": "无限",
@@ -369,7 +372,8 @@
"tariffs": "套餐",
"servers": "服务器",
"broadcasts": "群发",
"emailTemplates": "邮件模板"
"emailTemplates": "邮件模板",
"paymentMethods": "支付方式"
},
"panel": {
"title": "管理面板",
@@ -382,7 +386,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": "邮件模板",

View File

@@ -286,6 +286,12 @@ export default function AdminPanel() {
title: t('admin.nav.promoOffers', 'Промопредложения'),
description: t('admin.panel.promoOffersDesc', 'Персональные скидки'),
},
{
to: '/admin/payment-methods',
icon: <BanknotesIcon />,
title: t('admin.nav.paymentMethods', 'Платёжные методы'),
description: t('admin.panel.paymentMethodsDesc', 'Настройка и порядок платежек'),
},
],
},
{

View File

@@ -0,0 +1,747 @@
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 = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
)
const GripIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
)
const ChevronRightIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
)
const CloseIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
const SaveIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
)
// ============ Method icon by type ============
const METHOD_ICONS: Record<string, string> = {
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<string, string> = {
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 (
<div
ref={setNodeRef}
style={style}
className={`group flex items-center gap-3 p-4 rounded-xl border transition-all ${
isDragging
? 'bg-dark-700/80 border-accent-500/50 shadow-xl shadow-accent-500/10'
: config.is_enabled
? 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
: 'bg-dark-900/30 border-dark-800/50 opacity-60'
}`}
>
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="flex-shrink-0 p-1.5 rounded-lg text-dark-500 hover:text-dark-300 hover:bg-dark-700/50 cursor-grab active:cursor-grabbing touch-manipulation"
title={t('admin.paymentMethods.dragToReorder', 'Перетащите для изменения порядка')}
>
<GripIcon />
</button>
{/* Method icon */}
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-700/50 flex items-center justify-center text-xl">
{icon}
</div>
{/* Content */}
<div className="flex-1 min-w-0 cursor-pointer" onClick={onClick}>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-dark-100 truncate">{displayName}</span>
{config.is_enabled ? (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-success-500/15 text-success-400 border border-success-500/20">
{t('admin.paymentMethods.enabled', 'Вкл')}
</span>
) : (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-500 border border-dark-700/30">
{t('admin.paymentMethods.disabled', 'Выкл')}
</span>
)}
{!config.is_provider_configured && (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-warning-500/15 text-warning-400 border border-warning-500/20">
{t('admin.paymentMethods.notConfigured', 'Не настроен')}
</span>
)}
{subOptionsInfo && (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-400">
{subOptionsInfo}
</span>
)}
</div>
{/* Condition chips */}
{chips.length > 0 && (
<div className="flex items-center gap-1.5 mt-1.5 flex-wrap">
{chips.map((chip, i) => (
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-accent-500/10 text-accent-400 border border-accent-500/15">
{chip}
</span>
))}
</div>
)}
</div>
{/* Chevron */}
<button
onClick={onClick}
className="flex-shrink-0 p-1 text-dark-500 hover:text-dark-300 transition-colors"
>
<ChevronRightIcon />
</button>
</div>
)
}
// ============ Detail Modal ============
interface DetailModalProps {
config: PaymentMethodConfig
promoGroups: PromoGroupSimple[]
onClose: () => void
onSave: (methodId: string, data: Record<string, unknown>) => 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<Record<string, boolean>>(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<number[]>(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<string, unknown> = {
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 (
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center" onClick={onClose}>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto m-4 rounded-2xl bg-dark-800 border border-dark-700 shadow-2xl"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between p-5 border-b border-dark-700 bg-dark-800 rounded-t-2xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-dark-700/50 flex items-center justify-center text-xl">
{icon}
</div>
<div>
<h2 className="text-lg font-bold text-dark-50">{displayName}</h2>
<p className="text-xs text-dark-500">{METHOD_LABELS[config.method_id] || config.method_id}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-lg hover:bg-dark-700 text-dark-400 hover:text-dark-200 transition-colors">
<CloseIcon />
</button>
</div>
<div className="p-5 space-y-6">
{/* Enable toggle */}
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-dark-200">{t('admin.paymentMethods.methodEnabled', 'Метод включён')}</div>
{!config.is_provider_configured && (
<div className="text-xs text-warning-400 mt-0.5">{t('admin.paymentMethods.providerNotConfigured', 'Провайдер не настроен в env')}</div>
)}
</div>
<button
onClick={() => setIsEnabled(!isEnabled)}
className={`relative w-12 h-7 rounded-full transition-colors ${
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span className={`absolute top-0.5 w-6 h-6 rounded-full bg-white shadow transition-transform ${
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
}`} />
</button>
</div>
{/* Display name */}
<div>
<label className="block text-sm font-medium text-dark-200 mb-2">
{t('admin.paymentMethods.displayName', 'Отображаемое имя')}
</label>
<input
type="text"
value={customName}
onChange={e => 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"
/>
<p className="text-xs text-dark-500 mt-1">
{t('admin.paymentMethods.displayNameHint', 'Оставьте пустым для имени по умолчанию')}: {config.default_display_name}
</p>
</div>
{/* Sub-options */}
{config.available_sub_options && config.available_sub_options.length > 0 && (
<div>
<label className="block text-sm font-medium text-dark-200 mb-2">
{t('admin.paymentMethods.subOptions', 'Варианты оплаты')}
</label>
<div className="space-y-2">
{config.available_sub_options.map(opt => {
const enabled = subOptions[opt.id] !== false
return (
<button
key={opt.id}
onClick={() => setSubOptions(prev => ({ ...prev, [opt.id]: !enabled }))}
className={`w-full flex items-center justify-between p-3 rounded-xl border transition-all ${
enabled
? 'bg-dark-700/30 border-accent-500/30 text-dark-100'
: 'bg-dark-900/30 border-dark-800 text-dark-500'
}`}
>
<span className="text-sm">{opt.name}</span>
<div className={`w-5 h-5 rounded flex items-center justify-center ${
enabled ? 'bg-accent-500 text-white' : 'bg-dark-700 border border-dark-600'
}`}>
{enabled && <CheckIcon />}
</div>
</button>
)
})}
</div>
</div>
)}
{/* Min/Max amounts */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-dark-200 mb-2">
{t('admin.paymentMethods.minAmount', 'Мин. сумма (коп.)')}
</label>
<input
type="number"
value={minAmount}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-200 mb-2">
{t('admin.paymentMethods.maxAmount', 'Макс. сумма (коп.)')}
</label>
<input
type="number"
value={maxAmount}
onChange={e => 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"
/>
</div>
</div>
{/* Display conditions */}
<div className="pt-3 border-t border-dark-700">
<h3 className="text-sm font-semibold text-dark-200 mb-4">
{t('admin.paymentMethods.conditions', 'Условия отображения')}
</h3>
{/* User type filter */}
<div className="mb-4">
<label className="block text-sm text-dark-300 mb-2">
{t('admin.paymentMethods.userTypeFilter', 'Тип пользователя')}
</label>
<div className="flex gap-2">
{(['all', 'telegram', 'email'] as const).map(val => (
<button
key={val}
onClick={() => setUserTypeFilter(val)}
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
userTypeFilter === val
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
}`}
>
{val === 'all' ? t('admin.paymentMethods.userTypeAll', 'Все') :
val === 'telegram' ? 'Telegram' : 'Email'}
</button>
))}
</div>
</div>
{/* First topup filter */}
<div className="mb-4">
<label className="block text-sm text-dark-300 mb-2">
{t('admin.paymentMethods.firstTopupFilter', 'Первое пополнение')}
</label>
<div className="flex gap-2">
{(['any', 'yes', 'no'] as const).map(val => (
<button
key={val}
onClick={() => setFirstTopupFilter(val)}
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
firstTopupFilter === val
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
}`}
>
{val === 'any' ? t('admin.paymentMethods.firstTopupAny', 'Не важно') :
val === 'yes' ? t('admin.paymentMethods.firstTopupYes', 'Было') :
t('admin.paymentMethods.firstTopupNo', 'Не было')}
</button>
))}
</div>
</div>
{/* Promo groups filter */}
<div>
<label className="block text-sm text-dark-300 mb-2">
{t('admin.paymentMethods.promoGroupFilter', 'Промо-группы')}
</label>
<div className="flex gap-2 mb-3">
{(['all', 'selected'] as const).map(val => (
<button
key={val}
onClick={() => setPromoGroupFilterMode(val)}
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
promoGroupFilterMode === val
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
}`}
>
{val === 'all'
? t('admin.paymentMethods.promoGroupAll', 'Все группы')
: t('admin.paymentMethods.promoGroupSelected', 'Выбранные')
}
</button>
))}
</div>
{promoGroupFilterMode === 'selected' && (
<div className="max-h-48 overflow-y-auto space-y-1.5 p-3 rounded-xl bg-dark-900/30 border border-dark-700/50">
{promoGroups.length === 0 ? (
<p className="text-sm text-dark-500 text-center py-2">
{t('admin.paymentMethods.noPromoGroups', 'Нет промо-групп')}
</p>
) : (
promoGroups.map(group => {
const selected = selectedPromoGroupIds.includes(group.id)
return (
<button
key={group.id}
onClick={() => togglePromoGroup(group.id)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
selected
? 'bg-accent-500/15 text-accent-300'
: 'text-dark-400 hover:bg-dark-800/50'
}`}
>
<span>{group.name}</span>
<div className={`w-4 h-4 rounded flex items-center justify-center ${
selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
}`}>
{selected && <CheckIcon />}
</div>
</button>
)
})
)}
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="sticky bottom-0 flex items-center gap-3 p-5 border-t border-dark-700 bg-dark-800 rounded-b-2xl">
<button
onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors font-medium"
>
{t('common.cancel', 'Отмена')}
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="flex-1 px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-400 disabled:opacity-50 transition-colors font-medium flex items-center justify-center gap-2"
>
{isSaving ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<SaveIcon />
)}
{t('common.save', 'Сохранить')}
</button>
</div>
</div>
</div>
)
}
// ============ Toast ============
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
useEffect(() => {
const timer = setTimeout(onClose, 3000)
return () => clearTimeout(timer)
}, [onClose])
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-3 rounded-xl bg-success-500/90 text-white text-sm font-medium shadow-lg backdrop-blur-sm animate-fade-in flex items-center gap-2">
<CheckIcon />
{message}
</div>
)
}
// ============ Main Page ============
export default function AdminPaymentMethods() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [methods, setMethods] = useState<PaymentMethodConfig[]>([])
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodConfig | null>(null)
const [orderChanged, setOrderChanged] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(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', 'Порядок сохранён'))
},
onError: () => {
setToastMessage(t('common.error', 'Ошибка'))
},
})
// Update method mutation
const updateMethodMutation = useMutation({
mutationFn: ({ methodId, data }: { methodId: string; data: Record<string, unknown> }) =>
adminPaymentMethodsApi.update(methodId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] })
setSelectedMethod(null)
setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены'))
},
onError: () => {
setToastMessage(t('common.error', 'Ошибка'))
},
})
// 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)
if (oldIndex === -1 || newIndex === -1) return prev
return arrayMove(prev, oldIndex, newIndex)
})
setOrderChanged(true)
}
}, [])
const handleSaveOrder = () => {
saveOrderMutation.mutate(methods.map(m => m.method_id))
}
const handleSaveMethod = (methodId: string, data: Record<string, unknown>) => {
updateMethodMutation.mutate({ methodId, data })
}
const handleCloseToast = useCallback(() => setToastMessage(null), [])
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<Link
to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
>
<BackIcon />
</Link>
<div>
<h1 className="text-2xl font-bold text-dark-50">{t('admin.paymentMethods.title', 'Платёжные методы')}</h1>
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description', 'Настройка порядка и условий отображения')}</p>
</div>
</div>
{orderChanged && (
<button
onClick={handleSaveOrder}
disabled={saveOrderMutation.isPending}
className="btn-primary flex items-center gap-2"
>
{saveOrderMutation.isPending ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<SaveIcon />
)}
{t('admin.paymentMethods.saveOrder', 'Сохранить порядок')}
</button>
)}
</div>
{/* Drag hint */}
<div className="text-sm text-dark-500 flex items-center gap-2">
<GripIcon />
{t('admin.paymentMethods.dragHint', 'Перетаскивайте карточки для изменения порядка. Нажмите для настройки.')}
</div>
{/* Methods list */}
<div className="card">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : methods.length > 0 ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={methods.map(m => m.method_id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{methods.map(config => (
<SortablePaymentCard
key={config.method_id}
config={config}
onClick={() => setSelectedMethod(config)}
/>
))}
</div>
</SortableContext>
</DndContext>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<span className="text-3xl">{'\uD83D\uDCB3'}</span>
</div>
<div className="text-dark-400">{t('admin.paymentMethods.noMethods', 'Нет настроенных платёжных методов')}</div>
</div>
)}
</div>
{/* Detail Modal */}
{selectedMethod && (
<PaymentMethodDetailModal
config={selectedMethod}
promoGroups={promoGroups}
onClose={() => setSelectedMethod(null)}
onSave={handleSaveMethod}
isSaving={updateMethodMutation.isPending}
/>
)}
{/* Toast */}
{toastMessage && (
<Toast message={toastMessage} onClose={handleCloseToast} />
)}
</div>
)
}

View File

@@ -1657,7 +1657,14 @@ export default function Subscription() {
</div>
<div>
<span className="text-dark-500">{t('subscription.devices')}:</span>
<span className="ml-2 text-dark-200">{selectedTariff.device_limit}</span>
<span className="ml-2 text-dark-200">
{selectedTariff.device_limit}
{selectedTariff.extra_devices_count > 0 && (
<span className="ml-1 text-accent-400 text-xs">
(+{selectedTariff.extra_devices_count})
</span>
)}
</span>
</div>
<div>
<span className="text-dark-500">{t('subscription.servers')}:</span>
@@ -1951,17 +1958,33 @@ export default function Subscription() {
</div>
</div>
) : selectedTariffPeriod && (
<div className="flex justify-between text-sm text-dark-300">
<span>Период: {selectedTariffPeriod.label}</span>
<div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span>
{(hasExistingPeriodDiscount || promoPeriod.original) && (
<span className="text-dark-500 text-xs line-through">
{formatPrice(hasExistingPeriodDiscount ? selectedTariffPeriod.original_price_kopeks! : promoPeriod.original!)}
</span>
)}
</div>
</div>
<>
{/* Если есть доп. устройства - показываем разбивку */}
{(selectedTariffPeriod.extra_devices_count ?? 0) > 0 && selectedTariffPeriod.base_tariff_price_kopeks ? (
<>
<div className="flex justify-between text-sm text-dark-300">
<span>{t('subscription.baseTariff')}: {selectedTariffPeriod.label}</span>
<span>{formatPrice(selectedTariffPeriod.base_tariff_price_kopeks)}</span>
</div>
<div className="flex justify-between text-sm text-dark-300">
<span>{t('subscription.extraDevices')} ({selectedTariffPeriod.extra_devices_count})</span>
<span>+{formatPrice(selectedTariffPeriod.extra_devices_cost_kopeks ?? 0)}</span>
</div>
</>
) : (
<div className="flex justify-between text-sm text-dark-300">
<span>Период: {selectedTariffPeriod.label}</span>
<div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span>
{(hasExistingPeriodDiscount || promoPeriod.original) && (
<span className="text-dark-500 text-xs line-through">
{formatPrice(hasExistingPeriodDiscount ? selectedTariffPeriod.original_price_kopeks! : promoPeriod.original!)}
</span>
)}
</div>
</div>
)}
</>
)}
{useCustomTraffic && selectedTariff.custom_traffic_enabled && (
<div className="flex justify-between text-sm text-dark-300">

View File

@@ -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[]
@@ -556,3 +564,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<string, boolean> | 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
}