Merge PR #493: состав промокода чекбоксами — баланс / дни / промогруппа

This commit is contained in:
Egor
2026-07-19 16:29:08 +03:00
committed by GitHub
8 changed files with 162 additions and 56 deletions

View File

@@ -7,7 +7,8 @@ export type PromoCodeType =
| 'subscription_days'
| 'trial_subscription'
| 'promo_group'
| 'discount';
| 'discount'
| 'balance_and_days';
export interface PromoCode {
id: number;

View File

@@ -3144,7 +3144,8 @@
"subscriptionDays": "Subscription days",
"trialSubscription": "Trial subscription",
"promoGroup": "Discount group",
"discount": "Discount %"
"discount": "Discount %",
"balanceAndDays": "Balance + days"
},
"modal": {
"editPromocode": "Edit promo code",
@@ -3162,6 +3163,11 @@
"typeTrialSubscription": "Trial subscription",
"typePromoGroup": "Discount group",
"typeDiscount": "Percentage discount",
"typeBonusSet": "Bonus set (balance / days / promo group)",
"bonusComposition": "Promo code contents",
"includeBalance": "Balance bonus",
"includeDays": "Subscription days",
"includePromoGroup": "Promo group",
"bonusAmount": "Bonus amount",
"rub": "RUB",
"daysCount": "Number of days",
@@ -3253,6 +3259,7 @@
},
"validation": {
"codeRequired": "Enter promo code",
"bonusSetEmpty": "Select at least one bonus for the promo code",
"balanceRequired": "Bonus amount must be greater than 0",
"daysRequired": "Number of days must be greater than 0",
"groupRequired": "Select a discount group",

View File

@@ -2669,7 +2669,8 @@
"subscriptionDays": "روزهای اشتراک",
"trialSubscription": "اشتراک آزمایشی",
"promoGroup": "گروه تخفیف",
"discount": "تخفیف %"
"discount": "تخفیف %",
"balanceAndDays": "موجودی + روزها"
},
"modal": {
"editPromocode": "ویرایش کد تخفیف",
@@ -2687,6 +2688,11 @@
"typeTrialSubscription": "اشتراک آزمایشی",
"typePromoGroup": "گروه تخفیف",
"typeDiscount": "تخفیف درصدی",
"typeBonusSet": "مجموعه پاداش (موجودی / روزها / گروه تخفیف)",
"bonusComposition": "محتوای کد تخفیف",
"includeBalance": "پاداش موجودی",
"includeDays": "روزهای اشتراک",
"includePromoGroup": "گروه تخفیف",
"bonusAmount": "مبلغ پاداش",
"rub": "روبل",
"daysCount": "تعداد روزها",
@@ -2776,6 +2782,7 @@
},
"validation": {
"codeRequired": "کد تخفیف را وارد کنید",
"bonusSetEmpty": "حداقل یک پاداش برای کد تخفیف انتخاب کنید",
"balanceRequired": "مبلغ پاداش باید بیشتر از 0 باشد",
"daysRequired": "تعداد روزها باید بیشتر از 0 باشد",
"groupRequired": "یک گروه تخفیف انتخاب کنید",

View File

@@ -3538,7 +3538,8 @@
"subscriptionDays": "Дни подписки",
"trialSubscription": "Тестовая подписка",
"promoGroup": "Группа скидок",
"discount": "Скидка %"
"discount": "Скидка %",
"balanceAndDays": "Баланс + дни"
},
"modal": {
"editPromocode": "Редактирование промокода",
@@ -3556,6 +3557,11 @@
"typeTrialSubscription": "Тестовая подписка",
"typePromoGroup": "Группа скидок",
"typeDiscount": "Процентная скидка",
"typeBonusSet": "Набор бонусов (баланс / дни / промогруппа)",
"bonusComposition": "Состав промокода",
"includeBalance": "Бонус на баланс",
"includeDays": "Дни подписки",
"includePromoGroup": "Промогруппа",
"bonusAmount": "Сумма бонуса",
"rub": "руб.",
"daysCount": "Количество дней",
@@ -3649,6 +3655,7 @@
},
"validation": {
"codeRequired": "Введите код промокода",
"bonusSetEmpty": "Выберите хотя бы один бонус в составе промокода",
"balanceRequired": "Сумма бонуса должна быть больше 0",
"daysRequired": "Количество дней должно быть больше 0",
"groupRequired": "Выберите группу скидок",

View File

@@ -2668,7 +2668,8 @@
"subscriptionDays": "订阅天数",
"trialSubscription": "试用订阅",
"promoGroup": "折扣组",
"discount": "折扣 %"
"discount": "折扣 %",
"balanceAndDays": "余额 + 天数"
},
"modal": {
"editPromocode": "编辑促销码",
@@ -2686,6 +2687,11 @@
"typeTrialSubscription": "试用订阅",
"typePromoGroup": "折扣组",
"typeDiscount": "百分比折扣",
"typeBonusSet": "奖励组合(余额 / 天数 / 优惠组)",
"bonusComposition": "促销码内容",
"includeBalance": "余额奖励",
"includeDays": "订阅天数",
"includePromoGroup": "优惠组",
"bonusAmount": "奖金金额",
"rub": "卢布",
"daysCount": "天数",
@@ -2775,6 +2781,7 @@
},
"validation": {
"codeRequired": "请输入促销码",
"bonusSetEmpty": "请为促销码至少选择一项奖励",
"balanceRequired": "奖金金额必须大于0",
"daysRequired": "天数必须大于0",
"groupRequired": "请选择折扣组",

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) {
if (mode === 'bonus_set') {
if (!includeBalance && !includeDays && !includeGroup) {
validationErrors.push('bonusSetEmpty');
}
if (includeBalance && balanceValue <= 0) {
validationErrors.push('balanceRequired');
}
if ((type === 'subscription_days' || type === 'trial_subscription') && daysValue <= 0) {
if (includeDays && daysValue <= 0) {
validationErrors.push('daysRequired');
}
if (type === 'promo_group' && !promoGroupId) {
if (includeGroup && !promoGroupId) {
validationErrors.push('groupRequired');
}
if (type === 'discount') {
}
if (mode === 'trial_subscription' && daysValue <= 0) {
validationErrors.push('daysRequired');
}
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

View File

@@ -2,7 +2,7 @@ import { useParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { promocodesApi, PromoCodeType } from '../api/promocodes';
import { promocodesApi, type PromoCodeType } from '../api/promocodes';
import { AdminBackButton } from '../components/admin';
import { StatCard } from '../components/stats';
import {
@@ -22,6 +22,7 @@ const getTypeLabel = (type: PromoCodeType): string => {
trial_subscription: i18n.t('admin.promocodes.type.trialSubscription'),
promo_group: i18n.t('admin.promocodes.type.promoGroup'),
discount: i18n.t('admin.promocodes.type.discount'),
balance_and_days: i18n.t('admin.promocodes.type.balanceAndDays'),
};
return labels[type] || type;
};
@@ -33,6 +34,7 @@ const getTypeColor = (type: PromoCodeType): string => {
trial_subscription: 'bg-accent-500/20 text-accent-400',
promo_group: 'bg-warning-500/20 text-warning-400',
discount: 'bg-pink-500/20 text-pink-400',
balance_and_days: 'bg-success-500/20 text-success-400',
};
return colors[type] || 'bg-dark-600 text-dark-300';
};

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { promocodesApi, PromoCode, PromoCodeType } from '../api/promocodes';
import { promocodesApi, type PromoCode, type PromoCodeType } from '../api/promocodes';
import { usePlatform } from '../platform/hooks/usePlatform';
import { copyToClipboard } from '../utils/clipboard';
import {
@@ -29,6 +29,7 @@ const getTypeLabel = (type: PromoCodeType): string => {
trial_subscription: i18n.t('admin.promocodes.type.trialSubscription'),
promo_group: i18n.t('admin.promocodes.type.promoGroup'),
discount: i18n.t('admin.promocodes.type.discount'),
balance_and_days: i18n.t('admin.promocodes.type.balanceAndDays'),
};
return labels[type] || type;
};
@@ -40,6 +41,7 @@ const getTypeColor = (type: PromoCodeType): string => {
trial_subscription: 'bg-accent-500/20 text-accent-400',
promo_group: 'bg-warning-500/20 text-warning-400',
discount: 'bg-pink-500/20 text-pink-400',
balance_and_days: 'bg-success-500/20 text-success-400',
};
return colors[type] || 'bg-dark-600 text-dark-300';
};
@@ -194,13 +196,14 @@ export default function AdminPromocodes() {
</div>
{/* Info line */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
{promo.type === 'balance' && (
{(promo.type === 'balance' || promo.type === 'balance_and_days') && (
<span className="text-success-400">
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
</span>
)}
{(promo.type === 'subscription_days' ||
promo.type === 'trial_subscription') && (
promo.type === 'trial_subscription' ||
promo.type === 'balance_and_days') && (
<span className="text-accent-400">
+{promo.subscription_days} {t('admin.promocodes.form.days')}
</span>