mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 16:23:08 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user