refactor: convert partner/withdrawal modals to separate pages

Replace 7 modal dialogs with 8 dedicated page routes for better
mobile/Telegram WebApp UX. Add campaign management (assign/create
with auto-assign) to partner detail page.

New pages: AdminApplicationReview, AdminPartnerCommission,
AdminPartnerRevoke, AdminWithdrawalReject, AdminPartnerCampaignAssign,
AdminPartnerCampaignCreate, ReferralPartnerApply,
ReferralWithdrawalRequest.

~610 lines removed from parent pages, ~425 lines removed net.
This commit is contained in:
Fringg
2026-02-17 10:28:32 +03:00
parent 779fbf0dc6
commit 062976180c
17 changed files with 1320 additions and 614 deletions

View File

@@ -61,8 +61,16 @@ const AdminCampaignStats = lazy(() => import('./pages/AdminCampaignStats'));
const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit'));
const AdminPartners = lazy(() => import('./pages/AdminPartners'));
const AdminPartnerDetail = lazy(() => import('./pages/AdminPartnerDetail'));
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'));
const ReferralPartnerApply = lazy(() => import('./pages/ReferralPartnerApply'));
const ReferralWithdrawalRequest = lazy(() => import('./pages/ReferralWithdrawalRequest'));
const AdminUsers = lazy(() => import('./pages/AdminUsers'));
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
@@ -221,6 +229,26 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/referral/partner/apply"
element={
<ProtectedRoute>
<LazyPage>
<ReferralPartnerApply />
</LazyPage>
</ProtectedRoute>
}
/>
<Route
path="/referral/withdrawal/request"
element={
<ProtectedRoute>
<LazyPage>
<ReferralWithdrawalRequest />
</LazyPage>
</ProtectedRoute>
}
/>
<Route
path="/support"
element={
@@ -563,6 +591,56 @@ function App() {
</AdminRoute>
}
/>
<Route
path="/admin/partners/applications/:id/review"
element={
<AdminRoute>
<LazyPage>
<AdminApplicationReview />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/partners/:userId/commission"
element={
<AdminRoute>
<LazyPage>
<AdminPartnerCommission />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/partners/:userId/revoke"
element={
<AdminRoute>
<LazyPage>
<AdminPartnerRevoke />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/partners/:userId/campaigns/assign"
element={
<AdminRoute>
<LazyPage>
<AdminPartnerCampaignAssign />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/partners/:userId/campaigns/create"
element={
<AdminRoute>
<LazyPage>
<AdminPartnerCampaignCreate />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/partners/:userId"
element={
@@ -583,6 +661,16 @@ function App() {
</AdminRoute>
}
/>
<Route
path="/admin/withdrawals/:id/reject"
element={
<AdminRoute>
<LazyPage>
<AdminWithdrawalReject />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/withdrawals/:id"
element={

View File

@@ -1868,9 +1868,11 @@
"referrals": "{{count}} referrals",
"actions": {
"approve": "Approve",
"reject": "Reject"
"reject": "Reject",
"review": "Review"
},
"applicationFields": {
"companyName": "Company",
"website": "Website",
"channel": "Channel",
"description": "Description",
@@ -1918,7 +1920,16 @@
"title": "Assigned Campaigns",
"noCampaigns": "No campaigns assigned",
"active": "Active",
"inactive": "Inactive"
"inactive": "Inactive",
"assign": "Assign",
"unassign": "Unassign",
"createNew": "Create",
"assignTitle": "Assign Campaign",
"noAvailable": "No available campaigns to assign",
"createTitle": "Create Campaign",
"createSubtitle": "Campaign will be auto-assigned to the partner",
"autoAssignNote": "Campaign will be automatically assigned to partner {{name}}",
"createAndAssign": "Create & Assign"
},
"dangerZone": {
"title": "Danger Zone",

View File

@@ -1605,9 +1605,11 @@
"referrals": "{{count}} معرفی",
"actions": {
"approve": "تأیید",
"reject": "رد"
"reject": "رد",
"review": "بررسی"
},
"applicationFields": {
"companyName": "شرکت",
"website": "وب‌سایت",
"channel": "کانال",
"description": "توضیحات",
@@ -1655,7 +1657,16 @@
"title": "کمپین‌های اختصاص‌یافته",
"noCampaigns": "کمپینی اختصاص نیافته",
"active": "فعال",
"inactive": "غیرفعال"
"inactive": "غیرفعال",
"assign": "اختصاص",
"unassign": "لغو اختصاص",
"createNew": "ایجاد",
"assignTitle": "اختصاص کمپین",
"noAvailable": "کمپینی برای اختصاص موجود نیست",
"createTitle": "ایجاد کمپین",
"createSubtitle": "کمپین به‌طور خودکار به شریک اختصاص می‌یابد",
"autoAssignNote": "کمپین به‌طور خودکار به شریک {{name}} اختصاص می‌یابد",
"createAndAssign": "ایجاد و اختصاص"
},
"dangerZone": {
"title": "منطقه خطر",

View File

@@ -2390,9 +2390,11 @@
"referrals": "{{count}} рефералов",
"actions": {
"approve": "Одобрить",
"reject": "Отклонить"
"reject": "Отклонить",
"review": "Рассмотреть"
},
"applicationFields": {
"companyName": "Компания",
"website": "Сайт",
"channel": "Канал",
"description": "Описание",
@@ -2440,7 +2442,16 @@
"title": "Привязанные кампании",
"noCampaigns": "Нет привязанных кампаний",
"active": "Активна",
"inactive": "Неактивна"
"inactive": "Неактивна",
"assign": "Привязать",
"unassign": "Отвязать",
"createNew": "Создать",
"assignTitle": "Привязать кампанию",
"noAvailable": "Нет доступных кампаний для привязки",
"createTitle": "Создать кампанию",
"createSubtitle": "Кампания будет автоматически привязана к партнёру",
"autoAssignNote": "Кампания будет автоматически привязана к партнёру {{name}}",
"createAndAssign": "Создать и привязать"
},
"dangerZone": {
"title": "Опасная зона",

View File

@@ -1604,9 +1604,11 @@
"referrals": "{{count}} 个推荐",
"actions": {
"approve": "批准",
"reject": "拒绝"
"reject": "拒绝",
"review": "审核"
},
"applicationFields": {
"companyName": "公司",
"website": "网站",
"channel": "频道",
"description": "描述",
@@ -1654,7 +1656,16 @@
"title": "分配的活动",
"noCampaigns": "暂无分配的活动",
"active": "活跃",
"inactive": "未激活"
"inactive": "未激活",
"assign": "分配",
"unassign": "取消分配",
"createNew": "创建",
"assignTitle": "分配活动",
"noAvailable": "没有可分配的活动",
"createTitle": "创建活动",
"createSubtitle": "活动将自动分配给合作伙伴",
"autoAssignNote": "活动将自动分配给合作伙伴 {{name}}",
"createAndAssign": "创建并分配"
},
"dangerZone": {
"title": "危险操作",

View File

@@ -0,0 +1,228 @@
import { useState } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { partnerApi, type AdminPartnerApplicationItem } from '../api/partners';
import { AdminBackButton } from '../components/admin';
export default function AdminApplicationReview() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const [commission, setCommission] = useState('10');
const [rejectComment, setRejectComment] = useState('');
// Try to get application from navigate state, fallback to fetching
const passedApp = (location.state as { application?: AdminPartnerApplicationItem } | null)
?.application;
const { data: fetchedApps } = useQuery({
queryKey: ['admin-partner-applications'],
queryFn: () => partnerApi.getApplications({ status: 'pending' }),
enabled: !passedApp && !!id,
});
const app = passedApp ?? fetchedApps?.items.find((a) => a.id === Number(id));
const approveMutation = useMutation({
mutationFn: ({ appId, commissionPercent }: { appId: number; commissionPercent: number }) =>
partnerApi.approveApplication(appId, { commission_percent: commissionPercent }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partner-applications'] });
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
navigate('/admin/partners');
},
});
const rejectMutation = useMutation({
mutationFn: ({ appId, comment }: { appId: number; comment?: string }) =>
partnerApi.rejectApplication(appId, { comment }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partner-applications'] });
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
navigate('/admin/partners');
},
});
if (!app) {
return (
<div className="animate-fade-in">
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to="/admin/partners" />
<h1 className="text-xl font-semibold text-dark-100">
{t('admin.partners.approveDialog.title')}
</h1>
</div>
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
</div>
);
}
const displayName = app.first_name || app.username || `#${app.user_id}`;
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to="/admin/partners" />
<div>
<h1 className="text-xl font-semibold text-dark-100">
{t('admin.partners.actions.review')}
</h1>
<p className="text-sm text-dark-400">{displayName}</p>
</div>
</div>
<div className="space-y-6">
{/* Application Details */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.partners.tabs.applications')}
</h3>
<div className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="font-medium text-dark-100">{displayName}</span>
{app.username && <span className="text-sm text-dark-500">@{app.username}</span>}
</div>
</div>
</div>
<div className="space-y-2 text-sm text-dark-400">
{app.company_name && (
<div className="rounded-lg bg-dark-700/50 p-3">
<span className="text-dark-500">
{t('admin.partners.applicationFields.companyName')}:
</span>{' '}
<span className="text-dark-200">{app.company_name}</span>
</div>
)}
{app.website_url && (
<div className="rounded-lg bg-dark-700/50 p-3">
<span className="text-dark-500">
{t('admin.partners.applicationFields.website')}:
</span>{' '}
<span className="text-dark-200">{app.website_url}</span>
</div>
)}
{app.telegram_channel && (
<div className="rounded-lg bg-dark-700/50 p-3">
<span className="text-dark-500">
{t('admin.partners.applicationFields.channel')}:
</span>{' '}
<span className="text-dark-200">{app.telegram_channel}</span>
</div>
)}
{app.description && (
<div className="rounded-lg bg-dark-700/50 p-3">
<span className="text-dark-500">
{t('admin.partners.applicationFields.description')}:
</span>{' '}
<span className="text-dark-200">{app.description}</span>
</div>
)}
{app.expected_monthly_referrals != null && (
<div className="rounded-lg bg-dark-700/50 p-3">
<span className="text-dark-500">
{t('admin.partners.applicationFields.expectedReferrals')}:
</span>{' '}
<span className="text-dark-200">{app.expected_monthly_referrals}</span>
</div>
)}
</div>
</div>
</div>
{/* Approve Section */}
<div className="rounded-xl border border-success-500/30 bg-dark-800 p-4">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.partners.approveDialog.title')}
</h3>
<p className="mb-4 text-sm text-dark-400">
{t('admin.partners.approveDialog.description', { name: displayName })}
</p>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.partners.approveDialog.commissionLabel')}
</label>
<input
type="number"
min="1"
max="100"
value={commission}
onChange={(e) => setCommission(e.target.value)}
className="mb-4 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="10"
/>
<button
onClick={() => {
const val = Number(commission);
if (val >= 1 && val <= 100) {
approveMutation.mutate({ appId: app.id, commissionPercent: val });
}
}}
disabled={
approveMutation.isPending ||
!commission ||
Number(commission) < 1 ||
Number(commission) > 100
}
className="w-full rounded-lg bg-success-500 px-4 py-3 font-medium text-white transition-colors hover:bg-success-600 disabled:opacity-50"
>
{approveMutation.isPending ? t('common.saving') : t('admin.partners.actions.approve')}
</button>
</div>
{approveMutation.isError && (
<div className="rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
{t('common.error')}
</div>
)}
{/* Reject Section */}
<div className="rounded-xl border border-error-500/30 bg-dark-800 p-4">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.partners.rejectDialog.title')}
</h3>
<p className="mb-4 text-sm text-dark-400">
{t('admin.partners.rejectDialog.description', { name: displayName })}
</p>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.partners.rejectDialog.commentLabel')}
</label>
<textarea
value={rejectComment}
onChange={(e) => setRejectComment(e.target.value)}
className="mb-4 w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
rows={3}
placeholder={t('admin.partners.rejectDialog.commentPlaceholder')}
/>
<button
onClick={() =>
rejectMutation.mutate({
appId: app.id,
comment: rejectComment || undefined,
})
}
disabled={rejectMutation.isPending}
className="w-full rounded-lg bg-error-500 px-4 py-3 font-medium text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
{rejectMutation.isPending ? t('common.saving') : t('admin.partners.actions.reject')}
</button>
{rejectMutation.isError && (
<div className="mt-4 rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
{t('common.error')}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
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 } from '../api/campaigns';
import { AdminBackButton } from '../components/admin';
export default function AdminPartnerCampaignAssign() {
const { t } = useTranslation();
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
// Fetch partner detail to know already assigned campaign IDs
const { data: partner } = useQuery({
queryKey: ['admin-partner-detail', userId],
queryFn: () => partnerApi.getPartnerDetail(Number(userId)),
enabled: !!userId,
});
// Fetch all campaigns
const { data: campaignsData, isLoading } = useQuery({
queryKey: ['admin-campaigns-all'],
queryFn: () => campaignsApi.getCampaigns(true, 0, 200),
});
const assignMutation = useMutation({
mutationFn: (campaignId: number) => partnerApi.assignCampaign(Number(userId), campaignId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
navigate(`/admin/partners/${userId}`);
},
});
const assignedIds = new Set(partner?.campaigns.map((c) => c.id) ?? []);
const available =
campaignsData?.campaigns.filter((c) => !assignedIds.has(c.id) && c.is_active) ?? [];
const partnerName = partner
? partner.first_name || partner.username || `#${partner.user_id}`
: '';
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.assignTitle')}
</h1>
{partnerName && <p className="text-sm text-dark-400">{partnerName}</p>}
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : available.length === 0 ? (
<div className="rounded-xl border border-dark-700 bg-dark-800 p-6">
<div className="py-4 text-center text-sm text-dark-500">
{t('admin.partnerDetail.campaigns.noAvailable')}
</div>
<button
onClick={() => navigate(`/admin/partners/${userId}/campaigns/create`)}
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')}
</button>
</div>
) : (
<div className="space-y-3">
{available.map((campaign) => (
<div key={campaign.id} className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="font-medium text-dark-100">{campaign.name}</div>
<div className="mt-1 flex items-center gap-3 text-xs text-dark-500">
<span className="font-mono">?start={campaign.start_parameter}</span>
<span>
{campaign.registrations_count}{' '}
{t('admin.campaigns.overview.registrations').toLowerCase()}
</span>
</div>
</div>
<button
onClick={() => assignMutation.mutate(campaign.id)}
disabled={assignMutation.isPending}
className="shrink-0 rounded-lg bg-accent-500/20 px-4 py-2 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/30 disabled:opacity-50"
>
{t('admin.partnerDetail.campaigns.assign')}
</button>
</div>
</div>
))}
</div>
)}
{assignMutation.isError && (
<div className="mt-4 rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
{t('common.error')}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
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

@@ -0,0 +1,107 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { partnerApi } from '../api/partners';
import { AdminBackButton } from '../components/admin';
export default function AdminPartnerCommission() {
const { t } = useTranslation();
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
// Try to get current commission from navigate state
const passedCommission = (location.state as { currentCommission?: number } | null)
?.currentCommission;
const { data: partner } = useQuery({
queryKey: ['admin-partner-detail', userId],
queryFn: () => partnerApi.getPartnerDetail(Number(userId)),
enabled: passedCommission === undefined && !!userId,
});
const currentCommission = passedCommission ?? partner?.commission_percent ?? 0;
const [commissionValue, setCommissionValue] = useState(String(currentCommission));
// Sync commission value when data loads asynchronously
useEffect(() => {
if (partner?.commission_percent != null && passedCommission === undefined) {
setCommissionValue(String(partner.commission_percent));
}
}, [partner?.commission_percent, passedCommission]);
const updateMutation = useMutation({
mutationFn: (commission: number) => partnerApi.updateCommission(Number(userId), commission),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
navigate(`/admin/partners/${userId}`);
},
});
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to={`/admin/partners/${userId}`} />
<h1 className="text-xl font-semibold text-dark-100">
{t('admin.partnerDetail.commissionDialog.title')}
</h1>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-6">
<p className="mb-4 text-sm text-dark-400">
{t('admin.partnerDetail.commissionDialog.description')}
</p>
<div className="mb-2 text-sm text-dark-500">
{t('admin.partnerDetail.commission.title')}: {currentCommission}%
</div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.partnerDetail.commissionDialog.label')}
</label>
<input
type="number"
min="1"
max="100"
value={commissionValue}
onChange={(e) => setCommissionValue(e.target.value)}
className="mb-6 w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
/>
<div className="flex gap-3">
<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
onClick={() => {
const val = Number(commissionValue);
if (val >= 1 && val <= 100) updateMutation.mutate(val);
}}
disabled={
updateMutation.isPending ||
!commissionValue ||
Number(commissionValue) < 1 ||
Number(commissionValue) > 100
}
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"
>
{updateMutation.isPending ? t('common.saving') : t('common.save')}
</button>
</div>
{updateMutation.isError && (
<div className="mt-4 rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
{t('common.error')}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -37,10 +36,12 @@ export default function AdminPartnerDetail() {
const queryClient = useQueryClient();
const { formatWithCurrency } = useCurrency();
// Dialog states
const [showCommissionDialog, setShowCommissionDialog] = useState(false);
const [commissionValue, setCommissionValue] = useState('');
const [showRevokeDialog, setShowRevokeDialog] = useState(false);
const unassignMutation = useMutation({
mutationFn: (campaignId: number) => partnerApi.unassignCampaign(Number(userId), campaignId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
},
});
// Fetch partner detail
const {
@@ -53,27 +54,6 @@ export default function AdminPartnerDetail() {
enabled: !!userId,
});
// Mutations
const updateCommissionMutation = useMutation({
mutationFn: (commission: number) => partnerApi.updateCommission(Number(userId), commission),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
setShowCommissionDialog(false);
setCommissionValue('');
},
});
const revokeMutation = useMutation({
mutationFn: () => partnerApi.revokePartner(Number(userId)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
setShowRevokeDialog(false);
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
@@ -204,10 +184,11 @@ export default function AdminPartnerDetail() {
</div>
</div>
<button
onClick={() => {
setCommissionValue(String(partner.commission_percent ?? 0));
setShowCommissionDialog(true);
}}
onClick={() =>
navigate(`/admin/partners/${userId}/commission`, {
state: { currentCommission: partner.commission_percent ?? 0 },
})
}
className="rounded-lg bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
>
{t('admin.partnerDetail.commission.update')}
@@ -217,9 +198,25 @@ export default function AdminPartnerDetail() {
{/* Campaigns */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.partnerDetail.campaigns.title')}
</h3>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-200">
{t('admin.partnerDetail.campaigns.title')}
</h3>
<div className="flex gap-2">
<button
onClick={() => navigate(`/admin/partners/${userId}/campaigns/assign`)}
className="rounded-lg bg-dark-700 px-3 py-1.5 text-xs text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
>
{t('admin.partnerDetail.campaigns.assign')}
</button>
<button
onClick={() => navigate(`/admin/partners/${userId}/campaigns/create`)}
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')}
</button>
</div>
</div>
{partner.campaigns.length === 0 ? (
<div className="py-4 text-center text-sm text-dark-500">
{t('admin.partnerDetail.campaigns.noCampaigns')}
@@ -233,21 +230,43 @@ export default function AdminPartnerDetail() {
!campaign.is_active ? 'opacity-60' : ''
}`}
>
<div>
<div className="min-w-0 flex-1">
<div className="font-medium text-dark-100">{campaign.name}</div>
<div className="font-mono text-xs text-dark-500">
?start={campaign.start_parameter}
</div>
</div>
{campaign.is_active ? (
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
{t('admin.partnerDetail.campaigns.active')}
</span>
) : (
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{t('admin.partnerDetail.campaigns.inactive')}
</span>
)}
<div className="flex items-center gap-2">
{campaign.is_active ? (
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
{t('admin.partnerDetail.campaigns.active')}
</span>
) : (
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{t('admin.partnerDetail.campaigns.inactive')}
</span>
)}
<button
onClick={() => unassignMutation.mutate(campaign.id)}
disabled={unassignMutation.isPending}
className="rounded p-1 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400"
title={t('admin.partnerDetail.campaigns.unassign')}
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
))}
</div>
@@ -260,95 +279,13 @@ export default function AdminPartnerDetail() {
{t('admin.partnerDetail.dangerZone.title')}
</h3>
<button
onClick={() => setShowRevokeDialog(true)}
onClick={() => navigate(`/admin/partners/${userId}/revoke`)}
className="w-full rounded-lg bg-error-500/20 px-4 py-3 text-sm font-medium text-error-400 transition-colors hover:bg-error-500/30"
>
{t('admin.partnerDetail.dangerZone.revokeButton')}
</button>
</div>
</div>
{/* Commission Update Dialog */}
{showCommissionDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setShowCommissionDialog(false)}
/>
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.partnerDetail.commissionDialog.title')}
</h3>
<p className="mb-4 text-sm text-dark-400">
{t('admin.partnerDetail.commissionDialog.description')}
</p>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.partnerDetail.commissionDialog.label')}
</label>
<input
type="number"
min="1"
max="100"
value={commissionValue}
onChange={(e) => setCommissionValue(e.target.value)}
className="mb-6 w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
/>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setShowCommissionDialog(false);
setCommissionValue('');
}}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('common.cancel')}
</button>
<button
onClick={() => updateCommissionMutation.mutate(Number(commissionValue))}
disabled={updateCommissionMutation.isPending || !commissionValue}
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{updateCommissionMutation.isPending ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>
)}
{/* Revoke Confirmation Dialog */}
{showRevokeDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setShowRevokeDialog(false)}
/>
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.partnerDetail.revokeDialog.title')}
</h3>
<p className="mb-6 text-dark-400">
{t('admin.partnerDetail.revokeDialog.description')}
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowRevokeDialog(false)}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('common.cancel')}
</button>
<button
onClick={() => revokeMutation.mutate()}
disabled={revokeMutation.isPending}
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
{revokeMutation.isPending
? t('common.saving')
: t('admin.partnerDetail.dangerZone.revokeButton')}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useParams, useNavigate } from 'react-router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { partnerApi } from '../api/partners';
import { AdminBackButton } from '../components/admin';
export default function AdminPartnerRevoke() {
const { t } = useTranslation();
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const revokeMutation = useMutation({
mutationFn: () => partnerApi.revokePartner(Number(userId)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
navigate(`/admin/partners/${userId}`);
},
});
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to={`/admin/partners/${userId}`} />
<h1 className="text-xl font-semibold text-dark-100">
{t('admin.partnerDetail.revokeDialog.title')}
</h1>
</div>
<div className="rounded-xl border border-error-500/30 bg-dark-800 p-6">
<div className="mb-6 rounded-lg bg-error-500/10 p-4">
<p className="text-dark-400">{t('admin.partnerDetail.revokeDialog.description')}</p>
</div>
<div className="flex gap-3">
<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
onClick={() => revokeMutation.mutate()}
disabled={revokeMutation.isPending}
className="flex-1 rounded-lg bg-error-500 px-4 py-3 font-medium text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
{revokeMutation.isPending
? t('common.saving')
: t('admin.partnerDetail.dangerZone.revokeButton')}
</button>
</div>
{revokeMutation.isError && (
<div className="mt-4 rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
{t('common.error')}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
partnerApi,
@@ -13,19 +13,10 @@ import { useCurrency } from '../hooks/useCurrency';
export default function AdminPartners() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { formatWithCurrency } = useCurrency();
const [activeTab, setActiveTab] = useState<'partners' | 'applications'>('partners');
// Approve dialog state
const [approveDialog, setApproveDialog] = useState<AdminPartnerApplicationItem | null>(null);
const [approveCommission, setApproveCommission] = useState('10');
// Reject dialog state
const [rejectDialog, setRejectDialog] = useState<AdminPartnerApplicationItem | null>(null);
const [rejectComment, setRejectComment] = useState('');
// Queries
const { data: stats } = useQuery({
queryKey: ['admin-partner-stats'],
@@ -42,30 +33,6 @@ export default function AdminPartners() {
queryFn: () => partnerApi.getApplications({ status: 'pending' }),
});
// Mutations
const approveMutation = useMutation({
mutationFn: ({ id, commission }: { id: number; commission: number }) =>
partnerApi.approveApplication(id, { commission_percent: commission }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partner-applications'] });
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
setApproveDialog(null);
setApproveCommission('10');
},
});
const rejectMutation = useMutation({
mutationFn: ({ id, comment }: { id: number; comment?: string }) =>
partnerApi.rejectApplication(id, { comment }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partner-applications'] });
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
setRejectDialog(null);
setRejectComment('');
},
});
const partners = partnersData?.items || [];
const applications = applicationsData?.items || [];
@@ -256,16 +223,14 @@ export default function AdminPartners() {
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => setApproveDialog(app)}
className="flex-1 rounded-lg bg-success-500/20 px-4 py-2 text-sm font-medium text-success-400 transition-colors hover:bg-success-500/30"
onClick={() =>
navigate(`/admin/partners/applications/${app.id}/review`, {
state: { application: app },
})
}
className="flex-1 rounded-lg bg-accent-500/20 px-4 py-2 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/30"
>
{t('admin.partners.actions.approve')}
</button>
<button
onClick={() => setRejectDialog(app)}
className="flex-1 rounded-lg bg-error-500/20 px-4 py-2 text-sm font-medium text-error-400 transition-colors hover:bg-error-500/30"
>
{t('admin.partners.actions.reject')}
{t('admin.partners.actions.review')}
</button>
</div>
</div>
@@ -274,112 +239,6 @@ export default function AdminPartners() {
)}
</>
)}
{/* Approve Dialog */}
{approveDialog !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={() => setApproveDialog(null)} />
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.partners.approveDialog.title')}
</h3>
<p className="mb-4 text-sm text-dark-400">
{t('admin.partners.approveDialog.description', {
name:
approveDialog.first_name || approveDialog.username || `#${approveDialog.user_id}`,
})}
</p>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.partners.approveDialog.commissionLabel')}
</label>
<input
type="number"
min="1"
max="100"
value={approveCommission}
onChange={(e) => setApproveCommission(e.target.value)}
className="mb-6 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="10"
/>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setApproveDialog(null);
setApproveCommission('10');
}}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('common.cancel')}
</button>
<button
onClick={() =>
approveMutation.mutate({
id: approveDialog.id,
commission: Number(approveCommission),
})
}
disabled={approveMutation.isPending || !approveCommission}
className="rounded-lg bg-success-500 px-4 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
>
{approveMutation.isPending
? t('common.saving')
: t('admin.partners.actions.approve')}
</button>
</div>
</div>
</div>
)}
{/* Reject Dialog */}
{rejectDialog !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={() => setRejectDialog(null)} />
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.partners.rejectDialog.title')}
</h3>
<p className="mb-4 text-sm text-dark-400">
{t('admin.partners.rejectDialog.description', {
name:
rejectDialog.first_name || rejectDialog.username || `#${rejectDialog.user_id}`,
})}
</p>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.partners.rejectDialog.commentLabel')}
</label>
<textarea
value={rejectComment}
onChange={(e) => setRejectComment(e.target.value)}
className="mb-6 w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
rows={3}
placeholder={t('admin.partners.rejectDialog.commentPlaceholder')}
/>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setRejectDialog(null);
setRejectComment('');
}}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('common.cancel')}
</button>
<button
onClick={() =>
rejectMutation.mutate({
id: rejectDialog.id,
comment: rejectComment || undefined,
})
}
disabled={rejectMutation.isPending}
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
{rejectMutation.isPending ? t('common.saving') : t('admin.partners.actions.reject')}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -93,9 +92,6 @@ export default function AdminWithdrawalDetail() {
const queryClient = useQueryClient();
const { formatWithCurrency } = useCurrency();
const [rejectComment, setRejectComment] = useState('');
const [showRejectDialog, setShowRejectDialog] = useState(false);
// Fetch detail
const {
data: detail,
@@ -116,16 +112,6 @@ export default function AdminWithdrawalDetail() {
},
});
const rejectMutation = useMutation({
mutationFn: (comment: string) => withdrawalApi.reject(Number(id), comment || undefined),
onSuccess: () => {
setShowRejectDialog(false);
setRejectComment('');
queryClient.invalidateQueries({ queryKey: ['admin-withdrawal-detail', id] });
queryClient.invalidateQueries({ queryKey: ['admin-withdrawals'] });
},
});
const completeMutation = useMutation({
mutationFn: () => withdrawalApi.complete(Number(id)),
onSuccess: () => {
@@ -424,7 +410,15 @@ export default function AdminWithdrawalDetail() {
: t('admin.withdrawals.detail.approve')}
</button>
<button
onClick={() => setShowRejectDialog(true)}
onClick={() =>
navigate(`/admin/withdrawals/${id}/reject`, {
state: {
amountKopeks: detail.amount_kopeks,
username: detail.username,
firstName: detail.first_name,
},
})
}
className="flex-1 rounded-lg bg-error-500 px-4 py-3 font-medium text-white transition-colors hover:bg-error-600"
>
{t('admin.withdrawals.detail.reject')}
@@ -446,48 +440,6 @@ export default function AdminWithdrawalDetail() {
</div>
)}
</div>
{/* Reject Dialog */}
{showRejectDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60" onClick={() => setShowRejectDialog(false)} />
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.withdrawals.detail.rejectTitle')}
</h3>
<p className="mb-4 text-sm text-dark-400">
{t('admin.withdrawals.detail.rejectDescription')}
</p>
<textarea
value={rejectComment}
onChange={(e) => setRejectComment(e.target.value)}
placeholder={t('admin.withdrawals.detail.commentPlaceholder')}
className="mb-4 w-full rounded-lg border border-dark-600 bg-dark-700 p-3 text-sm text-dark-200 placeholder:text-dark-500 focus:border-accent-500 focus:outline-none"
rows={3}
/>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setShowRejectDialog(false);
setRejectComment('');
}}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('common.cancel')}
</button>
<button
onClick={() => rejectMutation.mutate(rejectComment)}
disabled={rejectMutation.isPending}
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
{rejectMutation.isPending
? t('admin.withdrawals.detail.rejecting')
: t('admin.withdrawals.detail.confirmReject')}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { useState } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { withdrawalApi } from '../api/withdrawals';
import { AdminBackButton } from '../components/admin';
import { useCurrency } from '../hooks/useCurrency';
export default function AdminWithdrawalReject() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const { formatWithCurrency } = useCurrency();
const [comment, setComment] = useState('');
// Try to get withdrawal summary from navigate state
const passedDetail = location.state as {
amountKopeks?: number;
username?: string;
firstName?: string;
} | null;
const rejectMutation = useMutation({
mutationFn: (rejectComment: string) =>
withdrawalApi.reject(Number(id), rejectComment || undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-withdrawal-detail', id] });
queryClient.invalidateQueries({ queryKey: ['admin-withdrawals'] });
navigate(`/admin/withdrawals/${id}`);
},
});
const displayName = passedDetail?.username
? `@${passedDetail.username}`
: passedDetail?.firstName || '';
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to={`/admin/withdrawals/${id}`} />
<div>
<h1 className="text-xl font-semibold text-dark-100">
{t('admin.withdrawals.detail.rejectTitle')}
</h1>
{passedDetail?.amountKopeks != null && passedDetail.amountKopeks > 0 && (
<p className="text-sm text-dark-400">
#{id} {'\u2022'} {formatWithCurrency(passedDetail.amountKopeks / 100, 0)}
{displayName && ` \u2022 ${displayName}`}
</p>
)}
</div>
</div>
<div className="rounded-xl border border-error-500/30 bg-dark-800 p-6">
<p className="mb-4 text-sm text-dark-400">
{t('admin.withdrawals.detail.rejectDescription')}
</p>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.withdrawals.detail.commentPlaceholder')}
</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder={t('admin.withdrawals.detail.commentPlaceholder')}
className="mb-6 w-full rounded-lg border border-dark-600 bg-dark-700 p-3 text-sm text-dark-200 placeholder:text-dark-500 focus:border-accent-500 focus:outline-none"
rows={3}
/>
<div className="flex gap-3">
<button
onClick={() => navigate(`/admin/withdrawals/${id}`)}
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
onClick={() => rejectMutation.mutate(comment)}
disabled={rejectMutation.isPending}
className="flex-1 rounded-lg bg-error-500 px-4 py-3 font-medium text-white transition-colors hover:bg-error-600 disabled:opacity-50"
>
{rejectMutation.isPending
? t('admin.withdrawals.detail.rejecting')
: t('admin.withdrawals.detail.confirmReject')}
</button>
</div>
{rejectMutation.isError && (
<div className="mt-4 rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
{t('common.error')}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,20 +1,12 @@
import { useState, type FormEvent } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { referralApi } from '../api/referral';
import { brandingApi } from '../api/branding';
import { partnerApi, type PartnerApplicationRequest } from '../api/partners';
import { partnerApi } from '../api/partners';
import { withdrawalApi } from '../api/withdrawals';
import { useCurrency } from '../hooks/useCurrency';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '../components/primitives/Dialog/Dialog';
const CopyIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -87,27 +79,10 @@ function getWithdrawalStatusBadge(status: string): string {
export default function Referral() {
const { t } = useTranslation();
const navigate = useNavigate();
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
const queryClient = useQueryClient();
const [copied, setCopied] = useState(false);
const [showApplyDialog, setShowApplyDialog] = useState(false);
const [showWithdrawDialog, setShowWithdrawDialog] = useState(false);
const [showReapply, setShowReapply] = useState(false);
// Partner application form state
const [applyForm, setApplyForm] = useState<PartnerApplicationRequest>({
company_name: '',
website_url: '',
telegram_channel: '',
description: '',
expected_monthly_referrals: undefined,
});
// Withdrawal form state
const [withdrawForm, setWithdrawForm] = useState({
amount_kopeks: 0,
payment_details: '',
});
const { data: info, isLoading } = useQuery({
queryKey: ['referral-info'],
@@ -161,33 +136,6 @@ export default function Referral() {
enabled: isPartner,
});
// Partner apply mutation
const applyMutation = useMutation({
mutationFn: partnerApi.apply,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['partner-status'] });
setShowApplyDialog(false);
setApplyForm({
company_name: '',
website_url: '',
telegram_channel: '',
description: '',
expected_monthly_referrals: undefined,
});
},
});
// Withdrawal create mutation
const withdrawMutation = useMutation({
mutationFn: withdrawalApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['withdrawal-balance'] });
queryClient.invalidateQueries({ queryKey: ['withdrawal-history'] });
setShowWithdrawDialog(false);
setWithdrawForm({ amount_kopeks: 0, payment_details: '' });
},
});
// Withdrawal cancel mutation
const cancelWithdrawalMutation = useMutation({
mutationFn: withdrawalApi.cancel,
@@ -231,29 +179,6 @@ export default function Referral() {
window.open(telegramUrl, '_blank', 'noopener,noreferrer');
};
const handleApplySubmit = (e: FormEvent) => {
e.preventDefault();
const payload: PartnerApplicationRequest = {};
if (applyForm.company_name) payload.company_name = applyForm.company_name;
if (applyForm.website_url) payload.website_url = applyForm.website_url;
if (applyForm.telegram_channel) payload.telegram_channel = applyForm.telegram_channel;
if (applyForm.description) payload.description = applyForm.description;
if (applyForm.expected_monthly_referrals) {
payload.expected_monthly_referrals = applyForm.expected_monthly_referrals;
}
applyMutation.mutate(payload);
};
const handleWithdrawSubmit = (e: FormEvent) => {
e.preventDefault();
if (withdrawForm.payment_details.length < 5) return;
if (withdrawForm.amount_kopeks <= 0) return;
withdrawMutation.mutate({
amount_kopeks: withdrawForm.amount_kopeks,
payment_details: withdrawForm.payment_details,
});
};
if (isLoading) {
return (
<div className="flex min-h-64 items-center justify-center">
@@ -290,10 +215,10 @@ export default function Referral() {
}
const partnerStatusValue = partnerStatus?.partner_status ?? 'none';
const showApplySection = partnerStatusValue === 'none' || showReapply;
const showPendingSection = partnerStatusValue === 'pending' && !showReapply;
const showApplySection = partnerStatusValue === 'none';
const showPendingSection = partnerStatusValue === 'pending';
const showApprovedSection = partnerStatusValue === 'approved';
const showRejectedSection = partnerStatusValue === 'rejected' && !showReapply;
const showRejectedSection = partnerStatusValue === 'rejected';
return (
<div className="space-y-6">
@@ -480,7 +405,10 @@ export default function Referral() {
<p className="mt-1 text-sm text-dark-400">
{t('referral.partner.becomePartnerDesc')}
</p>
<button onClick={() => setShowApplyDialog(true)} className="btn-primary mt-4 px-6">
<button
onClick={() => navigate('/referral/partner/apply')}
className="btn-primary mt-4 px-6"
>
{t('referral.partner.applyButton')}
</button>
</div>
@@ -569,7 +497,10 @@ export default function Referral() {
{partnerStatus.latest_application.admin_comment}
</p>
)}
<button onClick={() => setShowReapply(true)} className="btn-primary mt-4 px-6">
<button
onClick={() => navigate('/referral/partner/apply')}
className="btn-primary mt-4 px-6"
>
{t('referral.partner.reapplyButton')}
</button>
</div>
@@ -630,7 +561,7 @@ export default function Referral() {
<div className="mt-4">
<button
onClick={() => setShowWithdrawDialog(true)}
onClick={() => navigate('/referral/withdrawal/request')}
disabled={!withdrawalBalance.can_request}
className={`btn-primary w-full px-6 sm:w-auto ${
!withdrawalBalance.can_request ? 'cursor-not-allowed opacity-50' : ''
@@ -710,195 +641,6 @@ export default function Referral() {
</div>
</div>
)}
{/* ==================== Partner Application Dialog ==================== */}
<Dialog open={showApplyDialog} onOpenChange={setShowApplyDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('referral.partner.applyTitle')}</DialogTitle>
<DialogDescription>{t('referral.partner.applyDesc')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleApplySubmit} className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.partner.fields.companyName')}
</label>
<input
type="text"
className="input w-full"
value={applyForm.company_name ?? ''}
onChange={(e) => setApplyForm({ ...applyForm, company_name: e.target.value })}
placeholder={t('referral.partner.fields.companyNamePlaceholder')}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.partner.fields.telegramChannel')}
</label>
<input
type="text"
className="input w-full"
value={applyForm.telegram_channel ?? ''}
onChange={(e) => setApplyForm({ ...applyForm, telegram_channel: e.target.value })}
placeholder={t('referral.partner.fields.telegramChannelPlaceholder')}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.partner.fields.websiteUrl')}
</label>
<input
type="url"
className="input w-full"
value={applyForm.website_url ?? ''}
onChange={(e) => setApplyForm({ ...applyForm, website_url: e.target.value })}
placeholder={t('referral.partner.fields.websiteUrlPlaceholder')}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.partner.fields.description')}
</label>
<textarea
className="input min-h-[80px] w-full"
value={applyForm.description ?? ''}
onChange={(e) => setApplyForm({ ...applyForm, description: e.target.value })}
placeholder={t('referral.partner.fields.descriptionPlaceholder')}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.partner.fields.expectedReferrals')}
</label>
<input
type="number"
min={0}
className="input w-full"
value={applyForm.expected_monthly_referrals ?? ''}
onChange={(e) =>
setApplyForm({
...applyForm,
expected_monthly_referrals: e.target.value ? Number(e.target.value) : undefined,
})
}
placeholder={t('referral.partner.fields.expectedReferralsPlaceholder')}
/>
</div>
{applyMutation.isError && (
<div className="rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
{t('referral.partner.applyError')}
</div>
)}
<DialogFooter>
<DialogClose asChild>
<button type="button" className="btn-secondary px-5">
{t('common.cancel')}
</button>
</DialogClose>
<button
type="submit"
disabled={applyMutation.isPending}
className={`btn-primary px-5 ${applyMutation.isPending ? 'opacity-50' : ''}`}
>
{applyMutation.isPending
? t('referral.partner.applying')
: t('referral.partner.submitApplication')}
</button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* ==================== Withdrawal Request Dialog ==================== */}
<Dialog open={showWithdrawDialog} onOpenChange={setShowWithdrawDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('referral.withdrawal.requestTitle')}</DialogTitle>
<DialogDescription>
{t('referral.withdrawal.requestDesc', {
available: withdrawalBalance
? formatWithCurrency(withdrawalBalance.available_total / 100)
: '',
})}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleWithdrawSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.withdrawal.fields.amount')}
</label>
<input
type="number"
min={withdrawalBalance?.min_amount_kopeks ?? 0}
max={withdrawalBalance?.available_total ?? 0}
step={100}
className="input w-full"
value={withdrawForm.amount_kopeks || ''}
onChange={(e) =>
setWithdrawForm({
...withdrawForm,
amount_kopeks: e.target.value ? Number(e.target.value) : 0,
})
}
placeholder={t('referral.withdrawal.fields.amountPlaceholder')}
/>
<p className="mt-1 text-xs text-dark-500">
{t('referral.withdrawal.fields.amountHint', { currency: currencySymbol })}
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.withdrawal.fields.paymentDetails')}
</label>
<textarea
className="input min-h-[80px] w-full"
value={withdrawForm.payment_details}
onChange={(e) =>
setWithdrawForm({ ...withdrawForm, payment_details: e.target.value })
}
placeholder={t('referral.withdrawal.fields.paymentDetailsPlaceholder')}
required
minLength={5}
/>
</div>
{withdrawMutation.isError && (
<div className="rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
{t('referral.withdrawal.requestError')}
</div>
)}
<DialogFooter>
<DialogClose asChild>
<button type="button" className="btn-secondary px-5">
{t('common.cancel')}
</button>
</DialogClose>
<button
type="submit"
disabled={
withdrawMutation.isPending ||
withdrawForm.payment_details.length < 5 ||
withdrawForm.amount_kopeks <= 0
}
className={`btn-primary px-5 ${
withdrawMutation.isPending ||
withdrawForm.payment_details.length < 5 ||
withdrawForm.amount_kopeks <= 0
? 'opacity-50'
: ''
}`}
>
{withdrawMutation.isPending
? t('referral.withdrawal.requesting')
: t('referral.withdrawal.submitRequest')}
</button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import { useState, useEffect, type FormEvent } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { partnerApi, type PartnerApplicationRequest } from '../api/partners';
export default function ReferralPartnerApply() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [form, setForm] = useState<PartnerApplicationRequest>({
company_name: '',
website_url: '',
telegram_channel: '',
description: '',
expected_monthly_referrals: undefined,
});
// Guard: redirect if already approved or pending
const { data: partnerStatus } = useQuery({
queryKey: ['partner-status'],
queryFn: partnerApi.getStatus,
});
useEffect(() => {
if (
partnerStatus?.partner_status === 'approved' ||
partnerStatus?.partner_status === 'pending'
) {
navigate('/referral', { replace: true });
}
}, [partnerStatus, navigate]);
const applyMutation = useMutation({
mutationFn: partnerApi.apply,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['partner-status'] });
navigate('/referral');
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const payload: PartnerApplicationRequest = {};
if (form.company_name) payload.company_name = form.company_name;
if (form.website_url) payload.website_url = form.website_url;
if (form.telegram_channel) payload.telegram_channel = form.telegram_channel;
if (form.description) payload.description = form.description;
if (form.expected_monthly_referrals) {
payload.expected_monthly_referrals = form.expected_monthly_referrals;
}
applyMutation.mutate(payload);
};
return (
<div className="animate-fade-in space-y-6">
<h1 className="text-2xl font-bold text-dark-50">{t('referral.partner.applyTitle')}</h1>
<p className="text-sm text-dark-400">{t('referral.partner.applyDesc')}</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bento-card space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.partner.fields.companyName')}
</label>
<input
type="text"
className="input w-full"
value={form.company_name ?? ''}
onChange={(e) => setForm({ ...form, company_name: e.target.value })}
placeholder={t('referral.partner.fields.companyNamePlaceholder')}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.partner.fields.telegramChannel')}
</label>
<input
type="text"
className="input w-full"
value={form.telegram_channel ?? ''}
onChange={(e) => setForm({ ...form, telegram_channel: e.target.value })}
placeholder={t('referral.partner.fields.telegramChannelPlaceholder')}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.partner.fields.websiteUrl')}
</label>
<input
type="url"
className="input w-full"
value={form.website_url ?? ''}
onChange={(e) => setForm({ ...form, website_url: e.target.value })}
placeholder={t('referral.partner.fields.websiteUrlPlaceholder')}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.partner.fields.description')}
</label>
<textarea
className="input min-h-[80px] w-full"
value={form.description ?? ''}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder={t('referral.partner.fields.descriptionPlaceholder')}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.partner.fields.expectedReferrals')}
</label>
<input
type="number"
min={0}
className="input w-full"
value={form.expected_monthly_referrals ?? ''}
onChange={(e) =>
setForm({
...form,
expected_monthly_referrals: e.target.value ? Number(e.target.value) : undefined,
})
}
placeholder={t('referral.partner.fields.expectedReferralsPlaceholder')}
/>
</div>
</div>
{applyMutation.isError && (
<div className="rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
{t('referral.partner.applyError')}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => navigate('/referral')}
className="btn-secondary flex-1 px-5"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={applyMutation.isPending}
className={`btn-primary flex-1 px-5 ${applyMutation.isPending ? 'opacity-50' : ''}`}
>
{applyMutation.isPending
? t('referral.partner.applying')
: t('referral.partner.submitApplication')}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import { useState, useEffect, type FormEvent } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { withdrawalApi } from '../api/withdrawals';
import { useCurrency } from '../hooks/useCurrency';
export default function ReferralWithdrawalRequest() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { formatWithCurrency, currencySymbol } = useCurrency();
const [form, setForm] = useState({
amount_kopeks: 0,
payment_details: '',
});
const { data: balance } = useQuery({
queryKey: ['withdrawal-balance'],
queryFn: withdrawalApi.getBalance,
});
// Guard: redirect if can't request
useEffect(() => {
if (balance && !balance.can_request) {
navigate('/referral', { replace: true });
}
}, [balance, navigate]);
const withdrawMutation = useMutation({
mutationFn: withdrawalApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['withdrawal-balance'] });
queryClient.invalidateQueries({ queryKey: ['withdrawal-history'] });
navigate('/referral');
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (form.payment_details.length < 5) return;
if (form.amount_kopeks <= 0) return;
withdrawMutation.mutate({
amount_kopeks: form.amount_kopeks,
payment_details: form.payment_details,
});
};
return (
<div className="animate-fade-in space-y-6">
<h1 className="text-2xl font-bold text-dark-50">{t('referral.withdrawal.requestTitle')}</h1>
<p className="text-sm text-dark-400">
{t('referral.withdrawal.requestDesc', {
available: balance ? formatWithCurrency(balance.available_total / 100) : '',
})}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bento-card space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.withdrawal.fields.amount')}
</label>
<input
type="number"
min={balance?.min_amount_kopeks ?? 0}
max={balance?.available_total ?? 0}
step={100}
className="input w-full"
value={form.amount_kopeks || ''}
onChange={(e) =>
setForm({
...form,
amount_kopeks: e.target.value ? Number(e.target.value) : 0,
})
}
placeholder={t('referral.withdrawal.fields.amountPlaceholder')}
/>
<p className="mt-1 text-xs text-dark-500">
{t('referral.withdrawal.fields.amountHint', { currency: currencySymbol })}
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('referral.withdrawal.fields.paymentDetails')}
</label>
<textarea
className="input min-h-[80px] w-full"
value={form.payment_details}
onChange={(e) => setForm({ ...form, payment_details: e.target.value })}
placeholder={t('referral.withdrawal.fields.paymentDetailsPlaceholder')}
required
minLength={5}
/>
</div>
</div>
{withdrawMutation.isError && (
<div className="rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
{t('referral.withdrawal.requestError')}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => navigate('/referral')}
className="btn-secondary flex-1 px-5"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={
withdrawMutation.isPending ||
form.payment_details.length < 5 ||
form.amount_kopeks <= 0
}
className={`btn-primary flex-1 px-5 ${
withdrawMutation.isPending ||
form.payment_details.length < 5 ||
form.amount_kopeks <= 0
? 'opacity-50'
: ''
}`}
>
{withdrawMutation.isPending
? t('referral.withdrawal.requesting')
: t('referral.withdrawal.submitRequest')}
</button>
</div>
</form>
</div>
);
}