Files
bedolaga-cabinet/src/pages/AdminPaymentMethodEdit.tsx
c0mrade d0e0b6b7e3 feat(cabinet): migrate all icons to the panel's Phosphor set
Replace the cabinet's hand-written heroicons-style SVG icon components with
the Remnawave panel's own icon family — Phosphor via react-icons/pi (Duotone).
All icons now live in a single central barrel (src/components/icons:
index + extended-icons + editor-icons, 144 icons); every feature file imports
from it instead of redefining inline SVGs (~440 icon defs removed).

- Add react-icons dependency
- Desktop nav (AppShell) now uses the central Phosphor icons, removing a
  stroke-vs-duotone inconsistency the partial migration introduced
- Icons with custom props (SortIcon's direction, expandable chevrons) kept as
  thin Phosphor wrappers; the RemnawaveIcon brand logo and animated SVGs
  (checkmarks, spinners) are intentionally left as-is
- Restore the original per-call-site icon sizes wherever the centralized
  default differed (211 call sites)
2026-05-31 18:50:50 +03:00

447 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } 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 { METHOD_LABELS } from '../constants/paymentMethods';
import type { PromoGroupSimple } from '../types';
import { usePlatform } from '../platform/hooks/usePlatform';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import { BackIcon, CheckIcon, SaveIcon } from '@/components/icons';
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 [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);
// 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 ?? '');
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);
}
}, [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;
}
// 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;
}
updateMethodMutation.mutate(data);
};
const togglePromoGroup = (id: number) => {
setSelectedPromoGroupIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
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>
{/* 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-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-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>
{/* 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 className="h-4 w-4" />
)}
{t('admin.paymentMethods.saveButton')}
</button>
</div>
</div>
);
}