Files
bedolaga-cabinet/src/pages/AdminPaymentMethodEdit.tsx
c0mrade 7b0a72db5b fix(a11y): switch semantics for admin form toggles
Add role=switch + aria-checked + aria-label to 12 ad-hoc toggle buttons across admin
create/edit forms (AdminCampaignCreate/Edit, AdminPromocodeCreate, AdminPromoGroupCreate,
AdminPromoOfferTemplateEdit, AdminPaymentMethodEdit, AdminTariffCreate) so screen readers
announce on/off state (WCAG 4.1.2).
2026-05-26 00:03:17 +03:00

473 lines
18 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';
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 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();
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 />
)}
{t('admin.paymentMethods.saveButton')}
</button>
</div>
</div>
);
}