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 rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none" />

{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 rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none" />
setMaxAmount(e.target.value)} placeholder={config.default_max_amount_kopeks.toString()} className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none" />
{/* 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 && }
); }