From 779fbf0dc61b5963e2ac48162b02a292155457a5 Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 17 Feb 2026 09:51:48 +0300 Subject: [PATCH 01/19] 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 --- src/App.tsx | 44 ++ src/api/partners.ts | 181 +++++++++ src/api/withdrawals.ts | 152 +++++++ src/locales/en.json | 219 +++++++++- src/locales/fa.json | 219 +++++++++- src/locales/ru.json | 219 +++++++++- src/locales/zh.json | 219 +++++++++- src/pages/AdminPanel.tsx | 22 + src/pages/AdminPartnerDetail.tsx | 354 ++++++++++++++++ src/pages/AdminPartners.tsx | 385 ++++++++++++++++++ src/pages/AdminWithdrawalDetail.tsx | 493 +++++++++++++++++++++++ src/pages/AdminWithdrawals.tsx | 226 +++++++++++ src/pages/Referral.tsx | 604 +++++++++++++++++++++++++++- 13 files changed, 3322 insertions(+), 15 deletions(-) create mode 100644 src/api/partners.ts create mode 100644 src/api/withdrawals.ts create mode 100644 src/pages/AdminPartnerDetail.tsx create mode 100644 src/pages/AdminPartners.tsx create mode 100644 src/pages/AdminWithdrawalDetail.tsx create mode 100644 src/pages/AdminWithdrawals.tsx diff --git a/src/App.tsx b/src/App.tsx index 31eeb9f..bfd25ff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,6 +59,10 @@ const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns')); const AdminCampaignCreate = lazy(() => import('./pages/AdminCampaignCreate')); const AdminCampaignStats = lazy(() => import('./pages/AdminCampaignStats')); const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit')); +const AdminPartners = lazy(() => import('./pages/AdminPartners')); +const AdminPartnerDetail = lazy(() => import('./pages/AdminPartnerDetail')); +const AdminWithdrawals = lazy(() => import('./pages/AdminWithdrawals')); +const AdminWithdrawalDetail = lazy(() => import('./pages/AdminWithdrawalDetail')); const AdminUsers = lazy(() => import('./pages/AdminUsers')); const AdminPayments = lazy(() => import('./pages/AdminPayments')); const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods')); @@ -549,6 +553,46 @@ function App() { } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> => { + const response = await apiClient.get('/cabinet/referral/partner/status'); + return response.data; + }, + + apply: async (data: PartnerApplicationRequest): Promise => { + const response = await apiClient.post( + '/cabinet/referral/partner/apply', + data, + ); + return response.data; + }, + + // Admin endpoints + getStats: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/partners/stats'); + return response.data; + }, + + getApplications: async (params?: { + status?: string; + offset?: number; + limit?: number; + }): Promise => { + const response = await apiClient.get( + '/cabinet/admin/partners/applications', + { params }, + ); + return response.data; + }, + + approveApplication: async ( + applicationId: number, + data: { commission_percent: number; comment?: string }, + ): Promise => { + await apiClient.post(`/cabinet/admin/partners/applications/${applicationId}/approve`, data); + }, + + rejectApplication: async (applicationId: number, data: { comment?: string }): Promise => { + await apiClient.post(`/cabinet/admin/partners/applications/${applicationId}/reject`, data); + }, + + getPartners: async (params?: { + offset?: number; + limit?: number; + }): Promise => { + const response = await apiClient.get('/cabinet/admin/partners', { + params, + }); + return response.data; + }, + + getPartnerDetail: async (userId: number): Promise => { + const response = await apiClient.get( + `/cabinet/admin/partners/${userId}`, + ); + return response.data; + }, + + updateCommission: async (userId: number, commissionPercent: number): Promise => { + await apiClient.patch(`/cabinet/admin/partners/${userId}/commission`, { + commission_percent: commissionPercent, + }); + }, + + revokePartner: async (userId: number): Promise => { + await apiClient.post(`/cabinet/admin/partners/${userId}/revoke`); + }, + + assignCampaign: async (userId: number, campaignId: number): Promise => { + await apiClient.post(`/cabinet/admin/partners/${userId}/campaigns/${campaignId}/assign`); + }, + + unassignCampaign: async (userId: number, campaignId: number): Promise => { + await apiClient.post(`/cabinet/admin/partners/${userId}/campaigns/${campaignId}/unassign`); + }, +}; diff --git a/src/api/withdrawals.ts b/src/api/withdrawals.ts new file mode 100644 index 0000000..6e0dfe8 --- /dev/null +++ b/src/api/withdrawals.ts @@ -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 | 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 => { + const response = await apiClient.get( + '/cabinet/referral/withdrawal/balance', + ); + return response.data; + }, + + create: async (data: WithdrawalCreateRequest): Promise => { + const response = await apiClient.post( + '/cabinet/referral/withdrawal/create', + data, + ); + return response.data; + }, + + getHistory: async (): Promise => { + const response = await apiClient.get( + '/cabinet/referral/withdrawal/history', + ); + return response.data; + }, + + cancel: async (requestId: number): Promise => { + await apiClient.post(`/cabinet/referral/withdrawal/${requestId}/cancel`); + }, + + // Admin endpoints + getAll: async (params?: { + status?: string; + offset?: number; + limit?: number; + }): Promise => { + const response = await apiClient.get( + '/cabinet/admin/withdrawals', + { params }, + ); + return response.data; + }, + + getDetail: async (withdrawalId: number): Promise => { + const response = await apiClient.get( + `/cabinet/admin/withdrawals/${withdrawalId}`, + ); + return response.data; + }, + + approve: async (withdrawalId: number, comment?: string): Promise => { + await apiClient.post(`/cabinet/admin/withdrawals/${withdrawalId}/approve`, { comment }); + }, + + reject: async (withdrawalId: number, comment?: string): Promise => { + await apiClient.post(`/cabinet/admin/withdrawals/${withdrawalId}/reject`, { comment }); + }, + + complete: async (withdrawalId: number): Promise => { + await apiClient.post(`/cabinet/admin/withdrawals/${withdrawalId}/complete`); + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index a0cd87a..500f70c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -652,7 +652,69 @@ "referral_subscription_renewal": "Referral Subscription Renewal", "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": { "title": "Support", @@ -775,7 +837,9 @@ "promoGroups": "Discount Groups", "trafficUsage": "Traffic Usage", "updates": "Updates", - "pinnedMessages": "Pinned Messages" + "pinnedMessages": "Pinned Messages", + "partners": "Partners", + "withdrawals": "Withdrawals" }, "panel": { "title": "Admin Panel", @@ -800,7 +864,9 @@ "promoGroupsDesc": "Discount groups for users", "trafficUsageDesc": "Per-user traffic by nodes", "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": { "title": "Traffic Usage", @@ -1721,6 +1787,153 @@ "loadError": "Failed to load campaign", "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": { "title": "Promo Codes", "subtitle": "Manage promo codes and discount groups", diff --git a/src/locales/fa.json b/src/locales/fa.json index b2a0e69..e190c7b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -543,7 +543,69 @@ "referral_subscription_renewal": "تمدید اشتراک کاربر معرفی‌شده", "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": { "title": "پشتیبانی", @@ -656,7 +718,9 @@ "users": "کاربران", "trafficUsage": "مصرف ترافیک", "updates": "به‌روزرسانی‌ها", - "pinnedMessages": "پیام‌های سنجاق‌شده" + "pinnedMessages": "پیام‌های سنجاق‌شده", + "partners": "شرکا", + "withdrawals": "برداشت‌ها" }, "panel": { "title": "پنل مدیریت", @@ -681,7 +745,9 @@ "usersDesc": "مدیریت کاربران ربات", "trafficUsageDesc": "ترافیک هر کاربر بر اساس نود", "updatesDesc": "نسخه‌های ربات و کابینت", - "pinnedMessagesDesc": "مدیریت پیام‌های سنجاق‌شده" + "pinnedMessagesDesc": "مدیریت پیام‌های سنجاق‌شده", + "partnersDesc": "مدیریت درخواست‌های شراکت و کمیسیون‌ها", + "withdrawalsDesc": "بررسی و پردازش درخواست‌های برداشت" }, "trafficUsage": { "title": "مصرف ترافیک", @@ -1458,6 +1524,153 @@ "loadError": "بارگذاری کمپین ناموفق بود", "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": { "title": "کدهای تخفیف", "subtitle": "مدیریت کدهای تخفیف و گروه‌های تخفیف", diff --git a/src/locales/ru.json b/src/locales/ru.json index 20cc607..dd048b5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -679,7 +679,69 @@ "referral_subscription_renewal": "Продление подписки реферала", "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": { "title": "Поддержка", @@ -796,7 +858,9 @@ "promoGroups": "Группы скидок", "trafficUsage": "Расход трафика", "updates": "Обновления", - "pinnedMessages": "Закреплённые" + "pinnedMessages": "Закреплённые", + "partners": "Партнёры", + "withdrawals": "Выводы" }, "panel": { "title": "Панель администратора", @@ -821,7 +885,9 @@ "promoGroupsDesc": "Группы скидок для пользователей", "trafficUsageDesc": "Трафик пользователей по нодам", "updatesDesc": "Версии бота и кабинета", - "pinnedMessagesDesc": "Управление закреплёнными сообщениями" + "pinnedMessagesDesc": "Управление закреплёнными сообщениями", + "partnersDesc": "Управление заявками партнёров и комиссиями", + "withdrawalsDesc": "Проверка и обработка заявок на вывод" }, "trafficUsage": { "title": "Расход трафика", @@ -2243,6 +2309,153 @@ "loadError": "Не удалось загрузить кампанию", "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": { "title": "Промокоды", "subtitle": "Управление промокодами и группами скидок", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5e02d71..6fba271 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -543,7 +543,69 @@ "referral_subscription_renewal": "推荐用户续订", "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": { "title": "支持", @@ -656,7 +718,9 @@ "users": "用户", "trafficUsage": "流量使用", "updates": "更新", - "pinnedMessages": "置顶消息" + "pinnedMessages": "置顶消息", + "partners": "合作伙伴", + "withdrawals": "提现" }, "panel": { "title": "管理面板", @@ -681,7 +745,9 @@ "usersDesc": "管理机器人用户", "trafficUsageDesc": "按节点统计用户流量", "updatesDesc": "机器人和面板版本", - "pinnedMessagesDesc": "管理用户置顶消息" + "pinnedMessagesDesc": "管理用户置顶消息", + "partnersDesc": "管理合作伙伴申请和佣金", + "withdrawalsDesc": "审核和处理提现请求" }, "trafficUsage": { "title": "流量使用", @@ -1457,6 +1523,153 @@ "loadError": "加载活动失败", "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": { "title": "促销码", "subtitle": "管理促销码和折扣组", diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index b44517e..3bbe34b 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -186,6 +186,16 @@ const SparklesIcon = () => ( ); +const HandshakeIcon = () => ( + + + +); + const CogIcon = () => ( , + title: t('admin.nav.partners'), + description: t('admin.panel.partnersDesc'), + }, + { + to: '/admin/withdrawals', + icon: , + title: t('admin.nav.withdrawals'), + description: t('admin.panel.withdrawalsDesc'), + }, ], }, { diff --git a/src/pages/AdminPartnerDetail.tsx b/src/pages/AdminPartnerDetail.tsx new file mode 100644 index 0000000..618d8c9 --- /dev/null +++ b/src/pages/AdminPartnerDetail.tsx @@ -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 = { + 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 ( +
+
+
+ ); + } + + if (error || !partner) { + return ( +
+
+ +

{t('admin.partnerDetail.title')}

+
+
+

{t('admin.partnerDetail.loadError')}

+ +
+
+ ); + } + + const badge = statusConfig[partner.partner_status] || statusConfig.none; + + return ( +
+ {/* Header */} +
+ +
+
+

+ {partner.first_name || partner.username || `#${partner.user_id}`} +

+ + {t(badge.labelKey)} + +
+ {partner.username &&

@{partner.username}

} +
+
+ +
+ {/* Referral Stats */} +
+
+
{partner.total_referrals}
+
+ {t('admin.partnerDetail.stats.totalReferrals')} +
+
+
+
{partner.paid_referrals}
+
+ {t('admin.partnerDetail.stats.paidReferrals')} +
+
+
+
{partner.active_referrals}
+
+ {t('admin.partnerDetail.stats.activeReferrals')} +
+
+
+
{partner.conversion_to_paid}%
+
+ {t('admin.partnerDetail.stats.conversionRate')} +
+
+
+ + {/* Earnings */} +
+

+ {t('admin.partnerDetail.earnings.title')} +

+
+
+
+ {t('admin.partnerDetail.earnings.allTime')} +
+
+ {formatWithCurrency(partner.earnings_all_time / 100)} +
+
+
+
+ {t('admin.partnerDetail.earnings.today')} +
+
+ {formatWithCurrency(partner.earnings_today / 100)} +
+
+
+
+ {t('admin.partnerDetail.earnings.week')} +
+
+ {formatWithCurrency(partner.earnings_week / 100)} +
+
+
+
+ {t('admin.partnerDetail.earnings.month')} +
+
+ {formatWithCurrency(partner.earnings_month / 100)} +
+
+
+
+ + {/* Commission */} +
+
+
+

+ {t('admin.partnerDetail.commission.title')} +

+
+ {partner.commission_percent ?? 0}% +
+
+ +
+
+ + {/* Campaigns */} +
+

+ {t('admin.partnerDetail.campaigns.title')} +

+ {partner.campaigns.length === 0 ? ( +
+ {t('admin.partnerDetail.campaigns.noCampaigns')} +
+ ) : ( +
+ {partner.campaigns.map((campaign) => ( +
+
+
{campaign.name}
+
+ ?start={campaign.start_parameter} +
+
+ {campaign.is_active ? ( + + {t('admin.partnerDetail.campaigns.active')} + + ) : ( + + {t('admin.partnerDetail.campaigns.inactive')} + + )} +
+ ))} +
+ )} +
+ + {/* Actions */} +
+

+ {t('admin.partnerDetail.dangerZone.title')} +

+ +
+
+ + {/* Commission Update Dialog */} + {showCommissionDialog && ( +
+
setShowCommissionDialog(false)} + /> +
+

+ {t('admin.partnerDetail.commissionDialog.title')} +

+

+ {t('admin.partnerDetail.commissionDialog.description')} +

+ + 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" + /> +
+ + +
+
+
+ )} + + {/* Revoke Confirmation Dialog */} + {showRevokeDialog && ( +
+
setShowRevokeDialog(false)} + /> +
+

+ {t('admin.partnerDetail.revokeDialog.title')} +

+

+ {t('admin.partnerDetail.revokeDialog.description')} +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/pages/AdminPartners.tsx b/src/pages/AdminPartners.tsx new file mode 100644 index 0000000..0e571fc --- /dev/null +++ b/src/pages/AdminPartners.tsx @@ -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(null); + const [approveCommission, setApproveCommission] = useState('10'); + + // Reject dialog state + const [rejectDialog, setRejectDialog] = useState(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 ( +
+ {/* Header */} +
+ +
+

{t('admin.partners.title')}

+

{t('admin.partners.subtitle')}

+
+
+ + {/* Stats Overview */} + {stats && ( +
+
+
{stats.total_partners}
+
{t('admin.partners.totalPartners')}
+
+
+
{stats.pending_applications}
+
{t('admin.partners.pendingApplications')}
+
+
+
{stats.total_referrals}
+
{t('admin.partners.totalReferrals')}
+
+
+
+ {formatWithCurrency(stats.total_earnings_kopeks / 100)} +
+
{t('admin.partners.totalEarnings')}
+
+
+ )} + + {/* Tabs */} +
+ + +
+ + {/* Partners Tab */} + {activeTab === 'partners' && ( + <> + {partnersLoading ? ( +
+
+
+ ) : partners.length === 0 ? ( +
+

{t('admin.partners.noPartners')}

+
+ ) : ( +
+ {partners.map((partner: AdminPartnerItem) => ( + + ))} +
+ )} + + )} + + {/* Applications Tab */} + {activeTab === 'applications' && ( + <> + {applicationsLoading ? ( +
+
+
+ ) : applications.length === 0 ? ( +
+

{t('admin.partners.noApplications')}

+
+ ) : ( +
+ {applications.map((app: AdminPartnerApplicationItem) => ( +
+
+
+
+

+ {app.first_name || app.username || `#${app.user_id}`} +

+ {app.username && ( + @{app.username} + )} +
+ {app.company_name && ( +
{app.company_name}
+ )} +
+
+ + {/* Application details */} +
+ {app.website_url && ( +
+ {t('admin.partners.applicationFields.website')}: {app.website_url} +
+ )} + {app.telegram_channel && ( +
+ {t('admin.partners.applicationFields.channel')}: {app.telegram_channel} +
+ )} + {app.description && ( +
+ {t('admin.partners.applicationFields.description')}: {app.description} +
+ )} + {app.expected_monthly_referrals != null && ( +
+ {t('admin.partners.applicationFields.expectedReferrals')}:{' '} + {app.expected_monthly_referrals} +
+ )} +
+ + {/* Actions */} +
+ + +
+
+ ))} +
+ )} + + )} + + {/* Approve Dialog */} + {approveDialog !== null && ( +
+
setApproveDialog(null)} /> +
+

+ {t('admin.partners.approveDialog.title')} +

+

+ {t('admin.partners.approveDialog.description', { + name: + approveDialog.first_name || approveDialog.username || `#${approveDialog.user_id}`, + })} +

+ + 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" + /> +
+ + +
+
+
+ )} + + {/* Reject Dialog */} + {rejectDialog !== null && ( +
+
setRejectDialog(null)} /> +
+

+ {t('admin.partners.rejectDialog.title')} +

+

+ {t('admin.partners.rejectDialog.description', { + name: + rejectDialog.first_name || rejectDialog.username || `#${rejectDialog.user_id}`, + })} +

+ +