mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
256
src/components/tickets/MessageMediaGrid.tsx
Normal file
256
src/components/tickets/MessageMediaGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "صفحات اطلاعات",
|
||||
|
||||
@@ -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": "Покупки",
|
||||
|
||||
@@ -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": "信息页面",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
28
src/utils/linkify.ts
Normal 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
25
src/utils/yandexCid.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user