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 && ( + + )} +
+ ) +}