From d095f428dc2b62e655c134589ec85f2aebb2e1bb Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 08:48:36 +0300 Subject: [PATCH 01/47] Add files via upload --- src/pages/AdminPanel.tsx | 16 ++ src/pages/AdminPayments.tsx | 283 ++++++++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 src/pages/AdminPayments.tsx diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index e13bb32..a7fad73 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -75,6 +75,12 @@ const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( ) +const PaymentsIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + + + +) + const ChevronRightIcon = () => ( @@ -244,6 +250,16 @@ export default function AdminPanel() { bgColor: 'bg-indigo-500/20', textColor: 'text-indigo-400' }, + { + to: '/admin/payments', + icon: , + mobileIcon: , + title: t('admin.nav.payments', 'Платежи'), + description: t('admin.panel.paymentsDesc', 'Проверка платежей'), + color: 'lime', + bgColor: 'bg-lime-500/20', + textColor: 'text-lime-400' + }, ] return ( diff --git a/src/pages/AdminPayments.tsx b/src/pages/AdminPayments.tsx new file mode 100644 index 0000000..95aad68 --- /dev/null +++ b/src/pages/AdminPayments.tsx @@ -0,0 +1,283 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { adminPaymentsApi } from '../api/adminPayments' +import { useCurrency } from '../hooks/useCurrency' +import type { PendingPayment, PaginatedResponse } from '../types' + +export default function AdminPayments() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const { formatAmount, currencySymbol } = useCurrency() + + const [page, setPage] = useState(1) + const [methodFilter, setMethodFilter] = useState('') + const [checkingPaymentId, setCheckingPaymentId] = useState(null) + + // Fetch payments + const { data: payments, isLoading, refetch } = useQuery>({ + queryKey: ['admin-payments', page, methodFilter], + queryFn: () => adminPaymentsApi.getPendingPayments({ + page, + per_page: 20, + method_filter: methodFilter || undefined, + }), + refetchInterval: 30000, // Auto-refresh every 30 seconds + }) + + // Fetch stats + const { data: stats } = useQuery({ + queryKey: ['admin-payments-stats'], + queryFn: adminPaymentsApi.getStats, + refetchInterval: 30000, + }) + + // Check payment mutation + const checkPaymentMutation = useMutation({ + mutationFn: ({ method, paymentId }: { method: string; paymentId: number }) => + adminPaymentsApi.checkPaymentStatus(method, paymentId), + onSuccess: async (result) => { + if (result.status_changed) { + await refetch() + queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] }) + } else { + await refetch() + } + }, + onSettled: () => { + setCheckingPaymentId(null) + }, + }) + + const handleCheckPayment = (payment: PendingPayment) => { + setCheckingPaymentId(`${payment.method}_${payment.id}`) + checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id }) + } + + // Get unique methods from stats for filter + const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : [] + + return ( +
+ {/* Header */} +
+
+ + + + + +
+

{t('admin.payments.title', 'Проверка платежей')}

+

{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}

+
+
+ +
+ + {/* Stats cards */} + {stats && ( +
+
+
{stats.total_pending}
+
{t('admin.payments.totalPending', 'Всего ожидает')}
+
+ {Object.entries(stats.by_method).map(([method, count]) => ( +
setMethodFilter(methodFilter === method ? '' : method)} + > +
{count}
+
{method}
+
+ ))} +
+ )} + + {/* Filter */} + {methodOptions.length > 0 && ( +
+ {t('admin.payments.filterByMethod', 'Фильтр по методу')}: + + {methodOptions.map((method) => ( + + ))} +
+ )} + + {/* Payments list */} +
+ {isLoading ? ( +
+
+
+ ) : payments?.items && payments.items.length > 0 ? ( +
+ {payments.items.map((payment) => { + const paymentKey = `${payment.method}_${payment.id}` + const isChecking = checkingPaymentId === paymentKey + + return ( +
+
+
+
+ {payment.method_display} + + {payment.status_emoji} {payment.status_text} + + {payment.is_paid && ( + + {t('admin.payments.paid', 'Оплачено')} + + )} +
+
+ {formatAmount(payment.amount_rubles)} {currencySymbol} +
+
+ ID: {payment.identifier} +
+
+ {new Date(payment.created_at).toLocaleString()} +
+ {/* User info */} + {(payment.user_username || payment.user_telegram_id) && ( +
+ {t('admin.payments.user', 'Пользователь')}:{' '} + {payment.user_username ? ( + @{payment.user_username} + ) : ( + ID: {payment.user_telegram_id} + )} +
+ )} +
+
+ {payment.payment_url && ( + + {t('admin.payments.openLink', 'Открыть ссылку')} + + )} + {payment.is_checkable && ( + + )} +
+
+ {/* Show result after check */} + {checkPaymentMutation.isSuccess && checkPaymentMutation.variables?.paymentId === payment.id && ( +
+ {checkPaymentMutation.data?.message} +
+ )} +
+ ) + })} +
+ ) : ( +
+
+ + + +
+
{t('admin.payments.noPayments', 'Нет ожидающих платежей')}
+
+ )} + + {/* Pagination */} + {payments && payments.pages > 1 && ( +
+ +
+ {t('balance.page', '{current} / {total}', { current: payments.page, total: payments.pages })} +
+ +
+ )} +
+
+ ) +} From 42e3ae16be78b0364077a851aa751504474af455 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 08:49:18 +0300 Subject: [PATCH 02/47] Add files via upload --- src/api/adminPayments.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/api/balance.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/api/adminPayments.ts diff --git a/src/api/adminPayments.ts b/src/api/adminPayments.ts new file mode 100644 index 0000000..a169517 --- /dev/null +++ b/src/api/adminPayments.ts @@ -0,0 +1,39 @@ +import apiClient from './client' +import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types' + +export interface PaymentsStats { + total_pending: number + by_method: Record +} + +export const adminPaymentsApi = { + // Get all pending payments (admin) + getPendingPayments: async (params?: { + page?: number + per_page?: number + method_filter?: string + }): Promise> => { + const response = await apiClient.get>('/cabinet/admin/payments', { + params, + }) + return response.data + }, + + // Get payments statistics + getStats: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/payments/stats') + return response.data + }, + + // Get specific payment details + getPayment: async (method: string, paymentId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/payments/${method}/${paymentId}`) + return response.data + }, + + // Manually check payment status + checkPaymentStatus: async (method: string, paymentId: number): Promise => { + const response = await apiClient.post(`/cabinet/admin/payments/${method}/${paymentId}/check`) + return response.data + }, +} diff --git a/src/api/balance.ts b/src/api/balance.ts index b0a4d82..d2aeef4 100644 --- a/src/api/balance.ts +++ b/src/api/balance.ts @@ -1,5 +1,5 @@ -import apiClient from './client' -import type { Balance, Transaction, PaymentMethod, PaginatedResponse } from '../types' +import apiClient from './client' +import type { Balance, Transaction, PaymentMethod, PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types' export const balanceApi = { // Get current balance @@ -73,5 +73,28 @@ export const balanceApi = { }) return response.data }, + + // Get pending payments for manual verification + getPendingPayments: async (params?: { + page?: number + per_page?: number + }): Promise> => { + const response = await apiClient.get>('/cabinet/balance/pending-payments', { + params, + }) + return response.data + }, + + // Get specific pending payment details + getPendingPayment: async (method: string, paymentId: number): Promise => { + const response = await apiClient.get(`/cabinet/balance/pending-payments/${method}/${paymentId}`) + return response.data + }, + + // Manually check payment status + checkPaymentStatus: async (method: string, paymentId: number): Promise => { + const response = await apiClient.post(`/cabinet/balance/pending-payments/${method}/${paymentId}/check`) + return response.data + }, } From d5783d829300b15188178c773c463d75d0188e8f Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 08:52:20 +0300 Subject: [PATCH 03/47] Update App.tsx --- src/App.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index c5a2f3a..02d9471 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import AdminBroadcasts from './pages/AdminBroadcasts' import AdminPromocodes from './pages/AdminPromocodes' import AdminCampaigns from './pages/AdminCampaigns' import AdminUsers from './pages/AdminUsers' +import AdminPayments from './pages/AdminPayments' function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore() @@ -253,6 +254,14 @@ function App() { } /> + + + + } + /> {/* Catch all */} } /> From 3a35915c730de4d208f05060cf9492e10a820e98 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 08:52:39 +0300 Subject: [PATCH 04/47] Update index.ts --- src/types/index.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/types/index.ts b/src/types/index.ts index 5fb15cb..64cb61f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -486,3 +486,33 @@ export interface AppConfig { supportUrl?: string } } + +// Pending payment types +export interface PendingPayment { + id: number + method: string + method_display: string + identifier: string + amount_kopeks: number + amount_rubles: number + status: string + status_emoji: string + status_text: string + is_paid: boolean + is_checkable: boolean + created_at: string + expires_at: string | null + payment_url: string | null + user_id?: number + user_telegram_id?: number + user_username?: string | null +} + +export interface ManualCheckResponse { + success: boolean + message: string + payment: PendingPayment | null + status_changed: boolean + old_status: string | null + new_status: string | null +} From 5d1030b3e2d126b47ef92579fa79d5c7c24b1185 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 08:52:55 +0300 Subject: [PATCH 05/47] Add files via upload --- src/locales/en.json | 26 ++++++++++++++++++++++++-- src/locales/ru.json | 26 +++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 17827b9..81fe552 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -305,6 +305,12 @@ "name": "FreeKassa", "description": "Pay via FreeKassa" } + }, + "pendingPayments": { + "title": "Pending Payments", + "pay": "Pay", + "checkStatus": "Check Status", + "checking": "Checking..." } }, "referral": { @@ -440,7 +446,9 @@ "wheel": "Wheel", "tariffs": "Tariffs", "servers": "Servers", - "broadcasts": "Broadcasts" + "broadcasts": "Broadcasts", + "users": "Users", + "payments": "Payments" }, "panel": { "title": "Admin Panel", @@ -452,7 +460,21 @@ "wheelDesc": "Configure fortune wheel and prizes", "tariffsDesc": "Manage tariff plans", "serversDesc": "Configure VPN servers", - "broadcastsDesc": "Mass messaging to users" + "broadcastsDesc": "Mass messaging to users", + "usersDesc": "Manage bot users", + "paymentsDesc": "Payment verification" + }, + "payments": { + "title": "Payment Verification", + "description": "Pending payments in the last 24 hours", + "totalPending": "Total Pending", + "filterByMethod": "Filter by method", + "noPayments": "No pending payments", + "paid": "Paid", + "user": "User", + "openLink": "Open Link", + "checkStatus": "Check Status", + "checking": "Checking..." }, "wheel": { "title": "Fortune Wheel Settings", diff --git a/src/locales/ru.json b/src/locales/ru.json index 2628d26..7f96c08 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1,4 +1,4 @@ -{ +{ "common": { "loading": "Загрузка...", "error": "Ошибка", @@ -305,6 +305,12 @@ "name": "FreeKassa", "description": "Оплата через FreeKassa" } + }, + "pendingPayments": { + "title": "Ожидающие платежи", + "pay": "Оплатить", + "checkStatus": "Проверить статус", + "checking": "Проверка..." } }, "referral": { @@ -441,7 +447,8 @@ "tariffs": "Тарифы", "servers": "Серверы", "broadcasts": "Рассылки", - "users": "Пользователи" + "users": "Пользователи", + "payments": "Платежи" }, "panel": { "title": "Панель администратора", @@ -454,7 +461,20 @@ "tariffsDesc": "Управление тарифными планами", "serversDesc": "Настройка VPN серверов", "broadcastsDesc": "Массовая отправка сообщений", - "usersDesc": "Управление пользователями бота" + "usersDesc": "Управление пользователями бота", + "paymentsDesc": "Проверка платежей" + }, + "payments": { + "title": "Проверка платежей", + "description": "Ожидающие платежи за последние 24 часа", + "totalPending": "Всего ожидает", + "filterByMethod": "Фильтр по методу", + "noPayments": "Нет ожидающих платежей", + "paid": "Оплачено", + "user": "Пользователь", + "openLink": "Открыть ссылку", + "checkStatus": "Проверить статус", + "checking": "Проверка..." }, "wheel": { "title": "Настройки колеса удачи", From a65e67ce5dca3a498a8b9fcceec8ef14f4b6697f Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 09:49:01 +0300 Subject: [PATCH 06/47] Add files via upload --- src/components/PromoOffersSection.tsx | 255 ++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 src/components/PromoOffersSection.tsx diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx new file mode 100644 index 0000000..29aa452 --- /dev/null +++ b/src/components/PromoOffersSection.tsx @@ -0,0 +1,255 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { promoApi, PromoOffer, ActiveDiscount } from '../api/promo' + +// Icons +const GiftIcon = () => ( + + + +) + +const ClockIcon = () => ( + + + +) + +const SparklesIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +const ServerIcon = () => ( + + + +) + +// Helper functions +const formatTimeLeft = (expiresAt: string): string => { + const now = new Date() + const expires = new Date(expiresAt) + const diffMs = expires.getTime() - now.getTime() + + if (diffMs <= 0) return 'Истекло' + + const hours = Math.floor(diffMs / (1000 * 60 * 60)) + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) + + if (hours > 24) { + const days = Math.floor(hours / 24) + return `${days} дн.` + } + if (hours > 0) { + return `${hours}ч ${minutes}м` + } + return `${minutes}м` +} + +const getOfferIcon = (effectType: string) => { + if (effectType === 'test_access') return + return +} + +const getOfferTitle = (offer: PromoOffer): string => { + if (offer.effect_type === 'test_access') { + return 'Тестовый доступ' + } + if (offer.discount_percent) { + return `Скидка ${offer.discount_percent}%` + } + return 'Специальное предложение' +} + +const getOfferDescription = (offer: PromoOffer): string => { + if (offer.effect_type === 'test_access') { + const squads = offer.extra_data?.test_squad_uuids?.length || 0 + return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам' + } + return 'Активируйте скидку на покупку подписки' +} + +interface PromoOffersSectionProps { + className?: string +} + +export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [claimingId, setClaimingId] = useState(null) + const [successMessage, setSuccessMessage] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) + + // Fetch available offers + const { data: offers = [], isLoading: offersLoading } = useQuery({ + queryKey: ['promo-offers'], + queryFn: promoApi.getOffers, + staleTime: 30000, + }) + + // Fetch active discount + const { data: activeDiscount } = useQuery({ + queryKey: ['active-discount'], + queryFn: promoApi.getActiveDiscount, + staleTime: 30000, + }) + + // Claim offer mutation + const claimMutation = useMutation({ + mutationFn: promoApi.claimOffer, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['promo-offers'] }) + queryClient.invalidateQueries({ queryKey: ['active-discount'] }) + queryClient.invalidateQueries({ queryKey: ['subscription'] }) + setSuccessMessage(result.message) + setClaimingId(null) + setTimeout(() => setSuccessMessage(null), 5000) + }, + onError: (error: any) => { + setErrorMessage(error.response?.data?.detail || 'Не удалось активировать предложение') + setClaimingId(null) + setTimeout(() => setErrorMessage(null), 5000) + }, + }) + + const handleClaim = (offerId: number) => { + setClaimingId(offerId) + setErrorMessage(null) + setSuccessMessage(null) + claimMutation.mutate(offerId) + } + + // Filter unclaimed and active offers + const availableOffers = offers.filter(o => o.is_active && !o.is_claimed) + const claimedOffers = offers.filter(o => o.is_claimed) + + // Don't render if no offers and no active discount + if (!offersLoading && availableOffers.length === 0 && (!activeDiscount || !activeDiscount.is_active)) { + return null + } + + return ( +
+ {/* Active Discount Banner */} + {activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && ( +
+
+
+ +
+
+
+

+ Скидка {activeDiscount.discount_percent}% активна +

+ + Действует + +
+
+ {activeDiscount.expires_at && ( +
+ + Истекает: {formatTimeLeft(activeDiscount.expires_at)} +
+ )} +
+
+
+
+ )} + + {/* Success/Error Messages */} + {successMessage && ( +
+ + {successMessage} +
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {/* Available Offers */} + {availableOffers.length > 0 && ( +
+ {availableOffers.map((offer) => ( +
+
+
+ {getOfferIcon(offer.effect_type)} +
+
+
+

+ {getOfferTitle(offer)} +

+ {offer.effect_type === 'test_access' && ( + + Тест + + )} +
+

+ {getOfferDescription(offer)} +

+
+
+ + Осталось: {formatTimeLeft(offer.expires_at)} +
+ +
+
+
+
+ ))} +
+ )} + + {/* Loading State */} + {offersLoading && ( +
+
+
+
+
+
+
+
+
+ )} +
+ ) +} From d42bd3c091e25a655ce41d21d96e6540dcd96265 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 09:49:33 +0300 Subject: [PATCH 07/47] Add files via upload --- src/api/promoOffers.ts | 247 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 src/api/promoOffers.ts diff --git a/src/api/promoOffers.ts b/src/api/promoOffers.ts new file mode 100644 index 0000000..e4bcd2a --- /dev/null +++ b/src/api/promoOffers.ts @@ -0,0 +1,247 @@ +import apiClient from './client' + +// ============== Types ============== + +export interface PromoOfferUserInfo { + id: number + telegram_id: number + username: string | null + first_name: string | null + last_name: string | null + full_name: string | null +} + +export interface PromoOfferSubscriptionInfo { + id: number + status: string + is_trial: boolean + start_date: string + end_date: string + autopay_enabled: boolean +} + +export interface PromoOffer { + id: number + user_id: number + subscription_id: number | null + notification_type: string + discount_percent: number + bonus_amount_kopeks: number + expires_at: string + claimed_at: string | null + is_active: boolean + effect_type: string + extra_data: Record + created_at: string + updated_at: string + user: PromoOfferUserInfo | null + subscription: PromoOfferSubscriptionInfo | null +} + +export interface PromoOfferListResponse { + items: PromoOffer[] + total: number + limit: number + offset: number +} + +export interface PromoOfferCreateRequest { + user_id?: number + telegram_id?: number + notification_type: string + valid_hours: number + discount_percent?: number + bonus_amount_kopeks?: number + subscription_id?: number + effect_type?: string + extra_data?: Record +} + +export interface PromoOfferBroadcastRequest extends PromoOfferCreateRequest { + target?: string +} + +export interface PromoOfferBroadcastResponse { + created_offers: number + user_ids: number[] + target: string | null +} + +export interface PromoOfferTemplate { + id: number + name: string + offer_type: string + message_text: string + button_text: string + valid_hours: number + discount_percent: number + bonus_amount_kopeks: number + active_discount_hours: number | null + test_duration_hours: number | null + test_squad_uuids: string[] + is_active: boolean + created_by: number | null + created_at: string + updated_at: string +} + +export interface PromoOfferTemplateListResponse { + items: PromoOfferTemplate[] +} + +export interface PromoOfferTemplateUpdateRequest { + name?: string + message_text?: string + button_text?: string + valid_hours?: number + discount_percent?: number + bonus_amount_kopeks?: number + active_discount_hours?: number + test_duration_hours?: number + test_squad_uuids?: string[] + is_active?: boolean +} + +export interface PromoOfferLogOfferInfo { + id: number + notification_type: string | null + discount_percent: number | null + bonus_amount_kopeks: number | null + effect_type: string | null + expires_at: string | null + claimed_at: string | null + is_active: boolean | null +} + +export interface PromoOfferLog { + id: number + user_id: number | null + offer_id: number | null + action: string + source: string | null + percent: number | null + effect_type: string | null + details: Record + created_at: string + user: PromoOfferUserInfo | null + offer: PromoOfferLogOfferInfo | null +} + +export interface PromoOfferLogListResponse { + items: PromoOfferLog[] + total: number + limit: number + offset: number +} + +// Target segments for broadcast +export const TARGET_SEGMENTS = { + all: 'Все пользователи', + active: 'Активные подписчики', + trial: 'Триал пользователи', + trial_ending: 'Заканчивается триал', + expiring: 'Заканчивается подписка', + expired: 'Истекшая подписка', + zero: 'Нулевой баланс', + autopay_failed: 'Ошибка автоплатежа', + low_balance: 'Низкий баланс', + inactive_30d: 'Неактивны 30 дней', + inactive_60d: 'Неактивны 60 дней', + inactive_90d: 'Неактивны 90 дней', + custom_today: 'Зарегистрированы сегодня', + custom_week: 'Зарегистрированы за неделю', + custom_month: 'Зарегистрированы за месяц', + custom_active_today: 'Активны сегодня', +} as const + +export type TargetSegment = keyof typeof TARGET_SEGMENTS + +// Offer type configurations +export const OFFER_TYPE_CONFIG = { + test_access: { + icon: '🧪', + label: 'Тестовый доступ', + effect: 'test_access', + description: 'Временный доступ к дополнительным серверам', + }, + extend_discount: { + icon: '💎', + label: 'Скидка на продление', + effect: 'percent_discount', + description: 'Скидка для текущих подписчиков', + }, + purchase_discount: { + icon: '🎯', + label: 'Скидка на покупку', + effect: 'percent_discount', + description: 'Скидка для новых пользователей', + }, +} as const + +export type OfferType = keyof typeof OFFER_TYPE_CONFIG + +// ============== API ============== + +export const promoOffersApi = { + // Get list of promo offers + getOffers: async (params?: { + limit?: number + offset?: number + user_id?: number + telegram_id?: number + notification_type?: string + is_active?: boolean + }): Promise => { + const response = await apiClient.get('/api/promo-offers', { params }) + return response.data + }, + + // Get single offer + getOffer: async (id: number): Promise => { + const response = await apiClient.get(`/api/promo-offers/${id}`) + return response.data + }, + + // Create single offer + createOffer: async (data: PromoOfferCreateRequest): Promise => { + const response = await apiClient.post('/api/promo-offers', data) + return response.data + }, + + // Broadcast offer to multiple users + broadcastOffer: async (data: PromoOfferBroadcastRequest): Promise => { + const response = await apiClient.post('/api/promo-offers/broadcast', data) + return response.data + }, + + // Get promo offer logs + getLogs: async (params?: { + limit?: number + offset?: number + user_id?: number + offer_id?: number + action?: string + source?: string + }): Promise => { + const response = await apiClient.get('/api/promo-offers/logs', { params }) + return response.data + }, + + // Get all templates + getTemplates: async (): Promise => { + const response = await apiClient.get('/api/promo-offers/templates') + return response.data + }, + + // Get single template + getTemplate: async (id: number): Promise => { + const response = await apiClient.get(`/api/promo-offers/templates/${id}`) + return response.data + }, + + // Update template + updateTemplate: async (id: number, data: PromoOfferTemplateUpdateRequest): Promise => { + const response = await apiClient.patch(`/api/promo-offers/templates/${id}`, data) + return response.data + }, +} From 8330a86855998f11314653607c5bd82cc7bb9f7e Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 09:50:09 +0300 Subject: [PATCH 08/47] Add files via upload --- src/pages/AdminPanel.tsx | 16 + src/pages/AdminPromoOffers.tsx | 786 +++++++++++++++++++++++++++++++++ src/pages/Dashboard.tsx | 4 + 3 files changed, 806 insertions(+) create mode 100644 src/pages/AdminPromoOffers.tsx diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index a7fad73..58aa047 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -81,6 +81,12 @@ const PaymentsIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( ) +const PromoOffersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + + + +) + const ChevronRightIcon = () => ( @@ -230,6 +236,16 @@ export default function AdminPanel() { bgColor: 'bg-violet-500/20', textColor: 'text-violet-400' }, + { + to: '/admin/promo-offers', + icon: , + mobileIcon: , + title: t('admin.nav.promoOffers', 'Промопредложения'), + description: t('admin.panel.promoOffersDesc', 'Персональные скидки и предложения'), + color: 'orange', + bgColor: 'bg-orange-500/20', + textColor: 'text-orange-400' + }, { to: '/admin/campaigns', icon: , diff --git a/src/pages/AdminPromoOffers.tsx b/src/pages/AdminPromoOffers.tsx new file mode 100644 index 0000000..3e265b4 --- /dev/null +++ b/src/pages/AdminPromoOffers.tsx @@ -0,0 +1,786 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + promoOffersApi, + PromoOfferTemplate, + PromoOfferTemplateUpdateRequest, + PromoOfferLog, + TARGET_SEGMENTS, + TargetSegment, + OFFER_TYPE_CONFIG, + OfferType, +} from '../api/promoOffers' + +// Icons +const GiftIcon = () => ( + + + +) + +const EditIcon = () => ( + + + +) + +const XIcon = () => ( + + + +) + +const SendIcon = () => ( + + + +) + +const ClockIcon = () => ( + + + +) + +const UserIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +const UsersIcon = () => ( + + + +) + +// Helper functions +const formatDateTime = (date: string | null): string => { + if (!date) return '-' + return new Date(date).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +const getActionLabel = (action: string): string => { + const labels: Record = { + created: 'Создано', + claimed: 'Активировано', + consumed: 'Использовано', + disabled: 'Деактивировано', + } + return labels[action] || action +} + +const getActionColor = (action: string): string => { + const colors: Record = { + created: 'bg-blue-500/20 text-blue-400', + claimed: 'bg-emerald-500/20 text-emerald-400', + consumed: 'bg-purple-500/20 text-purple-400', + disabled: 'bg-dark-600 text-dark-400', + } + return colors[action] || 'bg-dark-600 text-dark-400' +} + +const getOfferTypeIcon = (offerType: string): string => { + return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁' +} + +const getOfferTypeLabel = (offerType: string): string => { + return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType +} + +// Template Edit Modal +interface TemplateEditModalProps { + template: PromoOfferTemplate + onSave: (data: PromoOfferTemplateUpdateRequest) => void + onClose: () => void + isLoading?: boolean +} + +function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEditModalProps) { + const [name, setName] = useState(template.name) + const [messageText, setMessageText] = useState(template.message_text) + const [buttonText, setButtonText] = useState(template.button_text) + const [validHours, setValidHours] = useState(template.valid_hours) + const [discountPercent, setDiscountPercent] = useState(template.discount_percent) + const [activeDiscountHours, setActiveDiscountHours] = useState(template.active_discount_hours || 0) + const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0) + const [isActive, setIsActive] = useState(template.is_active) + + const isTestAccess = template.offer_type === 'test_access' + + const handleSubmit = () => { + const data: PromoOfferTemplateUpdateRequest = { + name, + message_text: messageText, + button_text: buttonText, + valid_hours: validHours, + discount_percent: discountPercent, + is_active: isActive, + } + if (isTestAccess) { + data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined + } else { + data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined + } + onSave(data) + } + + return ( +
+
+ {/* Header */} +
+
+ {getOfferTypeIcon(template.offer_type)} +

+ Редактирование шаблона +

+
+ +
+ + {/* Content */} +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + /> +
+ +
+ +