mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
Move payment method editing to separate page and fix auth error handling
- Replace modal with dedicated AdminPaymentMethodEdit page - Add route /admin/payment-methods/:methodId/edit - Fix 401 error handling for auth endpoints to show error messages instead of reloading page - Update client.ts to use window.Telegram.WebApp.initData directly - Maintain useTelegramDnd for drag and drop functionality
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -54,6 +54,7 @@ const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit'));
|
|||||||
const AdminUsers = lazy(() => import('./pages/AdminUsers'));
|
const AdminUsers = lazy(() => import('./pages/AdminUsers'));
|
||||||
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
|
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
|
||||||
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
||||||
|
const AdminPaymentMethodEdit = lazy(() => import('./pages/AdminPaymentMethodEdit'));
|
||||||
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'));
|
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'));
|
||||||
const AdminPromoOfferTemplateEdit = lazy(() => import('./pages/AdminPromoOfferTemplateEdit'));
|
const AdminPromoOfferTemplateEdit = lazy(() => import('./pages/AdminPromoOfferTemplateEdit'));
|
||||||
const AdminPromoOfferSend = lazy(() => import('./pages/AdminPromoOfferSend'));
|
const AdminPromoOfferSend = lazy(() => import('./pages/AdminPromoOfferSend'));
|
||||||
@@ -531,6 +532,16 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/payment-methods/:methodId/edit"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminPaymentMethodEdit />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/promo-offers"
|
path="/admin/promo-offers"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
safeRedirectToLogin,
|
safeRedirectToLogin,
|
||||||
} from '../utils/token';
|
} from '../utils/token';
|
||||||
import { useBlockingStore } from '../store/blocking';
|
import { useBlockingStore } from '../store/blocking';
|
||||||
import { getTelegramInitData as getSDKInitData } from '../hooks/useTelegramSDK';
|
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
@@ -42,8 +41,7 @@ function ensureCsrfToken(): string {
|
|||||||
const getTelegramInitData = (): string | null => {
|
const getTelegramInitData = (): string | null => {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
// Use SDK helper to get initData
|
const initData = window.Telegram?.WebApp?.initData;
|
||||||
const initData = getSDKInitData();
|
|
||||||
if (initData) {
|
if (initData) {
|
||||||
tokenStorage.setTelegramInitData(initData);
|
tokenStorage.setTelegramInitData(initData);
|
||||||
return initData;
|
return initData;
|
||||||
@@ -155,6 +153,20 @@ apiClient.interceptors.response.use(
|
|||||||
|
|
||||||
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
// Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента
|
||||||
|
const authEndpoints = [
|
||||||
|
'/cabinet/auth/email/login',
|
||||||
|
'/cabinet/auth/telegram',
|
||||||
|
'/cabinet/auth/telegram/widget',
|
||||||
|
];
|
||||||
|
const requestUrl = originalRequest.url || '';
|
||||||
|
const isAuthEndpoint = authEndpoints.some((endpoint) => requestUrl.includes(endpoint));
|
||||||
|
|
||||||
|
if (isAuthEndpoint) {
|
||||||
|
// Пробрасываем ошибку в компонент для показа сообщения пользователю
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||||
|
|||||||
460
src/pages/AdminPaymentMethodEdit.tsx
Normal file
460
src/pages/AdminPaymentMethodEdit.tsx
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||||
|
import type { PromoGroupSimple } from '../types';
|
||||||
|
|
||||||
|
const BackIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-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 METHOD_ICONS: Record<string, string> = {
|
||||||
|
telegram_stars: '⭐',
|
||||||
|
tribute: '🎁',
|
||||||
|
cryptobot: '🪙',
|
||||||
|
heleket: '⚡',
|
||||||
|
yookassa: '🏦',
|
||||||
|
mulenpay: '💳',
|
||||||
|
pal24: '💸',
|
||||||
|
platega: '💰',
|
||||||
|
wata: '💧',
|
||||||
|
freekassa: '💵',
|
||||||
|
cloudpayments: '☁️',
|
||||||
|
kassa_ai: '🏦',
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
kassa_ai: 'Kassa AI',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg className="h-4 w-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="h-4 w-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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function AdminPaymentMethodEdit() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { methodId } = useParams<{ methodId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch payment methods
|
||||||
|
const { data: methods, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-payment-methods'],
|
||||||
|
queryFn: adminPaymentMethodsApi.getAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch promo groups
|
||||||
|
const { data: promoGroups = [] } = useQuery<PromoGroupSimple[]>({
|
||||||
|
queryKey: ['admin-payment-methods-promo-groups'],
|
||||||
|
queryFn: adminPaymentMethodsApi.getPromoGroups,
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = methods?.find((m) => m.method_id === methodId);
|
||||||
|
|
||||||
|
// Local state for editing
|
||||||
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
|
const [customName, setCustomName] = useState('');
|
||||||
|
const [subOptions, setSubOptions] = useState<Record<string, boolean>>({});
|
||||||
|
const [minAmount, setMinAmount] = useState('');
|
||||||
|
const [maxAmount, setMaxAmount] = useState('');
|
||||||
|
const [userTypeFilter, setUserTypeFilter] = useState<'all' | 'telegram' | 'email'>('all');
|
||||||
|
const [firstTopupFilter, setFirstTopupFilter] = useState<'any' | 'yes' | 'no'>('any');
|
||||||
|
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState<'all' | 'selected'>('all');
|
||||||
|
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// Initialize state when config loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
setIsEnabled(config.is_enabled);
|
||||||
|
setCustomName(config.display_name || '');
|
||||||
|
setSubOptions(config.sub_options || {});
|
||||||
|
setMinAmount(config.min_amount_kopeks?.toString() || '');
|
||||||
|
setMaxAmount(config.max_amount_kopeks?.toString() || '');
|
||||||
|
setUserTypeFilter(config.user_type_filter);
|
||||||
|
setFirstTopupFilter(config.first_topup_filter);
|
||||||
|
setPromoGroupFilterMode(config.promo_group_filter_mode);
|
||||||
|
setSelectedPromoGroupIds(config.allowed_promo_group_ids);
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// Update method mutation
|
||||||
|
const updateMethodMutation = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => adminPaymentMethodsApi.update(methodId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
|
||||||
|
navigate('/admin/payment-methods');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMethodMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePromoGroup = (id: number) => {
|
||||||
|
setSelectedPromoGroupIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/admin/payment-methods"
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-dark-50">
|
||||||
|
{t('admin.paymentMethods.notFound', 'Payment method not found')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = config.display_name || config.default_display_name;
|
||||||
|
const icon = METHOD_ICONS[config.method_id] || '💳';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/admin/payment-methods"
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</Link>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-dark-50">{displayName}</h1>
|
||||||
|
<p className="text-sm text-dark-500">
|
||||||
|
{METHOD_LABELS[config.method_id] || config.method_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="card 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="mt-0.5 text-xs text-warning-400">
|
||||||
|
{t('admin.paymentMethods.providerNotConfigured')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEnabled(!isEnabled)}
|
||||||
|
className={`relative h-7 w-12 rounded-full transition-colors ${
|
||||||
|
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform ${
|
||||||
|
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.paymentMethods.displayName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customName}
|
||||||
|
onChange={(e) => setCustomName(e.target.value)}
|
||||||
|
placeholder={config.default_display_name}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-dark-500">
|
||||||
|
{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="mb-2 block text-sm font-medium text-dark-200">
|
||||||
|
{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={`flex w-full items-center justify-between rounded-xl border p-3 transition-all ${
|
||||||
|
enabled
|
||||||
|
? 'border-accent-500/30 bg-dark-700/30 text-dark-100'
|
||||||
|
: 'border-dark-800 bg-dark-900/30 text-dark-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{opt.name}</span>
|
||||||
|
<div
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||||
|
enabled ? 'bg-accent-500 text-white' : 'border border-dark-600 bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{enabled && <CheckIcon />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Min/Max amounts */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.paymentMethods.minAmount')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={(e) => setMinAmount(e.target.value)}
|
||||||
|
placeholder={config.default_min_amount_kopeks.toString()}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.paymentMethods.maxAmount')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={(e) => setMaxAmount(e.target.value)}
|
||||||
|
placeholder={config.default_max_amount_kopeks.toString()}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display conditions */}
|
||||||
|
<div className="border-t border-dark-700 pt-3">
|
||||||
|
<h3 className="mb-4 text-sm font-semibold text-dark-200">
|
||||||
|
{t('admin.paymentMethods.conditions')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* User type filter */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="mb-2 block text-sm text-dark-300">
|
||||||
|
{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 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||||
|
userTypeFilter === val
|
||||||
|
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||||
|
: 'border border-dark-700 bg-dark-900/50 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="mb-2 block text-sm text-dark-300">
|
||||||
|
{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 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||||
|
firstTopupFilter === val
|
||||||
|
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||||
|
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{val === 'any'
|
||||||
|
? t('admin.paymentMethods.firstTopupAny')
|
||||||
|
: val === 'yes'
|
||||||
|
? t('admin.paymentMethods.firstTopupWas')
|
||||||
|
: t('admin.paymentMethods.firstTopupWasNot')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Promo groups filter */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm text-dark-300">
|
||||||
|
{t('admin.paymentMethods.promoGroupFilter')}
|
||||||
|
</label>
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
{(['all', 'selected'] as const).map((val) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
onClick={() => setPromoGroupFilterMode(val)}
|
||||||
|
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||||
|
promoGroupFilterMode === val
|
||||||
|
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||||
|
: 'border border-dark-700 bg-dark-900/50 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 space-y-1.5 overflow-y-auto rounded-xl border border-dark-700/50 bg-dark-900/30 p-3">
|
||||||
|
{promoGroups.length === 0 ? (
|
||||||
|
<p className="py-2 text-center text-sm text-dark-500">
|
||||||
|
{t('admin.paymentMethods.noPromoGroups')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
promoGroups.map((group) => {
|
||||||
|
const selected = selectedPromoGroupIds.includes(group.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
onClick={() => togglePromoGroup(group.id)}
|
||||||
|
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 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={`flex h-4 w-4 items-center justify-center rounded ${
|
||||||
|
selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected && <CheckIcon />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/admin/payment-methods')} className="btn-secondary flex-1">
|
||||||
|
{t('admin.paymentMethods.cancelButton')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateMethodMutation.isPending}
|
||||||
|
className="btn-primary flex flex-1 items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{updateMethodMutation.isPending ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
) : (
|
||||||
|
<SaveIcon />
|
||||||
|
)}
|
||||||
|
{t('admin.paymentMethods.saveButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
@@ -20,11 +23,22 @@ import {
|
|||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import type { PaymentMethodConfig } from '../types';
|
||||||
import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
|
|
||||||
|
|
||||||
// ============ Icons ============
|
// ============ Icons ============
|
||||||
|
|
||||||
|
const BackIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-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 = () => (
|
const GripIcon = () => (
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path
|
<path
|
||||||
@@ -41,12 +55,6 @@ const ChevronRightIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CloseIcon = () => (
|
|
||||||
<svg className="h-5 w-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 = () => (
|
const CheckIcon = () => (
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
@@ -80,21 +88,6 @@ const METHOD_ICONS: Record<string, string> = {
|
|||||||
kassa_ai: '\uD83C\uDFE6',
|
kassa_ai: '\uD83C\uDFE6',
|
||||||
};
|
};
|
||||||
|
|
||||||
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',
|
|
||||||
kassa_ai: 'Kassa AI',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ Sortable Card ============
|
// ============ Sortable Card ============
|
||||||
|
|
||||||
interface SortableCardProps {
|
interface SortableCardProps {
|
||||||
@@ -108,11 +101,11 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
|||||||
id: config.method_id,
|
id: config.method_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
zIndex: isDragging ? 50 : undefined,
|
zIndex: isDragging ? 50 : undefined,
|
||||||
position: isDragging ? 'relative' : undefined,
|
opacity: isDragging ? 0.85 : 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayName = config.display_name || config.default_display_name;
|
const displayName = config.display_name || config.default_display_name;
|
||||||
@@ -147,19 +140,19 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`group flex items-center gap-3 rounded-xl border p-4 ${
|
className={`group flex items-center gap-3 rounded-xl border p-4 transition-all ${
|
||||||
isDragging
|
isDragging
|
||||||
? 'border-accent-500/50 bg-dark-800 shadow-xl shadow-accent-500/20'
|
? 'border-accent-500/50 bg-dark-700/80 shadow-xl shadow-accent-500/10'
|
||||||
: config.is_enabled
|
: config.is_enabled
|
||||||
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
||||||
: 'border-dark-800/50 bg-dark-900/30 opacity-60'
|
: 'border-dark-800/50 bg-dark-900/30 opacity-60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Drag handle - larger touch target for mobile */}
|
{/* Drag handle */}
|
||||||
<button
|
<button
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
className="flex-shrink-0 cursor-grab touch-none rounded-lg p-2.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing sm:p-1.5"
|
className="flex-shrink-0 cursor-grab touch-manipulation rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
|
||||||
title={t('admin.paymentMethods.dragToReorder')}
|
title={t('admin.paymentMethods.dragToReorder')}
|
||||||
>
|
>
|
||||||
<GripIcon />
|
<GripIcon />
|
||||||
@@ -221,382 +214,6 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 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 m-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-dark-700 bg-dark-800 shadow-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-2xl border-b border-dark-700 bg-dark-800 p-5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-700/50 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="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6 p-5">
|
|
||||||
{/* 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="mt-0.5 text-xs text-warning-400">
|
|
||||||
{t('admin.paymentMethods.providerNotConfigured')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEnabled(!isEnabled)}
|
|
||||||
className={`relative h-7 w-12 rounded-full transition-colors ${
|
|
||||||
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform ${
|
|
||||||
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Display name */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
|
||||||
{t('admin.paymentMethods.displayName')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customName}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-dark-500">
|
|
||||||
{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="mb-2 block text-sm font-medium text-dark-200">
|
|
||||||
{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={`flex w-full items-center justify-between rounded-xl border p-3 transition-all ${
|
|
||||||
enabled
|
|
||||||
? 'border-accent-500/30 bg-dark-700/30 text-dark-100'
|
|
||||||
: 'border-dark-800 bg-dark-900/30 text-dark-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-sm">{opt.name}</span>
|
|
||||||
<div
|
|
||||||
className={`flex h-5 w-5 items-center justify-center rounded ${
|
|
||||||
enabled
|
|
||||||
? 'bg-accent-500 text-white'
|
|
||||||
: 'border border-dark-600 bg-dark-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{enabled && <CheckIcon />}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Min/Max amounts */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
|
||||||
{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 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
|
||||||
{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 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Display conditions */}
|
|
||||||
<div className="border-t border-dark-700 pt-3">
|
|
||||||
<h3 className="mb-4 text-sm font-semibold text-dark-200">
|
|
||||||
{t('admin.paymentMethods.conditions')}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* User type filter */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="mb-2 block text-sm text-dark-300">
|
|
||||||
{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 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
|
||||||
userTypeFilter === val
|
|
||||||
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
|
||||||
: 'border border-dark-700 bg-dark-900/50 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="mb-2 block text-sm text-dark-300">
|
|
||||||
{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 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
|
||||||
firstTopupFilter === val
|
|
||||||
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
|
||||||
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{val === 'any'
|
|
||||||
? t('admin.paymentMethods.firstTopupAny')
|
|
||||||
: val === 'yes'
|
|
||||||
? t('admin.paymentMethods.firstTopupWas')
|
|
||||||
: t('admin.paymentMethods.firstTopupWasNot')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Promo groups filter */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm text-dark-300">
|
|
||||||
{t('admin.paymentMethods.promoGroupFilter')}
|
|
||||||
</label>
|
|
||||||
<div className="mb-3 flex gap-2">
|
|
||||||
{(['all', 'selected'] as const).map((val) => (
|
|
||||||
<button
|
|
||||||
key={val}
|
|
||||||
onClick={() => setPromoGroupFilterMode(val)}
|
|
||||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
|
||||||
promoGroupFilterMode === val
|
|
||||||
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
|
||||||
: 'border border-dark-700 bg-dark-900/50 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 space-y-1.5 overflow-y-auto rounded-xl border border-dark-700/50 bg-dark-900/30 p-3">
|
|
||||||
{promoGroups.length === 0 ? (
|
|
||||||
<p className="py-2 text-center text-sm text-dark-500">
|
|
||||||
{t('admin.paymentMethods.noPromoGroups')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
promoGroups.map((group) => {
|
|
||||||
const selected = selectedPromoGroupIds.includes(group.id);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={group.id}
|
|
||||||
onClick={() => togglePromoGroup(group.id)}
|
|
||||||
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 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={`flex h-4 w-4 items-center justify-center rounded ${
|
|
||||||
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 rounded-b-2xl border-t border-dark-700 bg-dark-800 p-5">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 rounded-xl bg-dark-700 px-4 py-2.5 font-medium text-dark-300 transition-colors hover:bg-dark-600"
|
|
||||||
>
|
|
||||||
{t('admin.paymentMethods.cancelButton')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent-400 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
||||||
) : (
|
|
||||||
<SaveIcon />
|
|
||||||
)}
|
|
||||||
{t('admin.paymentMethods.saveButton')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Toast ============
|
// ============ Toast ============
|
||||||
|
|
||||||
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
|
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
|
||||||
@@ -617,10 +234,10 @@ function Toast({ message, onClose }: { message: string; onClose: () => void }) {
|
|||||||
|
|
||||||
export default function AdminPaymentMethods() {
|
export default function AdminPaymentMethods() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [methods, setMethods] = useState<PaymentMethodConfig[]>([]);
|
const [methods, setMethods] = useState<PaymentMethodConfig[]>([]);
|
||||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodConfig | null>(null);
|
|
||||||
const [orderChanged, setOrderChanged] = useState(false);
|
const [orderChanged, setOrderChanged] = useState(false);
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -630,12 +247,6 @@ export default function AdminPaymentMethods() {
|
|||||||
queryFn: adminPaymentMethodsApi.getAll,
|
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
|
// Sync fetched data to local state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fetchedMethods && !orderChanged) {
|
if (fetchedMethods && !orderChanged) {
|
||||||
@@ -656,23 +267,10 @@ export default function AdminPaymentMethods() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update method mutation
|
// DnD sensors
|
||||||
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 - PointerSensor handles both mouse and touch
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -715,10 +313,6 @@ export default function AdminPaymentMethods() {
|
|||||||
saveOrderMutation.mutate(methods.map((m) => m.method_id));
|
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), []);
|
const handleCloseToast = useCallback(() => setToastMessage(null), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -726,7 +320,12 @@ export default function AdminPaymentMethods() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AdminBackButton />
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.paymentMethods.title')}</h1>
|
<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>
|
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description')}</p>
|
||||||
@@ -763,6 +362,7 @@ export default function AdminPaymentMethods() {
|
|||||||
) : methods.length > 0 ? (
|
) : methods.length > 0 ? (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={handleDragCancel}
|
onDragCancel={handleDragCancel}
|
||||||
@@ -776,7 +376,7 @@ export default function AdminPaymentMethods() {
|
|||||||
<SortablePaymentCard
|
<SortablePaymentCard
|
||||||
key={config.method_id}
|
key={config.method_id}
|
||||||
config={config}
|
config={config}
|
||||||
onClick={() => setSelectedMethod(config)}
|
onClick={() => navigate(`/admin/payment-methods/${config.method_id}/edit`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -792,17 +392,6 @@ export default function AdminPaymentMethods() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail Modal */}
|
|
||||||
{selectedMethod && (
|
|
||||||
<PaymentMethodDetailModal
|
|
||||||
config={selectedMethod}
|
|
||||||
promoGroups={promoGroups}
|
|
||||||
onClose={() => setSelectedMethod(null)}
|
|
||||||
onSave={handleSaveMethod}
|
|
||||||
isSaving={updateMethodMutation.isPending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toastMessage && <Toast message={toastMessage} onClose={handleCloseToast} />}
|
{toastMessage && <Toast message={toastMessage} onClose={handleCloseToast} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user