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:
Fringg
2026-02-17 09:51:48 +03:00
parent e0dd21fd0b
commit 779fbf0dc6
13 changed files with 3322 additions and 15 deletions

View File

@@ -59,6 +59,10 @@ const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns'));
const AdminCampaignCreate = lazy(() => import('./pages/AdminCampaignCreate')); const AdminCampaignCreate = lazy(() => import('./pages/AdminCampaignCreate'));
const AdminCampaignStats = lazy(() => import('./pages/AdminCampaignStats')); const AdminCampaignStats = lazy(() => import('./pages/AdminCampaignStats'));
const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit')); const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit'));
const AdminPartners = lazy(() => import('./pages/AdminPartners'));
const AdminPartnerDetail = lazy(() => import('./pages/AdminPartnerDetail'));
const AdminWithdrawals = lazy(() => import('./pages/AdminWithdrawals'));
const AdminWithdrawalDetail = lazy(() => import('./pages/AdminWithdrawalDetail'));
const AdminUsers = lazy(() => import('./pages/AdminUsers')); const AdminUsers = lazy(() => import('./pages/AdminUsers'));
const AdminPayments = lazy(() => import('./pages/AdminPayments')); const AdminPayments = lazy(() => import('./pages/AdminPayments'));
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods')); const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
@@ -549,6 +553,46 @@ function App() {
</AdminRoute> </AdminRoute>
} }
/> />
<Route
path="/admin/partners"
element={
<AdminRoute>
<LazyPage>
<AdminPartners />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/partners/:userId"
element={
<AdminRoute>
<LazyPage>
<AdminPartnerDetail />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/withdrawals"
element={
<AdminRoute>
<LazyPage>
<AdminWithdrawals />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/withdrawals/:id"
element={
<AdminRoute>
<LazyPage>
<AdminWithdrawalDetail />
</LazyPage>
</AdminRoute>
}
/>
<Route <Route
path="/admin/users" path="/admin/users"
element={ element={

181
src/api/partners.ts Normal file
View File

@@ -0,0 +1,181 @@
import apiClient from './client';
// ==================== User-facing types ====================
export interface PartnerApplicationInfo {
id: number;
status: string;
company_name: string | null;
website_url: string | null;
telegram_channel: string | null;
description: string | null;
expected_monthly_referrals: number | null;
admin_comment: string | null;
approved_commission_percent: number | null;
created_at: string;
processed_at: string | null;
}
export interface PartnerStatusResponse {
partner_status: string;
commission_percent: number | null;
latest_application: PartnerApplicationInfo | null;
}
export interface PartnerApplicationRequest {
company_name?: string;
website_url?: string;
telegram_channel?: string;
description?: string;
expected_monthly_referrals?: number;
}
// ==================== Admin-facing types ====================
export interface AdminPartnerApplicationItem {
id: number;
user_id: number;
username: string | null;
first_name: string | null;
telegram_id: number | null;
company_name: string | null;
website_url: string | null;
telegram_channel: string | null;
description: string | null;
expected_monthly_referrals: number | null;
status: string;
admin_comment: string | null;
approved_commission_percent: number | null;
created_at: string;
processed_at: string | null;
}
export interface AdminPartnerApplicationsResponse {
items: AdminPartnerApplicationItem[];
total: number;
}
export interface AdminPartnerItem {
user_id: number;
username: string | null;
first_name: string | null;
telegram_id: number | null;
commission_percent: number | null;
total_referrals: number;
total_earnings_kopeks: number;
balance_kopeks: number;
partner_status: string;
created_at: string;
}
export interface AdminPartnerListResponse {
items: AdminPartnerItem[];
total: number;
}
export interface AdminPartnerDetailResponse {
user_id: number;
username: string | null;
first_name: string | null;
telegram_id: number | null;
commission_percent: number | null;
partner_status: string;
balance_kopeks: number;
total_referrals: number;
paid_referrals: number;
active_referrals: number;
earnings_all_time: number;
earnings_today: number;
earnings_week: number;
earnings_month: number;
conversion_to_paid: number;
campaigns: { id: number; name: string; start_parameter: string; is_active: boolean }[];
created_at: string;
}
export interface PartnerStats {
total_partners: number;
pending_applications: number;
total_referrals: number;
total_earnings_kopeks: number;
}
export const partnerApi = {
// User endpoints
getStatus: async (): Promise<PartnerStatusResponse> => {
const response = await apiClient.get<PartnerStatusResponse>('/cabinet/referral/partner/status');
return response.data;
},
apply: async (data: PartnerApplicationRequest): Promise<PartnerApplicationInfo> => {
const response = await apiClient.post<PartnerApplicationInfo>(
'/cabinet/referral/partner/apply',
data,
);
return response.data;
},
// Admin endpoints
getStats: async (): Promise<PartnerStats> => {
const response = await apiClient.get<PartnerStats>('/cabinet/admin/partners/stats');
return response.data;
},
getApplications: async (params?: {
status?: string;
offset?: number;
limit?: number;
}): Promise<AdminPartnerApplicationsResponse> => {
const response = await apiClient.get<AdminPartnerApplicationsResponse>(
'/cabinet/admin/partners/applications',
{ params },
);
return response.data;
},
approveApplication: async (
applicationId: number,
data: { commission_percent: number; comment?: string },
): Promise<void> => {
await apiClient.post(`/cabinet/admin/partners/applications/${applicationId}/approve`, data);
},
rejectApplication: async (applicationId: number, data: { comment?: string }): Promise<void> => {
await apiClient.post(`/cabinet/admin/partners/applications/${applicationId}/reject`, data);
},
getPartners: async (params?: {
offset?: number;
limit?: number;
}): Promise<AdminPartnerListResponse> => {
const response = await apiClient.get<AdminPartnerListResponse>('/cabinet/admin/partners', {
params,
});
return response.data;
},
getPartnerDetail: async (userId: number): Promise<AdminPartnerDetailResponse> => {
const response = await apiClient.get<AdminPartnerDetailResponse>(
`/cabinet/admin/partners/${userId}`,
);
return response.data;
},
updateCommission: async (userId: number, commissionPercent: number): Promise<void> => {
await apiClient.patch(`/cabinet/admin/partners/${userId}/commission`, {
commission_percent: commissionPercent,
});
},
revokePartner: async (userId: number): Promise<void> => {
await apiClient.post(`/cabinet/admin/partners/${userId}/revoke`);
},
assignCampaign: async (userId: number, campaignId: number): Promise<void> => {
await apiClient.post(`/cabinet/admin/partners/${userId}/campaigns/${campaignId}/assign`);
},
unassignCampaign: async (userId: number, campaignId: number): Promise<void> => {
await apiClient.post(`/cabinet/admin/partners/${userId}/campaigns/${campaignId}/unassign`);
},
};

152
src/api/withdrawals.ts Normal file
View File

@@ -0,0 +1,152 @@
import apiClient from './client';
// ==================== User-facing types ====================
export interface WithdrawalBalanceResponse {
total_earned: number;
referral_spent: number;
withdrawn: number;
pending: number;
available_referral: number;
available_total: number;
only_referral_mode: boolean;
min_amount_kopeks: number;
is_withdrawal_enabled: boolean;
can_request: boolean;
cannot_request_reason: string | null;
}
export interface WithdrawalCreateRequest {
amount_kopeks: number;
payment_details: string;
}
export interface WithdrawalItem {
id: number;
amount_kopeks: number;
amount_rubles: number;
status: string;
payment_details: string | null;
admin_comment: string | null;
created_at: string;
processed_at: string | null;
}
export interface WithdrawalListResponse {
items: WithdrawalItem[];
total: number;
}
export interface WithdrawalCreateResponse {
id: number;
amount_kopeks: number;
status: string;
}
// ==================== Admin-facing types ====================
export interface AdminWithdrawalItem {
id: number;
user_id: number;
username: string | null;
first_name: string | null;
telegram_id: number | null;
amount_kopeks: number;
amount_rubles: number;
status: string;
risk_score: number;
risk_level: string;
payment_details: string | null;
admin_comment: string | null;
created_at: string;
processed_at: string | null;
}
export interface AdminWithdrawalListResponse {
items: AdminWithdrawalItem[];
total: number;
pending_count: number;
pending_total_kopeks: number;
}
export interface AdminWithdrawalDetailResponse {
id: number;
user_id: number;
username: string | null;
first_name: string | null;
telegram_id: number | null;
amount_kopeks: number;
amount_rubles: number;
status: string;
risk_score: number;
risk_level: string;
risk_analysis: Record<string, unknown> | null;
payment_details: string | null;
admin_comment: string | null;
balance_kopeks: number;
total_referrals: number;
total_earnings_kopeks: number;
created_at: string;
processed_at: string | null;
}
export const withdrawalApi = {
// User endpoints
getBalance: async (): Promise<WithdrawalBalanceResponse> => {
const response = await apiClient.get<WithdrawalBalanceResponse>(
'/cabinet/referral/withdrawal/balance',
);
return response.data;
},
create: async (data: WithdrawalCreateRequest): Promise<WithdrawalCreateResponse> => {
const response = await apiClient.post<WithdrawalCreateResponse>(
'/cabinet/referral/withdrawal/create',
data,
);
return response.data;
},
getHistory: async (): Promise<WithdrawalListResponse> => {
const response = await apiClient.get<WithdrawalListResponse>(
'/cabinet/referral/withdrawal/history',
);
return response.data;
},
cancel: async (requestId: number): Promise<void> => {
await apiClient.post(`/cabinet/referral/withdrawal/${requestId}/cancel`);
},
// Admin endpoints
getAll: async (params?: {
status?: string;
offset?: number;
limit?: number;
}): Promise<AdminWithdrawalListResponse> => {
const response = await apiClient.get<AdminWithdrawalListResponse>(
'/cabinet/admin/withdrawals',
{ params },
);
return response.data;
},
getDetail: async (withdrawalId: number): Promise<AdminWithdrawalDetailResponse> => {
const response = await apiClient.get<AdminWithdrawalDetailResponse>(
`/cabinet/admin/withdrawals/${withdrawalId}`,
);
return response.data;
},
approve: async (withdrawalId: number, comment?: string): Promise<void> => {
await apiClient.post(`/cabinet/admin/withdrawals/${withdrawalId}/approve`, { comment });
},
reject: async (withdrawalId: number, comment?: string): Promise<void> => {
await apiClient.post(`/cabinet/admin/withdrawals/${withdrawalId}/reject`, { comment });
},
complete: async (withdrawalId: number): Promise<void> => {
await apiClient.post(`/cabinet/admin/withdrawals/${withdrawalId}/complete`);
},
};

View File

@@ -652,7 +652,69 @@
"referral_subscription_renewal": "Referral Subscription Renewal", "referral_subscription_renewal": "Referral Subscription Renewal",
"referral_bonus": "Referral Bonus" "referral_bonus": "Referral Bonus"
}, },
"disabled": "Referral program is currently disabled" "disabled": "Referral program is currently disabled",
"partner": {
"becomePartner": "Become a Partner",
"becomePartnerDesc": "Apply for the partner program to get higher commission rates and the ability to withdraw your earnings.",
"applyButton": "Apply",
"applyTitle": "Partner Application",
"applyDesc": "Fill out the form below to apply for our partner program. All fields are optional.",
"underReview": "Application Under Review",
"underReviewDesc": "Your partner application is being reviewed. We'll notify you once a decision is made.",
"submittedAt": "Submitted on {{date}}",
"partnerStatus": "Partner",
"active": "Active",
"commissionInfo": "Your partner commission rate is {{percent}}%",
"rejected": "Application Rejected",
"reapplyButton": "Reapply",
"applying": "Submitting...",
"submitApplication": "Submit Application",
"applyError": "Failed to submit application. Please try again.",
"fields": {
"companyName": "Company Name",
"companyNamePlaceholder": "Your company or brand name",
"telegramChannel": "Telegram Channel",
"telegramChannelPlaceholder": "@yourchannel",
"websiteUrl": "Website URL",
"websiteUrlPlaceholder": "https://example.com",
"description": "Description",
"descriptionPlaceholder": "Tell us about your audience and how you plan to promote...",
"expectedReferrals": "Expected Monthly Referrals",
"expectedReferralsPlaceholder": "Estimated number per month"
}
},
"withdrawal": {
"title": "Withdrawal",
"goToWithdrawal": "Withdraw",
"available": "Available",
"totalEarned": "Total Earned",
"withdrawn": "Withdrawn",
"spent": "Spent on Services",
"pending": "Pending",
"requestButton": "Request Withdrawal",
"minAmount": "Minimum withdrawal amount: {{amount}}",
"history": "Withdrawal History",
"noHistory": "No withdrawal requests yet",
"requestTitle": "Request Withdrawal",
"requestDesc": "Available for withdrawal: {{available}}",
"requesting": "Submitting...",
"submitRequest": "Submit Request",
"requestError": "Failed to create withdrawal request. Please try again.",
"fields": {
"amount": "Amount (kopeks)",
"amountPlaceholder": "Enter amount in kopeks",
"amountHint": "Enter the amount in kopeks (100 kopeks = 1 {{currency}})",
"paymentDetails": "Payment Details",
"paymentDetailsPlaceholder": "Card number, wallet address, or other payment info..."
},
"status": {
"pending": "Pending",
"approved": "Approved",
"completed": "Completed",
"rejected": "Rejected",
"cancelled": "Cancelled"
}
}
}, },
"support": { "support": {
"title": "Support", "title": "Support",
@@ -775,7 +837,9 @@
"promoGroups": "Discount Groups", "promoGroups": "Discount Groups",
"trafficUsage": "Traffic Usage", "trafficUsage": "Traffic Usage",
"updates": "Updates", "updates": "Updates",
"pinnedMessages": "Pinned Messages" "pinnedMessages": "Pinned Messages",
"partners": "Partners",
"withdrawals": "Withdrawals"
}, },
"panel": { "panel": {
"title": "Admin Panel", "title": "Admin Panel",
@@ -800,7 +864,9 @@
"promoGroupsDesc": "Discount groups for users", "promoGroupsDesc": "Discount groups for users",
"trafficUsageDesc": "Per-user traffic by nodes", "trafficUsageDesc": "Per-user traffic by nodes",
"updatesDesc": "Bot & cabinet versions", "updatesDesc": "Bot & cabinet versions",
"pinnedMessagesDesc": "Manage pinned messages for users" "pinnedMessagesDesc": "Manage pinned messages for users",
"partnersDesc": "Manage partner applications and commissions",
"withdrawalsDesc": "Review and process withdrawal requests"
}, },
"trafficUsage": { "trafficUsage": {
"title": "Traffic Usage", "title": "Traffic Usage",
@@ -1721,6 +1787,153 @@
"loadError": "Failed to load campaign", "loadError": "Failed to load campaign",
"updateError": "Failed to save changes" "updateError": "Failed to save changes"
}, },
"withdrawals": {
"title": "Withdrawals",
"subtitle": "Manage withdrawal requests",
"noData": "No withdrawal requests",
"risk": "Risk:",
"overview": {
"pendingCount": "Pending",
"pendingAmount": "Pending amount",
"approvedCount": "Approved",
"completedCount": "Completed"
},
"filter": {
"all": "All",
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"completed": "Completed"
},
"status": {
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"completed": "Completed",
"cancelled": "Cancelled"
},
"detail": {
"title": "Withdrawal",
"loadError": "Failed to load withdrawal details",
"userInfo": "User Information",
"username": "Username",
"telegramId": "Telegram ID",
"balance": "Balance",
"totalReferrals": "Total referrals",
"totalEarnings": "Total earnings",
"createdAt": "Created",
"paymentDetails": "Payment Details",
"noPaymentDetails": "No payment details provided",
"riskAnalysis": "Risk Analysis",
"riskScore": "Risk score",
"riskLevel": {
"low": "Low",
"medium": "Medium",
"high": "High",
"critical": "Critical"
},
"flags": "Flags",
"balanceStats": "Balance Statistics",
"referralDeposits": "Referral Deposits",
"suspiciousReferrals": "Suspicious Referrals",
"earningsByReason": "Earnings by Reason",
"adminComment": "Admin Comment",
"processedAt": "Processed at",
"approve": "Approve",
"approving": "Approving...",
"reject": "Reject",
"rejecting": "Rejecting...",
"complete": "Mark as Completed",
"completing": "Completing...",
"rejectTitle": "Reject withdrawal?",
"rejectDescription": "Optionally add a comment explaining the reason for rejection.",
"commentPlaceholder": "Reason for rejection (optional)...",
"confirmReject": "Confirm Rejection"
}
},
"partners": {
"title": "Partners",
"subtitle": "Partner management and applications",
"totalPartners": "Total partners",
"pendingApplications": "Pending applications",
"totalReferrals": "Total referrals",
"totalEarnings": "Total earnings",
"tabs": {
"partners": "Partners",
"applications": "Applications"
},
"noPartners": "No partners yet",
"noApplications": "No pending applications",
"commission": "{{percent}}% commission",
"referrals": "{{count}} referrals",
"actions": {
"approve": "Approve",
"reject": "Reject"
},
"applicationFields": {
"website": "Website",
"channel": "Channel",
"description": "Description",
"expectedReferrals": "Expected referrals/mo"
},
"approveDialog": {
"title": "Approve Application",
"description": "Approve partner application from {{name}}",
"commissionLabel": "Commission (%)"
},
"rejectDialog": {
"title": "Reject Application",
"description": "Reject partner application from {{name}}",
"commentLabel": "Comment (optional)",
"commentPlaceholder": "Reason for rejection..."
}
},
"partnerDetail": {
"title": "Partner Details",
"loadError": "Failed to load partner details",
"status": {
"approved": "Active",
"pending": "Pending",
"rejected": "Rejected",
"none": "Not a partner"
},
"stats": {
"totalReferrals": "Total referrals",
"paidReferrals": "Paid referrals",
"activeReferrals": "Active referrals",
"conversionRate": "Conversion"
},
"earnings": {
"title": "Earnings",
"allTime": "All time",
"today": "Today",
"week": "This week",
"month": "This month"
},
"commission": {
"title": "Commission",
"update": "Update"
},
"campaigns": {
"title": "Assigned Campaigns",
"noCampaigns": "No campaigns assigned",
"active": "Active",
"inactive": "Inactive"
},
"dangerZone": {
"title": "Danger Zone",
"revokeButton": "Revoke Partner Status"
},
"commissionDialog": {
"title": "Update Commission",
"description": "Set a new commission percentage for this partner.",
"label": "Commission (%)"
},
"revokeDialog": {
"title": "Revoke Partner Status?",
"description": "This will remove partner privileges. The partner will no longer earn commissions from referrals."
}
},
"promocodes": { "promocodes": {
"title": "Promo Codes", "title": "Promo Codes",
"subtitle": "Manage promo codes and discount groups", "subtitle": "Manage promo codes and discount groups",

View File

@@ -543,7 +543,69 @@
"referral_subscription_renewal": "تمدید اشتراک کاربر معرفی‌شده", "referral_subscription_renewal": "تمدید اشتراک کاربر معرفی‌شده",
"referral_bonus": "پاداش کاربر معرفی‌شده" "referral_bonus": "پاداش کاربر معرفی‌شده"
}, },
"disabled": "برنامه معرفی موقتاً غیرفعال است" "disabled": "برنامه معرفی موقتاً غیرفعال است",
"partner": {
"becomePartner": "شریک شوید",
"becomePartnerDesc": "برای دریافت نرخ کمیسیون بالاتر و امکان برداشت درآمد، درخواست شراکت دهید.",
"applyButton": "درخواست",
"applyTitle": "درخواست شراکت",
"applyDesc": "فرم زیر را برای درخواست برنامه شراکت پر کنید. تمام فیلدها اختیاری هستند.",
"underReview": "درخواست در حال بررسی",
"underReviewDesc": "درخواست شراکت شما در حال بررسی است. پس از تصمیم‌گیری به شما اطلاع خواهیم داد.",
"submittedAt": "ارسال شده در {{date}}",
"partnerStatus": "شریک",
"active": "فعال",
"commissionInfo": "نرخ کمیسیون شراکت شما {{percent}}% است",
"rejected": "درخواست رد شد",
"reapplyButton": "درخواست مجدد",
"applying": "در حال ارسال...",
"submitApplication": "ارسال درخواست",
"applyError": "ارسال درخواست ناموفق بود. لطفاً دوباره تلاش کنید.",
"fields": {
"companyName": "نام شرکت",
"companyNamePlaceholder": "نام شرکت یا برند شما",
"telegramChannel": "کانال تلگرام",
"telegramChannelPlaceholder": "@yourchannel",
"websiteUrl": "آدرس وب‌سایت",
"websiteUrlPlaceholder": "https://example.com",
"description": "توضیحات",
"descriptionPlaceholder": "درباره مخاطبان خود و نحوه تبلیغ بگویید...",
"expectedReferrals": "تعداد معرفی ماهانه مورد انتظار",
"expectedReferralsPlaceholder": "تعداد تخمینی در ماه"
}
},
"withdrawal": {
"title": "برداشت",
"goToWithdrawal": "برداشت",
"available": "قابل برداشت",
"totalEarned": "مجموع درآمد",
"withdrawn": "برداشت شده",
"spent": "صرف خدمات شده",
"pending": "در انتظار",
"requestButton": "درخواست برداشت",
"minAmount": "حداقل مبلغ برداشت: {{amount}}",
"history": "تاریخچه برداشت",
"noHistory": "هنوز درخواست برداشتی وجود ندارد",
"requestTitle": "درخواست برداشت",
"requestDesc": "قابل برداشت: {{available}}",
"requesting": "در حال ارسال...",
"submitRequest": "ارسال درخواست",
"requestError": "ایجاد درخواست برداشت ناموفق بود. لطفاً دوباره تلاش کنید.",
"fields": {
"amount": "مبلغ (کوپک)",
"amountPlaceholder": "مبلغ را به کوپک وارد کنید",
"amountHint": "مبلغ را به کوپک وارد کنید (۱۰۰ کوپک = ۱ {{currency}})",
"paymentDetails": "اطلاعات پرداخت",
"paymentDetailsPlaceholder": "شماره کارت، آدرس کیف پول یا سایر اطلاعات پرداخت..."
},
"status": {
"pending": "در انتظار",
"approved": "تأیید شده",
"completed": "تکمیل شده",
"rejected": "رد شده",
"cancelled": "لغو شده"
}
}
}, },
"support": { "support": {
"title": "پشتیبانی", "title": "پشتیبانی",
@@ -656,7 +718,9 @@
"users": "کاربران", "users": "کاربران",
"trafficUsage": "مصرف ترافیک", "trafficUsage": "مصرف ترافیک",
"updates": "به‌روزرسانی‌ها", "updates": "به‌روزرسانی‌ها",
"pinnedMessages": "پیام‌های سنجاق‌شده" "pinnedMessages": "پیام‌های سنجاق‌شده",
"partners": "شرکا",
"withdrawals": "برداشت‌ها"
}, },
"panel": { "panel": {
"title": "پنل مدیریت", "title": "پنل مدیریت",
@@ -681,7 +745,9 @@
"usersDesc": "مدیریت کاربران ربات", "usersDesc": "مدیریت کاربران ربات",
"trafficUsageDesc": "ترافیک هر کاربر بر اساس نود", "trafficUsageDesc": "ترافیک هر کاربر بر اساس نود",
"updatesDesc": "نسخه‌های ربات و کابینت", "updatesDesc": "نسخه‌های ربات و کابینت",
"pinnedMessagesDesc": "مدیریت پیام‌های سنجاق‌شده" "pinnedMessagesDesc": "مدیریت پیام‌های سنجاق‌شده",
"partnersDesc": "مدیریت درخواست‌های شراکت و کمیسیون‌ها",
"withdrawalsDesc": "بررسی و پردازش درخواست‌های برداشت"
}, },
"trafficUsage": { "trafficUsage": {
"title": "مصرف ترافیک", "title": "مصرف ترافیک",
@@ -1458,6 +1524,153 @@
"loadError": "بارگذاری کمپین ناموفق بود", "loadError": "بارگذاری کمپین ناموفق بود",
"updateError": "ذخیره تغییرات ناموفق بود" "updateError": "ذخیره تغییرات ناموفق بود"
}, },
"withdrawals": {
"title": "برداشت‌ها",
"subtitle": "مدیریت درخواست‌های برداشت",
"noData": "درخواست برداشتی وجود ندارد",
"risk": "ریسک:",
"overview": {
"pendingCount": "در انتظار",
"pendingAmount": "مبلغ در انتظار",
"approvedCount": "تأیید شده",
"completedCount": "تکمیل شده"
},
"filter": {
"all": "همه",
"pending": "در انتظار",
"approved": "تأیید شده",
"rejected": "رد شده",
"completed": "تکمیل شده"
},
"status": {
"pending": "در انتظار",
"approved": "تأیید شده",
"rejected": "رد شده",
"completed": "تکمیل شده",
"cancelled": "لغو شده"
},
"detail": {
"title": "جزئیات برداشت",
"loadError": "بارگذاری جزئیات برداشت ناموفق بود",
"userInfo": "اطلاعات کاربر",
"username": "نام کاربری",
"telegramId": "شناسه تلگرام",
"balance": "موجودی",
"totalReferrals": "مجموع معرفی‌ها",
"totalEarnings": "مجموع درآمد",
"createdAt": "تاریخ ایجاد",
"paymentDetails": "اطلاعات پرداخت",
"noPaymentDetails": "اطلاعات پرداختی ارائه نشده",
"riskAnalysis": "تحلیل ریسک",
"riskScore": "امتیاز ریسک",
"riskLevel": {
"low": "کم",
"medium": "متوسط",
"high": "زیاد",
"critical": "بحرانی"
},
"flags": "پرچم‌ها",
"balanceStats": "آمار موجودی",
"referralDeposits": "واریزهای معرفی",
"suspiciousReferrals": "معرفی‌های مشکوک",
"earningsByReason": "درآمد بر اساس دلیل",
"adminComment": "نظر مدیر",
"processedAt": "زمان پردازش",
"approve": "تأیید",
"approving": "در حال تأیید...",
"reject": "رد",
"rejecting": "در حال رد...",
"complete": "تکمیل شده علامت بزنید",
"completing": "در حال تکمیل...",
"rejectTitle": "رد درخواست برداشت؟",
"rejectDescription": "در صورت تمایل دلیل رد را بنویسید.",
"commentPlaceholder": "دلیل رد (اختیاری)...",
"confirmReject": "تأیید رد"
}
},
"partners": {
"title": "شرکا",
"subtitle": "مدیریت شرکا و درخواست‌ها",
"totalPartners": "مجموع شرکا",
"pendingApplications": "درخواست‌های در انتظار",
"totalReferrals": "مجموع معرفی‌ها",
"totalEarnings": "مجموع درآمد",
"tabs": {
"partners": "شرکا",
"applications": "درخواست‌ها"
},
"noPartners": "هنوز شریکی وجود ندارد",
"noApplications": "درخواست در انتظاری وجود ندارد",
"commission": "{{percent}}% کمیسیون",
"referrals": "{{count}} معرفی",
"actions": {
"approve": "تأیید",
"reject": "رد"
},
"applicationFields": {
"website": "وب‌سایت",
"channel": "کانال",
"description": "توضیحات",
"expectedReferrals": "معرفی مورد انتظار/ماه"
},
"approveDialog": {
"title": "تأیید درخواست",
"description": "تأیید درخواست شراکت از {{name}}",
"commissionLabel": "کمیسیون (%)"
},
"rejectDialog": {
"title": "رد درخواست",
"description": "رد درخواست شراکت از {{name}}",
"commentLabel": "نظر (اختیاری)",
"commentPlaceholder": "دلیل رد..."
}
},
"partnerDetail": {
"title": "جزئیات شریک",
"loadError": "بارگذاری جزئیات شریک ناموفق بود",
"status": {
"approved": "فعال",
"pending": "در انتظار بررسی",
"rejected": "رد شده",
"none": "شریک نیست"
},
"stats": {
"totalReferrals": "مجموع معرفی‌ها",
"paidReferrals": "معرفی‌های پرداختی",
"activeReferrals": "معرفی‌های فعال",
"conversionRate": "نرخ تبدیل"
},
"earnings": {
"title": "درآمد",
"allTime": "کل",
"today": "امروز",
"week": "این هفته",
"month": "این ماه"
},
"commission": {
"title": "کمیسیون",
"update": "به‌روزرسانی"
},
"campaigns": {
"title": "کمپین‌های اختصاص‌یافته",
"noCampaigns": "کمپینی اختصاص نیافته",
"active": "فعال",
"inactive": "غیرفعال"
},
"dangerZone": {
"title": "منطقه خطر",
"revokeButton": "لغو وضعیت شراکت"
},
"commissionDialog": {
"title": "به‌روزرسانی کمیسیون",
"description": "درصد کمیسیون جدیدی برای این شریک تعیین کنید.",
"label": "کمیسیون (%)"
},
"revokeDialog": {
"title": "لغو وضعیت شراکت؟",
"description": "این کار امتیازات شراکت را حذف می‌کند. شریک دیگر از معرفی‌ها کمیسیون دریافت نخواهد کرد."
}
},
"promocodes": { "promocodes": {
"title": "کدهای تخفیف", "title": "کدهای تخفیف",
"subtitle": "مدیریت کدهای تخفیف و گروه‌های تخفیف", "subtitle": "مدیریت کدهای تخفیف و گروه‌های تخفیف",

View File

@@ -679,7 +679,69 @@
"referral_subscription_renewal": "Продление подписки реферала", "referral_subscription_renewal": "Продление подписки реферала",
"referral_bonus": "Бонус за реферала" "referral_bonus": "Бонус за реферала"
}, },
"disabled": "Реферальная программа временно отключена" "disabled": "Реферальная программа временно отключена",
"partner": {
"becomePartner": "Стать партнером",
"becomePartnerDesc": "Подайте заявку на партнерскую программу, чтобы получить повышенную комиссию и возможность вывода заработка.",
"applyButton": "Подать заявку",
"applyTitle": "Заявка на партнерство",
"applyDesc": "Заполните форму ниже для подачи заявки на партнерскую программу. Все поля необязательные.",
"underReview": "Заявка на рассмотрении",
"underReviewDesc": "Ваша заявка на партнерство рассматривается. Мы уведомим вас, когда будет принято решение.",
"submittedAt": "Подана {{date}}",
"partnerStatus": "Партнер",
"active": "Активен",
"commissionInfo": "Ваша партнерская комиссия составляет {{percent}}%",
"rejected": "Заявка отклонена",
"reapplyButton": "Подать заново",
"applying": "Отправка...",
"submitApplication": "Отправить заявку",
"applyError": "Не удалось отправить заявку. Попробуйте снова.",
"fields": {
"companyName": "Название компании",
"companyNamePlaceholder": "Название вашей компании или бренда",
"telegramChannel": "Telegram-канал",
"telegramChannelPlaceholder": "@вашканал",
"websiteUrl": "Сайт",
"websiteUrlPlaceholder": "https://example.com",
"description": "Описание",
"descriptionPlaceholder": "Расскажите о вашей аудитории и планах продвижения...",
"expectedReferrals": "Ожидаемое количество рефералов в месяц",
"expectedReferralsPlaceholder": "Примерное количество в месяц"
}
},
"withdrawal": {
"title": "Вывод средств",
"goToWithdrawal": "Вывести",
"available": "Доступно",
"totalEarned": "Всего заработано",
"withdrawn": "Выведено",
"spent": "Потрачено на услуги",
"pending": "В обработке",
"requestButton": "Запросить вывод",
"minAmount": "Минимальная сумма вывода: {{amount}}",
"history": "История выводов",
"noHistory": "Запросов на вывод пока нет",
"requestTitle": "Запрос на вывод",
"requestDesc": "Доступно для вывода: {{available}}",
"requesting": "Отправка...",
"submitRequest": "Отправить запрос",
"requestError": "Не удалось создать запрос на вывод. Попробуйте снова.",
"fields": {
"amount": "Сумма (копейки)",
"amountPlaceholder": "Введите сумму в копейках",
"amountHint": "Введите сумму в копейках (100 копеек = 1 {{currency}})",
"paymentDetails": "Реквизиты для оплаты",
"paymentDetailsPlaceholder": "Номер карты, адрес кошелька или другие реквизиты..."
},
"status": {
"pending": "В обработке",
"approved": "Одобрен",
"completed": "Завершен",
"rejected": "Отклонен",
"cancelled": "Отменен"
}
}
}, },
"support": { "support": {
"title": "Поддержка", "title": "Поддержка",
@@ -796,7 +858,9 @@
"promoGroups": "Группы скидок", "promoGroups": "Группы скидок",
"trafficUsage": "Расход трафика", "trafficUsage": "Расход трафика",
"updates": "Обновления", "updates": "Обновления",
"pinnedMessages": "Закреплённые" "pinnedMessages": "Закреплённые",
"partners": "Партнёры",
"withdrawals": "Выводы"
}, },
"panel": { "panel": {
"title": "Панель администратора", "title": "Панель администратора",
@@ -821,7 +885,9 @@
"promoGroupsDesc": "Группы скидок для пользователей", "promoGroupsDesc": "Группы скидок для пользователей",
"trafficUsageDesc": "Трафик пользователей по нодам", "trafficUsageDesc": "Трафик пользователей по нодам",
"updatesDesc": "Версии бота и кабинета", "updatesDesc": "Версии бота и кабинета",
"pinnedMessagesDesc": "Управление закреплёнными сообщениями" "pinnedMessagesDesc": "Управление закреплёнными сообщениями",
"partnersDesc": "Управление заявками партнёров и комиссиями",
"withdrawalsDesc": "Проверка и обработка заявок на вывод"
}, },
"trafficUsage": { "trafficUsage": {
"title": "Расход трафика", "title": "Расход трафика",
@@ -2243,6 +2309,153 @@
"loadError": "Не удалось загрузить кампанию", "loadError": "Не удалось загрузить кампанию",
"updateError": "Не удалось сохранить изменения" "updateError": "Не удалось сохранить изменения"
}, },
"withdrawals": {
"title": "Выводы средств",
"subtitle": "Управление заявками на вывод",
"noData": "Нет заявок на вывод",
"risk": "Риск:",
"overview": {
"pendingCount": "Ожидают",
"pendingAmount": "Сумма ожидания",
"approvedCount": "Одобрено",
"completedCount": "Выполнено"
},
"filter": {
"all": "Все",
"pending": "Ожидание",
"approved": "Одобрено",
"rejected": "Отклонено",
"completed": "Выполнено"
},
"status": {
"pending": "Ожидание",
"approved": "Одобрено",
"rejected": "Отклонено",
"completed": "Выполнено",
"cancelled": "Отменено"
},
"detail": {
"title": "Заявка на вывод",
"loadError": "Не удалось загрузить детали заявки",
"userInfo": "Информация о пользователе",
"username": "Имя пользователя",
"telegramId": "Telegram ID",
"balance": "Баланс",
"totalReferrals": "Всего рефералов",
"totalEarnings": "Общий заработок",
"createdAt": "Создано",
"paymentDetails": "Реквизиты для оплаты",
"noPaymentDetails": "Реквизиты не указаны",
"riskAnalysis": "Анализ рисков",
"riskScore": "Оценка риска",
"riskLevel": {
"low": "Низкий",
"medium": "Средний",
"high": "Высокий",
"critical": "Критический"
},
"flags": "Флаги",
"balanceStats": "Статистика баланса",
"referralDeposits": "Депозиты рефералов",
"suspiciousReferrals": "Подозрительные рефералы",
"earningsByReason": "Доходы по причинам",
"adminComment": "Комментарий администратора",
"processedAt": "Обработано",
"approve": "Одобрить",
"approving": "Одобрение...",
"reject": "Отклонить",
"rejecting": "Отклонение...",
"complete": "Отметить как выполнено",
"completing": "Завершение...",
"rejectTitle": "Отклонить заявку?",
"rejectDescription": "При необходимости добавьте комментарий с причиной отклонения.",
"commentPlaceholder": "Причина отклонения (необязательно)...",
"confirmReject": "Подтвердить отклонение"
}
},
"partners": {
"title": "Партнёры",
"subtitle": "Управление партнёрами и заявками",
"totalPartners": "Всего партнёров",
"pendingApplications": "Заявок на рассмотрении",
"totalReferrals": "Всего рефералов",
"totalEarnings": "Всего заработано",
"tabs": {
"partners": "Партнёры",
"applications": "Заявки"
},
"noPartners": "Партнёров пока нет",
"noApplications": "Нет заявок на рассмотрении",
"commission": "{{percent}}% комиссия",
"referrals": "{{count}} рефералов",
"actions": {
"approve": "Одобрить",
"reject": "Отклонить"
},
"applicationFields": {
"website": "Сайт",
"channel": "Канал",
"description": "Описание",
"expectedReferrals": "Ожидаемых рефералов/мес"
},
"approveDialog": {
"title": "Одобрить заявку",
"description": "Одобрить партнёрскую заявку от {{name}}",
"commissionLabel": "Комиссия (%)"
},
"rejectDialog": {
"title": "Отклонить заявку",
"description": "Отклонить партнёрскую заявку от {{name}}",
"commentLabel": "Комментарий (необязательно)",
"commentPlaceholder": "Причина отклонения..."
}
},
"partnerDetail": {
"title": "Детали партнёра",
"loadError": "Не удалось загрузить данные партнёра",
"status": {
"approved": "Активен",
"pending": "На рассмотрении",
"rejected": "Отклонён",
"none": "Не партнёр"
},
"stats": {
"totalReferrals": "Всего рефералов",
"paidReferrals": "Оплативших",
"activeReferrals": "Активных",
"conversionRate": "Конверсия"
},
"earnings": {
"title": "Заработок",
"allTime": "За всё время",
"today": "Сегодня",
"week": "За неделю",
"month": "За месяц"
},
"commission": {
"title": "Комиссия",
"update": "Изменить"
},
"campaigns": {
"title": "Привязанные кампании",
"noCampaigns": "Нет привязанных кампаний",
"active": "Активна",
"inactive": "Неактивна"
},
"dangerZone": {
"title": "Опасная зона",
"revokeButton": "Отозвать партнёрский статус"
},
"commissionDialog": {
"title": "Изменить комиссию",
"description": "Установите новый процент комиссии для этого партнёра.",
"label": "Комиссия (%)"
},
"revokeDialog": {
"title": "Отозвать статус партнёра?",
"description": "Это лишит партнёра привилегий. Партнёр больше не будет получать комиссию от рефералов."
}
},
"promocodes": { "promocodes": {
"title": "Промокоды", "title": "Промокоды",
"subtitle": "Управление промокодами и группами скидок", "subtitle": "Управление промокодами и группами скидок",

View File

@@ -543,7 +543,69 @@
"referral_subscription_renewal": "推荐用户续订", "referral_subscription_renewal": "推荐用户续订",
"referral_bonus": "推荐奖金" "referral_bonus": "推荐奖金"
}, },
"disabled": "推荐计划暂时停用" "disabled": "推荐计划暂时停用",
"partner": {
"becomePartner": "成为合作伙伴",
"becomePartnerDesc": "申请加入合作伙伴计划,获得更高的佣金率并可以提现您的收益。",
"applyButton": "申请",
"applyTitle": "合作伙伴申请",
"applyDesc": "填写以下表格申请我们的合作伙伴计划。所有字段均为可选。",
"underReview": "申请审核中",
"underReviewDesc": "您的合作伙伴申请正在审核中。我们会在做出决定后通知您。",
"submittedAt": "提交于 {{date}}",
"partnerStatus": "合作伙伴",
"active": "活跃",
"commissionInfo": "您的合作伙伴佣金率为 {{percent}}%",
"rejected": "申请被拒绝",
"reapplyButton": "重新申请",
"applying": "提交中...",
"submitApplication": "提交申请",
"applyError": "提交申请失败,请重试。",
"fields": {
"companyName": "公司名称",
"companyNamePlaceholder": "您的公司或品牌名称",
"telegramChannel": "Telegram频道",
"telegramChannelPlaceholder": "@yourchannel",
"websiteUrl": "网站地址",
"websiteUrlPlaceholder": "https://example.com",
"description": "描述",
"descriptionPlaceholder": "请介绍您的受众以及推广计划...",
"expectedReferrals": "预计每月推荐数",
"expectedReferralsPlaceholder": "每月预估数量"
}
},
"withdrawal": {
"title": "提现",
"goToWithdrawal": "提现",
"available": "可提现",
"totalEarned": "总收益",
"withdrawn": "已提现",
"spent": "已用于服务",
"pending": "待处理",
"requestButton": "申请提现",
"minAmount": "最低提现金额:{{amount}}",
"history": "提现记录",
"noHistory": "暂无提现记录",
"requestTitle": "申请提现",
"requestDesc": "可提现金额:{{available}}",
"requesting": "提交中...",
"submitRequest": "提交请求",
"requestError": "创建提现请求失败,请重试。",
"fields": {
"amount": "金额(戈比)",
"amountPlaceholder": "输入金额(戈比)",
"amountHint": "请输入戈比金额100戈比 = 1 {{currency}}",
"paymentDetails": "付款信息",
"paymentDetailsPlaceholder": "银行卡号、钱包地址或其他付款信息..."
},
"status": {
"pending": "待处理",
"approved": "已批准",
"completed": "已完成",
"rejected": "已拒绝",
"cancelled": "已取消"
}
}
}, },
"support": { "support": {
"title": "支持", "title": "支持",
@@ -656,7 +718,9 @@
"users": "用户", "users": "用户",
"trafficUsage": "流量使用", "trafficUsage": "流量使用",
"updates": "更新", "updates": "更新",
"pinnedMessages": "置顶消息" "pinnedMessages": "置顶消息",
"partners": "合作伙伴",
"withdrawals": "提现"
}, },
"panel": { "panel": {
"title": "管理面板", "title": "管理面板",
@@ -681,7 +745,9 @@
"usersDesc": "管理机器人用户", "usersDesc": "管理机器人用户",
"trafficUsageDesc": "按节点统计用户流量", "trafficUsageDesc": "按节点统计用户流量",
"updatesDesc": "机器人和面板版本", "updatesDesc": "机器人和面板版本",
"pinnedMessagesDesc": "管理用户置顶消息" "pinnedMessagesDesc": "管理用户置顶消息",
"partnersDesc": "管理合作伙伴申请和佣金",
"withdrawalsDesc": "审核和处理提现请求"
}, },
"trafficUsage": { "trafficUsage": {
"title": "流量使用", "title": "流量使用",
@@ -1457,6 +1523,153 @@
"loadError": "加载活动失败", "loadError": "加载活动失败",
"updateError": "保存更改失败" "updateError": "保存更改失败"
}, },
"withdrawals": {
"title": "提现",
"subtitle": "管理提现请求",
"noData": "暂无提现请求",
"risk": "风险:",
"overview": {
"pendingCount": "待处理",
"pendingAmount": "待处理金额",
"approvedCount": "已批准",
"completedCount": "已完成"
},
"filter": {
"all": "全部",
"pending": "待处理",
"approved": "已批准",
"rejected": "已拒绝",
"completed": "已完成"
},
"status": {
"pending": "待处理",
"approved": "已批准",
"rejected": "已拒绝",
"completed": "已完成",
"cancelled": "已取消"
},
"detail": {
"title": "提现详情",
"loadError": "加载提现详情失败",
"userInfo": "用户信息",
"username": "用户名",
"telegramId": "Telegram ID",
"balance": "余额",
"totalReferrals": "总推荐数",
"totalEarnings": "总收益",
"createdAt": "创建时间",
"paymentDetails": "付款信息",
"noPaymentDetails": "未提供付款信息",
"riskAnalysis": "风险分析",
"riskScore": "风险评分",
"riskLevel": {
"low": "低",
"medium": "中",
"high": "高",
"critical": "严重"
},
"flags": "标记",
"balanceStats": "余额统计",
"referralDeposits": "推荐充值",
"suspiciousReferrals": "可疑推荐",
"earningsByReason": "按原因分类收益",
"adminComment": "管理员备注",
"processedAt": "处理时间",
"approve": "批准",
"approving": "批准中...",
"reject": "拒绝",
"rejecting": "拒绝中...",
"complete": "标记为已完成",
"completing": "完成中...",
"rejectTitle": "拒绝提现?",
"rejectDescription": "可选择添加备注说明拒绝原因。",
"commentPlaceholder": "拒绝原因(可选)...",
"confirmReject": "确认拒绝"
}
},
"partners": {
"title": "合作伙伴",
"subtitle": "合作伙伴管理和申请",
"totalPartners": "总合作伙伴数",
"pendingApplications": "待审核申请",
"totalReferrals": "总推荐数",
"totalEarnings": "总收益",
"tabs": {
"partners": "合作伙伴",
"applications": "申请"
},
"noPartners": "暂无合作伙伴",
"noApplications": "暂无待审核申请",
"commission": "{{percent}}% 佣金",
"referrals": "{{count}} 个推荐",
"actions": {
"approve": "批准",
"reject": "拒绝"
},
"applicationFields": {
"website": "网站",
"channel": "频道",
"description": "描述",
"expectedReferrals": "预计每月推荐数"
},
"approveDialog": {
"title": "批准申请",
"description": "批准来自 {{name}} 的合作伙伴申请",
"commissionLabel": "佣金(%"
},
"rejectDialog": {
"title": "拒绝申请",
"description": "拒绝来自 {{name}} 的合作伙伴申请",
"commentLabel": "备注(可选)",
"commentPlaceholder": "拒绝原因..."
}
},
"partnerDetail": {
"title": "合作伙伴详情",
"loadError": "加载合作伙伴详情失败",
"status": {
"approved": "活跃",
"pending": "审核中",
"rejected": "已拒绝",
"none": "非合作伙伴"
},
"stats": {
"totalReferrals": "总推荐数",
"paidReferrals": "已付费推荐",
"activeReferrals": "活跃推荐",
"conversionRate": "转化率"
},
"earnings": {
"title": "收益",
"allTime": "总计",
"today": "今日",
"week": "本周",
"month": "本月"
},
"commission": {
"title": "佣金",
"update": "更新"
},
"campaigns": {
"title": "分配的活动",
"noCampaigns": "暂无分配的活动",
"active": "活跃",
"inactive": "未激活"
},
"dangerZone": {
"title": "危险操作",
"revokeButton": "撤销合作伙伴身份"
},
"commissionDialog": {
"title": "更新佣金",
"description": "为该合作伙伴设置新的佣金百分比。",
"label": "佣金(%"
},
"revokeDialog": {
"title": "撤销合作伙伴身份?",
"description": "这将移除合作伙伴权限。该合作伙伴将不再从推荐中获得佣金。"
}
},
"promocodes": { "promocodes": {
"title": "促销码", "title": "促销码",
"subtitle": "管理促销码和折扣组", "subtitle": "管理促销码和折扣组",

View File

@@ -186,6 +186,16 @@ const SparklesIcon = () => (
</svg> </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 = () => ( const CogIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path <path
@@ -449,6 +459,18 @@ export default function AdminPanel() {
title: t('admin.nav.wheel'), title: t('admin.nav.wheel'),
description: t('admin.panel.wheelDesc'), 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'),
},
], ],
}, },
{ {

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,9 +1,20 @@
import { useState } from 'react'; import { useState, type FormEvent } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { referralApi } from '../api/referral'; import { referralApi } from '../api/referral';
import { brandingApi } from '../api/branding'; import { brandingApi } from '../api/branding';
import { partnerApi, type PartnerApplicationRequest } from '../api/partners';
import { withdrawalApi } from '../api/withdrawals';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '../components/primitives/Dialog/Dialog';
const CopyIcon = () => ( const CopyIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -28,10 +39,75 @@ const ShareIcon = () => (
</svg> </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() { export default function Referral() {
const { t } = useTranslation(); const { t } = useTranslation();
const { formatAmount, currencySymbol, formatPositive } = useCurrency(); const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
const queryClient = useQueryClient();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showApplyDialog, setShowApplyDialog] = useState(false);
const [showWithdrawDialog, setShowWithdrawDialog] = useState(false);
const [showReapply, setShowReapply] = useState(false);
// Partner application form state
const [applyForm, setApplyForm] = useState<PartnerApplicationRequest>({
company_name: '',
website_url: '',
telegram_channel: '',
description: '',
expected_monthly_referrals: undefined,
});
// Withdrawal form state
const [withdrawForm, setWithdrawForm] = useState({
amount_kopeks: 0,
payment_details: '',
});
const { data: info, isLoading } = useQuery({ const { data: info, isLoading } = useQuery({
queryKey: ['referral-info'], queryKey: ['referral-info'],
@@ -64,6 +140,63 @@ export default function Referral() {
staleTime: 60000, 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 = () => { const copyLink = () => {
if (referralLink) { if (referralLink) {
navigator.clipboard.writeText(referralLink); navigator.clipboard.writeText(referralLink);
@@ -98,6 +231,29 @@ export default function Referral() {
window.open(telegramUrl, '_blank', 'noopener,noreferrer'); window.open(telegramUrl, '_blank', 'noopener,noreferrer');
}; };
const handleApplySubmit = (e: FormEvent) => {
e.preventDefault();
const payload: PartnerApplicationRequest = {};
if (applyForm.company_name) payload.company_name = applyForm.company_name;
if (applyForm.website_url) payload.website_url = applyForm.website_url;
if (applyForm.telegram_channel) payload.telegram_channel = applyForm.telegram_channel;
if (applyForm.description) payload.description = applyForm.description;
if (applyForm.expected_monthly_referrals) {
payload.expected_monthly_referrals = applyForm.expected_monthly_referrals;
}
applyMutation.mutate(payload);
};
const handleWithdrawSubmit = (e: FormEvent) => {
e.preventDefault();
if (withdrawForm.payment_details.length < 5) return;
if (withdrawForm.amount_kopeks <= 0) return;
withdrawMutation.mutate({
amount_kopeks: withdrawForm.amount_kopeks,
payment_details: withdrawForm.payment_details,
});
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex min-h-64 items-center justify-center"> <div className="flex min-h-64 items-center justify-center">
@@ -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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('referral.title')}</h1> <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>
</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">
&bull;{' '}
{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> </div>
); );
} }