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)
This commit is contained in:
Fringg
2026-02-18 06:47:07 +03:00
parent 76d20fdb1a
commit 959f89266b
12 changed files with 201 additions and 207 deletions

View File

@@ -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() {
</AdminRoute>
}
/>
<Route
path="/admin/partners/:userId/campaigns/create"
element={
<AdminRoute>
<LazyPage>
<AdminPartnerCampaignCreate />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/partners/:userId"
element={

View File

@@ -17,6 +17,8 @@ export interface CampaignListItem {
registrations_count: number;
total_revenue_kopeks: number;
conversion_rate: number;
partner_user_id: number | null;
partner_name: string | null;
created_at: string;
}
@@ -40,6 +42,8 @@ export interface CampaignDetail {
tariff_id: number | null;
tariff_duration_days: number | null;
tariff: TariffInfo | null;
partner_user_id: number | null;
partner_name: string | null;
created_by: number | null;
created_at: string;
updated_at: string | null;
@@ -59,6 +63,7 @@ export interface CampaignCreateRequest {
subscription_squads?: string[];
tariff_id?: number;
tariff_duration_days?: number;
partner_user_id?: number | null;
}
export interface CampaignUpdateRequest {
@@ -73,6 +78,7 @@ export interface CampaignUpdateRequest {
subscription_squads?: string[];
tariff_id?: number;
tariff_duration_days?: number;
partner_user_id?: number | null;
}
export interface CampaignToggleResponse {
@@ -159,6 +165,12 @@ export interface TariffListItem {
device_limit: number;
}
export interface AvailablePartner {
user_id: number;
username: string | null;
first_name: string | null;
}
export const campaignsApi = {
// Get campaigns overview
getOverview: async (): Promise<CampaignsOverview> => {
@@ -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<AvailablePartner[]> => {
const response = await apiClient.get('/cabinet/admin/campaigns/available-partners');
return response.data;
},
};

View File

@@ -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",

View File

@@ -1486,7 +1486,11 @@
"active": "فعال",
"cancel": "لغو",
"save": "ذخیره",
"saving": "در حال ذخیره..."
"saving": "در حال ذخیره...",
"partner": "شریک",
"noPartner": "بدون شریک",
"selectPartner": "انتخاب شریک",
"partnerAutoAssign": "کمپین به طور خودکار به شریک {{name}} اختصاص داده می‌شود"
},
"stats": {
"title": "آمار کمپین",

View File

@@ -2267,7 +2267,11 @@
"active": "Активна",
"cancel": "Отмена",
"save": "Сохранить",
"saving": "Сохранение..."
"saving": "Сохранение...",
"partner": "Партнёр",
"noPartner": "Без партнёра",
"selectPartner": "Выберите партнёра",
"partnerAutoAssign": "Кампания будет автоматически привязана к партнёру {{name}}"
},
"stats": {
"title": "Статистика кампании",

View File

@@ -1485,7 +1485,11 @@
"active": "启用",
"cancel": "取消",
"save": "保存",
"saving": "保存中..."
"saving": "保存中...",
"partner": "合作伙伴",
"noPartner": "无合作伙伴",
"selectPartner": "选择合作伙伴",
"partnerAutoAssign": "活动将自动分配给合作伙伴 {{name}}"
},
"stats": {
"title": "活动统计",

View File

@@ -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<CampaignBonusType>('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<number | ''>(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() {
</div>
</div>
{/* Partner info banner */}
{partnerId && partner && (
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-4">
<div className="flex items-start gap-3">
<svg
className="mt-0.5 h-5 w-5 shrink-0 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
<p className="text-sm text-accent-300">
{t('admin.campaigns.form.partnerAutoAssign', {
name: partner.first_name || partner.username || `#${partnerId}`,
})}
</p>
</div>
</div>
)}
{/* Basic Info */}
<div className="card space-y-4">
{/* Name */}
@@ -442,7 +506,10 @@ export default function AdminCampaignCreate() {
{/* Footer */}
<div className="card flex items-center justify-end gap-3">
<button onClick={() => navigate('/admin/campaigns')} className="btn-secondary">
<button
onClick={() => navigate(partnerId ? `/admin/partners/${partnerId}` : '/admin/campaigns')}
className="btn-secondary"
>
{t('common.cancel')}
</button>
<button

View File

@@ -8,6 +8,7 @@ import {
CampaignBonusType,
ServerSquadInfo,
TariffListItem,
AvailablePartner,
} from '../api/campaigns';
import { AdminBackButton } from '../components/admin';
import { CheckIcon, CampaignIcon } from '../components/icons';
@@ -124,6 +125,40 @@ function TariffSelector({
);
}
// Partner selector component
function PartnerSelector({
partners,
value,
onChange,
}: {
partners: AvailablePartner[];
value: number | null;
onChange: (id: number | null) => void;
}) {
const { t } = useTranslation();
return (
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.partner')}
</label>
<select
value={value || ''}
onChange={(e) => onChange(e.target.value ? parseInt(e.target.value) : null)}
className="input"
>
<option value="">{t('admin.campaigns.form.noPartner')}</option>
{partners.map((p) => (
<option key={p.user_id} value={p.user_id}>
{p.first_name || p.username || `#${p.user_id}`}
{p.username ? ` (@${p.username})` : ''}
</option>
))}
</select>
</div>
);
}
export default function AdminCampaignEdit() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
@@ -155,6 +190,12 @@ export default function AdminCampaignEdit() {
queryFn: () => campaignsApi.getAvailableTariffs(),
});
// Fetch partners
const { data: partners = [] } = useQuery({
queryKey: ['admin-campaigns-partners'],
queryFn: () => campaignsApi.getAvailablePartners(),
});
// Form state
const [name, setName] = useState('');
const [startParameter, setStartParameter] = useState('');
@@ -174,6 +215,10 @@ export default function AdminCampaignEdit() {
const [tariffId, setTariffId] = useState<number | null>(null);
const [tariffDays, setTariffDays] = useState<number | ''>(30);
// Partner
const [partnerUserId, setPartnerUserId] = useState<number | null>(null);
const [initialPartnerUserId, setInitialPartnerUserId] = useState<number | null>(null);
// Initialize form when campaign loads
useEffect(() => {
if (campaign) {
@@ -188,6 +233,8 @@ export default function AdminCampaignEdit() {
setSelectedSquads(campaign.subscription_squads || []);
setTariffId(campaign.tariff_id || null);
setTariffDays(campaign.tariff_duration_days || 30);
setPartnerUserId(campaign.partner_user_id ?? null);
setInitialPartnerUserId(campaign.partner_user_id ?? null);
}
}, [campaign]);
@@ -215,6 +262,11 @@ export default function AdminCampaignEdit() {
is_active: isActive,
};
// Only send partner_user_id when it was actually changed
if (partnerUserId !== initialPartnerUserId) {
data.partner_user_id = partnerUserId;
}
if (bonusType === 'balance') {
data.balance_bonus_kopeks = Math.round(toNumber(balanceBonusRubles) * 100);
} else if (bonusType === 'subscription') {
@@ -344,6 +396,11 @@ export default function AdminCampaignEdit() {
/>
</button>
</div>
{/* Partner */}
{partners.length > 0 && (
<PartnerSelector partners={partners} value={partnerUserId} onChange={setPartnerUserId} />
)}
</div>
{/* Bonus Type */}

View File

@@ -183,6 +183,11 @@ export default function AdminCampaigns() {
>
{t(bonusTypeConfig[campaign.bonus_type].labelKey)}
</span>
{campaign.partner_name && (
<span className="rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
{campaign.partner_name}
</span>
)}
{!campaign.is_active && (
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{t('admin.campaigns.table.inactive')}

View File

@@ -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')}
</div>
<button
onClick={() => navigate(`/admin/partners/${userId}/campaigns/create`)}
onClick={() => navigate(`/admin/campaigns/create?partnerId=${userId}`)}
className="mt-2 w-full rounded-lg bg-accent-500 px-4 py-3 font-medium text-white transition-colors hover:bg-accent-600"
>
{t('admin.partnerDetail.campaigns.createNew')}
@@ -100,6 +105,28 @@ export default function AdminPartnerCampaignAssign() {
</div>
</div>
))}
{/* Show campaigns assigned to other partners as greyed out */}
{takenByOthers.map((campaign) => (
<div
key={campaign.id}
className="rounded-xl border border-dark-700/50 bg-dark-800 p-4 opacity-50"
>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-dark-300">{campaign.name}</span>
<span className="rounded bg-purple-500/20 px-1.5 py-0.5 text-xs text-purple-400">
{campaign.partner_name}
</span>
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-dark-500">
<span className="font-mono">?start={campaign.start_parameter}</span>
</div>
</div>
</div>
</div>
))}
</div>
)}

View File

@@ -1,185 +0,0 @@
import { useState, useEffect, type FormEvent } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { partnerApi } from '../api/partners';
import { campaignsApi, type CampaignBonusType } from '../api/campaigns';
import { AdminBackButton } from '../components/admin';
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/gi, '_')
.replace(/^_|_$/g, '')
.slice(0, 30);
}
export default function AdminPartnerCampaignCreate() {
const { t } = useTranslation();
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: partner } = useQuery({
queryKey: ['admin-partner-detail', userId],
queryFn: () => partnerApi.getPartnerDetail(Number(userId)),
enabled: !!userId,
});
const partnerName = partner?.first_name || partner?.username || '';
const suggestedParam = partnerName ? `partner_${slugify(partnerName)}` : '';
const [name, setName] = useState('');
const [startParam, setStartParam] = useState('');
const [bonusType, setBonusType] = useState<CampaignBonusType>('none');
// Auto-fill start_parameter when partner loads and fields are empty
const [autoFilled, setAutoFilled] = useState(false);
useEffect(() => {
if (partner && !autoFilled) {
if (!startParam) setStartParam(suggestedParam);
if (!name && partnerName) setName(partnerName);
setAutoFilled(true);
}
}, [partner, autoFilled, startParam, suggestedParam, name, partnerName]);
const createAndAssignMutation = useMutation({
mutationFn: async () => {
const campaign = await campaignsApi.createCampaign({
name,
start_parameter: startParam,
bonus_type: bonusType,
is_active: true,
});
await partnerApi.assignCampaign(Number(userId), campaign.id);
return campaign;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
queryClient.invalidateQueries({ queryKey: ['admin-campaigns-all'] });
navigate(`/admin/partners/${userId}`);
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!name.trim() || !startParam.trim()) return;
createAndAssignMutation.mutate();
};
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to={`/admin/partners/${userId}`} />
<div>
<h1 className="text-xl font-semibold text-dark-100">
{t('admin.partnerDetail.campaigns.createTitle')}
</h1>
<p className="text-sm text-dark-400">
{t('admin.partnerDetail.campaigns.createSubtitle')}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4 rounded-xl border border-dark-700 bg-dark-800 p-4">
{/* Name */}
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.name')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
placeholder={t('admin.campaigns.form.namePlaceholder')}
required
/>
</div>
{/* Start Parameter */}
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.startParameter')}
</label>
<input
type="text"
value={startParam}
onChange={(e) => setStartParam(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 font-mono text-dark-100 outline-none focus:border-accent-500"
placeholder="partner_name"
required
/>
<p className="mt-1 text-xs text-dark-500">?start={startParam || '...'}</p>
</div>
{/* Bonus Type */}
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.bonusType')}
</label>
<select
value={bonusType}
onChange={(e) => setBonusType(e.target.value as CampaignBonusType)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
>
<option value="none">{t('admin.campaigns.bonusType.none')}</option>
<option value="balance">{t('admin.campaigns.bonusType.balance')}</option>
</select>
</div>
</div>
{/* Auto-assign notice */}
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-4">
<div className="flex items-start gap-3">
<svg
className="mt-0.5 h-5 w-5 shrink-0 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
<p className="text-sm text-accent-300">
{t('admin.partnerDetail.campaigns.autoAssignNote', {
name: partnerName || `#${userId}`,
})}
</p>
</div>
</div>
{createAndAssignMutation.isError && (
<div className="rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
{t('common.error')}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => navigate(`/admin/partners/${userId}`)}
className="flex-1 rounded-lg bg-dark-700 px-4 py-3 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={createAndAssignMutation.isPending || !name.trim() || !startParam.trim()}
className="flex-1 rounded-lg bg-accent-500 px-4 py-3 font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{createAndAssignMutation.isPending
? t('common.saving')
: t('admin.partnerDetail.campaigns.createAndAssign')}
</button>
</div>
</form>
</div>
);
}

View File

@@ -216,7 +216,7 @@ export default function AdminPartnerDetail() {
{t('admin.partnerDetail.campaigns.assign')}
</button>
<button
onClick={() => navigate(`/admin/partners/${userId}/campaigns/create`)}
onClick={() => navigate(`/admin/campaigns/create?partnerId=${userId}`)}
className="rounded-lg bg-accent-500/20 px-3 py-1.5 text-xs font-medium text-accent-400 transition-colors hover:bg-accent-500/30"
>
{t('admin.partnerDetail.campaigns.createNew')}