diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts index e459f1e..e162937 100644 --- a/src/api/promocodes.ts +++ b/src/api/promocodes.ts @@ -17,6 +17,8 @@ export interface PromoCode { balance_bonus_kopeks: number; balance_bonus_rubles: number; subscription_days: number; + /** Гигабайты к подписке — третья составляющая набора бонусов. */ + traffic_gb: number; max_uses: number; current_uses: number; uses_left: number; @@ -60,6 +62,7 @@ export interface PromoCodeCreateRequest { type: PromoCodeType; balance_bonus_kopeks?: number; subscription_days?: number; + traffic_gb?: number; max_uses?: number; valid_from?: string; valid_until?: string | null; @@ -74,6 +77,7 @@ export interface PromoCodeUpdateRequest { type?: PromoCodeType; balance_bonus_kopeks?: number; subscription_days?: number; + traffic_gb?: number; max_uses?: number; valid_from?: string; valid_until?: string | null; diff --git a/src/locales/en.json b/src/locales/en.json index ef1ef93..e5bbd95 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3303,7 +3303,10 @@ "save": "Save", "defaultTrialTariff": "Default trial tariff", "selectTariff": "Select tariff", - "tariff": "Tariff" + "tariff": "Tariff", + "includeTraffic": "Traffic", + "trafficAmount": "Traffic amount", + "gb": "GB" }, "stats": { "title": "Promo code statistics", @@ -3377,7 +3380,8 @@ "daysRequired": "Number of days must be greater than 0", "groupRequired": "Select a discount group", "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": { diff --git a/src/locales/fa.json b/src/locales/fa.json index e2a4d74..9ba6a00 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2813,7 +2813,10 @@ "save": "ذخیره", "defaultTrialTariff": "تعرفه پیش‌فرض آزمایشی", "selectTariff": "تعرفه را انتخاب کنید", - "tariff": "تعرفه" + "tariff": "تعرفه", + "includeTraffic": "ترافیک", + "trafficAmount": "حجم ترافیک", + "gb": "گیگابایت" }, "stats": { "title": "آمار کد تخفیف", @@ -2885,7 +2888,8 @@ "daysRequired": "تعداد روزها باید بیشتر از 0 باشد", "groupRequired": "یک گروه تخفیف انتخاب کنید", "discountPercentInvalid": "درصد تخفیف باید بین 1 تا 100 باشد", - "discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید" + "discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید", + "trafficRequired": "حجم ترافیک باید بیشتر از ۰ باشد" } }, "promoGroups": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 7cfcb5a..b1e0c61 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3697,7 +3697,10 @@ "save": "Сохранить", "defaultTrialTariff": "Тариф триала по умолчанию", "selectTariff": "Выберите тариф", - "tariff": "Тариф" + "tariff": "Тариф", + "includeTraffic": "Трафик", + "trafficAmount": "Объём трафика", + "gb": "ГБ" }, "stats": { "title": "Статистика промокода", @@ -3773,7 +3776,8 @@ "daysRequired": "Количество дней должно быть больше 0", "groupRequired": "Выберите группу скидок", "discountPercentInvalid": "Процент скидки должен быть от 1 до 100", - "discountHoursRequired": "Укажите время действия скидки в часах" + "discountHoursRequired": "Укажите время действия скидки в часах", + "trafficRequired": "Объём трафика должен быть больше 0" } }, "promoGroups": { diff --git a/src/locales/zh.json b/src/locales/zh.json index bcb9953..f39b815 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2812,7 +2812,10 @@ "save": "保存", "defaultTrialTariff": "默认试用套餐", "selectTariff": "选择套餐", - "tariff": "套餐" + "tariff": "套餐", + "includeTraffic": "流量", + "trafficAmount": "流量额度", + "gb": "GB" }, "stats": { "title": "促销码统计", @@ -2884,7 +2887,8 @@ "daysRequired": "天数必须大于0", "groupRequired": "请选择折扣组", "discountPercentInvalid": "折扣百分比必须在1到100之间", - "discountHoursRequired": "请指定折扣有效期(小时)" + "discountHoursRequired": "请指定折扣有效期(小时)", + "trafficRequired": "流量额度必须大于 0" } }, "promoGroups": { diff --git a/src/pages/AdminPromocodeCreate.tsx b/src/pages/AdminPromocodeCreate.tsx index f9975c3..56f703c 100644 --- a/src/pages/AdminPromocodeCreate.tsx +++ b/src/pages/AdminPromocodeCreate.tsx @@ -44,6 +44,8 @@ export default function AdminPromocodeCreate() { const [includeBalance, setIncludeBalance] = useState(true); const [includeDays, setIncludeDays] = useState(false); const [includeGroup, setIncludeGroup] = useState(false); + const [includeTraffic, setIncludeTraffic] = useState(false); + const [trafficGb, setTrafficGb] = useState(0); const [balanceBonusRubles, setBalanceBonusRubles] = useState(0); const [subscriptionDays, setSubscriptionDays] = useState(0); const [maxUses, setMaxUses] = useState(1); @@ -85,8 +87,15 @@ export default function AdminPromocodeCreate() { 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'); + // Галочки поднимаем по ЗНАЧЕНИЯМ, а не по типу. С появлением трафика + // 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экенд назначает её // независимо от типа), поэтому чекбокс — по факту наличия группы setIncludeGroup(data.type === 'promo_group' || !!data.promo_group_id); @@ -99,6 +108,8 @@ export default function AdminPromocodeCreate() { setBalanceBonusRubles(data.balance_bonus_rubles || 0); } setSubscriptionDays(data.subscription_days || 0); + setTrafficGb(data.traffic_gb || 0); + setIncludeTraffic((data.traffic_gb || 0) > 0); setMaxUses(data.max_uses || 1); setIsActive(data.is_active ?? true); setFirstPurchaseOnly(data.first_purchase_only || false); @@ -133,7 +144,7 @@ export default function AdminPromocodeCreate() { // едет через promo_group_id при любом типе (бэкенд применяет её независимо). const derivedType: PromoCodeType = mode === 'bonus_set' - ? includeBalance && includeDays + ? includeTraffic || (includeBalance && includeDays) ? 'balance_and_days' : includeBalance ? 'balance' @@ -148,6 +159,7 @@ export default function AdminPromocodeCreate() { const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles; const daysValue = subscriptionDays === '' ? 0 : subscriptionDays; const maxUsesValue = maxUses === '' ? 0 : maxUses; + const trafficValue = trafficGb === '' ? 0 : trafficGb; const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = { code: code.trim().toUpperCase(), @@ -164,6 +176,7 @@ export default function AdminPromocodeCreate() { (mode === 'bonus_set' && includeDays) ? daysValue : 0, + traffic_gb: mode === 'bonus_set' && includeTraffic ? trafficValue : 0, max_uses: maxUsesValue, is_active: isActive, first_purchase_only: firstPurchaseOnly, @@ -197,7 +210,7 @@ export default function AdminPromocodeCreate() { validationErrors.push('codeRequired'); } if (mode === 'bonus_set') { - if (!includeBalance && !includeDays && !includeGroup) { + if (!includeBalance && !includeDays && !includeGroup && !includeTraffic) { validationErrors.push('bonusSetEmpty'); } if (includeBalance && balanceValue <= 0) { @@ -209,6 +222,9 @@ export default function AdminPromocodeCreate() { if (includeGroup && !promoGroupId) { validationErrors.push('groupRequired'); } + if (includeTraffic && (trafficGb === '' || trafficGb <= 0)) { + validationErrors.push('trafficRequired'); + } } if (mode === 'trial_subscription' && daysValue <= 0) { validationErrors.push('daysRequired'); @@ -308,6 +324,7 @@ export default function AdminPromocodeCreate() { [ ['includeBalance', includeBalance, setIncludeBalance] as const, ['includeDays', includeDays, setIncludeDays] as const, + ['includeTraffic', includeTraffic, setIncludeTraffic] as const, ['includePromoGroup', includeGroup, setIncludeGroup] as const, ] as const ).map(([key, checked, setChecked]) => ( @@ -361,6 +378,28 @@ export default function AdminPromocodeCreate() { )} + {mode === 'bonus_set' && includeTraffic && ( +
+ +
+ + {t('admin.promocodes.form.gb')} +
+
+ )} + {(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
{/* Info line */}
- {(promo.type === 'balance' || promo.type === 'balance_and_days') && ( - - +{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')} - - )} + {/* Составляющие показываем по значению, а не по типу: + balance_and_days теперь стоит и у набора, где баланса + или дней нет вовсе, — иначе в списке висело бы «+0 ₽ + +0 дн.» у кода, который на самом деле раздаёт трафик. */} + {(promo.type === 'balance' || promo.type === 'balance_and_days') && + promo.balance_bonus_rubles > 0 && ( + + +{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')} + + )} {(promo.type === 'subscription_days' || promo.type === 'trial_subscription' || - promo.type === 'balance_and_days') && ( + promo.type === 'balance_and_days') && + promo.subscription_days > 0 && ( + + +{promo.subscription_days} {t('admin.promocodes.form.days')} + + )} + {promo.type === 'balance_and_days' && (promo.traffic_gb || 0) > 0 && ( - +{promo.subscription_days} {t('admin.promocodes.form.days')} + +{promo.traffic_gb} {t('admin.promocodes.form.gb')} )} {promo.type === 'discount' && (