mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
132
src/App.tsx
132
src/App.tsx
@@ -59,6 +59,18 @@ 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 AdminPartnerSettings = lazy(() => import('./pages/AdminPartnerSettings'));
|
||||
const AdminPartnerDetail = lazy(() => import('./pages/AdminPartnerDetail'));
|
||||
const AdminApplicationReview = lazy(() => import('./pages/AdminApplicationReview'));
|
||||
const AdminPartnerCommission = lazy(() => import('./pages/AdminPartnerCommission'));
|
||||
const AdminPartnerRevoke = lazy(() => import('./pages/AdminPartnerRevoke'));
|
||||
const AdminPartnerCampaignAssign = lazy(() => import('./pages/AdminPartnerCampaignAssign'));
|
||||
const AdminWithdrawals = lazy(() => import('./pages/AdminWithdrawals'));
|
||||
const AdminWithdrawalDetail = lazy(() => import('./pages/AdminWithdrawalDetail'));
|
||||
const AdminWithdrawalReject = lazy(() => import('./pages/AdminWithdrawalReject'));
|
||||
const ReferralPartnerApply = lazy(() => import('./pages/ReferralPartnerApply'));
|
||||
const ReferralWithdrawalRequest = lazy(() => import('./pages/ReferralWithdrawalRequest'));
|
||||
const AdminUsers = lazy(() => import('./pages/AdminUsers'));
|
||||
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
|
||||
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
||||
@@ -217,6 +229,26 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/referral/partner/apply"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<ReferralPartnerApply />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/referral/withdrawal/request"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<ReferralWithdrawalRequest />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/support"
|
||||
element={
|
||||
@@ -549,6 +581,106 @@ function App() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPartners />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/settings"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPartnerSettings />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/applications/:id/review"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminApplicationReview />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId/commission"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPartnerCommission />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId/revoke"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPartnerRevoke />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId/campaigns/assign"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPartnerCampaignAssign />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/partners/:userId"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPartnerDetail />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/withdrawals"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminWithdrawals />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/withdrawals/:id/reject"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminWithdrawalReject />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/withdrawals/:id"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminWithdrawalDetail />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface Broadcast {
|
||||
total_count: number;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
blocked_count: number;
|
||||
status:
|
||||
| 'queued'
|
||||
| 'in_progress'
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface CampaignListItem {
|
||||
registrations_count: number;
|
||||
total_revenue_kopeks: number;
|
||||
conversion_rate: number;
|
||||
partner_user_id: number | null;
|
||||
partner_name: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -40,6 +42,8 @@ export interface CampaignDetail {
|
||||
tariff_id: number | null;
|
||||
tariff_duration_days: number | null;
|
||||
tariff: TariffInfo | null;
|
||||
partner_user_id: number | null;
|
||||
partner_name: string | null;
|
||||
created_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
@@ -59,6 +63,7 @@ export interface CampaignCreateRequest {
|
||||
subscription_squads?: string[];
|
||||
tariff_id?: number;
|
||||
tariff_duration_days?: number;
|
||||
partner_user_id?: number | null;
|
||||
}
|
||||
|
||||
export interface CampaignUpdateRequest {
|
||||
@@ -73,6 +78,7 @@ export interface CampaignUpdateRequest {
|
||||
subscription_squads?: string[];
|
||||
tariff_id?: number;
|
||||
tariff_duration_days?: number;
|
||||
partner_user_id?: number | null;
|
||||
}
|
||||
|
||||
export interface CampaignToggleResponse {
|
||||
@@ -159,6 +165,12 @@ export interface TariffListItem {
|
||||
device_limit: number;
|
||||
}
|
||||
|
||||
export interface AvailablePartner {
|
||||
user_id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
}
|
||||
|
||||
export const campaignsApi = {
|
||||
// Get campaigns overview
|
||||
getOverview: async (): Promise<CampaignsOverview> => {
|
||||
@@ -240,4 +252,10 @@ export const campaignsApi = {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available partners for campaign assignment
|
||||
getAvailablePartners: async (): Promise<AvailablePartner[]> => {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-partners');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
228
src/api/partners.ts
Normal file
228
src/api/partners.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
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 PartnerCampaignInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
start_parameter: string;
|
||||
bonus_type: string;
|
||||
balance_bonus_kopeks: number;
|
||||
subscription_duration_days: number | null;
|
||||
subscription_traffic_gb: number | null;
|
||||
deep_link: string | null;
|
||||
web_link: string | null;
|
||||
}
|
||||
|
||||
export interface PartnerStatusResponse {
|
||||
partner_status: string;
|
||||
commission_percent: number | null;
|
||||
latest_application: PartnerApplicationInfo | null;
|
||||
campaigns: PartnerCampaignInfo[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ==================== Partner Settings types ====================
|
||||
|
||||
export interface PartnerSettings {
|
||||
withdrawal_enabled: boolean;
|
||||
withdrawal_min_amount_kopeks: number;
|
||||
withdrawal_cooldown_days: number;
|
||||
withdrawal_requisites_text: string;
|
||||
partner_section_visible: boolean;
|
||||
referral_program_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PartnerSettingsUpdate {
|
||||
withdrawal_enabled?: boolean;
|
||||
withdrawal_min_amount_kopeks?: number;
|
||||
withdrawal_cooldown_days?: number;
|
||||
withdrawal_requisites_text?: string;
|
||||
partner_section_visible?: boolean;
|
||||
referral_program_enabled?: boolean;
|
||||
}
|
||||
|
||||
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`);
|
||||
},
|
||||
|
||||
// Settings
|
||||
getPartnerSettings: async (): Promise<PartnerSettings> => {
|
||||
const response = await apiClient.get<PartnerSettings>('/cabinet/admin/partners/settings');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePartnerSettings: async (data: PartnerSettingsUpdate): Promise<PartnerSettings> => {
|
||||
const response = await apiClient.patch<PartnerSettings>(
|
||||
'/cabinet/admin/partners/settings',
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
153
src/api/withdrawals.ts
Normal file
153
src/api/withdrawals.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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;
|
||||
requisites_text: string;
|
||||
}
|
||||
|
||||
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`);
|
||||
},
|
||||
};
|
||||
@@ -13,9 +13,15 @@ export function AccordionBlock({
|
||||
}: BlockRendererProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
const visibleBlocks = blocks.filter(
|
||||
(b) => getLocalizedText(b.title) || getLocalizedText(b.description) || b.buttons?.length,
|
||||
);
|
||||
|
||||
if (!visibleBlocks.length) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block, index) => {
|
||||
{visibleBlocks.map((block, index) => {
|
||||
const gradientStyle = getColorGradient(block.svgIconColor || 'cyan', isLight);
|
||||
const isOpen = openIndex === index;
|
||||
|
||||
|
||||
@@ -10,9 +10,15 @@ export function CardsBlock({
|
||||
getSvgHtml,
|
||||
renderBlockButtons,
|
||||
}: BlockRendererProps) {
|
||||
const visibleBlocks = blocks.filter(
|
||||
(b) => getLocalizedText(b.title) || getLocalizedText(b.description) || b.buttons?.length,
|
||||
);
|
||||
|
||||
if (!visibleBlocks.length) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, index) => {
|
||||
{visibleBlocks.map((block, index) => {
|
||||
const gradientStyle = getColorGradient(block.svgIconColor || 'cyan', isLight);
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,11 +10,17 @@ export function MinimalBlock({
|
||||
getSvgHtml,
|
||||
renderBlockButtons,
|
||||
}: BlockRendererProps) {
|
||||
const visibleBlocks = blocks.filter(
|
||||
(b) => getLocalizedText(b.title) || getLocalizedText(b.description) || b.buttons?.length,
|
||||
);
|
||||
|
||||
if (!visibleBlocks.length) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{blocks.map((block, index) => {
|
||||
{visibleBlocks.map((block, index) => {
|
||||
const gradientStyle = getColorGradient(block.svgIconColor || 'cyan', isLight);
|
||||
const isLast = index === blocks.length - 1;
|
||||
const isLast = index === visibleBlocks.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -10,11 +10,17 @@ export function TimelineBlock({
|
||||
getSvgHtml,
|
||||
renderBlockButtons,
|
||||
}: BlockRendererProps) {
|
||||
const visibleBlocks = blocks.filter(
|
||||
(b) => getLocalizedText(b.title) || getLocalizedText(b.description) || b.buttons?.length,
|
||||
);
|
||||
|
||||
if (!visibleBlocks.length) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{blocks.map((block, index) => {
|
||||
{visibleBlocks.map((block, index) => {
|
||||
const gradientStyle = getColorGradientSolid(block.svgIconColor || 'cyan', isLight);
|
||||
const isLast = index === blocks.length - 1;
|
||||
const isLast = index === visibleBlocks.length - 1;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex gap-3 sm:gap-4">
|
||||
|
||||
@@ -243,6 +243,14 @@
|
||||
"serversLabel": "Servers",
|
||||
"servers_one": "{{count}} server",
|
||||
"servers_other": "{{count}} servers",
|
||||
"locationsLabel": "Locations",
|
||||
"locations_one": "{{count}} location",
|
||||
"locations_other": "{{count}} locations",
|
||||
"trafficReset": {
|
||||
"DAY": "Resets daily",
|
||||
"WEEK": "Resets weekly",
|
||||
"MONTH": "Resets monthly"
|
||||
},
|
||||
"traffic": "Traffic",
|
||||
"unlimited": "Unlimited",
|
||||
"used": "Used",
|
||||
@@ -652,7 +660,83 @@
|
||||
"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.",
|
||||
"yourCampaigns": "Your campaigns",
|
||||
"campaignLinks": {
|
||||
"bot": "Bot link",
|
||||
"web": "Cabinet link"
|
||||
},
|
||||
"campaignBonus": {
|
||||
"title": "Referral bonus",
|
||||
"balance": "+{{amount}} to balance",
|
||||
"balanceDesc": "{{amount}} credited on signup",
|
||||
"subscription": "{{days}} days subscription",
|
||||
"subscriptionDesc": "{{days}} days free subscription",
|
||||
"tariff": "Bonus tariff",
|
||||
"tariffDesc": "Free tariff plan"
|
||||
},
|
||||
"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 +859,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 +886,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",
|
||||
@@ -1116,6 +1204,8 @@
|
||||
"total": "Total",
|
||||
"sent": "Sent",
|
||||
"failed": "Failed",
|
||||
"blocked": "Blocked",
|
||||
"blockedShort": "blocked",
|
||||
"filter": "Filter",
|
||||
"message": "Message",
|
||||
"unknownAdmin": "Unknown",
|
||||
@@ -1665,7 +1755,11 @@
|
||||
"active": "Active",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"saving": "Saving..."
|
||||
"saving": "Saving...",
|
||||
"partner": "Partner",
|
||||
"noPartner": "No partner",
|
||||
"selectPartner": "Select partner",
|
||||
"partnerAutoAssign": "Campaign will be automatically assigned to partner {{name}}"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Campaign Statistics",
|
||||
@@ -1721,6 +1815,189 @@
|
||||
"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",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"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",
|
||||
"review": "Review"
|
||||
},
|
||||
"applicationFields": {
|
||||
"companyName": "Company",
|
||||
"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..."
|
||||
},
|
||||
"settings": "Settings",
|
||||
"settingsSubtitle": "Partner program and withdrawal settings",
|
||||
"settingsLoadError": "Failed to load settings",
|
||||
"settingsUpdateError": "Failed to save settings",
|
||||
"settingsSection": {
|
||||
"referralProgram": "Referral Program",
|
||||
"withdrawalSettings": "Withdrawal Settings"
|
||||
},
|
||||
"settingsFields": {
|
||||
"programEnabled": "Referral program enabled",
|
||||
"programEnabledDesc": "Enable/disable referral program for all users",
|
||||
"partnerVisible": "Partner section visible in cabinet",
|
||||
"partnerVisibleDesc": "Show/hide partner application and withdrawal sections for users",
|
||||
"withdrawalEnabled": "Withdrawal enabled",
|
||||
"withdrawalEnabledDesc": "Allow partners to request withdrawal of referral balance",
|
||||
"minAmount": "Min. withdrawal amount (kopeks)",
|
||||
"minAmountDesc": "Minimum amount for a withdrawal request (100 kopeks = 1 ₽)",
|
||||
"cooldownDays": "Cooldown between withdrawals (days)",
|
||||
"cooldownDaysDesc": "How many days must pass between withdrawal requests",
|
||||
"requisitesText": "Requisites field prompt",
|
||||
"requisitesTextDesc": "Custom text shown to users to describe what payment details to provide",
|
||||
"requisitesTextPlaceholder": "E.g.: Provide your bank card number or USDT TRC-20 address"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"assign": "Assign",
|
||||
"unassign": "Unassign",
|
||||
"createNew": "Create",
|
||||
"assignTitle": "Assign Campaign",
|
||||
"noAvailable": "No available campaigns to assign",
|
||||
"createTitle": "Create Campaign",
|
||||
"createSubtitle": "Campaign will be auto-assigned to the partner",
|
||||
"autoAssignNote": "Campaign will be automatically assigned to partner {{name}}",
|
||||
"createAndAssign": "Create & Assign"
|
||||
},
|
||||
"dangerZone": {
|
||||
"title": "Danger Zone",
|
||||
"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",
|
||||
|
||||
@@ -239,6 +239,13 @@
|
||||
"baseTariff": "تعرفه",
|
||||
"serversLabel": "سرورها",
|
||||
"servers_other": "{{count}} سرور",
|
||||
"locationsLabel": "مکانها",
|
||||
"locations_other": "{{count}} مکان",
|
||||
"trafficReset": {
|
||||
"DAY": "بازنشانی روزانه",
|
||||
"WEEK": "بازنشانی هفتگی",
|
||||
"MONTH": "بازنشانی ماهانه"
|
||||
},
|
||||
"traffic": "ترافیک",
|
||||
"unlimited": "نامحدود",
|
||||
"used": "استفاده شده",
|
||||
@@ -543,7 +550,83 @@
|
||||
"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": "ارسال درخواست ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||
"yourCampaigns": "کمپینهای شما",
|
||||
"campaignLinks": {
|
||||
"bot": "لینک ربات",
|
||||
"web": "لینک کابینت"
|
||||
},
|
||||
"campaignBonus": {
|
||||
"title": "جایزه معرفی",
|
||||
"balance": "+{{amount}} به موجودی",
|
||||
"balanceDesc": "{{amount}} هنگام ثبتنام",
|
||||
"subscription": "{{days}} روز اشتراک",
|
||||
"subscriptionDesc": "{{days}} روز اشتراک رایگان",
|
||||
"tariff": "تعرفه جایزه",
|
||||
"tariffDesc": "طرح تعرفه رایگان"
|
||||
},
|
||||
"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 +739,9 @@
|
||||
"users": "کاربران",
|
||||
"trafficUsage": "مصرف ترافیک",
|
||||
"updates": "بهروزرسانیها",
|
||||
"pinnedMessages": "پیامهای سنجاقشده"
|
||||
"pinnedMessages": "پیامهای سنجاقشده",
|
||||
"partners": "شرکا",
|
||||
"withdrawals": "برداشتها"
|
||||
},
|
||||
"panel": {
|
||||
"title": "پنل مدیریت",
|
||||
@@ -681,7 +766,9 @@
|
||||
"usersDesc": "مدیریت کاربران ربات",
|
||||
"trafficUsageDesc": "ترافیک هر کاربر بر اساس نود",
|
||||
"updatesDesc": "نسخههای ربات و کابینت",
|
||||
"pinnedMessagesDesc": "مدیریت پیامهای سنجاقشده"
|
||||
"pinnedMessagesDesc": "مدیریت پیامهای سنجاقشده",
|
||||
"partnersDesc": "مدیریت درخواستهای شراکت و کمیسیونها",
|
||||
"withdrawalsDesc": "بررسی و پردازش درخواستهای برداشت"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "مصرف ترافیک",
|
||||
@@ -837,6 +924,8 @@
|
||||
"total": "کل",
|
||||
"sent": "ارسال شده",
|
||||
"failed": "ناموفق",
|
||||
"blocked": "مسدود شده",
|
||||
"blockedShort": "مسدود",
|
||||
"filter": "فیلتر",
|
||||
"message": "پیام",
|
||||
"unknownAdmin": "نامعلوم",
|
||||
@@ -1404,7 +1493,11 @@
|
||||
"active": "فعال",
|
||||
"cancel": "لغو",
|
||||
"save": "ذخیره",
|
||||
"saving": "در حال ذخیره..."
|
||||
"saving": "در حال ذخیره...",
|
||||
"partner": "شریک",
|
||||
"noPartner": "بدون شریک",
|
||||
"selectPartner": "انتخاب شریک",
|
||||
"partnerAutoAssign": "کمپین به طور خودکار به شریک {{name}} اختصاص داده میشود"
|
||||
},
|
||||
"stats": {
|
||||
"title": "آمار کمپین",
|
||||
@@ -1458,6 +1551,165 @@
|
||||
"loadError": "بارگذاری کمپین ناموفق بود",
|
||||
"updateError": "ذخیره تغییرات ناموفق بود"
|
||||
},
|
||||
"withdrawals": {
|
||||
"title": "برداشتها",
|
||||
"subtitle": "مدیریت درخواستهای برداشت",
|
||||
"noData": "درخواست برداشتی وجود ندارد",
|
||||
"risk": "ریسک:",
|
||||
"overview": {
|
||||
"pendingCount": "در انتظار",
|
||||
"pendingAmount": "مبلغ در انتظار",
|
||||
"approvedCount": "تأیید شده",
|
||||
"completedCount": "تکمیل شده"
|
||||
},
|
||||
"filter": {
|
||||
"all": "همه",
|
||||
"pending": "در انتظار",
|
||||
"approved": "تأیید شده",
|
||||
"rejected": "رد شده",
|
||||
"completed": "تکمیل شده",
|
||||
"cancelled": "لغو شده"
|
||||
},
|
||||
"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": "رد",
|
||||
"review": "بررسی"
|
||||
},
|
||||
"applicationFields": {
|
||||
"companyName": "شرکت",
|
||||
"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": "غیرفعال",
|
||||
"assign": "اختصاص",
|
||||
"unassign": "لغو اختصاص",
|
||||
"createNew": "ایجاد",
|
||||
"assignTitle": "اختصاص کمپین",
|
||||
"noAvailable": "کمپینی برای اختصاص موجود نیست",
|
||||
"createTitle": "ایجاد کمپین",
|
||||
"createSubtitle": "کمپین بهطور خودکار به شریک اختصاص مییابد",
|
||||
"autoAssignNote": "کمپین بهطور خودکار به شریک {{name}} اختصاص مییابد",
|
||||
"createAndAssign": "ایجاد و اختصاص"
|
||||
},
|
||||
"dangerZone": {
|
||||
"title": "منطقه خطر",
|
||||
"revokeButton": "لغو وضعیت شراکت"
|
||||
},
|
||||
"commissionDialog": {
|
||||
"title": "بهروزرسانی کمیسیون",
|
||||
"description": "درصد کمیسیون جدیدی برای این شریک تعیین کنید.",
|
||||
"label": "کمیسیون (%)"
|
||||
},
|
||||
"revokeDialog": {
|
||||
"title": "لغو وضعیت شراکت؟",
|
||||
"description": "این کار امتیازات شراکت را حذف میکند. شریک دیگر از معرفیها کمیسیون دریافت نخواهد کرد."
|
||||
}
|
||||
},
|
||||
"promocodes": {
|
||||
"title": "کدهای تخفیف",
|
||||
"subtitle": "مدیریت کدهای تخفیف و گروههای تخفیف",
|
||||
|
||||
@@ -257,6 +257,15 @@
|
||||
"servers_one": "{{count}} сервер",
|
||||
"servers_few": "{{count}} сервера",
|
||||
"servers_many": "{{count}} серверов",
|
||||
"locationsLabel": "Локации",
|
||||
"locations_one": "{{count}} локация",
|
||||
"locations_few": "{{count}} локации",
|
||||
"locations_many": "{{count}} локаций",
|
||||
"trafficReset": {
|
||||
"DAY": "Сброс ежедневно",
|
||||
"WEEK": "Сброс еженедельно",
|
||||
"MONTH": "Сброс ежемесячно"
|
||||
},
|
||||
"traffic": "Трафик",
|
||||
"unlimited": "Безлимит",
|
||||
"used": "Использовано",
|
||||
@@ -679,7 +688,83 @@
|
||||
"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": "Не удалось отправить заявку. Попробуйте снова.",
|
||||
"yourCampaigns": "Ваши кампании",
|
||||
"campaignLinks": {
|
||||
"bot": "Ссылка в бот",
|
||||
"web": "Ссылка в кабинет"
|
||||
},
|
||||
"campaignBonus": {
|
||||
"title": "Бонус для реферала",
|
||||
"balance": "+{{amount}} на баланс",
|
||||
"balanceDesc": "{{amount}} на баланс при регистрации",
|
||||
"subscription": "{{days}} дн. подписки",
|
||||
"subscriptionDesc": "{{days}} дней бесплатной подписки",
|
||||
"tariff": "Бонусный тариф",
|
||||
"tariffDesc": "Бесплатный тарифный план"
|
||||
},
|
||||
"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 +881,9 @@
|
||||
"promoGroups": "Группы скидок",
|
||||
"trafficUsage": "Расход трафика",
|
||||
"updates": "Обновления",
|
||||
"pinnedMessages": "Закреплённые"
|
||||
"pinnedMessages": "Закреплённые",
|
||||
"partners": "Партнёры",
|
||||
"withdrawals": "Выводы"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Панель администратора",
|
||||
@@ -821,7 +908,9 @@
|
||||
"promoGroupsDesc": "Группы скидок для пользователей",
|
||||
"trafficUsageDesc": "Трафик пользователей по нодам",
|
||||
"updatesDesc": "Версии бота и кабинета",
|
||||
"pinnedMessagesDesc": "Управление закреплёнными сообщениями"
|
||||
"pinnedMessagesDesc": "Управление закреплёнными сообщениями",
|
||||
"partnersDesc": "Управление заявками партнёров и комиссиями",
|
||||
"withdrawalsDesc": "Проверка и обработка заявок на вывод"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "Расход трафика",
|
||||
@@ -1141,6 +1230,8 @@
|
||||
"total": "Всего",
|
||||
"sent": "Отправлено",
|
||||
"failed": "Ошибки",
|
||||
"blocked": "Заблокировали",
|
||||
"blockedShort": "заблок.",
|
||||
"filter": "Фильтр",
|
||||
"message": "Сообщение",
|
||||
"unknownAdmin": "Неизвестный админ",
|
||||
@@ -2185,7 +2276,11 @@
|
||||
"active": "Активна",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохранение..."
|
||||
"saving": "Сохранение...",
|
||||
"partner": "Партнёр",
|
||||
"noPartner": "Без партнёра",
|
||||
"selectPartner": "Выберите партнёра",
|
||||
"partnerAutoAssign": "Кампания будет автоматически привязана к партнёру {{name}}"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Статистика кампании",
|
||||
@@ -2243,6 +2338,189 @@
|
||||
"loadError": "Не удалось загрузить кампанию",
|
||||
"updateError": "Не удалось сохранить изменения"
|
||||
},
|
||||
"withdrawals": {
|
||||
"title": "Выводы средств",
|
||||
"subtitle": "Управление заявками на вывод",
|
||||
"noData": "Нет заявок на вывод",
|
||||
"risk": "Риск:",
|
||||
"overview": {
|
||||
"pendingCount": "Ожидают",
|
||||
"pendingAmount": "Сумма ожидания",
|
||||
"approvedCount": "Одобрено",
|
||||
"completedCount": "Выполнено"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Все",
|
||||
"pending": "Ожидание",
|
||||
"approved": "Одобрено",
|
||||
"rejected": "Отклонено",
|
||||
"completed": "Выполнено",
|
||||
"cancelled": "Отменено"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Ожидание",
|
||||
"approved": "Одобрено",
|
||||
"rejected": "Отклонено",
|
||||
"completed": "Выполнено",
|
||||
"cancelled": "Отменено",
|
||||
"unknown": "Неизвестно"
|
||||
},
|
||||
"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": "Отклонить",
|
||||
"review": "Рассмотреть"
|
||||
},
|
||||
"applicationFields": {
|
||||
"companyName": "Компания",
|
||||
"website": "Сайт",
|
||||
"channel": "Канал",
|
||||
"description": "Описание",
|
||||
"expectedReferrals": "Ожидаемых рефералов/мес"
|
||||
},
|
||||
"approveDialog": {
|
||||
"title": "Одобрить заявку",
|
||||
"description": "Одобрить партнёрскую заявку от {{name}}",
|
||||
"commissionLabel": "Комиссия (%)"
|
||||
},
|
||||
"rejectDialog": {
|
||||
"title": "Отклонить заявку",
|
||||
"description": "Отклонить партнёрскую заявку от {{name}}",
|
||||
"commentLabel": "Комментарий (необязательно)",
|
||||
"commentPlaceholder": "Причина отклонения..."
|
||||
},
|
||||
"settings": "Настройки",
|
||||
"settingsSubtitle": "Настройки партнёрской программы и вывода средств",
|
||||
"settingsLoadError": "Не удалось загрузить настройки",
|
||||
"settingsUpdateError": "Не удалось сохранить настройки",
|
||||
"settingsSection": {
|
||||
"referralProgram": "Реферальная программа",
|
||||
"withdrawalSettings": "Настройки вывода"
|
||||
},
|
||||
"settingsFields": {
|
||||
"programEnabled": "Реферальная программа включена",
|
||||
"programEnabledDesc": "Вкл/выкл реферальную программу для всех пользователей",
|
||||
"partnerVisible": "Раздел партнёрки виден в кабинете",
|
||||
"partnerVisibleDesc": "Показывать/скрывать секцию подачи заявки на партнёрство и вывода средств",
|
||||
"withdrawalEnabled": "Вывод средств включён",
|
||||
"withdrawalEnabledDesc": "Разрешить партнёрам запрашивать вывод реферального баланса",
|
||||
"minAmount": "Мин. сумма вывода (копейки)",
|
||||
"minAmountDesc": "Минимальная сумма для запроса вывода (100 коп. = 1 ₽)",
|
||||
"cooldownDays": "Кулдаун между выводами (дни)",
|
||||
"cooldownDaysDesc": "Сколько дней должно пройти между запросами на вывод",
|
||||
"requisitesText": "Текст для поля реквизитов",
|
||||
"requisitesTextDesc": "Кастомная подсказка пользователю, какие реквизиты указать при выводе",
|
||||
"requisitesTextPlaceholder": "Например: Укажите номер карты Сбербанк / Тинькофф или USDT TRC-20 адрес"
|
||||
}
|
||||
},
|
||||
"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": "Неактивна",
|
||||
"assign": "Привязать",
|
||||
"unassign": "Отвязать",
|
||||
"createNew": "Создать",
|
||||
"assignTitle": "Привязать кампанию",
|
||||
"noAvailable": "Нет доступных кампаний для привязки",
|
||||
"createTitle": "Создать кампанию",
|
||||
"createSubtitle": "Кампания будет автоматически привязана к партнёру",
|
||||
"autoAssignNote": "Кампания будет автоматически привязана к партнёру {{name}}",
|
||||
"createAndAssign": "Создать и привязать"
|
||||
},
|
||||
"dangerZone": {
|
||||
"title": "Опасная зона",
|
||||
"revokeButton": "Отозвать партнёрский статус"
|
||||
},
|
||||
"commissionDialog": {
|
||||
"title": "Изменить комиссию",
|
||||
"description": "Установите новый процент комиссии для этого партнёра.",
|
||||
"label": "Комиссия (%)"
|
||||
},
|
||||
"revokeDialog": {
|
||||
"title": "Отозвать статус партнёра?",
|
||||
"description": "Это лишит партнёра привилегий. Партнёр больше не будет получать комиссию от рефералов."
|
||||
}
|
||||
},
|
||||
"promocodes": {
|
||||
"title": "Промокоды",
|
||||
"subtitle": "Управление промокодами и группами скидок",
|
||||
|
||||
@@ -239,6 +239,13 @@
|
||||
"baseTariff": "套餐",
|
||||
"serversLabel": "服务器",
|
||||
"servers_other": "{{count}} 个服务器",
|
||||
"locationsLabel": "位置",
|
||||
"locations_other": "{{count}} 个位置",
|
||||
"trafficReset": {
|
||||
"DAY": "每日重置",
|
||||
"WEEK": "每周重置",
|
||||
"MONTH": "每月重置"
|
||||
},
|
||||
"traffic": "流量",
|
||||
"unlimited": "无限",
|
||||
"used": "已使用",
|
||||
@@ -543,7 +550,83 @@
|
||||
"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": "提交申请失败,请重试。",
|
||||
"yourCampaigns": "您的活动",
|
||||
"campaignLinks": {
|
||||
"bot": "机器人链接",
|
||||
"web": "网页链接"
|
||||
},
|
||||
"campaignBonus": {
|
||||
"title": "推荐奖励",
|
||||
"balance": "+{{amount}} 余额",
|
||||
"balanceDesc": "注册时充值 {{amount}}",
|
||||
"subscription": "{{days}} 天订阅",
|
||||
"subscriptionDesc": "{{days}} 天免费订阅",
|
||||
"tariff": "奖励套餐",
|
||||
"tariffDesc": "免费套餐方案"
|
||||
},
|
||||
"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 +739,9 @@
|
||||
"users": "用户",
|
||||
"trafficUsage": "流量使用",
|
||||
"updates": "更新",
|
||||
"pinnedMessages": "置顶消息"
|
||||
"pinnedMessages": "置顶消息",
|
||||
"partners": "合作伙伴",
|
||||
"withdrawals": "提现"
|
||||
},
|
||||
"panel": {
|
||||
"title": "管理面板",
|
||||
@@ -681,7 +766,9 @@
|
||||
"usersDesc": "管理机器人用户",
|
||||
"trafficUsageDesc": "按节点统计用户流量",
|
||||
"updatesDesc": "机器人和面板版本",
|
||||
"pinnedMessagesDesc": "管理用户置顶消息"
|
||||
"pinnedMessagesDesc": "管理用户置顶消息",
|
||||
"partnersDesc": "管理合作伙伴申请和佣金",
|
||||
"withdrawalsDesc": "审核和处理提现请求"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "流量使用",
|
||||
@@ -889,6 +976,8 @@
|
||||
"total": "总计",
|
||||
"sent": "已发送",
|
||||
"failed": "失败",
|
||||
"blocked": "已屏蔽",
|
||||
"blockedShort": "屏蔽",
|
||||
"filter": "筛选",
|
||||
"message": "消息",
|
||||
"unknownAdmin": "未知",
|
||||
@@ -1403,7 +1492,11 @@
|
||||
"active": "启用",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"saving": "保存中..."
|
||||
"saving": "保存中...",
|
||||
"partner": "合作伙伴",
|
||||
"noPartner": "无合作伙伴",
|
||||
"selectPartner": "选择合作伙伴",
|
||||
"partnerAutoAssign": "活动将自动分配给合作伙伴 {{name}}"
|
||||
},
|
||||
"stats": {
|
||||
"title": "活动统计",
|
||||
@@ -1457,6 +1550,165 @@
|
||||
"loadError": "加载活动失败",
|
||||
"updateError": "保存更改失败"
|
||||
},
|
||||
"withdrawals": {
|
||||
"title": "提现",
|
||||
"subtitle": "管理提现请求",
|
||||
"noData": "暂无提现请求",
|
||||
"risk": "风险:",
|
||||
"overview": {
|
||||
"pendingCount": "待处理",
|
||||
"pendingAmount": "待处理金额",
|
||||
"approvedCount": "已批准",
|
||||
"completedCount": "已完成"
|
||||
},
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"pending": "待处理",
|
||||
"approved": "已批准",
|
||||
"rejected": "已拒绝",
|
||||
"completed": "已完成",
|
||||
"cancelled": "已取消"
|
||||
},
|
||||
"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": "拒绝",
|
||||
"review": "审核"
|
||||
},
|
||||
"applicationFields": {
|
||||
"companyName": "公司",
|
||||
"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": "未激活",
|
||||
"assign": "分配",
|
||||
"unassign": "取消分配",
|
||||
"createNew": "创建",
|
||||
"assignTitle": "分配活动",
|
||||
"noAvailable": "没有可分配的活动",
|
||||
"createTitle": "创建活动",
|
||||
"createSubtitle": "活动将自动分配给合作伙伴",
|
||||
"autoAssignNote": "活动将自动分配给合作伙伴 {{name}}",
|
||||
"createAndAssign": "创建并分配"
|
||||
},
|
||||
"dangerZone": {
|
||||
"title": "危险操作",
|
||||
"revokeButton": "撤销合作伙伴身份"
|
||||
},
|
||||
"commissionDialog": {
|
||||
"title": "更新佣金",
|
||||
"description": "为该合作伙伴设置新的佣金百分比。",
|
||||
"label": "佣金(%)"
|
||||
},
|
||||
"revokeDialog": {
|
||||
"title": "撤销合作伙伴身份?",
|
||||
"description": "这将移除合作伙伴权限。该合作伙伴将不再从推荐中获得佣金。"
|
||||
}
|
||||
},
|
||||
"promocodes": {
|
||||
"title": "促销码",
|
||||
"subtitle": "管理促销码和折扣组",
|
||||
|
||||
228
src/pages/AdminApplicationReview.tsx
Normal file
228
src/pages/AdminApplicationReview.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { partnerApi, type AdminPartnerApplicationItem } from '../api/partners';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
export default function AdminApplicationReview() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [commission, setCommission] = useState('10');
|
||||
const [rejectComment, setRejectComment] = useState('');
|
||||
|
||||
// Try to get application from navigate state, fallback to fetching
|
||||
const passedApp = (location.state as { application?: AdminPartnerApplicationItem } | null)
|
||||
?.application;
|
||||
|
||||
const { data: fetchedApps } = useQuery({
|
||||
queryKey: ['admin-partner-applications'],
|
||||
queryFn: () => partnerApi.getApplications({ status: 'pending' }),
|
||||
enabled: !passedApp && !!id,
|
||||
});
|
||||
|
||||
const app = passedApp ?? fetchedApps?.items.find((a) => a.id === Number(id));
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({ appId, commissionPercent }: { appId: number; commissionPercent: number }) =>
|
||||
partnerApi.approveApplication(appId, { commission_percent: commissionPercent }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-applications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
|
||||
navigate('/admin/partners');
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ appId, comment }: { appId: number; comment?: string }) =>
|
||||
partnerApi.rejectApplication(appId, { comment }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-applications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
|
||||
navigate('/admin/partners');
|
||||
},
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/partners" />
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{t('admin.partners.approveDialog.title')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = app.first_name || app.username || `#${app.user_id}`;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/partners" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{t('admin.partners.actions.review')}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-400">{displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Application Details */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<h3 className="mb-4 font-medium text-dark-200">
|
||||
{t('admin.partners.tabs.applications')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="font-medium text-dark-100">{displayName}</span>
|
||||
{app.username && <span className="text-sm text-dark-500">@{app.username}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-dark-400">
|
||||
{app.company_name && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<span className="text-dark-500">
|
||||
{t('admin.partners.applicationFields.companyName')}:
|
||||
</span>{' '}
|
||||
<span className="text-dark-200">{app.company_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{app.website_url && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<span className="text-dark-500">
|
||||
{t('admin.partners.applicationFields.website')}:
|
||||
</span>{' '}
|
||||
<span className="text-dark-200">{app.website_url}</span>
|
||||
</div>
|
||||
)}
|
||||
{app.telegram_channel && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<span className="text-dark-500">
|
||||
{t('admin.partners.applicationFields.channel')}:
|
||||
</span>{' '}
|
||||
<span className="text-dark-200">{app.telegram_channel}</span>
|
||||
</div>
|
||||
)}
|
||||
{app.description && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<span className="text-dark-500">
|
||||
{t('admin.partners.applicationFields.description')}:
|
||||
</span>{' '}
|
||||
<span className="text-dark-200">{app.description}</span>
|
||||
</div>
|
||||
)}
|
||||
{app.expected_monthly_referrals != null && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<span className="text-dark-500">
|
||||
{t('admin.partners.applicationFields.expectedReferrals')}:
|
||||
</span>{' '}
|
||||
<span className="text-dark-200">{app.expected_monthly_referrals}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approve Section */}
|
||||
<div className="rounded-xl border border-success-500/30 bg-dark-800 p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{t('admin.partners.approveDialog.title')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('admin.partners.approveDialog.description', { name: displayName })}
|
||||
</p>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('admin.partners.approveDialog.commissionLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={commission}
|
||||
onChange={(e) => setCommission(e.target.value)}
|
||||
className="mb-4 w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
|
||||
placeholder="10"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const val = Number(commission);
|
||||
if (val >= 1 && val <= 100) {
|
||||
approveMutation.mutate({ appId: app.id, commissionPercent: val });
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
approveMutation.isPending ||
|
||||
!commission ||
|
||||
Number(commission) < 1 ||
|
||||
Number(commission) > 100
|
||||
}
|
||||
className="w-full rounded-lg bg-success-500 px-4 py-3 font-medium text-white transition-colors hover:bg-success-600 disabled:opacity-50"
|
||||
>
|
||||
{approveMutation.isPending ? t('common.saving') : t('admin.partners.actions.approve')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{approveMutation.isError && (
|
||||
<div className="rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{t('common.error')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reject Section */}
|
||||
<div className="rounded-xl border border-error-500/30 bg-dark-800 p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{t('admin.partners.rejectDialog.title')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('admin.partners.rejectDialog.description', { name: displayName })}
|
||||
</p>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('admin.partners.rejectDialog.commentLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={rejectComment}
|
||||
onChange={(e) => setRejectComment(e.target.value)}
|
||||
className="mb-4 w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
|
||||
rows={3}
|
||||
placeholder={t('admin.partners.rejectDialog.commentPlaceholder')}
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
rejectMutation.mutate({
|
||||
appId: app.id,
|
||||
comment: rejectComment || undefined,
|
||||
})
|
||||
}
|
||||
disabled={rejectMutation.isPending}
|
||||
className="w-full rounded-lg bg-error-500 px-4 py-3 font-medium text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
||||
>
|
||||
{rejectMutation.isPending ? t('common.saving') : t('admin.partners.actions.reject')}
|
||||
</button>
|
||||
|
||||
{rejectMutation.isError && (
|
||||
<div className="mt-4 rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{t('common.error')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -167,10 +167,7 @@ export default function AdminBroadcastDetail() {
|
||||
queryKey: ['admin', 'broadcasts', 'detail', broadcastId],
|
||||
queryFn: async () => {
|
||||
if (!broadcastId) throw new Error('Invalid broadcast ID');
|
||||
const response = await adminBroadcastsApi.list(100, 0);
|
||||
const found = response.items.find((b) => b.id === broadcastId);
|
||||
if (!found) throw new Error('Broadcast not found');
|
||||
return found;
|
||||
return adminBroadcastsApi.get(broadcastId);
|
||||
},
|
||||
enabled: !!broadcastId && !isNaN(broadcastId),
|
||||
refetchInterval: (query) => {
|
||||
@@ -266,7 +263,7 @@ export default function AdminBroadcastDetail() {
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-dark-100">{broadcast.total_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.total')}</p>
|
||||
@@ -275,6 +272,10 @@ export default function AdminBroadcastDetail() {
|
||||
<p className="text-3xl font-bold text-success-400">{broadcast.sent_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.sent')}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-warning-500/30 bg-warning-500/10 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-warning-400">{broadcast.blocked_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.blocked')}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-error-400">{broadcast.failed_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.failed')}</p>
|
||||
|
||||
@@ -217,6 +217,12 @@ export default function AdminBroadcasts() {
|
||||
<span>{broadcast.target_type}</span>
|
||||
<span>
|
||||
{broadcast.sent_count}/{broadcast.total_count}
|
||||
{broadcast.blocked_count > 0 && (
|
||||
<span className="text-warning-400">
|
||||
{' '}
|
||||
({broadcast.blocked_count} {t('admin.broadcasts.blockedShort')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{new Date(broadcast.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ServerSquadInfo,
|
||||
TariffListItem,
|
||||
} from '../api/campaigns';
|
||||
import { partnerApi } from '../api/partners';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||
// Icons
|
||||
@@ -149,10 +150,27 @@ function TariffSelector({
|
||||
);
|
||||
}
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/gi, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
.slice(0, 30);
|
||||
}
|
||||
|
||||
export default function AdminCampaignCreate() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
const partnerId = searchParams.get('partnerId');
|
||||
|
||||
// Fetch partner info if creating for a partner
|
||||
const { data: partner } = useQuery({
|
||||
queryKey: ['admin-partner-detail', partnerId],
|
||||
queryFn: () => partnerApi.getPartnerDetail(Number(partnerId)),
|
||||
enabled: !!partnerId,
|
||||
});
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
@@ -160,6 +178,17 @@ export default function AdminCampaignCreate() {
|
||||
const [bonusType, setBonusType] = useState<CampaignBonusType>('balance');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
|
||||
// Auto-fill from partner
|
||||
const [autoFilled, setAutoFilled] = useState(false);
|
||||
useEffect(() => {
|
||||
if (partner && !autoFilled) {
|
||||
const partnerName = partner.first_name || partner.username || '';
|
||||
if (!name && partnerName) setName(partnerName);
|
||||
if (!startParameter && partnerName) setStartParameter(`partner_${slugify(partnerName)}`);
|
||||
setAutoFilled(true);
|
||||
}
|
||||
}, [partner, autoFilled, name, startParameter]);
|
||||
|
||||
// Balance bonus
|
||||
const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
|
||||
|
||||
@@ -191,7 +220,12 @@ export default function AdminCampaignCreate() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-campaigns-overview'] });
|
||||
if (partnerId) {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', partnerId] });
|
||||
navigate(`/admin/partners/${partnerId}`);
|
||||
} else {
|
||||
navigate('/admin/campaigns');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -221,6 +255,10 @@ export default function AdminCampaignCreate() {
|
||||
data.tariff_duration_days = toNumber(tariffDays, 30);
|
||||
}
|
||||
|
||||
if (partnerId) {
|
||||
data.partner_user_id = Number(partnerId);
|
||||
}
|
||||
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
@@ -247,6 +285,32 @@ export default function AdminCampaignCreate() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Partner info banner */}
|
||||
{partnerId && partner && (
|
||||
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
className="mt-0.5 h-5 w-5 shrink-0 text-accent-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-accent-300">
|
||||
{t('admin.campaigns.form.partnerAutoAssign', {
|
||||
name: partner.first_name || partner.username || `#${partnerId}`,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="card space-y-4">
|
||||
{/* Name */}
|
||||
@@ -442,7 +506,10 @@ export default function AdminCampaignCreate() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="card flex items-center justify-end gap-3">
|
||||
<button onClick={() => navigate('/admin/campaigns')} className="btn-secondary">
|
||||
<button
|
||||
onClick={() => navigate(partnerId ? `/admin/partners/${partnerId}` : '/admin/campaigns')}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CampaignBonusType,
|
||||
ServerSquadInfo,
|
||||
TariffListItem,
|
||||
AvailablePartner,
|
||||
} from '../api/campaigns';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { CheckIcon, CampaignIcon } from '../components/icons';
|
||||
@@ -124,6 +125,40 @@ function TariffSelector({
|
||||
);
|
||||
}
|
||||
|
||||
// Partner selector component
|
||||
function PartnerSelector({
|
||||
partners,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
partners: AvailablePartner[];
|
||||
value: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.campaigns.form.partner')}
|
||||
</label>
|
||||
<select
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">{t('admin.campaigns.form.noPartner')}</option>
|
||||
{partners.map((p) => (
|
||||
<option key={p.user_id} value={p.user_id}>
|
||||
{p.first_name || p.username || `#${p.user_id}`}
|
||||
{p.username ? ` (@${p.username})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminCampaignEdit() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -155,6 +190,12 @@ export default function AdminCampaignEdit() {
|
||||
queryFn: () => campaignsApi.getAvailableTariffs(),
|
||||
});
|
||||
|
||||
// Fetch partners
|
||||
const { data: partners = [] } = useQuery({
|
||||
queryKey: ['admin-campaigns-partners'],
|
||||
queryFn: () => campaignsApi.getAvailablePartners(),
|
||||
});
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
const [startParameter, setStartParameter] = useState('');
|
||||
@@ -174,6 +215,10 @@ export default function AdminCampaignEdit() {
|
||||
const [tariffId, setTariffId] = useState<number | null>(null);
|
||||
const [tariffDays, setTariffDays] = useState<number | ''>(30);
|
||||
|
||||
// Partner
|
||||
const [partnerUserId, setPartnerUserId] = useState<number | null>(null);
|
||||
const [initialPartnerUserId, setInitialPartnerUserId] = useState<number | null>(null);
|
||||
|
||||
// Initialize form when campaign loads
|
||||
useEffect(() => {
|
||||
if (campaign) {
|
||||
@@ -188,6 +233,8 @@ export default function AdminCampaignEdit() {
|
||||
setSelectedSquads(campaign.subscription_squads || []);
|
||||
setTariffId(campaign.tariff_id || null);
|
||||
setTariffDays(campaign.tariff_duration_days || 30);
|
||||
setPartnerUserId(campaign.partner_user_id ?? null);
|
||||
setInitialPartnerUserId(campaign.partner_user_id ?? null);
|
||||
}
|
||||
}, [campaign]);
|
||||
|
||||
@@ -215,6 +262,11 @@ export default function AdminCampaignEdit() {
|
||||
is_active: isActive,
|
||||
};
|
||||
|
||||
// Only send partner_user_id when it was actually changed
|
||||
if (partnerUserId !== initialPartnerUserId) {
|
||||
data.partner_user_id = partnerUserId;
|
||||
}
|
||||
|
||||
if (bonusType === 'balance') {
|
||||
data.balance_bonus_kopeks = Math.round(toNumber(balanceBonusRubles) * 100);
|
||||
} else if (bonusType === 'subscription') {
|
||||
@@ -344,6 +396,11 @@ export default function AdminCampaignEdit() {
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Partner */}
|
||||
{partners.length > 0 && (
|
||||
<PartnerSelector partners={partners} value={partnerUserId} onChange={setPartnerUserId} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bonus Type */}
|
||||
|
||||
@@ -183,6 +183,11 @@ export default function AdminCampaigns() {
|
||||
>
|
||||
{t(bonusTypeConfig[campaign.bonus_type].labelKey)}
|
||||
</span>
|
||||
{campaign.partner_name && (
|
||||
<span className="rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
|
||||
{campaign.partner_name}
|
||||
</span>
|
||||
)}
|
||||
{!campaign.is_active && (
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
{t('admin.campaigns.table.inactive')}
|
||||
|
||||
@@ -186,6 +186,16 @@ const SparklesIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const HandshakeIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CogIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
@@ -449,6 +459,18 @@ export default function AdminPanel() {
|
||||
title: t('admin.nav.wheel'),
|
||||
description: t('admin.panel.wheelDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/partners',
|
||||
icon: <HandshakeIcon />,
|
||||
title: t('admin.nav.partners'),
|
||||
description: t('admin.panel.partnersDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/withdrawals',
|
||||
icon: <BanknotesIcon />,
|
||||
title: t('admin.nav.withdrawals'),
|
||||
description: t('admin.panel.withdrawalsDesc'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
140
src/pages/AdminPartnerCampaignAssign.tsx
Normal file
140
src/pages/AdminPartnerCampaignAssign.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { partnerApi } from '../api/partners';
|
||||
import { campaignsApi } from '../api/campaigns';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
export default function AdminPartnerCampaignAssign() {
|
||||
const { t } = useTranslation();
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch partner detail to know already assigned campaign IDs
|
||||
const { data: partner } = useQuery({
|
||||
queryKey: ['admin-partner-detail', userId],
|
||||
queryFn: () => partnerApi.getPartnerDetail(Number(userId)),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
// Fetch all campaigns
|
||||
const { data: campaignsData, isLoading } = useQuery({
|
||||
queryKey: ['admin-campaigns-all'],
|
||||
queryFn: () => campaignsApi.getCampaigns(true, 0, 100),
|
||||
});
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: (campaignId: number) => partnerApi.assignCampaign(Number(userId), campaignId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
|
||||
navigate(`/admin/partners/${userId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const assignedIds = new Set(partner?.campaigns.map((c) => c.id) ?? []);
|
||||
const available =
|
||||
campaignsData?.campaigns.filter((c) => !assignedIds.has(c.id) && c.partner_user_id === null) ??
|
||||
[];
|
||||
const takenByOthers =
|
||||
campaignsData?.campaigns.filter((c) => !assignedIds.has(c.id) && c.partner_user_id !== null) ??
|
||||
[];
|
||||
|
||||
const partnerName = partner
|
||||
? partner.first_name || partner.username || `#${partner.user_id}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to={`/admin/partners/${userId}`} />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{t('admin.partnerDetail.campaigns.assignTitle')}
|
||||
</h1>
|
||||
{partnerName && <p className="text-sm text-dark-400">{partnerName}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : available.length === 0 ? (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-6">
|
||||
<div className="py-4 text-center text-sm text-dark-500">
|
||||
{t('admin.partnerDetail.campaigns.noAvailable')}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/campaigns/create?partnerId=${userId}`)}
|
||||
className="mt-2 w-full rounded-lg bg-accent-500 px-4 py-3 font-medium text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('admin.partnerDetail.campaigns.createNew')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{available.map((campaign) => (
|
||||
<div key={campaign.id} className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-dark-100">{campaign.name}</span>
|
||||
{!campaign.is_active && (
|
||||
<span className="rounded bg-dark-600 px-1.5 py-0.5 text-xs text-dark-400">
|
||||
{t('admin.campaigns.table.inactive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-dark-500">
|
||||
<span className="font-mono">?start={campaign.start_parameter}</span>
|
||||
<span>
|
||||
{campaign.registrations_count}{' '}
|
||||
{t('admin.campaigns.overview.registrations').toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => assignMutation.mutate(campaign.id)}
|
||||
disabled={assignMutation.isPending}
|
||||
className="shrink-0 rounded-lg bg-accent-500/20 px-4 py-2 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/30 disabled:opacity-50"
|
||||
>
|
||||
{t('admin.partnerDetail.campaigns.assign')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Show campaigns assigned to other partners as greyed out */}
|
||||
{takenByOthers.map((campaign) => (
|
||||
<div
|
||||
key={campaign.id}
|
||||
className="rounded-xl border border-dark-700/50 bg-dark-800 p-4 opacity-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-dark-300">{campaign.name}</span>
|
||||
<span className="rounded bg-purple-500/20 px-1.5 py-0.5 text-xs text-purple-400">
|
||||
{campaign.partner_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-dark-500">
|
||||
<span className="font-mono">?start={campaign.start_parameter}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assignMutation.isError && (
|
||||
<div className="mt-4 rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{t('common.error')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/pages/AdminPartnerCommission.tsx
Normal file
107
src/pages/AdminPartnerCommission.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { partnerApi } from '../api/partners';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
export default function AdminPartnerCommission() {
|
||||
const { t } = useTranslation();
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Try to get current commission from navigate state
|
||||
const passedCommission = (location.state as { currentCommission?: number } | null)
|
||||
?.currentCommission;
|
||||
|
||||
const { data: partner } = useQuery({
|
||||
queryKey: ['admin-partner-detail', userId],
|
||||
queryFn: () => partnerApi.getPartnerDetail(Number(userId)),
|
||||
enabled: passedCommission === undefined && !!userId,
|
||||
});
|
||||
|
||||
const currentCommission = passedCommission ?? partner?.commission_percent ?? 0;
|
||||
const [commissionValue, setCommissionValue] = useState(String(currentCommission));
|
||||
|
||||
// Sync commission value when data loads asynchronously
|
||||
useEffect(() => {
|
||||
if (partner?.commission_percent != null && passedCommission === undefined) {
|
||||
setCommissionValue(String(partner.commission_percent));
|
||||
}
|
||||
}, [partner?.commission_percent, passedCommission]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (commission: number) => partnerApi.updateCommission(Number(userId), commission),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
|
||||
navigate(`/admin/partners/${userId}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to={`/admin/partners/${userId}`} />
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{t('admin.partnerDetail.commissionDialog.title')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-6">
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('admin.partnerDetail.commissionDialog.description')}
|
||||
</p>
|
||||
|
||||
<div className="mb-2 text-sm text-dark-500">
|
||||
{t('admin.partnerDetail.commission.title')}: {currentCommission}%
|
||||
</div>
|
||||
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('admin.partnerDetail.commissionDialog.label')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={commissionValue}
|
||||
onChange={(e) => setCommissionValue(e.target.value)}
|
||||
className="mb-6 w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => navigate(`/admin/partners/${userId}`)}
|
||||
className="flex-1 rounded-lg bg-dark-700 px-4 py-3 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const val = Number(commissionValue);
|
||||
if (val >= 1 && val <= 100) updateMutation.mutate(val);
|
||||
}}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
!commissionValue ||
|
||||
Number(commissionValue) < 1 ||
|
||||
Number(commissionValue) > 100
|
||||
}
|
||||
className="flex-1 rounded-lg bg-accent-500 px-4 py-3 font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{updateMutation.isError && (
|
||||
<div className="mt-4 rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{t('common.error')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
297
src/pages/AdminPartnerDetail.tsx
Normal file
297
src/pages/AdminPartnerDetail.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
const unknownStatus = {
|
||||
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();
|
||||
|
||||
const unassignMutation = useMutation({
|
||||
mutationFn: (campaignId: number) => partnerApi.unassignCampaign(Number(userId), campaignId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch partner detail
|
||||
const {
|
||||
data: partner,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-partner-detail', userId],
|
||||
queryFn: () => partnerApi.getPartnerDetail(Number(userId)),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
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] || unknownStatus;
|
||||
|
||||
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={() =>
|
||||
navigate(`/admin/partners/${userId}/commission`, {
|
||||
state: { currentCommission: partner.commission_percent ?? 0 },
|
||||
})
|
||||
}
|
||||
className="rounded-lg bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
>
|
||||
{t('admin.partnerDetail.commission.update')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaigns */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-dark-200">
|
||||
{t('admin.partnerDetail.campaigns.title')}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/admin/partners/${userId}/campaigns/assign`)}
|
||||
className="rounded-lg bg-dark-700 px-3 py-1.5 text-xs text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
>
|
||||
{t('admin.partnerDetail.campaigns.assign')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/campaigns/create?partnerId=${userId}`)}
|
||||
className="rounded-lg bg-accent-500/20 px-3 py-1.5 text-xs font-medium text-accent-400 transition-colors hover:bg-accent-500/30"
|
||||
>
|
||||
{t('admin.partnerDetail.campaigns.createNew')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{partner.campaigns.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-dark-500">
|
||||
{t('admin.partnerDetail.campaigns.noCampaigns')}
|
||||
</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 className="min-w-0 flex-1">
|
||||
<div className="font-medium text-dark-100">{campaign.name}</div>
|
||||
<div className="font-mono text-xs text-dark-500">
|
||||
?start={campaign.start_parameter}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{campaign.is_active ? (
|
||||
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||
{t('admin.partnerDetail.campaigns.active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
{t('admin.partnerDetail.campaigns.inactive')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => unassignMutation.mutate(campaign.id)}
|
||||
disabled={unassignMutation.isPending}
|
||||
className="rounded p-1 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400"
|
||||
title={t('admin.partnerDetail.campaigns.unassign')}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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={() => navigate(`/admin/partners/${userId}/revoke`)}
|
||||
className="w-full rounded-lg bg-error-500/20 px-4 py-3 text-sm font-medium text-error-400 transition-colors hover:bg-error-500/30"
|
||||
>
|
||||
{t('admin.partnerDetail.dangerZone.revokeButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/pages/AdminPartnerRevoke.tsx
Normal file
64
src/pages/AdminPartnerRevoke.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { partnerApi } from '../api/partners';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
export default function AdminPartnerRevoke() {
|
||||
const { t } = useTranslation();
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: () => partnerApi.revokePartner(Number(userId)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-detail', userId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-partner-stats'] });
|
||||
navigate(`/admin/partners/${userId}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to={`/admin/partners/${userId}`} />
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{t('admin.partnerDetail.revokeDialog.title')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-error-500/30 bg-dark-800 p-6">
|
||||
<div className="mb-6 rounded-lg bg-error-500/10 p-4">
|
||||
<p className="text-dark-400">{t('admin.partnerDetail.revokeDialog.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => navigate(`/admin/partners/${userId}`)}
|
||||
className="flex-1 rounded-lg bg-dark-700 px-4 py-3 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => revokeMutation.mutate()}
|
||||
disabled={revokeMutation.isPending}
|
||||
className="flex-1 rounded-lg bg-error-500 px-4 py-3 font-medium text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
||||
>
|
||||
{revokeMutation.isPending
|
||||
? t('common.saving')
|
||||
: t('admin.partnerDetail.dangerZone.revokeButton')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{revokeMutation.isError && (
|
||||
<div className="mt-4 rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{t('common.error')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
src/pages/AdminPartnerSettings.tsx
Normal file
318
src/pages/AdminPartnerSettings.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { 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 { toNumber } from '../utils/inputHelpers';
|
||||
|
||||
type NumberOrEmpty = number | '';
|
||||
|
||||
const SettingsIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AdminPartnerSettings() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['partner-settings'],
|
||||
queryFn: partnerApi.getPartnerSettings,
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState<{
|
||||
referral_program_enabled: boolean;
|
||||
partner_section_visible: boolean;
|
||||
withdrawal_enabled: boolean;
|
||||
withdrawal_min_amount_kopeks: NumberOrEmpty;
|
||||
withdrawal_cooldown_days: NumberOrEmpty;
|
||||
withdrawal_requisites_text: string;
|
||||
}>({
|
||||
referral_program_enabled: true,
|
||||
partner_section_visible: true,
|
||||
withdrawal_enabled: false,
|
||||
withdrawal_min_amount_kopeks: 100000,
|
||||
withdrawal_cooldown_days: 30,
|
||||
withdrawal_requisites_text: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setFormData({
|
||||
referral_program_enabled: settings.referral_program_enabled,
|
||||
partner_section_visible: settings.partner_section_visible,
|
||||
withdrawal_enabled: settings.withdrawal_enabled,
|
||||
withdrawal_min_amount_kopeks: settings.withdrawal_min_amount_kopeks,
|
||||
withdrawal_cooldown_days: settings.withdrawal_cooldown_days,
|
||||
withdrawal_requisites_text: settings.withdrawal_requisites_text,
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: partnerApi.updatePartnerSettings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['partner-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['referral-terms'] });
|
||||
navigate('/admin/partners');
|
||||
},
|
||||
});
|
||||
|
||||
// Validation
|
||||
const isMinAmountValid =
|
||||
formData.withdrawal_min_amount_kopeks !== '' &&
|
||||
formData.withdrawal_min_amount_kopeks >= 0 &&
|
||||
formData.withdrawal_min_amount_kopeks <= 100_000_000;
|
||||
const isCooldownValid =
|
||||
formData.withdrawal_cooldown_days !== '' &&
|
||||
formData.withdrawal_cooldown_days >= 0 &&
|
||||
formData.withdrawal_cooldown_days <= 365;
|
||||
const isValid = !formData.withdrawal_enabled || (isMinAmountValid && isCooldownValid);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isValid) return;
|
||||
updateMutation.mutate({
|
||||
...formData,
|
||||
withdrawal_min_amount_kopeks: toNumber(formData.withdrawal_min_amount_kopeks, 100000),
|
||||
withdrawal_cooldown_days: toNumber(formData.withdrawal_cooldown_days, 30),
|
||||
});
|
||||
};
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/partners" />
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.partners.settings')}</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.partners.settingsLoadError')}</p>
|
||||
<button
|
||||
onClick={() => navigate('/admin/partners')}
|
||||
className="mt-4 text-sm text-dark-400 hover:text-dark-200"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/partners" />
|
||||
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||
<SettingsIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.partners.settings')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.partners.settingsSubtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Referral Program Section */}
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.partners.settingsSection.referralProgram')}
|
||||
</h3>
|
||||
|
||||
{/* Program Enabled */}
|
||||
<div className="mb-4">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.referral_program_enabled}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, referral_program_enabled: e.target.checked })
|
||||
}
|
||||
className="h-5 w-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{t('admin.partners.settingsFields.programEnabled')}
|
||||
</div>
|
||||
<div className="text-sm text-dark-500">
|
||||
{t('admin.partners.settingsFields.programEnabledDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Partner Section Visible */}
|
||||
<div>
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.partner_section_visible}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, partner_section_visible: e.target.checked })
|
||||
}
|
||||
className="h-5 w-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{t('admin.partners.settingsFields.partnerVisible')}
|
||||
</div>
|
||||
<div className="text-sm text-dark-500">
|
||||
{t('admin.partners.settingsFields.partnerVisibleDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Withdrawal Settings Section */}
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.partners.settingsSection.withdrawalSettings')}
|
||||
</h3>
|
||||
|
||||
{/* Withdrawal Enabled */}
|
||||
<div className="mb-6">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.withdrawal_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, withdrawal_enabled: e.target.checked })}
|
||||
className="h-5 w-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{t('admin.partners.settingsFields.withdrawalEnabled')}
|
||||
</div>
|
||||
<div className="text-sm text-dark-500">
|
||||
{t('admin.partners.settingsFields.withdrawalEnabledDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Min Amount */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.partners.settingsFields.minAmount')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100000000}
|
||||
value={formData.withdrawal_min_amount_kopeks}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === '')
|
||||
return setFormData({ ...formData, withdrawal_min_amount_kopeks: '' });
|
||||
const num = parseInt(val);
|
||||
if (!isNaN(num)) setFormData({ ...formData, withdrawal_min_amount_kopeks: num });
|
||||
}}
|
||||
className="input"
|
||||
disabled={!formData.withdrawal_enabled}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.partners.settingsFields.minAmountDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cooldown Days */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.partners.settingsFields.cooldownDays')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={365}
|
||||
value={formData.withdrawal_cooldown_days}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === '') return setFormData({ ...formData, withdrawal_cooldown_days: '' });
|
||||
const num = parseInt(val);
|
||||
if (!isNaN(num)) setFormData({ ...formData, withdrawal_cooldown_days: num });
|
||||
}}
|
||||
className="input"
|
||||
disabled={!formData.withdrawal_enabled}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.partners.settingsFields.cooldownDaysDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Requisites Text */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.partners.settingsFields.requisitesText')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.withdrawal_requisites_text}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, withdrawal_requisites_text: e.target.value })
|
||||
}
|
||||
className="input min-h-[80px] w-full"
|
||||
maxLength={2000}
|
||||
disabled={!formData.withdrawal_enabled}
|
||||
placeholder={t('admin.partners.settingsFields.requisitesTextPlaceholder')}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.partners.settingsFields.requisitesTextDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/partners')}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || updateMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
{t('common.saving')}
|
||||
</span>
|
||||
) : (
|
||||
t('common.save')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{updateMutation.isError && (
|
||||
<div className="rounded-lg border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{t('admin.partners.settingsUpdateError')}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
268
src/pages/AdminPartners.tsx
Normal file
268
src/pages/AdminPartners.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery } 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 { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'partners' | 'applications'>('partners');
|
||||
|
||||
// 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' }),
|
||||
});
|
||||
|
||||
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 className="flex-1">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => navigate('/admin/partners/settings')}
|
||||
className="rounded-lg bg-dark-800 p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
title={t('admin.partners.settings')}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</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={() =>
|
||||
navigate(`/admin/partners/applications/${app.id}/review`, {
|
||||
state: { application: app },
|
||||
})
|
||||
}
|
||||
className="flex-1 rounded-lg bg-accent-500/20 px-4 py-2 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/30"
|
||||
>
|
||||
{t('admin.partners.actions.review')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
381
src/pages/AdminWithdrawalDetail.tsx
Normal file
381
src/pages/AdminWithdrawalDetail.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { withdrawalApi } from '../api/withdrawals';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import {
|
||||
formatDate,
|
||||
getWithdrawalStatusBadge,
|
||||
getRiskColor,
|
||||
getRiskLevelColor,
|
||||
} from '../utils/withdrawalUtils';
|
||||
|
||||
// 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();
|
||||
|
||||
// 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 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 = getWithdrawalStatusBadge(detail.status);
|
||||
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={() =>
|
||||
navigate(`/admin/withdrawals/${id}/reject`, {
|
||||
state: {
|
||||
amountKopeks: detail.amount_kopeks,
|
||||
username: detail.username,
|
||||
firstName: detail.first_name,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex-1 rounded-lg bg-error-500 px-4 py-3 font-medium text-white transition-colors hover:bg-error-600"
|
||||
>
|
||||
{t('admin.withdrawals.detail.reject')}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/pages/AdminWithdrawalReject.tsx
Normal file
100
src/pages/AdminWithdrawalReject.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { withdrawalApi } from '../api/withdrawals';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
export default function AdminWithdrawalReject() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
// Try to get withdrawal summary from navigate state
|
||||
const passedDetail = location.state as {
|
||||
amountKopeks?: number;
|
||||
username?: string;
|
||||
firstName?: string;
|
||||
} | null;
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (rejectComment: string) =>
|
||||
withdrawalApi.reject(Number(id), rejectComment || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawal-detail', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawals'] });
|
||||
navigate(`/admin/withdrawals/${id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const displayName = passedDetail?.username
|
||||
? `@${passedDetail.username}`
|
||||
: passedDetail?.firstName || '';
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to={`/admin/withdrawals/${id}`} />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{t('admin.withdrawals.detail.rejectTitle')}
|
||||
</h1>
|
||||
{passedDetail?.amountKopeks != null && passedDetail.amountKopeks > 0 && (
|
||||
<p className="text-sm text-dark-400">
|
||||
#{id} {'\u2022'} {formatWithCurrency(passedDetail.amountKopeks / 100, 0)}
|
||||
{displayName && ` \u2022 ${displayName}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-error-500/30 bg-dark-800 p-6">
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.rejectDescription')}
|
||||
</p>
|
||||
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('admin.withdrawals.detail.commentPlaceholder')}
|
||||
</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder={t('admin.withdrawals.detail.commentPlaceholder')}
|
||||
className="mb-6 w-full rounded-lg border border-dark-600 bg-dark-700 p-3 text-sm text-dark-200 placeholder:text-dark-500 focus:border-accent-500 focus:outline-none"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => navigate(`/admin/withdrawals/${id}`)}
|
||||
className="flex-1 rounded-lg bg-dark-700 px-4 py-3 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectMutation.mutate(comment)}
|
||||
disabled={rejectMutation.isPending}
|
||||
className="flex-1 rounded-lg bg-error-500 px-4 py-3 font-medium text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
||||
>
|
||||
{rejectMutation.isPending
|
||||
? t('admin.withdrawals.detail.rejecting')
|
||||
: t('admin.withdrawals.detail.confirmReject')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rejectMutation.isError && (
|
||||
<div className="mt-4 rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{t('common.error')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/pages/AdminWithdrawals.tsx
Normal file
174
src/pages/AdminWithdrawals.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { withdrawalApi, AdminWithdrawalItem } from '../api/withdrawals';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { formatDate, getWithdrawalStatusBadge, getRiskColor } from '../utils/withdrawalUtils';
|
||||
|
||||
// Status filter tabs
|
||||
type StatusFilter = 'all' | 'pending' | 'approved' | 'rejected' | 'completed' | 'cancelled';
|
||||
|
||||
const STATUS_FILTERS: StatusFilter[] = [
|
||||
'all',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'completed',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
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 = getWithdrawalStatusBadge(item.status);
|
||||
const riskColor = getRiskColor(item.risk_score);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => navigate(`/admin/withdrawals/${item.id}`)}
|
||||
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/40 p-4 text-left transition-colors hover:border-dark-600 hover:bg-dark-800/60"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* User and amount */}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="truncate font-medium text-dark-100">
|
||||
{item.username
|
||||
? `@${item.username}`
|
||||
: item.first_name || `#${item.user_id}`}
|
||||
</span>
|
||||
<span className="font-semibold text-dark-100">
|
||||
{formatWithCurrency(item.amount_kopeks / 100, 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status badge + Risk score + Date */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${badge.bgColor} ${badge.color}`}
|
||||
>
|
||||
{t(badge.labelKey)}
|
||||
</span>
|
||||
|
||||
{/* Risk score */}
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${riskColor.bg} ${riskColor.text}`}
|
||||
>
|
||||
{t('admin.withdrawals.risk')} {item.risk_score}
|
||||
</span>
|
||||
|
||||
{/* Risk level */}
|
||||
<span className="text-xs text-dark-500">
|
||||
{t(`admin.withdrawals.detail.riskLevel.${item.risk_level}`)}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-dark-500">{formatDate(item.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chevron right */}
|
||||
<svg
|
||||
className="mt-1 h-5 w-5 shrink-0 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { referralApi } from '../api/referral';
|
||||
import { brandingApi } from '../api/branding';
|
||||
import { partnerApi } from '../api/partners';
|
||||
import { withdrawalApi } from '../api/withdrawals';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
const LinkIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
@@ -28,10 +41,59 @@ const ShareIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PartnerIcon = () => (
|
||||
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0112 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 01-.673-.38m0 0A2.18 2.18 0 013 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 013.413-.387m7.5 0V5.25A2.25 2.25 0 0013.5 3h-3a2.25 2.25 0 00-2.25 2.25v.894m7.5 0a48.667 48.667 0 00-7.5 0M12 12.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WalletIcon = () => (
|
||||
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
function getWithdrawalStatusBadge(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'badge-success';
|
||||
case 'approved':
|
||||
return 'badge-info';
|
||||
case 'pending':
|
||||
return 'badge-warning';
|
||||
case 'rejected':
|
||||
case 'cancelled':
|
||||
return 'badge-error';
|
||||
default:
|
||||
return 'badge-neutral';
|
||||
}
|
||||
}
|
||||
|
||||
export default function Referral() {
|
||||
const { t } = useTranslation();
|
||||
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
|
||||
const navigate = useNavigate();
|
||||
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
|
||||
const queryClient = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
|
||||
const { data: info, isLoading } = useQuery({
|
||||
queryKey: ['referral-info'],
|
||||
@@ -64,6 +126,36 @@ export default function Referral() {
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
// Partner status query
|
||||
const { data: partnerStatus } = useQuery({
|
||||
queryKey: ['partner-status'],
|
||||
queryFn: partnerApi.getStatus,
|
||||
});
|
||||
|
||||
const isPartner = partnerStatus?.partner_status === 'approved';
|
||||
|
||||
// Withdrawal queries (only when partner is approved)
|
||||
const { data: withdrawalBalance } = useQuery({
|
||||
queryKey: ['withdrawal-balance'],
|
||||
queryFn: withdrawalApi.getBalance,
|
||||
enabled: isPartner,
|
||||
});
|
||||
|
||||
const { data: withdrawalHistory } = useQuery({
|
||||
queryKey: ['withdrawal-history'],
|
||||
queryFn: withdrawalApi.getHistory,
|
||||
enabled: isPartner,
|
||||
});
|
||||
|
||||
// Withdrawal cancel mutation
|
||||
const cancelWithdrawalMutation = useMutation({
|
||||
mutationFn: withdrawalApi.cancel,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['withdrawal-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['withdrawal-history'] });
|
||||
},
|
||||
});
|
||||
|
||||
const copyLink = () => {
|
||||
if (referralLink) {
|
||||
navigator.clipboard.writeText(referralLink);
|
||||
@@ -133,6 +225,12 @@ export default function Referral() {
|
||||
);
|
||||
}
|
||||
|
||||
const partnerStatusValue = partnerStatus?.partner_status ?? 'none';
|
||||
const showApplySection = partnerStatusValue === 'none';
|
||||
const showPendingSection = partnerStatusValue === 'pending';
|
||||
const showApprovedSection = partnerStatusValue === 'approved';
|
||||
const showRejectedSection = partnerStatusValue === 'rejected';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('referral.title')}</h1>
|
||||
@@ -301,6 +399,372 @@ export default function Referral() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== Partner Application Section ==================== */}
|
||||
|
||||
{/* Status: none — Become a Partner CTA */}
|
||||
{terms?.partner_section_visible !== false && 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={() => navigate('/referral/partner/apply')}
|
||||
className="btn-primary mt-4 px-6"
|
||||
>
|
||||
{t('referral.partner.applyButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status: pending — Application Under Review */}
|
||||
{terms?.partner_section_visible !== false && 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 */}
|
||||
{terms?.partner_section_visible !== false && 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 */}
|
||||
{terms?.partner_section_visible !== false && 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={() => navigate('/referral/partner/apply')}
|
||||
className="btn-primary mt-4 px-6"
|
||||
>
|
||||
{t('referral.partner.reapplyButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== Partner Campaigns Section ==================== */}
|
||||
|
||||
{terms?.partner_section_visible !== false &&
|
||||
isPartner &&
|
||||
partnerStatus?.campaigns &&
|
||||
partnerStatus.campaigns.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="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">
|
||||
<LinkIcon />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('referral.partner.yourCampaigns')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{partnerStatus.campaigns.map((campaign) => {
|
||||
const copyLink = (url: string, key: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopiedLink(key);
|
||||
setTimeout(() => setCopiedLink(null), 2000);
|
||||
};
|
||||
|
||||
const botKey = `${campaign.id}-bot`;
|
||||
const webKey = `${campaign.id}-web`;
|
||||
|
||||
return (
|
||||
<div key={campaign.id} className="bento-card space-y-4">
|
||||
{/* Campaign header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-dark-100">{campaign.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* Bonus info */}
|
||||
{campaign.bonus_type !== 'none' && (
|
||||
<div className="rounded-lg bg-success-500/10 p-3">
|
||||
<div className="mb-1 text-xs font-medium text-success-500">
|
||||
{t('referral.partner.campaignBonus.title')}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-success-400">
|
||||
{campaign.bonus_type === 'balance' &&
|
||||
t('referral.partner.campaignBonus.balanceDesc', {
|
||||
amount: formatWithCurrency(campaign.balance_bonus_kopeks / 100, 0),
|
||||
})}
|
||||
{campaign.bonus_type === 'subscription' &&
|
||||
t('referral.partner.campaignBonus.subscriptionDesc', {
|
||||
days: campaign.subscription_duration_days ?? 0,
|
||||
...(campaign.subscription_traffic_gb
|
||||
? { traffic: campaign.subscription_traffic_gb }
|
||||
: {}),
|
||||
})}
|
||||
{campaign.bonus_type === 'tariff' &&
|
||||
t('referral.partner.campaignBonus.tariffDesc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bot link */}
|
||||
{campaign.deep_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.bot')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.deep_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyLink(campaign.deep_link!, botKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2 ${
|
||||
copiedLink === botKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === botKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Web link */}
|
||||
{campaign.web_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.web')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.web_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyLink(campaign.web_link!, webKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2 ${
|
||||
copiedLink === webKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === webKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== Withdrawal Section (approved partners only) ==================== */}
|
||||
|
||||
{terms?.partner_section_visible !== false && 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={() => navigate('/referral/withdrawal/request')}
|
||||
disabled={!withdrawalBalance.can_request}
|
||||
className={`btn-primary w-full px-6 sm:w-auto ${
|
||||
!withdrawalBalance.can_request ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{t('referral.withdrawal.requestButton')}
|
||||
</button>
|
||||
{!withdrawalBalance.can_request && withdrawalBalance.cannot_request_reason ? (
|
||||
<p className="mt-2 text-xs text-dark-500">
|
||||
{withdrawalBalance.cannot_request_reason}
|
||||
</p>
|
||||
) : (
|
||||
withdrawalBalance.min_amount_kopeks > 0 && (
|
||||
<p className="mt-2 text-xs text-dark-500">
|
||||
{t('referral.withdrawal.minAmount', {
|
||||
amount: formatWithCurrency(withdrawalBalance.min_amount_kopeks / 100),
|
||||
})}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Withdrawal History */}
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('referral.withdrawal.history')}
|
||||
</h2>
|
||||
{withdrawalHistory?.items && withdrawalHistory.items.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{withdrawalHistory.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-dark-100">
|
||||
{formatWithCurrency(item.amount_rubles)}
|
||||
</span>
|
||||
<span className={getWithdrawalStatusBadge(item.status)}>
|
||||
{t(`referral.withdrawal.status.${item.status}`, item.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{new Date(item.created_at).toLocaleDateString()}
|
||||
{item.payment_details && (
|
||||
<span className="ml-1">
|
||||
•{' '}
|
||||
{item.payment_details.length > 40
|
||||
? `${item.payment_details.slice(0, 40)}...`
|
||||
: item.payment_details}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.admin_comment && (
|
||||
<div className="mt-1 text-xs text-dark-400">{item.admin_comment}</div>
|
||||
)}
|
||||
</div>
|
||||
{item.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => cancelWithdrawalMutation.mutate(item.id)}
|
||||
disabled={cancelWithdrawalMutation.isPending}
|
||||
className="ml-3 shrink-0 text-sm text-error-400 transition-colors hover:text-error-300"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<div className="text-dark-400">{t('referral.withdrawal.noHistory')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
157
src/pages/ReferralPartnerApply.tsx
Normal file
157
src/pages/ReferralPartnerApply.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { partnerApi, type PartnerApplicationRequest } from '../api/partners';
|
||||
|
||||
export default function ReferralPartnerApply() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [form, setForm] = useState<PartnerApplicationRequest>({
|
||||
company_name: '',
|
||||
website_url: '',
|
||||
telegram_channel: '',
|
||||
description: '',
|
||||
expected_monthly_referrals: undefined,
|
||||
});
|
||||
|
||||
// Guard: redirect if already approved or pending
|
||||
const { data: partnerStatus } = useQuery({
|
||||
queryKey: ['partner-status'],
|
||||
queryFn: partnerApi.getStatus,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
partnerStatus?.partner_status === 'approved' ||
|
||||
partnerStatus?.partner_status === 'pending'
|
||||
) {
|
||||
navigate('/referral', { replace: true });
|
||||
}
|
||||
}, [partnerStatus, navigate]);
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: partnerApi.apply,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['partner-status'] });
|
||||
navigate('/referral');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const payload: PartnerApplicationRequest = {};
|
||||
if (form.company_name) payload.company_name = form.company_name;
|
||||
if (form.website_url) payload.website_url = form.website_url;
|
||||
if (form.telegram_channel) payload.telegram_channel = form.telegram_channel;
|
||||
if (form.description) payload.description = form.description;
|
||||
if (form.expected_monthly_referrals) {
|
||||
payload.expected_monthly_referrals = form.expected_monthly_referrals;
|
||||
}
|
||||
applyMutation.mutate(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('referral.partner.applyTitle')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('referral.partner.applyDesc')}</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="bento-card space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.partner.fields.companyName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full"
|
||||
value={form.company_name ?? ''}
|
||||
onChange={(e) => setForm({ ...form, company_name: e.target.value })}
|
||||
placeholder={t('referral.partner.fields.companyNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.partner.fields.telegramChannel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full"
|
||||
value={form.telegram_channel ?? ''}
|
||||
onChange={(e) => setForm({ ...form, telegram_channel: e.target.value })}
|
||||
placeholder={t('referral.partner.fields.telegramChannelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.partner.fields.websiteUrl')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
className="input w-full"
|
||||
value={form.website_url ?? ''}
|
||||
onChange={(e) => setForm({ ...form, website_url: e.target.value })}
|
||||
placeholder={t('referral.partner.fields.websiteUrlPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.partner.fields.description')}
|
||||
</label>
|
||||
<textarea
|
||||
className="input min-h-[80px] w-full"
|
||||
value={form.description ?? ''}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder={t('referral.partner.fields.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.partner.fields.expectedReferrals')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input w-full"
|
||||
value={form.expected_monthly_referrals ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
expected_monthly_referrals: e.target.value ? Number(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t('referral.partner.fields.expectedReferralsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{applyMutation.isError && (
|
||||
<div className="rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{t('referral.partner.applyError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/referral')}
|
||||
className="btn-secondary flex-1 px-5"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={applyMutation.isPending}
|
||||
className={`btn-primary flex-1 px-5 ${applyMutation.isPending ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{applyMutation.isPending
|
||||
? t('referral.partner.applying')
|
||||
: t('referral.partner.submitApplication')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/pages/ReferralWithdrawalRequest.tsx
Normal file
136
src/pages/ReferralWithdrawalRequest.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { withdrawalApi } from '../api/withdrawals';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
export default function ReferralWithdrawalRequest() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { formatWithCurrency, currencySymbol } = useCurrency();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
amount_kopeks: 0,
|
||||
payment_details: '',
|
||||
});
|
||||
|
||||
const { data: balance } = useQuery({
|
||||
queryKey: ['withdrawal-balance'],
|
||||
queryFn: withdrawalApi.getBalance,
|
||||
});
|
||||
|
||||
// Guard: redirect if can't request
|
||||
useEffect(() => {
|
||||
if (balance && !balance.can_request) {
|
||||
navigate('/referral', { replace: true });
|
||||
}
|
||||
}, [balance, navigate]);
|
||||
|
||||
const withdrawMutation = useMutation({
|
||||
mutationFn: withdrawalApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['withdrawal-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['withdrawal-history'] });
|
||||
navigate('/referral');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (form.payment_details.length < 5) return;
|
||||
if (form.amount_kopeks <= 0) return;
|
||||
withdrawMutation.mutate({
|
||||
amount_kopeks: form.amount_kopeks,
|
||||
payment_details: form.payment_details,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('referral.withdrawal.requestTitle')}</h1>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('referral.withdrawal.requestDesc', {
|
||||
available: balance ? formatWithCurrency(balance.available_total / 100) : '',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="bento-card space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.withdrawal.fields.amount')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={balance?.min_amount_kopeks ?? 0}
|
||||
max={balance?.available_total ?? 0}
|
||||
step={100}
|
||||
className="input w-full"
|
||||
value={form.amount_kopeks || ''}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
amount_kopeks: e.target.value ? Number(e.target.value) : 0,
|
||||
})
|
||||
}
|
||||
placeholder={t('referral.withdrawal.fields.amountPlaceholder')}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('referral.withdrawal.fields.amountHint', { currency: currencySymbol })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{balance?.requisites_text || t('referral.withdrawal.fields.paymentDetails')}
|
||||
</label>
|
||||
<textarea
|
||||
className="input min-h-[80px] w-full"
|
||||
value={form.payment_details}
|
||||
onChange={(e) => setForm({ ...form, payment_details: e.target.value })}
|
||||
placeholder={t('referral.withdrawal.fields.paymentDetailsPlaceholder')}
|
||||
required
|
||||
minLength={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{withdrawMutation.isError && (
|
||||
<div className="rounded-lg bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{t('referral.withdrawal.requestError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/referral')}
|
||||
className="btn-secondary flex-1 px-5"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
withdrawMutation.isPending ||
|
||||
form.payment_details.length < 5 ||
|
||||
form.amount_kopeks <= 0
|
||||
}
|
||||
className={`btn-primary flex-1 px-5 ${
|
||||
withdrawMutation.isPending ||
|
||||
form.payment_details.length < 5 ||
|
||||
form.amount_kopeks <= 0
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{withdrawMutation.isPending
|
||||
? t('referral.withdrawal.requesting')
|
||||
: t('referral.withdrawal.submitRequest')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -822,6 +822,12 @@ export default function Subscription() {
|
||||
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} /{' '}
|
||||
{subscription.traffic_limit_gb || '∞'} GB
|
||||
</div>
|
||||
{subscription.traffic_reset_mode &&
|
||||
subscription.traffic_reset_mode !== 'NO_RESET' && (
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{t(`subscription.trafficReset.${subscription.traffic_reset_mode}`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
|
||||
@@ -829,10 +835,10 @@ export default function Subscription() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Servers */}
|
||||
{/* Locations */}
|
||||
{subscription.servers && subscription.servers.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 text-sm text-dark-500">{t('subscription.serversLabel')}</div>
|
||||
<div className="mb-2 text-sm text-dark-500">{t('subscription.locationsLabel')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.servers.map((server) => (
|
||||
<span
|
||||
@@ -2380,7 +2386,9 @@ export default function Subscription() {
|
||||
{t('subscription.devices', { count: tariff.device_limit })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Servers */}
|
||||
{/* Traffic Reset */}
|
||||
{tariff.traffic_reset_mode &&
|
||||
tariff.traffic_reset_mode !== 'NO_RESET' && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg
|
||||
className="h-4 w-4 text-dark-400"
|
||||
@@ -2392,13 +2400,14 @@ export default function Subscription() {
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M2.985 19.644l3.181-3.182"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-dark-300">
|
||||
{t('subscription.servers', { count: tariff.servers_count })}
|
||||
{t(`subscription.trafficReset.${tariff.traffic_reset_mode}`)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Price info - daily or period-based */}
|
||||
<div className="mt-3 border-t border-dark-700/50 pt-3 text-sm text-dark-400">
|
||||
@@ -2595,26 +2604,8 @@ export default function Subscription() {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-dark-500">{t('subscription.serversLabel')}:</span>
|
||||
<span className="ml-2 text-dark-200">{selectedTariff.servers_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
{selectedTariff.servers.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedTariff.servers.map((server) => (
|
||||
<span key={server.uuid} className="badge-secondary">
|
||||
{server.name}
|
||||
</span>
|
||||
))}
|
||||
{selectedTariff.servers_count > selectedTariff.servers.length && (
|
||||
<span className="text-sm text-dark-500">
|
||||
+{selectedTariff.servers_count - selectedTariff.servers.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Tariff Purchase */}
|
||||
{selectedTariff.is_daily ||
|
||||
|
||||
@@ -116,6 +116,12 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Don't reconnect on auth failures (1008 = Policy Violation / invalid token)
|
||||
if (event.code === 1008) {
|
||||
if (isDev) console.log('[WS] Auth rejected, not reconnecting');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to reconnect if not closed intentionally
|
||||
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
const delay = Math.min(
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface Subscription {
|
||||
// Tariff info
|
||||
tariff_id?: number;
|
||||
tariff_name?: string;
|
||||
traffic_reset_mode?: string;
|
||||
}
|
||||
|
||||
// Response wrapper for subscription status endpoint
|
||||
@@ -316,6 +317,8 @@ export interface Tariff {
|
||||
daily_discount_percent?: number;
|
||||
original_price_per_day_kopeks?: number;
|
||||
custom_days_discount_percent?: number;
|
||||
// Traffic reset
|
||||
traffic_reset_mode?: string;
|
||||
}
|
||||
|
||||
export interface TariffsPurchaseOptions {
|
||||
@@ -431,6 +434,7 @@ export interface ReferralTerms {
|
||||
first_topup_bonus_rubles: number;
|
||||
inviter_bonus_kopeks: number;
|
||||
inviter_bonus_rubles: number;
|
||||
partner_section_visible?: boolean;
|
||||
}
|
||||
|
||||
// Ticket types
|
||||
|
||||
83
src/utils/withdrawalUtils.ts
Normal file
83
src/utils/withdrawalUtils.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import i18n from '../i18n';
|
||||
|
||||
export const localeMap: Record<string, string> = {
|
||||
ru: 'ru-RU',
|
||||
en: 'en-US',
|
||||
zh: 'zh-CN',
|
||||
fa: 'fa-IR',
|
||||
};
|
||||
|
||||
export 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',
|
||||
});
|
||||
};
|
||||
|
||||
export type StatusBadge = { labelKey: string; color: string; bgColor: string };
|
||||
|
||||
export const withdrawalStatusBadgeConfig: Record<string, StatusBadge> = {
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
const unknownBadge: StatusBadge = {
|
||||
labelKey: 'admin.withdrawals.status.unknown',
|
||||
color: 'text-dark-400',
|
||||
bgColor: 'bg-dark-600',
|
||||
};
|
||||
|
||||
export function getWithdrawalStatusBadge(status: string): StatusBadge {
|
||||
return withdrawalStatusBadgeConfig[status] || unknownBadge;
|
||||
}
|
||||
|
||||
export 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' };
|
||||
}
|
||||
|
||||
export 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' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user