mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Ключ был объявлен дважды: как подзаголовок страницы списка методов и как подпись поля формы. Побеждало последнее определение, поэтому на странице «Платёжные методы» вместо «Настройка порядка и условий отображения» показывалось «Описание». Подзаголовок переименован в subtitle, пара description/descriptionHint осталась за полем формы. В zh/fa подписи поля не было вовсе — там под description лежал текст подзаголовка, поэтому форма показывала не то; добавлены недостающие переводы вместо русского фолбэка. Заодно форматирование AdminPaymentMethodEdit.tsx: шаг format:check в CI не доходил до выполнения, пока падал lint. Чинит красный lint в CI ветки dev.
762 lines
29 KiB
TypeScript
762 lines
29 KiB
TypeScript
import { useState, useEffect, useRef, type ChangeEvent } from 'react';
|
||
import { useParams, useNavigate } from 'react-router';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||
import { adminOverpayCertificateApi, OVERPAY_CERT_MAX_SIZE } from '../api/adminOverpayCertificate';
|
||
import { METHOD_LABELS } from '../constants/paymentMethods';
|
||
import type { PromoGroupSimple } from '../types';
|
||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
||
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
||
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||
import { localeMap } from '../utils/withdrawalUtils';
|
||
import { PermissionGate } from '@/components/auth/PermissionGate';
|
||
import { BackIcon, CheckIcon, SaveIcon } from '@/components/icons';
|
||
|
||
function extractErrorDetail(err: unknown): string | null {
|
||
const error = err as { response?: { data?: { detail?: unknown } } };
|
||
const detail = error.response?.data?.detail;
|
||
return typeof detail === 'string' ? detail : null;
|
||
}
|
||
|
||
function OverpayCertificateSection() {
|
||
const { t, i18n } = useTranslation();
|
||
const queryClient = useQueryClient();
|
||
const haptic = useHapticFeedback();
|
||
const confirm = useDestructiveConfirm();
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [certFile, setCertFile] = useState<File | null>(null);
|
||
const [passphrase, setPassphrase] = useState('');
|
||
const [certError, setCertError] = useState<string | null>(null);
|
||
const [certWarning, setCertWarning] = useState<string | null>(null);
|
||
|
||
const { data: certStatus, isLoading } = useQuery({
|
||
queryKey: ['admin', 'overpay-certificate'],
|
||
queryFn: adminOverpayCertificateApi.getStatus,
|
||
});
|
||
|
||
const uploadMutation = useMutation({
|
||
mutationFn: (payload: { file: File; passphrase: string }) =>
|
||
adminOverpayCertificateApi.upload(payload.file, payload.passphrase),
|
||
onSuccess: (resp) => {
|
||
haptic.success();
|
||
setCertError(null);
|
||
setCertWarning(resp.warning ?? null);
|
||
setCertFile(null);
|
||
setPassphrase('');
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
queryClient.invalidateQueries({ queryKey: ['admin', 'overpay-certificate'] });
|
||
},
|
||
onError: (err) => {
|
||
haptic.error();
|
||
setCertWarning(null);
|
||
setCertError(extractErrorDetail(err) ?? t('admin.paymentMethods.overpayCertUploadError'));
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: adminOverpayCertificateApi.remove,
|
||
onSuccess: () => {
|
||
haptic.success();
|
||
setCertError(null);
|
||
setCertWarning(null);
|
||
queryClient.invalidateQueries({ queryKey: ['admin', 'overpay-certificate'] });
|
||
},
|
||
onError: (err) => {
|
||
haptic.error();
|
||
setCertError(extractErrorDetail(err) ?? t('admin.paymentMethods.overpayCertDeleteError'));
|
||
},
|
||
});
|
||
|
||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||
setCertError(null);
|
||
setCertWarning(null);
|
||
const selected = e.target.files?.[0] ?? null;
|
||
if (selected && selected.size > OVERPAY_CERT_MAX_SIZE) {
|
||
setCertFile(null);
|
||
e.target.value = '';
|
||
setCertError(t('admin.paymentMethods.overpayCertFileTooLarge'));
|
||
return;
|
||
}
|
||
setCertFile(selected);
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
const confirmed = await confirm(t('admin.paymentMethods.overpayCertConfirmDelete'));
|
||
if (confirmed) deleteMutation.mutate();
|
||
};
|
||
|
||
const expiryDate = certStatus?.not_valid_after
|
||
? new Date(certStatus.not_valid_after).toLocaleDateString(localeMap[i18n.language] || 'ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
})
|
||
: null;
|
||
|
||
const isExpired = certStatus?.not_valid_after
|
||
? new Date(certStatus.not_valid_after).getTime() < Date.now()
|
||
: false;
|
||
|
||
return (
|
||
<div className="card space-y-4">
|
||
<h3 className="text-sm font-semibold text-dark-200">
|
||
{t('admin.paymentMethods.overpayCertTitle')}
|
||
</h3>
|
||
|
||
{isLoading ? (
|
||
<div className="skeleton h-10 w-full rounded-xl" />
|
||
) : certStatus ? (
|
||
<div>
|
||
{certStatus.valid ? (
|
||
<>
|
||
{isExpired ? (
|
||
<p className="text-sm text-warning-400">
|
||
{t('admin.paymentMethods.overpayCertExpired', { date: expiryDate })}
|
||
</p>
|
||
) : (
|
||
<p className="text-sm text-success-400">
|
||
{t('admin.paymentMethods.overpayCertValid', { date: expiryDate })}
|
||
</p>
|
||
)}
|
||
{certStatus.subject && (
|
||
<p className="mt-1 break-all text-xs text-dark-500">{certStatus.subject}</p>
|
||
)}
|
||
</>
|
||
) : certStatus.uploaded ? (
|
||
<p className="text-sm text-warning-400">
|
||
{t('admin.paymentMethods.overpayCertUnreadable')}
|
||
</p>
|
||
) : (
|
||
<p className="text-sm text-dark-400">{t('admin.paymentMethods.overpayCertMissing')}</p>
|
||
)}
|
||
{certStatus.env_locked_path && (
|
||
<p className="mt-1 text-xs text-dark-500">
|
||
{t('admin.paymentMethods.overpayCertEnvLockedPath')}
|
||
</p>
|
||
)}
|
||
{certStatus.env_locked_passphrase && (
|
||
<p className="mt-1 text-xs text-dark-500">
|
||
{t('admin.paymentMethods.overpayCertEnvLockedPassphrase')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||
{t('admin.paymentMethods.overpayCertFile')}
|
||
</label>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".p12,.pfx"
|
||
onChange={handleFileChange}
|
||
className="hidden"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-dark-600 bg-dark-800/50 p-4 text-sm text-dark-400 transition-colors hover:border-dark-500 hover:text-dark-300"
|
||
>
|
||
{certFile ? certFile.name : t('admin.paymentMethods.overpayCertChooseFile')}
|
||
</button>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||
{t('admin.paymentMethods.overpayCertPassphrase')}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
autoComplete="off"
|
||
value={passphrase}
|
||
onChange={(e) => setPassphrase(e.target.value)}
|
||
placeholder={t('admin.paymentMethods.overpayCertPassphrasePlaceholder')}
|
||
className="input"
|
||
/>
|
||
</div>
|
||
|
||
{certError && <p className="text-sm text-error-400">{certError}</p>}
|
||
{certWarning && <p className="text-sm text-warning-400">{certWarning}</p>}
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => certFile && uploadMutation.mutate({ file: certFile, passphrase })}
|
||
disabled={!certFile || uploadMutation.isPending}
|
||
className="btn-primary flex flex-1 items-center justify-center gap-2"
|
||
>
|
||
{uploadMutation.isPending && (
|
||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||
)}
|
||
{t('admin.paymentMethods.overpayCertUpload')}
|
||
</button>
|
||
{certStatus?.uploaded && (
|
||
<button
|
||
type="button"
|
||
onClick={handleDelete}
|
||
disabled={deleteMutation.isPending}
|
||
className="btn-danger"
|
||
>
|
||
{t('admin.paymentMethods.overpayCertDelete')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function AdminPaymentMethodEdit() {
|
||
const { t } = useTranslation();
|
||
const { methodId } = useParams<{ methodId: string }>();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const { capabilities } = usePlatform();
|
||
|
||
// 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 [customDesc, setCustomDesc] = useState('');
|
||
const [subOptions, setSubOptions] = useState<Record<string, boolean>>({});
|
||
const [minAmount, setMinAmount] = useState<number | ''>('');
|
||
const [maxAmount, setMaxAmount] = useState<number | ''>('');
|
||
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[]>([]);
|
||
const [openUrlDirect, setOpenUrlDirect] = useState(false);
|
||
const [quickAmounts, setQuickAmounts] = useState<number[]>([]);
|
||
const [quickAmountInput, setQuickAmountInput] = useState('');
|
||
const [quickAmountsError, setQuickAmountsError] = useState<string | null>(null);
|
||
|
||
// Initialize state when config loads
|
||
useEffect(() => {
|
||
if (config) {
|
||
setIsEnabled(config.is_enabled);
|
||
setCustomName(config.display_name || '');
|
||
setCustomDesc(config.description || '');
|
||
setSubOptions(config.sub_options || {});
|
||
setMinAmount(config.min_amount_kopeks ?? '');
|
||
setMaxAmount(config.max_amount_kopeks ?? '');
|
||
setUserTypeFilter(config.user_type_filter);
|
||
setFirstTopupFilter(config.first_topup_filter);
|
||
setPromoGroupFilterMode(config.promo_group_filter_mode);
|
||
setSelectedPromoGroupIds(config.allowed_promo_group_ids);
|
||
// ?? false — защита от stale-config (backend ещё не пришёл с миграцией)
|
||
setOpenUrlDirect(config.open_url_direct ?? false);
|
||
setQuickAmounts((config.quick_amounts ?? []).map((kopeks) => kopeks / 100));
|
||
}
|
||
}, [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 : [],
|
||
open_url_direct: openUrlDirect,
|
||
};
|
||
|
||
// Display name
|
||
if (customName.trim()) {
|
||
data.display_name = customName.trim();
|
||
} else {
|
||
data.reset_display_name = true;
|
||
}
|
||
|
||
// Description
|
||
if (customDesc.trim()) {
|
||
data.description = customDesc.trim();
|
||
} else {
|
||
data.reset_description = true;
|
||
}
|
||
|
||
// Sub-options
|
||
if (config.available_sub_options) {
|
||
data.sub_options = subOptions;
|
||
}
|
||
|
||
// Amounts
|
||
if (minAmount !== '') {
|
||
data.min_amount_kopeks = toNumber(minAmount) || null;
|
||
} else {
|
||
data.reset_min_amount = true;
|
||
}
|
||
if (maxAmount !== '') {
|
||
data.max_amount_kopeks = toNumber(maxAmount) || null;
|
||
} else {
|
||
data.reset_max_amount = true;
|
||
}
|
||
|
||
if (quickAmounts.length > 0) {
|
||
data.quick_amounts = [...quickAmounts]
|
||
.sort((a, b) => a - b)
|
||
.map((rubles) => Math.round(rubles * 100));
|
||
} else {
|
||
data.reset_quick_amounts = true;
|
||
}
|
||
|
||
updateMethodMutation.mutate(data);
|
||
};
|
||
|
||
const togglePromoGroup = (id: number) => {
|
||
setSelectedPromoGroupIds((prev) =>
|
||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||
);
|
||
};
|
||
|
||
const addQuickAmount = () => {
|
||
setQuickAmountsError(null);
|
||
const parsed = parseFloat(quickAmountInput.replace(',', '.'));
|
||
const value = Math.round(parsed);
|
||
if (isNaN(value) || value <= 0) {
|
||
setQuickAmountsError(t('admin.paymentMethods.quickAmountsInvalid'));
|
||
return;
|
||
}
|
||
if (quickAmounts.includes(value)) {
|
||
setQuickAmountInput('');
|
||
return;
|
||
}
|
||
if (quickAmounts.length >= 10) {
|
||
setQuickAmountsError(t('admin.paymentMethods.quickAmountsLimit'));
|
||
return;
|
||
}
|
||
setQuickAmounts((prev) => [...prev, value].sort((a, b) => a - b));
|
||
setQuickAmountInput('');
|
||
};
|
||
|
||
const removeQuickAmount = (value: number) => {
|
||
setQuickAmounts((prev) => prev.filter((amount) => amount !== value));
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="min-h-viewport flex 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">
|
||
{/* Show back button only on web, not in Telegram Mini App */}
|
||
{!capabilities.hasBackButton && (
|
||
<button
|
||
onClick={() => navigate('/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 />
|
||
</button>
|
||
)}
|
||
<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;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-3">
|
||
{/* Show back button only on web, not in Telegram Mini App */}
|
||
{!capabilities.hasBackButton && (
|
||
<button
|
||
onClick={() => navigate('/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 />
|
||
</button>
|
||
)}
|
||
<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)}
|
||
role="switch"
|
||
aria-checked={isEnabled}
|
||
aria-label={t('admin.paymentMethods.methodEnabled')}
|
||
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||
isEnabled ? 'left-6' : 'left-1'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Open URL directly toggle */}
|
||
<div className="flex items-center justify-between border-t border-dark-800 pt-6">
|
||
<div className="pr-4">
|
||
<div className="text-sm font-medium text-dark-200">
|
||
{t('admin.paymentMethods.openUrlDirect', 'Открывать страницу оплаты сразу')}
|
||
</div>
|
||
<div className="mt-0.5 text-xs text-dark-500">
|
||
{t(
|
||
'admin.paymentMethods.openUrlDirectHint',
|
||
'Без панели со ссылкой — провайдер открывается внутри MiniApp/вкладки сразу после клика. После оплаты юзер возвращается на /balance/result.',
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setOpenUrlDirect(!openUrlDirect)}
|
||
role="switch"
|
||
aria-checked={openUrlDirect}
|
||
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
|
||
openUrlDirect ? 'bg-accent-500' : 'bg-dark-600'
|
||
}`}
|
||
aria-label={t('admin.paymentMethods.openUrlDirect', 'Open payment page directly')}
|
||
>
|
||
<span
|
||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||
openUrlDirect ? 'left-6' : 'left-1'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Display name */}
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||
{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>
|
||
|
||
{/* Description */}
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||
{t('admin.paymentMethods.description')}
|
||
</label>
|
||
<textarea
|
||
value={customDesc}
|
||
onChange={(e) => setCustomDesc(e.target.value)}
|
||
rows={2}
|
||
className="input"
|
||
/>
|
||
<p className="mt-1 text-xs text-dark-500">{t('admin.paymentMethods.descriptionHint')}</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-300">
|
||
{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-on-accent'
|
||
: '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-300">
|
||
{t('admin.paymentMethods.minAmount')}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={minAmount}
|
||
onChange={createNumberInputHandler(setMinAmount, 0)}
|
||
placeholder={config.default_min_amount_kopeks.toString()}
|
||
className="input"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||
{t('admin.paymentMethods.maxAmount')}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={maxAmount}
|
||
onChange={createNumberInputHandler(setMaxAmount, 0)}
|
||
placeholder={config.default_max_amount_kopeks.toString()}
|
||
className="input"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||
{t('admin.paymentMethods.quickAmounts')}
|
||
</label>
|
||
{quickAmounts.length > 0 && (
|
||
<div className="mb-2 flex flex-wrap gap-2">
|
||
{quickAmounts.map((value) => (
|
||
<button
|
||
key={value}
|
||
type="button"
|
||
onClick={() => removeQuickAmount(value)}
|
||
aria-label={t('admin.paymentMethods.quickAmountsRemove', { value })}
|
||
className="flex items-center gap-1.5 rounded-xl border border-accent-500/30 bg-accent-500/10 px-3 py-1.5 text-sm font-medium text-accent-300 transition-colors hover:border-error-500/40 hover:bg-error-500/10 hover:text-error-400"
|
||
>
|
||
<span>{value} ₽</span>
|
||
<span className="text-base leading-none">×</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={quickAmountInput}
|
||
onChange={(e) => setQuickAmountInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
addQuickAmount();
|
||
}
|
||
}}
|
||
placeholder={t('admin.paymentMethods.quickAmountsPlaceholder')}
|
||
className="input flex-1"
|
||
/>
|
||
<button type="button" onClick={addQuickAmount} className="btn-secondary shrink-0">
|
||
{t('admin.paymentMethods.quickAmountsAdd')}
|
||
</button>
|
||
</div>
|
||
{quickAmountsError && <p className="mt-1 text-xs text-error-400">{quickAmountsError}</p>}
|
||
<p className="mt-1 text-xs text-dark-500">
|
||
{t('admin.paymentMethods.quickAmountsHint', {
|
||
defaults: (config.default_quick_amounts ?? [])
|
||
.map((kopeks) => kopeks / 100)
|
||
.join(', '),
|
||
})}
|
||
</p>
|
||
</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-on-accent' : 'border border-dark-600'
|
||
}`}
|
||
>
|
||
{selected && <CheckIcon />}
|
||
</div>
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{config.method_id === 'overpay' && (
|
||
<PermissionGate permission="settings:read">
|
||
<OverpayCertificateSection />
|
||
</PermissionGate>
|
||
)}
|
||
|
||
{/* 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 className="h-4 w-4" />
|
||
)}
|
||
{t('admin.paymentMethods.saveButton')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|