feat(promocodes): состав промокода чекбоксами — баланс / дни / промогруппа

Форма создания промокода переведена с жёсткого выбора одного типа на режим
«Набор бонусов»: галочками выбирается, что входит в промокод — бонус на
баланс, дни подписки, промогруппа — в любой комбинации. Тип для бэкенда
выводится из выбора (balance / subscription_days / balance_and_days /
promo_group), промогруппа едет через promo_group_id при любом типе (бэкенд
назначает её при активации независимо от типа).

Триал и процентная скидка остаются отдельными режимами: скидка
переиспользует те же колонки с другой семантикой (процент/часы) и не
комбинируется без миграции.

- список и статистика: лейбл/цвет нового типа balance_and_days, у составных
  кодов видны оба значения (+руб и +дни)
- edit-режим восстанавливает чекбоксы из типа и promo_group_id
- унифицирована валидация формы (isValid = список ошибок пуст; для скидки
  0 часов = «до первой покупки» — разрешено, как и было в isValid)
- локали ru/en/fa/zh

Парный PR бэкенда: remnawave-bedolaga-telegram-bot (тип BALANCE_AND_DAYS,
порядок эффектов дни→баланс, валидации, бот-визард).
This commit is contained in:
Case211
2026-07-14 08:52:06 +05:00
parent d7ea3c0635
commit 9ce838bade
8 changed files with 162 additions and 56 deletions

View File

@@ -6,11 +6,11 @@ import { DateField } from '../components/DateField';
import { createNumberInputHandler } from '../utils/inputHelpers';
import {
promocodesApi,
PromoCodeDetail,
PromoCodeType,
PromoCodeCreateRequest,
PromoCodeUpdateRequest,
PromoGroup,
type PromoCodeDetail,
type PromoCodeType,
type PromoCodeCreateRequest,
type PromoCodeUpdateRequest,
type PromoGroup,
} from '../api/promocodes';
import { tariffsApi } from '../api/tariffs';
import { usePlatform } from '../platform/hooks/usePlatform';
@@ -34,9 +34,16 @@ export default function AdminPromocodeCreate() {
const { capabilities } = usePlatform();
const isEdit = !!id;
// Form state
// Form state.
// Режим формы: «набор бонусов» (баланс/дни/промогруппа чекбоксами — тип
// промокода выводится из выбранной комбинации) либо особые типы, которые не
// комбинируются: триал и процентная скидка (скидка переиспользует те же
// колонки с другой семантикой — процент/часы).
const [code, setCode] = useState('');
const [type, setType] = useState<PromoCodeType>('balance');
const [mode, setMode] = useState<'bonus_set' | 'trial_subscription' | 'discount'>('bonus_set');
const [includeBalance, setIncludeBalance] = useState(true);
const [includeDays, setIncludeDays] = useState(false);
const [includeGroup, setIncludeGroup] = useState(false);
const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(0);
const [maxUses, setMaxUses] = useState<number | ''>(1);
@@ -58,7 +65,7 @@ export default function AdminPromocodeCreate() {
const { data: tariffsData } = useQuery({
queryKey: ['admin-tariffs-for-promo'],
queryFn: () => tariffsApi.getTariffs(true),
enabled: type === 'trial_subscription',
enabled: mode === 'trial_subscription',
});
const trialTariff = tariffsData?.tariffs?.find((t) => t.is_trial_available) || null;
@@ -74,7 +81,16 @@ export default function AdminPromocodeCreate() {
refetchOnWindowFocus: false,
select: useCallback((data: PromoCodeDetail) => {
setCode(data.code);
setType(data.type);
if (data.type === 'trial_subscription' || data.type === 'discount') {
setMode(data.type);
} else {
setMode('bonus_set');
setIncludeBalance(data.type === 'balance' || data.type === 'balance_and_days');
setIncludeDays(data.type === 'subscription_days' || data.type === 'balance_and_days');
// Промогруппа комбинируется с любым составом (bэкенд назначает её
// независимо от типа), поэтому чекбокс — по факту наличия группы
setIncludeGroup(data.type === 'promo_group' || !!data.promo_group_id);
}
// For discount type, balance_bonus_kopeks is percentage directly
// For balance type, balance_bonus_kopeks needs to be converted to rubles
if (data.type === 'discount') {
@@ -112,6 +128,20 @@ export default function AdminPromocodeCreate() {
},
});
// Тип промокода выводится из режима и выбранных чекбоксов. «Только
// промогруппа» — легаси-тип promo_group; группа в комбинации с балансом/днями
// едет через promo_group_id при любом типе (бэкенд применяет её независимо).
const derivedType: PromoCodeType =
mode === 'bonus_set'
? includeBalance && includeDays
? 'balance_and_days'
: includeBalance
? 'balance'
: includeDays
? 'subscription_days'
: 'promo_group'
: mode;
const handleSubmit = () => {
// For discount: balance_bonus_kopeks = percent (integer), subscription_days = hours
// For balance: balance_bonus_kopeks = rubles * 100
@@ -121,12 +151,19 @@ export default function AdminPromocodeCreate() {
const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = {
code: code.trim().toUpperCase(),
type,
type: derivedType,
balance_bonus_kopeks:
type === 'discount'
mode === 'discount'
? Math.round(balanceValue) // percent as integer
: Math.round(balanceValue * 100), // rubles to kopeks
subscription_days: daysValue,
: mode === 'bonus_set' && includeBalance
? Math.round(balanceValue * 100) // rubles to kopeks
: 0,
subscription_days:
mode === 'discount' ||
mode === 'trial_subscription' ||
(mode === 'bonus_set' && includeDays)
? daysValue
: 0,
max_uses: maxUsesValue,
is_active: isActive,
first_purchase_only: firstPurchaseOnly,
@@ -136,8 +173,8 @@ export default function AdminPromocodeCreate() {
// (the START of the day) — for a GMT+3 admin that made a code picked for
// "today" already expired by 3am, surfacing as a bogus "expired" error.
valid_until: validUntil ? new Date(`${validUntil}T23:59:59`).toISOString() : null,
promo_group_id: type === 'promo_group' ? promoGroupId : null,
...(type === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}),
promo_group_id: mode === 'bonus_set' && includeGroup ? promoGroupId : null,
...(mode === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}),
};
if (isEdit) {
@@ -154,41 +191,41 @@ export default function AdminPromocodeCreate() {
const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles;
const daysValue = subscriptionDays === '' ? 0 : subscriptionDays;
const isValid = (): boolean => {
if (!isCodeValid) return false;
if (type === 'balance' && balanceValue <= 0) return false;
if ((type === 'subscription_days' || type === 'trial_subscription') && daysValue <= 0)
return false;
if (type === 'promo_group' && !promoGroupId) return false;
// For discount, validity hours of 0 = "until first purchase" (perpetual) — allowed.
if (type === 'discount' && (balanceValue <= 0 || balanceValue > 100 || daysValue < 0))
return false;
return true;
};
// Collect validation errors for display
const validationErrors: string[] = [];
if (!isCodeValid) {
validationErrors.push('codeRequired');
}
if (type === 'balance' && balanceValue <= 0) {
validationErrors.push('balanceRequired');
if (mode === 'bonus_set') {
if (!includeBalance && !includeDays && !includeGroup) {
validationErrors.push('bonusSetEmpty');
}
if (includeBalance && balanceValue <= 0) {
validationErrors.push('balanceRequired');
}
if (includeDays && daysValue <= 0) {
validationErrors.push('daysRequired');
}
if (includeGroup && !promoGroupId) {
validationErrors.push('groupRequired');
}
}
if ((type === 'subscription_days' || type === 'trial_subscription') && daysValue <= 0) {
if (mode === 'trial_subscription' && daysValue <= 0) {
validationErrors.push('daysRequired');
}
if (type === 'promo_group' && !promoGroupId) {
validationErrors.push('groupRequired');
}
if (type === 'discount') {
if (mode === 'discount') {
if (balanceValue <= 0 || balanceValue > 100) {
validationErrors.push('discountPercentInvalid');
}
if (daysValue <= 0) {
// 0 часов = «бессрочно до первой покупки» — разрешено (как в isValid до
// унификации; раньше isValid и список ошибок противоречили друг другу)
if (daysValue < 0) {
validationErrors.push('discountHoursRequired');
}
}
const isValid = (): boolean => validationErrors.length === 0;
// Loading state
if (isEdit && isLoadingPromocode) {
return (
@@ -247,24 +284,59 @@ export default function AdminPromocodeCreate() {
</label>
<select
id="pc-type"
value={type}
onChange={(e) => setType(e.target.value as PromoCodeType)}
value={mode}
onChange={(e) => setMode(e.target.value as typeof mode)}
className="input"
>
<option value="balance">{t('admin.promocodes.form.typeBalance')}</option>
<option value="subscription_days">
{t('admin.promocodes.form.typeSubscriptionDays')}
</option>
<option value="bonus_set">{t('admin.promocodes.form.typeBonusSet')}</option>
<option value="trial_subscription">
{t('admin.promocodes.form.typeTrialSubscription')}
</option>
<option value="promo_group">{t('admin.promocodes.form.typePromoGroup')}</option>
<option value="discount">{t('admin.promocodes.form.typeDiscount')}</option>
</select>
</div>
{/* Состав набора бонусов — любая комбинация чекбоксами */}
{mode === 'bonus_set' && (
<div>
<div className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promocodes.form.bonusComposition')}
<span className="text-error-400">*</span>
</div>
<div className="flex flex-col gap-2">
{(
[
['includeBalance', includeBalance, setIncludeBalance] as const,
['includeDays', includeDays, setIncludeDays] as const,
['includePromoGroup', includeGroup, setIncludeGroup] as const,
] as const
).map(([key, checked, setChecked]) => (
<label key={key} className="flex cursor-pointer items-center gap-3">
<button
type="button"
onClick={() => setChecked(!checked)}
role="switch"
aria-checked={checked}
aria-label={t(`admin.promocodes.form.${key}`)}
className={`relative h-6 w-10 rounded-full transition-colors ${
checked ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
checked ? 'left-5' : 'left-1'
}`}
/>
</button>
<span className="text-sm text-dark-200">{t(`admin.promocodes.form.${key}`)}</span>
</label>
))}
</div>
</div>
)}
{/* Type-specific fields */}
{type === 'balance' && (
{mode === 'bonus_set' && includeBalance && (
<div>
<label
htmlFor="pc-balance-bonus"
@@ -289,7 +361,7 @@ export default function AdminPromocodeCreate() {
</div>
)}
{(type === 'subscription_days' || type === 'trial_subscription') && (
{(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
<div>
<label htmlFor="pc-sub-days" className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promocodes.form.daysCount')}
@@ -310,7 +382,7 @@ export default function AdminPromocodeCreate() {
</div>
)}
{type === 'trial_subscription' && (
{mode === 'trial_subscription' && (
<div>
<label htmlFor="pc-tariff" className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promocodes.form.tariff', 'Тариф')}
@@ -345,7 +417,7 @@ export default function AdminPromocodeCreate() {
</div>
)}
{type === 'promo_group' && (
{mode === 'bonus_set' && includeGroup && (
<div>
<label
htmlFor="pc-promo-group"
@@ -370,7 +442,7 @@ export default function AdminPromocodeCreate() {
</div>
)}
{type === 'discount' && (
{mode === 'discount' && (
<>
<div>
<label