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', 'Порядок сохранён')) }, onError: () => { setToastMessage(t('common.error', 'Ошибка')) }, }) // 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', 'Настройки сохранены')) }, 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) => { 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 && ( )}
) }