fix: standardize admin form inputs, validation, and sync with backend constraints

- Unified all inputs to use .input CSS class, consistent label styles, and btn-primary/btn-secondary buttons
- Fixed number inputs to use number | '' pattern allowing field clearing without default replacement
- Added field-level validation with inline error borders and messages
- Synced frontend constraints with backend Pydantic schemas (maxLength, min/max values)
- Fixed toggle switch dimensions across all admin forms
This commit is contained in:
c0mrade
2026-02-06 19:37:07 +03:00
parent 88d9377adb
commit 6e7eb36f76
13 changed files with 364 additions and 227 deletions

View File

@@ -675,7 +675,7 @@ export default function AdminBanSystem() {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder={t('banSystem.users.searchPlaceholder')} placeholder={t('banSystem.users.searchPlaceholder')}
className="w-full rounded-lg border border-dark-700 bg-dark-800 py-2 pl-10 pr-4 text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none" className="input pl-10"
/> />
</div> </div>
<button <button
@@ -1333,7 +1333,7 @@ export default function AdminBanSystem() {
min={setting.min_value ?? undefined} min={setting.min_value ?? undefined}
max={setting.max_value ?? undefined} max={setting.max_value ?? undefined}
disabled={!setting.editable || settingLoading === setting.key} disabled={!setting.editable || settingLoading === setting.key}
className="w-24 rounded-lg border border-dark-700 bg-dark-900 px-3 py-1.5 text-sm text-dark-100 focus:border-accent-500 focus:outline-none disabled:opacity-50" className="input w-24"
/> />
) : setting.type === 'list' ? ( ) : setting.type === 'list' ? (
<div className="flex max-w-xs flex-wrap justify-end gap-1.5"> <div className="flex max-w-xs flex-wrap justify-end gap-1.5">
@@ -1353,7 +1353,7 @@ export default function AdminBanSystem() {
)} )}
{setting.editable && nodes && setting.key.includes('nodes') && ( {setting.editable && nodes && setting.key.includes('nodes') && (
<select <select
className="rounded border border-dark-700 bg-dark-900 px-2 py-1 text-xs text-dark-300 focus:border-accent-500 focus:outline-none" className="input py-1 text-xs"
onChange={(e) => { onChange={(e) => {
if (e.target.value) { if (e.target.value) {
const currentList = Array.isArray(setting.value) const currentList = Array.isArray(setting.value)

View File

@@ -224,7 +224,10 @@ export default function AdminCampaignCreate() {
createMutation.mutate(data); createMutation.mutate(data);
}; };
const isValid = name.trim() && startParameter.trim() && /^[a-zA-Z0-9_-]+$/.test(startParameter); const isNameValid = name.trim().length > 0;
const isStartParamValid =
startParameter.trim().length > 0 && /^[a-zA-Z0-9_-]+$/.test(startParameter);
const isValid = isNameValid && isStartParamValid;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -250,27 +253,36 @@ export default function AdminCampaignCreate() {
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.name')} {t('admin.campaigns.form.name')}
<span className="text-error-400">*</span>
</label> </label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="input" className={`input ${name.length > 0 && !isNameValid ? 'border-error-500/50' : ''}`}
placeholder={t('admin.campaigns.form.namePlaceholder')} placeholder={t('admin.campaigns.form.namePlaceholder')}
maxLength={255}
/> />
{name.length > 0 && !isNameValid && (
<p className="mt-1 text-xs text-error-400">
{t('admin.campaigns.validation.nameRequired')}
</p>
)}
</div> </div>
{/* Start Parameter */} {/* Start Parameter */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.startParameter')} {t('admin.campaigns.form.startParameter')}
<span className="text-error-400">*</span>
</label> </label>
<input <input
type="text" type="text"
value={startParameter} value={startParameter}
onChange={(e) => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))} onChange={(e) => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))}
className="input font-mono" className={`input font-mono ${startParameter.length > 0 && !isStartParamValid ? 'border-error-500/50' : ''}`}
placeholder="instagram_jan2024" placeholder="instagram_jan2024"
maxLength={100}
/> />
<p className="mt-1 text-xs text-dark-500"> <p className="mt-1 text-xs text-dark-500">
{t('admin.campaigns.form.startParameterHint')} {t('admin.campaigns.form.startParameterHint')}
@@ -354,7 +366,7 @@ export default function AdminCampaignCreate() {
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div> <div>
<label className="mb-2 block text-sm text-dark-400"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.days')} {t('admin.campaigns.form.days')}
</label> </label>
<input <input
@@ -366,7 +378,7 @@ export default function AdminCampaignCreate() {
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm text-dark-400"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.trafficGb')} {t('admin.campaigns.form.trafficGb')}
</label> </label>
<input <input
@@ -378,7 +390,7 @@ export default function AdminCampaignCreate() {
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm text-dark-400"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.devices')} {t('admin.campaigns.form.devices')}
</label> </label>
<input <input

View File

@@ -230,7 +230,10 @@ export default function AdminCampaignEdit() {
updateMutation.mutate(data); updateMutation.mutate(data);
}; };
const isValid = name.trim() && startParameter.trim() && /^[a-zA-Z0-9_-]+$/.test(startParameter); const isNameValid = name.trim().length > 0;
const isStartParamValid =
startParameter.trim().length > 0 && /^[a-zA-Z0-9_-]+$/.test(startParameter);
const isValid = isNameValid && isStartParamValid;
if (isLoading) { if (isLoading) {
return ( return (
@@ -286,27 +289,36 @@ export default function AdminCampaignEdit() {
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.name')} {t('admin.campaigns.form.name')}
<span className="text-error-400">*</span>
</label> </label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="input" className={`input ${name.length > 0 && !isNameValid ? 'border-error-500/50' : ''}`}
placeholder={t('admin.campaigns.form.namePlaceholder')} placeholder={t('admin.campaigns.form.namePlaceholder')}
maxLength={255}
/> />
{name.length > 0 && !isNameValid && (
<p className="mt-1 text-xs text-error-400">
{t('admin.campaigns.validation.nameRequired')}
</p>
)}
</div> </div>
{/* Start Parameter */} {/* Start Parameter */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.startParameter')} {t('admin.campaigns.form.startParameter')}
<span className="text-error-400">*</span>
</label> </label>
<input <input
type="text" type="text"
value={startParameter} value={startParameter}
onChange={(e) => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))} onChange={(e) => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))}
className="input font-mono" className={`input font-mono ${startParameter.length > 0 && !isStartParamValid ? 'border-error-500/50' : ''}`}
placeholder="instagram_jan2024" placeholder="instagram_jan2024"
maxLength={100}
/> />
<p className="mt-1 text-xs text-dark-500"> <p className="mt-1 text-xs text-dark-500">
{t('admin.campaigns.form.startParameterHint')} {t('admin.campaigns.form.startParameterHint')}
@@ -390,7 +402,7 @@ export default function AdminCampaignEdit() {
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div> <div>
<label className="mb-2 block text-sm text-dark-400"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.days')} {t('admin.campaigns.form.days')}
</label> </label>
<input <input
@@ -402,7 +414,7 @@ export default function AdminCampaignEdit() {
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm text-dark-400"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.trafficGb')} {t('admin.campaigns.form.trafficGb')}
</label> </label>
<input <input
@@ -414,7 +426,7 @@ export default function AdminCampaignEdit() {
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm text-dark-400"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.devices')} {t('admin.campaigns.form.devices')}
</label> </label>
<input <input

View File

@@ -334,14 +334,14 @@ function TemplateEditor({
{/* Subject */} {/* Subject */}
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.emailTemplates.subject')} {t('admin.emailTemplates.subject')}
</label> </label>
<input <input
type="text" type="text"
value={editSubject} value={editSubject}
onChange={(e) => handleSubjectChange(e.target.value)} onChange={(e) => handleSubjectChange(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500" className="input"
placeholder={t('admin.emailTemplates.subjectPlaceholder')} placeholder={t('admin.emailTemplates.subjectPlaceholder')}
/> />
</div> </div>
@@ -372,7 +372,7 @@ function TemplateEditor({
{/* Body HTML editor */} {/* Body HTML editor */}
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.emailTemplates.body')} {t('admin.emailTemplates.body')}
</label> </label>
<textarea <textarea
@@ -380,7 +380,7 @@ function TemplateEditor({
value={editBody} value={editBody}
onChange={(e) => handleBodyChange(e.target.value)} onChange={(e) => handleBodyChange(e.target.value)}
rows={12} rows={12}
className="min-h-[200px] w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 font-mono text-xs leading-relaxed text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 sm:min-h-[300px] sm:text-sm" className="input min-h-[200px] resize-y font-mono text-xs leading-relaxed sm:min-h-[300px] sm:text-sm"
placeholder="<h2>Title</h2><p>Content...</p>" placeholder="<h2>Title</h2><p>Content...</p>"
spellCheck={false} spellCheck={false}
/> />

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
import type { PromoGroupSimple } from '../types'; import type { PromoGroupSimple } from '../types';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
const BackIcon = () => ( const BackIcon = () => (
<svg <svg
@@ -89,8 +90,8 @@ export default function AdminPaymentMethodEdit() {
const [isEnabled, setIsEnabled] = useState(false); const [isEnabled, setIsEnabled] = useState(false);
const [customName, setCustomName] = useState(''); const [customName, setCustomName] = useState('');
const [subOptions, setSubOptions] = useState<Record<string, boolean>>({}); const [subOptions, setSubOptions] = useState<Record<string, boolean>>({});
const [minAmount, setMinAmount] = useState(''); const [minAmount, setMinAmount] = useState<number | ''>('');
const [maxAmount, setMaxAmount] = useState(''); const [maxAmount, setMaxAmount] = useState<number | ''>('');
const [userTypeFilter, setUserTypeFilter] = useState<'all' | 'telegram' | 'email'>('all'); const [userTypeFilter, setUserTypeFilter] = useState<'all' | 'telegram' | 'email'>('all');
const [firstTopupFilter, setFirstTopupFilter] = useState<'any' | 'yes' | 'no'>('any'); const [firstTopupFilter, setFirstTopupFilter] = useState<'any' | 'yes' | 'no'>('any');
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState<'all' | 'selected'>('all'); const [promoGroupFilterMode, setPromoGroupFilterMode] = useState<'all' | 'selected'>('all');
@@ -102,8 +103,8 @@ export default function AdminPaymentMethodEdit() {
setIsEnabled(config.is_enabled); setIsEnabled(config.is_enabled);
setCustomName(config.display_name || ''); setCustomName(config.display_name || '');
setSubOptions(config.sub_options || {}); setSubOptions(config.sub_options || {});
setMinAmount(config.min_amount_kopeks?.toString() || ''); setMinAmount(config.min_amount_kopeks ?? '');
setMaxAmount(config.max_amount_kopeks?.toString() || ''); setMaxAmount(config.max_amount_kopeks ?? '');
setUserTypeFilter(config.user_type_filter); setUserTypeFilter(config.user_type_filter);
setFirstTopupFilter(config.first_topup_filter); setFirstTopupFilter(config.first_topup_filter);
setPromoGroupFilterMode(config.promo_group_filter_mode); setPromoGroupFilterMode(config.promo_group_filter_mode);
@@ -144,13 +145,13 @@ export default function AdminPaymentMethodEdit() {
} }
// Amounts // Amounts
if (minAmount.trim()) { if (minAmount !== '') {
data.min_amount_kopeks = parseInt(minAmount, 10) || null; data.min_amount_kopeks = toNumber(minAmount) || null;
} else { } else {
data.reset_min_amount = true; data.reset_min_amount = true;
} }
if (maxAmount.trim()) { if (maxAmount !== '') {
data.max_amount_kopeks = parseInt(maxAmount, 10) || null; data.max_amount_kopeks = toNumber(maxAmount) || null;
} else { } else {
data.reset_max_amount = true; data.reset_max_amount = true;
} }
@@ -236,13 +237,13 @@ export default function AdminPaymentMethodEdit() {
</div> </div>
<button <button
onClick={() => setIsEnabled(!isEnabled)} onClick={() => setIsEnabled(!isEnabled)}
className={`relative h-7 w-12 rounded-full transition-colors ${ className={`relative h-6 w-11 rounded-full transition-colors ${
isEnabled ? 'bg-accent-500' : 'bg-dark-600' isEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`} }`}
> >
<span <span
className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform ${ className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5' isEnabled ? 'left-6' : 'left-1'
}`} }`}
/> />
</button> </button>
@@ -250,7 +251,7 @@ export default function AdminPaymentMethodEdit() {
{/* Display name */} {/* Display name */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-200"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.paymentMethods.displayName')} {t('admin.paymentMethods.displayName')}
</label> </label>
<input <input
@@ -268,7 +269,7 @@ export default function AdminPaymentMethodEdit() {
{/* Sub-options */} {/* Sub-options */}
{config.available_sub_options && config.available_sub_options.length > 0 && ( {config.available_sub_options && config.available_sub_options.length > 0 && (
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-200"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.paymentMethods.subOptions')} {t('admin.paymentMethods.subOptions')}
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
@@ -302,25 +303,25 @@ export default function AdminPaymentMethodEdit() {
{/* Min/Max amounts */} {/* Min/Max amounts */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-200"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.paymentMethods.minAmount')} {t('admin.paymentMethods.minAmount')}
</label> </label>
<input <input
type="number" type="number"
value={minAmount} value={minAmount}
onChange={(e) => setMinAmount(e.target.value)} onChange={createNumberInputHandler(setMinAmount, 0)}
placeholder={config.default_min_amount_kopeks.toString()} placeholder={config.default_min_amount_kopeks.toString()}
className="input" className="input"
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-200"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.paymentMethods.maxAmount')} {t('admin.paymentMethods.maxAmount')}
</label> </label>
<input <input
type="number" type="number"
value={maxAmount} value={maxAmount}
onChange={(e) => setMaxAmount(e.target.value)} onChange={createNumberInputHandler(setMaxAmount, 0)}
placeholder={config.default_max_amount_kopeks.toString()} placeholder={config.default_max_amount_kopeks.toString()}
className="input" className="input"
/> />

View File

@@ -44,8 +44,8 @@ const RefreshIcon = () => (
); );
interface PeriodDiscount { interface PeriodDiscount {
days: number; days: number | '';
percent: number; percent: number | '';
} }
export default function AdminPromoGroupCreate() { export default function AdminPromoGroupCreate() {
@@ -121,18 +121,20 @@ export default function AdminPromoGroupCreate() {
setPeriodDiscounts(periodDiscounts.filter((_, i) => i !== index)); setPeriodDiscounts(periodDiscounts.filter((_, i) => i !== index));
}; };
const updatePeriodDiscount = (index: number, field: 'days' | 'percent', value: number) => { const updatePeriodDiscount = (index: number, field: 'days' | 'percent', value: number | '') => {
const updated = [...periodDiscounts]; setPeriodDiscounts(
updated[index][field] = value; periodDiscounts.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
setPeriodDiscounts(updated); );
}; };
const handleSubmit = () => { const handleSubmit = () => {
// Convert periodDiscounts array to Record<number, number> // Convert periodDiscounts array to Record<number, number>
const periodDiscountsRecord: Record<number, number> = {}; const periodDiscountsRecord: Record<number, number> = {};
periodDiscounts.forEach((pd) => { periodDiscounts.forEach((pd) => {
if (pd.days > 0 && pd.percent > 0) { const days = pd.days === '' ? 0 : pd.days;
periodDiscountsRecord[pd.days] = pd.percent; const percent = pd.percent === '' ? 0 : pd.percent;
if (days > 0 && percent > 0) {
periodDiscountsRecord[days] = percent;
} }
}); });
@@ -196,9 +198,14 @@ export default function AdminPromoGroupCreate() {
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="input" className={`input ${name.length > 0 && name.trim().length === 0 ? 'border-error-500/50' : ''}`}
placeholder={t('admin.promoGroups.form.namePlaceholder')} placeholder={t('admin.promoGroups.form.namePlaceholder')}
/> />
{name.length > 0 && name.trim().length === 0 && (
<p className="mt-1 text-xs text-error-400">
{t('admin.promoGroups.form.nameRequired')}
</p>
)}
</div> </div>
{/* Category Discounts */} {/* Category Discounts */}
@@ -299,13 +306,12 @@ export default function AdminPromoGroupCreate() {
<input <input
type="number" type="number"
value={pd.days} value={pd.days}
onChange={(e) => onChange={(e) => {
updatePeriodDiscount( const val = e.target.value;
index, if (val === '') return updatePeriodDiscount(index, 'days', '');
'days', const num = parseInt(val);
Math.max(1, parseInt(e.target.value) || 1), if (!isNaN(num)) updatePeriodDiscount(index, 'days', num);
) }}
}
className="input w-20" className="input w-20"
min={1} min={1}
placeholder={t('admin.promoGroups.form.daysPlaceholder')} placeholder={t('admin.promoGroups.form.daysPlaceholder')}
@@ -314,13 +320,12 @@ export default function AdminPromoGroupCreate() {
<input <input
type="number" type="number"
value={pd.percent} value={pd.percent}
onChange={(e) => onChange={(e) => {
updatePeriodDiscount( const val = e.target.value;
index, if (val === '') return updatePeriodDiscount(index, 'percent', '');
'percent', const num = parseInt(val);
Math.min(100, Math.max(0, parseInt(e.target.value) || 0)), if (!isNaN(num)) updatePeriodDiscount(index, 'percent', num);
) }}
}
className="input w-20" className="input w-20"
min={0} min={0}
max={100} max={100}
@@ -371,13 +376,13 @@ export default function AdminPromoGroupCreate() {
<button <button
type="button" type="button"
onClick={() => setApplyToAddons(!applyToAddons)} onClick={() => setApplyToAddons(!applyToAddons)}
className={`relative h-6 w-10 rounded-full transition-colors ${ className={`relative h-6 w-11 rounded-full transition-colors ${
applyToAddons ? 'bg-accent-500' : 'bg-dark-600' applyToAddons ? 'bg-accent-500' : 'bg-dark-600'
}`} }`}
> >
<span <span
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${ className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
applyToAddons ? 'left-5' : 'left-1' applyToAddons ? 'left-6' : 'left-1'
}`} }`}
/> />
</button> </button>
@@ -388,10 +393,7 @@ export default function AdminPromoGroupCreate() {
{/* Footer */} {/* Footer */}
<div className="card"> <div className="card">
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button onClick={() => navigate('/admin/promo-groups')} className="btn-secondary">
onClick={() => navigate('/admin/promo-groups')}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('admin.promoGroups.form.cancel')} {t('admin.promoGroups.form.cancel')}
</button> </button>
<button <button

View File

@@ -300,8 +300,9 @@ export default function AdminPromoOfferSend() {
<div className="mx-auto max-w-2xl space-y-6"> <div className="mx-auto max-w-2xl space-y-6">
{/* Template Selection */} {/* Template Selection */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-6"> <div className="rounded-xl border border-dark-700 bg-dark-800 p-6">
<label className="mb-3 block text-sm font-medium text-dark-200"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promoOffers.send.offerTemplate')} {t('admin.promoOffers.send.offerTemplate')}
<span className="text-error-400">*</span>
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
{activeTemplates.map((template) => ( {activeTemplates.map((template) => (
@@ -344,8 +345,9 @@ export default function AdminPromoOfferSend() {
{/* Send Mode */} {/* Send Mode */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-6"> <div className="rounded-xl border border-dark-700 bg-dark-800 p-6">
<label className="mb-3 block text-sm font-medium text-dark-200"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promoOffers.send.sendTo')} {t('admin.promoOffers.send.sendTo')}
<span className="text-error-400">*</span>
</label> </label>
<div className="mb-4 flex gap-2"> <div className="mb-4 flex gap-2">
<button <button
@@ -376,7 +378,7 @@ export default function AdminPromoOfferSend() {
<select <select
value={selectedTarget} value={selectedTarget}
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)} onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2.5 text-dark-100 focus:border-accent-500 focus:outline-none" className="input"
> >
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => ( {Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => (
<option key={key} value={key}> <option key={key} value={key}>
@@ -428,7 +430,7 @@ export default function AdminPromoOfferSend() {
}} }}
onFocus={() => setShowDropdown(true)} onFocus={() => setShowDropdown(true)}
placeholder={t('admin.promoOffers.send.searchUserPlaceholder')} placeholder={t('admin.promoOffers.send.searchUserPlaceholder')}
className="w-full rounded-lg border border-dark-600 bg-dark-700 py-2.5 pl-10 pr-3 text-dark-100 focus:border-accent-500 focus:outline-none" className="input pl-10"
/> />
{isSearching && ( {isSearching && (
<div className="absolute right-3 top-1/2 -translate-y-1/2"> <div className="absolute right-3 top-1/2 -translate-y-1/2">
@@ -482,7 +484,7 @@ export default function AdminPromoOfferSend() {
{/* Preview */} {/* Preview */}
{selectedTemplate && ( {selectedTemplate && (
<div className="rounded-xl border border-dark-700 bg-dark-800 p-6"> <div className="rounded-xl border border-dark-700 bg-dark-800 p-6">
<h4 className="mb-3 text-sm font-medium text-dark-200"> <h4 className="mb-2 text-sm font-medium text-dark-300">
{t('admin.promoOffers.send.preview')} {t('admin.promoOffers.send.preview')}
</h4> </h4>
<div className="rounded-lg bg-dark-700/50 p-4"> <div className="rounded-lg bg-dark-700/50 p-4">
@@ -500,16 +502,13 @@ export default function AdminPromoOfferSend() {
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button onClick={() => navigate('/admin/promo-offers')} className="btn-secondary">
onClick={() => navigate('/admin/promo-offers')}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isValid() || broadcastMutation.isPending} disabled={!isValid() || broadcastMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-accent-500 px-6 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50" className="btn-primary flex items-center gap-2"
> >
<SendIcon /> <SendIcon />
{broadcastMutation.isPending {broadcastMutation.isPending

View File

@@ -126,51 +126,54 @@ export default function AdminPromoOfferTemplateEdit() {
<div className="rounded-xl border border-dark-700 bg-dark-800 p-6"> <div className="rounded-xl border border-dark-700 bg-dark-800 p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="mb-1 block text-sm text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promoOffers.form.templateName')} {t('admin.promoOffers.form.templateName')}
<span className="text-error-400">*</span>
</label> </label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" className={`input ${name.length > 0 && !name.trim() ? 'border-error-500/50' : ''}`}
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-sm text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promoOffers.form.messageText')} {t('admin.promoOffers.form.messageText')}
<span className="text-error-400">*</span>
</label> </label>
<textarea <textarea
value={messageText} value={messageText}
onChange={(e) => setMessageText(e.target.value)} onChange={(e) => setMessageText(e.target.value)}
rows={4} rows={4}
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" className={`input resize-none ${messageText.length > 0 && !messageText.trim() ? 'border-error-500/50' : ''}`}
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-sm text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promoOffers.form.buttonText')} {t('admin.promoOffers.form.buttonText')}
<span className="text-error-400">*</span>
</label> </label>
<input <input
type="text" type="text"
value={buttonText} value={buttonText}
onChange={(e) => setButtonText(e.target.value)} onChange={(e) => setButtonText(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" className={`input ${buttonText.length > 0 && !buttonText.trim() ? 'border-error-500/50' : ''}`}
/> />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promoOffers.form.validHours')} {t('admin.promoOffers.form.validHours')}
</label> </label>
<input <input
type="number" type="number"
value={validHours} value={validHours}
onChange={createNumberInputHandler(setValidHours, 1)} onChange={createNumberInputHandler(setValidHours, 1)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" className="input"
min={1} min={1}
placeholder="0" placeholder="0"
/> />
@@ -181,14 +184,14 @@ export default function AdminPromoOfferTemplateEdit() {
{!isTestAccess && ( {!isTestAccess && (
<div> <div>
<label className="mb-1 block text-sm text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promoOffers.form.discountPercent')} {t('admin.promoOffers.form.discountPercent')}
</label> </label>
<input <input
type="number" type="number"
value={discountPercent} value={discountPercent}
onChange={createNumberInputHandler(setDiscountPercent, 0, 100)} onChange={createNumberInputHandler(setDiscountPercent, 0, 100)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" className="input"
min={0} min={0}
max={100} max={100}
placeholder="0" placeholder="0"
@@ -199,14 +202,14 @@ export default function AdminPromoOfferTemplateEdit() {
{isTestAccess ? ( {isTestAccess ? (
<div> <div>
<label className="mb-1 block text-sm text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promoOffers.form.testDurationHours')} {t('admin.promoOffers.form.testDurationHours')}
</label> </label>
<input <input
type="number" type="number"
value={testDurationHours} value={testDurationHours}
onChange={createNumberInputHandler(setTestDurationHours, 0)} onChange={createNumberInputHandler(setTestDurationHours, 0)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" className="input"
min={0} min={0}
placeholder="0" placeholder="0"
/> />
@@ -216,14 +219,14 @@ export default function AdminPromoOfferTemplateEdit() {
</div> </div>
) : ( ) : (
<div> <div>
<label className="mb-1 block text-sm text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promoOffers.form.activeDiscountHours')} {t('admin.promoOffers.form.activeDiscountHours')}
</label> </label>
<input <input
type="number" type="number"
value={activeDiscountHours} value={activeDiscountHours}
onChange={createNumberInputHandler(setActiveDiscountHours, 0)} onChange={createNumberInputHandler(setActiveDiscountHours, 0)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" className="input"
min={0} min={0}
placeholder="0" placeholder="0"
/> />
@@ -237,13 +240,13 @@ export default function AdminPromoOfferTemplateEdit() {
<button <button
type="button" type="button"
onClick={() => setIsActive(!isActive)} onClick={() => setIsActive(!isActive)}
className={`relative h-6 w-10 rounded-full transition-colors ${ className={`relative h-6 w-11 rounded-full transition-colors ${
isActive ? 'bg-accent-500' : 'bg-dark-600' isActive ? 'bg-accent-500' : 'bg-dark-600'
}`} }`}
> >
<span <span
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${ className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
isActive ? 'left-5' : 'left-1' isActive ? 'left-6' : 'left-1'
}`} }`}
/> />
</button> </button>
@@ -256,16 +259,13 @@ export default function AdminPromoOfferTemplateEdit() {
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button onClick={() => navigate('/admin/promo-offers')} className="btn-secondary">
onClick={() => navigate('/admin/promo-offers')}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!name.trim() || updateMutation.isPending} disabled={!name.trim() || updateMutation.isPending}
className="rounded-lg bg-accent-500 px-6 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50" className="btn-primary"
> >
{updateMutation.isPending ? t('admin.promoOffers.form.saving') : t('common.save')} {updateMutation.isPending ? t('admin.promoOffers.form.saving') : t('common.save')}
</button> </button>

View File

@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
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 { createNumberInputHandler } from '../utils/inputHelpers';
import { import {
promocodesApi, promocodesApi,
PromoCodeDetail, PromoCodeDetail,
@@ -232,6 +233,7 @@ export default function AdminPromocodeCreate() {
onChange={(e) => setCode(e.target.value.toUpperCase())} onChange={(e) => setCode(e.target.value.toUpperCase())}
className={`input uppercase ${!isCodeValid && code.length > 0 ? 'border-error-500/50' : ''}`} className={`input uppercase ${!isCodeValid && code.length > 0 ? 'border-error-500/50' : ''}`}
placeholder="SUMMER2025" placeholder="SUMMER2025"
maxLength={50}
/> />
</div> </div>
@@ -268,14 +270,7 @@ export default function AdminPromocodeCreate() {
<input <input
type="number" type="number"
value={balanceBonusRubles} value={balanceBonusRubles}
onChange={(e) => { onChange={createNumberInputHandler(setBalanceBonusRubles, 0)}
const val = e.target.value;
if (val === '') {
setBalanceBonusRubles('');
} else {
setBalanceBonusRubles(Math.max(0, parseFloat(val) || 0));
}
}}
className="input w-32" className="input w-32"
min={0} min={0}
step={1} step={1}
@@ -296,14 +291,7 @@ export default function AdminPromocodeCreate() {
<input <input
type="number" type="number"
value={subscriptionDays} value={subscriptionDays}
onChange={(e) => { onChange={createNumberInputHandler(setSubscriptionDays, 0)}
const val = e.target.value;
if (val === '') {
setSubscriptionDays('');
} else {
setSubscriptionDays(Math.max(0, parseInt(val) || 0));
}
}}
className="input w-32" className="input w-32"
min={1} min={1}
placeholder="0" placeholder="0"
@@ -403,14 +391,7 @@ export default function AdminPromocodeCreate() {
<input <input
type="number" type="number"
value={maxUses} value={maxUses}
onChange={(e) => { onChange={createNumberInputHandler(setMaxUses, 0)}
const val = e.target.value;
if (val === '') {
setMaxUses('');
} else {
setMaxUses(Math.max(0, parseInt(val) || 0));
}
}}
className="input w-32" className="input w-32"
min={0} min={0}
placeholder="0" placeholder="0"

View File

@@ -152,7 +152,7 @@ export default function AdminServerEdit() {
{/* Original Name (readonly) */} {/* Original Name (readonly) */}
<div className="mb-4"> <div className="mb-4">
<label className="mb-2 block text-sm font-medium text-dark-100"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.servers.originalName')} {t('admin.servers.originalName')}
</label> </label>
<div className="rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-dark-400"> <div className="rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-dark-400">
@@ -162,21 +162,23 @@ export default function AdminServerEdit() {
{/* Display Name */} {/* Display Name */}
<div className="mb-4"> <div className="mb-4">
<label className="mb-2 block text-sm font-medium text-dark-100"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.servers.displayName')} {t('admin.servers.displayName')}
<span className="text-error-400">*</span>
</label> </label>
<input <input
type="text" type="text"
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
className="input" className={`input ${displayName.length > 0 && !displayName.trim() ? 'border-error-500/50' : ''}`}
placeholder={t('admin.servers.displayNamePlaceholder')} placeholder={t('admin.servers.displayNamePlaceholder')}
maxLength={255}
/> />
</div> </div>
{/* Description */} {/* Description */}
<div className="mb-4"> <div className="mb-4">
<label className="mb-2 block text-sm font-medium text-dark-100"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.servers.description')} {t('admin.servers.description')}
</label> </label>
<textarea <textarea
@@ -190,7 +192,7 @@ export default function AdminServerEdit() {
{/* Country Code */} {/* Country Code */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-100"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.servers.countryCode')} {t('admin.servers.countryCode')}
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -215,7 +217,7 @@ export default function AdminServerEdit() {
{/* Price */} {/* Price */}
<div className="mb-4"> <div className="mb-4">
<label className="mb-2 block text-sm font-medium text-dark-100"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.servers.price')} {t('admin.servers.price')}
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -241,7 +243,7 @@ export default function AdminServerEdit() {
{/* Max Users */} {/* Max Users */}
<div className="mb-4"> <div className="mb-4">
<label className="mb-2 block text-sm font-medium text-dark-100"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.servers.maxUsers')} {t('admin.servers.maxUsers')}
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -263,7 +265,7 @@ export default function AdminServerEdit() {
{/* Sort Order */} {/* Sort Order */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-100"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.servers.sortOrder')} {t('admin.servers.sortOrder')}
</label> </label>
<input <input

View File

@@ -4,6 +4,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { adminApi } from '../api/admin'; import { adminApi } from '../api/admin';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { toNumber } from '../utils/inputHelpers';
type NumberOrEmpty = number | '';
const SettingsIcon = () => ( const SettingsIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -30,7 +33,15 @@ export default function AdminTicketSettings() {
queryFn: adminApi.getTicketSettings, queryFn: adminApi.getTicketSettings,
}); });
const [formData, setFormData] = useState({ const [formData, setFormData] = useState<{
sla_enabled: boolean;
sla_minutes: NumberOrEmpty;
sla_check_interval_seconds: NumberOrEmpty;
sla_reminder_cooldown_minutes: NumberOrEmpty;
support_system_mode: string;
cabinet_user_notifications_enabled: boolean;
cabinet_admin_notifications_enabled: boolean;
}>({
sla_enabled: true, sla_enabled: true,
sla_minutes: 5, sla_minutes: 5,
sla_check_interval_seconds: 60, sla_check_interval_seconds: 60,
@@ -62,9 +73,29 @@ export default function AdminTicketSettings() {
}, },
}); });
// Validation
const isSlaMinutesValid =
formData.sla_minutes !== '' && formData.sla_minutes >= 1 && formData.sla_minutes <= 1440;
const isCheckIntervalValid =
formData.sla_check_interval_seconds !== '' &&
formData.sla_check_interval_seconds >= 30 &&
formData.sla_check_interval_seconds <= 600;
const isReminderCooldownValid =
formData.sla_reminder_cooldown_minutes !== '' &&
formData.sla_reminder_cooldown_minutes >= 1 &&
formData.sla_reminder_cooldown_minutes <= 120;
const isValid =
!formData.sla_enabled || (isSlaMinutesValid && isCheckIntervalValid && isReminderCooldownValid);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
updateMutation.mutate(formData); if (!isValid) return;
updateMutation.mutate({
...formData,
sla_minutes: toNumber(formData.sla_minutes, 5),
sla_check_interval_seconds: toNumber(formData.sla_check_interval_seconds, 60),
sla_reminder_cooldown_minutes: toNumber(formData.sla_reminder_cooldown_minutes, 15),
});
}; };
if (isLoading) { if (isLoading) {
@@ -208,59 +239,87 @@ export default function AdminTicketSettings() {
{/* SLA Minutes */} {/* SLA Minutes */}
<div className="mb-4"> <div className="mb-4">
<label className="mb-2 block text-sm font-medium text-dark-100"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.tickets.slaMinutes')} {t('admin.tickets.slaMinutes')}
</label> </label>
<input <input
type="number" type="number"
min="1" min={1}
max="1440" max={1440}
value={formData.sla_minutes} value={formData.sla_minutes}
onChange={(e) => setFormData({ ...formData, sla_minutes: parseInt(e.target.value) })} onChange={(e) => {
className="input" const val = e.target.value;
if (val === '') return setFormData({ ...formData, sla_minutes: '' });
const num = parseInt(val);
if (!isNaN(num)) setFormData({ ...formData, sla_minutes: num });
}}
className={`input ${formData.sla_enabled && !isSlaMinutesValid && formData.sla_minutes !== '' ? 'border-error-500/50' : ''}`}
disabled={!formData.sla_enabled} disabled={!formData.sla_enabled}
/> />
{formData.sla_enabled && formData.sla_minutes !== '' && !isSlaMinutesValid && (
<p className="mt-1 text-xs text-error-400">
{t('admin.tickets.validation.slaMinutesRange')}
</p>
)}
<p className="mt-1 text-xs text-dark-500">{t('admin.tickets.slaMinutesDesc')}</p> <p className="mt-1 text-xs text-dark-500">{t('admin.tickets.slaMinutesDesc')}</p>
</div> </div>
{/* Check Interval */} {/* Check Interval */}
<div className="mb-4"> <div className="mb-4">
<label className="mb-2 block text-sm font-medium text-dark-100"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.tickets.checkInterval')} {t('admin.tickets.checkInterval')}
</label> </label>
<input <input
type="number" type="number"
min="30" min={30}
max="600" max={600}
value={formData.sla_check_interval_seconds} value={formData.sla_check_interval_seconds}
onChange={(e) => onChange={(e) => {
setFormData({ ...formData, sla_check_interval_seconds: parseInt(e.target.value) }) const val = e.target.value;
} if (val === '') return setFormData({ ...formData, sla_check_interval_seconds: '' });
className="input" const num = parseInt(val);
if (!isNaN(num)) setFormData({ ...formData, sla_check_interval_seconds: num });
}}
className={`input ${formData.sla_enabled && !isCheckIntervalValid && formData.sla_check_interval_seconds !== '' ? 'border-error-500/50' : ''}`}
disabled={!formData.sla_enabled} disabled={!formData.sla_enabled}
/> />
{formData.sla_enabled &&
formData.sla_check_interval_seconds !== '' &&
!isCheckIntervalValid && (
<p className="mt-1 text-xs text-error-400">
{t('admin.tickets.validation.checkIntervalRange')}
</p>
)}
<p className="mt-1 text-xs text-dark-500">{t('admin.tickets.checkIntervalDesc')}</p> <p className="mt-1 text-xs text-dark-500">{t('admin.tickets.checkIntervalDesc')}</p>
</div> </div>
{/* Reminder Cooldown */} {/* Reminder Cooldown */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-100"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.tickets.reminderCooldown')} {t('admin.tickets.reminderCooldown')}
</label> </label>
<input <input
type="number" type="number"
min="1" min={1}
max="120" max={120}
value={formData.sla_reminder_cooldown_minutes} value={formData.sla_reminder_cooldown_minutes}
onChange={(e) => onChange={(e) => {
setFormData({ const val = e.target.value;
...formData, if (val === '')
sla_reminder_cooldown_minutes: parseInt(e.target.value), return setFormData({ ...formData, sla_reminder_cooldown_minutes: '' });
}) const num = parseInt(val);
} if (!isNaN(num)) setFormData({ ...formData, sla_reminder_cooldown_minutes: num });
className="input" }}
className={`input ${formData.sla_enabled && !isReminderCooldownValid && formData.sla_reminder_cooldown_minutes !== '' ? 'border-error-500/50' : ''}`}
disabled={!formData.sla_enabled} disabled={!formData.sla_enabled}
/> />
{formData.sla_enabled &&
formData.sla_reminder_cooldown_minutes !== '' &&
!isReminderCooldownValid && (
<p className="mt-1 text-xs text-error-400">
{t('admin.tickets.validation.reminderCooldownRange')}
</p>
)}
<p className="mt-1 text-xs text-dark-500">{t('admin.tickets.reminderCooldownDesc')}</p> <p className="mt-1 text-xs text-dark-500">{t('admin.tickets.reminderCooldownDesc')}</p>
</div> </div>
</div> </div>
@@ -274,7 +333,11 @@ export default function AdminTicketSettings() {
> >
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button type="submit" disabled={updateMutation.isPending} className="btn-primary"> <button
type="submit"
disabled={!isValid || updateMutation.isPending}
className="btn-primary"
>
{updateMutation.isPending ? ( {updateMutation.isPending ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />

View File

@@ -11,6 +11,7 @@ import {
type UpdateSubscriptionRequest, type UpdateSubscriptionRequest,
} from '../api/adminUsers'; } from '../api/adminUsers';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
// ============ Icons ============ // ============ Icons ============
@@ -92,12 +93,12 @@ export default function AdminUserDetail() {
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
// Balance form // Balance form
const [balanceAmount, setBalanceAmount] = useState(''); const [balanceAmount, setBalanceAmount] = useState<number | ''>('');
const [balanceDescription, setBalanceDescription] = useState(''); const [balanceDescription, setBalanceDescription] = useState('');
// Subscription form // Subscription form
const [subAction, setSubAction] = useState<string>('extend'); const [subAction, setSubAction] = useState<string>('extend');
const [subDays, setSubDays] = useState('30'); const [subDays, setSubDays] = useState<number | ''>(30);
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null); const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
const userId = id ? parseInt(id, 10) : null; const userId = id ? parseInt(id, 10) : null;
@@ -150,10 +151,10 @@ export default function AdminUserDetail() {
}, [activeTab, loadSyncStatus, loadTariffs]); }, [activeTab, loadSyncStatus, loadTariffs]);
const handleUpdateBalance = async (isAdd: boolean) => { const handleUpdateBalance = async (isAdd: boolean) => {
if (!balanceAmount || !userId) return; if (balanceAmount === '' || !userId) return;
setActionLoading(true); setActionLoading(true);
try { try {
const amount = Math.abs(parseFloat(balanceAmount) * 100); const amount = Math.abs(toNumber(balanceAmount) * 100);
await adminUsersApi.updateBalance(userId, { await adminUsersApi.updateBalance(userId, {
amount_kopeks: isAdd ? amount : -amount, amount_kopeks: isAdd ? amount : -amount,
description: description:
@@ -178,13 +179,13 @@ export default function AdminUserDetail() {
try { try {
const data: UpdateSubscriptionRequest = { const data: UpdateSubscriptionRequest = {
action: subAction as UpdateSubscriptionRequest['action'], action: subAction as UpdateSubscriptionRequest['action'],
...(subAction === 'extend' ? { days: parseInt(subDays) } : {}), ...(subAction === 'extend' ? { days: toNumber(subDays, 30) } : {}),
...(subAction === 'change_tariff' && selectedTariffId ...(subAction === 'change_tariff' && selectedTariffId
? { tariff_id: selectedTariffId } ? { tariff_id: selectedTariffId }
: {}), : {}),
...(subAction === 'create' ...(subAction === 'create'
? { ? {
days: parseInt(subDays), days: toNumber(subDays, 30),
...(selectedTariffId ? { tariff_id: selectedTariffId } : {}), ...(selectedTariffId ? { tariff_id: selectedTariffId } : {}),
} }
: {}), : {}),
@@ -517,7 +518,7 @@ export default function AdminUserDetail() {
<select <select
value={subAction} value={subAction}
onChange={(e) => setSubAction(e.target.value)} onChange={(e) => setSubAction(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100" className="input"
> >
<option value="extend">{t('admin.users.detail.subscription.extend')}</option> <option value="extend">{t('admin.users.detail.subscription.extend')}</option>
<option value="change_tariff"> <option value="change_tariff">
@@ -533,9 +534,11 @@ export default function AdminUserDetail() {
<input <input
type="number" type="number"
value={subDays} value={subDays}
onChange={(e) => setSubDays(e.target.value)} onChange={createNumberInputHandler(setSubDays, 1)}
placeholder={t('admin.users.detail.subscription.days')} placeholder={t('admin.users.detail.subscription.days')}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100" className="input"
min={1}
max={3650}
/> />
)} )}
@@ -545,7 +548,7 @@ export default function AdminUserDetail() {
onChange={(e) => onChange={(e) =>
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null) setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
} }
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100" className="input"
> >
<option value=""> <option value="">
{t('admin.users.detail.subscription.selectTariff')} {t('admin.users.detail.subscription.selectTariff')}
@@ -563,7 +566,7 @@ export default function AdminUserDetail() {
<button <button
onClick={handleUpdateSubscription} onClick={handleUpdateSubscription}
disabled={actionLoading} disabled={actionLoading}
className="w-full rounded-lg bg-accent-500 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50" className="btn-primary w-full"
> >
{actionLoading {actionLoading
? t('admin.users.actions.applying') ? t('admin.users.actions.applying')
@@ -583,7 +586,7 @@ export default function AdminUserDetail() {
onChange={(e) => onChange={(e) =>
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null) setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
} }
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100" className="input"
> >
<option value="">{t('admin.users.detail.subscription.selectTariff')}</option> <option value="">{t('admin.users.detail.subscription.selectTariff')}</option>
{tariffs.map((tariffItem) => ( {tariffs.map((tariffItem) => (
@@ -595,9 +598,11 @@ export default function AdminUserDetail() {
<input <input
type="number" type="number"
value={subDays} value={subDays}
onChange={(e) => setSubDays(e.target.value)} onChange={createNumberInputHandler(setSubDays, 1)}
placeholder={t('admin.users.detail.subscription.days')} placeholder={t('admin.users.detail.subscription.days')}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100" className="input"
min={1}
max={3650}
/> />
<button <button
onClick={() => { onClick={() => {
@@ -605,7 +610,7 @@ export default function AdminUserDetail() {
handleUpdateSubscription(); handleUpdateSubscription();
}} }}
disabled={actionLoading} disabled={actionLoading}
className="w-full rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50" className="btn-primary w-full"
> >
{actionLoading {actionLoading
? t('admin.users.detail.subscription.creating') ? t('admin.users.detail.subscription.creating')
@@ -635,28 +640,29 @@ export default function AdminUserDetail() {
<input <input
type="number" type="number"
value={balanceAmount} value={balanceAmount}
onChange={(e) => setBalanceAmount(e.target.value)} onChange={createNumberInputHandler(setBalanceAmount)}
placeholder={t('admin.users.detail.balance.amountPlaceholder')} placeholder={t('admin.users.detail.balance.amountPlaceholder')}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100" className="input"
/> />
<input <input
type="text" type="text"
value={balanceDescription} value={balanceDescription}
onChange={(e) => setBalanceDescription(e.target.value)} onChange={(e) => setBalanceDescription(e.target.value)}
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')} placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100" className="input"
maxLength={500}
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => handleUpdateBalance(true)} onClick={() => handleUpdateBalance(true)}
disabled={actionLoading || !balanceAmount} disabled={actionLoading || balanceAmount === ''}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50" className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
> >
<PlusIcon /> {t('admin.users.detail.balance.add')} <PlusIcon /> {t('admin.users.detail.balance.add')}
</button> </button>
<button <button
onClick={() => handleUpdateBalance(false)} onClick={() => handleUpdateBalance(false)}
disabled={actionLoading || !balanceAmount} disabled={actionLoading || balanceAmount === ''}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50" className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
> >
<MinusIcon /> {t('admin.users.detail.balance.subtract')} <MinusIcon /> {t('admin.users.detail.balance.subtract')}

View File

@@ -27,6 +27,7 @@ import { useNotify } from '@/platform/hooks/useNotify';
import FortuneWheel from '../components/wheel/FortuneWheel'; import FortuneWheel from '../components/wheel/FortuneWheel';
import { ColorPicker } from '@/components/ColorPicker'; import { ColorPicker } from '@/components/ColorPicker';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { toNumber } from '../utils/inputHelpers';
// Icons // Icons
@@ -303,13 +304,13 @@ export default function AdminWheel() {
// Settings form state // Settings form state
const [settingsForm, setSettingsForm] = useState<{ const [settingsForm, setSettingsForm] = useState<{
is_enabled: boolean; is_enabled: boolean;
spin_cost_stars: number; spin_cost_stars: number | '';
spin_cost_stars_enabled: boolean; spin_cost_stars_enabled: boolean;
spin_cost_days: number; spin_cost_days: number | '';
spin_cost_days_enabled: boolean; spin_cost_days_enabled: boolean;
rtp_percent: number; rtp_percent: number;
daily_spin_limit: number; daily_spin_limit: number | '';
min_subscription_days_for_day_payment: number; min_subscription_days_for_day_payment: number | '';
promo_prefix: string; promo_prefix: string;
} | null>(null); } | null>(null);
@@ -601,18 +602,25 @@ export default function AdminWheel() {
</h3> </h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.settings.costInStars')} {t('admin.wheel.settings.costInStars')}
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="number" type="number"
value={settingsForm?.spin_cost_stars ?? config.spin_cost_stars} value={settingsForm?.spin_cost_stars ?? config.spin_cost_stars}
onChange={(e) => onChange={(e) => {
setSettingsForm((prev) => const val = e.target.value;
prev ? { ...prev, spin_cost_stars: parseInt(e.target.value) || 1 } : null, if (val === '')
) return setSettingsForm((prev) =>
} prev ? { ...prev, spin_cost_stars: '' } : null,
);
const num = parseInt(val);
if (!isNaN(num))
setSettingsForm((prev) =>
prev ? { ...prev, spin_cost_stars: num } : null,
);
}}
min={1} min={1}
max={1000} max={1000}
className="input flex-1" className="input flex-1"
@@ -636,18 +644,23 @@ export default function AdminWheel() {
</div> </div>
<div> <div>
<label className="mb-1 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.settings.costInDays')} {t('admin.wheel.settings.costInDays')}
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="number" type="number"
value={settingsForm?.spin_cost_days ?? config.spin_cost_days} value={settingsForm?.spin_cost_days ?? config.spin_cost_days}
onChange={(e) => onChange={(e) => {
setSettingsForm((prev) => const val = e.target.value;
prev ? { ...prev, spin_cost_days: parseInt(e.target.value) || 1 } : null, if (val === '')
) return setSettingsForm((prev) =>
} prev ? { ...prev, spin_cost_days: '' } : null,
);
const num = parseInt(val);
if (!isNaN(num))
setSettingsForm((prev) => (prev ? { ...prev, spin_cost_days: num } : null));
}}
min={1} min={1}
max={30} max={30}
className="input flex-1" className="input flex-1"
@@ -682,7 +695,7 @@ export default function AdminWheel() {
</h3> </h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.settings.rtpPercent')} {t('admin.wheel.settings.rtpPercent')}
</label> </label>
<input <input
@@ -707,17 +720,22 @@ export default function AdminWheel() {
</div> </div>
<div> <div>
<label className="mb-1 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.settings.dailyLimit')} {t('admin.wheel.settings.dailyLimit')}
</label> </label>
<input <input
type="number" type="number"
value={settingsForm?.daily_spin_limit ?? config.daily_spin_limit} value={settingsForm?.daily_spin_limit ?? config.daily_spin_limit}
onChange={(e) => onChange={(e) => {
setSettingsForm((prev) => const val = e.target.value;
prev ? { ...prev, daily_spin_limit: parseInt(e.target.value) || 0 } : null, if (val === '')
) return setSettingsForm((prev) =>
} prev ? { ...prev, daily_spin_limit: '' } : null,
);
const num = parseInt(val);
if (!isNaN(num))
setSettingsForm((prev) => (prev ? { ...prev, daily_spin_limit: num } : null));
}}
min={0} min={0}
max={100} max={100}
className="input w-full" className="input w-full"
@@ -725,7 +743,7 @@ export default function AdminWheel() {
</div> </div>
<div> <div>
<label className="mb-1 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.settings.minSubDays')} {t('admin.wheel.settings.minSubDays')}
</label> </label>
<input <input
@@ -734,16 +752,18 @@ export default function AdminWheel() {
settingsForm?.min_subscription_days_for_day_payment ?? settingsForm?.min_subscription_days_for_day_payment ??
config.min_subscription_days_for_day_payment config.min_subscription_days_for_day_payment
} }
onChange={(e) => onChange={(e) => {
setSettingsForm((prev) => const val = e.target.value;
prev if (val === '')
? { return setSettingsForm((prev) =>
...prev, prev ? { ...prev, min_subscription_days_for_day_payment: '' } : null,
min_subscription_days_for_day_payment: parseInt(e.target.value) || 1, );
} const num = parseInt(val);
: null, if (!isNaN(num))
) setSettingsForm((prev) =>
} prev ? { ...prev, min_subscription_days_for_day_payment: num } : null,
);
}}
min={1} min={1}
max={30} max={30}
className="input w-full" className="input w-full"
@@ -762,7 +782,7 @@ export default function AdminWheel() {
</h3> </h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.settings.promoPrefix')} {t('admin.wheel.settings.promoPrefix')}
</label> </label>
<input <input
@@ -798,7 +818,19 @@ export default function AdminWheel() {
// If we can't check, allow saving // If we can't check, allow saving
} }
} }
updateConfigMutation.mutate(settingsForm); updateConfigMutation.mutate({
...settingsForm,
spin_cost_stars: toNumber(settingsForm.spin_cost_stars, config.spin_cost_stars),
spin_cost_days: toNumber(settingsForm.spin_cost_days, config.spin_cost_days),
daily_spin_limit: toNumber(
settingsForm.daily_spin_limit,
config.daily_spin_limit,
),
min_subscription_days_for_day_payment: toNumber(
settingsForm.min_subscription_days_for_day_payment,
config.min_subscription_days_for_day_payment,
),
});
}} }}
disabled={updateConfigMutation.isPending} disabled={updateConfigMutation.isPending}
className="btn-primary flex items-center gap-2" className="btn-primary flex items-center gap-2"
@@ -1022,7 +1054,19 @@ function InlinePrizeForm({
isLoading?: boolean; isLoading?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState<{
prize_type: string;
prize_value: number | '';
display_name: string;
emoji: string;
color: string;
prize_value_kopeks: number | '';
is_active: boolean;
manual_probability: number | null;
promo_balance_bonus_kopeks: number | '';
promo_subscription_days: number | '';
promo_traffic_gb: number | '';
}>({
prize_type: prize?.prize_type || 'balance_bonus', prize_type: prize?.prize_type || 'balance_bonus',
prize_value: prize?.prize_value || 0, prize_value: prize?.prize_value || 0,
display_name: prize?.display_name || '', display_name: prize?.display_name || '',
@@ -1038,7 +1082,14 @@ function InlinePrizeForm({
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
onSave(formData); onSave({
...formData,
prize_value: toNumber(formData.prize_value),
prize_value_kopeks: toNumber(formData.prize_value_kopeks),
promo_balance_bonus_kopeks: toNumber(formData.promo_balance_bonus_kopeks),
promo_subscription_days: toNumber(formData.promo_subscription_days),
promo_traffic_gb: toNumber(formData.promo_traffic_gb),
});
}; };
return ( return (
@@ -1057,7 +1108,7 @@ function InlinePrizeForm({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Prize type */} {/* Prize type */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.fields.type')} {t('admin.wheel.prizes.fields.type')}
</label> </label>
<select <select
@@ -1075,7 +1126,7 @@ function InlinePrizeForm({
{/* Display name */} {/* Display name */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.fields.displayName')} {t('admin.wheel.prizes.fields.displayName')}
</label> </label>
<input <input
@@ -1092,7 +1143,7 @@ function InlinePrizeForm({
{/* Prize value */} {/* Prize value */}
{formData.prize_type !== 'nothing' && ( {formData.prize_type !== 'nothing' && (
<div> <div>
<label className="mb-1 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.fields.value')} ( {t('admin.wheel.prizes.fields.value')} (
{formData.prize_type === 'balance_bonus' {formData.prize_type === 'balance_bonus'
? 'kopeks' ? 'kopeks'
@@ -1104,9 +1155,12 @@ function InlinePrizeForm({
<input <input
type="number" type="number"
value={formData.prize_value} value={formData.prize_value}
onChange={(e) => onChange={(e) => {
setFormData({ ...formData, prize_value: parseInt(e.target.value) || 0 }) const val = e.target.value;
} if (val === '') return setFormData({ ...formData, prize_value: '' });
const num = parseInt(val);
if (!isNaN(num)) setFormData({ ...formData, prize_value: num });
}}
min={0} min={0}
className="input w-full" className="input w-full"
/> />
@@ -1115,26 +1169,29 @@ function InlinePrizeForm({
{/* Prize value in kopeks (for RTP calculation) */} {/* Prize value in kopeks (for RTP calculation) */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.fields.valueKopeks')} {t('admin.wheel.prizes.fields.valueKopeks')}
</label> </label>
<input <input
type="number" type="number"
value={formData.prize_value_kopeks} value={formData.prize_value_kopeks}
onChange={(e) => onChange={(e) => {
setFormData({ ...formData, prize_value_kopeks: parseInt(e.target.value) || 0 }) const val = e.target.value;
} if (val === '') return setFormData({ ...formData, prize_value_kopeks: '' });
const num = parseInt(val);
if (!isNaN(num)) setFormData({ ...formData, prize_value_kopeks: num });
}}
min={0} min={0}
className="input w-full" className="input w-full"
/> />
<p className="mt-1 text-xs text-dark-500"> <p className="mt-1 text-xs text-dark-500">
= {(formData.prize_value_kopeks / 100).toFixed(2)} RUB = {(toNumber(formData.prize_value_kopeks) / 100).toFixed(2)} RUB
</p> </p>
</div> </div>
{/* Emoji */} {/* Emoji */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.fields.emoji')} {t('admin.wheel.prizes.fields.emoji')}
</label> </label>
<input <input
@@ -1174,7 +1231,7 @@ function InlinePrizeForm({
<h4 className="font-medium text-dark-200">{t('admin.wheel.prizes.promo.title')}</h4> <h4 className="font-medium text-dark-200">{t('admin.wheel.prizes.promo.title')}</h4>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm text-dark-400"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.promo.balanceBonus')} {t('admin.wheel.prizes.promo.balanceBonus')}
</label> </label>
<input <input
@@ -1183,7 +1240,8 @@ function InlinePrizeForm({
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
promo_balance_bonus_kopeks: parseInt(e.target.value) || 0, promo_balance_bonus_kopeks:
e.target.value === '' ? '' : parseInt(e.target.value) || 0,
}) })
} }
min={0} min={0}
@@ -1191,7 +1249,7 @@ function InlinePrizeForm({
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-sm text-dark-400"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.promo.subscriptionDays')} {t('admin.wheel.prizes.promo.subscriptionDays')}
</label> </label>
<input <input
@@ -1200,7 +1258,8 @@ function InlinePrizeForm({
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
promo_subscription_days: parseInt(e.target.value) || 0, promo_subscription_days:
e.target.value === '' ? '' : parseInt(e.target.value) || 0,
}) })
} }
min={0} min={0}