From 959f89266bd7fe6e8a38d218c7d34e14c509a21b Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 18 Feb 2026 06:47:07 +0300 Subject: [PATCH] feat: partner-campaign integration in admin UI - Add partner badge to campaigns list - Add partner selector dropdown in campaign edit - Support ?partnerId= query param in campaign create (replaces separate page) - Filter already-assigned campaigns in partner assign flow - Show taken campaigns greyed out with partner name - Delete AdminPartnerCampaignCreate (replaced by query param approach) - Add partner translations (ru, en, zh, fa) --- src/App.tsx | 11 -- src/api/campaigns.ts | 18 +++ src/locales/en.json | 6 +- src/locales/fa.json | 6 +- src/locales/ru.json | 6 +- src/locales/zh.json | 6 +- src/pages/AdminCampaignCreate.tsx | 75 ++++++++- src/pages/AdminCampaignEdit.tsx | 57 +++++++ src/pages/AdminCampaigns.tsx | 5 + src/pages/AdminPartnerCampaignAssign.tsx | 31 +++- src/pages/AdminPartnerCampaignCreate.tsx | 185 ----------------------- src/pages/AdminPartnerDetail.tsx | 2 +- 12 files changed, 201 insertions(+), 207 deletions(-) delete mode 100644 src/pages/AdminPartnerCampaignCreate.tsx diff --git a/src/App.tsx b/src/App.tsx index 29b455b..b39bd3e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -66,7 +66,6 @@ const AdminApplicationReview = lazy(() => import('./pages/AdminApplicationReview const AdminPartnerCommission = lazy(() => import('./pages/AdminPartnerCommission')); const AdminPartnerRevoke = lazy(() => import('./pages/AdminPartnerRevoke')); const AdminPartnerCampaignAssign = lazy(() => import('./pages/AdminPartnerCampaignAssign')); -const AdminPartnerCampaignCreate = lazy(() => import('./pages/AdminPartnerCampaignCreate')); const AdminWithdrawals = lazy(() => import('./pages/AdminWithdrawals')); const AdminWithdrawalDetail = lazy(() => import('./pages/AdminWithdrawalDetail')); const AdminWithdrawalReject = lazy(() => import('./pages/AdminWithdrawalReject')); @@ -642,16 +641,6 @@ function App() { } /> - - - - - - } - /> => { @@ -240,4 +252,10 @@ export const campaignsApi = { const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs'); return response.data; }, + + // Get available partners for campaign assignment + getAvailablePartners: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/campaigns/available-partners'); + return response.data; + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index d9e6ab8..2d0fbbc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1747,7 +1747,11 @@ "active": "Active", "cancel": "Cancel", "save": "Save", - "saving": "Saving..." + "saving": "Saving...", + "partner": "Partner", + "noPartner": "No partner", + "selectPartner": "Select partner", + "partnerAutoAssign": "Campaign will be automatically assigned to partner {{name}}" }, "stats": { "title": "Campaign Statistics", diff --git a/src/locales/fa.json b/src/locales/fa.json index f509303..503a076 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1486,7 +1486,11 @@ "active": "فعال", "cancel": "لغو", "save": "ذخیره", - "saving": "در حال ذخیره..." + "saving": "در حال ذخیره...", + "partner": "شریک", + "noPartner": "بدون شریک", + "selectPartner": "انتخاب شریک", + "partnerAutoAssign": "کمپین به طور خودکار به شریک {{name}} اختصاص داده می‌شود" }, "stats": { "title": "آمار کمپین", diff --git a/src/locales/ru.json b/src/locales/ru.json index 18e3075..4c07342 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2267,7 +2267,11 @@ "active": "Активна", "cancel": "Отмена", "save": "Сохранить", - "saving": "Сохранение..." + "saving": "Сохранение...", + "partner": "Партнёр", + "noPartner": "Без партнёра", + "selectPartner": "Выберите партнёра", + "partnerAutoAssign": "Кампания будет автоматически привязана к партнёру {{name}}" }, "stats": { "title": "Статистика кампании", diff --git a/src/locales/zh.json b/src/locales/zh.json index 6110478..ee5f231 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1485,7 +1485,11 @@ "active": "启用", "cancel": "取消", "save": "保存", - "saving": "保存中..." + "saving": "保存中...", + "partner": "合作伙伴", + "noPartner": "无合作伙伴", + "selectPartner": "选择合作伙伴", + "partnerAutoAssign": "活动将自动分配给合作伙伴 {{name}}" }, "stats": { "title": "活动统计", diff --git a/src/pages/AdminCampaignCreate.tsx b/src/pages/AdminCampaignCreate.tsx index 87c3bec..2aa9c42 100644 --- a/src/pages/AdminCampaignCreate.tsx +++ b/src/pages/AdminCampaignCreate.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router'; +import { useState, useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { @@ -9,6 +9,7 @@ import { ServerSquadInfo, TariffListItem, } from '../api/campaigns'; +import { partnerApi } from '../api/partners'; import { AdminBackButton } from '../components/admin'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; // Icons @@ -149,10 +150,27 @@ function TariffSelector({ ); } +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/gi, '_') + .replace(/^_|_$/g, '') + .slice(0, 30); +} + export default function AdminCampaignCreate() { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const [searchParams] = useSearchParams(); + const partnerId = searchParams.get('partnerId'); + + // Fetch partner info if creating for a partner + const { data: partner } = useQuery({ + queryKey: ['admin-partner-detail', partnerId], + queryFn: () => partnerApi.getPartnerDetail(Number(partnerId)), + enabled: !!partnerId, + }); // Form state const [name, setName] = useState(''); @@ -160,6 +178,17 @@ export default function AdminCampaignCreate() { const [bonusType, setBonusType] = useState('balance'); const [isActive, setIsActive] = useState(true); + // Auto-fill from partner + const [autoFilled, setAutoFilled] = useState(false); + useEffect(() => { + if (partner && !autoFilled) { + const partnerName = partner.first_name || partner.username || ''; + if (!name && partnerName) setName(partnerName); + if (!startParameter && partnerName) setStartParameter(`partner_${slugify(partnerName)}`); + setAutoFilled(true); + } + }, [partner, autoFilled, name, startParameter]); + // Balance bonus const [balanceBonusRubles, setBalanceBonusRubles] = useState(0); @@ -191,7 +220,12 @@ export default function AdminCampaignCreate() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] }); queryClient.invalidateQueries({ queryKey: ['admin-campaigns-overview'] }); - navigate('/admin/campaigns'); + if (partnerId) { + queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', partnerId] }); + navigate(`/admin/partners/${partnerId}`); + } else { + navigate('/admin/campaigns'); + } }, }); @@ -221,6 +255,10 @@ export default function AdminCampaignCreate() { data.tariff_duration_days = toNumber(tariffDays, 30); } + if (partnerId) { + data.partner_user_id = Number(partnerId); + } + createMutation.mutate(data); }; @@ -247,6 +285,32 @@ export default function AdminCampaignCreate() { + {/* Partner info banner */} + {partnerId && partner && ( +
+
+ + + +

+ {t('admin.campaigns.form.partnerAutoAssign', { + name: partner.first_name || partner.username || `#${partnerId}`, + })} +

+
+
+ )} + {/* Basic Info */}
{/* Name */} @@ -442,7 +506,10 @@ export default function AdminCampaignCreate() { {/* Footer */}
-
+ + {/* Partner */} + {partners.length > 0 && ( + + )}
{/* Bonus Type */} diff --git a/src/pages/AdminCampaigns.tsx b/src/pages/AdminCampaigns.tsx index 42c3c2e..d6eae21 100644 --- a/src/pages/AdminCampaigns.tsx +++ b/src/pages/AdminCampaigns.tsx @@ -183,6 +183,11 @@ export default function AdminCampaigns() { > {t(bonusTypeConfig[campaign.bonus_type].labelKey)} + {campaign.partner_name && ( + + {campaign.partner_name} + + )} {!campaign.is_active && ( {t('admin.campaigns.table.inactive')} diff --git a/src/pages/AdminPartnerCampaignAssign.tsx b/src/pages/AdminPartnerCampaignAssign.tsx index 2ee6870..fb990f9 100644 --- a/src/pages/AdminPartnerCampaignAssign.tsx +++ b/src/pages/AdminPartnerCampaignAssign.tsx @@ -33,7 +33,12 @@ export default function AdminPartnerCampaignAssign() { }); const assignedIds = new Set(partner?.campaigns.map((c) => c.id) ?? []); - const available = campaignsData?.campaigns.filter((c) => !assignedIds.has(c.id)) ?? []; + const available = + campaignsData?.campaigns.filter((c) => !assignedIds.has(c.id) && c.partner_user_id === null) ?? + []; + const takenByOthers = + campaignsData?.campaigns.filter((c) => !assignedIds.has(c.id) && c.partner_user_id !== null) ?? + []; const partnerName = partner ? partner.first_name || partner.username || `#${partner.user_id}` @@ -62,7 +67,7 @@ export default function AdminPartnerCampaignAssign() { {t('admin.partnerDetail.campaigns.noAvailable')} - - - - - ); -} diff --git a/src/pages/AdminPartnerDetail.tsx b/src/pages/AdminPartnerDetail.tsx index 3349bf3..9d52949 100644 --- a/src/pages/AdminPartnerDetail.tsx +++ b/src/pages/AdminPartnerDetail.tsx @@ -216,7 +216,7 @@ export default function AdminPartnerDetail() { {t('admin.partnerDetail.campaigns.assign')}