From e1d2f8cee403d895508f538ffbc6c9c528f92b05 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 06:11:12 +0300 Subject: [PATCH 01/12] feat: show VPN connection info and subscription request history on admin user detail - Load panelInfo eagerly on page load (not just on subscription tab) - Add VPN connection card to info tab: last/first connection, online status indicator, last node - Rename "Last activity" to "Bot activity" to distinguish from VPN connection - Show cabinet_last_login field that was never rendered - Add collapsible subscription request history section in subscription tab with paginated table - Add subscription request history API types and method - Add i18n keys for all 4 locales (ru, en, zh, fa) --- src/api/adminUsers.ts | 29 ++++ src/locales/en.json | 13 ++ src/locales/fa.json | 13 ++ src/locales/ru.json | 13 ++ src/locales/zh.json | 13 ++ src/pages/AdminUserDetail.tsx | 263 +++++++++++++++++++++++++++++++++- 6 files changed, 342 insertions(+), 2 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 8697196..604bf94 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -158,6 +158,19 @@ export interface UserPanelInfo { last_connected_node_name: string | null; } +export interface SubscriptionRequestRecord { + id: number; + userUuid: string; + requestAt: string; + requestIp: string | null; + userAgent: string | null; +} + +export interface SubscriptionRequestHistory { + total: number; + records: SubscriptionRequestRecord[]; +} + export interface UserNodeUsageItem { node_uuid: string; node_name: string; @@ -684,6 +697,22 @@ export const adminUsersApi = { return response.data; }, + // Get subscription request history from RemnaWave panel + getSubscriptionRequestHistory: async ( + userId: number, + subscriptionId?: number, + offset = 0, + limit = 20, + ): Promise => { + const params: Record = { offset, limit }; + if (subscriptionId != null) params.subscription_id = subscriptionId; + const response = await apiClient.get( + `/cabinet/admin/users/${userId}/subscription-request-history`, + { params }, + ); + return response.data; + }, + // Get user devices getUserDevices: async ( userId: number, diff --git a/src/locales/en.json b/src/locales/en.json index 4263969..6108f2d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3075,6 +3075,19 @@ "language": "Language", "registration": "Registration", "lastActivity": "Last activity", + "botActivity": "Bot activity", + "cabinetLastLogin": "Last cabinet login", + "vpnConnection": "VPN connection", + "lastConnection": "Last connection", + "firstConnection": "First connection", + "online": "Online", + "requestHistory": "Subscription request history", + "requestHistoryTotal": "Total requests", + "requestAt": "Date", + "requestIp": "IP address", + "requestUserAgent": "Device", + "loadMore": "Show more", + "noRequests": "No requests", "totalSpent": "Total spent", "purchases": "Purchases", "campaign": "Advertising campaign", diff --git a/src/locales/fa.json b/src/locales/fa.json index 3c29a4a..63d5f36 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2579,6 +2579,19 @@ "language": "زبان", "registration": "ثبت‌نام", "lastActivity": "آخرین فعالیت", + "botActivity": "فعالیت در ربات", + "cabinetLastLogin": "آخرین ورود به کابینت", + "vpnConnection": "اتصال VPN", + "lastConnection": "آخرین اتصال", + "firstConnection": "اولین اتصال", + "online": "آنلاین", + "requestHistory": "تاریخچه درخواست‌های اشتراک", + "requestHistoryTotal": "کل درخواست‌ها", + "requestAt": "تاریخ", + "requestIp": "آدرس IP", + "requestUserAgent": "دستگاه", + "loadMore": "نمایش بیشتر", + "noRequests": "بدون درخواست", "totalSpent": "کل هزینه", "purchases": "خریدها", "campaign": "کمپین تبلیغاتی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 6de3b43..16a5261 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3482,6 +3482,19 @@ "language": "Язык", "registration": "Регистрация", "lastActivity": "Последняя активность", + "botActivity": "Активность в боте", + "cabinetLastLogin": "Последний вход в кабинет", + "vpnConnection": "VPN подключение", + "lastConnection": "Последнее подключение", + "firstConnection": "Первое подключение", + "online": "Онлайн", + "requestHistory": "История запросов подписки", + "requestHistoryTotal": "Всего запросов", + "requestAt": "Дата", + "requestIp": "IP адрес", + "requestUserAgent": "Устройство", + "loadMore": "Показать ещё", + "noRequests": "Нет запросов", "totalSpent": "Всего потрачено", "purchases": "Покупок", "campaign": "Рекламная кампания", diff --git a/src/locales/zh.json b/src/locales/zh.json index 8661527..6c04819 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2578,6 +2578,19 @@ "language": "语言", "registration": "注册时间", "lastActivity": "最后活跃", + "botActivity": "机器人活动", + "cabinetLastLogin": "最后登录面板", + "vpnConnection": "VPN 连接", + "lastConnection": "最后连接", + "firstConnection": "首次连接", + "online": "在线", + "requestHistory": "订阅请求历史", + "requestHistoryTotal": "总请求数", + "requestAt": "日期", + "requestIp": "IP 地址", + "requestUserAgent": "设备", + "loadMore": "显示更多", + "noRequests": "没有请求", "totalSpent": "总消费", "purchases": "购买次数", "campaign": "广告活动", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index a5a80ae..20bacb3 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -14,6 +14,7 @@ import { type PanelSyncStatusResponse, type UpdateSubscriptionRequest, type AdminUserGiftsResponse, + type SubscriptionRequestRecord, } from '../api/adminUsers'; import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin'; import { promocodesApi, type PromoGroup } from '../api/promocodes'; @@ -383,6 +384,13 @@ export default function AdminUserDetail() { const [giftsData, setGiftsData] = useState(null); const [giftsLoading, setGiftsLoading] = useState(false); + // Subscription request history + const [requestHistory, setRequestHistory] = useState([]); + const [requestHistoryLoading, setRequestHistoryLoading] = useState(false); + const [requestHistoryOffset, setRequestHistoryOffset] = useState(0); + const [requestHistoryTotal, setRequestHistoryTotal] = useState(0); + const [requestHistoryExpanded, setRequestHistoryExpanded] = useState(false); + const userId = id ? parseInt(id, 10) : null; const loadUser = useCallback(async () => { @@ -483,6 +491,29 @@ export default function AdminUserDetail() { } }, [userId, activeSubscriptionId]); + const loadRequestHistory = useCallback( + async (offset = 0, append = false) => { + if (!userId) return; + try { + setRequestHistoryLoading(true); + const data = await adminUsersApi.getSubscriptionRequestHistory( + userId, + activeSubscriptionId ?? undefined, + offset, + 20, + ); + setRequestHistory((prev) => (append ? [...prev, ...data.records] : data.records)); + setRequestHistoryTotal(data.total); + setRequestHistoryOffset(offset + data.records.length); + } catch { + // silent + } finally { + setRequestHistoryLoading(false); + } + }, + [userId, activeSubscriptionId], + ); + const loadNodeUsage = useCallback(async () => { if (!userId) return; try { @@ -575,7 +606,8 @@ export default function AdminUserDetail() { return; } loadUser(); - }, [userId, loadUser, navigate]); + loadPanelInfo(); + }, [userId, loadUser, loadPanelInfo, navigate]); useEffect(() => { if (activeTab === 'info') { @@ -1301,10 +1333,16 @@ export default function AdminUserDetail() {
- {t('admin.users.detail.lastActivity')} + {t('admin.users.detail.botActivity')}
{formatDate(user.last_activity)}
+
+
+ {t('admin.users.detail.cabinetLastLogin')} +
+
{formatDate(user.cabinet_last_login)}
+
{t('admin.users.detail.totalSpent')} @@ -1321,6 +1359,87 @@ export default function AdminUserDetail() {
+ {/* VPN Connection Info */} + {panelInfo && panelInfo.found && ( +
+
+ {t('admin.users.detail.vpnConnection')} +
+
+
+
+ {t('admin.users.detail.lastConnection')} +
+
+ {panelInfo.online_at && + (() => { + const onlineDate = new Date(panelInfo.online_at); + const isRecent = Date.now() - onlineDate.getTime() < 5 * 60 * 1000; + return ( + <> + + + {isRecent + ? t('admin.users.detail.online') + : formatDate(panelInfo.online_at)} + + + ); + })()} + {!panelInfo.online_at && -} +
+
+
+
+ {t('admin.users.detail.firstConnection')} +
+
+ {panelInfo.first_connected_at + ? new Date(panelInfo.first_connected_at).toLocaleDateString(locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + : '-'} +
+
+ {panelInfo.last_connected_node_name && ( +
+
+ {t('admin.users.detail.lastNode')} +
+
+ + + + {panelInfo.last_connected_node_name} +
+
+ )} +
+
+ )} + {panelInfoLoading && !panelInfo && ( +
+
+
+ )} + {/* Campaign */} {user.campaign_name && (
@@ -2348,6 +2467,146 @@ export default function AdminUserDetail() {
)}
+ + {/* Subscription Request History */} +
+ + + {requestHistoryExpanded && ( +
+ {/* Subscription selector for multi-tariff */} + {userSubscriptions.length > 1 && ( +
+ +
+ )} + + {requestHistoryLoading && requestHistory.length === 0 ? ( +
+
+
+ ) : requestHistory.length === 0 && !requestHistoryLoading ? ( +
+ {t('admin.users.detail.noRequests')} +
+ ) : ( + <> +
+ {t('admin.users.detail.requestHistoryTotal')}: {requestHistoryTotal} +
+ + {/* Table */} +
+ + + + + + + + + + {requestHistory.map((record, idx) => ( + + + + + + ))} + +
+ {t('admin.users.detail.requestAt')} + + {t('admin.users.detail.requestIp')} + + {t('admin.users.detail.requestUserAgent')} +
+ {formatDate(record.requestAt)} + + {record.requestIp || '\u2014'} + + {record.userAgent + ? record.userAgent.length > 60 + ? `${record.userAgent.slice(0, 60)}...` + : record.userAgent + : '\u2014'} +
+
+ + {/* Load more */} + {requestHistory.length < requestHistoryTotal && ( + + )} + + )} +
+ )} +
)}
From 853e1c9c8477c028a7046e1c83c3089b00847cb9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 06:22:38 +0300 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20user=20detail=20=E2=80=94=20separa?= =?UTF-8?q?te=20request=20history=20sub=20selector,=20split=20mount=20effe?= =?UTF-8?q?cts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Request history subscription selector no longer mutates shared activeSubscriptionId — uses independent requestHistorySubId state - Request history reloads when subscription selector changes - Split mount effect: loadPanelInfo in separate effect to avoid redundant loadUser calls when activeSubscriptionId changes --- src/pages/AdminUserDetail.tsx | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 20bacb3..e4baf70 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -390,6 +390,7 @@ export default function AdminUserDetail() { const [requestHistoryOffset, setRequestHistoryOffset] = useState(0); const [requestHistoryTotal, setRequestHistoryTotal] = useState(0); const [requestHistoryExpanded, setRequestHistoryExpanded] = useState(false); + const [requestHistorySubId, setRequestHistorySubId] = useState(null); const userId = id ? parseInt(id, 10) : null; @@ -498,7 +499,7 @@ export default function AdminUserDetail() { setRequestHistoryLoading(true); const data = await adminUsersApi.getSubscriptionRequestHistory( userId, - activeSubscriptionId ?? undefined, + requestHistorySubId ?? undefined, offset, 20, ); @@ -511,7 +512,7 @@ export default function AdminUserDetail() { setRequestHistoryLoading(false); } }, - [userId, activeSubscriptionId], + [userId, requestHistorySubId], ); const loadNodeUsage = useCallback(async () => { @@ -606,8 +607,22 @@ export default function AdminUserDetail() { return; } loadUser(); + }, [userId, loadUser, navigate]); + + // Load panel info when subscription changes (separate from mount to avoid redundant loadUser) + useEffect(() => { + if (!userId || isNaN(userId)) return; loadPanelInfo(); - }, [userId, loadUser, loadPanelInfo, navigate]); + }, [userId, loadPanelInfo]); + + // Reload request history when the request-history subscription selector changes + useEffect(() => { + if (!requestHistoryExpanded || requestHistorySubId === null) return; + setRequestHistory([]); + setRequestHistoryOffset(0); + setRequestHistoryTotal(0); + loadRequestHistory(0); + }, [requestHistorySubId]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (activeTab === 'info') { @@ -860,6 +875,7 @@ export default function AdminUserDetail() { if (user && userSubscriptions.length > 0 && !hasAutoSelectedSub.current) { const activeSub = userSubscriptions.find((s) => s.is_active) ?? userSubscriptions[0]; setActiveSubscriptionId(activeSub.id); + setRequestHistorySubId(activeSub.id); hasAutoSelectedSub.current = true; } }, [user, userSubscriptions]); @@ -2511,13 +2527,9 @@ export default function AdminUserDetail() { {userSubscriptions.length > 1 && (
{ + const subId = Number(e.target.value); + setActiveSubscriptionId(subId); + }} + className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-xs text-dark-200" + > + {userSubscriptions.map((s) => ( + + ))} + )}
- - )} - {panelInfoLoading && !panelInfo && ( -
-
+ {panelInfoLoading && !panelInfo?.found && ( +
+
+
+ )} + {panelInfo?.found && ( +
+
+
+ {t('admin.users.detail.lastConnection')} +
+
+ {panelInfo.online_at && + (() => { + const onlineDate = new Date(panelInfo.online_at); + const isRecent = Date.now() - onlineDate.getTime() < 5 * 60 * 1000; + return ( + <> + + + {isRecent + ? t('admin.users.detail.online') + : formatDate(panelInfo.online_at)} + + + ); + })()} + {!panelInfo.online_at && -} +
+
+
+
+ {t('admin.users.detail.firstConnection')} +
+
+ {panelInfo.first_connected_at + ? new Date(panelInfo.first_connected_at).toLocaleDateString(locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + : '-'} +
+
+ {panelInfo.last_connected_node_name && ( +
+
+ {t('admin.users.detail.lastNode')} +
+
+ + + + {panelInfo.last_connected_node_name} +
+
+ )} +
+ )} + {!panelInfoLoading && !panelInfo?.found && userSubscriptions.length > 0 && ( +
+ {t('admin.users.detail.noVpnData')} +
+ )}
)} From 8fcdbbe53ef5de8392d752b3c05cb2e3ffadce3d Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 06:42:25 +0300 Subject: [PATCH 04/12] fix: remove stale cabinet_last_login field from user detail --- src/pages/AdminUserDetail.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 92a4b4c..b57b975 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -1353,12 +1353,6 @@ export default function AdminUserDetail() {
{formatDate(user.last_activity)}
-
-
- {t('admin.users.detail.cabinetLastLogin')} -
-
{formatDate(user.cabinet_last_login)}
-
{t('admin.users.detail.totalSpent')} From 8044b664c36aea080b7ab8a84ba55aa48578a17e Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 06:46:20 +0300 Subject: [PATCH 05/12] fix: restore cabinet_last_login in user detail (now shows real data) --- src/pages/AdminUserDetail.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index b57b975..92a4b4c 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -1353,6 +1353,12 @@ export default function AdminUserDetail() {
{formatDate(user.last_activity)}
+
+
+ {t('admin.users.detail.cabinetLastLogin')} +
+
{formatDate(user.cabinet_last_login)}
+
{t('admin.users.detail.totalSpent')} From 9b1e26d4ec146fa1a1e37dde2e9212e4edc5668b Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 08:34:37 +0300 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20switch=20component=20=E2=80=94=20r?= =?UTF-8?q?eplace=20motion=20with=20CSS=20transition-transform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/primitives/Switch/Switch.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/primitives/Switch/Switch.tsx b/src/components/primitives/Switch/Switch.tsx index 6d914b6..bd01472 100644 --- a/src/components/primitives/Switch/Switch.tsx +++ b/src/components/primitives/Switch/Switch.tsx @@ -1,9 +1,7 @@ import * as SwitchPrimitive from '@radix-ui/react-switch'; -import { motion } from 'framer-motion'; import { forwardRef, type ComponentPropsWithoutRef } from 'react'; import { cn } from '@/lib/utils'; import { usePlatform } from '@/platform'; -import { springTransition } from '../../motion/transitions'; export interface SwitchProps extends Omit< ComponentPropsWithoutRef, @@ -42,13 +40,11 @@ export const Switch = forwardRef( {...props} > - From 6d3010b6212f41a484dd507535d1dafa28a9160b Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 08:45:15 +0300 Subject: [PATCH 07/12] feat: multi-media attachments, linkify URLs, shared MessageMediaGrid - Add media_items array support to ticket messages (types, API clients) - Create shared MessageMediaGrid component with photo grid, fullscreen viewer, keyboard nav, video/document rendering - Replace per-page AdminMessageMedia/MessageMedia with MessageMediaGrid - Add linkifyText util (DOMPurify-sanitized) for auto-linking URLs - Support multi-file upload (up to 10) in AdminTickets and Support pages - Remove unused ticketsApi import from AdminUserDetail --- src/api/admin.ts | 14 +- src/api/tickets.ts | 9 +- src/components/tickets/MessageMediaGrid.tsx | 256 +++++++++++ src/pages/AdminTickets.tsx | 444 ++++++++------------ src/pages/AdminUserDetail.tsx | 34 +- src/pages/Support.tsx | 427 ++++++++----------- src/types/index.ts | 7 + src/utils/linkify.ts | 28 ++ 8 files changed, 666 insertions(+), 553 deletions(-) create mode 100644 src/components/tickets/MessageMediaGrid.tsx create mode 100644 src/utils/linkify.ts diff --git a/src/api/admin.ts b/src/api/admin.ts index b2960ce..2497974 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -8,6 +8,12 @@ export interface AdminTicketUser { last_name: string | null; } +export interface AdminTicketMediaItem { + type: 'photo' | 'video' | 'document'; + file_id: string; + caption?: string | null; +} + export interface AdminTicketMessage { id: number; message_text: string; @@ -16,6 +22,7 @@ export interface AdminTicketMessage { media_type: string | null; media_file_id: string | null; media_caption: string | null; + media_items?: AdminTicketMediaItem[] | null; created_at: string; } @@ -118,7 +125,12 @@ export const adminApi = { replyToTicket: async ( ticketId: number, message: string, - media?: { media_type?: string; media_file_id?: string; media_caption?: string }, + media?: { + media_type?: string; + media_file_id?: string; + media_caption?: string; + media_items?: AdminTicketMediaItem[]; + }, ): Promise => { const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message, diff --git a/src/api/tickets.ts b/src/api/tickets.ts index 1cb4405..2794127 100644 --- a/src/api/tickets.ts +++ b/src/api/tickets.ts @@ -1,5 +1,11 @@ import apiClient from './client'; -import type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types'; +import type { + Ticket, + TicketDetail, + TicketMessage, + TicketMediaItem, + PaginatedResponse, +} from '../types'; // Media upload response type interface MediaUploadResponse { @@ -14,6 +20,7 @@ interface MediaParams { media_type?: string; media_file_id?: string; media_caption?: string; + media_items?: TicketMediaItem[]; } export const ticketsApi = { diff --git a/src/components/tickets/MessageMediaGrid.tsx b/src/components/tickets/MessageMediaGrid.tsx new file mode 100644 index 0000000..7ccb1c0 --- /dev/null +++ b/src/components/tickets/MessageMediaGrid.tsx @@ -0,0 +1,256 @@ +import { useState, useCallback, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { ticketsApi } from '../../api/tickets'; + +export interface MediaItem { + type: string; + file_id: string; + caption?: string | null; +} + +interface MessageLike { + has_media?: boolean; + media_type?: string | null; + media_file_id?: string | null; + media_caption?: string | null; + media_items?: MediaItem[] | null; +} + +/** + * Normalize message media into a unified list. + * If media_items is present, use it. Otherwise fall back to legacy single-media fields. + */ +function getItems(message: MessageLike): MediaItem[] { + if (message.media_items && message.media_items.length > 0) { + return message.media_items; + } + if (message.media_file_id && message.media_type) { + return [ + { + type: message.media_type, + file_id: message.media_file_id, + caption: message.media_caption, + }, + ]; + } + return []; +} + +export function MessageMediaGrid({ + message, + translateError = 'Failed to load image', +}: { + message: MessageLike; + translateError?: string; +}) { + const items = getItems(message); + const photoItems = items.filter((i) => i.type === 'photo'); + const otherItems = items.filter((i) => i.type !== 'photo'); + + const [fullscreenIndex, setFullscreenIndex] = useState(null); + + const openFullscreen = useCallback((idx: number) => setFullscreenIndex(idx), []); + const closeFullscreen = useCallback(() => setFullscreenIndex(null), []); + + // Escape + arrow keys for fullscreen nav + useEffect(() => { + if (fullscreenIndex === null) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setFullscreenIndex(null); + else if (e.key === 'ArrowLeft' && fullscreenIndex > 0) + setFullscreenIndex(fullscreenIndex - 1); + else if (e.key === 'ArrowRight' && fullscreenIndex < photoItems.length - 1) + setFullscreenIndex(fullscreenIndex + 1); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [fullscreenIndex, photoItems.length]); + + // Lock body scroll while fullscreen overlay is open (mobile mainly). + useEffect(() => { + if (fullscreenIndex === null) return undefined; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, [fullscreenIndex]); + + // All hooks have been called — safe to early-return now. + if (items.length === 0) return null; + + // Grid layout based on photo count + let gridClass = ''; + if (photoItems.length === 1) { + gridClass = 'grid-cols-1'; + } else if (photoItems.length === 2) { + gridClass = 'grid-cols-2'; + } else if (photoItems.length === 3) { + gridClass = 'grid-cols-3'; + } else { + gridClass = 'grid-cols-2'; // 4+ → 2x2 + } + + const visiblePhotos = photoItems.slice(0, 4); + const hiddenCount = photoItems.length - visiblePhotos.length; + + return ( +
+ {photoItems.length > 0 && ( +
+ {visiblePhotos.map((item, visIdx) => { + // visIdx is always the correct index into photoItems (visible prefix) + const originalIdx = visIdx; + const isLastVisible = visIdx === visiblePhotos.length - 1 && hiddenCount > 0; + return ( + + ); + })} +
+ )} + + {/* Non-photo media rendered inline */} + {otherItems.map((item) => { + const mediaUrl = ticketsApi.getMediaUrl(item.file_id); + if (item.type === 'video') { + return ( +
+
+ ); + } + return ( + + + + + {item.caption || `Download ${item.type}`} + + ); + })} + + {fullscreenIndex !== null && + photoItems[fullscreenIndex] && + createPortal( +
+ + + {photoItems.length > 1 && ( + <> + + +
+ {fullscreenIndex + 1} / {photoItems.length} +
+ + )} + +
+ {photoItems[fullscreenIndex].caption e.stopPropagation()} + /> +
+
, + document.body, + )} + + {/* Fallback: error state */} + {photoItems.length === 0 && otherItems.length === 0 && ( +
{translateError}
+ )} +
+ ); +} diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index 5ae505f..80ed5cd 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -1,12 +1,16 @@ import { useState, useRef, useEffect } from 'react'; +import logger from '../utils/logger'; +import { linkifyText } from '../utils/linkify'; +import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin'; +import { adminApi, AdminTicket, AdminTicketDetail } from '../api/admin'; import { ticketsApi } from '../api/tickets'; import { usePlatform } from '../platform/hooks/usePlatform'; interface MediaAttachment { + id: string; file: File; preview: string; uploading: boolean; @@ -34,123 +38,6 @@ const ALLOWED_FILE_TYPES: Record = { const ACCEPT_STRING = Object.keys(ALLOWED_FILE_TYPES).join(','); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB -function AdminMessageMedia({ - message, - t, -}: { - message: AdminTicketMessage; - t: (key: string) => string; -}) { - const [imageLoaded, setImageLoaded] = useState(false); - const [imageError, setImageError] = useState(false); - const [showFullImage, setShowFullImage] = useState(false); - - if (!message.has_media || !message.media_file_id) { - return null; - } - - const mediaUrl = ticketsApi.getMediaUrl(message.media_file_id); - - if (message.media_type === 'photo') { - return ( -
- {!imageLoaded && !imageError && ( -
-
-
- )} - {imageError ? ( -
- {t('support.imageLoadFailed')} -
- ) : ( - {message.media_caption setImageLoaded(true)} - onError={() => setImageError(true)} - onClick={() => setShowFullImage(true)} - /> - )} - {message.media_caption && ( -

{message.media_caption}

- )} - {showFullImage && ( -
setShowFullImage(false)} - > - - {message.media_caption -
- )} -
- ); - } - - if (message.media_type === 'video') { - return ( -
-
- ); - } - - return ( - - ); -} - // BackIcon const BackIcon = () => ( (null); const [statusFilter, setStatusFilter] = useState(''); const [replyText, setReplyText] = useState(''); + const [isReplying, setIsReplying] = useState(false); + const [replyError, setReplyError] = useState(null); const [page, setPage] = useState(1); - const [attachment, setAttachment] = useState(null); + const [attachments, setAttachments] = useState([]); const fileInputRef = useRef(null); - const previewRef = useRef(null); const uploadIdRef = useRef(0); - // Cancel in-flight uploads and cleanup blob URL on unmount + // Track all created blob URLs for cleanup on unmount + const blobUrlsRef = useRef>(new Set()); + useEffect(() => { const uploadRef = uploadIdRef; - const prevRef = previewRef; + const urls = blobUrlsRef; return () => { uploadRef.current++; - if (prevRef.current) { - URL.revokeObjectURL(prevRef.current); - } + urls.current.forEach((u) => URL.revokeObjectURL(u)); }; }, []); @@ -212,25 +100,6 @@ export default function AdminTickets() { enabled: !!selectedTicketId, }); - const replyMutation = useMutation({ - mutationFn: ({ - ticketId, - message, - media, - }: { - ticketId: number; - message: string; - media?: { media_type?: string; media_file_id?: string; media_caption?: string }; - }) => adminApi.replyToTicket(ticketId, message, media), - onSuccess: () => { - setReplyText(''); - clearAttachment(); - queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] }); - queryClient.invalidateQueries({ queryKey: ['admin-tickets'] }); - queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] }); - }, - }); - const statusMutation = useMutation({ mutationFn: ({ ticketId, status }: { ticketId: number; status: string }) => adminApi.updateTicketStatus(ticketId, status), @@ -241,84 +110,116 @@ export default function AdminTickets() { }, }); - const clearAttachment = () => { + const clearAttachments = () => { uploadIdRef.current++; - if (previewRef.current) { - URL.revokeObjectURL(previewRef.current); - previewRef.current = null; - } - setAttachment(null); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } + setAttachments((prev) => { + prev.forEach((a) => { + if (a.preview) { + URL.revokeObjectURL(a.preview); + blobUrlsRef.current.delete(a.preview); + } + }); + return []; + }); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const removeAttachment = (idx: number) => { + setAttachments((prev) => { + const removed = prev[idx]; + if (removed?.preview) URL.revokeObjectURL(removed.preview); + return prev.filter((_, i) => i !== idx); + }); }; const handleFileSelect = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; + const files = Array.from(e.target.files || []); + if (!files.length) return; + if (fileInputRef.current) fileInputRef.current.value = ''; - // Revoke any existing blob URL before processing new file - if (previewRef.current) { - URL.revokeObjectURL(previewRef.current); - previewRef.current = null; - } + const remaining = 10 - attachments.length; + const toAdd = files.slice(0, remaining); - const mediaType = ALLOWED_FILE_TYPES[file.type]; - if (!mediaType) { - setAttachment({ - file, - preview: '', - uploading: false, - mediaType: 'document', - error: t('admin.tickets.invalidFileType'), - }); - return; - } + for (const file of toAdd) { + const mediaType = ALLOWED_FILE_TYPES[file.type]; + if (!mediaType) continue; + if (file.size > MAX_FILE_SIZE) continue; - if (file.size > MAX_FILE_SIZE) { - setAttachment({ - file, - preview: '', - uploading: false, - mediaType, - error: t('admin.tickets.fileTooLarge'), - }); - return; - } + const preview = mediaType === 'photo' ? URL.createObjectURL(file) : ''; + if (preview) blobUrlsRef.current.add(preview); + const id = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `att_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const entry: MediaAttachment = { id, file, preview, uploading: true, mediaType }; + const uploadToken = uploadIdRef.current; - const preview = mediaType === 'photo' ? URL.createObjectURL(file) : ''; - previewRef.current = preview; + setAttachments((prev) => [...prev, entry]); - const currentUploadId = ++uploadIdRef.current; - setAttachment({ file, preview, uploading: true, mediaType }); - - try { - const result = await ticketsApi.uploadMedia(file, mediaType); - if (uploadIdRef.current !== currentUploadId) return; // stale upload - setAttachment((prev) => - prev ? { ...prev, uploading: false, fileId: result.file_id } : null, - ); - } catch { - if (uploadIdRef.current !== currentUploadId) return; // stale upload - setAttachment((prev) => - prev ? { ...prev, uploading: false, error: t('admin.tickets.uploadFailed') } : null, - ); + // Upload in background; ignore the result if user cleared/unmounted in the meantime. + (async () => { + try { + const result = await ticketsApi.uploadMedia(file, mediaType); + if (uploadIdRef.current !== uploadToken) return; + setAttachments((prev) => + prev.map((a) => (a.id === id ? { ...a, uploading: false, fileId: result.file_id } : a)), + ); + } catch { + if (uploadIdRef.current !== uploadToken) return; + setAttachments((prev) => + prev.map((a) => + a.id === id ? { ...a, uploading: false, error: t('admin.tickets.uploadFailed') } : a, + ), + ); + } + })(); } }; - const handleReply = (e: React.FormEvent) => { + const handleReply = async (e: React.FormEvent) => { e.preventDefault(); - if (!selectedTicketId || !replyText.trim()) return; - if (attachment && (attachment.uploading || attachment.error)) return; + if (!selectedTicketId) return; + if (attachments.some((a) => a.uploading || a.error)) return; - const media = attachment?.fileId + const readyAttachments = attachments.filter((a) => a.fileId) as Array<{ + fileId: string; + mediaType: string; + }>; + + const hasText = replyText.trim().length > 0; + const hasMedia = readyAttachments.length > 0; + if (!hasText && !hasMedia) return; + + const media = hasMedia ? { - media_type: attachment.mediaType, - media_file_id: attachment.fileId, + media_type: readyAttachments[0].mediaType, + media_file_id: readyAttachments[0].fileId, + media_items: readyAttachments.map((a) => ({ + type: a.mediaType as 'photo' | 'video' | 'document', + file_id: a.fileId, + })), } : undefined; - replyMutation.mutate({ ticketId: selectedTicketId, message: replyText, media }); + setIsReplying(true); + setReplyError(null); + try { + await adminApi.replyToTicket(selectedTicketId, replyText, media); + } catch (err) { + logger.error('Ticket reply failed:', err); + const msg = + err instanceof Error ? err.message : t('admin.tickets.replyFailed', 'Failed to send reply'); + setReplyError(msg); + setIsReplying(false); + return; + } + + setReplyText(''); + clearAttachments(); + setIsReplying(false); + queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] }); + queryClient.invalidateQueries({ queryKey: ['admin-tickets'] }); + queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] }); }; const getStatusBadge = (status: string) => { @@ -470,7 +371,7 @@ export default function AdminTickets() { onClick={() => { setSelectedTicketId(ticket.id); setReplyText(''); - clearAttachment(); + clearAttachments(); }} className={`w-full rounded-xl border p-4 text-left transition-all ${ selectedTicketId === ticket.id @@ -657,9 +558,12 @@ export default function AdminTickets() {
{msg.message_text && ( -

{msg.message_text}

+

)} - +

))}
@@ -675,76 +579,53 @@ export default function AdminTickets() { className="input resize-none" /> - {/* Attachment preview */} - {attachment && ( -
- {attachment.mediaType === 'photo' && attachment.preview ? ( - Preview - ) : ( -
- 0 && ( +
+ {attachments.map((att, idx) => ( +
+ {att.mediaType === 'photo' && att.preview ? ( + Preview + ) : ( +
+ {att.file.name.slice(-6)} +
+ )} + {att.uploading && ( +
+ +
+ )} + {att.error && ( +
+ ! +
+ )} +
- )} -
-
{attachment.file.name}
- {attachment.uploading && ( -
- - {t('admin.tickets.uploading')} -
- )} - {attachment.error && ( -
{attachment.error}
- )} - {attachment.fileId && !attachment.uploading && ( -
- {t('admin.tickets.uploadComplete')} -
- )} -
- + ))}
)} @@ -752,15 +633,22 @@ export default function AdminTickets() { ref={fileInputRef} type="file" accept={ACCEPT_STRING} + multiple onChange={handleFileSelect} className="hidden" /> + {replyError && ( +
+ {replyError} +
+ )} +
-

- {msg.message_text} -

- {msg.has_media && msg.media_file_id && ( -
- {msg.media_type === 'photo' ? ( - {msg.media_caption - ) : ( - - {msg.media_caption || msg.media_type} - - )} - {msg.media_caption && msg.media_type === 'photo' && ( -

{msg.media_caption}

- )} -
+ {msg.message_text && ( +

)} +

))}
diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index e70b1d4..495e12e 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -3,15 +3,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import { ticketsApi } from '../api/tickets'; +import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid'; import { infoApi } from '../api/info'; import { useAuthStore } from '../store/auth'; import { logger } from '../utils/logger'; import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; -import type { TicketDetail, TicketMessage } from '../types'; +import type { TicketDetail } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { usePlatform } from '@/platform'; +import { linkifyText } from '../utils/linkify'; const log = logger.createLogger('Support'); @@ -49,6 +51,7 @@ const CloseIcon = () => ( // Media attachment state interface MediaAttachment { + id: string; file: File; preview: string; uploading: boolean; @@ -56,97 +59,6 @@ interface MediaAttachment { error?: string; } -// Message media display component -function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string) => string }) { - const [imageLoaded, setImageLoaded] = useState(false); - const [imageError, setImageError] = useState(false); - const [showFullImage, setShowFullImage] = useState(false); - - if (!message.has_media || !message.media_file_id) { - return null; - } - - const mediaUrl = ticketsApi.getMediaUrl(message.media_file_id); - - if (message.media_type === 'photo') { - return ( - <> -
- {!imageLoaded && !imageError && ( -
-
-
- )} - {imageError ? ( -
- {t('support.imageLoadFailed')} -
- ) : ( - {message.media_caption setImageLoaded(true)} - onError={() => setImageError(true)} - onClick={() => setShowFullImage(true)} - /> - )} - {message.media_caption && ( -

{message.media_caption}

- )} -
- - {/* Full image modal */} - {showFullImage && ( -
setShowFullImage(false)} - > - - {message.media_caption -
- )} - - ); - } - - // For documents/videos - show download link - return ( - - ); -} - export default function Support() { log.debug('Component loaded'); @@ -161,38 +73,35 @@ export default function Support() { const [replyMessage, setReplyMessage] = useState(''); const [rateLimitError, setRateLimitError] = useState(null); - // Media attachment states - const [createAttachment, setCreateAttachment] = useState(null); - const [replyAttachment, setReplyAttachment] = useState(null); + // Media attachment states (multi-upload, up to 10) + const [createAttachments, setCreateAttachments] = useState([]); + const [replyAttachments, setReplyAttachments] = useState([]); const createFileInputRef = useRef(null); const replyFileInputRef = useRef(null); - const createPreviewRef = useRef(null); - const replyPreviewRef = useRef(null); - // Revoke blob URLs on unmount to prevent memory leaks + const blobUrlsRef = useRef>(new Set()); + useEffect(() => { - const createRef = createPreviewRef; - const replyRef = replyPreviewRef; + const urls = blobUrlsRef; return () => { - if (createRef.current) URL.revokeObjectURL(createRef.current); - if (replyRef.current) URL.revokeObjectURL(replyRef.current); + urls.current.forEach((u) => URL.revokeObjectURL(u)); }; }, []); - const clearCreateAttachment = () => { - if (createPreviewRef.current) { - URL.revokeObjectURL(createPreviewRef.current); - createPreviewRef.current = null; - } - clearCreateAttachment(); + const clearCreateAttachments = () => { + createAttachments.forEach((a) => { + if (a.preview) URL.revokeObjectURL(a.preview); + }); + setCreateAttachments([]); + if (createFileInputRef.current) createFileInputRef.current.value = ''; }; - const clearReplyAttachment = () => { - if (replyPreviewRef.current) { - URL.revokeObjectURL(replyPreviewRef.current); - replyPreviewRef.current = null; - } - clearReplyAttachment(); + const clearReplyAttachments = () => { + replyAttachments.forEach((a) => { + if (a.preview) URL.revokeObjectURL(a.preview); + }); + setReplyAttachments([]); + if (replyFileInputRef.current) replyFileInputRef.current.value = ''; }; // Get support configuration @@ -213,67 +122,49 @@ export default function Support() { enabled: !!selectedTicket, }); - // Handle file selection + // Handle file selection (multi-upload) const handleFileSelect = async ( file: File, - setAttachment: (a: MediaAttachment | null) => void, + setAttachments: React.Dispatch>, ) => { - // Validate file type const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; - if (!allowedTypes.includes(file.type)) { - setAttachment({ - file, - preview: '', - uploading: false, - error: t('support.invalidFileType'), - }); - return; - } + if (!allowedTypes.includes(file.type)) return; + if (file.size > 10 * 1024 * 1024) return; - // Validate file size (10MB) - if (file.size > 10 * 1024 * 1024) { - setAttachment({ - file, - preview: '', - uploading: false, - error: t('support.fileTooLarge'), - }); - return; - } - - // Revoke old blob URL before creating new one - const previewRef = setAttachment === setCreateAttachment ? createPreviewRef : replyPreviewRef; - if (previewRef.current) URL.revokeObjectURL(previewRef.current); const preview = URL.createObjectURL(file); - previewRef.current = preview; - setAttachment({ file, preview, uploading: true }); + blobUrlsRef.current.add(preview); + const id = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `att_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const entry: MediaAttachment = { id, file, preview, uploading: true }; + setAttachments((prev) => (prev.length >= 10 ? prev : [...prev, entry])); try { const result = await ticketsApi.uploadMedia(file, 'photo'); - setAttachment({ - file, - preview, - uploading: false, - fileId: result.file_id, - }); + setAttachments((prev) => + prev.map((a) => (a.id === id ? { ...a, uploading: false, fileId: result.file_id } : a)), + ); } catch { - setAttachment({ - file, - preview, - uploading: false, - error: t('support.uploadFailed'), - }); + setAttachments((prev) => + prev.map((a) => + a.id === id ? { ...a, uploading: false, error: t('support.uploadFailed') } : a, + ), + ); } }; const createMutation = useMutation({ mutationFn: async () => { - const media = createAttachment?.fileId - ? { - media_type: 'photo', - media_file_id: createAttachment.fileId, - } - : undefined; + const ready = createAttachments.filter((a) => a.fileId) as Array<{ fileId: string }>; + const media = + ready.length > 0 + ? { + media_type: 'photo', + media_file_id: ready[0].fileId, + media_items: ready.map((a) => ({ type: 'photo' as const, file_id: a.fileId })), + } + : undefined; return ticketsApi.createTicket(newTitle, newMessage, media); }, onSuccess: (ticket) => { @@ -281,25 +172,28 @@ export default function Support() { setShowCreateForm(false); setNewTitle(''); setNewMessage(''); - clearCreateAttachment(); + clearCreateAttachments(); setSelectedTicket(ticket); }, }); const replyMutation = useMutation({ mutationFn: async () => { - const media = replyAttachment?.fileId - ? { - media_type: 'photo', - media_file_id: replyAttachment.fileId, - } - : undefined; - return ticketsApi.addMessage(selectedTicket!.id, replyMessage, media); + const ready = replyAttachments.filter((a) => a.fileId) as Array<{ fileId: string }>; + const media = + ready.length > 0 + ? { + media_type: 'photo', + media_file_id: ready[0].fileId, + media_items: ready.map((a) => ({ type: 'photo' as const, file_id: a.fileId })), + } + : undefined; + await ticketsApi.addMessage(selectedTicket!.id, replyMessage, media); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] }); setReplyMessage(''); - clearReplyAttachment(); + clearReplyAttachments(); }, }); @@ -427,37 +321,50 @@ export default function Support() { ); } - // Attachment preview component - const AttachmentPreview = ({ - attachment, + // Attachments preview component + const AttachmentsPreview = ({ + items, onRemove, }: { - attachment: MediaAttachment; - onRemove: () => void; - }) => ( -
- {attachment.preview && ( - Attachment preview - )} - {attachment.uploading && ( -
-
-
- )} - {attachment.error &&
{attachment.error}
} - -
- ); + items: MediaAttachment[]; + onRemove: (idx: number) => void; + }) => + items.length === 0 ? null : ( +
+ {items.map((att, idx) => ( +
+ {att.preview ? ( + Preview + ) : ( +
+ {att.file.name.slice(-6)} +
+ )} + {att.uploading && ( +
+ +
+ )} + {att.error && ( +
+ ! +
+ )} + +
+ ))} +
+ ); return ( { setShowCreateForm(true); setSelectedTicket(null); - clearCreateAttachment(); + clearCreateAttachments(); }} > @@ -540,7 +447,7 @@ export default function Support() { onClick={() => { setSelectedTicket(ticket as unknown as TicketDetail); setShowCreateForm(false); - clearReplyAttachment(); + clearReplyAttachments(); }} className={`w-full rounded-bento border p-4 text-left transition-all ${ selectedTicket?.id === ticket.id @@ -629,32 +536,40 @@ export default function Support() { />
- {/* Image attachment for create */} + {/* Image attachments for create */}
{ - const file = e.target.files?.[0]; - if (file) handleFileSelect(file, setCreateAttachment); + const files = Array.from(e.target.files || []); + files.forEach((file) => handleFileSelect(file, setCreateAttachments)); e.target.value = ''; }} /> - {createAttachment ? ( - clearCreateAttachment()} - /> - ) : ( + + setCreateAttachments((prev) => { + const removed = prev[idx]; + if (removed?.preview) URL.revokeObjectURL(removed.preview); + return prev.filter((_, i) => i !== idx); + }) + } + /> + {createAttachments.length < 10 && ( )}
@@ -668,7 +583,7 @@ export default function Support() {
-
{msg.message_text}
+ {msg.message_text && ( +
+ )} {/* Display media if present */} - +
))}
@@ -763,46 +686,56 @@ export default function Support() { placeholder={t('support.replyPlaceholder')} value={replyMessage} onChange={(e) => setReplyMessage(e.target.value)} - required - minLength={1} maxLength={4000} />
- {/* Image attachment for reply */} + {/* Image attachments for reply */} +
+ { + const files = Array.from(e.target.files || []); + files.forEach((file) => handleFileSelect(file, setReplyAttachments)); + e.target.value = ''; + }} + /> + + setReplyAttachments((prev) => { + const removed = prev[idx]; + if (removed?.preview) URL.revokeObjectURL(removed.preview); + return prev.filter((_, i) => i !== idx); + }) + } + /> +
-
- { - const file = e.target.files?.[0]; - if (file) handleFileSelect(file, setReplyAttachment); - e.target.value = ''; - }} - /> - {replyAttachment ? ( - clearReplyAttachment()} - /> - ) : ( - - )} -
+ {replyAttachments.length < 10 && ( + + )}
+ {/* Offline Conversions (inside Yandex block) */} + {analytics?.offline_conv_enabled && ( + <> +
+
+
+ + + + + {t('admin.settings.offlineConv', 'Offline Conversions')} + +
+ + + {t('admin.settings.counterActive')} + +
+ {analytics.offline_conv_counter_id && ( +
+ {t('admin.settings.counterId')}: + + {analytics.offline_conv_counter_id} + +
+ )} + {analytics.offline_conv_measurement_secret_masked && ( +
+ + {t('admin.settings.apiKey', 'API Key')}: + + + {analytics.offline_conv_measurement_secret_masked} + +
+ )} + {analytics.offline_conv_goals && analytics.offline_conv_goals.length > 0 && ( +
+ {analytics.offline_conv_goals.map((goal) => ( +
+
+ + {goal.name} +
+
+ + {goal.event_id} + + {goal.dedup} +
+
+ ))} +
+ )} + + )}
{/* Google Ads */} diff --git a/src/hooks/useAnalyticsCounters.ts b/src/hooks/useAnalyticsCounters.ts index fdf4660..8d02a2e 100644 --- a/src/hooks/useAnalyticsCounters.ts +++ b/src/hooks/useAnalyticsCounters.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { brandingApi } from '../api/branding'; +import { setYandexCid } from '../utils/yandexCid'; const YM_SCRIPT_ID = 'ym-counter-script'; const GTAG_LOADER_ID = 'gtag-loader-script'; @@ -11,6 +12,11 @@ function removeElement(id: string) { } function injectYandexMetrika(counterId: string) { + try { + localStorage.setItem('ym_counter_id', counterId); + } catch { + /* sandboxed / private */ + } if (document.getElementById(YM_SCRIPT_ID)) return; const script = document.createElement('script'); @@ -70,6 +76,8 @@ export function useAnalyticsCounters() { // Yandex Metrika if (data.yandex_metrika_id) { injectYandexMetrika(data.yandex_metrika_id); + cacheYandexCid(data.yandex_metrika_id); + syncYandexCid(data.yandex_metrika_id); } else { removeElement(YM_SCRIPT_ID); } @@ -83,3 +91,87 @@ export function useAnalyticsCounters() { } }, [data]); } + +function cacheYandexCid(counterId: string) { + const w = window as unknown as Record; + const ym = w.ym as ((...args: unknown[]) => void) | undefined; + if (typeof ym !== 'function') return; + setTimeout(() => { + try { + (w.ym as (...args: unknown[]) => void)(Number(counterId), 'getClientID', (cid: string) => { + if (cid) setYandexCid(cid); + }); + } catch { + /* ignore */ + } + }, 2000); +} + +function syncYandexCid(counterId: string) { + const SENT_KEY = 'ym_cid_sent'; + try { + if (localStorage.getItem(SENT_KEY)) return; + } catch { + return; + } + const w = window as unknown as Record; + const ym = w.ym as ((...args: unknown[]) => void) | undefined; + if (typeof ym !== 'function') return; + setTimeout(() => { + try { + (w.ym as (...args: unknown[]) => void)(Number(counterId), 'getClientID', (cid: string) => { + if (!cid) return; + setYandexCid(cid); + // Only POST when the user is authenticated. Guest sessions cache the + // CID locally; it gets synced after login by the next mount of this + // hook on the cabinet shell. + let token: string | null = null; + try { + token = localStorage.getItem('access_token'); + } catch { + /* ignore */ + } + if (!token) return; + // Route through brandingApi (apiClient) so baseURL, auth refresh, and + // error handling all flow through the same interceptors as every other + // cabinet API call. + import('../api/branding').then(({ brandingApi: api }) => { + api + .storeYandexCid(cid) + .then(() => { + try { + localStorage.setItem(SENT_KEY, '1'); + } catch { + /* ignore */ + } + }) + .catch(() => { + /* swallow -- non-critical, will retry on next login */ + }); + }); + }); + } catch { + /* ignore */ + } + }, 3000); +} + +export function fireAnalyticsEvent(goalName: string, params?: Record) { + const w = window as unknown as Record; + const ym = w.ym as ((...args: unknown[]) => void) | undefined; + if (typeof ym === 'function') { + try { + const counterId = localStorage.getItem('ym_counter_id'); + if (counterId && /^\d{1,15}$/.test(counterId)) { + ym(Number(counterId), 'reachGoal', goalName, params); + } + } catch { + /* ignore */ + } + } +} + +// Re-export the canonical CID accessor from utils for back-compat with +// existing imports inside this hook file. New code should import from +// '../utils/yandexCid' directly to avoid pulling React into api/. +export { getYandexCid } from '../utils/yandexCid'; diff --git a/src/locales/en.json b/src/locales/en.json index 12ed0e2..c28819d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2021,7 +2021,9 @@ "telegramOidcEnabled": "OIDC Enabled", "telegramOidcClientId": "Client ID (Bot ID)", "telegramOidcClientSecret": "Client Secret" - } + }, + "offlineConv": "Offline Conversions", + "apiKey": "API Key" }, "bulkActions": { "title": "Bulk Actions", diff --git a/src/locales/fa.json b/src/locales/fa.json index 63d5f36..ad3ab4e 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1671,7 +1671,9 @@ "telegramOidcEnabled": "فعال‌سازی OIDC", "telegramOidcClientId": "Client ID (شناسه ربات)", "telegramOidcClientSecret": "Client Secret" - } + }, + "offlineConv": "\u062a\u0628\u062f\u06cc\u0644\u200c\u0647\u0627\u06cc \u0622\u0641\u0644\u0627\u06cc\u0646", + "apiKey": "\u06a9\u0644\u06cc\u062f API" }, "buttons": { "color": "رنگ دکمه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 6d34673..74cb79f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2533,7 +2533,9 @@ "TIMEZONE": "Часовой пояс", "REFERRAL": "Реферальная система", "MODERATION": "Модерация" - } + }, + "offlineConv": "Офлайн-конверсии", + "apiKey": "API-ключ" }, "buttons": { "color": "Цвет кнопки", diff --git a/src/locales/zh.json b/src/locales/zh.json index 6c04819..644db0f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1709,7 +1709,9 @@ "telegramOidcEnabled": "启用 OIDC", "telegramOidcClientId": "Client ID(机器人 ID)", "telegramOidcClientSecret": "Client Secret" - } + }, + "offlineConv": "\u79bb\u7ebf\u8f6c\u5316", + "apiKey": "API \u5bc6\u94a5" }, "buttons": { "color": "按钮颜色", diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index 8dc3877..5a680d6 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import { useParams } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { fireAnalyticsEvent, getYandexCid } from '../hooks/useAnalyticsCounters'; import { motion, AnimatePresence } from 'framer-motion'; import DOMPurify from 'dompurify'; import { landingApi } from '../api/landings'; @@ -470,6 +472,7 @@ function SummaryCard({ currentPrice, isSubmitting, canSubmit, + stickyPayButton = false, submitError, onSubmit, }: { @@ -479,11 +482,22 @@ function SummaryCard({ currentPrice: number; isSubmitting: boolean; canSubmit: boolean; + stickyPayButton?: boolean; submitError: string | null; onSubmit: () => void; }) { const { t } = useTranslation(); + // Responsive: track mobile for sticky pay button + const [isMobile, setIsMobile] = useState( + () => typeof window !== 'undefined' && window.innerWidth < 1024, + ); + useEffect(() => { + const onResize = () => setIsMobile(window.innerWidth < 1024); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + return (
{/* Summary */} @@ -569,32 +583,101 @@ function SummaryCard({ {/* Pay button */} - + > + {isSubmitting ? ( +
+ ) : ( + <> + {t('landing.pay', 'Pay')}{' '} + {selectedPeriod?.original_price_kopeks != null && + selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && ( + + {formatPrice(selectedPeriod.original_price_kopeks)} + + )} + {formatPrice(currentPrice)} + + )} + +
, + document.body, + ) + ) : ( + + )} {/* Footer */} {config.footer_text && ( @@ -737,10 +820,41 @@ export default function QuickPurchase() { if (config?.discount) setDiscountExpired(false); }, [config?.discount]); + // Save document.referrer on mount (before SPA navigation loses it) + useEffect(() => { + if (document.referrer && !sessionStorage.getItem('landing_referrer')) { + sessionStorage.setItem('landing_referrer', document.referrer); + } + // Save subid from URL + const urlSubid = new URLSearchParams(window.location.search).get('subid'); + if (urlSubid) { + sessionStorage.setItem('landing_subid', urlSubid); + } + }, []); + + // Fire landing-specific view goal on mount + useEffect(() => { + if (config?.analytics_view_enabled && config?.analytics_view_goal) { + try { + const w = window as unknown as Record; + const counterId = localStorage.getItem('ym_counter_id'); + if (counterId && typeof w.ym === 'function') { + (w.ym as (...args: unknown[]) => void)( + Number(counterId), + 'reachGoal', + config.analytics_view_goal, + ); + } + } catch { + /* ignore */ + } + } + }, [config?.analytics_view_enabled, config?.analytics_view_goal]); + // Selection state const [selectedTariffId, setSelectedTariffId] = useState(null); const [selectedPeriodDays, setSelectedPeriodDays] = useState(null); - const [contactValue, setContactValue] = useState(''); + const [contactValue, setContactValue] = useState(() => localStorage.getItem('lp_contact') || ''); const [isGift, setIsGift] = useState(false); const [giftRecipient, setGiftRecipient] = useState(''); const [giftMessage, setGiftMessage] = useState(''); @@ -844,7 +958,9 @@ export default function QuickPurchase() { if (!config?.custom_css) return; let css = config.custom_css; - // Strip all at-rules (including @font-face, @import, @charset, @namespace, @keyframes, @media) + // Strip ALL @-rules (block + inline). The previous full-strip regex was + // intentionally broad: @media / @keyframes / @supports / @container can + // smuggle behaviour the rest of the sanitizer doesn't catch. css = css.replace(/@[a-zA-Z-]+\s*[^{}]*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g, ''); css = css.replace(/@[a-zA-Z-]+\s*[^;{}]+;/g, ''); // Strip ALL url() including data: URIs @@ -911,6 +1027,8 @@ export default function QuickPurchase() { const handleSubmit = () => { if (!canSubmit || !slug || isSubmitting) return; + fireAnalyticsEvent('purchase_click'); + setIsSubmitting(true); setSubmitError(null); @@ -929,6 +1047,7 @@ export default function QuickPurchase() { payment_method: paymentMethod, language: i18n.language, is_gift: isGift, + referrer: sessionStorage.getItem('landing_referrer') || undefined, }; if (isGift && giftRecipient) { @@ -937,6 +1056,29 @@ export default function QuickPurchase() { data.gift_message = giftMessage.trim() || undefined; } + // Get Yandex CID for offline conversions (sync from localStorage) + const ymCid = getYandexCid(); + if (ymCid) data.yandex_cid = ymCid; + const subid = sessionStorage.getItem('landing_subid'); + if (subid) (data as unknown as Record).subid = subid; + + // Fire landing-specific click goal + if (config?.analytics_click_enabled && config?.analytics_click_goal) { + try { + const w = window as unknown as Record; + const counterId = localStorage.getItem('ym_counter_id'); + if (counterId && typeof w.ym === 'function') { + (w.ym as (...args: unknown[]) => void)( + Number(counterId), + 'reachGoal', + config.analytics_click_goal, + ); + } + } catch { + /* ignore */ + } + } + purchaseMutation.mutate(data); }; @@ -1015,6 +1157,7 @@ export default function QuickPurchase() { contactValue={contactValue} onContactChange={(v) => { setContactValue(v); + localStorage.setItem('lp_contact', v); setSubmitError(null); }} isGift={isGift} @@ -1095,7 +1238,10 @@ export default function QuickPurchase() { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.2 }} - className="min-w-0 lg:sticky lg:top-8 lg:self-start" + className={cn( + 'min-w-0 lg:sticky lg:top-8 lg:self-start', + config?.sticky_pay_button && 'mb-20 lg:mb-0', + )} >
diff --git a/src/utils/yandexCid.ts b/src/utils/yandexCid.ts new file mode 100644 index 0000000..1c01a7d --- /dev/null +++ b/src/utils/yandexCid.ts @@ -0,0 +1,25 @@ +/** + * Yandex Metrika ClientID helpers. + * + * Lives in `utils/` (not `hooks/`) so that both the `api/` layer + * (auth.ts, oauth.ts) and React hooks can read the cached CID + * without `api/` accidentally importing from `hooks/`. + */ + +const STORAGE_KEY = 'ym_client_id'; + +export function getYandexCid(): string | null { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } +} + +export function setYandexCid(cid: string): void { + try { + localStorage.setItem(STORAGE_KEY, cid); + } catch { + /* sandboxed iframe / private mode -- ignore */ + } +} From a50bd39df2fbc1fa994249ad24f1a02eb3f4ad8a Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 09:06:29 +0300 Subject: [PATCH 09/12] fix: validate counterId/conversionId before script injection (XSS prevention) --- src/hooks/useAnalyticsCounters.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/useAnalyticsCounters.ts b/src/hooks/useAnalyticsCounters.ts index 8d02a2e..2b5ee7f 100644 --- a/src/hooks/useAnalyticsCounters.ts +++ b/src/hooks/useAnalyticsCounters.ts @@ -12,6 +12,7 @@ function removeElement(id: string) { } function injectYandexMetrika(counterId: string) { + if (!/^\d{1,15}$/.test(counterId)) return; try { localStorage.setItem('ym_counter_id', counterId); } catch { @@ -38,6 +39,7 @@ function injectYandexMetrika(counterId: string) { } function injectGoogleAds(conversionId: string) { + if (!/^[A-Za-z0-9_-]{1,30}$/.test(conversionId)) return; if (document.getElementById(GTAG_LOADER_ID)) return; // External gtag.js loader From 020f4c95e271d3f582cb907e9aeeef0fe7cc2353 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 09:17:52 +0300 Subject: [PATCH 10/12] feat: landing analytics goals, daily bar chart, referrer tracking, contact persistence - Add per-landing analytics goals (view/click) with admin editor toggle - Add sticky pay button option for mobile landing pages - Add daily purchases bar chart (created vs paid) to landing stats - Replace single purchase count with created/paid split in stats summary - Add referrer tracking to purchases with hostname display in stats - Add time display to purchase cards alongside date - Pass user timezone to stats API for correct daily grouping - Clamp referrer (500 chars) and subid (255 chars) to backend limits - Persist contact value per-landing-slug in localStorage - Fire buy_success analytics goal on successful delivery - Export USER_TIMEZONE from format utils - Add analytics/stats translations for fa.json and zh.json locales Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api/landings.ts | 30 +++- src/locales/en.json | 7 +- src/locales/fa.json | 65 ++++++++- src/locales/ru.json | 6 +- src/locales/zh.json | 65 ++++++++- src/pages/AdminLandingEditor.tsx | 84 +++++++++++ src/pages/AdminLandingStats.tsx | 236 +++++++++++++++++++++++-------- src/pages/AdminLandings.tsx | 14 +- src/pages/PurchaseSuccess.tsx | 27 ++++ src/pages/QuickPurchase.tsx | 25 +++- src/utils/format.ts | 2 + 11 files changed, 482 insertions(+), 79 deletions(-) diff --git a/src/api/landings.ts b/src/api/landings.ts index cae1c1f..17e7d69 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -89,12 +89,11 @@ export interface LandingConfig { meta_description: string | null; discount: LandingDiscountInfo | null; background_config: AnimationConfig | null; - // Per-landing analytics goals + sticky pay button (bot PR #2852 backend fields) - analytics_view_enabled?: boolean; - analytics_view_goal?: string | null; - analytics_click_enabled?: boolean; - analytics_click_goal?: string | null; - sticky_pay_button?: boolean; + analytics_view_enabled: boolean; + analytics_view_goal: string; + analytics_click_enabled: boolean; + analytics_click_goal: string; + sticky_pay_button: boolean; } export interface PurchaseRequest { @@ -167,6 +166,8 @@ export interface LandingListItem { gift_enabled: boolean; tariff_count: number; method_count: number; + analytics_view_enabled: boolean; + analytics_click_enabled: boolean; purchase_stats: { total: number; pending: number; @@ -205,6 +206,11 @@ export interface LandingDetail { discount_ends_at: string | null; discount_badge_text: LocaleDict | null; background_config: AnimationConfig | null; + analytics_view_enabled: boolean; + analytics_view_goal: string; + analytics_click_enabled: boolean; + analytics_click_goal: string; + sticky_pay_button: boolean; } export interface LandingCreateRequest { @@ -227,6 +233,11 @@ export interface LandingCreateRequest { discount_ends_at?: string | null; discount_badge_text?: LocaleDict | null; background_config?: AnimationConfig | null; + analytics_view_enabled?: boolean; + analytics_view_goal?: string; + analytics_click_enabled?: boolean; + analytics_click_goal?: string; + sticky_pay_button?: boolean; } export type LandingUpdateRequest = Partial; @@ -272,6 +283,7 @@ export const landingApi = { export interface LandingDailyStat { date: string; + created: number; purchases: number; revenue_kopeks: number; gifts: number; @@ -321,6 +333,7 @@ export interface LandingPurchaseItem { status: PurchaseItemStatus; created_at: string; paid_at: string | null; + referrer: string | null; } export interface LandingPurchaseListResponse { @@ -364,7 +377,10 @@ export const adminLandingsApi = { }, getStats: async (id: number): Promise => { - const response = await apiClient.get(`/cabinet/admin/landings/${id}/stats`); + const { USER_TIMEZONE } = await import('../utils/format'); + const response = await apiClient.get(`/cabinet/admin/landings/${id}/stats`, { + params: { tz: USER_TIMEZONE }, + }); return response.data; }, diff --git a/src/locales/en.json b/src/locales/en.json index c28819d..9b10375 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3776,6 +3776,10 @@ "discountOverridesHint": "Leave empty to use global discount", "discountPreview": "Preview", "discountActive": "Discount", + "background": "Animated background", + "analytics": "Analytics", + "viewGoal": "View goal", + "clickGoal": "Payment click goal", "statistics": "Statistics", "stats": { "title": "Landing Statistics", @@ -3796,7 +3800,8 @@ "successful": "Successful", "funnel": "Funnel", "loadError": "Failed to load statistics", - "noPurchases": "No purchases" + "noPurchases": "No purchases", + "paid": "paid" }, "purchases": { "title": "Purchases", diff --git a/src/locales/fa.json b/src/locales/fa.json index ad3ab4e..4bdc6ee 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3381,7 +3381,70 @@ "loadingPeriods": "در حال بارگذاری...", "periodDaySuffix": "روز", "localeTab": "زبان", - "localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید" + "localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید", + "discount": "تخفیف", + "discountEnabled": "فعال‌سازی تخفیف", + "discountPercent": "تخفیف %", + "discountStartsAt": "تاریخ شروع", + "discountEndsAt": "تاریخ پایان", + "discountBadge": "متن بنر (اختیاری)", + "discountBadgePlaceholder": "مثلاً حراج بهاره!", + "discountOverrides": "تخفیف‌های جداگانه برای هر طرح", + "discountOverridesHint": "خالی بگذارید تا از تخفیف عمومی استفاده شود", + "discountPreview": "پیش‌نمایش", + "discountActive": "تخفیف", + "background": "پس‌زمینه متحرک", + "analytics": "تحلیل‌ها", + "viewGoal": "هدف بازدید", + "clickGoal": "هدف کلیک پرداخت", + "statistics": "آمار", + "stats": { + "title": "آمار صفحه فرود", + "totalPurchases": "خریدها", + "revenue": "درآمد", + "giftPurchases": "هدایا", + "regularPurchases": "عادی", + "conversionRate": "نرخ تبدیل", + "avgPurchase": "میانگین خرید", + "dailyChart": "خرید و درآمد روزانه", + "tariffChart": "توزیع طرح‌ها", + "giftBreakdown": "هدایا در مقابل عادی", + "purchases": "خریدها", + "revenueLabel": "درآمد", + "gifts": "هدایا", + "regular": "عادی", + "created": "ایجاد شده", + "successful": "موفق", + "funnel": "قیف", + "loadError": "بارگذاری آمار ناموفق بود", + "noPurchases": "خریدی وجود ندارد", + "paid": "پرداخت شده" + }, + "purchases": { + "title": "خریدها", + "contact": "مخاطب", + "recipient": "گیرنده", + "tariff": "طرح", + "period": "دوره", + "days": "روز", + "price": "قیمت", + "method": "روش", + "date": "تاریخ", + "gift": "هدیه", + "forSelf": "برای خود", + "allStatuses": "همه وضعیت‌ها", + "status_pending": "در انتظار", + "status_paid": "پرداخت شده", + "status_delivered": "تحویل شده", + "status_pending_activation": "در انتظار فعال‌سازی", + "status_failed": "ناموفق", + "status_expired": "منقضی شده", + "noPurchases": "خریدی وجود ندارد", + "showing": "نمایش {{from}}–{{to}} از {{total}}", + "page": "صفحه {{current}} از {{total}}", + "prev": "قبلی", + "next": "بعدی" + } }, "infoPages": { "title": "صفحات اطلاعات", diff --git a/src/locales/ru.json b/src/locales/ru.json index 74cb79f..c381d48 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4320,6 +4320,9 @@ "discountPreview": "Предпросмотр", "discountActive": "Скидка", "background": "Анимированный фон", + "analytics": "Аналитика", + "viewGoal": "Цель просмотра", + "clickGoal": "Цель клика оплаты", "statistics": "Статистика", "stats": { "title": "Статистика лендинга", @@ -4340,7 +4343,8 @@ "successful": "Успешных", "funnel": "Воронка", "loadError": "Не удалось загрузить статистику", - "noPurchases": "Нет покупок" + "noPurchases": "Нет покупок", + "paid": "оплачено" }, "purchases": { "title": "Покупки", diff --git a/src/locales/zh.json b/src/locales/zh.json index 644db0f..9993ce9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3380,7 +3380,70 @@ "loadingPeriods": "加载中...", "periodDaySuffix": "天", "localeTab": "语言", - "localeHint": "切换语言以编辑该语言的文本" + "localeHint": "切换语言以编辑该语言的文本", + "discount": "折扣", + "discountEnabled": "启用折扣", + "discountPercent": "折扣 %", + "discountStartsAt": "开始日期", + "discountEndsAt": "结束日期", + "discountBadge": "横幅文本(可选)", + "discountBadgePlaceholder": "例如 春季特卖!", + "discountOverrides": "各套餐独立折扣", + "discountOverridesHint": "留空则使用全局折扣", + "discountPreview": "预览", + "discountActive": "折扣", + "background": "动态背景", + "analytics": "分析", + "viewGoal": "浏览目标", + "clickGoal": "支付点击目标", + "statistics": "统计", + "stats": { + "title": "落地页统计", + "totalPurchases": "购买次数", + "revenue": "收入", + "giftPurchases": "礼物", + "regularPurchases": "普通", + "conversionRate": "转化率", + "avgPurchase": "平均客单价", + "dailyChart": "每日购买与收入", + "tariffChart": "套餐分布", + "giftBreakdown": "礼物 vs 普通", + "purchases": "购买", + "revenueLabel": "收入", + "gifts": "礼物", + "regular": "普通", + "created": "已创建", + "successful": "成功", + "funnel": "漏斗", + "loadError": "加载统计失败", + "noPurchases": "无购买记录", + "paid": "已支付" + }, + "purchases": { + "title": "购买记录", + "contact": "联系方式", + "recipient": "收件人", + "tariff": "套餐", + "period": "周期", + "days": "天", + "price": "价格", + "method": "方式", + "date": "日期", + "gift": "礼物", + "forSelf": "自用", + "allStatuses": "全部状态", + "status_pending": "待处理", + "status_paid": "已支付", + "status_delivered": "已交付", + "status_pending_activation": "待激活", + "status_failed": "失败", + "status_expired": "已过期", + "noPurchases": "无购买记录", + "showing": "显示第 {{from}}–{{to}} 条,共 {{total}} 条", + "page": "第 {{current}} 页,共 {{total}} 页", + "prev": "上一页", + "next": "下一页" + } }, "infoPages": { "title": "信息页面", diff --git a/src/pages/AdminLandingEditor.tsx b/src/pages/AdminLandingEditor.tsx index 39dc68f..ef9f395 100644 --- a/src/pages/AdminLandingEditor.tsx +++ b/src/pages/AdminLandingEditor.tsx @@ -106,6 +106,7 @@ export default function AdminLandingEditor() { methods: false, gifts: false, background: false, + analytics: false, footer: false, }); @@ -137,6 +138,13 @@ export default function AdminLandingEditor() { enabled: false, }); + // Analytics goals state + const [analyticsViewEnabled, setAnalyticsViewEnabled] = useState(false); + const [analyticsViewGoal, setAnalyticsViewGoal] = useState('landing_view'); + const [analyticsClickEnabled, setAnalyticsClickEnabled] = useState(false); + const [analyticsClickGoal, setAnalyticsClickGoal] = useState('landing_pay'); + const [stickyPayButton, setStickyPayButton] = useState(false); + // Discount state const [discountPercent, setDiscountPercent] = useState(null); const [discountOverrides, setDiscountOverrides] = useState>({}); @@ -267,6 +275,11 @@ export default function AdminLandingEditor() { landingData.discount_ends_at ? isoToDatetimeLocal(landingData.discount_ends_at) : '', ); setDiscountBadgeText(toLocaleDict(landingData.discount_badge_text)); + setAnalyticsViewEnabled(landingData.analytics_view_enabled ?? false); + setAnalyticsViewGoal(landingData.analytics_view_goal || 'landing_view'); + setAnalyticsClickEnabled(landingData.analytics_click_enabled ?? false); + setAnalyticsClickGoal(landingData.analytics_click_goal || 'landing_pay'); + setStickyPayButton(landingData.sticky_pay_button ?? false); }, [landingData]); // Create mutation @@ -378,6 +391,11 @@ export default function AdminLandingEditor() { discount_badge_text: discountPercent !== null ? (nonEmptyDict(discountBadgeText) ?? null) : null, background_config: backgroundConfig.enabled ? backgroundConfig : null, + analytics_view_enabled: analyticsViewEnabled, + analytics_view_goal: analyticsViewGoal, + analytics_click_enabled: analyticsClickEnabled, + analytics_click_goal: analyticsClickGoal, + sticky_pay_button: stickyPayButton, }; if (isEdit) { @@ -1079,6 +1097,72 @@ export default function AdminLandingEditor() { + {/* Analytics Goals Section */} +
toggleSection('analytics')} + > +
+ {/* View Goal */} +
+
+ + setAnalyticsViewGoal(e.target.value)} + disabled={!analyticsViewEnabled} + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500 disabled:opacity-50" + placeholder="landing_view" + /> +
+ setAnalyticsViewEnabled((v) => !v)} + /> +
+ + {/* Click Goal */} +
+
+ + setAnalyticsClickGoal(e.target.value)} + disabled={!analyticsClickEnabled} + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500 disabled:opacity-50" + placeholder="landing_pay" + /> +
+ setAnalyticsClickEnabled((v) => !v)} + /> +
+ {/* Sticky pay button on mobile */} +
+
+

+ {t('admin.landings.stickyPayButton', 'Sticky pay button (mobile)')} +

+

+ {t( + 'admin.landings.stickyPayButtonHint', + 'Button pinned to bottom of screen on mobile', + )} +

+
+ setStickyPayButton((v) => !v)} /> +
+
+
+ {/* Footer & Custom CSS Section */}
{ + try { + return new URL(item.referrer).hostname; + } catch { + return item.referrer; + } + })() + : null; return (
@@ -205,8 +218,19 @@ function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) {
)} - {/* Date */} -
{dateStr}
+ {/* Referrer */} + {referrerHost && ( +
+ {referrerHost} +
+ )} + {/* Date + Time */} +
+ {dateStr} {timeStr} +
); @@ -276,15 +300,16 @@ export default function AdminLandingStats() { const dailyData = useMemo(() => { if (!stats) return []; return stats.daily_stats.map((item) => ({ - label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, { - month: 'short', - day: 'numeric', - }), + label: (() => { + const d = new Date(item.date + 'T00:00:00'); + return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`; + })(), + created: item.created, purchases: item.purchases, revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR, gifts: item.gifts, })); - }, [stats, i18n.language]); + }, [stats]); // Prepare tariff chart data const tariffData = useMemo(() => { @@ -377,13 +402,18 @@ export default function AdminLandingStats() { {/* Summary Cards */}
-
- {stats.total_purchases} +
+ {stats.total_created} + / + {stats.total_successful} +
+
+ {t('admin.landings.stats.created', 'Created')} /{' '} + {t('admin.landings.stats.paid', 'paid')}
-
{t('admin.landings.stats.totalPurchases')}
-
+
{formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
{t('admin.landings.stats.revenue')}
@@ -393,7 +423,7 @@ export default function AdminLandingStats() {
{t('admin.landings.stats.giftPurchases')}
-
+
{stats.conversion_rate}%
{t('admin.landings.stats.conversionRate')}
@@ -451,6 +481,24 @@ export default function AdminLandingStats() { stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY} /> + + + + + - {/* Tariff Distribution */} + {/* Daily Purchases Bar Chart */}

- {t('admin.landings.stats.tariffChart')} + {t('admin.landings.stats.dailyPurchases', 'Daily purchases')}

- {tariffData.length === 0 ? ( + {dailyData.length === 0 ? (
{t('admin.landings.stats.noPurchases')}
) : ( - - - - - - { - return [value ?? 0, t('admin.landings.stats.purchases')]; - }} - /> - - {tariffData.map((_, index) => ( - - ))} - - - +
+ {[...dailyData] + .slice(-7) + .reverse() + .map((day, i) => { + const purchasedPct = + (day.created || 0) > 0 + ? ((day.purchases || 0) / (day.created || 1)) * 100 + : 0; + return ( +
+ + {day.label} + +
+
+
+ + {day.created || 0} + / + {day.purchases || 0} + +
+ ); + })} +
+
+
+ {t('admin.landings.stats.created', 'Created')} +
+
+
+ {t('admin.landings.stats.paid', 'paid')} +
+
+
)}
+ {/* Tariff Distribution -- full width */} +
+

+ {t('admin.landings.stats.tariffChart')} +

+ {tariffData.length === 0 ? ( +
+ {t('admin.landings.stats.noPurchases')} +
+ ) : ( + + + + + + { + return [value ?? 0, t('admin.landings.stats.purchases')]; + }} + /> + + {tariffData.map((_, index) => ( + + ))} + + + + )} +
+ {/* Additional Stats Row */}
@@ -587,7 +699,9 @@ export default function AdminLandingStats() { {t('admin.landings.stats.created')} {' / '} {stats.total_successful}{' '} - {t('admin.landings.stats.successful')} + + {t('admin.landings.stats.paid', 'paid')} +
diff --git a/src/pages/AdminLandings.tsx b/src/pages/AdminLandings.tsx index 568e9b7..ffcadee 100644 --- a/src/pages/AdminLandings.tsx +++ b/src/pages/AdminLandings.tsx @@ -180,7 +180,19 @@ function SortableLandingCard({
- {landing.purchase_stats.total} {t('admin.landings.purchaseCount')} + {landing.purchase_stats.total} + + {t('admin.landings.stats.created', 'created')} + + / + + {landing.purchase_stats.paid + + landing.purchase_stats.delivered + + landing.purchase_stats.pending_activation} + + + {t('admin.landings.stats.paid', 'paid')} +
diff --git a/src/pages/PurchaseSuccess.tsx b/src/pages/PurchaseSuccess.tsx index cc9deb5..0f27b1a 100644 --- a/src/pages/PurchaseSuccess.tsx +++ b/src/pages/PurchaseSuccess.tsx @@ -665,6 +665,33 @@ export default function PurchaseSuccess() { }, [token, queryClient]); const isSuccess = purchaseStatus?.status === 'delivered'; + + // Fire analytics goal on successful delivery (once per purchase). + // Idempotency keyed by token so a page refresh doesn't double-count. + useEffect(() => { + if (!isSuccess || !token) return; + const FIRED_KEY = `ym_buy_success_${token}`; + try { + if (localStorage.getItem(FIRED_KEY)) return; + } catch { + /* ignore */ + } + try { + const counterId = localStorage.getItem('ym_counter_id'); + const w = window as unknown as Record; + if (counterId && typeof w.ym === 'function') { + (w.ym as (...args: unknown[]) => void)(Number(counterId), 'reachGoal', 'buy_success'); + try { + localStorage.setItem(FIRED_KEY, '1'); + } catch { + /* ignore */ + } + } + } catch { + /* analytics error */ + } + }, [isSuccess, token]); + const isPendingActivation = purchaseStatus?.status === 'pending_activation'; const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired'; diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index 5a680d6..3288833 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -820,15 +820,17 @@ export default function QuickPurchase() { if (config?.discount) setDiscountExpired(false); }, [config?.discount]); - // Save document.referrer on mount (before SPA navigation loses it) + // Save document.referrer on mount (before SPA navigation loses it). + // Clamp to 500 chars -- backend `referrer` column is max_length=500 and would + // otherwise reject long ad-click referrers (gclid+gbraid+params) with 422. useEffect(() => { if (document.referrer && !sessionStorage.getItem('landing_referrer')) { - sessionStorage.setItem('landing_referrer', document.referrer); + sessionStorage.setItem('landing_referrer', document.referrer.slice(0, 500)); } - // Save subid from URL + // Save subid from URL (also clamped to backend limit of 255) const urlSubid = new URLSearchParams(window.location.search).get('subid'); if (urlSubid) { - sessionStorage.setItem('landing_subid', urlSubid); + sessionStorage.setItem('landing_subid', urlSubid.slice(0, 255)); } }, []); @@ -854,7 +856,14 @@ export default function QuickPurchase() { // Selection state const [selectedTariffId, setSelectedTariffId] = useState(null); const [selectedPeriodDays, setSelectedPeriodDays] = useState(null); - const [contactValue, setContactValue] = useState(() => localStorage.getItem('lp_contact') || ''); + const contactKey = `lp_contact_${slug ?? ''}`; + const [contactValue, setContactValue] = useState(() => { + try { + return localStorage.getItem(contactKey) || ''; + } catch { + return ''; + } + }); const [isGift, setIsGift] = useState(false); const [giftRecipient, setGiftRecipient] = useState(''); const [giftMessage, setGiftMessage] = useState(''); @@ -1157,7 +1166,11 @@ export default function QuickPurchase() { contactValue={contactValue} onContactChange={(v) => { setContactValue(v); - localStorage.setItem('lp_contact', v); + try { + localStorage.setItem(contactKey, v); + } catch { + /* */ + } setSubmitError(null); }} isGift={isGift} diff --git a/src/utils/format.ts b/src/utils/format.ts index ee42e2f..a116038 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,3 +1,5 @@ +export const USER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + export function formatUptime(seconds: number): string { const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); From ae55a18fc9217f98e5bcbd6edde0d14b38884a7a Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 11:14:38 +0300 Subject: [PATCH 11/12] feat: dedicated RBAC permissions for bulk actions, info pages, news --- src/App.tsx | 8 ++++---- src/pages/AdminPanel.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 096c55c..33f220f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -985,7 +985,7 @@ function App() { + @@ -1300,7 +1300,7 @@ function App() { + @@ -1310,7 +1310,7 @@ function App() { + @@ -1320,7 +1320,7 @@ function App() { + diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 24db4c2..6f9f917 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -431,7 +431,7 @@ const sections: AdminSection[] = [ name: 'admin.nav.bulkActions', icon: 'list-checks', to: '/admin/bulk-actions', - permission: 'users:read', + permission: 'bulk_actions:read', }, { name: 'admin.nav.tickets', @@ -574,7 +574,7 @@ const sections: AdminSection[] = [ name: 'admin.nav.infoPages', icon: 'file-text', to: '/admin/info-pages', - permission: 'settings:read', + permission: 'info_pages:read', }, { name: 'admin.nav.updates', From afffab17d316bc7c37fde10cf6c125320b7828c6 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 11:48:59 +0300 Subject: [PATCH 12/12] feat: bulk delete subscription protection for active paid subs Add force_delete_active_paid guard to prevent accidental deletion of active paid subscriptions. Shows warning with count and requires explicit checkbox confirmation. Also fixes allVisibleSubscriptionIds to use filteredUsers and getFilteredSubs to respect trialOnly filter on subscription sub-rows. --- src/api/adminBulkActions.ts | 1 + src/api/adminUsers.ts | 1 + src/locales/en.json | 4 +- src/locales/ru.json | 4 +- src/pages/AdminBulkActions.tsx | 205 +++++++++++++++++++++++---------- 5 files changed, 150 insertions(+), 65 deletions(-) diff --git a/src/api/adminBulkActions.ts b/src/api/adminBulkActions.ts index 43bb19f..9afcd01 100644 --- a/src/api/adminBulkActions.ts +++ b/src/api/adminBulkActions.ts @@ -32,6 +32,7 @@ export interface BulkActionParams { promo_group_id?: number | null; device_limit?: number; delete_from_panel?: boolean; + force_delete_active_paid?: boolean; } export interface BulkActionErrorItem { diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 604bf94..c348e46 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -38,6 +38,7 @@ export interface UserListItemSubscription { tariff_id: number | null; tariff_name: string | null; status: string; + is_trial: boolean; end_date: string | null; days_remaining: number; traffic_used_gb: number; diff --git a/src/locales/en.json b/src/locales/en.json index 9b10375..607ddcc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2050,7 +2050,9 @@ }, "deleteSubscription": { "warning": "Selected subscriptions will be permanently deleted from the bot and RemnaWave panel!", - "hint": "Users will lose VPN access for deleted subscriptions. This action cannot be undone." + "hint": "Users will lose VPN access for deleted subscriptions. This action cannot be undone.", + "activePaidWarning": "{{count}} of {{total}} selected subscriptions are active paid", + "forceDeleteConfirm": "I confirm deletion of active paid subscriptions" }, "deleteUser": { "warning": "Selected users will be permanently deleted from the bot! All their subscriptions, balance, and data will be lost!", diff --git a/src/locales/ru.json b/src/locales/ru.json index c381d48..7c03a0f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3812,7 +3812,9 @@ }, "deleteSubscription": { "warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!", - "hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить." + "hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить.", + "activePaidWarning": "{{count}} из {{total}} выбранных подписок — активные платные", + "forceDeleteConfirm": "Подтверждаю удаление активных платных подписок" }, "deleteUser": { "warning": "Выбранные пользователи будут безвозвратно удалены из бота! Все их подписки, баланс и данные будут потеряны!", diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 0a9c286..ad5ad9a 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -741,6 +741,8 @@ interface ActionModalProps { selectedCount: number; tariffs: TariffListItem[]; promoGroups: PromoGroup[]; + users: UserListItem[]; + selectedSubscriptionIds: number[]; onClose: () => void; onExecute: (params: BulkActionParams) => void; } @@ -750,6 +752,8 @@ function ActionModal({ selectedCount, tariffs, promoGroups, + users, + selectedSubscriptionIds, onClose, onExecute, }: ActionModalProps) { @@ -763,6 +767,7 @@ function ActionModal({ const [grantDays, setGrantDays] = useState(30); const [deviceLimit, setDeviceLimit] = useState(1); const [deleteFromPanel, setDeleteFromPanel] = useState(true); + const [forceDeleteActivePaid, setForceDeleteActivePaid] = useState(false); useEffect(() => { if (tariffs.length > 0 && tariffId === 0) { @@ -779,10 +784,15 @@ function ActionModal({ } }, [promoGroups, promoGroupId]); - // Reset deleteFromPanel to default when modal opens + // Reset modal-specific state when modal opens useEffect(() => { - if (modal.open && modal.action === 'delete_user') { - setDeleteFromPanel(true); + if (modal.open) { + if (modal.action === 'delete_user') { + setDeleteFromPanel(true); + } + if (modal.action === 'delete_subscription') { + setForceDeleteActivePaid(false); + } } }, [modal.open, modal.action]); @@ -798,6 +808,25 @@ function ActionModal({ return () => document.removeEventListener('keydown', handleKeyDown); }, [modal.open, modal.loading, onClose]); + // Count active paid subscriptions among selected ones + const activePaidCount = useMemo(() => { + if (modal.action !== 'delete_subscription') return 0; + const selectedSubIdSet = new Set(selectedSubscriptionIds); + let count = 0; + for (const user of users) { + for (const sub of user.subscriptions ?? []) { + if (selectedSubIdSet.has(sub.id) && sub.status === 'active' && !sub.is_trial) { + count++; + } + } + } + return count; + }, [modal.action, selectedSubscriptionIds, users]); + + const isConfirmDisabled = + modal.loading || + (modal.action === 'delete_subscription' && activePaidCount > 0 && !forceDeleteActivePaid); + if (!modal.open || !modal.action) return null; const actionLabelKeys: Record = { @@ -840,6 +869,9 @@ function ActionModal({ case 'set_devices': params.device_limit = deviceLimit; break; + case 'delete_subscription': + params.force_delete_active_paid = forceDeleteActivePaid; + break; case 'delete_user': params.delete_from_panel = deleteFromPanel; break; @@ -982,17 +1014,56 @@ function ActionModal({
); - case 'delete_subscription': + case 'delete_subscription': { + const totalSelected = selectedSubscriptionIds.length; + return ( -
-

- {t('admin.bulkActions.deleteSubscription.warning')} -

-

- {t('admin.bulkActions.deleteSubscription.hint')} -

+
+
+

+ {t('admin.bulkActions.deleteSubscription.warning')} +

+

+ {t('admin.bulkActions.deleteSubscription.hint')} +

+
+ {activePaidCount > 0 && ( + <> +
+

+ {t('admin.bulkActions.deleteSubscription.activePaidWarning', { + count: activePaidCount, + total: totalSelected, + })} +

+
+ + + )}
); + } case 'delete_user': return (
@@ -1158,7 +1229,7 @@ function ActionModal({