diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts index 32e56b7..e459f1e 100644 --- a/src/api/promocodes.ts +++ b/src/api/promocodes.ts @@ -7,7 +7,8 @@ export type PromoCodeType = | 'subscription_days' | 'trial_subscription' | 'promo_group' - | 'discount'; + | 'discount' + | 'balance_and_days'; export interface PromoCode { id: number; diff --git a/src/locales/en.json b/src/locales/en.json index a3c6070..1fd7bdd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 7039dc4..5c249ee 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": "یک گروه تخفیف انتخاب کنید", diff --git a/src/locales/ru.json b/src/locales/ru.json index 55d0ba6..255e913 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": "Выберите группу скидок", diff --git a/src/locales/zh.json b/src/locales/zh.json index 1f08a6f..cb3bc36 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": "请选择折扣组", diff --git a/src/pages/AdminPromocodeCreate.tsx b/src/pages/AdminPromocodeCreate.tsx index f36462a..f9975c3 100644 --- a/src/pages/AdminPromocodeCreate.tsx +++ b/src/pages/AdminPromocodeCreate.tsx @@ -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('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(0); const [subscriptionDays, setSubscriptionDays] = useState(0); const [maxUses, setMaxUses] = useState(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() { + {/* Состав набора бонусов — любая комбинация чекбоксами */} + {mode === 'bonus_set' && ( +
+
+ {t('admin.promocodes.form.bonusComposition')} + * +
+
+ {( + [ + ['includeBalance', includeBalance, setIncludeBalance] as const, + ['includeDays', includeDays, setIncludeDays] as const, + ['includePromoGroup', includeGroup, setIncludeGroup] as const, + ] as const + ).map(([key, checked, setChecked]) => ( + + ))} +
+
+ )} + {/* Type-specific fields */} - {type === 'balance' && ( + {mode === 'bonus_set' && includeBalance && (