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/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/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 8697196..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; @@ -158,6 +159,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 +698,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/api/auth.ts b/src/api/auth.ts index 7bfd3fa..4e7db24 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,4 +1,5 @@ import apiClient from './client'; +import { getYandexCid } from '../utils/yandexCid'; import type { AuthResponse, LinkCallbackResponse, @@ -22,6 +23,7 @@ export const authApi = { init_data: initData, campaign_slug: campaignSlug || undefined, referral_code: referralCode || undefined, + yandex_cid: getYandexCid() || undefined, }); return response.data; }, @@ -43,6 +45,7 @@ export const authApi = { ...data, campaign_slug: campaignSlug || undefined, referral_code: referralCode || undefined, + yandex_cid: getYandexCid() || undefined, }); return response.data; }, @@ -56,6 +59,7 @@ export const authApi = { id_token: idToken, campaign_slug: campaignSlug || undefined, referral_code: referralCode || undefined, + yandex_cid: getYandexCid() || undefined, }); return response.data; }, @@ -71,6 +75,7 @@ export const authApi = { password, campaign_slug: campaignSlug || undefined, referral_code: referralCode || undefined, + yandex_cid: getYandexCid() || undefined, }); return response.data; }, @@ -87,6 +92,7 @@ export const authApi = { const response = await apiClient.post('/cabinet/auth/email/register', { email, password, + yandex_cid: getYandexCid() || undefined, }); return response.data; }, @@ -101,7 +107,7 @@ export const authApi = { }): Promise => { const response = await apiClient.post( '/cabinet/auth/email/register/standalone', - data, + { ...data, yandex_cid: getYandexCid() || undefined }, ); return response.data; }, @@ -198,6 +204,7 @@ export const authApi = { device_id: deviceId || undefined, campaign_slug: campaignSlug || undefined, referral_code: referralCode || undefined, + yandex_cid: getYandexCid() || undefined, }, ); return response.data; diff --git a/src/api/branding.ts b/src/api/branding.ts index ec3b9c8..c2aa0da 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -38,10 +38,20 @@ export interface TelegramWidgetConfig { oidc_client_id: string; } +export interface OfflineConvGoal { + name: string; + event_id: string; + dedup: string; +} + export interface AnalyticsCounters { yandex_metrika_id: string; google_ads_id: string; google_ads_label: string; + offline_conv_enabled?: boolean; + offline_conv_counter_id?: string; + offline_conv_measurement_secret_masked?: string; + offline_conv_goals?: OfflineConvGoal[]; } const BRANDING_CACHE_KEY = 'cabinet_branding'; @@ -274,7 +284,14 @@ export const brandingApi = { const response = await apiClient.get('/cabinet/branding/analytics'); return response.data; } catch { - return { yandex_metrika_id: '', google_ads_id: '', google_ads_label: '' }; + return { + yandex_metrika_id: '', + google_ads_id: '', + google_ads_label: '', + offline_conv_enabled: false, + offline_conv_counter_id: '', + offline_conv_goals: [], + }; } }, @@ -303,4 +320,9 @@ export const brandingApi = { const response = await apiClient.patch('/cabinet/branding/analytics', data); return response.data; }, + + // Store Yandex Metrika ClientID for the authenticated cabinet user + storeYandexCid: async (cid: string): Promise => { + await apiClient.post('/cabinet/branding/analytics/yandex-cid', { cid }); + }, }; diff --git a/src/api/landings.ts b/src/api/landings.ts index 83437c5..17e7d69 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -89,6 +89,11 @@ export interface LandingConfig { meta_description: string | null; discount: LandingDiscountInfo | 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 PurchaseRequest { @@ -102,6 +107,10 @@ export interface PurchaseRequest { gift_recipient_value?: string; gift_message?: string; language?: string; + // Yandex offline-conversions linkage (bot PR #2851 backend fields) + yandex_cid?: string; + referrer?: string; + subid?: string; } export interface PurchaseResponse { @@ -157,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; @@ -195,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 { @@ -217,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; @@ -262,6 +283,7 @@ export const landingApi = { export interface LandingDailyStat { date: string; + created: number; purchases: number; revenue_kopeks: number; gifts: number; @@ -311,6 +333,7 @@ export interface LandingPurchaseItem { status: PurchaseItemStatus; created_at: string; paid_at: string | null; + referrer: string | null; } export interface LandingPurchaseListResponse { @@ -354,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/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/admin/AnalyticsTab.tsx b/src/components/admin/AnalyticsTab.tsx index 033dbcd..c592ffd 100644 --- a/src/components/admin/AnalyticsTab.tsx +++ b/src/components/admin/AnalyticsTab.tsx @@ -161,6 +161,65 @@ export function AnalyticsTab() { )}

{t('admin.settings.yandexIdHint')}

+ {/* 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/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} > - 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/hooks/useAnalyticsCounters.ts b/src/hooks/useAnalyticsCounters.ts index fdf4660..2b5ee7f 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,12 @@ function removeElement(id: string) { } function injectYandexMetrika(counterId: string) { + if (!/^\d{1,15}$/.test(counterId)) return; + try { + localStorage.setItem('ym_counter_id', counterId); + } catch { + /* sandboxed / private */ + } if (document.getElementById(YM_SCRIPT_ID)) return; const script = document.createElement('script'); @@ -32,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 @@ -70,6 +78,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 +93,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 4263969..607ddcc 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", @@ -2048,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!", @@ -3075,6 +3079,20 @@ "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", + "noVpnData": "No connection data available", + "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", @@ -3760,6 +3778,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", @@ -3780,7 +3802,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 3c29a4a..4bdc6ee 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": "رنگ دکمه", @@ -2579,6 +2581,19 @@ "language": "زبان", "registration": "ثبت‌نام", "lastActivity": "آخرین فعالیت", + "botActivity": "فعالیت در ربات", + "cabinetLastLogin": "آخرین ورود به کابینت", + "vpnConnection": "اتصال VPN", + "lastConnection": "آخرین اتصال", + "firstConnection": "اولین اتصال", + "online": "آنلاین", + "requestHistory": "تاریخچه درخواست‌های اشتراک", + "requestHistoryTotal": "کل درخواست‌ها", + "requestAt": "تاریخ", + "requestIp": "آدرس IP", + "requestUserAgent": "دستگاه", + "loadMore": "نمایش بیشتر", + "noRequests": "بدون درخواست", "totalSpent": "کل هزینه", "purchases": "خریدها", "campaign": "کمپین تبلیغاتی", @@ -3366,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 6de3b43..7c03a0f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2533,7 +2533,9 @@ "TIMEZONE": "Часовой пояс", "REFERRAL": "Реферальная система", "MODERATION": "Модерация" - } + }, + "offlineConv": "Офлайн-конверсии", + "apiKey": "API-ключ" }, "buttons": { "color": "Цвет кнопки", @@ -3482,6 +3484,20 @@ "language": "Язык", "registration": "Регистрация", "lastActivity": "Последняя активность", + "botActivity": "Активность в боте", + "cabinetLastLogin": "Последний вход в кабинет", + "vpnConnection": "VPN подключение", + "lastConnection": "Последнее подключение", + "firstConnection": "Первое подключение", + "online": "Онлайн", + "noVpnData": "Нет данных о подключении", + "requestHistory": "История запросов подписки", + "requestHistoryTotal": "Всего запросов", + "requestAt": "Дата", + "requestIp": "IP адрес", + "requestUserAgent": "Устройство", + "loadMore": "Показать ещё", + "noRequests": "Нет запросов", "totalSpent": "Всего потрачено", "purchases": "Покупок", "campaign": "Рекламная кампания", @@ -3796,7 +3812,9 @@ }, "deleteSubscription": { "warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!", - "hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить." + "hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить.", + "activePaidWarning": "{{count}} из {{total}} выбранных подписок — активные платные", + "forceDeleteConfirm": "Подтверждаю удаление активных платных подписок" }, "deleteUser": { "warning": "Выбранные пользователи будут безвозвратно удалены из бота! Все их подписки, баланс и данные будут потеряны!", @@ -4304,6 +4322,9 @@ "discountPreview": "Предпросмотр", "discountActive": "Скидка", "background": "Анимированный фон", + "analytics": "Аналитика", + "viewGoal": "Цель просмотра", + "clickGoal": "Цель клика оплаты", "statistics": "Статистика", "stats": { "title": "Статистика лендинга", @@ -4324,7 +4345,8 @@ "successful": "Успешных", "funnel": "Воронка", "loadError": "Не удалось загрузить статистику", - "noPurchases": "Нет покупок" + "noPurchases": "Нет покупок", + "paid": "оплачено" }, "purchases": { "title": "Покупки", diff --git a/src/locales/zh.json b/src/locales/zh.json index 8661527..9993ce9 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": "按钮颜色", @@ -2578,6 +2580,19 @@ "language": "语言", "registration": "注册时间", "lastActivity": "最后活跃", + "botActivity": "机器人活动", + "cabinetLastLogin": "最后登录面板", + "vpnConnection": "VPN 连接", + "lastConnection": "最后连接", + "firstConnection": "首次连接", + "online": "在线", + "requestHistory": "订阅请求历史", + "requestHistoryTotal": "总请求数", + "requestAt": "日期", + "requestIp": "IP 地址", + "requestUserAgent": "设备", + "loadMore": "显示更多", + "noRequests": "没有请求", "totalSpent": "总消费", "purchases": "购买次数", "campaign": "广告活动", @@ -3365,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/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({
); @@ -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/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', 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} +
+ )} +
- {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 +1376,112 @@ export default function AdminUserDetail() {
+ {/* VPN Connection Info */} + {(panelInfo || userSubscriptions.length > 0) && ( +
+
+ + {t('admin.users.detail.vpnConnection')} + + {userSubscriptions.length > 1 && ( + + )} +
+ {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')} +
+ )} +
+ )} + {/* Campaign */} {user.campaign_name && (
@@ -2348,6 +2509,142 @@ 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 && ( + + )} + + )} +
+ )} +
)}
@@ -2796,32 +3093,13 @@ export default function AdminUserDetail() { {formatDate(msg.created_at)}
-

- {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/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 8dc3877..3288833 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,50 @@ export default function QuickPurchase() { if (config?.discount) setDiscountExpired(false); }, [config?.discount]); + // 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.slice(0, 500)); + } + // 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.slice(0, 255)); + } + }, []); + + // 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 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(''); @@ -844,7 +967,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 +1036,8 @@ export default function QuickPurchase() { const handleSubmit = () => { if (!canSubmit || !slug || isSubmitting) return; + fireAnalyticsEvent('purchase_click'); + setIsSubmitting(true); setSubmitError(null); @@ -929,6 +1056,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 +1065,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 +1166,11 @@ export default function QuickPurchase() { contactValue={contactValue} onContactChange={(v) => { setContactValue(v); + try { + localStorage.setItem(contactKey, v); + } catch { + /* */ + } setSubmitError(null); }} isGift={isGift} @@ -1095,7 +1251,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/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 && ( + + )}