import { useState, useCallback } from 'react'; import { useNavigate, useParams } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { DateField } from '../components/DateField'; import { createNumberInputHandler } from '../utils/inputHelpers'; import { promocodesApi, type PromoCodeDetail, type PromoCodeType, type PromoCodeCreateRequest, type PromoCodeUpdateRequest, type PromoGroup, } from '../api/promocodes'; import { tariffsApi } from '../api/tariffs'; import { usePlatform } from '../platform/hooks/usePlatform'; import { BackIcon, RefreshIcon } from '@/components/icons'; // valid_until is created as end-of-day in the admin's LOCAL tz, then stored/returned // as a UTC instant. Reading the picker back must convert UTC -> local date, otherwise // negative-offset admins see tomorrow's date and re-saving drifts the expiry forward a // day each time. (Mirror of DateField's local toISO; must NOT slice the raw UTC string.) const utcInstantToLocalDateInput = (iso: string): string => { const d = new Date(iso); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }; export default function AdminPromocodeCreate() { const { t } = useTranslation(); const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); const queryClient = useQueryClient(); const { capabilities } = usePlatform(); const isEdit = !!id; // Form state. // Режим формы: «набор бонусов» (баланс/дни/промогруппа чекбоксами — тип // промокода выводится из выбранной комбинации) либо особые типы, которые не // комбинируются: триал и процентная скидка (скидка переиспользует те же // колонки с другой семантикой — процент/часы). const [code, setCode] = useState(''); 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 [includeTraffic, setIncludeTraffic] = useState(false); const [trafficGb, setTrafficGb] = useState(0); const [balanceBonusRubles, setBalanceBonusRubles] = useState(0); const [subscriptionDays, setSubscriptionDays] = useState(0); const [maxUses, setMaxUses] = useState(1); const [isActive, setIsActive] = useState(true); const [firstPurchaseOnly, setFirstPurchaseOnly] = useState(false); const [validUntil, setValidUntil] = useState(''); const [promoGroupId, setPromoGroupId] = useState(null); const [tariffId, setTariffId] = useState(null); // Fetch promo groups (for promo_group type) const { data: promoGroupsData } = useQuery({ queryKey: ['admin-promo-groups'], queryFn: () => promocodesApi.getPromoGroups({ limit: 100 }), }); const promoGroups: PromoGroup[] = promoGroupsData?.items || []; // Fetch tariffs to show trial tariff info const { data: tariffsData } = useQuery({ queryKey: ['admin-tariffs-for-promo'], queryFn: () => tariffsApi.getTariffs(true), enabled: mode === 'trial_subscription', }); const trialTariff = tariffsData?.tariffs?.find((t) => t.is_trial_available) || null; // Fetch promocode for editing const { isLoading: isLoadingPromocode } = useQuery({ queryKey: ['admin-promocode', id], queryFn: () => promocodesApi.getPromocode(Number(id)), enabled: isEdit, staleTime: 0, gcTime: 0, refetchOnMount: true, refetchOnWindowFocus: false, select: useCallback((data: PromoCodeDetail) => { setCode(data.code); if (data.type === 'trial_subscription' || data.type === 'discount') { setMode(data.type); } else { setMode('bonus_set'); // Галочки поднимаем по ЗНАЧЕНИЯМ, а не по типу. С появлением трафика // 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); } // 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') { setBalanceBonusRubles(data.balance_bonus_kopeks); } else { 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); setValidUntil(data.valid_until ? utcInstantToLocalDateInput(data.valid_until) : ''); setPromoGroupId(data.promo_group_id || null); setTariffId(data.tariff_id || null); return data; }, []), }); // Create mutation const createMutation = useMutation({ mutationFn: promocodesApi.createPromocode, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-promocodes'] }); navigate('/admin/promocodes'); }, }); // Update mutation const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: PromoCodeUpdateRequest }) => promocodesApi.updatePromocode(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-promocodes'] }); navigate('/admin/promocodes'); }, }); // Тип промокода выводится из режима и выбранных чекбоксов. «Только // промогруппа» — легаси-тип promo_group; группа в комбинации с балансом/днями // едет через promo_group_id при любом типе (бэкенд применяет её независимо). const derivedType: PromoCodeType = mode === 'bonus_set' ? includeTraffic || (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 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(), type: derivedType, balance_bonus_kopeks: mode === 'discount' ? Math.round(balanceValue) // percent as integer : 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, traffic_gb: mode === 'bonus_set' && includeTraffic ? trafficValue : 0, max_uses: maxUsesValue, is_active: isActive, first_purchase_only: firstPurchaseOnly, // The picker yields a date-only 'YYYY-MM-DD'. A promo "valid until D" must // stay valid through the WHOLE of day D, so anchor to end-of-day in the // admin's local timezone. `new Date('YYYY-MM-DD')` parses as UTC midnight // (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: mode === 'bonus_set' && includeGroup ? promoGroupId : null, ...(mode === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}), }; if (isEdit) { updateMutation.mutate({ id: Number(id), data }); } else { createMutation.mutate(data as PromoCodeCreateRequest); } }; const isLoading = createMutation.isPending || updateMutation.isPending; // Validation const isCodeValid = code.trim().length > 0; const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles; const daysValue = subscriptionDays === '' ? 0 : subscriptionDays; // Collect validation errors for display const validationErrors: string[] = []; if (!isCodeValid) { validationErrors.push('codeRequired'); } if (mode === 'bonus_set') { if (!includeBalance && !includeDays && !includeGroup && !includeTraffic) { validationErrors.push('bonusSetEmpty'); } if (includeBalance && balanceValue <= 0) { validationErrors.push('balanceRequired'); } if (includeDays && daysValue <= 0) { validationErrors.push('daysRequired'); } if (includeGroup && !promoGroupId) { validationErrors.push('groupRequired'); } if (includeTraffic && (trafficGb === '' || trafficGb <= 0)) { validationErrors.push('trafficRequired'); } } if (mode === 'trial_subscription' && daysValue <= 0) { validationErrors.push('daysRequired'); } if (mode === 'discount') { if (balanceValue <= 0 || balanceValue > 100) { validationErrors.push('discountPercentInvalid'); } // 0 часов = «бессрочно до первой покупки» — разрешено (как в isValid до // унификации; раньше isValid и список ошибок противоречили друг другу) if (daysValue < 0) { validationErrors.push('discountHoursRequired'); } } const isValid = (): boolean => validationErrors.length === 0; // Loading state if (isEdit && isLoadingPromocode) { return (
); } return (
{/* Header */}
{/* Show back button only on web, not in Telegram Mini App */} {!capabilities.hasBackButton && ( )}

{isEdit ? t('admin.promocodes.modal.editPromocode') : t('admin.promocodes.modal.newPromocode')}

{t('admin.promocodes.subtitle')}

{/* Form */}
{/* Code */}
setCode(e.target.value.toUpperCase())} className={`input uppercase ${!isCodeValid && code.length > 0 ? 'border-error-500/50' : ''}`} placeholder="SUMMER2025" maxLength={50} />
{/* Type */}
{/* Состав набора бонусов — любая комбинация чекбоксами */} {mode === 'bonus_set' && (
{t('admin.promocodes.form.bonusComposition')} *
{( [ ['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]) => ( ))}
)} {/* Type-specific fields */} {mode === 'bonus_set' && includeBalance && (
{t('admin.promocodes.form.rub')}
)} {mode === 'bonus_set' && includeTraffic && (
{t('admin.promocodes.form.gb')}
)} {(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
{t('admin.promocodes.form.days')}
)} {mode === 'trial_subscription' && (
{!tariffId && !trialTariff && (
{t( 'admin.promocodes.form.noTrialTariffHint', 'Выберите тариф или отметьте тариф как «доступен для триала» в настройках.', )}
)}
)} {mode === 'bonus_set' && includeGroup && (
)} {mode === 'discount' && ( <>
{ const val = e.target.value; if (val === '') { setBalanceBonusRubles(''); } else { setBalanceBonusRubles(Math.min(100, Math.max(0, parseFloat(val) || 0))); } }} className="input w-32" min={1} max={100} placeholder="0" /> %

{t('admin.promocodes.form.discountHint')}

{ const val = e.target.value; if (val === '') { setSubscriptionDays(''); } else { setSubscriptionDays(Math.max(0, parseInt(val) || 0)); } }} className="input w-32" min={0} placeholder="0" /> {t('admin.promocodes.form.hours')}

{t('admin.promocodes.form.validityHint')}

)} {/* Max Uses */}
{t('admin.promocodes.form.unlimitedHint')}
{/* Valid Until */}

{t('admin.promocodes.form.validUntilHint')}

{/* Options */}
{/* Footer */}
{validationErrors.length > 0 && (

{t('admin.tariffs.cannotSave')}

    {validationErrors.map((error) => (
  • {t(`admin.promocodes.validation.${error}`)}
  • ))}
)}
); }