From 246fafdbfc1db6384d1626ae09e04431c72f61e2 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 18 Mar 2026 03:49:36 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=BA=D0=B0=20SeverPay=20=D0=B2=20=D0=BA=D0=B0=D0=B1?= =?UTF-8?q?=D0=B8=D0=BD=D0=B5=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Иконка SeverPay (синий градиент, SP) - Лейбл в paymentMethods - Статус decline в FAILED_STATUSES - Локализация (ru, en, fa, zh) --- src/components/PaymentMethodIcon.tsx | 20 ++++++++++++++++++++ src/constants/paymentMethods.ts | 1 + src/locales/en.json | 9 +++++++++ src/locales/fa.json | 3 +++ src/locales/ru.json | 9 +++++++++ src/locales/zh.json | 3 +++ src/utils/paymentStatus.ts | 1 + 7 files changed, 46 insertions(+) diff --git a/src/components/PaymentMethodIcon.tsx b/src/components/PaymentMethodIcon.tsx index 9609e4c..833baab 100644 --- a/src/components/PaymentMethodIcon.tsx +++ b/src/components/PaymentMethodIcon.tsx @@ -215,6 +215,26 @@ export default function PaymentMethodIcon({ ); } + case 'severpay': { + const severpayGradId = `${uid}-severpay`; + return ( + + + + + + + + + + + SP + + + + ); + } + default: return ( diff --git a/src/constants/paymentMethods.ts b/src/constants/paymentMethods.ts index 52b6477..1589a81 100644 --- a/src/constants/paymentMethods.ts +++ b/src/constants/paymentMethods.ts @@ -14,4 +14,5 @@ export const METHOD_LABELS: Record = { cloudpayments: 'CloudPayments', kassa_ai: 'Kassa AI', riopay: 'RioPay', + severpay: 'SeverPay', }; diff --git a/src/locales/en.json b/src/locales/en.json index 3307e52..3056aef 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -634,6 +634,15 @@ }, "tribute": { "description": "Pay with bank card via Tribute" + }, + "severpay": { + "description": "Pay via SeverPay" + }, + "riopay": { + "description": "Pay via RioPay" + }, + "kassa_ai": { + "description": "Pay via Kassa AI" } }, "transactionHistory": "Transaction History", diff --git a/src/locales/fa.json b/src/locales/fa.json index 07b6fba..494059b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -487,6 +487,9 @@ }, "tribute": { "description": "پرداخت با کارت بانکی از طریق Tribute" + }, + "severpay": { + "description": "پرداخت از طریق SeverPay" } }, "transactionHistory": "تاریخچه تراکنش‌ها", diff --git a/src/locales/ru.json b/src/locales/ru.json index db4916b..1046358 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -662,6 +662,15 @@ }, "tribute": { "description": "Оплата банковской картой через Tribute" + }, + "severpay": { + "description": "Оплата через SeverPay" + }, + "riopay": { + "description": "Оплата через RioPay" + }, + "kassa_ai": { + "description": "Оплата через Kassa AI" } }, "transactionHistory": "История операций", diff --git a/src/locales/zh.json b/src/locales/zh.json index a11c045..5301b10 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -487,6 +487,9 @@ }, "tribute": { "description": "通过Tribute银行卡支付" + }, + "severpay": { + "description": "通过SeverPay支付" } }, "transactionHistory": "交易记录", diff --git a/src/utils/paymentStatus.ts b/src/utils/paymentStatus.ts index 7f360f5..4c5c697 100644 --- a/src/utils/paymentStatus.ts +++ b/src/utils/paymentStatus.ts @@ -20,6 +20,7 @@ export const FAILED_STATUSES = new Set([ 'cancel', 'system_fail', 'refund_paid', + 'decline', ]); export function isPaidStatus(status: string): boolean { From 8636bd7659b6b18cc9e19bce5bf5c6637da21d72 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 18 Mar 2026 04:21:34 +0300 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D0=B5=20?= =?UTF-8?q?+=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D1=8B=20=D0=B4=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=20=D1=82=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Кнопка «Подключить устройство» заблокирована при достижении лимита - Haptic feedback при нажатии на заблокированную кнопку - Предупреждение «Отключите устройства для подключения новых» - Убран дублирующийся текст трафика под прогресс-баром (безлимит) --- .../dashboard/SubscriptionCardActive.tsx | 24 ++++++++++++++++-- .../dashboard/TrafficProgressBar.tsx | 25 +------------------ src/components/ui/hover-border-gradient.tsx | 2 ++ src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/styles/globals.css | 6 +++++ 8 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/components/dashboard/SubscriptionCardActive.tsx b/src/components/dashboard/SubscriptionCardActive.tsx index 64b487e..a3e09ad 100644 --- a/src/components/dashboard/SubscriptionCardActive.tsx +++ b/src/components/dashboard/SubscriptionCardActive.tsx @@ -10,6 +10,7 @@ import { useTrafficZone } from '../../hooks/useTrafficZone'; import { formatTraffic } from '../../utils/formatTraffic'; import { getGlassColors } from '../../utils/glassTheme'; import { HoverBorderGradient } from '../ui/hover-border-gradient'; +import { useHaptic } from '../../platform'; import type { Subscription } from '../../types'; interface SubscriptionCardActiveProps { @@ -58,6 +59,10 @@ export default function SubscriptionCardActive({ const isUnlimited = trafficData?.is_unlimited ?? subscription.traffic_limit_gb === 0; const zone = useTrafficZone(usedPercent); const animatedPercent = useAnimatedNumber(usedPercent); + const haptic = useHaptic(); + + const isAtDeviceLimit = + subscription.device_limit > 0 && connectedDevices >= subscription.device_limit; const formattedDate = new Date(subscription.end_date).toLocaleDateString(); const daysLeft = subscription.days_left; @@ -203,8 +208,15 @@ export default function SubscriptionCardActive({ navigate('/connection')} - className="mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300" + disabled={isAtDeviceLimit} + onClick={() => { + if (isAtDeviceLimit) { + haptic.notification('error'); + return; + } + navigate('/connection'); + }} + className={`mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`} data-onboarding="connect-devices" style={{ fontFamily: 'inherit' }} > @@ -243,6 +255,14 @@ export default function SubscriptionCardActive({ max: subscription.device_limit, })} + {isAtDeviceLimit && ( +
+ {t('dashboard.deviceLimitReached')} +
+ )} {/* Device indicator */} diff --git a/src/components/dashboard/TrafficProgressBar.tsx b/src/components/dashboard/TrafficProgressBar.tsx index 61c2919..a22f773 100644 --- a/src/components/dashboard/TrafficProgressBar.tsx +++ b/src/components/dashboard/TrafficProgressBar.tsx @@ -16,7 +16,7 @@ interface TrafficProgressBarProps { const THRESHOLDS = [50, 75, 90]; export default function TrafficProgressBar({ - usedGb, + usedGb: _usedGb, limitGb, percent, isUnlimited, @@ -83,29 +83,6 @@ export default function TrafficProgressBar({ aria-hidden="true" /> - - {/* Below bar: label + usage */} - {!compact && ( -
- - - - {t('dashboard.usedTraffic', { amount: formatTraffic(usedGb) })} - -
- )} ); } diff --git a/src/components/ui/hover-border-gradient.tsx b/src/components/ui/hover-border-gradient.tsx index a4302c6..97c8ad8 100644 --- a/src/components/ui/hover-border-gradient.tsx +++ b/src/components/ui/hover-border-gradient.tsx @@ -8,6 +8,8 @@ interface HoverBorderGradientProps extends React.HTMLAttributes { accentColor?: string; /** Full rotation duration in seconds. Default: 3 */ duration?: number; + /** Disables the element (applies to interactive elements like buttons) */ + disabled?: boolean; } /** diff --git a/src/locales/en.json b/src/locales/en.json index 3056aef..26ab68c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -254,6 +254,7 @@ "daysRemaining": "Days left", "usageLast14Days": "Usage last 14 days", "maxUsage": "max {{amount}}", + "deviceLimitReached": "Disconnect devices to add new ones", "expired": { "title": "Subscription Expired", "trialTitle": "Trial Expired", diff --git a/src/locales/fa.json b/src/locales/fa.json index 494059b..75464b1 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -242,6 +242,7 @@ "devices": "دستگاه‌ها", "expiredDate": "منقضی شده" }, + "deviceLimitReached": "برای افزودن دستگاه جدید، دستگاه‌های فعلی را قطع کنید", "suspended": { "title": "اشتراک معلق شده", "resume": "از سرگیری" diff --git a/src/locales/ru.json b/src/locales/ru.json index 1046358..2fd0b67 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -266,6 +266,7 @@ "daysRemaining": "Дней осталось", "usageLast14Days": "Расход за 14 дней", "maxUsage": "макс {{amount}}", + "deviceLimitReached": "Отключите устройства для подключения новых", "expired": { "title": "Подписка истекла", "trialTitle": "Пробный период истёк", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5301b10..5c6f4e9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -242,6 +242,7 @@ "devices": "设备", "expiredDate": "过期时间" }, + "deviceLimitReached": "断开设备以添加新设备", "suspended": { "title": "订阅已暂停", "resume": "恢复" diff --git a/src/styles/globals.css b/src/styles/globals.css index 3cc0f8c..9d48068 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -35,6 +35,12 @@ box-shadow: 0 0 20px var(--accent-glow, rgba(var(--color-accent-400), 0.25)); } +.hover-border-gradient:disabled, +.hover-border-gradient[disabled] { + box-shadow: none; + pointer-events: auto; +} + .light .hover-border-gradient { --_bg: var(--border-inner-bg, rgb(var(--color-champagne-100))); } From 2d89b5e342b7dbd3f6ae6457a7aba06db962c91c Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 18 Mar 2026 04:27:33 +0300 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аналогичная блокировка как на дашборде — disabled + haptic + предупреждение --- src/pages/Subscription.tsx | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 95514ae..56700ae 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -14,6 +14,7 @@ import { useCurrency } from '../hooks/useCurrency'; import { useCloseOnSuccessNotification } from '../store/successNotification'; import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton'; import { CopyIcon, CheckIcon } from '../components/icons'; +import { useHaptic } from '../platform'; import { getErrorMessage, getInsufficientBalanceError, @@ -169,6 +170,7 @@ export default function Subscription() { const navigate = useNavigate(); const { isDark } = useTheme(); const g = getGlassColors(isDark); + const haptic = useHaptic(); const [copied, setCopied] = useState(false); // Helper to format price from kopeks @@ -444,6 +446,8 @@ export default function Subscription() { const isUnlimited = (trafficData?.is_unlimited ?? false) || subscription.traffic_limit_gb === 0; const connectedDevices = devicesData?.total ?? 0; + const isAtDeviceLimit = + subscription.device_limit > 0 && connectedDevices >= subscription.device_limit; return (
navigate('/connection')} - className="mb-5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300" + disabled={isAtDeviceLimit} + onClick={() => { + if (isAtDeviceLimit) { + haptic.notification('error'); + return; + } + navigate('/connection'); + }} + className={`mb-5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`} style={{ fontFamily: 'inherit' }} >
+ {isAtDeviceLimit && ( +
+ {t('dashboard.deviceLimitReached')} +
+ )}
{subscription.device_limit === 0 ? (
Date: Wed, 18 Mar 2026 04:35:32 +0300 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8C?= =?UTF-8?q?=20=C2=AB=D0=A1=D1=82=D0=BE=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B2=D1=80=D0=B0=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=C2=BB=20?= =?UTF-8?q?=D0=BA=20=D0=B1=D0=BB=D0=BE=D0=BA=D1=83=20=D0=BE=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=8B=20=D1=81=D0=BF=D0=B8=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/Wheel.tsx | 3 ++- 5 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/locales/en.json b/src/locales/en.json index 26ab68c..e433e2d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -937,6 +937,7 @@ "days_other": "{{count}} days", "subscriptionDays": "Subscription days", "day": "Day", + "spinCost": "Spin cost", "spin": "SPIN!", "spinning": "Spinning...", "history": "History", diff --git a/src/locales/fa.json b/src/locales/fa.json index 75464b1..4311f6b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -779,6 +779,7 @@ "days_other": "{{count}} روز", "subscriptionDays": "روز اشتراک", "day": "روز", + "spinCost": "هزینه چرخش", "spin": "بچرخان!", "spinning": "در حال چرخش...", "history": "تاریخچه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 2fd0b67..66108e5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -966,6 +966,7 @@ "days_many": "{{count}} дней", "subscriptionDays": "Дни подписки", "day": "День", + "spinCost": "Стоимость вращения", "spin": "КРУТИТЬ!", "spinning": "Крутится...", "history": "История", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5c6f4e9..5e1948c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -779,6 +779,7 @@ "days_other": "{{count}} 天", "subscriptionDays": "订阅天数", "day": "天", + "spinCost": "抽奖费用", "spin": "开始抽奖!", "spinning": "抽奖中...", "history": "历史", diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 6a625cf..737b05b 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -545,7 +545,8 @@ export default function Wheel() {
{/* Payment type selector */} {(starsEnabled || daysEnabled) && ( -
+
+

{t('wheel.spinCost')}

From df73b3f77e4fe38715ca74782120f4c52ad0b1b7 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 18 Mar 2026 05:40:56 +0300 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=D1=80=D0=B5=D0=B4=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D1=8B=20=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=D0=B6=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=B2=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B5=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA,=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=D1=8B,=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поиск по инвойсу/клиенту, фильтры по статусу/периоду/методу, карточки статистики, кастомный диапазон дат, обработка ошибок, локализация на 4 языка. --- src/api/adminPayments.ts | 41 ++++ src/locales/en.json | 32 ++- src/locales/fa.json | 32 ++- src/locales/ru.json | 30 ++- src/locales/zh.json | 34 ++- src/pages/AdminPayments.tsx | 440 +++++++++++++++++++++++++++++------- 6 files changed, 512 insertions(+), 97 deletions(-) diff --git a/src/api/adminPayments.ts b/src/api/adminPayments.ts index 091e867..a3ad7fc 100644 --- a/src/api/adminPayments.ts +++ b/src/api/adminPayments.ts @@ -6,6 +6,14 @@ export interface PaymentsStats { by_method: Record; } +export interface SearchStats { + total: number; + pending: number; + paid: number; + cancelled: number; + by_method: Record; +} + export const adminPaymentsApi = { // Get all pending payments (admin) getPendingPayments: async (params?: { @@ -28,6 +36,39 @@ export const adminPaymentsApi = { return response.data; }, + // Search payments with filters + searchPayments: async (params?: { + search?: string; + status_filter?: string; + method_filter?: string; + period?: string; + date_from?: string; + date_to?: string; + page?: number; + per_page?: number; + }): Promise> => { + const response = await apiClient.get>( + '/cabinet/admin/payments/search', + { params }, + ); + return response.data; + }, + + // Get search statistics with filters + getSearchStats: async (params?: { + search?: string; + status_filter?: string; + method_filter?: string; + period?: string; + date_from?: string; + date_to?: string; + }): Promise => { + const response = await apiClient.get('/cabinet/admin/payments/search/stats', { + params, + }); + return response.data; + }, + // Get specific payment details getPayment: async (method: string, paymentId: number): Promise => { const response = await apiClient.get( diff --git a/src/locales/en.json b/src/locales/en.json index e433e2d..2e70d06 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1286,18 +1286,40 @@ "previewNotAvailable": "Preview not available" }, "payments": { - "title": "Payment verification", - "description": "Pending payments in the last 24 hours", + "title": "Payments", + "description": "Search and manage payments", + "searchPlaceholder": "Search: invoice, TG ID, @username, email...", + "searchHint": "Examples: SP-78291, @username, 123456789, user@mail.ru", + "searchResults": "Results for: {{query}} — found {{count}}", + "resetSearch": "Reset", + "statusAll": "All", + "statusPending": "Pending", + "statusPaid": "Paid", + "statusCancelled": "Cancelled", + "period24h": "24h", + "period7d": "7d", + "period30d": "30d", + "periodAll": "All", + "periodCustom": "Range", + "dateFrom": "From", + "dateTo": "To", + "applyDate": "Apply", + "allMethods": "All methods", + "totalCount": "Total", + "pendingCount": "Pending", + "paidCount": "Paid", + "cancelledCount": "Cancelled", "totalPending": "Total pending", "filterByMethod": "Filter by method", - "noPayments": "No pending payments", + "noPayments": "No payments found", "paid": "Paid", "user": "User", "openLink": "Open link", "checkStatus": "Check status", "checking": "Checking...", - "prev": "Previous", - "next": "Next" + "prev": "Prev", + "next": "Next", + "checkError": "Status check failed" }, "paymentMethods": { "title": "Payment Methods", diff --git a/src/locales/fa.json b/src/locales/fa.json index 4311f6b..b416e8d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1326,18 +1326,40 @@ } }, "payments": { - "title": "تأیید پرداخت", - "description": "پرداخت‌های معلق در ۲۴ ساعت گذشته", + "title": "پرداخت‌ها", + "description": "جستجو و مدیریت پرداخت‌ها", + "searchPlaceholder": "جستجو: فاکتور، شناسه تلگرام، @نام‌کاربری، ایمیل...", + "searchHint": "مثال‌ها: SP-78291, @username, 123456789, user@mail.ru", + "searchResults": "نتایج برای: {{query}} — {{count}} یافت شد", + "resetSearch": "بازنشانی", + "statusAll": "همه", + "statusPending": "در انتظار", + "statusPaid": "پرداخت شده", + "statusCancelled": "لغو شده", + "period24h": "۲۴ساعت", + "period7d": "۷روز", + "period30d": "۳۰روز", + "periodAll": "همه", + "periodCustom": "بازه", + "dateFrom": "از", + "dateTo": "تا", + "applyDate": "اعمال", + "allMethods": "همه روش‌ها", + "totalCount": "کل", + "pendingCount": "در انتظار", + "paidCount": "پرداخت شده", + "cancelledCount": "لغو شده", "totalPending": "کل معلق", "filterByMethod": "فیلتر بر اساس روش", + "noPayments": "پرداختی یافت نشد", "paid": "پرداخت شده", "user": "کاربر", "openLink": "باز کردن لینک", - "checking": "در حال بررسی...", "checkStatus": "بررسی وضعیت", - "noPayments": "هیچ پرداخت معلقی نیست", + "checking": "در حال بررسی...", "prev": "قبلی", - "next": "بعدی" + "next": "بعدی", + "checkError": "خطا در بررسی وضعیت" }, "settings": { "title": "تنظیمات سیستم", diff --git a/src/locales/ru.json b/src/locales/ru.json index 66108e5..e1d74f3 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1307,18 +1307,40 @@ "previewNotAvailable": "Предпросмотр недоступен" }, "payments": { - "title": "Проверка платежей", - "description": "Ожидающие платежи за последние 24 часа", + "title": "Платежи", + "description": "Поиск и управление платежами", + "searchPlaceholder": "Поиск: инвойс, TG ID, @username, email...", + "searchHint": "Примеры: SP-78291, @username, 123456789, user@mail.ru", + "searchResults": "Результаты по запросу: {{query}} — найдено {{count}}", + "resetSearch": "Сбросить", + "statusAll": "Все", + "statusPending": "В ожидании", + "statusPaid": "Оплачено", + "statusCancelled": "Отменено", + "period24h": "24ч", + "period7d": "7д", + "period30d": "30д", + "periodAll": "Все", + "periodCustom": "Период", + "dateFrom": "С", + "dateTo": "По", + "applyDate": "Применить", + "allMethods": "Все методы", + "totalCount": "Всего", + "pendingCount": "В ожидании", + "paidCount": "Оплачено", + "cancelledCount": "Отменено", "totalPending": "Всего ожидает", "filterByMethod": "Фильтр по методу", - "noPayments": "Нет ожидающих платежей", + "noPayments": "Платежи не найдены", "paid": "Оплачено", "user": "Пользователь", "openLink": "Открыть ссылку", "checkStatus": "Проверить статус", "checking": "Проверка...", "prev": "Назад", - "next": "Далее" + "next": "Далее", + "checkError": "Ошибка проверки статуса" }, "paymentMethods": { "title": "Платёжные методы", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5e1948c..0e5231b 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1158,18 +1158,40 @@ "previewNotAvailable": "预览不可用" }, "payments": { - "title": "支付验证", - "description": "最近24小时内的待处理支付", + "title": "支付", + "description": "搜索和管理支付", + "searchPlaceholder": "搜索:发票、TG ID、@用户名、邮箱...", + "searchHint": "示例:SP-78291、@username、123456789、user@mail.ru", + "searchResults": "搜索结果:{{query}} — 找到 {{count}} 条", + "resetSearch": "重置", + "statusAll": "全部", + "statusPending": "待处理", + "statusPaid": "已支付", + "statusCancelled": "已取消", + "period24h": "24小时", + "period7d": "7天", + "period30d": "30天", + "periodAll": "全部", + "periodCustom": "范围", + "dateFrom": "从", + "dateTo": "到", + "applyDate": "应用", + "allMethods": "所有方式", + "totalCount": "总计", + "pendingCount": "待处理", + "paidCount": "已支付", + "cancelledCount": "已取消", "totalPending": "总待处理", - "filterByMethod": "按方法筛选", + "filterByMethod": "按方式筛选", + "noPayments": "未找到支付记录", "paid": "已支付", "user": "用户", "openLink": "打开链接", - "checking": "检查中...", "checkStatus": "检查状态", - "noPayments": "没有待处理的支付", + "checking": "检查中...", "prev": "上一页", - "next": "下一页" + "next": "下一页", + "checkError": "状态检查失败" }, "wheel": { "title": "幸运转盘设置", diff --git a/src/pages/AdminPayments.tsx b/src/pages/AdminPayments.tsx index 95b8a42..eea7686 100644 --- a/src/pages/AdminPayments.tsx +++ b/src/pages/AdminPayments.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { adminPaymentsApi } from '../api/adminPayments'; +import { adminPaymentsApi, type SearchStats } from '../api/adminPayments'; import { useCurrency } from '../hooks/useCurrency'; import type { PendingPayment, PaginatedResponse } from '../types'; import { usePlatform } from '../platform/hooks/usePlatform'; @@ -20,6 +20,83 @@ const BackIcon = () => ( ); +// SearchIcon +const SearchIcon = () => ( + + + +); + +// CalendarIcon +const CalendarIcon = () => ( + + + +); + +interface StatusBadgeProps { + status: string; +} + +function StatusBadge({ status }: StatusBadgeProps) { + const styles: Record = { + paid: 'bg-green-500/20 text-green-400', + pending: 'bg-amber-500/20 text-amber-400', + cancelled: 'bg-red-500/20 text-red-400', + }; + + const normalized = status.toLowerCase(); + const match = Object.keys(styles).find((key) => normalized.includes(key)); + + return ( + + {status} + + ); +} + +interface StatCardProps { + label: string; + value: number; + color: 'blue' | 'amber' | 'green' | 'red'; + isActive: boolean; + onClick: () => void; +} + +function StatCard({ label, value, color, isActive, onClick }: StatCardProps) { + const colors: Record = { + blue: 'border-accent-500/30 bg-accent-500/20 text-accent-400', + amber: 'border-amber-500/30 bg-amber-500/20 text-amber-400', + green: 'border-green-500/30 bg-green-500/20 text-green-400', + red: 'border-red-500/30 bg-red-500/20 text-red-400', + }; + + return ( + + ); +} + export default function AdminPayments() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -27,44 +104,76 @@ export default function AdminPayments() { const { formatAmount, currencySymbol } = useCurrency(); const { capabilities } = usePlatform(); - const [page, setPage] = useState(1); + const [searchInput, setSearchInput] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [periodFilter, setPeriodFilter] = useState('24h'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [showDateRange, setShowDateRange] = useState(false); const [methodFilter, setMethodFilter] = useState(''); + const [page, setPage] = useState(1); const [checkingPaymentId, setCheckingPaymentId] = useState(null); + // Debounce search input (300ms) + useEffect(() => { + const timer = setTimeout(() => { + setSearchQuery(searchInput); + setPage(1); + }, 300); + return () => clearTimeout(timer); + }, [searchInput]); + + // Reset page on filter changes + useEffect(() => { + setPage(1); + }, [statusFilter, periodFilter, methodFilter, dateFrom, dateTo]); + + // Auto-refresh only when filters are at defaults and no search + const isDefaultFilters = + !searchQuery && statusFilter === 'all' && periodFilter === '24h' && !methodFilter; + + // Shared query params + const queryParams = { + search: searchQuery || undefined, + status_filter: statusFilter, + method_filter: methodFilter || undefined, + period: periodFilter === 'custom' ? undefined : periodFilter, + date_from: periodFilter === 'custom' && dateFrom ? dateFrom : undefined, + date_to: periodFilter === 'custom' && dateTo ? dateTo : undefined, + }; + // Fetch payments const { data: payments, isLoading, + isError, refetch, } = useQuery>({ - queryKey: ['admin-payments', page, methodFilter], + queryKey: ['admin-payments-search', queryParams, page], queryFn: () => - adminPaymentsApi.getPendingPayments({ + adminPaymentsApi.searchPayments({ + ...queryParams, page, per_page: 20, - method_filter: methodFilter || undefined, }), - refetchInterval: 30000, // Auto-refresh every 30 seconds + refetchInterval: isDefaultFilters ? 30000 : false, }); // Fetch stats - const { data: stats } = useQuery({ - queryKey: ['admin-payments-stats'], - queryFn: adminPaymentsApi.getStats, - refetchInterval: 30000, + const { data: stats } = useQuery({ + queryKey: ['admin-payments-search-stats', queryParams], + queryFn: () => adminPaymentsApi.getSearchStats(queryParams), + refetchInterval: isDefaultFilters ? 30000 : false, }); // 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(); - } + onSuccess: async () => { + await refetch(); + queryClient.invalidateQueries({ queryKey: ['admin-payments-search-stats'] }); }, onSettled: () => { setCheckingPaymentId(null); @@ -76,11 +185,47 @@ export default function AdminPayments() { checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id }); }; - // Get unique methods from stats for filter + const handleResetSearch = () => { + setSearchInput(''); + setSearchQuery(''); + setPage(1); + }; + + const handleStatusCardClick = (status: string) => { + setStatusFilter(statusFilter === status ? 'all' : status); + }; + + const handlePeriodChange = (period: string) => { + if (period === 'custom') { + setPeriodFilter('custom'); + setShowDateRange(true); + } else { + setPeriodFilter(period); + setShowDateRange(false); + } + }; + + // Get unique methods from stats for filter dropdown const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : []; + // Period filter options + const periodOptions = [ + { value: '24h', label: t('admin.payments.period24h') }, + { value: '7d', label: t('admin.payments.period7d') }, + { value: '30d', label: t('admin.payments.period30d') }, + { value: 'all', label: t('admin.payments.periodAll') }, + ]; + + // Status filter options + const statusOptions = [ + { value: 'all', label: t('admin.payments.statusAll') }, + { value: 'pending', label: t('admin.payments.statusPending') }, + { value: 'paid', label: t('admin.payments.statusPaid') }, + { value: 'cancelled', label: t('admin.payments.statusCancelled') }, + ]; + return ( -
+
{/* Header */}
@@ -94,7 +239,7 @@ export default function AdminPayments() { )}
-

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

+

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

{t('admin.payments.description')}

@@ -116,63 +261,178 @@ export default function AdminPayments() {
- {/* Stats cards */} - {stats && ( -
-
-
{stats.total_pending}
-
{t('admin.payments.totalPending')}
+ {/* Search bar */} +
+
+
+
- {Object.entries(stats.by_method).map(([method, count]) => ( -
setMethodFilter(methodFilter === method ? '' : method)} - > -
{count}
-
{method}
-
- ))} + setSearchInput(e.target.value)} + placeholder={t('admin.payments.searchPlaceholder')} + className="w-full rounded-xl border border-dark-700 bg-dark-800 py-3 pl-10 pr-4 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500" + /> +
+

{t('admin.payments.searchHint')}

+
+ + {/* Search result banner */} + {searchQuery && ( +
+ + {t('admin.payments.searchResults', { + query: searchQuery, + count: payments?.total ?? 0, + })} + +
)} - {/* Filter */} - {methodOptions.length > 0 && ( -
- {t('admin.payments.filterByMethod')}: - - {methodOptions.map((method) => ( + {/* Filters row */} +
+ {/* Status filter pills */} +
+ {statusOptions.map((option) => ( ))}
+ + {/* Period filter pills + Method filter */} +
+ {periodOptions.map((option) => ( + + ))} + + + {/* Method filter dropdown */} + {methodOptions.length > 0 && ( + + )} +
+
+ + {/* Date range panel */} + {showDateRange && ( +
+
+ + setDateFrom(e.target.value)} + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none" + /> +
+
+ + setDateTo(e.target.value)} + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none" + /> +
+ +
+ )} + + {/* Stats cards */} + {stats && ( +
+ handleStatusCardClick('all')} + /> + handleStatusCardClick('pending')} + /> + handleStatusCardClick('paid')} + /> + handleStatusCardClick('cancelled')} + /> +
)} {/* Payments list */}
- {isLoading ? ( + {isError ? ( +
+
{t('common.error')}
+ +
+ ) : isLoading ? (
@@ -181,6 +441,7 @@ export default function AdminPayments() { {payments.items.map((payment) => { const paymentKey = `${payment.method}_${payment.id}`; const isChecking = checkingPaymentId === paymentKey; + const isCancelled = payment.status.toLowerCase().includes('cancel'); return (
+ {/* Status badge + method */}
+ {payment.method_display} - - {payment.status_emoji} {payment.status_text} - {payment.is_paid && ( - + {t('admin.payments.paid')} )}
-
+ + {/* Amount */} +
{formatAmount(payment.amount_rubles)} {currencySymbol}
+ + {/* Invoice ID */}
- ID: {payment.identifier} -
-
- {new Date(payment.created_at).toLocaleString()} + {payment.identifier}
+ {/* User info */} {(payment.user_username || payment.user_telegram_id) && (
{t('admin.payments.user')}:{' '} - {payment.user_username ? ( + {payment.user_username && ( @{payment.user_username} - ) : ( - ID: {payment.user_telegram_id} + )} + {payment.user_username && payment.user_telegram_id && ( + · + )} + {payment.user_telegram_id && ( + TG: {payment.user_telegram_id} )}
)} + + {/* Timestamp */} +
+ {new Date(payment.created_at).toLocaleString()} +
+ + {/* Action buttons */}
+ {/* Show result after check */} {checkPaymentMutation.isSuccess && - checkPaymentMutation.variables?.paymentId === payment.id && ( + checkPaymentMutation.variables?.paymentId === payment.id && + checkPaymentMutation.variables?.method === payment.method && (
{checkPaymentMutation.data?.message}
)} + {checkPaymentMutation.isError && + checkPaymentMutation.variables?.paymentId === payment.id && + checkPaymentMutation.variables?.method === payment.method && ( +
+ {t('admin.payments.checkError')} +
+ )}
); })} @@ -318,7 +604,7 @@ export default function AdminPayments() { {t('admin.payments.prev')}
- {t('balance.page', '{current} / {total}', { + {t('balance.page', { current: payments.page, total: payments.pages, })}