From a96ddde314d938c2ee2e7c8fe7eaab84007060df Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 2 Feb 2026 05:40:25 +0300 Subject: [PATCH] feat: extract promocodes and promo groups into separate pages - Add AdminPromocodeCreate page for creating/editing promocodes - Add AdminPromoGroups page for listing discount groups - Add AdminPromoGroupCreate page for creating/editing discount groups - Move promo groups from Promocodes section to separate admin section - Simplify AdminPromocodes by removing embedded modals and groups tab - Add routes and navigation for new pages - Add i18n keys for all 4 locales (ru, en, zh, fa) --- src/App.tsx | 53 ++ src/locales/en.json | 62 +- src/locales/fa.json | 58 ++ src/locales/ru.json | 62 +- src/locales/zh.json | 58 ++ src/pages/AdminPanel.tsx | 16 + src/pages/AdminPromoGroupCreate.tsx | 409 ++++++++++ src/pages/AdminPromoGroups.tsx | 222 ++++++ src/pages/AdminPromocodeCreate.tsx | 490 ++++++++++++ src/pages/AdminPromocodes.tsx | 1121 +++------------------------ 10 files changed, 1536 insertions(+), 1015 deletions(-) create mode 100644 src/pages/AdminPromoGroupCreate.tsx create mode 100644 src/pages/AdminPromoGroups.tsx create mode 100644 src/pages/AdminPromocodeCreate.tsx diff --git a/src/App.tsx b/src/App.tsx index ac99b35..cdd0d8b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,6 +41,9 @@ const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem')); const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts')); const AdminBroadcastCreate = lazy(() => import('./pages/AdminBroadcastCreate')); const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes')); +const AdminPromocodeCreate = lazy(() => import('./pages/AdminPromocodeCreate')); +const AdminPromoGroups = lazy(() => import('./pages/AdminPromoGroups')); +const AdminPromoGroupCreate = lazy(() => import('./pages/AdminPromoGroupCreate')); const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns')); const AdminCampaignCreate = lazy(() => import('./pages/AdminCampaignCreate')); const AdminUsers = lazy(() => import('./pages/AdminUsers')); @@ -370,6 +373,56 @@ function App() { } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> ( ); +const UserGroupIcon = () => ( + + + +); + const MegaphoneIcon = () => ( , + title: t('admin.nav.promoGroups'), + description: t('admin.panel.promoGroupsDesc'), + }, { to: '/admin/promo-offers', icon: , diff --git a/src/pages/AdminPromoGroupCreate.tsx b/src/pages/AdminPromoGroupCreate.tsx new file mode 100644 index 0000000..66fcb57 --- /dev/null +++ b/src/pages/AdminPromoGroupCreate.tsx @@ -0,0 +1,409 @@ +import { useState, useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { + promocodesApi, + PromoGroup, + PromoGroupCreateRequest, + PromoGroupUpdateRequest, +} from '../api/promocodes'; +import { AdminBackButton } from '../components/admin'; + +// Icons +const PlusIcon = () => ( + + + +); + +const TrashIcon = () => ( + + + +); + +const RefreshIcon = () => ( + + + +); + +interface PeriodDiscount { + days: number; + percent: number; +} + +export default function AdminPromoGroupCreate() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const queryClient = useQueryClient(); + const isEdit = !!id; + + // Form state + const [name, setName] = useState(''); + const [serverDiscount, setServerDiscount] = useState(0); + const [trafficDiscount, setTrafficDiscount] = useState(0); + const [deviceDiscount, setDeviceDiscount] = useState(0); + const [applyToAddons, setApplyToAddons] = useState(true); + const [autoAssignSpent, setAutoAssignSpent] = useState(0); + const [periodDiscounts, setPeriodDiscounts] = useState([]); + + // Fetch promo group for editing + const { isLoading: isLoadingGroup } = useQuery({ + queryKey: ['admin-promo-group', id], + queryFn: () => promocodesApi.getPromoGroup(Number(id)), + enabled: isEdit, + staleTime: 0, + gcTime: 0, + refetchOnMount: true, + refetchOnWindowFocus: false, + select: useCallback((data: PromoGroup) => { + setName(data.name); + setServerDiscount(data.server_discount_percent || 0); + setTrafficDiscount(data.traffic_discount_percent || 0); + setDeviceDiscount(data.device_discount_percent || 0); + setApplyToAddons(data.apply_discounts_to_addons ?? true); + setAutoAssignSpent( + data.auto_assign_total_spent_kopeks ? data.auto_assign_total_spent_kopeks / 100 : 0, + ); + if (data.period_discounts && typeof data.period_discounts === 'object') { + setPeriodDiscounts( + Object.entries(data.period_discounts).map(([days, percent]) => ({ + days: parseInt(days), + percent: typeof percent === 'number' ? percent : 0, + })), + ); + } + return data; + }, []), + }); + + // Create mutation + const createMutation = useMutation({ + mutationFn: promocodesApi.createPromoGroup, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }); + navigate('/admin/promo-groups'); + }, + }); + + // Update mutation + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: PromoGroupUpdateRequest }) => + promocodesApi.updatePromoGroup(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }); + navigate('/admin/promo-groups'); + }, + }); + + const addPeriodDiscount = () => { + setPeriodDiscounts([...periodDiscounts, { days: 30, percent: 0 }]); + }; + + const removePeriodDiscount = (index: number) => { + setPeriodDiscounts(periodDiscounts.filter((_, i) => i !== index)); + }; + + const updatePeriodDiscount = (index: number, field: 'days' | 'percent', value: number) => { + const updated = [...periodDiscounts]; + updated[index][field] = value; + setPeriodDiscounts(updated); + }; + + const handleSubmit = () => { + // Convert periodDiscounts array to Record + const periodDiscountsRecord: Record = {}; + periodDiscounts.forEach((pd) => { + if (pd.days > 0 && pd.percent > 0) { + periodDiscountsRecord[pd.days] = pd.percent; + } + }); + + const serverVal = serverDiscount === '' ? 0 : serverDiscount; + const trafficVal = trafficDiscount === '' ? 0 : trafficDiscount; + const deviceVal = deviceDiscount === '' ? 0 : deviceDiscount; + const autoAssignVal = autoAssignSpent === '' ? 0 : autoAssignSpent; + + const data: PromoGroupCreateRequest | PromoGroupUpdateRequest = { + name, + server_discount_percent: serverVal, + traffic_discount_percent: trafficVal, + device_discount_percent: deviceVal, + period_discounts: + Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined, + apply_discounts_to_addons: applyToAddons, + auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : null, + }; + + if (isEdit) { + updateMutation.mutate({ id: Number(id), data }); + } else { + createMutation.mutate(data as PromoGroupCreateRequest); + } + }; + + const isLoading = createMutation.isPending || updateMutation.isPending; + const isValid = name.trim().length > 0; + + // Loading state + if (isEdit && isLoadingGroup) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

+ {isEdit ? t('admin.promoGroups.editTitle') : t('admin.promoGroups.createTitle')} +

+

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

+
+
+ + {/* Form */} +
+ {/* Name */} +
+ + setName(e.target.value)} + className="input" + placeholder={t('admin.promoGroups.form.namePlaceholder')} + /> +
+ + {/* Category Discounts */} +
+

+ {t('admin.promoGroups.form.categoryDiscounts')} +

+ +
+ {t('admin.promoGroups.servers')}: + { + const val = e.target.value; + if (val === '') { + setServerDiscount(''); + } else { + setServerDiscount(Math.min(100, Math.max(0, parseInt(val) || 0))); + } + }} + className="input w-20" + min={0} + max={100} + placeholder="0" + /> + % +
+ +
+ {t('admin.promoGroups.traffic')}: + { + const val = e.target.value; + if (val === '') { + setTrafficDiscount(''); + } else { + setTrafficDiscount(Math.min(100, Math.max(0, parseInt(val) || 0))); + } + }} + className="input w-20" + min={0} + max={100} + placeholder="0" + /> + % +
+ +
+ {t('admin.promoGroups.devices')}: + { + const val = e.target.value; + if (val === '') { + setDeviceDiscount(''); + } else { + setDeviceDiscount(Math.min(100, Math.max(0, parseInt(val) || 0))); + } + }} + className="input w-20" + min={0} + max={100} + placeholder="0" + /> + % +
+
+ + {/* Period Discounts */} +
+
+

+ {t('admin.promoGroups.form.periodDiscounts')} +

+ +
+

{t('admin.promoGroups.form.periodHint')}

+ + {periodDiscounts.length === 0 ? ( +

+ {t('admin.promoGroups.form.noPeriods')} +

+ ) : ( +
+ {periodDiscounts.map((pd, index) => ( +
+ + updatePeriodDiscount( + index, + 'days', + Math.max(1, parseInt(e.target.value) || 1), + ) + } + className="input w-20" + min={1} + placeholder={t('admin.promoGroups.form.daysPlaceholder')} + /> + {t('admin.promoGroups.form.arrow')} + + updatePeriodDiscount( + index, + 'percent', + Math.min(100, Math.max(0, parseInt(e.target.value) || 0)), + ) + } + className="input w-20" + min={0} + max={100} + placeholder="%" + /> + % + +
+ ))} +
+ )} +
+ + {/* Auto-assign */} +
+ +
+ { + const val = e.target.value; + if (val === '') { + setAutoAssignSpent(''); + } else { + setAutoAssignSpent(Math.max(0, parseFloat(val) || 0)); + } + }} + className="input w-32" + min={0} + placeholder="0" + /> + {t('admin.promoGroups.form.rub')} +
+

{t('admin.promoGroups.form.autoAssignHint')}

+
+ + {/* Apply to addons */} + +
+ + {/* Footer */} +
+
+ + +
+
+
+ ); +} diff --git a/src/pages/AdminPromoGroups.tsx b/src/pages/AdminPromoGroups.tsx new file mode 100644 index 0000000..14ab5e6 --- /dev/null +++ b/src/pages/AdminPromoGroups.tsx @@ -0,0 +1,222 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { promocodesApi, PromoGroup } from '../api/promocodes'; +import { AdminBackButton } from '../components/admin'; + +// Icons +const PlusIcon = () => ( + + + +); + +const EditIcon = () => ( + + + +); + +const TrashIcon = () => ( + + + +); + +const UsersIcon = () => ( + + + +); + +export default function AdminPromoGroups() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [deleteConfirm, setDeleteConfirm] = useState(null); + + // Query + const { data: groupsData, isLoading } = useQuery({ + queryKey: ['admin-promo-groups'], + queryFn: () => promocodesApi.getPromoGroups({ limit: 100 }), + }); + + // Delete mutation + const deleteMutation = useMutation({ + mutationFn: promocodesApi.deletePromoGroup, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }); + setDeleteConfirm(null); + }, + }); + + const groups = groupsData?.items || []; + + return ( +
+ {/* Header */} +
+
+ +
+

{t('admin.promoGroups.title')}

+

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

+
+
+ +
+ + {/* Stats */} + {groups.length > 0 && ( +
+
+
{groups.length}
+
{t('admin.promoGroups.stats.total')}
+
+
+
+ {groups.reduce((sum, g) => sum + g.members_count, 0)} +
+
{t('admin.promoGroups.stats.members')}
+
+
+
+ {groups.filter((g) => g.auto_assign_total_spent_kopeks).length} +
+
{t('admin.promoGroups.stats.autoAssign')}
+
+
+ )} + + {/* List */} + {isLoading ? ( +
+
+
+ ) : groups.length === 0 ? ( +
+

{t('admin.promoGroups.noGroups')}

+
+ ) : ( +
+ {groups.map((group: PromoGroup) => ( +
+
+
+
+

{group.name}

+ {group.is_default && ( + + {t('admin.promoGroups.default')} + + )} +
+
+ {group.server_discount_percent > 0 && ( + + {t('admin.promoGroups.servers')}: -{group.server_discount_percent}% + + )} + {group.traffic_discount_percent > 0 && ( + + {t('admin.promoGroups.traffic')}: -{group.traffic_discount_percent}% + + )} + {group.device_discount_percent > 0 && ( + + {t('admin.promoGroups.devices')}: -{group.device_discount_percent}% + + )} + {group.period_discounts && + Object.keys(group.period_discounts).length > 0 && + Object.entries(group.period_discounts).map(([days, percent]) => ( + + {t('admin.promoGroups.daysShort', { days })}: -{percent}% + + ))} + {group.auto_assign_total_spent_kopeks && + group.auto_assign_total_spent_kopeks > 0 && ( + + {t('admin.promoGroups.autoFrom', { + amount: group.auto_assign_total_spent_kopeks / 100, + })} + + )} + + + {t('admin.promoGroups.members', { count: group.members_count })} + +
+
+ +
+ + +
+
+
+ ))} +
+ )} + + {/* Delete Confirmation */} + {deleteConfirm && ( +
+
+

+ {t('admin.promoGroups.confirm.title')} +

+

{t('admin.promoGroups.confirm.text')}

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/pages/AdminPromocodeCreate.tsx b/src/pages/AdminPromocodeCreate.tsx new file mode 100644 index 0000000..0f43a1a --- /dev/null +++ b/src/pages/AdminPromocodeCreate.tsx @@ -0,0 +1,490 @@ +import { useState, useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { + promocodesApi, + PromoCodeDetail, + PromoCodeType, + PromoCodeCreateRequest, + PromoCodeUpdateRequest, + PromoGroup, +} from '../api/promocodes'; +import { AdminBackButton } from '../components/admin'; + +// Icons +const RefreshIcon = () => ( + + + +); + +export default function AdminPromocodeCreate() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const queryClient = useQueryClient(); + const isEdit = !!id; + + // Form state + const [code, setCode] = useState(''); + const [type, setType] = useState('balance'); + 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); + + // 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 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); + setType(data.type); + // 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); + setMaxUses(data.max_uses || 1); + setIsActive(data.is_active ?? true); + setFirstPurchaseOnly(data.first_purchase_only || false); + setValidUntil(data.valid_until ? data.valid_until.split('T')[0] : ''); + setPromoGroupId(data.promo_group_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'); + }, + }); + + 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 data: PromoCodeCreateRequest | PromoCodeUpdateRequest = { + code: code.trim().toUpperCase(), + type, + balance_bonus_kopeks: + type === 'discount' + ? Math.round(balanceValue) // percent as integer + : Math.round(balanceValue * 100), // rubles to kopeks + subscription_days: daysValue, + max_uses: maxUsesValue, + is_active: isActive, + first_purchase_only: firstPurchaseOnly, + valid_until: validUntil ? new Date(validUntil).toISOString() : null, + promo_group_id: type === 'promo_group' ? promoGroupId : null, + }; + + 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; + + 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; + 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 ((type === 'subscription_days' || type === 'trial_subscription') && daysValue <= 0) { + validationErrors.push('daysRequired'); + } + if (type === 'promo_group' && !promoGroupId) { + validationErrors.push('groupRequired'); + } + if (type === 'discount') { + if (balanceValue <= 0 || balanceValue > 100) { + validationErrors.push('discountPercentInvalid'); + } + if (daysValue <= 0) { + validationErrors.push('discountHoursRequired'); + } + } + + // Loading state + if (isEdit && isLoadingPromocode) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

+ {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" + /> +
+ + {/* Type */} +
+ + +
+ + {/* Type-specific fields */} + {type === 'balance' && ( +
+ +
+ { + const val = e.target.value; + if (val === '') { + setBalanceBonusRubles(''); + } else { + setBalanceBonusRubles(Math.max(0, parseFloat(val) || 0)); + } + }} + className="input w-32" + min={0} + step={1} + placeholder="0" + /> + {t('admin.promocodes.form.rub')} +
+
+ )} + + {(type === 'subscription_days' || type === 'trial_subscription') && ( +
+ +
+ { + const val = e.target.value; + if (val === '') { + setSubscriptionDays(''); + } else { + setSubscriptionDays(Math.max(0, parseInt(val) || 0)); + } + }} + className="input w-32" + min={1} + placeholder="0" + /> + {t('admin.promocodes.form.days')} +
+
+ )} + + {type === 'promo_group' && ( +
+ + +
+ )} + + {type === '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={1} + placeholder="0" + /> + {t('admin.promocodes.form.hours')} +
+

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

+
+ + )} + + {/* Max Uses */} +
+ +
+ { + const val = e.target.value; + if (val === '') { + setMaxUses(''); + } else { + setMaxUses(Math.max(0, parseInt(val) || 0)); + } + }} + className="input w-32" + min={0} + placeholder="0" + /> + + {t('admin.promocodes.form.unlimitedHint')} + +
+
+ + {/* Valid Until */} +
+ + setValidUntil(e.target.value)} + className="input" + /> +

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

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

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

+
    + {validationErrors.map((error) => ( +
  • {t(`admin.promocodes.validation.${error}`)}
  • + ))} +
+
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/pages/AdminPromocodes.tsx b/src/pages/AdminPromocodes.tsx index 47751e5..6839b52 100644 --- a/src/pages/AdminPromocodes.tsx +++ b/src/pages/AdminPromocodes.tsx @@ -1,18 +1,9 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import i18n from '../i18n'; -import { - promocodesApi, - PromoCode, - PromoCodeDetail, - PromoCodeType, - PromoCodeCreateRequest, - PromoCodeUpdateRequest, - PromoGroup, - PromoGroupCreateRequest, - PromoGroupUpdateRequest, -} from '../api/promocodes'; +import { promocodesApi, PromoCode, PromoCodeDetail, PromoCodeType } from '../api/promocodes'; import { AdminBackButton } from '../components/admin'; // Icons @@ -65,16 +56,6 @@ const CopyIcon = () => ( ); -const UsersIcon = () => ( - - - -); - const ChartIcon = () => ( { }); }; -// Promocode Modal -interface PromocodeModalProps { - promocode?: PromoCodeDetail | null; - promoGroups: PromoGroup[]; - onSave: (data: PromoCodeCreateRequest | PromoCodeUpdateRequest) => void; - onClose: () => void; - isLoading?: boolean; -} - -function PromocodeModal({ - promocode, - promoGroups, - onSave, - onClose, - isLoading, -}: PromocodeModalProps) { - const { t } = useTranslation(); - const isEdit = !!promocode; - - const [code, setCode] = useState(promocode?.code || ''); - const [type, setType] = useState(promocode?.type || 'balance'); - const [balanceBonusRubles, setBalanceBonusRubles] = useState( - promocode?.balance_bonus_rubles || 0, - ); - const [subscriptionDays, setSubscriptionDays] = useState(promocode?.subscription_days || 0); - const [maxUses, setMaxUses] = useState(promocode?.max_uses || 1); - const [isActive, setIsActive] = useState(promocode?.is_active ?? true); - const [firstPurchaseOnly, setFirstPurchaseOnly] = useState( - promocode?.first_purchase_only || false, - ); - const [validUntil, setValidUntil] = useState( - promocode?.valid_until ? promocode.valid_until.split('T')[0] : '', - ); - const [promoGroupId, setPromoGroupId] = useState( - promocode?.promo_group_id || null, - ); - - const handleSubmit = () => { - // Для discount: balance_bonus_kopeks = процент (целое число), subscription_days = часы - // Для balance: balance_bonus_kopeks = рубли * 100 - const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = { - code: code.trim().toUpperCase(), - type, - balance_bonus_kopeks: - type === 'discount' - ? Math.round(balanceBonusRubles) // процент как целое число - : Math.round(balanceBonusRubles * 100), // рубли в копейки - subscription_days: subscriptionDays, - max_uses: maxUses, - is_active: isActive, - first_purchase_only: firstPurchaseOnly, - valid_until: validUntil ? new Date(validUntil).toISOString() : null, - promo_group_id: type === 'promo_group' ? promoGroupId : null, - }; - onSave(data); - }; - - const isValid = () => { - if (!code.trim()) return false; - if (type === 'balance' && balanceBonusRubles <= 0) return false; - if ((type === 'subscription_days' || type === 'trial_subscription') && subscriptionDays <= 0) - return false; - if (type === 'promo_group' && !promoGroupId) return false; - if ( - type === 'discount' && - (balanceBonusRubles <= 0 || balanceBonusRubles > 100 || subscriptionDays <= 0) - ) - return false; - return true; - }; - - return ( -
-
- {/* Header */} -
-

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

- -
- - {/* Content */} -
- {/* Code */} -
- - setCode(e.target.value.toUpperCase())} - className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 uppercase text-dark-100 focus:border-accent-500 focus:outline-none" - placeholder="SUMMER2025" - /> -
- - {/* Type */} -
- - -
- - {/* Type-specific fields */} - {type === 'balance' && ( -
- -
- - setBalanceBonusRubles(Math.max(0, parseFloat(e.target.value) || 0)) - } - className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" - min={0} - step={1} - /> - {t('admin.promocodes.form.rub')} -
-
- )} - - {(type === 'subscription_days' || type === 'trial_subscription') && ( -
- -
- setSubscriptionDays(Math.max(0, parseInt(e.target.value) || 0))} - className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" - min={1} - /> - {t('admin.promocodes.form.days')} -
-
- )} - - {type === 'promo_group' && ( -
- - -
- )} - - {type === 'discount' && ( - <> -
- -
- - setBalanceBonusRubles( - Math.min(100, Math.max(0, parseFloat(e.target.value) || 0)), - ) - } - className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" - min={1} - max={100} - /> - % -
-

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

-
-
- -
- - setSubscriptionDays(Math.max(0, parseInt(e.target.value) || 0)) - } - className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" - min={1} - /> - {t('admin.promocodes.form.hours')} -
-

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

-
- - )} - - {/* Max Uses */} -
- -
- setMaxUses(Math.max(0, parseInt(e.target.value) || 0))} - className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" - min={0} - /> - - {t('admin.promocodes.form.unlimitedHint')} - -
-
- - {/* Valid Until */} -
- - setValidUntil(e.target.value)} - className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" - /> -

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

-
- - {/* Options */} -
- - - -
-
- - {/* Footer */} -
- - -
-
-
- ); -} - -// PromoGroup Modal -interface PromoGroupModalProps { - group?: PromoGroup | null; - onSave: (data: PromoGroupCreateRequest | PromoGroupUpdateRequest) => void; - onClose: () => void; - isLoading?: boolean; -} - -interface PeriodDiscount { - days: number; - percent: number; -} - -function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalProps) { - const { t } = useTranslation(); - const isEdit = !!group; - - const [name, setName] = useState(group?.name || ''); - const [serverDiscount, setServerDiscount] = useState(group?.server_discount_percent || 0); - const [trafficDiscount, setTrafficDiscount] = useState(group?.traffic_discount_percent || 0); - const [deviceDiscount, setDeviceDiscount] = useState(group?.device_discount_percent || 0); - const [applyToAddons, setApplyToAddons] = useState(group?.apply_discounts_to_addons ?? true); - const [autoAssignSpent, setAutoAssignSpent] = useState( - group?.auto_assign_total_spent_kopeks ? group.auto_assign_total_spent_kopeks / 100 : 0, - ); - - // Period discounts state - const [periodDiscounts, setPeriodDiscounts] = useState(() => { - if (group?.period_discounts && typeof group.period_discounts === 'object') { - return Object.entries(group.period_discounts).map(([days, percent]) => ({ - days: parseInt(days), - percent: typeof percent === 'number' ? percent : 0, - })); - } - return []; - }); - - const addPeriodDiscount = () => { - setPeriodDiscounts([...periodDiscounts, { days: 30, percent: 0 }]); - }; - - const removePeriodDiscount = (index: number) => { - setPeriodDiscounts(periodDiscounts.filter((_, i) => i !== index)); - }; - - const updatePeriodDiscount = (index: number, field: 'days' | 'percent', value: number) => { - const updated = [...periodDiscounts]; - updated[index][field] = value; - setPeriodDiscounts(updated); - }; - - const handleSubmit = () => { - // Convert periodDiscounts array to Record - const periodDiscountsRecord: Record = {}; - periodDiscounts.forEach((pd) => { - if (pd.days > 0 && pd.percent > 0) { - periodDiscountsRecord[pd.days] = pd.percent; - } - }); - - const data: PromoGroupCreateRequest | PromoGroupUpdateRequest = { - name, - server_discount_percent: serverDiscount, - traffic_discount_percent: trafficDiscount, - device_discount_percent: deviceDiscount, - period_discounts: - Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined, - apply_discounts_to_addons: applyToAddons, - auto_assign_total_spent_kopeks: - autoAssignSpent > 0 ? Math.round(autoAssignSpent * 100) : null, - }; - onSave(data); - }; - - return ( -
-
- {/* Header */} -
-

- {isEdit ? t('admin.promocodes.modal.editGroup') : t('admin.promocodes.modal.newGroup')} -

- -
- - {/* Content */} -
- {/* Name */} -
- - setName(e.target.value)} - className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" - placeholder={t('admin.promocodes.groups.namePlaceholder')} - /> -
- - {/* Category Discounts */} -
-

- {t('admin.promocodes.groups.categoryDiscounts')} -

- -
- - {t('admin.promocodes.groups.servers')}: - - - setServerDiscount(Math.min(100, Math.max(0, parseInt(e.target.value) || 0))) - } - className="w-20 rounded border border-dark-500 bg-dark-600 px-2 py-1 text-dark-100 focus:border-accent-500 focus:outline-none" - min={0} - max={100} - /> - % -
- -
- - {t('admin.promocodes.groups.traffic')}: - - - setTrafficDiscount(Math.min(100, Math.max(0, parseInt(e.target.value) || 0))) - } - className="w-20 rounded border border-dark-500 bg-dark-600 px-2 py-1 text-dark-100 focus:border-accent-500 focus:outline-none" - min={0} - max={100} - /> - % -
- -
- - {t('admin.promocodes.groups.devices')}: - - - setDeviceDiscount(Math.min(100, Math.max(0, parseInt(e.target.value) || 0))) - } - className="w-20 rounded border border-dark-500 bg-dark-600 px-2 py-1 text-dark-100 focus:border-accent-500 focus:outline-none" - min={0} - max={100} - /> - % -
-
- - {/* Period Discounts */} -
-
-

- {t('admin.promocodes.groups.periodDiscounts')} -

- -
-

- {t('admin.promocodes.groups.periodDiscountsHint')} -

- - {periodDiscounts.length === 0 ? ( -

- {t('admin.promocodes.groups.noPeriodDiscounts')} -

- ) : ( -
- {periodDiscounts.map((pd, index) => ( -
- - updatePeriodDiscount( - index, - 'days', - Math.max(1, parseInt(e.target.value) || 1), - ) - } - className="w-20 rounded border border-dark-500 bg-dark-600 px-2 py-1 text-dark-100 focus:border-accent-500 focus:outline-none" - min={1} - placeholder={t('admin.promocodes.groups.daysPlaceholder')} - /> - - {t('admin.promocodes.groups.daysArrow')} - - - updatePeriodDiscount( - index, - 'percent', - Math.min(100, Math.max(0, parseInt(e.target.value) || 0)), - ) - } - className="w-20 rounded border border-dark-500 bg-dark-600 px-2 py-1 text-dark-100 focus:border-accent-500 focus:outline-none" - min={0} - max={100} - placeholder="%" - /> - % - -
- ))} -
- )} -
- - {/* Auto-assign */} -
- -
- setAutoAssignSpent(Math.max(0, parseFloat(e.target.value) || 0))} - className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" - min={0} - /> - {t('admin.promocodes.form.rub')} -
-

- {t('admin.promocodes.groups.autoAssignHint')} -

-
- - {/* Apply to addons */} - -
- - {/* Footer */} -
- - -
-
-
- ); -} - // Promocode Stats Modal interface PromocodeStatsModalProps { promocode: PromoCodeDetail; @@ -971,51 +328,21 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal export default function AdminPromocodes() { const { t } = useTranslation(); + const navigate = useNavigate(); const queryClient = useQueryClient(); - const [activeTab, setActiveTab] = useState<'promocodes' | 'groups'>('promocodes'); const [viewingPromocode, setViewingPromocode] = useState(null); - const [showPromocodeModal, setShowPromocodeModal] = useState(false); - const [showGroupModal, setShowGroupModal] = useState(false); - const [editingPromocode, setEditingPromocode] = useState(null); - const [editingGroup, setEditingGroup] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ - type: 'promocode' | 'group'; - id: number; - } | null>(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); const [copiedCode, setCopiedCode] = useState(null); - // Queries - const { data: promocodesData, isLoading: promocodesLoading } = useQuery({ + // Query + const { data: promocodesData, isLoading } = useQuery({ queryKey: ['admin-promocodes'], queryFn: () => promocodesApi.getPromocodes({ limit: 100 }), }); - const { data: groupsData, isLoading: groupsLoading } = useQuery({ - queryKey: ['admin-promo-groups'], - queryFn: () => promocodesApi.getPromoGroups({ limit: 100 }), - }); - - // Promocode Mutations - const createPromocodeMutation = useMutation({ - mutationFn: promocodesApi.createPromocode, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-promocodes'] }); - setShowPromocodeModal(false); - }, - }); - - const updatePromocodeMutation = useMutation({ - mutationFn: ({ id, data }: { id: number; data: PromoCodeUpdateRequest }) => - promocodesApi.updatePromocode(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-promocodes'] }); - setShowPromocodeModal(false); - setEditingPromocode(null); - }, - }); - - const deletePromocodeMutation = useMutation({ + // Delete mutation + const deleteMutation = useMutation({ mutationFn: promocodesApi.deletePromocode, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-promocodes'] }); @@ -1023,43 +350,6 @@ export default function AdminPromocodes() { }, }); - // PromoGroup Mutations - const createGroupMutation = useMutation({ - mutationFn: promocodesApi.createPromoGroup, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }); - setShowGroupModal(false); - }, - }); - - const updateGroupMutation = useMutation({ - mutationFn: ({ id, data }: { id: number; data: PromoGroupUpdateRequest }) => - promocodesApi.updatePromoGroup(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }); - setShowGroupModal(false); - setEditingGroup(null); - }, - }); - - const deleteGroupMutation = useMutation({ - mutationFn: promocodesApi.deletePromoGroup, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }); - setDeleteConfirm(null); - }, - }); - - const handleEditPromocode = async (id: number) => { - try { - const detail = await promocodesApi.getPromocode(id); - setEditingPromocode(detail); - setShowPromocodeModal(true); - } catch (error) { - console.error('Failed to load promocode:', error); - } - }; - const handleViewStats = async (id: number) => { try { const detail = await promocodesApi.getPromocode(id); @@ -1069,39 +359,13 @@ export default function AdminPromocodes() { } }; - const handleSavePromocode = (data: PromoCodeCreateRequest | PromoCodeUpdateRequest) => { - if (editingPromocode) { - updatePromocodeMutation.mutate({ id: editingPromocode.id, data }); - } else { - createPromocodeMutation.mutate(data as PromoCodeCreateRequest); - } - }; - - const handleSaveGroup = (data: PromoGroupCreateRequest | PromoGroupUpdateRequest) => { - if (editingGroup) { - updateGroupMutation.mutate({ id: editingGroup.id, data }); - } else { - createGroupMutation.mutate(data as PromoGroupCreateRequest); - } - }; - const handleCopyCode = (code: string) => { navigator.clipboard.writeText(code); setCopiedCode(code); setTimeout(() => setCopiedCode(null), 2000); }; - const handleDelete = () => { - if (!deleteConfirm) return; - if (deleteConfirm.type === 'promocode') { - deletePromocodeMutation.mutate(deleteConfirm.id); - } else { - deleteGroupMutation.mutate(deleteConfirm.id); - } - }; - const promocodes = promocodesData?.items || []; - const groups = groupsData?.items || []; return (
@@ -1115,26 +379,16 @@ export default function AdminPromocodes() {
{/* Stats Overview */} - {activeTab === 'promocodes' && promocodes.length > 0 && ( + {promocodes.length > 0 && (
{promocodes.length}
@@ -1163,256 +417,107 @@ export default function AdminPromocodes() {
)} - {/* Tabs */} -
- - -
- {/* Promocodes List */} - {activeTab === 'promocodes' && ( - <> - {promocodesLoading ? ( -
-
-
- ) : promocodes.length === 0 ? ( -
-

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

-
- ) : ( -
- {promocodes.map((promo: PromoCode) => ( -
-
-
-
- - - {getTypeLabel(promo.type)} - - {!promo.is_active && ( - - {t('admin.promocodes.stats.inactive')} - - )} - {promo.first_purchase_only && ( - - {t('admin.promocodes.firstPurchase')} - - )} -
-
- {promo.type === 'balance' && ( - - +{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')} - - )} - {(promo.type === 'subscription_days' || - promo.type === 'trial_subscription') && ( - - +{promo.subscription_days} {t('admin.promocodes.form.days')} - - )} - {promo.type === 'discount' && ( - - {t('admin.promocodes.discountForHours', { - percent: promo.balance_bonus_kopeks, - hours: promo.subscription_days, - })} - - )} - - {t('admin.promocodes.used')}: {promo.current_uses}/ - {promo.max_uses === 0 ? '∞' : promo.max_uses} - - {promo.valid_until && ( - - {t('admin.promocodes.until')}: {formatDate(promo.valid_until)} - - )} -
-
- -
- - - -
+ {isLoading ? ( +
+
+
+ ) : promocodes.length === 0 ? ( +
+

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

+
+ ) : ( +
+ {promocodes.map((promo: PromoCode) => ( +
+
+
+
+ + + {getTypeLabel(promo.type)} + + {!promo.is_active && ( + + {t('admin.promocodes.stats.inactive')} + + )} + {promo.first_purchase_only && ( + + {t('admin.promocodes.firstPurchase')} + + )} +
+
+ {promo.type === 'balance' && ( + + +{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')} + + )} + {(promo.type === 'subscription_days' || + promo.type === 'trial_subscription') && ( + + +{promo.subscription_days} {t('admin.promocodes.form.days')} + + )} + {promo.type === 'discount' && ( + + {t('admin.promocodes.discountForHours', { + percent: promo.balance_bonus_kopeks, + hours: promo.subscription_days, + })} + + )} + + {t('admin.promocodes.used')}: {promo.current_uses}/ + {promo.max_uses === 0 ? '∞' : promo.max_uses} + + {promo.valid_until && ( + + {t('admin.promocodes.until')}: {formatDate(promo.valid_until)} + + )}
- ))} -
- )} - - )} - {/* Groups List */} - {activeTab === 'groups' && ( - <> - {groupsLoading ? ( -
-
-
- ) : groups.length === 0 ? ( -
-

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

-
- ) : ( -
- {groups.map((group: PromoGroup) => ( -
-
-
-
-

{group.name}

- {group.is_default && ( - - {t('admin.promocodes.groups.default')} - - )} -
-
- {group.server_discount_percent > 0 && ( - - {t('admin.promocodes.groups.servers')}: -{group.server_discount_percent} - % - - )} - {group.traffic_discount_percent > 0 && ( - - {t('admin.promocodes.groups.traffic')}: - - {group.traffic_discount_percent}% - - )} - {group.device_discount_percent > 0 && ( - - {t('admin.promocodes.groups.devices')}: -{group.device_discount_percent} - % - - )} - {group.period_discounts && - Object.keys(group.period_discounts).length > 0 && - Object.entries(group.period_discounts).map(([days, percent]) => ( - - {t('admin.promocodes.groups.daysShort', { days })}: -{percent}% - - ))} - {group.auto_assign_total_spent_kopeks && - group.auto_assign_total_spent_kopeks > 0 && ( - - {t('admin.promocodes.groups.autoFrom', { - amount: group.auto_assign_total_spent_kopeks / 100, - })} - - )} - - - {t('admin.promocodes.groups.members', { count: group.members_count })} - -
-
- -
- - -
-
+
+ + +
- ))} +
- )} - - )} - - {/* Promocode Modal */} - {showPromocodeModal && ( - { - setShowPromocodeModal(false); - setEditingPromocode(null); - }} - isLoading={createPromocodeMutation.isPending || updatePromocodeMutation.isPending} - /> - )} - - {/* PromoGroup Modal */} - {showGroupModal && ( - { - setShowGroupModal(false); - setEditingGroup(null); - }} - isLoading={createGroupMutation.isPending || updateGroupMutation.isPending} - /> + ))} +
)} {/* Promocode Stats Modal */} @@ -1421,9 +526,7 @@ export default function AdminPromocodes() { promocode={viewingPromocode} onClose={() => setViewingPromocode(null)} onEdit={() => { - setEditingPromocode(viewingPromocode); - setViewingPromocode(null); - setShowPromocodeModal(true); + navigate(`/admin/promocodes/${viewingPromocode.id}/edit`); }} /> )} @@ -1433,14 +536,10 @@ export default function AdminPromocodes() {

- {deleteConfirm.type === 'promocode' - ? t('admin.promocodes.confirm.deletePromocode') - : t('admin.promocodes.confirm.deleteGroup')} + {t('admin.promocodes.confirm.deletePromocode')}

- {deleteConfirm.type === 'promocode' - ? t('admin.promocodes.confirm.deletePromocodeText') - : t('admin.promocodes.confirm.deleteGroupText')} + {t('admin.promocodes.confirm.deletePromocodeText')}