Merge pull request #543 from Case211/feat/promocode-traffic-bonus

This commit is contained in:
Fringg
2026-08-06 02:53:11 +03:00
7 changed files with 89 additions and 19 deletions

View File

@@ -17,6 +17,8 @@ export interface PromoCode {
balance_bonus_kopeks: number; balance_bonus_kopeks: number;
balance_bonus_rubles: number; balance_bonus_rubles: number;
subscription_days: number; subscription_days: number;
/** Гигабайты к подписке — третья составляющая набора бонусов. */
traffic_gb: number;
max_uses: number; max_uses: number;
current_uses: number; current_uses: number;
uses_left: number; uses_left: number;
@@ -60,6 +62,7 @@ export interface PromoCodeCreateRequest {
type: PromoCodeType; type: PromoCodeType;
balance_bonus_kopeks?: number; balance_bonus_kopeks?: number;
subscription_days?: number; subscription_days?: number;
traffic_gb?: number;
max_uses?: number; max_uses?: number;
valid_from?: string; valid_from?: string;
valid_until?: string | null; valid_until?: string | null;
@@ -74,6 +77,7 @@ export interface PromoCodeUpdateRequest {
type?: PromoCodeType; type?: PromoCodeType;
balance_bonus_kopeks?: number; balance_bonus_kopeks?: number;
subscription_days?: number; subscription_days?: number;
traffic_gb?: number;
max_uses?: number; max_uses?: number;
valid_from?: string; valid_from?: string;
valid_until?: string | null; valid_until?: string | null;

View File

@@ -3303,7 +3303,10 @@
"save": "Save", "save": "Save",
"defaultTrialTariff": "Default trial tariff", "defaultTrialTariff": "Default trial tariff",
"selectTariff": "Select tariff", "selectTariff": "Select tariff",
"tariff": "Tariff" "tariff": "Tariff",
"includeTraffic": "Traffic",
"trafficAmount": "Traffic amount",
"gb": "GB"
}, },
"stats": { "stats": {
"title": "Promo code statistics", "title": "Promo code statistics",
@@ -3377,7 +3380,8 @@
"daysRequired": "Number of days must be greater than 0", "daysRequired": "Number of days must be greater than 0",
"groupRequired": "Select a discount group", "groupRequired": "Select a discount group",
"discountPercentInvalid": "Discount percent must be between 1 and 100", "discountPercentInvalid": "Discount percent must be between 1 and 100",
"discountHoursRequired": "Specify the discount validity period in hours" "discountHoursRequired": "Specify the discount validity period in hours",
"trafficRequired": "Traffic amount must be greater than 0"
} }
}, },
"promoGroups": { "promoGroups": {

View File

@@ -2813,7 +2813,10 @@
"save": "ذخیره", "save": "ذخیره",
"defaultTrialTariff": "تعرفه پیش‌فرض آزمایشی", "defaultTrialTariff": "تعرفه پیش‌فرض آزمایشی",
"selectTariff": "تعرفه را انتخاب کنید", "selectTariff": "تعرفه را انتخاب کنید",
"tariff": "تعرفه" "tariff": "تعرفه",
"includeTraffic": "ترافیک",
"trafficAmount": "حجم ترافیک",
"gb": "گیگابایت"
}, },
"stats": { "stats": {
"title": "آمار کد تخفیف", "title": "آمار کد تخفیف",
@@ -2885,7 +2888,8 @@
"daysRequired": "تعداد روزها باید بیشتر از 0 باشد", "daysRequired": "تعداد روزها باید بیشتر از 0 باشد",
"groupRequired": "یک گروه تخفیف انتخاب کنید", "groupRequired": "یک گروه تخفیف انتخاب کنید",
"discountPercentInvalid": "درصد تخفیف باید بین 1 تا 100 باشد", "discountPercentInvalid": "درصد تخفیف باید بین 1 تا 100 باشد",
"discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید" "discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید",
"trafficRequired": "حجم ترافیک باید بیشتر از ۰ باشد"
} }
}, },
"promoGroups": { "promoGroups": {

View File

@@ -3697,7 +3697,10 @@
"save": "Сохранить", "save": "Сохранить",
"defaultTrialTariff": "Тариф триала по умолчанию", "defaultTrialTariff": "Тариф триала по умолчанию",
"selectTariff": "Выберите тариф", "selectTariff": "Выберите тариф",
"tariff": "Тариф" "tariff": "Тариф",
"includeTraffic": "Трафик",
"trafficAmount": "Объём трафика",
"gb": "ГБ"
}, },
"stats": { "stats": {
"title": "Статистика промокода", "title": "Статистика промокода",
@@ -3773,7 +3776,8 @@
"daysRequired": "Количество дней должно быть больше 0", "daysRequired": "Количество дней должно быть больше 0",
"groupRequired": "Выберите группу скидок", "groupRequired": "Выберите группу скидок",
"discountPercentInvalid": "Процент скидки должен быть от 1 до 100", "discountPercentInvalid": "Процент скидки должен быть от 1 до 100",
"discountHoursRequired": "Укажите время действия скидки в часах" "discountHoursRequired": "Укажите время действия скидки в часах",
"trafficRequired": "Объём трафика должен быть больше 0"
} }
}, },
"promoGroups": { "promoGroups": {

View File

@@ -2812,7 +2812,10 @@
"save": "保存", "save": "保存",
"defaultTrialTariff": "默认试用套餐", "defaultTrialTariff": "默认试用套餐",
"selectTariff": "选择套餐", "selectTariff": "选择套餐",
"tariff": "套餐" "tariff": "套餐",
"includeTraffic": "流量",
"trafficAmount": "流量额度",
"gb": "GB"
}, },
"stats": { "stats": {
"title": "促销码统计", "title": "促销码统计",
@@ -2884,7 +2887,8 @@
"daysRequired": "天数必须大于0", "daysRequired": "天数必须大于0",
"groupRequired": "请选择折扣组", "groupRequired": "请选择折扣组",
"discountPercentInvalid": "折扣百分比必须在1到100之间", "discountPercentInvalid": "折扣百分比必须在1到100之间",
"discountHoursRequired": "请指定折扣有效期(小时)" "discountHoursRequired": "请指定折扣有效期(小时)",
"trafficRequired": "流量额度必须大于 0"
} }
}, },
"promoGroups": { "promoGroups": {

View File

@@ -44,6 +44,8 @@ export default function AdminPromocodeCreate() {
const [includeBalance, setIncludeBalance] = useState(true); const [includeBalance, setIncludeBalance] = useState(true);
const [includeDays, setIncludeDays] = useState(false); const [includeDays, setIncludeDays] = useState(false);
const [includeGroup, setIncludeGroup] = useState(false); const [includeGroup, setIncludeGroup] = useState(false);
const [includeTraffic, setIncludeTraffic] = useState(false);
const [trafficGb, setTrafficGb] = useState<number | ''>(0);
const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0); const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(0); const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(0);
const [maxUses, setMaxUses] = useState<number | ''>(1); const [maxUses, setMaxUses] = useState<number | ''>(1);
@@ -85,8 +87,15 @@ export default function AdminPromocodeCreate() {
setMode(data.type); setMode(data.type);
} else { } else {
setMode('bonus_set'); setMode('bonus_set');
setIncludeBalance(data.type === 'balance' || data.type === 'balance_and_days'); // Галочки поднимаем по ЗНАЧЕНИЯМ, а не по типу. С появлением трафика
setIncludeDays(data.type === 'subscription_days' || data.type === 'balance_and_days'); // balance_and_days перестал означать «есть и баланс, и дни»: этот тип
// теперь у любого набора с трафиком, в том числе с нулевым балансом и
// нулём дней. По типу такой код открывался бы с двумя чужими галочками
// на нулях, валидация требовала бы «больше 0», и Сохранить не работало
// бы, пока админ их не снимет, — а подсказка толкает вместо этого
// вписать сумму и молча добавить коду бонус, которого в нём не было.
setIncludeBalance(data.type === 'balance' || (data.balance_bonus_rubles || 0) > 0);
setIncludeDays(data.type === 'subscription_days' || (data.subscription_days || 0) > 0);
// Промогруппа комбинируется с любым составом (bэкенд назначает её // Промогруппа комбинируется с любым составом (bэкенд назначает её
// независимо от типа), поэтому чекбокс — по факту наличия группы // независимо от типа), поэтому чекбокс — по факту наличия группы
setIncludeGroup(data.type === 'promo_group' || !!data.promo_group_id); setIncludeGroup(data.type === 'promo_group' || !!data.promo_group_id);
@@ -99,6 +108,8 @@ export default function AdminPromocodeCreate() {
setBalanceBonusRubles(data.balance_bonus_rubles || 0); setBalanceBonusRubles(data.balance_bonus_rubles || 0);
} }
setSubscriptionDays(data.subscription_days || 0); setSubscriptionDays(data.subscription_days || 0);
setTrafficGb(data.traffic_gb || 0);
setIncludeTraffic((data.traffic_gb || 0) > 0);
setMaxUses(data.max_uses || 1); setMaxUses(data.max_uses || 1);
setIsActive(data.is_active ?? true); setIsActive(data.is_active ?? true);
setFirstPurchaseOnly(data.first_purchase_only || false); setFirstPurchaseOnly(data.first_purchase_only || false);
@@ -133,7 +144,7 @@ export default function AdminPromocodeCreate() {
// едет через promo_group_id при любом типе (бэкенд применяет её независимо). // едет через promo_group_id при любом типе (бэкенд применяет её независимо).
const derivedType: PromoCodeType = const derivedType: PromoCodeType =
mode === 'bonus_set' mode === 'bonus_set'
? includeBalance && includeDays ? includeTraffic || (includeBalance && includeDays)
? 'balance_and_days' ? 'balance_and_days'
: includeBalance : includeBalance
? 'balance' ? 'balance'
@@ -148,6 +159,7 @@ export default function AdminPromocodeCreate() {
const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles; const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles;
const daysValue = subscriptionDays === '' ? 0 : subscriptionDays; const daysValue = subscriptionDays === '' ? 0 : subscriptionDays;
const maxUsesValue = maxUses === '' ? 0 : maxUses; const maxUsesValue = maxUses === '' ? 0 : maxUses;
const trafficValue = trafficGb === '' ? 0 : trafficGb;
const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = { const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = {
code: code.trim().toUpperCase(), code: code.trim().toUpperCase(),
@@ -164,6 +176,7 @@ export default function AdminPromocodeCreate() {
(mode === 'bonus_set' && includeDays) (mode === 'bonus_set' && includeDays)
? daysValue ? daysValue
: 0, : 0,
traffic_gb: mode === 'bonus_set' && includeTraffic ? trafficValue : 0,
max_uses: maxUsesValue, max_uses: maxUsesValue,
is_active: isActive, is_active: isActive,
first_purchase_only: firstPurchaseOnly, first_purchase_only: firstPurchaseOnly,
@@ -197,7 +210,7 @@ export default function AdminPromocodeCreate() {
validationErrors.push('codeRequired'); validationErrors.push('codeRequired');
} }
if (mode === 'bonus_set') { if (mode === 'bonus_set') {
if (!includeBalance && !includeDays && !includeGroup) { if (!includeBalance && !includeDays && !includeGroup && !includeTraffic) {
validationErrors.push('bonusSetEmpty'); validationErrors.push('bonusSetEmpty');
} }
if (includeBalance && balanceValue <= 0) { if (includeBalance && balanceValue <= 0) {
@@ -209,6 +222,9 @@ export default function AdminPromocodeCreate() {
if (includeGroup && !promoGroupId) { if (includeGroup && !promoGroupId) {
validationErrors.push('groupRequired'); validationErrors.push('groupRequired');
} }
if (includeTraffic && (trafficGb === '' || trafficGb <= 0)) {
validationErrors.push('trafficRequired');
}
} }
if (mode === 'trial_subscription' && daysValue <= 0) { if (mode === 'trial_subscription' && daysValue <= 0) {
validationErrors.push('daysRequired'); validationErrors.push('daysRequired');
@@ -308,6 +324,7 @@ export default function AdminPromocodeCreate() {
[ [
['includeBalance', includeBalance, setIncludeBalance] as const, ['includeBalance', includeBalance, setIncludeBalance] as const,
['includeDays', includeDays, setIncludeDays] as const, ['includeDays', includeDays, setIncludeDays] as const,
['includeTraffic', includeTraffic, setIncludeTraffic] as const,
['includePromoGroup', includeGroup, setIncludeGroup] as const, ['includePromoGroup', includeGroup, setIncludeGroup] as const,
] as const ] as const
).map(([key, checked, setChecked]) => ( ).map(([key, checked, setChecked]) => (
@@ -361,6 +378,28 @@ export default function AdminPromocodeCreate() {
</div> </div>
)} )}
{mode === 'bonus_set' && includeTraffic && (
<div>
<label htmlFor="pc-traffic-gb" className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promocodes.form.trafficAmount')}
<span className="text-error-400">*</span>
</label>
<div className="flex items-center gap-2">
<input
id="pc-traffic-gb"
type="number"
value={trafficGb}
onChange={createNumberInputHandler(setTrafficGb, 0)}
className="input w-32"
min={0}
step={1}
placeholder="0"
/>
<span className="text-dark-400">{t('admin.promocodes.form.gb')}</span>
</div>
</div>
)}
{(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && ( {(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
<div> <div>
<label htmlFor="pc-sub-days" className="mb-2 block text-sm font-medium text-dark-300"> <label htmlFor="pc-sub-days" className="mb-2 block text-sm font-medium text-dark-300">

View File

@@ -196,16 +196,27 @@ export default function AdminPromocodes() {
</div> </div>
{/* Info line */} {/* Info line */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400"> <div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
{(promo.type === 'balance' || promo.type === 'balance_and_days') && ( {/* Составляющие показываем по значению, а не по типу:
<span className="text-success-400"> balance_and_days теперь стоит и у набора, где баланса
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')} или дней нет вовсе, — иначе в списке висело бы «+0 ₽
</span> +0 дн.» у кода, который на самом деле раздаёт трафик. */}
)} {(promo.type === 'balance' || promo.type === 'balance_and_days') &&
promo.balance_bonus_rubles > 0 && (
<span className="text-success-400">
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
</span>
)}
{(promo.type === 'subscription_days' || {(promo.type === 'subscription_days' ||
promo.type === 'trial_subscription' || promo.type === 'trial_subscription' ||
promo.type === 'balance_and_days') && ( promo.type === 'balance_and_days') &&
promo.subscription_days > 0 && (
<span className="text-accent-400">
+{promo.subscription_days} {t('admin.promocodes.form.days')}
</span>
)}
{promo.type === 'balance_and_days' && (promo.traffic_gb || 0) > 0 && (
<span className="text-accent-400"> <span className="text-accent-400">
+{promo.subscription_days} {t('admin.promocodes.form.days')} +{promo.traffic_gb} {t('admin.promocodes.form.gb')}
</span> </span>
)} )}
{promo.type === 'discount' && ( {promo.type === 'discount' && (