mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
feat: add partner management and withdrawal admin pages
- Admin partners page: list partners, view applications, approve/reject - Admin partner detail: stats, commission management, campaign assignment - Admin withdrawals page: list with risk scoring, status filters including cancelled - Admin withdrawal detail: fraud analysis, approve/reject/complete actions - Partner API client with full TypeScript interfaces - Withdrawal API client with admin and user endpoints - Referral page: partner application form, withdrawal balance and history - Navigation: partners and withdrawals in admin panel marketing group - i18n: full translations for ru, en, zh, fa locales - Neutral fallbacks for unknown status badges
This commit is contained in:
@@ -186,6 +186,16 @@ const SparklesIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const HandshakeIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CogIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
@@ -449,6 +459,18 @@ export default function AdminPanel() {
|
||||
title: t('admin.nav.wheel'),
|
||||
description: t('admin.panel.wheelDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/partners',
|
||||
icon: <HandshakeIcon />,
|
||||
title: t('admin.nav.partners'),
|
||||
description: t('admin.panel.partnersDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/withdrawals',
|
||||
icon: <BanknotesIcon />,
|
||||
title: t('admin.nav.withdrawals'),
|
||||
description: t('admin.panel.withdrawalsDesc'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
354
src/pages/AdminPartnerDetail.tsx
Normal file
354
src/pages/AdminPartnerDetail.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { useState } 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 { AdminBackButton } from '../components/admin';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
// Status badge config — keys must match backend PartnerStatus enum values
|
||||
const statusConfig: Record<string, { labelKey: string; color: string; bgColor: string }> = {
|
||||
approved: {
|
||||
labelKey: 'admin.partnerDetail.status.approved',
|
||||
color: 'text-success-400',
|
||||
bgColor: 'bg-success-500/20',
|
||||
},
|
||||
pending: {
|
||||
labelKey: 'admin.partnerDetail.status.pending',
|
||||
color: 'text-yellow-400',
|
||||
bgColor: 'bg-yellow-500/20',
|
||||
},
|
||||
rejected: {
|
||||
labelKey: 'admin.partnerDetail.status.rejected',
|
||||
color: 'text-error-400',
|
||||
bgColor: 'bg-error-500/20',
|
||||
},
|
||||
none: {
|
||||
labelKey: 'admin.partnerDetail.status.none',
|
||||
color: 'text-dark-400',
|
||||
bgColor: 'bg-dark-600',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AdminPartnerDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
// Dialog states
|
||||
const [showCommissionDialog, setShowCommissionDialog] = useState(false);
|
||||
const [commissionValue, setCommissionValue] = useState('');
|
||||
const [showRevokeDialog, setShowRevokeDialog] = useState(false);
|
||||
|
||||
// Fetch partner detail
|
||||
const {
|
||||
data: partner,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-partner-detail', userId],
|
||||
queryFn: () => partnerApi.getPartnerDetail(Number(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) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !partner) {
|
||||
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.partnerDetail.title')}</h1>
|
||||
</div>
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-6 text-center">
|
||||
<p className="text-error-400">{t('admin.partnerDetail.loadError')}</p>
|
||||
<button
|
||||
onClick={() => navigate('/admin/partners')}
|
||||
className="mt-4 text-sm text-dark-400 hover:text-dark-200"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const badge = statusConfig[partner.partner_status] || statusConfig.none;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/partners" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{partner.first_name || partner.username || `#${partner.user_id}`}
|
||||
</h1>
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${badge.bgColor} ${badge.color}`}>
|
||||
{t(badge.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
{partner.username && <p className="text-sm text-dark-400">@{partner.username}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Referral Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-dark-100">{partner.total_referrals}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.partnerDetail.stats.totalReferrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-success-400">{partner.paid_referrals}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.partnerDetail.stats.paidReferrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{partner.active_referrals}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.partnerDetail.stats.activeReferrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{partner.conversion_to_paid}%</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.partnerDetail.stats.conversionRate')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Earnings */}
|
||||
<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.earnings.title')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.partnerDetail.earnings.allTime')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-success-400">
|
||||
{formatWithCurrency(partner.earnings_all_time / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.partnerDetail.earnings.today')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatWithCurrency(partner.earnings_today / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.partnerDetail.earnings.week')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatWithCurrency(partner.earnings_week / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.partnerDetail.earnings.month')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatWithCurrency(partner.earnings_month / 100)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commission */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-dark-200">
|
||||
{t('admin.partnerDetail.commission.title')}
|
||||
</h3>
|
||||
<div className="mt-1 text-2xl font-bold text-accent-400">
|
||||
{partner.commission_percent ?? 0}%
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCommissionValue(String(partner.commission_percent ?? 0));
|
||||
setShowCommissionDialog(true);
|
||||
}}
|
||||
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')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{partner.campaigns.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-dark-500">
|
||||
{t('admin.partnerDetail.campaigns.noCampaigns')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{partner.campaigns.map((campaign) => (
|
||||
<div
|
||||
key={campaign.id}
|
||||
className={`flex items-center justify-between rounded-lg bg-dark-700/50 p-3 ${
|
||||
!campaign.is_active ? 'opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<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.dangerZone.title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowRevokeDialog(true)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
385
src/pages/AdminPartners.tsx
Normal file
385
src/pages/AdminPartners.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
partnerApi,
|
||||
type AdminPartnerItem,
|
||||
type AdminPartnerApplicationItem,
|
||||
} from '../api/partners';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
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'],
|
||||
queryFn: () => partnerApi.getStats(),
|
||||
});
|
||||
|
||||
const { data: partnersData, isLoading: partnersLoading } = useQuery({
|
||||
queryKey: ['admin-partners'],
|
||||
queryFn: () => partnerApi.getPartners(),
|
||||
});
|
||||
|
||||
const { data: applicationsData, isLoading: applicationsLoading } = useQuery({
|
||||
queryKey: ['admin-partner-applications'],
|
||||
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 || [];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to="/admin" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.partners.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.partners.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<div className="mb-6 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-dark-100">{stats.total_partners}</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.partners.totalPartners')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.pending_applications}</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.partners.pendingApplications')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-dark-100">{stats.total_referrals}</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.partners.totalReferrals')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{formatWithCurrency(stats.total_earnings_kopeks / 100)}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.partners.totalEarnings')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-4 flex gap-1 rounded-lg border border-dark-700 bg-dark-800/40 p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('partners')}
|
||||
className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'partners'
|
||||
? 'bg-dark-700 text-dark-100'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{t('admin.partners.tabs.partners')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('applications')}
|
||||
className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'applications'
|
||||
? 'bg-dark-700 text-dark-100'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{t('admin.partners.tabs.applications')}
|
||||
{applications.length > 0 && (
|
||||
<span className="ml-2 rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||
{applications.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Partners Tab */}
|
||||
{activeTab === 'partners' && (
|
||||
<>
|
||||
{partnersLoading ? (
|
||||
<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>
|
||||
) : partners.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">{t('admin.partners.noPartners')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{partners.map((partner: AdminPartnerItem) => (
|
||||
<button
|
||||
key={partner.user_id}
|
||||
onClick={() => navigate(`/admin/partners/${partner.user_id}`)}
|
||||
className="w-full rounded-xl border border-dark-700 bg-dark-800 p-4 text-left transition-colors hover:border-dark-600"
|
||||
>
|
||||
<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">
|
||||
<h3 className="truncate font-medium text-dark-100">
|
||||
{partner.first_name || partner.username || `#${partner.user_id}`}
|
||||
</h3>
|
||||
{partner.username && (
|
||||
<span className="text-sm text-dark-500">@{partner.username}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
<span>
|
||||
{t('admin.partners.commission', {
|
||||
percent: partner.commission_percent ?? 0,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t('admin.partners.referrals', { count: partner.total_referrals })}
|
||||
</span>
|
||||
<span className="text-success-400">
|
||||
{formatWithCurrency(partner.total_earnings_kopeks / 100)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Applications Tab */}
|
||||
{activeTab === 'applications' && (
|
||||
<>
|
||||
{applicationsLoading ? (
|
||||
<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>
|
||||
) : applications.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">{t('admin.partners.noApplications')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{applications.map((app: AdminPartnerApplicationItem) => (
|
||||
<div key={app.id} className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="truncate font-medium text-dark-100">
|
||||
{app.first_name || app.username || `#${app.user_id}`}
|
||||
</h3>
|
||||
{app.username && (
|
||||
<span className="text-sm text-dark-500">@{app.username}</span>
|
||||
)}
|
||||
</div>
|
||||
{app.company_name && (
|
||||
<div className="text-sm text-dark-300">{app.company_name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Application details */}
|
||||
<div className="mb-3 space-y-1 text-sm text-dark-400">
|
||||
{app.website_url && (
|
||||
<div>
|
||||
{t('admin.partners.applicationFields.website')}: {app.website_url}
|
||||
</div>
|
||||
)}
|
||||
{app.telegram_channel && (
|
||||
<div>
|
||||
{t('admin.partners.applicationFields.channel')}: {app.telegram_channel}
|
||||
</div>
|
||||
)}
|
||||
{app.description && (
|
||||
<div>
|
||||
{t('admin.partners.applicationFields.description')}: {app.description}
|
||||
</div>
|
||||
)}
|
||||
{app.expected_monthly_referrals != null && (
|
||||
<div>
|
||||
{t('admin.partners.applicationFields.expectedReferrals')}:{' '}
|
||||
{app.expected_monthly_referrals}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
{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')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
493
src/pages/AdminWithdrawalDetail.tsx
Normal file
493
src/pages/AdminWithdrawalDetail.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { withdrawalApi } from '../api/withdrawals';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
// Locale mapping for formatting
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
|
||||
const formatDate = (date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Status badge config
|
||||
const statusBadgeConfig: Record<string, { labelKey: string; color: string; bgColor: string }> = {
|
||||
pending: {
|
||||
labelKey: 'admin.withdrawals.status.pending',
|
||||
color: 'text-yellow-400',
|
||||
bgColor: 'bg-yellow-500/20',
|
||||
},
|
||||
approved: {
|
||||
labelKey: 'admin.withdrawals.status.approved',
|
||||
color: 'text-accent-400',
|
||||
bgColor: 'bg-accent-500/20',
|
||||
},
|
||||
rejected: {
|
||||
labelKey: 'admin.withdrawals.status.rejected',
|
||||
color: 'text-error-400',
|
||||
bgColor: 'bg-error-500/20',
|
||||
},
|
||||
completed: {
|
||||
labelKey: 'admin.withdrawals.status.completed',
|
||||
color: 'text-success-400',
|
||||
bgColor: 'bg-success-500/20',
|
||||
},
|
||||
cancelled: {
|
||||
labelKey: 'admin.withdrawals.status.cancelled',
|
||||
color: 'text-dark-400',
|
||||
bgColor: 'bg-dark-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
// Risk score color helper
|
||||
function getRiskColor(score: number): { text: string; bg: string; bar: string } {
|
||||
if (score < 30)
|
||||
return { text: 'text-success-400', bg: 'bg-success-500/20', bar: 'bg-success-500' };
|
||||
if (score < 50) return { text: 'text-yellow-400', bg: 'bg-yellow-500/20', bar: 'bg-yellow-500' };
|
||||
if (score < 70) return { text: 'text-orange-400', bg: 'bg-orange-500/20', bar: 'bg-orange-500' };
|
||||
return { text: 'text-error-400', bg: 'bg-error-500/20', bar: 'bg-error-500' };
|
||||
}
|
||||
|
||||
// Risk level badge color
|
||||
function getRiskLevelColor(level: string): { text: string; bg: string } {
|
||||
switch (level) {
|
||||
case 'low':
|
||||
return { text: 'text-success-400', bg: 'bg-success-500/20' };
|
||||
case 'medium':
|
||||
return { text: 'text-yellow-400', bg: 'bg-yellow-500/20' };
|
||||
case 'high':
|
||||
return { text: 'text-orange-400', bg: 'bg-orange-500/20' };
|
||||
case 'critical':
|
||||
return { text: 'text-error-400', bg: 'bg-error-500/20' };
|
||||
default:
|
||||
return { text: 'text-dark-400', bg: 'bg-dark-500/20' };
|
||||
}
|
||||
}
|
||||
|
||||
// Type for parsed risk analysis
|
||||
interface RiskAnalysis {
|
||||
flags?: string[];
|
||||
balance_stats?: Record<string, unknown>;
|
||||
referral_deposits?: Record<string, unknown>;
|
||||
suspicious_referrals?: Record<string, unknown>;
|
||||
earnings_by_reason?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default function AdminWithdrawalDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [rejectComment, setRejectComment] = useState('');
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
||||
|
||||
// Fetch detail
|
||||
const {
|
||||
data: detail,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-withdrawal-detail', id],
|
||||
queryFn: () => withdrawalApi.getDetail(Number(id)),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: () => withdrawalApi.approve(Number(id)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawal-detail', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawals'] });
|
||||
},
|
||||
});
|
||||
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawal-detail', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawals'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// Error
|
||||
if (error || !detail) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/withdrawals" />
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{t('admin.withdrawals.detail.title')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-6 text-center">
|
||||
<p className="text-error-400">{t('admin.withdrawals.detail.loadError')}</p>
|
||||
<button
|
||||
onClick={() => navigate('/admin/withdrawals')}
|
||||
className="mt-4 text-sm text-dark-400 hover:text-dark-200"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const badge = statusBadgeConfig[detail.status] || statusBadgeConfig.cancelled;
|
||||
const riskColor = getRiskColor(detail.risk_score);
|
||||
|
||||
// Parse risk analysis
|
||||
const riskAnalysis = (detail.risk_analysis || {}) as RiskAnalysis;
|
||||
const flags = riskAnalysis.flags || [];
|
||||
|
||||
const riskLevelKey = detail.risk_level;
|
||||
const riskLevelBadge = getRiskLevelColor(riskLevelKey);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/withdrawals" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{t('admin.withdrawals.detail.title')} #{detail.id}
|
||||
</h1>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${badge.bgColor} ${badge.color}`}>
|
||||
{t(badge.labelKey)}
|
||||
</span>
|
||||
<span className="font-semibold text-dark-100">
|
||||
{formatWithCurrency(detail.amount_kopeks / 100, 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* User Info Section */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<h3 className="mb-4 font-medium text-dark-200">
|
||||
{t('admin.withdrawals.detail.userInfo')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.username')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{detail.username ? `@${detail.username}` : detail.first_name || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.telegramId')}
|
||||
</div>
|
||||
<div className="font-mono text-sm font-medium text-dark-200">
|
||||
{detail.telegram_id ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.balance')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{formatWithCurrency(detail.balance_kopeks / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.totalReferrals')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">{detail.total_referrals}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.totalEarnings')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{formatWithCurrency(detail.total_earnings_kopeks / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.createdAt')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{formatDate(detail.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Details Section */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<h3 className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.withdrawals.detail.paymentDetails')}
|
||||
</h3>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<p className="whitespace-pre-wrap break-all text-sm text-dark-300">
|
||||
{detail.payment_details || t('admin.withdrawals.detail.noPaymentDetails')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Analysis Section */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<h3 className="mb-4 font-medium text-dark-200">
|
||||
{t('admin.withdrawals.detail.riskAnalysis')}
|
||||
</h3>
|
||||
|
||||
{/* Risk Score Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.riskScore')}
|
||||
</span>
|
||||
<span className={`text-lg font-bold ${riskColor.text}`}>{detail.risk_score}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${riskLevelBadge.bg} ${riskLevelBadge.text}`}
|
||||
>
|
||||
{t(`admin.withdrawals.detail.riskLevel.${riskLevelKey}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${riskColor.bar}`}
|
||||
style={{ width: `${Math.min(detail.risk_score, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags */}
|
||||
{flags.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.flags')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{flags.map((flag, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 rounded-lg bg-error-500/10 px-3 py-2"
|
||||
>
|
||||
<svg
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-error-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-error-300">{flag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Breakdown */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{riskAnalysis.balance_stats && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-300">
|
||||
{t('admin.withdrawals.detail.balanceStats')}
|
||||
</div>
|
||||
{Object.entries(riskAnalysis.balance_stats).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between text-xs">
|
||||
<span className="text-dark-500">{key}</span>
|
||||
<span className="text-dark-300">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{riskAnalysis.referral_deposits && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-300">
|
||||
{t('admin.withdrawals.detail.referralDeposits')}
|
||||
</div>
|
||||
{Object.entries(riskAnalysis.referral_deposits).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between text-xs">
|
||||
<span className="text-dark-500">{key}</span>
|
||||
<span className="text-dark-300">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{riskAnalysis.suspicious_referrals && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-300">
|
||||
{t('admin.withdrawals.detail.suspiciousReferrals')}
|
||||
</div>
|
||||
{Object.entries(riskAnalysis.suspicious_referrals).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between text-xs">
|
||||
<span className="text-dark-500">{key}</span>
|
||||
<span className="text-dark-300">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{riskAnalysis.earnings_by_reason && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-300">
|
||||
{t('admin.withdrawals.detail.earningsByReason')}
|
||||
</div>
|
||||
{Object.entries(riskAnalysis.earnings_by_reason).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between text-xs">
|
||||
<span className="text-dark-500">{key}</span>
|
||||
<span className="text-dark-300">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Comment Section */}
|
||||
{detail.admin_comment && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<h3 className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.withdrawals.detail.adminComment')}
|
||||
</h3>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<p className="whitespace-pre-wrap text-sm text-dark-300">{detail.admin_comment}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processed At */}
|
||||
{detail.processed_at && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.processedAt')}
|
||||
</span>
|
||||
<span className="text-sm text-dark-200">{formatDate(detail.processed_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{detail.status === 'pending' && (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => approveMutation.mutate()}
|
||||
disabled={approveMutation.isPending}
|
||||
className="flex-1 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('admin.withdrawals.detail.approving')
|
||||
: t('admin.withdrawals.detail.approve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRejectDialog(true)}
|
||||
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')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.status === 'approved' && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => completeMutation.mutate()}
|
||||
disabled={completeMutation.isPending}
|
||||
className="w-full rounded-lg bg-accent-500 px-4 py-3 font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{completeMutation.isPending
|
||||
? t('admin.withdrawals.detail.completing')
|
||||
: t('admin.withdrawals.detail.complete')}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
226
src/pages/AdminWithdrawals.tsx
Normal file
226
src/pages/AdminWithdrawals.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { withdrawalApi, AdminWithdrawalItem } from '../api/withdrawals';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
// Locale mapping for formatting
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
|
||||
const formatDate = (date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Status filter tabs
|
||||
type StatusFilter = 'all' | 'pending' | 'approved' | 'rejected' | 'completed' | 'cancelled';
|
||||
|
||||
const STATUS_FILTERS: StatusFilter[] = [
|
||||
'all',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'completed',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
// Status badge config
|
||||
const statusBadgeConfig: Record<string, { labelKey: string; color: string; bgColor: string }> = {
|
||||
pending: {
|
||||
labelKey: 'admin.withdrawals.status.pending',
|
||||
color: 'text-yellow-400',
|
||||
bgColor: 'bg-yellow-500/20',
|
||||
},
|
||||
approved: {
|
||||
labelKey: 'admin.withdrawals.status.approved',
|
||||
color: 'text-accent-400',
|
||||
bgColor: 'bg-accent-500/20',
|
||||
},
|
||||
rejected: {
|
||||
labelKey: 'admin.withdrawals.status.rejected',
|
||||
color: 'text-error-400',
|
||||
bgColor: 'bg-error-500/20',
|
||||
},
|
||||
completed: {
|
||||
labelKey: 'admin.withdrawals.status.completed',
|
||||
color: 'text-success-400',
|
||||
bgColor: 'bg-success-500/20',
|
||||
},
|
||||
cancelled: {
|
||||
labelKey: 'admin.withdrawals.status.cancelled',
|
||||
color: 'text-dark-400',
|
||||
bgColor: 'bg-dark-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
// Risk score color helper
|
||||
function getRiskColor(score: number): { text: string; bg: string } {
|
||||
if (score < 30) return { text: 'text-success-400', bg: 'bg-success-500/20' };
|
||||
if (score < 50) return { text: 'text-yellow-400', bg: 'bg-yellow-500/20' };
|
||||
if (score < 70) return { text: 'text-orange-400', bg: 'bg-orange-500/20' };
|
||||
return { text: 'text-error-400', bg: 'bg-error-500/20' };
|
||||
}
|
||||
|
||||
export default function AdminWithdrawals() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
|
||||
// Query
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-withdrawals', statusFilter],
|
||||
queryFn: () =>
|
||||
withdrawalApi.getAll({
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
}),
|
||||
});
|
||||
|
||||
const items = data?.items || [];
|
||||
|
||||
const pendingCount = data?.pending_count ?? 0;
|
||||
const pendingTotal = data?.pending_total_kopeks ?? 0;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton to="/admin" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.withdrawals.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.withdrawals.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Stats */}
|
||||
{data && (
|
||||
<div className="mb-6 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-yellow-400">{pendingCount}</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.withdrawals.overview.pendingCount')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-yellow-400">
|
||||
{formatWithCurrency(pendingTotal / 100, 0)}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.withdrawals.overview.pendingAmount')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Filter Tabs */}
|
||||
<div className="mb-4 flex gap-2 overflow-x-auto">
|
||||
{STATUS_FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setStatusFilter(filter)}
|
||||
className={`whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
statusFilter === filter
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-800/40 text-dark-400 hover:bg-dark-700/50 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.withdrawals.filter.${filter}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Withdrawal Cards List */}
|
||||
{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>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">{t('admin.withdrawals.noData')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{items.map((item: AdminWithdrawalItem) => {
|
||||
const badge = statusBadgeConfig[item.status] || statusBadgeConfig.cancelled;
|
||||
const riskColor = getRiskColor(item.risk_score);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => navigate(`/admin/withdrawals/${item.id}`)}
|
||||
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/40 p-4 text-left transition-colors hover:border-dark-600 hover:bg-dark-800/60"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* User and amount */}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="truncate font-medium text-dark-100">
|
||||
{item.username
|
||||
? `@${item.username}`
|
||||
: item.first_name || `#${item.user_id}`}
|
||||
</span>
|
||||
<span className="font-semibold text-dark-100">
|
||||
{formatWithCurrency(item.amount_kopeks / 100, 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status badge + Risk score + Date */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${badge.bgColor} ${badge.color}`}
|
||||
>
|
||||
{t(badge.labelKey)}
|
||||
</span>
|
||||
|
||||
{/* Risk score */}
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${riskColor.bg} ${riskColor.text}`}
|
||||
>
|
||||
{t('admin.withdrawals.risk')} {item.risk_score}
|
||||
</span>
|
||||
|
||||
{/* Risk level */}
|
||||
<span className="text-xs text-dark-500">
|
||||
{t(`admin.withdrawals.detail.riskLevel.${item.risk_level}`)}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-dark-500">{formatDate(item.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chevron right */}
|
||||
<svg
|
||||
className="mt-1 h-5 w-5 shrink-0 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
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 { 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}>
|
||||
@@ -28,10 +39,75 @@ const ShareIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PartnerIcon = () => (
|
||||
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0112 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 01-.673-.38m0 0A2.18 2.18 0 013 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 013.413-.387m7.5 0V5.25A2.25 2.25 0 0013.5 3h-3a2.25 2.25 0 00-2.25 2.25v.894m7.5 0a48.667 48.667 0 00-7.5 0M12 12.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WalletIcon = () => (
|
||||
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
function getWithdrawalStatusBadge(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'badge-success';
|
||||
case 'approved':
|
||||
return 'badge-info';
|
||||
case 'pending':
|
||||
return 'badge-warning';
|
||||
case 'rejected':
|
||||
case 'cancelled':
|
||||
return 'badge-error';
|
||||
default:
|
||||
return 'badge-neutral';
|
||||
}
|
||||
}
|
||||
|
||||
export default function Referral() {
|
||||
const { t } = useTranslation();
|
||||
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
|
||||
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'],
|
||||
@@ -64,6 +140,63 @@ export default function Referral() {
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
// Partner status query
|
||||
const { data: partnerStatus } = useQuery({
|
||||
queryKey: ['partner-status'],
|
||||
queryFn: partnerApi.getStatus,
|
||||
});
|
||||
|
||||
const isPartner = partnerStatus?.partner_status === 'approved';
|
||||
|
||||
// Withdrawal queries (only when partner is approved)
|
||||
const { data: withdrawalBalance } = useQuery({
|
||||
queryKey: ['withdrawal-balance'],
|
||||
queryFn: withdrawalApi.getBalance,
|
||||
enabled: isPartner,
|
||||
});
|
||||
|
||||
const { data: withdrawalHistory } = useQuery({
|
||||
queryKey: ['withdrawal-history'],
|
||||
queryFn: withdrawalApi.getHistory,
|
||||
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,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['withdrawal-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['withdrawal-history'] });
|
||||
},
|
||||
});
|
||||
|
||||
const copyLink = () => {
|
||||
if (referralLink) {
|
||||
navigator.clipboard.writeText(referralLink);
|
||||
@@ -98,6 +231,29 @@ 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">
|
||||
@@ -133,6 +289,12 @@ export default function Referral() {
|
||||
);
|
||||
}
|
||||
|
||||
const partnerStatusValue = partnerStatus?.partner_status ?? 'none';
|
||||
const showApplySection = partnerStatusValue === 'none' || showReapply;
|
||||
const showPendingSection = partnerStatusValue === 'pending' && !showReapply;
|
||||
const showApprovedSection = partnerStatusValue === 'approved';
|
||||
const showRejectedSection = partnerStatusValue === 'rejected' && !showReapply;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('referral.title')}</h1>
|
||||
@@ -301,6 +463,442 @@ export default function Referral() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== Partner Application Section ==================== */}
|
||||
|
||||
{/* Status: none — Become a Partner CTA */}
|
||||
{showApplySection && (
|
||||
<div className="bento-card">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-accent-500/10 text-accent-400">
|
||||
<PartnerIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('referral.partner.becomePartner')}
|
||||
</h2>
|
||||
<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">
|
||||
{t('referral.partner.applyButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status: pending — Application Under Review */}
|
||||
{showPendingSection && (
|
||||
<div className="bento-card border-warning-500/20">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-warning-500/10 text-warning-400">
|
||||
<ClockIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('referral.partner.underReview')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-dark-400">{t('referral.partner.underReviewDesc')}</p>
|
||||
{partnerStatus?.latest_application?.created_at && (
|
||||
<p className="mt-2 text-xs text-dark-500">
|
||||
{t('referral.partner.submittedAt', {
|
||||
date: new Date(
|
||||
partnerStatus.latest_application.created_at,
|
||||
).toLocaleDateString(),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status: approved — Partner Badge */}
|
||||
{showApprovedSection && (
|
||||
<div className="bento-card border-success-500/20">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-success-500/10 text-success-400">
|
||||
<PartnerIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('referral.partner.partnerStatus')}
|
||||
</h2>
|
||||
<span className="badge-success">{t('referral.partner.active')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-dark-400">
|
||||
{t('referral.partner.commissionInfo', {
|
||||
percent: partnerStatus?.commission_percent ?? 0,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<a href="#withdrawal-section" className="btn-secondary hidden px-4 sm:flex">
|
||||
{t('referral.withdrawal.goToWithdrawal')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status: rejected — Rejection Notice */}
|
||||
{showRejectedSection && (
|
||||
<div className="bento-card border-error-500/20">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-error-500/10 text-error-400">
|
||||
<svg
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('referral.partner.rejected')}
|
||||
</h2>
|
||||
{partnerStatus?.latest_application?.admin_comment && (
|
||||
<p className="mt-1 text-sm text-dark-300">
|
||||
{partnerStatus.latest_application.admin_comment}
|
||||
</p>
|
||||
)}
|
||||
<button onClick={() => setShowReapply(true)} className="btn-primary mt-4 px-6">
|
||||
{t('referral.partner.reapplyButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== Withdrawal Section (approved partners only) ==================== */}
|
||||
|
||||
{isPartner && (
|
||||
<div id="withdrawal-section" className="space-y-6">
|
||||
{/* Withdrawal Balance Card */}
|
||||
{withdrawalBalance && (
|
||||
<div className="bento-card">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent-500/10 text-accent-400">
|
||||
<WalletIcon />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('referral.withdrawal.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<div className="col-span-2 rounded-xl bg-dark-800/30 p-4 md:col-span-1">
|
||||
<div className="text-sm text-dark-500">{t('referral.withdrawal.available')}</div>
|
||||
<div className="mt-1 text-2xl font-bold text-success-400">
|
||||
{formatWithCurrency(withdrawalBalance.available_total / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-sm text-dark-500">
|
||||
{t('referral.withdrawal.totalEarned')}
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-semibold text-dark-100">
|
||||
{formatWithCurrency(withdrawalBalance.total_earned / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-sm text-dark-500">{t('referral.withdrawal.withdrawn')}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-dark-100">
|
||||
{formatWithCurrency(withdrawalBalance.withdrawn / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-sm text-dark-500">{t('referral.withdrawal.spent')}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-dark-100">
|
||||
{formatWithCurrency(withdrawalBalance.referral_spent / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-sm text-dark-500">{t('referral.withdrawal.pending')}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-warning-400">
|
||||
{formatWithCurrency(withdrawalBalance.pending / 100)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowWithdrawDialog(true)}
|
||||
disabled={!withdrawalBalance.can_request}
|
||||
className={`btn-primary w-full px-6 sm:w-auto ${
|
||||
!withdrawalBalance.can_request ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{t('referral.withdrawal.requestButton')}
|
||||
</button>
|
||||
{!withdrawalBalance.can_request && withdrawalBalance.cannot_request_reason && (
|
||||
<p className="mt-2 text-xs text-dark-500">
|
||||
{withdrawalBalance.cannot_request_reason}
|
||||
</p>
|
||||
)}
|
||||
{withdrawalBalance.min_amount_kopeks > 0 && (
|
||||
<p className="mt-2 text-xs text-dark-500">
|
||||
{t('referral.withdrawal.minAmount', {
|
||||
amount: formatWithCurrency(withdrawalBalance.min_amount_kopeks / 100),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Withdrawal History */}
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('referral.withdrawal.history')}
|
||||
</h2>
|
||||
{withdrawalHistory?.items && withdrawalHistory.items.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{withdrawalHistory.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-dark-100">
|
||||
{formatWithCurrency(item.amount_rubles)}
|
||||
</span>
|
||||
<span className={getWithdrawalStatusBadge(item.status)}>
|
||||
{t(`referral.withdrawal.status.${item.status}`, item.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{new Date(item.created_at).toLocaleDateString()}
|
||||
{item.payment_details && (
|
||||
<span className="ml-1">
|
||||
•{' '}
|
||||
{item.payment_details.length > 40
|
||||
? `${item.payment_details.slice(0, 40)}...`
|
||||
: item.payment_details}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.admin_comment && (
|
||||
<div className="mt-1 text-xs text-dark-400">{item.admin_comment}</div>
|
||||
)}
|
||||
</div>
|
||||
{item.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => cancelWithdrawalMutation.mutate(item.id)}
|
||||
disabled={cancelWithdrawalMutation.isPending}
|
||||
className="ml-3 shrink-0 text-sm text-error-400 transition-colors hover:text-error-300"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<div className="text-dark-400">{t('referral.withdrawal.noHistory')}</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user