mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +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 AdminPartners = lazy(() => import('./pages/AdminPartners'));
|
||||
const AdminPartnerDetail = lazy(() => import('./pages/AdminPartnerDetail'));
|
||||
const AdminApplicationReview = lazy(() => import('./pages/AdminApplicationReview'));
|
||||
const AdminPartnerCommission = lazy(() => import('./pages/AdminPartnerCommission'));
|
||||
const AdminPartnerRevoke = lazy(() => import('./pages/AdminPartnerRevoke'));
|
||||
const AdminPartnerCampaignAssign = lazy(() => import('./pages/AdminPartnerCampaignAssign'));
|
||||
const AdminPartnerCampaignCreate = lazy(() => import('./pages/AdminPartnerCampaignCreate'));
|
||||
const AdminWithdrawals = lazy(() => import('./pages/AdminWithdrawals'));
|
||||
const AdminWithdrawalDetail = lazy(() => import('./pages/AdminWithdrawalDetail'));
|
||||
const AdminWithdrawalReject = lazy(() => import('./pages/AdminWithdrawalReject'));
|
||||
const ReferralPartnerApply = lazy(() => import('./pages/ReferralPartnerApply'));
|
||||
const ReferralWithdrawalRequest = lazy(() => import('./pages/ReferralWithdrawalRequest'));
|
||||
const AdminUsers = lazy(() => import('./pages/AdminUsers'));
|
||||
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
|
||||
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
||||
@@ -221,6 +229,26 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/referral/partner/apply"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<ReferralPartnerApply />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/referral/withdrawal/request"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<ReferralWithdrawalRequest />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/support"
|
||||
element={
|
||||
@@ -563,6 +591,56 @@ function App() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/applications/:id/review"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminApplicationReview />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId/commission"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPartnerCommission />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId/revoke"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPartnerRevoke />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId/campaigns/assign"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPartnerCampaignAssign />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId/campaigns/create"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPartnerCampaignCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId"
|
||||
element={
|
||||
@@ -583,6 +661,16 @@ function App() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/withdrawals/:id/reject"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminWithdrawalReject />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/withdrawals/:id"
|
||||
element={
|
||||
|
||||
@@ -1868,9 +1868,11 @@
|
||||
"referrals": "{{count}} referrals",
|
||||
"actions": {
|
||||
"approve": "Approve",
|
||||
"reject": "Reject"
|
||||
"reject": "Reject",
|
||||
"review": "Review"
|
||||
},
|
||||
"applicationFields": {
|
||||
"companyName": "Company",
|
||||
"website": "Website",
|
||||
"channel": "Channel",
|
||||
"description": "Description",
|
||||
@@ -1918,7 +1920,16 @@
|
||||
"title": "Assigned Campaigns",
|
||||
"noCampaigns": "No campaigns assigned",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
"inactive": "Inactive",
|
||||
"assign": "Assign",
|
||||
"unassign": "Unassign",
|
||||
"createNew": "Create",
|
||||
"assignTitle": "Assign Campaign",
|
||||
"noAvailable": "No available campaigns to assign",
|
||||
"createTitle": "Create Campaign",
|
||||
"createSubtitle": "Campaign will be auto-assigned to the partner",
|
||||
"autoAssignNote": "Campaign will be automatically assigned to partner {{name}}",
|
||||
"createAndAssign": "Create & Assign"
|
||||
},
|
||||
"dangerZone": {
|
||||
"title": "Danger Zone",
|
||||
|
||||
@@ -1605,9 +1605,11 @@
|
||||
"referrals": "{{count}} معرفی",
|
||||
"actions": {
|
||||
"approve": "تأیید",
|
||||
"reject": "رد"
|
||||
"reject": "رد",
|
||||
"review": "بررسی"
|
||||
},
|
||||
"applicationFields": {
|
||||
"companyName": "شرکت",
|
||||
"website": "وبسایت",
|
||||
"channel": "کانال",
|
||||
"description": "توضیحات",
|
||||
@@ -1655,7 +1657,16 @@
|
||||
"title": "کمپینهای اختصاصیافته",
|
||||
"noCampaigns": "کمپینی اختصاص نیافته",
|
||||
"active": "فعال",
|
||||
"inactive": "غیرفعال"
|
||||
"inactive": "غیرفعال",
|
||||
"assign": "اختصاص",
|
||||
"unassign": "لغو اختصاص",
|
||||
"createNew": "ایجاد",
|
||||
"assignTitle": "اختصاص کمپین",
|
||||
"noAvailable": "کمپینی برای اختصاص موجود نیست",
|
||||
"createTitle": "ایجاد کمپین",
|
||||
"createSubtitle": "کمپین بهطور خودکار به شریک اختصاص مییابد",
|
||||
"autoAssignNote": "کمپین بهطور خودکار به شریک {{name}} اختصاص مییابد",
|
||||
"createAndAssign": "ایجاد و اختصاص"
|
||||
},
|
||||
"dangerZone": {
|
||||
"title": "منطقه خطر",
|
||||
|
||||
@@ -2390,9 +2390,11 @@
|
||||
"referrals": "{{count}} рефералов",
|
||||
"actions": {
|
||||
"approve": "Одобрить",
|
||||
"reject": "Отклонить"
|
||||
"reject": "Отклонить",
|
||||
"review": "Рассмотреть"
|
||||
},
|
||||
"applicationFields": {
|
||||
"companyName": "Компания",
|
||||
"website": "Сайт",
|
||||
"channel": "Канал",
|
||||
"description": "Описание",
|
||||
@@ -2440,7 +2442,16 @@
|
||||
"title": "Привязанные кампании",
|
||||
"noCampaigns": "Нет привязанных кампаний",
|
||||
"active": "Активна",
|
||||
"inactive": "Неактивна"
|
||||
"inactive": "Неактивна",
|
||||
"assign": "Привязать",
|
||||
"unassign": "Отвязать",
|
||||
"createNew": "Создать",
|
||||
"assignTitle": "Привязать кампанию",
|
||||
"noAvailable": "Нет доступных кампаний для привязки",
|
||||
"createTitle": "Создать кампанию",
|
||||
"createSubtitle": "Кампания будет автоматически привязана к партнёру",
|
||||
"autoAssignNote": "Кампания будет автоматически привязана к партнёру {{name}}",
|
||||
"createAndAssign": "Создать и привязать"
|
||||
},
|
||||
"dangerZone": {
|
||||
"title": "Опасная зона",
|
||||
|
||||
@@ -1604,9 +1604,11 @@
|
||||
"referrals": "{{count}} 个推荐",
|
||||
"actions": {
|
||||
"approve": "批准",
|
||||
"reject": "拒绝"
|
||||
"reject": "拒绝",
|
||||
"review": "审核"
|
||||
},
|
||||
"applicationFields": {
|
||||
"companyName": "公司",
|
||||
"website": "网站",
|
||||
"channel": "频道",
|
||||
"description": "描述",
|
||||
@@ -1654,7 +1656,16 @@
|
||||
"title": "分配的活动",
|
||||
"noCampaigns": "暂无分配的活动",
|
||||
"active": "活跃",
|
||||
"inactive": "未激活"
|
||||
"inactive": "未激活",
|
||||
"assign": "分配",
|
||||
"unassign": "取消分配",
|
||||
"createNew": "创建",
|
||||
"assignTitle": "分配活动",
|
||||
"noAvailable": "没有可分配的活动",
|
||||
"createTitle": "创建活动",
|
||||
"createSubtitle": "活动将自动分配给合作伙伴",
|
||||
"autoAssignNote": "活动将自动分配给合作伙伴 {{name}}",
|
||||
"createAndAssign": "创建并分配"
|
||||
},
|
||||
"dangerZone": {
|
||||
"title": "危险操作",
|
||||
|
||||
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -37,10 +36,12 @@ export default function AdminPartnerDetail() {
|
||||
const queryClient = useQueryClient();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
// Dialog states
|
||||
const [showCommissionDialog, setShowCommissionDialog] = useState(false);
|
||||
const [commissionValue, setCommissionValue] = useState('');
|
||||
const [showRevokeDialog, setShowRevokeDialog] = useState(false);
|
||||
const unassignMutation = useMutation({
|
||||
mutationFn: (campaignId: number) => partnerApi.unassignCampaign(Number(userId), campaignId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch partner detail
|
||||
const {
|
||||
@@ -53,27 +54,6 @@ export default function AdminPartnerDetail() {
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateCommissionMutation = useMutation({
|
||||
mutationFn: (commission: number) => partnerApi.updateCommission(Number(userId), commission),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
|
||||
setShowCommissionDialog(false);
|
||||
setCommissionValue('');
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: () => partnerApi.revokePartner(Number(userId)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
|
||||
setShowRevokeDialog(false);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -204,10 +184,11 @@ export default function AdminPartnerDetail() {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCommissionValue(String(partner.commission_percent ?? 0));
|
||||
setShowCommissionDialog(true);
|
||||
}}
|
||||
onClick={() =>
|
||||
navigate(`/admin/partners/${userId}/commission`, {
|
||||
state: { currentCommission: partner.commission_percent ?? 0 },
|
||||
})
|
||||
}
|
||||
className="rounded-lg bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
>
|
||||
{t('admin.partnerDetail.commission.update')}
|
||||
@@ -217,9 +198,25 @@ export default function AdminPartnerDetail() {
|
||||
|
||||
{/* Campaigns */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<h3 className="mb-4 font-medium text-dark-200">
|
||||
{t('admin.partnerDetail.campaigns.title')}
|
||||
</h3>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-dark-200">
|
||||
{t('admin.partnerDetail.campaigns.title')}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/admin/partners/${userId}/campaigns/assign`)}
|
||||
className="rounded-lg bg-dark-700 px-3 py-1.5 text-xs text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
>
|
||||
{t('admin.partnerDetail.campaigns.assign')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/partners/${userId}/campaigns/create`)}
|
||||
className="rounded-lg bg-accent-500/20 px-3 py-1.5 text-xs font-medium text-accent-400 transition-colors hover:bg-accent-500/30"
|
||||
>
|
||||
{t('admin.partnerDetail.campaigns.createNew')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{partner.campaigns.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-dark-500">
|
||||
{t('admin.partnerDetail.campaigns.noCampaigns')}
|
||||
@@ -233,21 +230,43 @@ export default function AdminPartnerDetail() {
|
||||
!campaign.is_active ? 'opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-dark-100">{campaign.name}</div>
|
||||
<div className="font-mono text-xs text-dark-500">
|
||||
?start={campaign.start_parameter}
|
||||
</div>
|
||||
</div>
|
||||
{campaign.is_active ? (
|
||||
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||
{t('admin.partnerDetail.campaigns.active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
{t('admin.partnerDetail.campaigns.inactive')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{campaign.is_active ? (
|
||||
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||
{t('admin.partnerDetail.campaigns.active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
{t('admin.partnerDetail.campaigns.inactive')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => unassignMutation.mutate(campaign.id)}
|
||||
disabled={unassignMutation.isPending}
|
||||
className="rounded p-1 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400"
|
||||
title={t('admin.partnerDetail.campaigns.unassign')}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -260,95 +279,13 @@ export default function AdminPartnerDetail() {
|
||||
{t('admin.partnerDetail.dangerZone.title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowRevokeDialog(true)}
|
||||
onClick={() => navigate(`/admin/partners/${userId}/revoke`)}
|
||||
className="w-full rounded-lg bg-error-500/20 px-4 py-3 text-sm font-medium text-error-400 transition-colors hover:bg-error-500/30"
|
||||
>
|
||||
{t('admin.partnerDetail.dangerZone.revokeButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commission Update Dialog */}
|
||||
{showCommissionDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
onClick={() => setShowCommissionDialog(false)}
|
||||
/>
|
||||
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{t('admin.partnerDetail.commissionDialog.title')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('admin.partnerDetail.commissionDialog.description')}
|
||||
</p>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('admin.partnerDetail.commissionDialog.label')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={commissionValue}
|
||||
onChange={(e) => setCommissionValue(e.target.value)}
|
||||
className="mb-6 w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCommissionDialog(false);
|
||||
setCommissionValue('');
|
||||
}}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCommissionMutation.mutate(Number(commissionValue))}
|
||||
disabled={updateCommissionMutation.isPending || !commissionValue}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{updateCommissionMutation.isPending ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revoke Confirmation Dialog */}
|
||||
{showRevokeDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
onClick={() => setShowRevokeDialog(false)}
|
||||
/>
|
||||
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{t('admin.partnerDetail.revokeDialog.title')}
|
||||
</h3>
|
||||
<p className="mb-6 text-dark-400">
|
||||
{t('admin.partnerDetail.revokeDialog.description')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowRevokeDialog(false)}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => revokeMutation.mutate()}
|
||||
disabled={revokeMutation.isPending}
|
||||
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
||||
>
|
||||
{revokeMutation.isPending
|
||||
? t('common.saving')
|
||||
: t('admin.partnerDetail.dangerZone.revokeButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
partnerApi,
|
||||
@@ -13,19 +13,10 @@ import { useCurrency } from '../hooks/useCurrency';
|
||||
export default function AdminPartners() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'partners' | 'applications'>('partners');
|
||||
|
||||
// Approve dialog state
|
||||
const [approveDialog, setApproveDialog] = useState<AdminPartnerApplicationItem | null>(null);
|
||||
const [approveCommission, setApproveCommission] = useState('10');
|
||||
|
||||
// Reject dialog state
|
||||
const [rejectDialog, setRejectDialog] = useState<AdminPartnerApplicationItem | null>(null);
|
||||
const [rejectComment, setRejectComment] = useState('');
|
||||
|
||||
// Queries
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['admin-partner-stats'],
|
||||
@@ -42,30 +33,6 @@ export default function AdminPartners() {
|
||||
queryFn: () => partnerApi.getApplications({ status: 'pending' }),
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({ id, commission }: { id: number; commission: number }) =>
|
||||
partnerApi.approveApplication(id, { commission_percent: commission }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-applications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
|
||||
setApproveDialog(null);
|
||||
setApproveCommission('10');
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ id, comment }: { id: number; comment?: string }) =>
|
||||
partnerApi.rejectApplication(id, { comment }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-applications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
|
||||
setRejectDialog(null);
|
||||
setRejectComment('');
|
||||
},
|
||||
});
|
||||
|
||||
const partners = partnersData?.items || [];
|
||||
const applications = applicationsData?.items || [];
|
||||
|
||||
@@ -256,16 +223,14 @@ export default function AdminPartners() {
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setApproveDialog(app)}
|
||||
className="flex-1 rounded-lg bg-success-500/20 px-4 py-2 text-sm font-medium text-success-400 transition-colors hover:bg-success-500/30"
|
||||
onClick={() =>
|
||||
navigate(`/admin/partners/applications/${app.id}/review`, {
|
||||
state: { application: app },
|
||||
})
|
||||
}
|
||||
className="flex-1 rounded-lg bg-accent-500/20 px-4 py-2 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/30"
|
||||
>
|
||||
{t('admin.partners.actions.approve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRejectDialog(app)}
|
||||
className="flex-1 rounded-lg bg-error-500/20 px-4 py-2 text-sm font-medium text-error-400 transition-colors hover:bg-error-500/30"
|
||||
>
|
||||
{t('admin.partners.actions.reject')}
|
||||
{t('admin.partners.actions.review')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,112 +239,6 @@ export default function AdminPartners() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Approve Dialog */}
|
||||
{approveDialog !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => setApproveDialog(null)} />
|
||||
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{t('admin.partners.approveDialog.title')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('admin.partners.approveDialog.description', {
|
||||
name:
|
||||
approveDialog.first_name || approveDialog.username || `#${approveDialog.user_id}`,
|
||||
})}
|
||||
</p>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('admin.partners.approveDialog.commissionLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={approveCommission}
|
||||
onChange={(e) => setApproveCommission(e.target.value)}
|
||||
className="mb-6 w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
|
||||
placeholder="10"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setApproveDialog(null);
|
||||
setApproveCommission('10');
|
||||
}}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
approveMutation.mutate({
|
||||
id: approveDialog.id,
|
||||
commission: Number(approveCommission),
|
||||
})
|
||||
}
|
||||
disabled={approveMutation.isPending || !approveCommission}
|
||||
className="rounded-lg bg-success-500 px-4 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
|
||||
>
|
||||
{approveMutation.isPending
|
||||
? t('common.saving')
|
||||
: t('admin.partners.actions.approve')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reject Dialog */}
|
||||
{rejectDialog !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => setRejectDialog(null)} />
|
||||
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{t('admin.partners.rejectDialog.title')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('admin.partners.rejectDialog.description', {
|
||||
name:
|
||||
rejectDialog.first_name || rejectDialog.username || `#${rejectDialog.user_id}`,
|
||||
})}
|
||||
</p>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('admin.partners.rejectDialog.commentLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={rejectComment}
|
||||
onChange={(e) => setRejectComment(e.target.value)}
|
||||
className="mb-6 w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
|
||||
rows={3}
|
||||
placeholder={t('admin.partners.rejectDialog.commentPlaceholder')}
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRejectDialog(null);
|
||||
setRejectComment('');
|
||||
}}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
rejectMutation.mutate({
|
||||
id: rejectDialog.id,
|
||||
comment: rejectComment || undefined,
|
||||
})
|
||||
}
|
||||
disabled={rejectMutation.isPending}
|
||||
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
||||
>
|
||||
{rejectMutation.isPending ? t('common.saving') : t('admin.partners.actions.reject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -93,9 +92,6 @@ export default function AdminWithdrawalDetail() {
|
||||
const queryClient = useQueryClient();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [rejectComment, setRejectComment] = useState('');
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
||||
|
||||
// Fetch detail
|
||||
const {
|
||||
data: detail,
|
||||
@@ -116,16 +112,6 @@ export default function AdminWithdrawalDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (comment: string) => withdrawalApi.reject(Number(id), comment || undefined),
|
||||
onSuccess: () => {
|
||||
setShowRejectDialog(false);
|
||||
setRejectComment('');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawal-detail', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawals'] });
|
||||
},
|
||||
});
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: () => withdrawalApi.complete(Number(id)),
|
||||
onSuccess: () => {
|
||||
@@ -424,7 +410,15 @@ export default function AdminWithdrawalDetail() {
|
||||
: t('admin.withdrawals.detail.approve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRejectDialog(true)}
|
||||
onClick={() =>
|
||||
navigate(`/admin/withdrawals/${id}/reject`, {
|
||||
state: {
|
||||
amountKopeks: detail.amount_kopeks,
|
||||
username: detail.username,
|
||||
firstName: detail.first_name,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex-1 rounded-lg bg-error-500 px-4 py-3 font-medium text-white transition-colors hover:bg-error-600"
|
||||
>
|
||||
{t('admin.withdrawals.detail.reject')}
|
||||
@@ -446,48 +440,6 @@ export default function AdminWithdrawalDetail() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reject Dialog */}
|
||||
{showRejectDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/60" onClick={() => setShowRejectDialog(false)} />
|
||||
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{t('admin.withdrawals.detail.rejectTitle')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.rejectDescription')}
|
||||
</p>
|
||||
<textarea
|
||||
value={rejectComment}
|
||||
onChange={(e) => setRejectComment(e.target.value)}
|
||||
placeholder={t('admin.withdrawals.detail.commentPlaceholder')}
|
||||
className="mb-4 w-full rounded-lg border border-dark-600 bg-dark-700 p-3 text-sm text-dark-200 placeholder:text-dark-500 focus:border-accent-500 focus:outline-none"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRejectDialog(false);
|
||||
setRejectComment('');
|
||||
}}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectMutation.mutate(rejectComment)}
|
||||
disabled={rejectMutation.isPending}
|
||||
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
||||
>
|
||||
{rejectMutation.isPending
|
||||
? t('admin.withdrawals.detail.rejecting')
|
||||
: t('admin.withdrawals.detail.confirmReject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { referralApi } from '../api/referral';
|
||||
import { brandingApi } from '../api/branding';
|
||||
import { partnerApi, type PartnerApplicationRequest } from '../api/partners';
|
||||
import { partnerApi } from '../api/partners';
|
||||
import { withdrawalApi } from '../api/withdrawals';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '../components/primitives/Dialog/Dialog';
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -87,27 +79,10 @@ function getWithdrawalStatusBadge(status: string): string {
|
||||
|
||||
export default function Referral() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
|
||||
const queryClient = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showApplyDialog, setShowApplyDialog] = useState(false);
|
||||
const [showWithdrawDialog, setShowWithdrawDialog] = useState(false);
|
||||
const [showReapply, setShowReapply] = useState(false);
|
||||
|
||||
// Partner application form state
|
||||
const [applyForm, setApplyForm] = useState<PartnerApplicationRequest>({
|
||||
company_name: '',
|
||||
website_url: '',
|
||||
telegram_channel: '',
|
||||
description: '',
|
||||
expected_monthly_referrals: undefined,
|
||||
});
|
||||
|
||||
// Withdrawal form state
|
||||
const [withdrawForm, setWithdrawForm] = useState({
|
||||
amount_kopeks: 0,
|
||||
payment_details: '',
|
||||
});
|
||||
|
||||
const { data: info, isLoading } = useQuery({
|
||||
queryKey: ['referral-info'],
|
||||
@@ -161,33 +136,6 @@ export default function Referral() {
|
||||
enabled: isPartner,
|
||||
});
|
||||
|
||||
// Partner apply mutation
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: partnerApi.apply,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['partner-status'] });
|
||||
setShowApplyDialog(false);
|
||||
setApplyForm({
|
||||
company_name: '',
|
||||
website_url: '',
|
||||
telegram_channel: '',
|
||||
description: '',
|
||||
expected_monthly_referrals: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Withdrawal create mutation
|
||||
const withdrawMutation = useMutation({
|
||||
mutationFn: withdrawalApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['withdrawal-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['withdrawal-history'] });
|
||||
setShowWithdrawDialog(false);
|
||||
setWithdrawForm({ amount_kopeks: 0, payment_details: '' });
|
||||
},
|
||||
});
|
||||
|
||||
// Withdrawal cancel mutation
|
||||
const cancelWithdrawalMutation = useMutation({
|
||||
mutationFn: withdrawalApi.cancel,
|
||||
@@ -231,29 +179,6 @@ export default function Referral() {
|
||||
window.open(telegramUrl, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const handleApplySubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const payload: PartnerApplicationRequest = {};
|
||||
if (applyForm.company_name) payload.company_name = applyForm.company_name;
|
||||
if (applyForm.website_url) payload.website_url = applyForm.website_url;
|
||||
if (applyForm.telegram_channel) payload.telegram_channel = applyForm.telegram_channel;
|
||||
if (applyForm.description) payload.description = applyForm.description;
|
||||
if (applyForm.expected_monthly_referrals) {
|
||||
payload.expected_monthly_referrals = applyForm.expected_monthly_referrals;
|
||||
}
|
||||
applyMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleWithdrawSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (withdrawForm.payment_details.length < 5) return;
|
||||
if (withdrawForm.amount_kopeks <= 0) return;
|
||||
withdrawMutation.mutate({
|
||||
amount_kopeks: withdrawForm.amount_kopeks,
|
||||
payment_details: withdrawForm.payment_details,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-64 items-center justify-center">
|
||||
@@ -290,10 +215,10 @@ export default function Referral() {
|
||||
}
|
||||
|
||||
const partnerStatusValue = partnerStatus?.partner_status ?? 'none';
|
||||
const showApplySection = partnerStatusValue === 'none' || showReapply;
|
||||
const showPendingSection = partnerStatusValue === 'pending' && !showReapply;
|
||||
const showApplySection = partnerStatusValue === 'none';
|
||||
const showPendingSection = partnerStatusValue === 'pending';
|
||||
const showApprovedSection = partnerStatusValue === 'approved';
|
||||
const showRejectedSection = partnerStatusValue === 'rejected' && !showReapply;
|
||||
const showRejectedSection = partnerStatusValue === 'rejected';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -480,7 +405,10 @@ export default function Referral() {
|
||||
<p className="mt-1 text-sm text-dark-400">
|
||||
{t('referral.partner.becomePartnerDesc')}
|
||||
</p>
|
||||
<button onClick={() => setShowApplyDialog(true)} className="btn-primary mt-4 px-6">
|
||||
<button
|
||||
onClick={() => navigate('/referral/partner/apply')}
|
||||
className="btn-primary mt-4 px-6"
|
||||
>
|
||||
{t('referral.partner.applyButton')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -569,7 +497,10 @@ export default function Referral() {
|
||||
{partnerStatus.latest_application.admin_comment}
|
||||
</p>
|
||||
)}
|
||||
<button onClick={() => setShowReapply(true)} className="btn-primary mt-4 px-6">
|
||||
<button
|
||||
onClick={() => navigate('/referral/partner/apply')}
|
||||
className="btn-primary mt-4 px-6"
|
||||
>
|
||||
{t('referral.partner.reapplyButton')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -630,7 +561,7 @@ export default function Referral() {
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowWithdrawDialog(true)}
|
||||
onClick={() => navigate('/referral/withdrawal/request')}
|
||||
disabled={!withdrawalBalance.can_request}
|
||||
className={`btn-primary w-full px-6 sm:w-auto ${
|
||||
!withdrawalBalance.can_request ? 'cursor-not-allowed opacity-50' : ''
|
||||
@@ -710,195 +641,6 @@ export default function Referral() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== Partner Application Dialog ==================== */}
|
||||
<Dialog open={showApplyDialog} onOpenChange={setShowApplyDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('referral.partner.applyTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('referral.partner.applyDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleApplySubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.partner.fields.companyName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full"
|
||||
value={applyForm.company_name ?? ''}
|
||||
onChange={(e) => setApplyForm({ ...applyForm, company_name: e.target.value })}
|
||||
placeholder={t('referral.partner.fields.companyNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.partner.fields.telegramChannel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full"
|
||||
value={applyForm.telegram_channel ?? ''}
|
||||
onChange={(e) => setApplyForm({ ...applyForm, telegram_channel: e.target.value })}
|
||||
placeholder={t('referral.partner.fields.telegramChannelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.partner.fields.websiteUrl')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
className="input w-full"
|
||||
value={applyForm.website_url ?? ''}
|
||||
onChange={(e) => setApplyForm({ ...applyForm, website_url: e.target.value })}
|
||||
placeholder={t('referral.partner.fields.websiteUrlPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.partner.fields.description')}
|
||||
</label>
|
||||
<textarea
|
||||
className="input min-h-[80px] w-full"
|
||||
value={applyForm.description ?? ''}
|
||||
onChange={(e) => setApplyForm({ ...applyForm, description: e.target.value })}
|
||||
placeholder={t('referral.partner.fields.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.partner.fields.expectedReferrals')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input w-full"
|
||||
value={applyForm.expected_monthly_referrals ?? ''}
|
||||
onChange={(e) =>
|
||||
setApplyForm({
|
||||
...applyForm,
|
||||
expected_monthly_referrals: e.target.value ? Number(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t('referral.partner.fields.expectedReferralsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{applyMutation.isError && (
|
||||
<div className="rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{t('referral.partner.applyError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<button type="button" className="btn-secondary px-5">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={applyMutation.isPending}
|
||||
className={`btn-primary px-5 ${applyMutation.isPending ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{applyMutation.isPending
|
||||
? t('referral.partner.applying')
|
||||
: t('referral.partner.submitApplication')}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ==================== Withdrawal Request Dialog ==================== */}
|
||||
<Dialog open={showWithdrawDialog} onOpenChange={setShowWithdrawDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('referral.withdrawal.requestTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('referral.withdrawal.requestDesc', {
|
||||
available: withdrawalBalance
|
||||
? formatWithCurrency(withdrawalBalance.available_total / 100)
|
||||
: '',
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleWithdrawSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.withdrawal.fields.amount')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={withdrawalBalance?.min_amount_kopeks ?? 0}
|
||||
max={withdrawalBalance?.available_total ?? 0}
|
||||
step={100}
|
||||
className="input w-full"
|
||||
value={withdrawForm.amount_kopeks || ''}
|
||||
onChange={(e) =>
|
||||
setWithdrawForm({
|
||||
...withdrawForm,
|
||||
amount_kopeks: e.target.value ? Number(e.target.value) : 0,
|
||||
})
|
||||
}
|
||||
placeholder={t('referral.withdrawal.fields.amountPlaceholder')}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('referral.withdrawal.fields.amountHint', { currency: currencySymbol })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.withdrawal.fields.paymentDetails')}
|
||||
</label>
|
||||
<textarea
|
||||
className="input min-h-[80px] w-full"
|
||||
value={withdrawForm.payment_details}
|
||||
onChange={(e) =>
|
||||
setWithdrawForm({ ...withdrawForm, payment_details: e.target.value })
|
||||
}
|
||||
placeholder={t('referral.withdrawal.fields.paymentDetailsPlaceholder')}
|
||||
required
|
||||
minLength={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{withdrawMutation.isError && (
|
||||
<div className="rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{t('referral.withdrawal.requestError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<button type="button" className="btn-secondary px-5">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
withdrawMutation.isPending ||
|
||||
withdrawForm.payment_details.length < 5 ||
|
||||
withdrawForm.amount_kopeks <= 0
|
||||
}
|
||||
className={`btn-primary px-5 ${
|
||||
withdrawMutation.isPending ||
|
||||
withdrawForm.payment_details.length < 5 ||
|
||||
withdrawForm.amount_kopeks <= 0
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{withdrawMutation.isPending
|
||||
? t('referral.withdrawal.requesting')
|
||||
: t('referral.withdrawal.submitRequest')}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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