mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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:
88
src/App.tsx
88
src/App.tsx
@@ -61,8 +61,16 @@ const AdminCampaignStats = lazy(() => import('./pages/AdminCampaignStats'));
|
|||||||
const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit'));
|
const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit'));
|
||||||
const AdminPartners = lazy(() => import('./pages/AdminPartners'));
|
const AdminPartners = lazy(() => import('./pages/AdminPartners'));
|
||||||
const AdminPartnerDetail = lazy(() => import('./pages/AdminPartnerDetail'));
|
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 AdminWithdrawals = lazy(() => import('./pages/AdminWithdrawals'));
|
||||||
const AdminWithdrawalDetail = lazy(() => import('./pages/AdminWithdrawalDetail'));
|
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 AdminUsers = lazy(() => import('./pages/AdminUsers'));
|
||||||
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
|
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
|
||||||
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
||||||
@@ -221,6 +229,26 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/referral/partner/apply"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<ReferralPartnerApply />
|
||||||
|
</LazyPage>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/referral/withdrawal/request"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<ReferralWithdrawalRequest />
|
||||||
|
</LazyPage>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/support"
|
path="/support"
|
||||||
element={
|
element={
|
||||||
@@ -563,6 +591,56 @@ function App() {
|
|||||||
</AdminRoute>
|
</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
|
<Route
|
||||||
path="/admin/partners/:userId"
|
path="/admin/partners/:userId"
|
||||||
element={
|
element={
|
||||||
@@ -583,6 +661,16 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/withdrawals/:id/reject"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminWithdrawalReject />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/withdrawals/:id"
|
path="/admin/withdrawals/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1868,9 +1868,11 @@
|
|||||||
"referrals": "{{count}} referrals",
|
"referrals": "{{count}} referrals",
|
||||||
"actions": {
|
"actions": {
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"reject": "Reject"
|
"reject": "Reject",
|
||||||
|
"review": "Review"
|
||||||
},
|
},
|
||||||
"applicationFields": {
|
"applicationFields": {
|
||||||
|
"companyName": "Company",
|
||||||
"website": "Website",
|
"website": "Website",
|
||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
@@ -1918,7 +1920,16 @@
|
|||||||
"title": "Assigned Campaigns",
|
"title": "Assigned Campaigns",
|
||||||
"noCampaigns": "No campaigns assigned",
|
"noCampaigns": "No campaigns assigned",
|
||||||
"active": "Active",
|
"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": {
|
"dangerZone": {
|
||||||
"title": "Danger Zone",
|
"title": "Danger Zone",
|
||||||
|
|||||||
@@ -1605,9 +1605,11 @@
|
|||||||
"referrals": "{{count}} معرفی",
|
"referrals": "{{count}} معرفی",
|
||||||
"actions": {
|
"actions": {
|
||||||
"approve": "تأیید",
|
"approve": "تأیید",
|
||||||
"reject": "رد"
|
"reject": "رد",
|
||||||
|
"review": "بررسی"
|
||||||
},
|
},
|
||||||
"applicationFields": {
|
"applicationFields": {
|
||||||
|
"companyName": "شرکت",
|
||||||
"website": "وبسایت",
|
"website": "وبسایت",
|
||||||
"channel": "کانال",
|
"channel": "کانال",
|
||||||
"description": "توضیحات",
|
"description": "توضیحات",
|
||||||
@@ -1655,7 +1657,16 @@
|
|||||||
"title": "کمپینهای اختصاصیافته",
|
"title": "کمپینهای اختصاصیافته",
|
||||||
"noCampaigns": "کمپینی اختصاص نیافته",
|
"noCampaigns": "کمپینی اختصاص نیافته",
|
||||||
"active": "فعال",
|
"active": "فعال",
|
||||||
"inactive": "غیرفعال"
|
"inactive": "غیرفعال",
|
||||||
|
"assign": "اختصاص",
|
||||||
|
"unassign": "لغو اختصاص",
|
||||||
|
"createNew": "ایجاد",
|
||||||
|
"assignTitle": "اختصاص کمپین",
|
||||||
|
"noAvailable": "کمپینی برای اختصاص موجود نیست",
|
||||||
|
"createTitle": "ایجاد کمپین",
|
||||||
|
"createSubtitle": "کمپین بهطور خودکار به شریک اختصاص مییابد",
|
||||||
|
"autoAssignNote": "کمپین بهطور خودکار به شریک {{name}} اختصاص مییابد",
|
||||||
|
"createAndAssign": "ایجاد و اختصاص"
|
||||||
},
|
},
|
||||||
"dangerZone": {
|
"dangerZone": {
|
||||||
"title": "منطقه خطر",
|
"title": "منطقه خطر",
|
||||||
|
|||||||
@@ -2390,9 +2390,11 @@
|
|||||||
"referrals": "{{count}} рефералов",
|
"referrals": "{{count}} рефералов",
|
||||||
"actions": {
|
"actions": {
|
||||||
"approve": "Одобрить",
|
"approve": "Одобрить",
|
||||||
"reject": "Отклонить"
|
"reject": "Отклонить",
|
||||||
|
"review": "Рассмотреть"
|
||||||
},
|
},
|
||||||
"applicationFields": {
|
"applicationFields": {
|
||||||
|
"companyName": "Компания",
|
||||||
"website": "Сайт",
|
"website": "Сайт",
|
||||||
"channel": "Канал",
|
"channel": "Канал",
|
||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
@@ -2440,7 +2442,16 @@
|
|||||||
"title": "Привязанные кампании",
|
"title": "Привязанные кампании",
|
||||||
"noCampaigns": "Нет привязанных кампаний",
|
"noCampaigns": "Нет привязанных кампаний",
|
||||||
"active": "Активна",
|
"active": "Активна",
|
||||||
"inactive": "Неактивна"
|
"inactive": "Неактивна",
|
||||||
|
"assign": "Привязать",
|
||||||
|
"unassign": "Отвязать",
|
||||||
|
"createNew": "Создать",
|
||||||
|
"assignTitle": "Привязать кампанию",
|
||||||
|
"noAvailable": "Нет доступных кампаний для привязки",
|
||||||
|
"createTitle": "Создать кампанию",
|
||||||
|
"createSubtitle": "Кампания будет автоматически привязана к партнёру",
|
||||||
|
"autoAssignNote": "Кампания будет автоматически привязана к партнёру {{name}}",
|
||||||
|
"createAndAssign": "Создать и привязать"
|
||||||
},
|
},
|
||||||
"dangerZone": {
|
"dangerZone": {
|
||||||
"title": "Опасная зона",
|
"title": "Опасная зона",
|
||||||
|
|||||||
@@ -1604,9 +1604,11 @@
|
|||||||
"referrals": "{{count}} 个推荐",
|
"referrals": "{{count}} 个推荐",
|
||||||
"actions": {
|
"actions": {
|
||||||
"approve": "批准",
|
"approve": "批准",
|
||||||
"reject": "拒绝"
|
"reject": "拒绝",
|
||||||
|
"review": "审核"
|
||||||
},
|
},
|
||||||
"applicationFields": {
|
"applicationFields": {
|
||||||
|
"companyName": "公司",
|
||||||
"website": "网站",
|
"website": "网站",
|
||||||
"channel": "频道",
|
"channel": "频道",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
@@ -1654,7 +1656,16 @@
|
|||||||
"title": "分配的活动",
|
"title": "分配的活动",
|
||||||
"noCampaigns": "暂无分配的活动",
|
"noCampaigns": "暂无分配的活动",
|
||||||
"active": "活跃",
|
"active": "活跃",
|
||||||
"inactive": "未激活"
|
"inactive": "未激活",
|
||||||
|
"assign": "分配",
|
||||||
|
"unassign": "取消分配",
|
||||||
|
"createNew": "创建",
|
||||||
|
"assignTitle": "分配活动",
|
||||||
|
"noAvailable": "没有可分配的活动",
|
||||||
|
"createTitle": "创建活动",
|
||||||
|
"createSubtitle": "活动将自动分配给合作伙伴",
|
||||||
|
"autoAssignNote": "活动将自动分配给合作伙伴 {{name}}",
|
||||||
|
"createAndAssign": "创建并分配"
|
||||||
},
|
},
|
||||||
"dangerZone": {
|
"dangerZone": {
|
||||||
"title": "危险操作",
|
"title": "危险操作",
|
||||||
|
|||||||
228
src/pages/AdminApplicationReview.tsx
Normal file
228
src/pages/AdminApplicationReview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/pages/AdminPartnerCampaignAssign.tsx
Normal file
107
src/pages/AdminPartnerCampaignAssign.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
src/pages/AdminPartnerCampaignCreate.tsx
Normal file
185
src/pages/AdminPartnerCampaignCreate.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/pages/AdminPartnerCommission.tsx
Normal file
107
src/pages/AdminPartnerCommission.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -37,10 +36,12 @@ export default function AdminPartnerDetail() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { formatWithCurrency } = useCurrency();
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|
||||||
// Dialog states
|
const unassignMutation = useMutation({
|
||||||
const [showCommissionDialog, setShowCommissionDialog] = useState(false);
|
mutationFn: (campaignId: number) => partnerApi.unassignCampaign(Number(userId), campaignId),
|
||||||
const [commissionValue, setCommissionValue] = useState('');
|
onSuccess: () => {
|
||||||
const [showRevokeDialog, setShowRevokeDialog] = useState(false);
|
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch partner detail
|
// Fetch partner detail
|
||||||
const {
|
const {
|
||||||
@@ -53,27 +54,6 @@ export default function AdminPartnerDetail() {
|
|||||||
enabled: !!userId,
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
@@ -204,10 +184,11 @@ export default function AdminPartnerDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
setCommissionValue(String(partner.commission_percent ?? 0));
|
navigate(`/admin/partners/${userId}/commission`, {
|
||||||
setShowCommissionDialog(true);
|
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"
|
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')}
|
{t('admin.partnerDetail.commission.update')}
|
||||||
@@ -217,9 +198,25 @@ export default function AdminPartnerDetail() {
|
|||||||
|
|
||||||
{/* Campaigns */}
|
{/* Campaigns */}
|
||||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||||
<h3 className="mb-4 font-medium text-dark-200">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
{t('admin.partnerDetail.campaigns.title')}
|
<h3 className="font-medium text-dark-200">
|
||||||
</h3>
|
{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 ? (
|
{partner.campaigns.length === 0 ? (
|
||||||
<div className="py-4 text-center text-sm text-dark-500">
|
<div className="py-4 text-center text-sm text-dark-500">
|
||||||
{t('admin.partnerDetail.campaigns.noCampaigns')}
|
{t('admin.partnerDetail.campaigns.noCampaigns')}
|
||||||
@@ -233,21 +230,43 @@ export default function AdminPartnerDetail() {
|
|||||||
!campaign.is_active ? 'opacity-60' : ''
|
!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-medium text-dark-100">{campaign.name}</div>
|
||||||
<div className="font-mono text-xs text-dark-500">
|
<div className="font-mono text-xs text-dark-500">
|
||||||
?start={campaign.start_parameter}
|
?start={campaign.start_parameter}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{campaign.is_active ? (
|
<div className="flex items-center gap-2">
|
||||||
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
{campaign.is_active ? (
|
||||||
{t('admin.partnerDetail.campaigns.active')}
|
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||||
</span>
|
{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 className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||||
</span>
|
{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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -260,95 +279,13 @@ export default function AdminPartnerDetail() {
|
|||||||
{t('admin.partnerDetail.dangerZone.title')}
|
{t('admin.partnerDetail.dangerZone.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<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"
|
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')}
|
{t('admin.partnerDetail.dangerZone.revokeButton')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/pages/AdminPartnerRevoke.tsx
Normal file
64
src/pages/AdminPartnerRevoke.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
partnerApi,
|
partnerApi,
|
||||||
@@ -13,19 +13,10 @@ import { useCurrency } from '../hooks/useCurrency';
|
|||||||
export default function AdminPartners() {
|
export default function AdminPartners() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { formatWithCurrency } = useCurrency();
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'partners' | 'applications'>('partners');
|
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
|
// Queries
|
||||||
const { data: stats } = useQuery({
|
const { data: stats } = useQuery({
|
||||||
queryKey: ['admin-partner-stats'],
|
queryKey: ['admin-partner-stats'],
|
||||||
@@ -42,30 +33,6 @@ export default function AdminPartners() {
|
|||||||
queryFn: () => partnerApi.getApplications({ status: 'pending' }),
|
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 partners = partnersData?.items || [];
|
||||||
const applications = applicationsData?.items || [];
|
const applications = applicationsData?.items || [];
|
||||||
|
|
||||||
@@ -256,16 +223,14 @@ export default function AdminPartners() {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setApproveDialog(app)}
|
onClick={() =>
|
||||||
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"
|
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')}
|
{t('admin.partners.actions.review')}
|
||||||
</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')}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -93,9 +92,6 @@ export default function AdminWithdrawalDetail() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { formatWithCurrency } = useCurrency();
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|
||||||
const [rejectComment, setRejectComment] = useState('');
|
|
||||||
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
|
||||||
|
|
||||||
// Fetch detail
|
// Fetch detail
|
||||||
const {
|
const {
|
||||||
data: detail,
|
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({
|
const completeMutation = useMutation({
|
||||||
mutationFn: () => withdrawalApi.complete(Number(id)),
|
mutationFn: () => withdrawalApi.complete(Number(id)),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -424,7 +410,15 @@ export default function AdminWithdrawalDetail() {
|
|||||||
: t('admin.withdrawals.detail.approve')}
|
: t('admin.withdrawals.detail.approve')}
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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')}
|
{t('admin.withdrawals.detail.reject')}
|
||||||
@@ -446,48 +440,6 @@ export default function AdminWithdrawalDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/pages/AdminWithdrawalReject.tsx
Normal file
100
src/pages/AdminWithdrawalReject.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { referralApi } from '../api/referral';
|
import { referralApi } from '../api/referral';
|
||||||
import { brandingApi } from '../api/branding';
|
import { brandingApi } from '../api/branding';
|
||||||
import { partnerApi, type PartnerApplicationRequest } from '../api/partners';
|
import { partnerApi } from '../api/partners';
|
||||||
import { withdrawalApi } from '../api/withdrawals';
|
import { withdrawalApi } from '../api/withdrawals';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogClose,
|
|
||||||
} from '../components/primitives/Dialog/Dialog';
|
|
||||||
|
|
||||||
const CopyIcon = () => (
|
const CopyIcon = () => (
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<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() {
|
export default function Referral() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
|
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [copied, setCopied] = useState(false);
|
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({
|
const { data: info, isLoading } = useQuery({
|
||||||
queryKey: ['referral-info'],
|
queryKey: ['referral-info'],
|
||||||
@@ -161,33 +136,6 @@ export default function Referral() {
|
|||||||
enabled: isPartner,
|
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
|
// Withdrawal cancel mutation
|
||||||
const cancelWithdrawalMutation = useMutation({
|
const cancelWithdrawalMutation = useMutation({
|
||||||
mutationFn: withdrawalApi.cancel,
|
mutationFn: withdrawalApi.cancel,
|
||||||
@@ -231,29 +179,6 @@ export default function Referral() {
|
|||||||
window.open(telegramUrl, '_blank', 'noopener,noreferrer');
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-64 items-center justify-center">
|
<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 partnerStatusValue = partnerStatus?.partner_status ?? 'none';
|
||||||
const showApplySection = partnerStatusValue === 'none' || showReapply;
|
const showApplySection = partnerStatusValue === 'none';
|
||||||
const showPendingSection = partnerStatusValue === 'pending' && !showReapply;
|
const showPendingSection = partnerStatusValue === 'pending';
|
||||||
const showApprovedSection = partnerStatusValue === 'approved';
|
const showApprovedSection = partnerStatusValue === 'approved';
|
||||||
const showRejectedSection = partnerStatusValue === 'rejected' && !showReapply;
|
const showRejectedSection = partnerStatusValue === 'rejected';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -480,7 +405,10 @@ export default function Referral() {
|
|||||||
<p className="mt-1 text-sm text-dark-400">
|
<p className="mt-1 text-sm text-dark-400">
|
||||||
{t('referral.partner.becomePartnerDesc')}
|
{t('referral.partner.becomePartnerDesc')}
|
||||||
</p>
|
</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')}
|
{t('referral.partner.applyButton')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -569,7 +497,10 @@ export default function Referral() {
|
|||||||
{partnerStatus.latest_application.admin_comment}
|
{partnerStatus.latest_application.admin_comment}
|
||||||
</p>
|
</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')}
|
{t('referral.partner.reapplyButton')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -630,7 +561,7 @@ export default function Referral() {
|
|||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowWithdrawDialog(true)}
|
onClick={() => navigate('/referral/withdrawal/request')}
|
||||||
disabled={!withdrawalBalance.can_request}
|
disabled={!withdrawalBalance.can_request}
|
||||||
className={`btn-primary w-full px-6 sm:w-auto ${
|
className={`btn-primary w-full px-6 sm:w-auto ${
|
||||||
!withdrawalBalance.can_request ? 'cursor-not-allowed opacity-50' : ''
|
!withdrawalBalance.can_request ? 'cursor-not-allowed opacity-50' : ''
|
||||||
@@ -710,195 +641,6 @@ export default function Referral() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
157
src/pages/ReferralPartnerApply.tsx
Normal file
157
src/pages/ReferralPartnerApply.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/pages/ReferralWithdrawalRequest.tsx
Normal file
136
src/pages/ReferralWithdrawalRequest.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user