diff --git a/src/App.tsx b/src/App.tsx index 0efbfe4..b64f4a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,6 +50,7 @@ const AdminPromoGroupCreate = lazy(() => import('./pages/AdminPromoGroupCreate') const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns')); const AdminCampaignCreate = lazy(() => import('./pages/AdminCampaignCreate')); const AdminCampaignStats = lazy(() => import('./pages/AdminCampaignStats')); +const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit')); const AdminUsers = lazy(() => import('./pages/AdminUsers')); const AdminPayments = lazy(() => import('./pages/AdminPayments')); const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods')); @@ -489,6 +490,16 @@ function App() { } /> + + + + + + } + /> ( /> ); + +export const CampaignIcon = ({ className }: IconProps) => ( + + + +); + +export const ChartIcon = ({ className }: IconProps) => ( + + + +); diff --git a/src/locales/en.json b/src/locales/en.json index 964487d..8c7e3a8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1489,7 +1489,9 @@ "deleteText": "This action cannot be undone. Campaigns with registrations cannot be deleted.", "cancel": "Cancel", "delete": "Delete" - } + }, + "loadError": "Failed to load campaign", + "updateError": "Failed to save changes" }, "promocodes": { "title": "Promo Codes", diff --git a/src/locales/fa.json b/src/locales/fa.json index aabfbc4..e5c74b3 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1282,7 +1282,9 @@ "deleteText": "این عمل قابل بازگشت نیست. کمپین‌هایی که ثبت‌نام دارند قابل حذف نیستند.", "cancel": "لغو", "delete": "حذف" - } + }, + "loadError": "بارگذاری کمپین ناموفق بود", + "updateError": "ذخیره تغییرات ناموفق بود" }, "promocodes": { "title": "کدهای تخفیف", diff --git a/src/locales/ru.json b/src/locales/ru.json index f9c89a1..059ffa9 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2000,7 +2000,9 @@ "deleteText": "Это действие нельзя отменить. Кампании с регистрациями удалить нельзя.", "cancel": "Отмена", "delete": "Удалить" - } + }, + "loadError": "Не удалось загрузить кампанию", + "updateError": "Не удалось сохранить изменения" }, "promocodes": { "title": "Промокоды", diff --git a/src/locales/zh.json b/src/locales/zh.json index 91889ca..cedbc8d 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1281,7 +1281,9 @@ "deleteText": "此操作不可撤销。有注册记录的活动无法删除。", "cancel": "取消", "delete": "删除" - } + }, + "loadError": "加载活动失败", + "updateError": "保存更改失败" }, "promocodes": { "title": "促销码", diff --git a/src/pages/AdminCampaignEdit.tsx b/src/pages/AdminCampaignEdit.tsx new file mode 100644 index 0000000..cb2396e --- /dev/null +++ b/src/pages/AdminCampaignEdit.tsx @@ -0,0 +1,491 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { + campaignsApi, + CampaignUpdateRequest, + CampaignBonusType, + ServerSquadInfo, + TariffListItem, +} from '../api/campaigns'; +import { AdminBackButton } from '../components/admin'; +import { CheckIcon, CampaignIcon } from '../components/icons'; +import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; + +// Bonus type config +const bonusTypeConfig: Record< + CampaignBonusType, + { labelKey: string; color: string; bgColor: string; borderColor: string } +> = { + balance: { + labelKey: 'admin.campaigns.bonusType.balance', + color: 'text-success-400', + bgColor: 'bg-success-500/10', + borderColor: 'border-success-500/30', + }, + subscription: { + labelKey: 'admin.campaigns.bonusType.subscription', + color: 'text-accent-400', + bgColor: 'bg-accent-500/10', + borderColor: 'border-accent-500/30', + }, + tariff: { + labelKey: 'admin.campaigns.bonusType.tariff', + color: 'text-accent-400', + bgColor: 'bg-accent-500/10', + borderColor: 'border-accent-500/30', + }, + none: { + labelKey: 'admin.campaigns.bonusType.none', + color: 'text-dark-400', + bgColor: 'bg-dark-500/10', + borderColor: 'border-dark-500/30', + }, +}; + +// Server selector component +function ServerSelector({ + servers, + selected, + onToggle, +}: { + servers: ServerSquadInfo[]; + selected: string[]; + onToggle: (uuid: string) => void; +}) { + const { t } = useTranslation(); + + if (servers.length === 0) return null; + + return ( +
+ +
+ {servers.map((server) => ( + + ))} +
+
+ ); +} + +// Tariff selector component +function TariffSelector({ + tariffs, + value, + onChange, +}: { + tariffs: TariffListItem[]; + value: number | null; + onChange: (id: number | null) => void; +}) { + const { t } = useTranslation(); + + return ( +
+ + +
+ ); +} + +export default function AdminCampaignEdit() { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const campaignId = parseInt(id || '0'); + + // Fetch campaign + const { + data: campaign, + isLoading, + error, + } = useQuery({ + queryKey: ['admin-campaign', campaignId], + queryFn: () => campaignsApi.getCampaign(campaignId), + enabled: campaignId > 0, + }); + + // Fetch servers + const { data: servers = [] } = useQuery({ + queryKey: ['admin-campaigns-servers'], + queryFn: () => campaignsApi.getAvailableServers(), + }); + + // Fetch tariffs + const { data: tariffs = [] } = useQuery({ + queryKey: ['admin-campaigns-tariffs'], + queryFn: () => campaignsApi.getAvailableTariffs(), + }); + + // Form state + const [name, setName] = useState(''); + const [startParameter, setStartParameter] = useState(''); + const [bonusType, setBonusType] = useState('balance'); + const [isActive, setIsActive] = useState(true); + + // Balance bonus + const [balanceBonusRubles, setBalanceBonusRubles] = useState(0); + + // Subscription bonus + const [subscriptionDays, setSubscriptionDays] = useState(7); + const [subscriptionTraffic, setSubscriptionTraffic] = useState(10); + const [subscriptionDevices, setSubscriptionDevices] = useState(1); + const [selectedSquads, setSelectedSquads] = useState([]); + + // Tariff bonus + const [tariffId, setTariffId] = useState(null); + const [tariffDays, setTariffDays] = useState(30); + + // Initialize form when campaign loads + useEffect(() => { + if (campaign) { + setName(campaign.name || ''); + setStartParameter(campaign.start_parameter || ''); + setBonusType(campaign.bonus_type || 'balance'); + setIsActive(campaign.is_active ?? true); + setBalanceBonusRubles((campaign.balance_bonus_kopeks || 0) / 100); + setSubscriptionDays(campaign.subscription_duration_days || 7); + setSubscriptionTraffic(campaign.subscription_traffic_gb || 10); + setSubscriptionDevices(campaign.subscription_device_limit || 1); + setSelectedSquads(campaign.subscription_squads || []); + setTariffId(campaign.tariff_id || null); + setTariffDays(campaign.tariff_duration_days || 30); + } + }, [campaign]); + + // Update mutation + const updateMutation = useMutation({ + mutationFn: (data: CampaignUpdateRequest) => campaignsApi.updateCampaign(campaignId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] }); + queryClient.invalidateQueries({ queryKey: ['admin-campaign', campaignId] }); + navigate('/admin/campaigns'); + }, + }); + + const toggleServer = (uuid: string) => { + setSelectedSquads((prev) => + prev.includes(uuid) ? prev.filter((s) => s !== uuid) : [...prev, uuid], + ); + }; + + const handleSubmit = () => { + const data: CampaignUpdateRequest = { + name, + start_parameter: startParameter, + bonus_type: bonusType, + is_active: isActive, + }; + + if (bonusType === 'balance') { + data.balance_bonus_kopeks = Math.round(toNumber(balanceBonusRubles) * 100); + } else if (bonusType === 'subscription') { + data.subscription_duration_days = toNumber(subscriptionDays, 7); + data.subscription_traffic_gb = toNumber(subscriptionTraffic, 10); + data.subscription_device_limit = toNumber(subscriptionDevices, 1); + data.subscription_squads = selectedSquads; + } else if (bonusType === 'tariff') { + data.tariff_id = tariffId || undefined; + data.tariff_duration_days = toNumber(tariffDays, 30); + } + + updateMutation.mutate(data); + }; + + const isValid = name.trim() && startParameter.trim() && /^[a-zA-Z0-9_-]+$/.test(startParameter); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error || !campaign) { + return ( +
+
+ +

+ {t('admin.campaigns.modal.editTitle')} +

+
+
+

{t('admin.campaigns.loadError')}

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+
+ +
+
+

+ {t('admin.campaigns.modal.editTitle')} +

+

{campaign.name}

+
+
+
+ + {/* Basic Info */} +
+ {/* Name */} +
+ + setName(e.target.value)} + className="input" + placeholder={t('admin.campaigns.form.namePlaceholder')} + /> +
+ + {/* Start Parameter */} +
+ + setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))} + className="input font-mono" + placeholder="instagram_jan2024" + /> +

+ {t('admin.campaigns.form.startParameterHint')} +

+
+ + {/* Active toggle */} +
+ + {t('admin.campaigns.form.active')} + + +
+
+ + {/* Bonus Type */} +
+

+ {t('admin.campaigns.form.bonusType')} +

+ +
+ {(Object.keys(bonusTypeConfig) as CampaignBonusType[]).map((type) => ( + + ))} +
+
+ + {/* Bonus Settings */} + {bonusType === 'balance' && ( +
+

+ {t('admin.campaigns.form.balanceBonus')} +

+
+ + +
+
+ )} + + {bonusType === 'subscription' && ( +
+

+ {t('admin.campaigns.form.trialSubscription')} +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ )} + + {bonusType === 'tariff' && ( +
+

+ {t('admin.campaigns.form.tariff')} +

+ + + +
+ + +
+
+ )} + + {bonusType === 'none' && ( +
+

{t('admin.campaigns.form.noBonusDescription')}

+
+ )} + + {/* Footer */} +
+ + +
+ + {updateMutation.isError && ( +
+ {t('admin.campaigns.updateError')} +
+ )} +
+ ); +} diff --git a/src/pages/AdminCampaigns.tsx b/src/pages/AdminCampaigns.tsx index 79078d8..be493c9 100644 --- a/src/pages/AdminCampaigns.tsx +++ b/src/pages/AdminCampaigns.tsx @@ -3,67 +3,9 @@ import { useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import i18n from '../i18n'; -import { - campaignsApi, - CampaignListItem, - CampaignDetail, - CampaignUpdateRequest, - CampaignBonusType, - ServerSquadInfo, - TariffListItem, -} from '../api/campaigns'; +import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns'; import { AdminBackButton } from '../components/admin'; -import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; - -// Icons - -const PlusIcon = () => ( - - - -); - -const EditIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const XIcon = () => ( - - - -); - -const ChartIcon = () => ( - - - -); +import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon, ChartIcon } from '../components/icons'; // Bonus type labels and colors const bonusTypeConfig: Record< @@ -104,349 +46,12 @@ const formatRubles = (kopeks: number) => { ); }; -// Campaign Edit Modal -interface CampaignModalProps { - campaign: CampaignDetail; - servers: ServerSquadInfo[]; - tariffs: TariffListItem[]; - onSave: (data: CampaignUpdateRequest) => void; - onClose: () => void; - isLoading?: boolean; -} - -function CampaignModal({ - campaign, - servers, - tariffs, - onSave, - onClose, - isLoading, -}: CampaignModalProps) { - const { t } = useTranslation(); - - const [name, setName] = useState(campaign.name || ''); - const [startParameter, setStartParameter] = useState(campaign.start_parameter || ''); - const [bonusType, setBonusType] = useState(campaign.bonus_type || 'balance'); - const [isActive, setIsActive] = useState(campaign.is_active ?? true); - - // Balance bonus - const [balanceBonusRubles, setBalanceBonusRubles] = useState( - (campaign.balance_bonus_kopeks || 0) / 100, - ); - - // Subscription bonus - const [subscriptionDays, setSubscriptionDays] = useState( - campaign.subscription_duration_days || 7, - ); - const [subscriptionTraffic, setSubscriptionTraffic] = useState( - campaign.subscription_traffic_gb || 10, - ); - const [subscriptionDevices, setSubscriptionDevices] = useState( - campaign.subscription_device_limit || 1, - ); - const [selectedSquads, setSelectedSquads] = useState( - campaign.subscription_squads || [], - ); - - // Tariff bonus - const [tariffId, setTariffId] = useState(campaign.tariff_id || null); - const [tariffDays, setTariffDays] = useState(campaign.tariff_duration_days || 30); - - const handleSubmit = () => { - const data: CampaignUpdateRequest = { - name, - start_parameter: startParameter, - bonus_type: bonusType, - is_active: isActive, - }; - - if (bonusType === 'balance') { - data.balance_bonus_kopeks = Math.round(toNumber(balanceBonusRubles) * 100); - } else if (bonusType === 'subscription') { - data.subscription_duration_days = toNumber(subscriptionDays, 7); - data.subscription_traffic_gb = toNumber(subscriptionTraffic, 10); - data.subscription_device_limit = toNumber(subscriptionDevices, 1); - data.subscription_squads = selectedSquads; - } else if (bonusType === 'tariff') { - data.tariff_id = tariffId || undefined; - data.tariff_duration_days = toNumber(tariffDays, 30); - } - - onSave(data); - }; - - const toggleServer = (uuid: string) => { - setSelectedSquads((prev) => - prev.includes(uuid) ? prev.filter((s) => s !== uuid) : [...prev, uuid], - ); - }; - - const isValid = name.trim() && startParameter.trim() && /^[a-zA-Z0-9_-]+$/.test(startParameter); - - return ( -
-
- {/* Header */} -
-

- {t('admin.campaigns.modal.editTitle')} -

- -
- - {/* 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.campaigns.form.namePlaceholder')} - /> -
- - {/* Start Parameter */} -
- - setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))} - 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="instagram_jan2024" - /> -

- {t('admin.campaigns.form.startParameterHint')} -

-
- - {/* Bonus Type */} -
- -
- {(Object.keys(bonusTypeConfig) as CampaignBonusType[]).map((type) => ( - - ))} -
-
- - {/* Bonus Settings */} - {bonusType === 'balance' && ( -
- -
- - -
-
- )} - - {bonusType === 'subscription' && ( -
- -
-
- - -
-
- - -
-
- - -
-
- {servers.length > 0 && ( -
- -
- {servers.map((server) => ( - - ))} -
-
- )} -
- )} - - {bonusType === 'tariff' && ( -
- -
- - -
-
- - -
-
- )} - - {bonusType === 'none' && ( -
-

- {t('admin.campaigns.form.noBonusDescription')} -

-
- )} - - {/* Active toggle */} -
- {t('admin.campaigns.form.active')} - -
-
- - {/* Footer */} -
- - -
-
-
- ); -} - // Main Component export default function AdminCampaigns() { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const [editingCampaign, setEditingCampaign] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); // Queries @@ -460,26 +65,7 @@ export default function AdminCampaigns() { queryFn: () => campaignsApi.getOverview(), }); - const { data: servers = [] } = useQuery({ - queryKey: ['admin-campaigns-servers'], - queryFn: () => campaignsApi.getAvailableServers(), - }); - - const { data: tariffs = [] } = useQuery({ - queryKey: ['admin-campaigns-tariffs'], - queryFn: () => campaignsApi.getAvailableTariffs(), - }); - // Mutations - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: number; data: CampaignUpdateRequest }) => - campaignsApi.updateCampaign(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] }); - setEditingCampaign(null); - }, - }); - const deleteMutation = useMutation({ mutationFn: campaignsApi.deleteCampaign, onSuccess: () => { @@ -496,25 +82,6 @@ export default function AdminCampaigns() { }, }); - const handleEdit = async (campaignId: number) => { - try { - const detail = await campaignsApi.getCampaign(campaignId); - setEditingCampaign(detail); - } catch (error) { - console.error('Failed to load campaign:', error); - } - }; - - const handleSave = (data: CampaignUpdateRequest) => { - if (editingCampaign) { - updateMutation.mutate({ id: editingCampaign.id, data }); - } - }; - - const handleCloseModal = () => { - setEditingCampaign(null); - }; - const campaigns = campaignsData?.campaigns || []; return ( @@ -647,7 +214,7 @@ export default function AdminCampaigns() { {/* Edit */}
)} - {/* Edit Modal */} - {editingCampaign && ( - - )} - {/* Delete Confirmation */} {deleteConfirm !== null && (