Merge pull request #413 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-04-29 12:12:34 +03:00
committed by GitHub
30 changed files with 2080 additions and 732 deletions

View File

@@ -985,7 +985,7 @@ function App() {
<Route
path="/admin/bulk-actions"
element={
<PermissionRoute permission="users:read">
<PermissionRoute permission="bulk_actions:read">
<LazyPage>
<AdminBulkActions />
</LazyPage>
@@ -1300,7 +1300,7 @@ function App() {
<Route
path="/admin/info-pages"
element={
<PermissionRoute permission="settings:read">
<PermissionRoute permission="info_pages:read">
<LazyPage>
<AdminInfoPages />
</LazyPage>
@@ -1310,7 +1310,7 @@ function App() {
<Route
path="/admin/info-pages/create"
element={
<PermissionRoute permission="settings:edit">
<PermissionRoute permission="info_pages:edit">
<LazyPage>
<AdminInfoPageEditor />
</LazyPage>
@@ -1320,7 +1320,7 @@ function App() {
<Route
path="/admin/info-pages/:id/edit"
element={
<PermissionRoute permission="settings:edit">
<PermissionRoute permission="info_pages:edit">
<LazyPage>
<AdminInfoPageEditor />
</LazyPage>

View File

@@ -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<AdminTicketMessage> => {
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, {
message,

View File

@@ -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 {

View File

@@ -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<SubscriptionRequestHistory> => {
const params: Record<string, unknown> = { 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,

View File

@@ -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<RegisterResponse> => {
const response = await apiClient.post<RegisterResponse>(
'/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;

View File

@@ -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<AnalyticsCounters>('/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<AnalyticsCounters>('/cabinet/branding/analytics', data);
return response.data;
},
// Store Yandex Metrika ClientID for the authenticated cabinet user
storeYandexCid: async (cid: string): Promise<void> => {
await apiClient.post('/cabinet/branding/analytics/yandex-cid', { cid });
},
};

View File

@@ -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<LandingCreateRequest>;
@@ -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<LandingStatsResponse> => {
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;
},

View File

@@ -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 = {

View File

@@ -161,6 +161,65 @@ export function AnalyticsTab() {
)}
<p className="text-xs text-dark-500">{t('admin.settings.yandexIdHint')}</p>
</div>
{/* Offline Conversions (inside Yandex block) */}
{analytics?.offline_conv_enabled && (
<>
<div className="my-5 border-t border-dark-700/30" />
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-orange-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z" />
</svg>
<span className="text-sm font-medium text-dark-200">
{t('admin.settings.offlineConv', 'Offline Conversions')}
</span>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-500/15 px-2 py-0.5 text-xs font-medium text-success-400">
<span className="h-1.5 w-1.5 rounded-full bg-success-400" />
{t('admin.settings.counterActive')}
</span>
</div>
{analytics.offline_conv_counter_id && (
<div className="mb-2 flex items-center gap-2">
<span className="text-sm text-dark-400">{t('admin.settings.counterId')}:</span>
<span className="font-mono text-sm text-dark-200">
{analytics.offline_conv_counter_id}
</span>
</div>
)}
{analytics.offline_conv_measurement_secret_masked && (
<div className="mb-3 flex items-center gap-2">
<span className="text-sm text-dark-400">
{t('admin.settings.apiKey', 'API Key')}:
</span>
<code className="rounded-md bg-dark-700/50 px-2 py-0.5 font-mono text-sm text-dark-300">
{analytics.offline_conv_measurement_secret_masked}
</code>
</div>
)}
{analytics.offline_conv_goals && analytics.offline_conv_goals.length > 0 && (
<div className="grid gap-2">
{analytics.offline_conv_goals.map((goal) => (
<div
key={goal.event_id}
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-900/40 px-4 py-2.5"
>
<div className="flex items-center gap-3">
<span className="inline-flex h-2 w-2 rounded-full bg-success-400" />
<span className="text-sm text-dark-200">{goal.name}</span>
</div>
<div className="flex items-center gap-4">
<code className="rounded-md bg-dark-700/50 px-2 py-0.5 text-xs text-dark-400">
{goal.event_id}
</code>
<span className="text-xs text-dark-500">{goal.dedup}</span>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
{/* Google Ads */}

View File

@@ -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<typeof SwitchPrimitive.Root>,
@@ -42,13 +40,11 @@ export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
{...props}
>
<SwitchPrimitive.Thumb asChild>
<motion.span
<span
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0',
'pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform duration-200',
'data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
layout
transition={springTransition}
/>
</SwitchPrimitive.Thumb>
</SwitchPrimitive.Root>

View File

@@ -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<number | null>(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 (
<div className="mt-3 space-y-2">
{photoItems.length > 0 && (
<div className={`grid gap-1 ${gridClass}`}>
{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 (
<button
key={`${item.file_id}-${visIdx}`}
type="button"
className="group relative aspect-square overflow-hidden rounded-lg bg-dark-800"
onClick={() => openFullscreen(originalIdx)}
>
<img
src={ticketsApi.getMediaUrl(item.file_id)}
alt={item.caption || 'Attached photo'}
className="h-full w-full object-cover transition-opacity group-hover:opacity-90"
loading="lazy"
/>
{isLastVisible && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/60 text-2xl font-semibold text-white">
+{hiddenCount}
</div>
)}
</button>
);
})}
</div>
)}
{/* Non-photo media rendered inline */}
{otherItems.map((item) => {
const mediaUrl = ticketsApi.getMediaUrl(item.file_id);
if (item.type === 'video') {
return (
<div key={item.file_id}>
<video
src={mediaUrl}
controls
className="max-h-64 max-w-full rounded-lg"
preload="metadata"
/>
{item.caption && <p className="mt-1 text-xs text-dark-400">{item.caption}</p>}
</div>
);
}
return (
<a
key={item.file_id}
href={mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
{item.caption || `Download ${item.type}`}
</a>
);
})}
{fullscreenIndex !== null &&
photoItems[fullscreenIndex] &&
createPortal(
<div
className="fixed inset-0 z-[9999] bg-black"
style={{ touchAction: 'pan-x pan-y pinch-zoom' }}
>
<button
type="button"
className="absolute right-4 top-4 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white text-black shadow-xl transition-colors hover:bg-gray-200"
onClick={closeFullscreen}
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{photoItems.length > 1 && (
<>
<button
type="button"
disabled={fullscreenIndex === 0}
onClick={() => setFullscreenIndex(fullscreenIndex - 1)}
className="absolute left-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 text-black shadow-xl transition-colors hover:bg-white disabled:opacity-30"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
type="button"
disabled={fullscreenIndex >= photoItems.length - 1}
onClick={() => setFullscreenIndex(fullscreenIndex + 1)}
className="absolute right-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 text-black shadow-xl transition-colors hover:bg-white disabled:opacity-30"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/70 px-3 py-1 text-sm text-white">
{fullscreenIndex + 1} / {photoItems.length}
</div>
</>
)}
<div
className="flex h-full w-full items-center justify-center overflow-auto"
onClick={closeFullscreen}
>
<img
src={ticketsApi.getMediaUrl(photoItems[fullscreenIndex].file_id)}
alt={photoItems[fullscreenIndex].caption || 'Attached photo'}
className="max-h-full max-w-full object-contain"
style={{ touchAction: 'pinch-zoom' }}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>,
document.body,
)}
{/* Fallback: error state */}
{photoItems.length === 0 && otherItems.length === 0 && (
<div className="text-xs text-dark-400">{translateError}</div>
)}
</div>
);
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>) {
const w = window as unknown as Record<string, unknown>;
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';

View File

@@ -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",

View File

@@ -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": "صفحات اطلاعات",

View File

@@ -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": "Покупки",

View File

@@ -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": "信息页面",

View File

@@ -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<BulkActionType, string> = {
@@ -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({
</div>
</div>
);
case 'delete_subscription':
case 'delete_subscription': {
const totalSelected = selectedSubscriptionIds.length;
return (
<div className="rounded-xl border border-error-500/20 bg-error-500/5 px-3 py-2.5">
<p className="text-sm font-medium text-error-400">
{t('admin.bulkActions.deleteSubscription.warning')}
</p>
<p className="mt-1 text-xs text-error-300/70">
{t('admin.bulkActions.deleteSubscription.hint')}
</p>
<div className="space-y-4">
<div className="rounded-xl border border-error-500/20 bg-error-500/5 px-3 py-2.5">
<p className="text-sm font-medium text-error-400">
{t('admin.bulkActions.deleteSubscription.warning')}
</p>
<p className="mt-1 text-xs text-error-300/70">
{t('admin.bulkActions.deleteSubscription.hint')}
</p>
</div>
{activePaidCount > 0 && (
<>
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2.5">
<p className="text-sm font-medium text-amber-400">
{t('admin.bulkActions.deleteSubscription.activePaidWarning', {
count: activePaidCount,
total: totalSelected,
})}
</p>
</div>
<label className="inline-flex min-h-[44px] cursor-pointer items-center gap-2">
<button
onClick={() => setForceDeleteActivePaid((prev) => !prev)}
className={cn(
'flex h-6 w-6 items-center justify-center rounded-md border-2 transition-all duration-150',
forceDeleteActivePaid
? 'border-error-500 bg-error-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]'
: 'border-dark-500 bg-dark-700/60 hover:border-error-500/50 hover:bg-dark-600/60',
)}
aria-pressed={forceDeleteActivePaid}
>
{forceDeleteActivePaid && <CheckIcon />}
</button>
<span
className={cn(
'text-sm',
forceDeleteActivePaid ? 'font-medium text-error-400' : 'text-dark-400',
)}
>
{t('admin.bulkActions.deleteSubscription.forceDeleteConfirm')}
</span>
</label>
</>
)}
</div>
);
}
case 'delete_user':
return (
<div className="space-y-4">
@@ -1158,7 +1229,7 @@ function ActionModal({
</button>
<button
onClick={handleSubmit}
disabled={modal.loading}
disabled={isConfirmDisabled}
className="flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{t('admin.bulkActions.confirm')}
@@ -1670,60 +1741,18 @@ export default function AdminBulkActions() {
const getFilteredSubs = useCallback(
(subs: UserListItemSubscription[]): UserListItemSubscription[] => {
if (tariffFilter.length === 0) return subs;
return subs.filter((s) => s.tariff_id !== null && tariffFilter.includes(s.tariff_id));
let result = subs;
if (tariffFilter.length > 0) {
result = result.filter((s) => s.tariff_id !== null && tariffFilter.includes(s.tariff_id));
}
if (trialOnly) {
result = result.filter((s) => s.is_trial);
}
return result;
},
[tariffFilter],
[tariffFilter, trialOnly],
);
const allVisibleSubscriptionIds = useMemo(() => {
const ids: number[] = [];
for (const user of users) {
const subs = user.subscriptions ?? [];
const filtered = getFilteredSubs(subs);
for (const sub of filtered) {
ids.push(sub.id);
}
}
return ids;
}, [users, getFilteredSubs]);
const toggleAllSubscriptions = useCallback(() => {
const allSelected =
allVisibleSubscriptionIds.length > 0 &&
allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]);
if (allSelected) {
// Deselect all
const next: Record<number, boolean> = {};
for (const key of Object.keys(subscriptionSelection)) {
const id = Number(key);
if (!allVisibleSubscriptionIds.includes(id)) {
next[id] = subscriptionSelection[id];
}
}
setSubscriptionSelection(next);
} else {
// Select all visible
const next = { ...subscriptionSelection };
for (const id of allVisibleSubscriptionIds) {
next[id] = true;
}
setSubscriptionSelection(next);
}
// Auto-expand rows that have filtered subs
const expanded: Record<number, boolean> = { ...expandedRows };
for (const user of users) {
const subs = user.subscriptions ?? [];
const filtered = getFilteredSubs(subs);
if (filtered.length > 1) {
expanded[user.id] = true;
}
}
setExpandedRows(expanded);
}, [allVisibleSubscriptionIds, subscriptionSelection, expandedRows, users, getFilteredSubs]);
const handleOpenAction = (type: BulkActionType) => {
setModal({ open: true, action: type, loading: false, result: null, progress: null });
};
@@ -2089,6 +2118,54 @@ export default function AdminBulkActions() {
return result;
}, [users, trialOnly]);
const allVisibleSubscriptionIds = useMemo(() => {
const ids: number[] = [];
for (const user of filteredUsers) {
const subs = user.subscriptions ?? [];
const filtered = getFilteredSubs(subs);
for (const sub of filtered) {
ids.push(sub.id);
}
}
return ids;
}, [filteredUsers, getFilteredSubs]);
const toggleAllSubscriptions = useCallback(() => {
const allSelected =
allVisibleSubscriptionIds.length > 0 &&
allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]);
if (allSelected) {
// Deselect all
const next: Record<number, boolean> = {};
for (const key of Object.keys(subscriptionSelection)) {
const id = Number(key);
if (!allVisibleSubscriptionIds.includes(id)) {
next[id] = subscriptionSelection[id];
}
}
setSubscriptionSelection(next);
} else {
// Select all visible
const next = { ...subscriptionSelection };
for (const id of allVisibleSubscriptionIds) {
next[id] = true;
}
setSubscriptionSelection(next);
}
// Auto-expand rows that have filtered subs
const expanded: Record<number, boolean> = { ...expandedRows };
for (const user of users) {
const subs = user.subscriptions ?? [];
const filtered = getFilteredSubs(subs);
if (filtered.length > 1) {
expanded[user.id] = true;
}
}
setExpandedRows(expanded);
}, [allVisibleSubscriptionIds, subscriptionSelection, expandedRows, users, getFilteredSubs]);
const table = useReactTable({
data: filteredUsers,
columns,
@@ -2417,6 +2494,8 @@ export default function AdminBulkActions() {
}
tariffs={tariffs}
promoGroups={promoGroups}
users={users}
selectedSubscriptionIds={selectedSubscriptionIds}
onClose={handleCloseModal}
onExecute={handleExecuteAction}
/>

View File

@@ -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<number | null>(null);
const [discountOverrides, setDiscountOverrides] = useState<Record<string, number>>({});
@@ -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() {
<BackgroundConfigEditor value={backgroundConfig} onChange={setBackgroundConfig} />
</Section>
{/* Analytics Goals Section */}
<Section
title={t('admin.landings.analytics', 'Analytics')}
open={openSections.analytics}
onToggle={() => toggleSection('analytics')}
>
<div className="space-y-4">
{/* View Goal */}
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.viewGoal', 'View goal')}
</label>
<input
type="text"
value={analyticsViewGoal}
onChange={(e) => 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"
/>
</div>
<Toggle
checked={analyticsViewEnabled}
onChange={() => setAnalyticsViewEnabled((v) => !v)}
/>
</div>
{/* Click Goal */}
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.clickGoal', 'Payment click goal')}
</label>
<input
type="text"
value={analyticsClickGoal}
onChange={(e) => 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"
/>
</div>
<Toggle
checked={analyticsClickEnabled}
onChange={() => setAnalyticsClickEnabled((v) => !v)}
/>
</div>
{/* Sticky pay button on mobile */}
<div className="flex items-center justify-between gap-4 border-t border-dark-800 pt-4">
<div>
<p className="text-sm text-dark-300">
{t('admin.landings.stickyPayButton', 'Sticky pay button (mobile)')}
</p>
<p className="text-xs text-dark-500">
{t(
'admin.landings.stickyPayButtonHint',
'Button pinned to bottom of screen on mobile',
)}
</p>
</div>
<Toggle checked={stickyPayButton} onChange={() => setStickyPayButton((v) => !v)} />
</div>
</div>
</Section>
{/* Footer & Custom CSS Section */}
<Section
title={t('admin.landings.content')}

View File

@@ -151,6 +151,19 @@ function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) {
month: 'short',
year: 'numeric',
});
const timeStr = new Date(item.created_at).toLocaleTimeString(lang, {
hour: '2-digit',
minute: '2-digit',
});
const referrerHost = item.referrer
? (() => {
try {
return new URL(item.referrer).hostname;
} catch {
return item.referrer;
}
})()
: null;
return (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/40 p-3 transition-colors hover:border-dark-600 sm:p-4">
@@ -205,8 +218,19 @@ function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) {
</div>
)}
{/* Date */}
<div className="shrink-0 text-xs text-dark-500">{dateStr}</div>
{/* Referrer */}
{referrerHost && (
<div
className="max-w-[140px] shrink-0 truncate rounded bg-accent-500/20 px-1.5 py-0.5 text-xs font-medium text-accent-400"
title={item.referrer || ''}
>
{referrerHost}
</div>
)}
{/* Date + Time */}
<div className="shrink-0 text-xs text-dark-500">
{dateStr} <span className="text-dark-600">{timeStr}</span>
</div>
</div>
</div>
);
@@ -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 */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-xl font-bold text-accent-400 sm:text-2xl">
{stats.total_purchases}
<div className="text-xl font-bold sm:text-2xl">
<span className="text-warning-400">{stats.total_created}</span>
<span className="mx-1 text-dark-600">/</span>
<span className="text-success-400">{stats.total_successful}</span>
</div>
<div className="text-xs text-dark-500">
{t('admin.landings.stats.created', 'Created')} /{' '}
{t('admin.landings.stats.paid', 'paid')}
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.totalPurchases')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="truncate text-xl font-bold text-success-400 sm:text-2xl">
<div className="truncate text-xl font-bold text-accent-400 sm:text-2xl">
{formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.revenue')}</div>
@@ -393,7 +423,7 @@ export default function AdminLandingStats() {
<div className="text-xs text-dark-500">{t('admin.landings.stats.giftPurchases')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-xl font-bold text-warning-400 sm:text-2xl">
<div className="text-xl font-bold text-dark-200 sm:text-2xl">
{stats.conversion_rate}%
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.conversionRate')}</div>
@@ -451,6 +481,24 @@ export default function AdminLandingStats() {
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
/>
</linearGradient>
<linearGradient
id={`landingCreatedGrad-${numericId}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset={CHART_COMMON.GRADIENT.START_OFFSET}
stopColor="#f59e0b"
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
/>
<stop
offset={CHART_COMMON.GRADIENT.END_OFFSET}
stopColor="#f59e0b"
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
@@ -482,6 +530,15 @@ export default function AdminLandingStats() {
labelStyle={{ color: colors.label }}
itemStyle={{ color: colors.label }}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="created"
name={t('admin.landings.stats.created', 'Created')}
stroke="#f59e0b"
fill={`url(#landingCreatedGrad-${numericId})`}
strokeWidth={CHART_COMMON.STROKE_WIDTH}
/>
<Area
yAxisId="left"
type="monotone"
@@ -505,65 +562,120 @@ export default function AdminLandingStats() {
)}
</div>
{/* Tariff Distribution */}
{/* Daily Purchases Bar Chart */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.tariffChart')}
{t('admin.landings.stats.dailyPurchases', 'Daily purchases')}
</h3>
{tariffData.length === 0 ? (
{dailyData.length === 0 ? (
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
{t('admin.landings.stats.noPurchases')}
</div>
) : (
<ResponsiveContainer width="100%" height={barChartHeight}>
<BarChart
data={tariffData}
layout="vertical"
margin={{ ...CHART_COMMON.CHART.MARGIN, left: 10 }}
>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
type="number"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
color: colors.label,
}}
labelStyle={{ color: colors.label }}
itemStyle={{ color: colors.label }}
formatter={(value: number | undefined) => {
return [value ?? 0, t('admin.landings.stats.purchases')];
}}
/>
<Bar
dataKey="purchases"
name={t('admin.landings.stats.purchases')}
radius={[0, 4, 4, 0]}
>
{tariffData.map((_, index) => (
<Cell key={index} fill={TARIFF_PALETTE[index % TARIFF_PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div className="space-y-2">
{[...dailyData]
.slice(-7)
.reverse()
.map((day, i) => {
const purchasedPct =
(day.created || 0) > 0
? ((day.purchases || 0) / (day.created || 1)) * 100
: 0;
return (
<div key={i} className="flex items-center gap-2">
<span className="w-10 shrink-0 text-right text-xs text-dark-500">
{day.label}
</span>
<div
className="group relative h-5 flex-1 overflow-hidden rounded-full bg-amber-500/80"
title={`${t('admin.landings.stats.created', 'Created')}: ${day.created || 0}\n${t('admin.landings.stats.paid', 'paid')}: ${day.purchases || 0}\n${t('admin.landings.stats.revenueLabel', 'Revenue')}: ${day.revenue?.toFixed(0) || 0} ${t('common.currency', '\u20BD')}\nCR: ${Math.round(purchasedPct)}%`}
>
<div
className="absolute inset-y-0 left-0 rounded-full bg-accent-500"
style={{ width: `${purchasedPct}%` }}
/>
</div>
<span className="w-12 shrink-0 text-xs text-dark-400">
<span className="text-amber-400">{day.created || 0}</span>
<span className="text-dark-600">/</span>
<span className="text-accent-400">{day.purchases || 0}</span>
</span>
</div>
);
})}
<div className="mt-2 flex items-center gap-4 text-xs text-dark-500">
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-amber-500/80" />
<span>{t('admin.landings.stats.created', 'Created')}</span>
</div>
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-accent-500" />
<span>{t('admin.landings.stats.paid', 'paid')}</span>
</div>
</div>
</div>
)}
</div>
</div>
{/* Tariff Distribution -- full width */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.tariffChart')}
</h3>
{tariffData.length === 0 ? (
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
{t('admin.landings.stats.noPurchases')}
</div>
) : (
<ResponsiveContainer width="100%" height={barChartHeight}>
<BarChart
data={tariffData}
layout="vertical"
margin={{ ...CHART_COMMON.CHART.MARGIN, left: 10 }}
>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
type="number"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
color: colors.label,
}}
labelStyle={{ color: colors.label }}
itemStyle={{ color: colors.label }}
formatter={(value: number | undefined) => {
return [value ?? 0, t('admin.landings.stats.purchases')];
}}
/>
<Bar
dataKey="purchases"
name={t('admin.landings.stats.purchases')}
radius={[0, 4, 4, 0]}
>
{tariffData.map((_, index) => (
<Cell key={index} fill={TARIFF_PALETTE[index % TARIFF_PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Additional Stats Row */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
@@ -587,7 +699,9 @@ export default function AdminLandingStats() {
<span className="text-sm text-dark-500">{t('admin.landings.stats.created')}</span>
{' / '}
{stats.total_successful}{' '}
<span className="text-sm text-dark-500">{t('admin.landings.stats.successful')}</span>
<span className="text-sm text-dark-500">
{t('admin.landings.stats.paid', 'paid')}
</span>
</div>
</div>
</div>

View File

@@ -180,7 +180,19 @@ function SortableLandingCard({
</div>
<div className="text-sm text-dark-400">
<span>
{landing.purchase_stats.total} {t('admin.landings.purchaseCount')}
{landing.purchase_stats.total}
<span className="ml-1 text-dark-600">
{t('admin.landings.stats.created', 'created')}
</span>
<span className="mx-1 text-dark-600">/</span>
<span className="text-success-400">
{landing.purchase_stats.paid +
landing.purchase_stats.delivered +
landing.purchase_stats.pending_activation}
</span>
<span className="ml-1 text-dark-600">
{t('admin.landings.stats.paid', 'paid')}
</span>
</span>
</div>
</div>

View File

@@ -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',

View File

@@ -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<string, string> = {
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 (
<div className="mt-3">
{!imageLoaded && !imageError && (
<div className="flex h-40 w-full animate-pulse items-center justify-center rounded-lg bg-dark-800">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{imageError ? (
<div className="flex h-32 w-full items-center justify-center rounded-lg bg-dark-800 text-sm text-dark-400">
{t('support.imageLoadFailed')}
</div>
) : (
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className={`max-h-64 max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90 ${
imageLoaded ? '' : 'hidden'
}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
onClick={() => setShowFullImage(true)}
/>
)}
{message.media_caption && (
<p className="mt-1 text-xs text-dark-400">{message.media_caption}</p>
)}
{showFullImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
onClick={() => setShowFullImage(false)}
>
<button
className="absolute right-4 top-4 text-white/70 hover:text-white"
onClick={() => setShowFullImage(false)}
>
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className="max-h-full max-w-full object-contain"
/>
</div>
)}
</div>
);
}
if (message.media_type === 'video') {
return (
<div className="mt-3">
<video
src={mediaUrl}
controls
className="max-h-64 max-w-full rounded-lg"
preload="metadata"
/>
{message.media_caption && (
<p className="mt-1 text-xs text-dark-400">{message.media_caption}</p>
)}
</div>
);
}
return (
<div className="mt-3">
<a
href={mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
{message.media_caption || `Download ${message.media_type}`}
</a>
</div>
);
}
// BackIcon
const BackIcon = () => (
<svg
@@ -173,21 +60,22 @@ export default function AdminTickets() {
const [selectedTicketId, setSelectedTicketId] = useState<number | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('');
const [replyText, setReplyText] = useState('');
const [isReplying, setIsReplying] = useState(false);
const [replyError, setReplyError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [attachment, setAttachment] = useState<MediaAttachment | null>(null);
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const previewRef = useRef<string | null>(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<Set<string>>(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<HTMLInputElement>) => {
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() {
</span>
</div>
{msg.message_text && (
<p className="whitespace-pre-wrap text-dark-200">{msg.message_text}</p>
<p
className="whitespace-pre-wrap text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
/>
)}
<AdminMessageMedia message={msg} t={t} />
<MessageMediaGrid message={msg} translateError={t('support.imageLoadFailed')} />
</div>
))}
</div>
@@ -675,76 +579,53 @@ export default function AdminTickets() {
className="input resize-none"
/>
{/* Attachment preview */}
{attachment && (
<div className="mt-2 flex items-center gap-3 rounded-lg border border-dark-700/50 bg-dark-800/50 p-2">
{attachment.mediaType === 'photo' && attachment.preview ? (
<img
src={attachment.preview}
alt="Preview"
className="h-12 w-12 rounded-lg object-cover"
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-dark-700">
<svg
className="h-6 w-6 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
{/* Attachments preview */}
{attachments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{attachments.map((att, idx) => (
<div key={att.id} className="relative">
{att.mediaType === 'photo' && att.preview ? (
<img
src={att.preview}
alt="Preview"
className="h-16 w-16 rounded-lg object-cover"
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-dark-700 text-xs text-dark-400">
{att.file.name.slice(-6)}
</div>
)}
{att.uploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/50">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{att.error && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-red-500/30">
<span className="text-xs text-red-300">!</span>
</div>
)}
<button
type="button"
onClick={() => removeAttachment(idx)}
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-dark-600 text-dark-300 hover:bg-red-500 hover:text-white"
>
{attachment.mediaType === 'video' ? (
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
)}
</svg>
</svg>
</button>
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-dark-200">{attachment.file.name}</div>
{attachment.uploading && (
<div className="flex items-center gap-1.5 text-xs text-accent-400">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
{t('admin.tickets.uploading')}
</div>
)}
{attachment.error && (
<div className="text-xs text-red-400">{attachment.error}</div>
)}
{attachment.fileId && !attachment.uploading && (
<div className="text-xs text-success-400">
{t('admin.tickets.uploadComplete')}
</div>
)}
</div>
<button
type="button"
onClick={clearAttachment}
className="shrink-0 rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-700 hover:text-dark-200"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
))}
</div>
)}
@@ -752,15 +633,22 @@ export default function AdminTickets() {
ref={fileInputRef}
type="file"
accept={ACCEPT_STRING}
multiple
onChange={handleFileSelect}
className="hidden"
/>
{replyError && (
<div className="mt-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{replyError}
</div>
)}
<div className="mt-3 flex items-center justify-between">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!!attachment?.uploading}
disabled={attachments.length >= 10 || attachments.some((a) => a.uploading)}
className="flex items-center gap-2 rounded-lg border border-dark-700/50 px-3 py-2 text-sm text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200 disabled:opacity-50"
>
<svg
@@ -776,19 +664,19 @@ export default function AdminTickets() {
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
/>
</svg>
{t('admin.tickets.attachMedia')}
{t('admin.tickets.attachMedia')}{' '}
{attachments.length > 0 && `(${attachments.length}/10)`}
</button>
<button
type="submit"
disabled={
!replyText.trim() ||
replyMutation.isPending ||
!!attachment?.uploading ||
!!attachment?.error
(!replyText.trim() && attachments.filter((a) => a.fileId).length === 0) ||
isReplying ||
attachments.some((a) => a.uploading || a.error)
}
className="btn-primary"
>
{replyMutation.isPending ? (
{isReplying ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}

View File

@@ -14,14 +14,16 @@ 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';
import { promoOffersApi } from '../api/promoOffers';
import { ticketsApi } from '../api/tickets';
import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import { usePermissionStore } from '../store/permissions';
import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid';
import { linkifyText } from '../utils/linkify';
// ============ Helpers ============
@@ -383,6 +385,14 @@ export default function AdminUserDetail() {
const [giftsData, setGiftsData] = useState<AdminUserGiftsResponse | null>(null);
const [giftsLoading, setGiftsLoading] = useState(false);
// Subscription request history
const [requestHistory, setRequestHistory] = useState<SubscriptionRequestRecord[]>([]);
const [requestHistoryLoading, setRequestHistoryLoading] = useState(false);
const [requestHistoryOffset, setRequestHistoryOffset] = useState(0);
const [requestHistoryTotal, setRequestHistoryTotal] = useState(0);
const [requestHistoryExpanded, setRequestHistoryExpanded] = useState(false);
const [requestHistorySubId, setRequestHistorySubId] = useState<number | null>(null);
const userId = id ? parseInt(id, 10) : null;
const loadUser = useCallback(async () => {
@@ -483,6 +493,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,
requestHistorySubId ?? 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, requestHistorySubId],
);
const loadNodeUsage = useCallback(async () => {
if (!userId) return;
try {
@@ -577,6 +610,21 @@ export default function AdminUserDetail() {
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, 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') {
loadReferrals();
@@ -828,6 +876,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]);
@@ -1301,10 +1350,16 @@ export default function AdminUserDetail() {
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.lastActivity')}
{t('admin.users.detail.botActivity')}
</div>
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.cabinetLastLogin')}
</div>
<div className="text-dark-100">{formatDate(user.cabinet_last_login)}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.totalSpent')}
@@ -1321,6 +1376,112 @@ export default function AdminUserDetail() {
</div>
</div>
{/* VPN Connection Info */}
{(panelInfo || userSubscriptions.length > 0) && (
<div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-dark-200">
{t('admin.users.detail.vpnConnection')}
</span>
{userSubscriptions.length > 1 && (
<select
value={activeSubscriptionId ?? ''}
onChange={(e) => {
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) => (
<option key={s.id} value={s.id}>
{s.tariff_name || `#${s.id}`} {s.status}
</option>
))}
</select>
)}
</div>
{panelInfoLoading && !panelInfo?.found && (
<div className="flex items-center justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{panelInfo?.found && (
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-dark-700/30 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.lastConnection')}
</div>
<div className="flex items-center gap-2">
{panelInfo.online_at &&
(() => {
const onlineDate = new Date(panelInfo.online_at);
const isRecent = Date.now() - onlineDate.getTime() < 5 * 60 * 1000;
return (
<>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${isRecent ? 'bg-success-400 shadow-[0_0_6px_rgba(74,222,128,0.5)]' : 'bg-dark-500'}`}
title={isRecent ? t('admin.users.detail.online') : ''}
/>
<span
className={`text-sm ${isRecent ? 'font-medium text-success-400' : 'text-dark-100'}`}
>
{isRecent
? t('admin.users.detail.online')
: formatDate(panelInfo.online_at)}
</span>
</>
);
})()}
{!panelInfo.online_at && <span className="text-sm text-dark-100">-</span>}
</div>
</div>
<div className="rounded-lg bg-dark-700/30 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.firstConnection')}
</div>
<div className="text-sm text-dark-100">
{panelInfo.first_connected_at
? new Date(panelInfo.first_connected_at).toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
: '-'}
</div>
</div>
{panelInfo.last_connected_node_name && (
<div className="col-span-2 rounded-lg bg-dark-700/30 p-3">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.lastNode')}
</div>
<div className="flex items-center gap-2 text-sm text-dark-100">
<svg
className="h-4 w-4 shrink-0 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
{panelInfo.last_connected_node_name}
</div>
</div>
)}
</div>
)}
{!panelInfoLoading && !panelInfo?.found && userSubscriptions.length > 0 && (
<div className="py-2 text-center text-xs text-dark-500">
{t('admin.users.detail.noVpnData')}
</div>
)}
</div>
)}
{/* Campaign */}
{user.campaign_name && (
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-3">
@@ -2348,6 +2509,142 @@ export default function AdminUserDetail() {
</div>
)}
</div>
{/* Subscription Request History */}
<div className="rounded-xl bg-dark-800/50">
<button
onClick={() => {
const next = !requestHistoryExpanded;
setRequestHistoryExpanded(next);
if (next && requestHistory.length === 0) {
loadRequestHistory(0);
}
}}
className="flex w-full items-center justify-between p-4 text-left"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-dark-200">
{t('admin.users.detail.requestHistory')}
</span>
{requestHistoryTotal > 0 && (
<span className="rounded-full bg-accent-500/15 px-2 py-0.5 text-xs font-medium text-accent-400">
{requestHistoryTotal}
</span>
)}
</div>
<svg
className={`h-4 w-4 text-dark-500 transition-transform ${requestHistoryExpanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
{requestHistoryExpanded && (
<div className="border-t border-dark-700/50 px-4 pb-4 pt-3">
{/* Subscription selector for multi-tariff */}
{userSubscriptions.length > 1 && (
<div className="mb-3">
<select
value={requestHistorySubId || ''}
onChange={(e) => {
setRequestHistorySubId(Number(e.target.value) || null);
}}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100"
>
{userSubscriptions.map((sub) => (
<option key={sub.id} value={sub.id}>
{sub.tariff_name || `#${sub.id}`} {sub.status}
</option>
))}
</select>
</div>
)}
{requestHistoryLoading && requestHistory.length === 0 ? (
<div className="flex justify-center py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : requestHistory.length === 0 && !requestHistoryLoading ? (
<div className="py-6 text-center text-sm text-dark-500">
{t('admin.users.detail.noRequests')}
</div>
) : (
<>
<div className="mb-2 text-xs text-dark-500">
{t('admin.users.detail.requestHistoryTotal')}: {requestHistoryTotal}
</div>
{/* Table */}
<div className="-mx-4 overflow-x-auto px-4">
<table className="w-full min-w-[480px] text-left text-sm">
<thead>
<tr className="border-b border-dark-700/50 text-xs text-dark-500">
<th className="pb-2 pr-3 font-medium">
{t('admin.users.detail.requestAt')}
</th>
<th className="pb-2 pr-3 font-medium">
{t('admin.users.detail.requestIp')}
</th>
<th className="pb-2 font-medium">
{t('admin.users.detail.requestUserAgent')}
</th>
</tr>
</thead>
<tbody>
{requestHistory.map((record, idx) => (
<tr
key={record.id}
className={`border-b border-dark-700/30 ${idx % 2 === 0 ? 'bg-dark-800/30' : ''}`}
>
<td className="whitespace-nowrap py-2.5 pr-3 text-dark-200">
{formatDate(record.requestAt)}
</td>
<td className="whitespace-nowrap py-2.5 pr-3 font-mono text-xs text-dark-300">
{record.requestIp || '\u2014'}
</td>
<td
className="max-w-[200px] truncate py-2.5 text-xs text-dark-400"
title={record.userAgent || ''}
>
{record.userAgent
? record.userAgent.length > 60
? `${record.userAgent.slice(0, 60)}...`
: record.userAgent
: '\u2014'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Load more */}
{requestHistory.length < requestHistoryTotal && (
<button
onClick={() => loadRequestHistory(requestHistoryOffset, true)}
disabled={requestHistoryLoading}
className="mt-3 flex min-h-[44px] w-full items-center justify-center rounded-lg bg-dark-700/50 py-2.5 text-sm text-dark-300 transition-colors hover:bg-dark-700 disabled:opacity-50"
>
{requestHistoryLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
) : (
t('admin.users.detail.loadMore')
)}
</button>
)}
</>
)}
</div>
)}
</div>
</>
)}
</div>
@@ -2796,32 +3093,13 @@ export default function AdminUserDetail() {
{formatDate(msg.created_at)}
</span>
</div>
<p className="whitespace-pre-wrap text-sm text-dark-200">
{msg.message_text}
</p>
{msg.has_media && msg.media_file_id && (
<div className="mt-2">
{msg.media_type === 'photo' ? (
<img
src={ticketsApi.getMediaUrl(msg.media_file_id)}
alt={msg.media_caption || ''}
className="max-h-48 max-w-full rounded-lg"
/>
) : (
<a
href={ticketsApi.getMediaUrl(msg.media_file_id)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-lg bg-dark-700 px-2 py-1 text-xs text-dark-200 hover:bg-dark-600"
>
{msg.media_caption || msg.media_type}
</a>
)}
{msg.media_caption && msg.media_type === 'photo' && (
<p className="mt-1 text-xs text-dark-400">{msg.media_caption}</p>
)}
</div>
{msg.message_text && (
<p
className="whitespace-pre-wrap text-sm text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
/>
)}
<MessageMediaGrid message={msg} />
</div>
))}
<div ref={messagesEndRef} />

View File

@@ -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<string, unknown>;
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';

View File

@@ -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 (
<div className="space-y-5">
{/* Summary */}
@@ -569,32 +583,101 @@ function SummaryCard({
</AnimatePresence>
{/* Pay button */}
<button
type="button"
onClick={onSubmit}
disabled={!canSubmit || isSubmitting}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-2xl px-6 py-4 text-base font-semibold transition-all duration-200',
canSubmit && !isSubmitting
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25 hover:bg-accent-400 hover:shadow-accent-500/40 active:scale-[0.98]'
: 'cursor-not-allowed bg-dark-800 text-dark-500',
)}
>
{isSubmitting ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<>
{t('landing.pay', 'Pay')}{' '}
{selectedPeriod?.original_price_kopeks != null &&
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
<span className="mr-1 text-sm font-normal text-white/50 line-through">
{formatPrice(selectedPeriod.original_price_kopeks)}
</span>
{stickyPayButton && isMobile ? (
createPortal(
<div
className="fixed bottom-0 left-0 right-0 z-50 p-3"
style={{
background:
'linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.6) 70%, transparent 100%)',
}}
>
<button
type="button"
onClick={() => {
if (!canSubmit || isSubmitting) {
const el = document.getElementById('contact-input');
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('!border-red-500', '!ring-2', '!ring-red-500/50');
setTimeout(() => {
el.classList.remove('!border-red-500', '!ring-2', '!ring-red-500/50');
}, 2000);
}
return;
}
onSubmit();
}}
disabled={isSubmitting}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-2xl px-6 py-4 text-base font-semibold transition-all duration-200',
canSubmit && !isSubmitting
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25 hover:bg-accent-400 hover:shadow-accent-500/40 active:scale-[0.98]'
: 'cursor-not-allowed bg-dark-800 text-dark-500',
)}
{formatPrice(currentPrice)}
</>
)}
</button>
>
{isSubmitting ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<>
{t('landing.pay', 'Pay')}{' '}
{selectedPeriod?.original_price_kopeks != null &&
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
<span className="mr-1 text-sm font-normal text-white/50 line-through">
{formatPrice(selectedPeriod.original_price_kopeks)}
</span>
)}
{formatPrice(currentPrice)}
</>
)}
</button>
</div>,
document.body,
)
) : (
<button
type="button"
onClick={() => {
if (!canSubmit || isSubmitting) {
const el = document.getElementById('contact-input');
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('!border-red-500', '!ring-2', '!ring-red-500/50');
setTimeout(() => {
el.focus();
}, 300);
setTimeout(() => {
el.classList.remove('!border-red-500', '!ring-2', '!ring-red-500/50');
}, 2000);
}
return;
}
onSubmit();
}}
disabled={isSubmitting}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-2xl px-6 py-4 text-base font-semibold transition-all duration-200',
canSubmit && !isSubmitting
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25 hover:bg-accent-400 hover:shadow-accent-500/40 active:scale-[0.98]'
: 'cursor-not-allowed bg-dark-800 text-dark-500',
)}
>
{isSubmitting ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<>
{t('landing.pay', 'Pay')}{' '}
{selectedPeriod?.original_price_kopeks != null &&
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
<span className="mr-1 text-sm font-normal text-white/50 line-through">
{formatPrice(selectedPeriod.original_price_kopeks)}
</span>
)}
{formatPrice(currentPrice)}
</>
)}
</button>
)}
{/* 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<string, unknown>;
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<number | null>(null);
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(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<string, unknown>).subid = subid;
// Fire landing-specific click goal
if (config?.analytics_click_enabled && config?.analytics_click_goal) {
try {
const w = window as unknown as Record<string, unknown>;
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',
)}
>
<SummaryCard
config={config}
@@ -1106,6 +1265,7 @@ export default function QuickPurchase() {
canSubmit={canSubmit}
submitError={submitError}
onSubmit={handleSubmit}
stickyPayButton={config?.sticky_pay_button}
/>
</motion.div>
</div>

View File

@@ -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 (
<>
<div className="relative mt-3">
{!imageLoaded && !imageError && (
<div className="flex h-48 w-full animate-pulse items-center justify-center rounded-lg bg-dark-700">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{imageError ? (
<div className="flex h-32 w-full items-center justify-center rounded-lg bg-dark-700 text-sm text-dark-400">
{t('support.imageLoadFailed')}
</div>
) : (
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className={`max-h-64 max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90 ${imageLoaded ? '' : 'hidden'}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
onClick={() => setShowFullImage(true)}
/>
)}
{message.media_caption && (
<p className="mt-1 text-xs text-dark-400">{message.media_caption}</p>
)}
</div>
{/* Full image modal */}
{showFullImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
onClick={() => setShowFullImage(false)}
>
<button
className="absolute right-4 top-4 text-white/70 hover:text-white"
onClick={() => setShowFullImage(false)}
>
<CloseIcon />
</button>
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className="max-h-full max-w-full object-contain"
/>
</div>
)}
</>
);
}
// For documents/videos - show download link
return (
<div className="mt-3">
<a
href={mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{message.media_caption || `Download ${message.media_type}`}
</a>
</div>
);
}
export default function Support() {
log.debug('Component loaded');
@@ -161,38 +73,35 @@ export default function Support() {
const [replyMessage, setReplyMessage] = useState('');
const [rateLimitError, setRateLimitError] = useState<string | null>(null);
// Media attachment states
const [createAttachment, setCreateAttachment] = useState<MediaAttachment | null>(null);
const [replyAttachment, setReplyAttachment] = useState<MediaAttachment | null>(null);
// Media attachment states (multi-upload, up to 10)
const [createAttachments, setCreateAttachments] = useState<MediaAttachment[]>([]);
const [replyAttachments, setReplyAttachments] = useState<MediaAttachment[]>([]);
const createFileInputRef = useRef<HTMLInputElement>(null);
const replyFileInputRef = useRef<HTMLInputElement>(null);
const createPreviewRef = useRef<string | null>(null);
const replyPreviewRef = useRef<string | null>(null);
// Revoke blob URLs on unmount to prevent memory leaks
const blobUrlsRef = useRef<Set<string>>(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<React.SetStateAction<MediaAttachment[]>>,
) => {
// 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;
}) => (
<div className="relative mt-2 inline-block">
{attachment.preview && (
<img
src={attachment.preview}
alt="Attachment preview"
className="h-20 w-auto rounded-lg border border-dark-700"
/>
)}
{attachment.uploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-dark-900/70">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{attachment.error && <div className="mt-1 text-xs text-red-400">{attachment.error}</div>}
<button
type="button"
onClick={onRemove}
className="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-white hover:bg-red-600"
>
<CloseIcon />
</button>
</div>
);
items: MediaAttachment[];
onRemove: (idx: number) => void;
}) =>
items.length === 0 ? null : (
<div className="mt-2 flex flex-wrap gap-2">
{items.map((att, idx) => (
<div key={idx} className="relative">
{att.preview ? (
<img
src={att.preview}
alt="Preview"
className="h-16 w-16 rounded-lg border border-dark-700 object-cover"
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-dark-700 text-xs text-dark-400">
{att.file.name.slice(-6)}
</div>
)}
{att.uploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/50">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{att.error && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-red-500/30">
<span className="text-xs text-red-300">!</span>
</div>
)}
<button
type="button"
onClick={() => onRemove(idx)}
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-dark-600 text-dark-300 hover:bg-red-500 hover:text-white"
>
<CloseIcon />
</button>
</div>
))}
</div>
);
return (
<motion.div
@@ -475,7 +382,7 @@ export default function Support() {
onClick={() => {
setShowCreateForm(true);
setSelectedTicket(null);
clearCreateAttachment();
clearCreateAttachments();
}}
>
<PlusIcon />
@@ -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() {
/>
</div>
{/* Image attachment for create */}
{/* Image attachments for create */}
<div>
<input
ref={createFileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
className="hidden"
onChange={(e) => {
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 ? (
<AttachmentPreview
attachment={createAttachment}
onRemove={() => clearCreateAttachment()}
/>
) : (
<AttachmentsPreview
items={createAttachments}
onRemove={(idx) =>
setCreateAttachments((prev) => {
const removed = prev[idx];
if (removed?.preview) URL.revokeObjectURL(removed.preview);
return prev.filter((_, i) => i !== idx);
})
}
/>
{createAttachments.length < 10 && (
<button
type="button"
onClick={() => createFileInputRef.current?.click()}
className="flex items-center gap-2 text-sm text-dark-400 transition-colors hover:text-dark-200"
disabled={createAttachments.some((a) => a.uploading)}
className="mt-2 flex items-center gap-2 text-sm text-dark-400 transition-colors hover:text-dark-200 disabled:opacity-50"
>
<ImageIcon />
{t('support.attachImage')}
{t('support.attachImage')}{' '}
{createAttachments.length > 0 && `(${createAttachments.length}/10)`}
</button>
)}
</div>
@@ -668,7 +583,7 @@ export default function Support() {
<div className="flex gap-3">
<Button
type="submit"
disabled={createAttachment?.uploading}
disabled={createAttachments.some((a) => a.uploading)}
loading={createMutation.isPending}
>
<SendIcon />
@@ -679,7 +594,7 @@ export default function Support() {
variant="secondary"
onClick={() => {
setShowCreateForm(false);
clearCreateAttachment();
clearCreateAttachments();
}}
>
{t('common.cancel')}
@@ -732,9 +647,17 @@ export default function Support() {
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<div className="whitespace-pre-wrap text-dark-200">{msg.message_text}</div>
{msg.message_text && (
<div
className="whitespace-pre-wrap text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
/>
)}
{/* Display media if present */}
<MessageMedia message={msg} t={t} />
<MessageMediaGrid
message={msg}
translateError={t('support.imageLoadFailed')}
/>
</div>
))}
</div>
@@ -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}
/>
</div>
{/* Image attachment for reply */}
{/* Image attachments for reply */}
<div>
<input
ref={replyFileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files || []);
files.forEach((file) => handleFileSelect(file, setReplyAttachments));
e.target.value = '';
}}
/>
<AttachmentsPreview
items={replyAttachments}
onRemove={(idx) =>
setReplyAttachments((prev) => {
const removed = prev[idx];
if (removed?.preview) URL.revokeObjectURL(removed.preview);
return prev.filter((_, i) => i !== idx);
})
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<input
ref={replyFileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file, setReplyAttachment);
e.target.value = '';
}}
/>
{replyAttachment ? (
<AttachmentPreview
attachment={replyAttachment}
onRemove={() => clearReplyAttachment()}
/>
) : (
<button
type="button"
onClick={() => replyFileInputRef.current?.click()}
className="flex items-center gap-2 text-sm text-dark-400 transition-colors hover:text-dark-200"
>
<ImageIcon />
{t('support.attachImage')}
</button>
)}
</div>
{replyAttachments.length < 10 && (
<button
type="button"
onClick={() => replyFileInputRef.current?.click()}
disabled={replyAttachments.some((a) => a.uploading)}
className="flex items-center gap-2 text-sm text-dark-400 transition-colors hover:text-dark-200 disabled:opacity-50"
>
<ImageIcon />
{t('support.attachImage')}{' '}
{replyAttachments.length > 0 && `(${replyAttachments.length}/10)`}
</button>
)}
<Button
type="submit"
disabled={!replyMessage.trim() || replyAttachment?.uploading}
disabled={
(!replyMessage.trim() &&
replyAttachments.filter((a) => a.fileId).length === 0) ||
replyAttachments.some((a) => a.uploading)
}
loading={replyMutation.isPending}
>
<SendIcon />

View File

@@ -473,6 +473,12 @@ export interface ReferralTerms {
}
// Ticket types
export interface TicketMediaItem {
type: 'photo' | 'video' | 'document';
file_id: string;
caption?: string | null;
}
export interface TicketMessage {
id: number;
message_text: string;
@@ -481,6 +487,7 @@ export interface TicketMessage {
media_type: string | null;
media_file_id: string | null;
media_caption: string | null;
media_items?: TicketMediaItem[] | null;
created_at: string;
}

View File

@@ -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);

28
src/utils/linkify.ts Normal file
View File

@@ -0,0 +1,28 @@
import DOMPurify from 'dompurify';
/**
* Match http(s) URLs but exclude common trailing punctuation that is unlikely
* to be part of the URL itself (e.g. the period at the end of a sentence,
* or a closing bracket / quote that wraps the URL).
*/
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?\])}"'])/g;
/**
* Linkify plain text by wrapping http(s) URLs in <a> tags.
* Returns an HTML string sanitized via DOMPurify with a strict allowlist
* (only <a> + <br>), safe to render in the UI.
*
* Trailing punctuation (.,;:!?)]}"') is excluded from the URL match so a
* sentence like `Visit https://example.com.` does not capture the period.
*/
export function linkifyText(text: string | null | undefined): string {
if (!text) return '';
const replaced = text.replace(
URL_REGEX,
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
);
return DOMPurify.sanitize(replaced, {
ALLOWED_TAGS: ['a', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
}

25
src/utils/yandexCid.ts Normal file
View File

@@ -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 */
}
}