mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
feat: Linear-style UI redesign with improved mobile experience
Major changes: - Redesign cabinet with Linear-style components and top navigation - Replace detail modals with dedicated pages (users, broadcasts, email templates) - Add email channel support for broadcasts - Remove pull-to-refresh, improve drag-and-drop on touch devices - Fix Telegram Mini App: fullscreen, back button, scroll restoration - Unify admin pages color scheme to design system - Mobile-first improvements: horizontal tabs for settings, better touch targets
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
adminAppsApi,
|
||||
AppDefinition,
|
||||
@@ -13,19 +12,9 @@ import {
|
||||
RemnawaveConfig,
|
||||
importFromRemnawaveFormat as convertRemnawave,
|
||||
} from '../api/adminApps';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AppsIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -87,7 +76,7 @@ const CopyIcon = () => (
|
||||
|
||||
const StarIcon = ({ filled }: { filled: boolean }) => (
|
||||
<svg
|
||||
className={`h-4 w-4 ${filled ? 'fill-yellow-400 text-yellow-400' : 'text-dark-500'}`}
|
||||
className={`h-4 w-4 ${filled ? 'fill-warning-400 text-warning-400' : 'text-dark-500'}`}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
@@ -816,12 +805,7 @@ export default function AdminApps() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('admin.apps.title')}</h1>
|
||||
</div>
|
||||
|
||||
|
||||
363
src/pages/AdminBroadcastDetail.tsx
Normal file
363
src/pages/AdminBroadcastDetail.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
|
||||
const StopIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PhotoIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VideoIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DocumentIcon = () => (
|
||||
<svg className="h-5 w-5" 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.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 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RefreshIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TelegramIcon = () => (
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EmailIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Channel badge component
|
||||
function ChannelBadge({ channel }: { channel?: BroadcastChannel }) {
|
||||
if (!channel || channel === 'telegram') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 rounded-full bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
|
||||
<TelegramIcon />
|
||||
<span className="hidden sm:inline">Telegram</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (channel === 'email') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 rounded-full bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
|
||||
<EmailIcon />
|
||||
<span className="hidden sm:inline">Email</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1 rounded-full bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
|
||||
<TelegramIcon />
|
||||
<span className="mx-0.5">+</span>
|
||||
<EmailIcon />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Status badge component
|
||||
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
|
||||
queued: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.queued',
|
||||
},
|
||||
in_progress: {
|
||||
bg: 'bg-accent-500/20',
|
||||
text: 'text-accent-400',
|
||||
labelKey: 'admin.broadcasts.status.inProgress',
|
||||
},
|
||||
completed: {
|
||||
bg: 'bg-success-500/20',
|
||||
text: 'text-success-400',
|
||||
labelKey: 'admin.broadcasts.status.completed',
|
||||
},
|
||||
partial: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.partial',
|
||||
},
|
||||
failed: {
|
||||
bg: 'bg-error-500/20',
|
||||
text: 'text-error-400',
|
||||
labelKey: 'admin.broadcasts.status.failed',
|
||||
},
|
||||
cancelled: {
|
||||
bg: 'bg-dark-500/20',
|
||||
text: 'text-dark-400',
|
||||
labelKey: 'admin.broadcasts.status.cancelled',
|
||||
},
|
||||
cancelling: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.cancelling',
|
||||
},
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const { t } = useTranslation();
|
||||
const config = statusConfig[status] || statusConfig.queued;
|
||||
return (
|
||||
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminBroadcastDetail() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const broadcastId = id ? parseInt(id, 10) : null;
|
||||
|
||||
// Fetch broadcast details
|
||||
const {
|
||||
data: broadcast,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'detail', broadcastId],
|
||||
queryFn: async () => {
|
||||
if (!broadcastId) throw new Error('Invalid broadcast ID');
|
||||
const response = await adminBroadcastsApi.list(100, 0);
|
||||
const found = response.items.find((b) => b.id === broadcastId);
|
||||
if (!found) throw new Error('Broadcast not found');
|
||||
return found;
|
||||
},
|
||||
enabled: !!broadcastId && !isNaN(broadcastId),
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
if (data && ['queued', 'in_progress', 'cancelling'].includes(data.status)) {
|
||||
return 3000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
// Stop mutation
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: adminBroadcastsApi.stop,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const isRunning = broadcast && ['queued', 'in_progress', 'cancelling'].includes(broadcast.status);
|
||||
|
||||
if (!broadcastId || isNaN(broadcastId)) {
|
||||
navigate('/admin/broadcasts');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!broadcast) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
|
||||
<p className="text-dark-400">{t('admin.broadcasts.notFound')}</p>
|
||||
<button
|
||||
onClick={() => navigate('/admin/broadcasts')}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/broadcasts" />
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-dark-100">
|
||||
{t('admin.broadcasts.detail')} #{broadcast.id}
|
||||
</h1>
|
||||
<StatusBadge status={broadcast.status} />
|
||||
<ChannelBadge channel={broadcast.channel} />
|
||||
</div>
|
||||
<p className="text-sm text-dark-400">
|
||||
{new Date(broadcast.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-lg p-2 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{isRunning && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{broadcast.progress_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
|
||||
style={{ width: `${broadcast.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-dark-100">{broadcast.total_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.total')}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-success-400">{broadcast.sent_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.sent')}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-error-400">{broadcast.failed_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.failed')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.filter')}</p>
|
||||
<p className="font-medium text-dark-100">{broadcast.target_type}</p>
|
||||
</div>
|
||||
|
||||
{/* Telegram Message */}
|
||||
{broadcast.message_text && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-2 flex items-center gap-2 text-sm text-dark-400">
|
||||
<TelegramIcon />
|
||||
{t('admin.broadcasts.message')}
|
||||
</p>
|
||||
<div className="max-h-60 overflow-y-auto whitespace-pre-wrap rounded-lg bg-dark-700/50 p-4 text-dark-100">
|
||||
{broadcast.message_text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Subject */}
|
||||
{broadcast.email_subject && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-2 flex items-center gap-2 text-sm text-dark-400">
|
||||
<EmailIcon />
|
||||
{t('admin.broadcasts.emailSubject')}
|
||||
</p>
|
||||
<div className="rounded-lg bg-dark-700/50 p-4 text-dark-100">
|
||||
{broadcast.email_subject}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Content */}
|
||||
{broadcast.email_html_content && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-2 text-sm text-dark-400">{t('admin.broadcasts.emailContent')}</p>
|
||||
<div className="max-h-60 overflow-y-auto whitespace-pre-wrap rounded-lg bg-dark-700/50 p-4 font-mono text-xs text-dark-100">
|
||||
{broadcast.email_html_content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Media */}
|
||||
{broadcast.has_media && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-2 text-sm text-dark-400">{t('admin.broadcasts.media')}</p>
|
||||
<div className="flex items-center gap-3 text-dark-100">
|
||||
{broadcast.media_type === 'photo' && <PhotoIcon />}
|
||||
{broadcast.media_type === 'video' && <VideoIcon />}
|
||||
{broadcast.media_type === 'document' && <DocumentIcon />}
|
||||
<span className="capitalize">{broadcast.media_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin info */}
|
||||
<div className="flex justify-between rounded-xl border border-dark-700 bg-dark-800/50 p-4 text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.broadcasts.createdBy')}:{' '}
|
||||
<span className="text-dark-100">
|
||||
{broadcast.admin_name || t('admin.broadcasts.unknownAdmin')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-dark-400">{new Date(broadcast.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
{/* Stop button */}
|
||||
{isRunning && broadcast.status !== 'cancelling' && (
|
||||
<button
|
||||
onClick={() => stopMutation.mutate(broadcast.id)}
|
||||
disabled={stopMutation.isPending}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl border border-error-500/30 bg-error-500/20 py-3 text-error-400 transition-colors hover:bg-error-500/30 disabled:opacity-50"
|
||||
>
|
||||
<StopIcon />
|
||||
{stopMutation.isPending ? t('admin.broadcasts.stopping') : t('admin.broadcasts.stop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import i18n from '../i18n';
|
||||
import {
|
||||
campaignsApi,
|
||||
@@ -15,19 +14,9 @@ import {
|
||||
TariffListItem,
|
||||
CampaignRegistrationItem,
|
||||
} from '../api/campaigns';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -114,23 +103,23 @@ const bonusTypeConfig: Record<
|
||||
> = {
|
||||
balance: {
|
||||
labelKey: 'admin.campaigns.bonusType.balance',
|
||||
color: 'text-emerald-400',
|
||||
bgColor: 'bg-emerald-500/20',
|
||||
color: 'text-success-400',
|
||||
bgColor: 'bg-success-500/20',
|
||||
},
|
||||
subscription: {
|
||||
labelKey: 'admin.campaigns.bonusType.subscription',
|
||||
color: 'text-blue-400',
|
||||
bgColor: 'bg-blue-500/20',
|
||||
color: 'text-accent-400',
|
||||
bgColor: 'bg-accent-500/20',
|
||||
},
|
||||
tariff: {
|
||||
labelKey: 'admin.campaigns.bonusType.tariff',
|
||||
color: 'text-purple-400',
|
||||
bgColor: 'bg-purple-500/20',
|
||||
color: 'text-accent-400',
|
||||
bgColor: 'bg-accent-500/20',
|
||||
},
|
||||
none: {
|
||||
labelKey: 'admin.campaigns.bonusType.none',
|
||||
color: 'text-gray-400',
|
||||
bgColor: 'bg-gray-500/20',
|
||||
color: 'text-dark-400',
|
||||
bgColor: 'bg-dark-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -310,8 +299,8 @@ function CampaignModal({
|
||||
|
||||
{/* Bonus Settings */}
|
||||
{bonusType === 'balance' && (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-emerald-400">
|
||||
<div className="rounded-lg border border-success-500/30 bg-success-500/10 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-success-400">
|
||||
{t('admin.campaigns.form.balanceBonus')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -321,7 +310,7 @@ function CampaignModal({
|
||||
onChange={(e) =>
|
||||
setBalanceBonusRubles(Math.max(0, parseFloat(e.target.value) || 0))
|
||||
}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-emerald-500 focus:outline-none"
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-success-500 focus:outline-none"
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
@@ -331,8 +320,8 @@ function CampaignModal({
|
||||
)}
|
||||
|
||||
{bonusType === 'subscription' && (
|
||||
<div className="space-y-3 rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
|
||||
<label className="block text-sm font-medium text-blue-400">
|
||||
<div className="space-y-3 rounded-lg border border-accent-500/30 bg-accent-500/10 p-4">
|
||||
<label className="block text-sm font-medium text-accent-400">
|
||||
{t('admin.campaigns.form.trialSubscription')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
@@ -346,7 +335,7 @@ function CampaignModal({
|
||||
onChange={(e) =>
|
||||
setSubscriptionDays(Math.max(1, parseInt(e.target.value) || 1))
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-blue-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
@@ -360,7 +349,7 @@ function CampaignModal({
|
||||
onChange={(e) =>
|
||||
setSubscriptionTraffic(Math.max(0, parseInt(e.target.value) || 0))
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-blue-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
@@ -374,7 +363,7 @@ function CampaignModal({
|
||||
onChange={(e) =>
|
||||
setSubscriptionDevices(Math.max(1, parseInt(e.target.value) || 1))
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-blue-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
@@ -392,14 +381,14 @@ function CampaignModal({
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg p-2 text-left transition-colors ${
|
||||
selectedSquads.includes(server.squad_uuid)
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
? 'bg-accent-500/20 text-accent-300'
|
||||
: 'bg-dark-600 text-dark-300 hover:bg-dark-500'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded ${
|
||||
selectedSquads.includes(server.squad_uuid)
|
||||
? 'bg-blue-500 text-white'
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-500'
|
||||
}`}
|
||||
>
|
||||
@@ -415,8 +404,8 @@ function CampaignModal({
|
||||
)}
|
||||
|
||||
{bonusType === 'tariff' && (
|
||||
<div className="space-y-3 rounded-lg border border-purple-500/30 bg-purple-500/10 p-4">
|
||||
<label className="block text-sm font-medium text-purple-400">
|
||||
<div className="space-y-3 rounded-lg border border-accent-500/30 bg-accent-500/10 p-4">
|
||||
<label className="block text-sm font-medium text-accent-400">
|
||||
{t('admin.campaigns.form.tariff')}
|
||||
</label>
|
||||
<div>
|
||||
@@ -426,7 +415,7 @@ function CampaignModal({
|
||||
<select
|
||||
value={tariffId || ''}
|
||||
onChange={(e) => setTariffId(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-purple-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
>
|
||||
<option value="">{t('admin.campaigns.form.notSelected')}</option>
|
||||
{tariffs.map((tariff) => (
|
||||
@@ -449,7 +438,7 @@ function CampaignModal({
|
||||
type="number"
|
||||
value={tariffDays}
|
||||
onChange={(e) => setTariffDays(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-purple-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
@@ -457,7 +446,7 @@ function CampaignModal({
|
||||
)}
|
||||
|
||||
{bonusType === 'none' && (
|
||||
<div className="rounded-lg border border-gray-500/30 bg-gray-500/10 p-4">
|
||||
<div className="rounded-lg border border-dark-500/30 bg-dark-500/10 p-4">
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('admin.campaigns.form.noBonusDescription')}
|
||||
</p>
|
||||
@@ -584,17 +573,17 @@ function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{formatRubles(stats.total_revenue_kopeks)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.revenue')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-400">{stats.paid_users_count}</div>
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.paid_users_count}</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.paidUsers')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-purple-400">{stats.conversion_rate}%</div>
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.conversion_rate}%</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.conversion')}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -606,19 +595,19 @@ function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
{t('admin.campaigns.stats.bonusesIssued')}
|
||||
</div>
|
||||
{stats.bonus_type === 'balance' && (
|
||||
<div className="text-lg font-medium text-emerald-400">
|
||||
<div className="text-lg font-medium text-success-400">
|
||||
{formatRubles(stats.balance_issued_kopeks)}
|
||||
</div>
|
||||
)}
|
||||
{stats.bonus_type === 'subscription' && (
|
||||
<div className="text-lg font-medium text-blue-400">
|
||||
<div className="text-lg font-medium text-accent-400">
|
||||
{t('admin.campaigns.stats.subscriptionsIssued', {
|
||||
count: stats.subscription_issued,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{stats.bonus_type === 'tariff' && (
|
||||
<div className="text-lg font-medium text-purple-400">
|
||||
<div className="text-lg font-medium text-accent-400">
|
||||
{t('admin.campaigns.stats.tariffsIssued', { count: stats.subscription_issued })}
|
||||
</div>
|
||||
)}
|
||||
@@ -792,12 +781,12 @@ function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
|
||||
<td className="p-3">
|
||||
<div className="flex gap-1">
|
||||
{reg.has_subscription && (
|
||||
<span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-xs text-blue-400">
|
||||
<span className="rounded bg-accent-500/20 px-1.5 py-0.5 text-xs text-accent-400">
|
||||
VPN
|
||||
</span>
|
||||
)}
|
||||
{reg.has_paid && (
|
||||
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-xs text-emerald-400">
|
||||
<span className="rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400">
|
||||
{t('admin.campaigns.users.paid')}
|
||||
</span>
|
||||
)}
|
||||
@@ -945,12 +934,7 @@ export default function AdminCampaigns() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.campaigns.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.campaigns.subtitle')}</p>
|
||||
@@ -982,13 +966,13 @@ export default function AdminCampaigns() {
|
||||
<div className="text-sm text-dark-400">{t('admin.campaigns.overview.active')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-blue-400">{overview.total_registrations}</div>
|
||||
<div className="text-2xl font-bold text-accent-400">{overview.total_registrations}</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.campaigns.overview.registrations')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{formatRubles(overview.total_balance_issued_kopeks)}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
statsApi,
|
||||
type DashboardStats,
|
||||
@@ -10,19 +9,9 @@ import {
|
||||
type RecentPaymentsResponse,
|
||||
} from '../api/admin';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons - styled like main navigation
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ServerIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -461,12 +450,7 @@ export default function AdminDashboard() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-100">{t('adminDashboard.title')}</h1>
|
||||
<p className="text-dark-400">{t('adminDashboard.subtitle')}</p>
|
||||
|
||||
113
src/pages/AdminEmailTemplatePreview.tsx
Normal file
113
src/pages/AdminEmailTemplatePreview.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminEmailTemplatesApi } from '../api/adminEmailTemplates';
|
||||
import { BackIcon } from '../components/admin';
|
||||
|
||||
interface PreviewState {
|
||||
subject: string;
|
||||
body_html: string;
|
||||
}
|
||||
|
||||
export default function AdminEmailTemplatePreview() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { type, lang } = useParams<{ type: string; lang: string }>();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
|
||||
// Get data from navigation state
|
||||
const state = location.state as PreviewState | null;
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!type || !lang || !state) {
|
||||
throw new Error('Missing required data');
|
||||
}
|
||||
return adminEmailTemplatesApi.previewTemplate(type, {
|
||||
language: lang,
|
||||
subject: state.subject,
|
||||
body_html: state.body_html,
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setPreviewHtml(data.body_html);
|
||||
},
|
||||
});
|
||||
|
||||
// Load preview on mount
|
||||
useEffect(() => {
|
||||
if (!type || !lang || !state) {
|
||||
navigate('/admin/email-templates');
|
||||
return;
|
||||
}
|
||||
previewMutation.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type, lang]);
|
||||
|
||||
// Write preview HTML into iframe
|
||||
useEffect(() => {
|
||||
if (previewHtml && iframeRef.current) {
|
||||
const doc = iframeRef.current.contentDocument;
|
||||
if (doc) {
|
||||
doc.open();
|
||||
doc.write(previewHtml);
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}, [previewHtml]);
|
||||
|
||||
if (!type || !lang || !state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-120px)] flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded-xl border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.emailTemplates.preview')}</h1>
|
||||
<p className="text-sm text-dark-400">
|
||||
{type} / {lang.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview content */}
|
||||
<div className="flex-1 overflow-hidden rounded-xl border border-dark-700 bg-white">
|
||||
{previewMutation.isPending ? (
|
||||
<div className="flex h-full items-center justify-center bg-dark-800">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : previewMutation.isError ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-dark-800">
|
||||
<p className="text-dark-400">{t('common.error')}</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="h-full w-full"
|
||||
sandbox="allow-same-origin"
|
||||
title="Email Preview"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,33 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
adminEmailTemplatesApi,
|
||||
EmailTemplateType,
|
||||
EmailTemplateDetail,
|
||||
EmailTemplateLanguageData,
|
||||
} from '../api/adminEmailTemplates';
|
||||
import { AdminBackButton, BackIcon } from '../components/admin';
|
||||
import { useIsTelegram } from '../platform/hooks/usePlatform';
|
||||
|
||||
// Hook to check if on mobile
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 1024;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 1024);
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MailIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -69,12 +76,6 @@ const ResetIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const XIcon = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
const LANG_LABELS: Record<string, string> = {
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
@@ -156,16 +157,22 @@ function TemplateEditor({
|
||||
currentLang: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const isTelegram = useIsTelegram();
|
||||
const isMobile = useIsMobile();
|
||||
const isPreviewDisabled = isTelegram || isMobile;
|
||||
|
||||
const [activeLang, setActiveLang] = useState('ru');
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
const [toast, setToast] = useState<{
|
||||
type: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [showPreviewAlert, setShowPreviewAlert] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
|
||||
|
||||
@@ -178,7 +185,7 @@ function TemplateEditor({
|
||||
}
|
||||
}, [activeLang, langData]);
|
||||
|
||||
const showToast = useCallback((type: 'success' | 'error', message: string) => {
|
||||
const showToast = useCallback((type: 'success' | 'error' | 'info', message: string) => {
|
||||
setToast({ type, message });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}, []);
|
||||
@@ -245,23 +252,6 @@ function TemplateEditor({
|
||||
},
|
||||
});
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
|
||||
language: activeLang,
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setPreviewHtml(data.body_html);
|
||||
setShowPreview(true);
|
||||
},
|
||||
onError: () => {
|
||||
showToast('error', t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
// Send test mutation
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -276,18 +266,6 @@ function TemplateEditor({
|
||||
},
|
||||
});
|
||||
|
||||
// Write preview HTML into iframe
|
||||
useEffect(() => {
|
||||
if (showPreview && iframeRef.current && previewHtml) {
|
||||
const doc = iframeRef.current.contentDocument;
|
||||
if (doc) {
|
||||
doc.open();
|
||||
doc.write(previewHtml);
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}, [showPreview, previewHtml]);
|
||||
|
||||
const handleSubjectChange = (value: string) => {
|
||||
setEditSubject(value);
|
||||
setIsDirty(true);
|
||||
@@ -421,9 +399,19 @@ function TemplateEditor({
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => previewMutation.mutate()}
|
||||
disabled={previewMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
onClick={() => {
|
||||
if (isPreviewDisabled) {
|
||||
setShowPreviewAlert(true);
|
||||
return;
|
||||
}
|
||||
navigate(`/admin/email-templates/preview/${detail.notification_type}/${activeLang}`, {
|
||||
state: {
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 sm:px-4 sm:py-2"
|
||||
>
|
||||
<EyeIcon />
|
||||
{t('admin.emailTemplates.preview')}
|
||||
@@ -460,36 +448,63 @@ function TemplateEditor({
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`fixed bottom-4 left-4 right-4 z-50 animate-fade-in rounded-xl px-4 py-3 text-center text-sm font-medium shadow-lg sm:bottom-6 sm:left-auto sm:right-6 sm:text-left ${
|
||||
toast.type === 'success' ? 'bg-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
|
||||
className={`fixed left-4 right-4 top-32 z-[100] mx-auto max-w-sm animate-fade-in rounded-xl px-4 py-3 text-center text-sm font-medium shadow-lg sm:left-1/2 sm:right-auto sm:-translate-x-1/2 ${
|
||||
toast.type === 'success'
|
||||
? 'bg-success-500 text-white'
|
||||
: toast.type === 'info'
|
||||
? 'bg-dark-700 text-dark-100 ring-1 ring-dark-600'
|
||||
: 'bg-error-500 text-white'
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{showPreview && (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="flex max-h-[90vh] w-full flex-col rounded-t-2xl border border-dark-600 bg-dark-800 shadow-2xl sm:max-h-[85vh] sm:max-w-2xl sm:rounded-2xl">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<h3 className="text-base font-semibold text-dark-100">
|
||||
{t('admin.emailTemplates.preview')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowPreview(false)}
|
||||
className="rounded-lg p-1.5 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
{/* Preview Disabled Alert */}
|
||||
{showPreviewAlert && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={() => setShowPreviewAlert(false)}
|
||||
/>
|
||||
{/* Alert Modal */}
|
||||
<div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-dark-700 bg-dark-800 shadow-2xl">
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center pt-6">
|
||||
<div className="rounded-full bg-warning-500/20 p-4">
|
||||
<svg
|
||||
className="h-8 w-8 text-warning-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-1">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="h-full min-h-[50vh] w-full rounded-lg bg-white sm:min-h-[400px]"
|
||||
sandbox="allow-same-origin"
|
||||
title="Email Preview"
|
||||
/>
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-2 pt-4 text-center">
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.emailTemplates.previewNotAvailable')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-dark-400">
|
||||
{t('admin.emailTemplates.previewDesktopOnly')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Button */}
|
||||
<div className="p-6 pt-4">
|
||||
<button
|
||||
onClick={() => setShowPreviewAlert(false)}
|
||||
className="w-full rounded-xl bg-dark-700 py-3 text-sm font-medium text-dark-100 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
{t('common.understand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -522,14 +537,9 @@ export default function AdminEmailTemplates() {
|
||||
<div className="mx-auto max-w-4xl space-y-4 px-3 py-4 sm:space-y-6 sm:px-4 sm:py-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex-shrink-0 rounded-xl border border-dark-700 bg-dark-800 p-1.5 transition-colors hover:bg-dark-700 sm:p-2"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton className="flex-shrink-0 rounded-xl border border-dark-700 bg-dark-800 p-1.5 transition-colors hover:bg-dark-700 sm:p-2" />
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5">
|
||||
<div className="flex-shrink-0 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 p-1.5 text-blue-400 sm:p-2">
|
||||
<div className="flex-shrink-0 rounded-xl bg-gradient-to-br from-accent-500/20 to-accent-600/10 p-1.5 text-accent-400 sm:p-2">
|
||||
<MailIcon />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -299,10 +299,10 @@ export default function AdminPanel() {
|
||||
id: 'analytics',
|
||||
title: t('admin.groups.analytics'),
|
||||
icon: <AnalyticsGroupIcon />,
|
||||
gradient: 'from-emerald-500/10 to-emerald-500/5',
|
||||
borderColor: 'border-emerald-500/20',
|
||||
iconBg: 'bg-emerald-500/20',
|
||||
iconColor: 'text-emerald-400',
|
||||
gradient: 'from-success-500/10 to-success-500/5',
|
||||
borderColor: 'border-success-500/20',
|
||||
iconBg: 'bg-success-500/20',
|
||||
iconColor: 'text-success-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/dashboard',
|
||||
@@ -322,10 +322,10 @@ export default function AdminPanel() {
|
||||
id: 'users',
|
||||
title: t('admin.groups.users'),
|
||||
icon: <UsersGroupIcon />,
|
||||
gradient: 'from-blue-500/10 to-blue-500/5',
|
||||
borderColor: 'border-blue-500/20',
|
||||
iconBg: 'bg-blue-500/20',
|
||||
iconColor: 'text-blue-400',
|
||||
gradient: 'from-accent-500/10 to-accent-500/5',
|
||||
borderColor: 'border-accent-500/20',
|
||||
iconBg: 'bg-accent-500/20',
|
||||
iconColor: 'text-accent-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/users',
|
||||
@@ -351,10 +351,10 @@ export default function AdminPanel() {
|
||||
id: 'tariffs',
|
||||
title: t('admin.groups.tariffs'),
|
||||
icon: <TariffsGroupIcon />,
|
||||
gradient: 'from-amber-500/10 to-amber-500/5',
|
||||
borderColor: 'border-amber-500/20',
|
||||
iconBg: 'bg-amber-500/20',
|
||||
iconColor: 'text-amber-400',
|
||||
gradient: 'from-warning-500/10 to-warning-500/5',
|
||||
borderColor: 'border-warning-500/20',
|
||||
iconBg: 'bg-warning-500/20',
|
||||
iconColor: 'text-warning-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/tariffs',
|
||||
@@ -386,10 +386,10 @@ export default function AdminPanel() {
|
||||
id: 'marketing',
|
||||
title: t('admin.groups.marketing'),
|
||||
icon: <MarketingGroupIcon />,
|
||||
gradient: 'from-rose-500/10 to-rose-500/5',
|
||||
borderColor: 'border-rose-500/20',
|
||||
iconBg: 'bg-rose-500/20',
|
||||
iconColor: 'text-rose-400',
|
||||
gradient: 'from-error-500/10 to-error-500/5',
|
||||
borderColor: 'border-error-500/20',
|
||||
iconBg: 'bg-error-500/20',
|
||||
iconColor: 'text-error-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/campaigns',
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
@@ -21,22 +18,11 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GripIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
@@ -120,11 +106,11 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
id: config.method_id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
opacity: isDragging ? 0.85 : 1,
|
||||
position: isDragging ? 'relative' : undefined,
|
||||
};
|
||||
|
||||
const displayName = config.display_name || config.default_display_name;
|
||||
@@ -159,19 +145,19 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`group flex items-center gap-3 rounded-xl border p-4 transition-all ${
|
||||
className={`group flex items-center gap-3 rounded-xl border p-4 ${
|
||||
isDragging
|
||||
? 'border-accent-500/50 bg-dark-700/80 shadow-xl shadow-accent-500/10'
|
||||
? 'border-accent-500/50 bg-dark-800 shadow-xl shadow-accent-500/20'
|
||||
: config.is_enabled
|
||||
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
||||
: 'border-dark-800/50 bg-dark-900/30 opacity-60'
|
||||
}`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{/* Drag handle - larger touch target for mobile */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex-shrink-0 cursor-grab touch-manipulation rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
|
||||
className="flex-shrink-0 cursor-grab touch-none rounded-lg p-2.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing sm:p-1.5"
|
||||
title={t('admin.paymentMethods.dragToReorder')}
|
||||
>
|
||||
<GripIcon />
|
||||
@@ -682,10 +668,9 @@ export default function AdminPaymentMethods() {
|
||||
},
|
||||
});
|
||||
|
||||
// DnD sensors
|
||||
// DnD sensors - PointerSensor handles both mouse and touch
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
@@ -717,12 +702,7 @@ export default function AdminPaymentMethods() {
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.paymentMethods.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description')}</p>
|
||||
@@ -757,11 +737,7 @@ export default function AdminPaymentMethods() {
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : methods.length > 0 ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={methods.map((m) => m.method_id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { adminPaymentsApi } from '../api/adminPayments';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import type { PendingPayment, PaginatedResponse } from '../types';
|
||||
|
||||
export default function AdminPayments() {
|
||||
@@ -68,20 +68,7 @@ export default function AdminPayments() {
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.payments.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.payments.description')}</p>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import i18n from '../i18n';
|
||||
import {
|
||||
promoOffersApi,
|
||||
@@ -14,19 +13,9 @@ import {
|
||||
OFFER_TYPE_CONFIG,
|
||||
OfferType,
|
||||
} from '../api/promoOffers';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EditIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -116,9 +105,9 @@ const getActionLabel = (action: string): string => {
|
||||
|
||||
const getActionColor = (action: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
created: 'bg-blue-500/20 text-blue-400',
|
||||
claimed: 'bg-emerald-500/20 text-emerald-400',
|
||||
consumed: 'bg-purple-500/20 text-purple-400',
|
||||
created: 'bg-accent-500/20 text-accent-400',
|
||||
claimed: 'bg-success-500/20 text-success-400',
|
||||
consumed: 'bg-accent-500/20 text-accent-400',
|
||||
disabled: 'bg-dark-600 text-dark-400',
|
||||
};
|
||||
return colors[action] || 'bg-dark-600 text-dark-400';
|
||||
@@ -545,12 +534,12 @@ function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
|
||||
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6 text-center">
|
||||
<div
|
||||
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
|
||||
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
|
||||
isSuccess ? 'bg-success-500/20' : 'bg-error-500/20'
|
||||
}`}
|
||||
>
|
||||
{isSuccess ? (
|
||||
<svg
|
||||
className="h-8 w-8 text-emerald-400"
|
||||
className="h-8 w-8 text-success-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -688,12 +677,7 @@ export default function AdminPromoOffers() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.promoOffers.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.promoOffers.subtitle')}</p>
|
||||
@@ -816,7 +800,7 @@ export default function AdminPromoOffers() {
|
||||
<div className="mt-3 border-t border-dark-700 pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{template.is_active ? (
|
||||
<span className="rounded bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-400">
|
||||
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||
{t('admin.promoOffers.status.active')}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import i18n from '../i18n';
|
||||
import {
|
||||
promocodesApi,
|
||||
@@ -14,19 +13,9 @@ import {
|
||||
PromoGroupCreateRequest,
|
||||
PromoGroupUpdateRequest,
|
||||
} from '../api/promocodes';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -130,10 +119,10 @@ const getTypeLabel = (type: PromoCodeType): string => {
|
||||
|
||||
const getTypeColor = (type: PromoCodeType): string => {
|
||||
const colors: Record<PromoCodeType, string> = {
|
||||
balance: 'bg-emerald-500/20 text-emerald-400',
|
||||
subscription_days: 'bg-blue-500/20 text-blue-400',
|
||||
trial_subscription: 'bg-purple-500/20 text-purple-400',
|
||||
promo_group: 'bg-amber-500/20 text-amber-400',
|
||||
balance: 'bg-success-500/20 text-success-400',
|
||||
subscription_days: 'bg-accent-500/20 text-accent-400',
|
||||
trial_subscription: 'bg-accent-500/20 text-accent-400',
|
||||
promo_group: 'bg-warning-500/20 text-warning-400',
|
||||
discount: 'bg-pink-500/20 text-pink-400',
|
||||
};
|
||||
return colors[type] || 'bg-dark-600 text-dark-300';
|
||||
@@ -830,7 +819,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
<div className="text-sm text-dark-400">{t('admin.promocodes.stats.totalUses')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-700/50 p-4 text-center">
|
||||
<div className="mb-1 text-3xl font-bold text-emerald-400">{promocode.today_uses}</div>
|
||||
<div className="mb-1 text-3xl font-bold text-success-400">{promocode.today_uses}</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.promocodes.stats.today')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-700/50 p-4 text-center">
|
||||
@@ -854,7 +843,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
{promocode.type === 'balance' && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.bonus')}:</span>
|
||||
<span className="text-emerald-400">
|
||||
<span className="text-success-400">
|
||||
+{promocode.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -863,7 +852,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
promocode.type === 'trial_subscription') && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.daysLabel')}:</span>
|
||||
<span className="text-blue-400">+{promocode.subscription_days}</span>
|
||||
<span className="text-accent-400">+{promocode.subscription_days}</span>
|
||||
</div>
|
||||
)}
|
||||
{promocode.type === 'discount' && (
|
||||
@@ -892,7 +881,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.status')}:</span>
|
||||
<span className={promocode.is_valid ? 'text-emerald-400' : 'text-error-400'}>
|
||||
<span className={promocode.is_valid ? 'text-success-400' : 'text-error-400'}>
|
||||
{promocode.is_valid
|
||||
? t('admin.promocodes.stats.active')
|
||||
: t('admin.promocodes.stats.inactive')}
|
||||
@@ -913,7 +902,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
{promocode.first_purchase_only && (
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.restriction')}:</span>
|
||||
<span className="text-amber-400">
|
||||
<span className="text-warning-400">
|
||||
{t('admin.promocodes.stats.firstPurchaseOnly')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1119,12 +1108,7 @@ export default function AdminPromocodes() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.promocodes.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.promocodes.subtitle')}</p>
|
||||
@@ -1159,7 +1143,7 @@ export default function AdminPromocodes() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{promocodes.filter((p) => p.is_active && p.is_valid).length}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.activeCount')}</div>
|
||||
@@ -1171,7 +1155,7 @@ export default function AdminPromocodes() {
|
||||
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.usagesCount')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-amber-400">
|
||||
<div className="text-2xl font-bold text-warning-400">
|
||||
{promocodes.filter((p) => p.uses_left === 0 && p.max_uses > 0).length}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.exhausted')}</div>
|
||||
@@ -1242,20 +1226,20 @@ export default function AdminPromocodes() {
|
||||
</span>
|
||||
)}
|
||||
{promo.first_purchase_only && (
|
||||
<span className="rounded bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400">
|
||||
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{t('admin.promocodes.firstPurchase')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
{promo.type === 'balance' && (
|
||||
<span className="text-emerald-400">
|
||||
<span className="text-success-400">
|
||||
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
||||
</span>
|
||||
)}
|
||||
{(promo.type === 'subscription_days' ||
|
||||
promo.type === 'trial_subscription') && (
|
||||
<span className="text-blue-400">
|
||||
<span className="text-accent-400">
|
||||
+{promo.subscription_days} {t('admin.promocodes.form.days')}
|
||||
</span>
|
||||
)}
|
||||
@@ -1363,7 +1347,7 @@ export default function AdminPromocodes() {
|
||||
))}
|
||||
{group.auto_assign_total_spent_kopeks &&
|
||||
group.auto_assign_total_spent_kopeks > 0 && (
|
||||
<span className="text-amber-400">
|
||||
<span className="text-warning-400">
|
||||
{t('admin.promocodes.groups.autoFrom', {
|
||||
amount: group.auto_assign_total_spent_kopeks / 100,
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
adminRemnawaveApi,
|
||||
NodeInfo,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
SystemStatsResponse,
|
||||
AutoSyncStatus,
|
||||
} from '../api/adminRemnawave';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
@@ -144,18 +144,6 @@ const ArrowPathIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronLeftIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
@@ -232,11 +220,11 @@ interface StatCardProps {
|
||||
function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardProps) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
accent: 'bg-accent-500/20 text-accent-400',
|
||||
green: 'bg-emerald-500/20 text-emerald-400',
|
||||
blue: 'bg-blue-500/20 text-blue-400',
|
||||
orange: 'bg-orange-500/20 text-orange-400',
|
||||
red: 'bg-red-500/20 text-red-400',
|
||||
purple: 'bg-purple-500/20 text-purple-400',
|
||||
green: 'bg-success-500/20 text-success-400',
|
||||
blue: 'bg-accent-500/20 text-accent-400',
|
||||
orange: 'bg-warning-500/20 text-warning-400',
|
||||
red: 'bg-error-500/20 text-error-400',
|
||||
purple: 'bg-accent-500/20 text-accent-400',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -265,8 +253,8 @@ function NodeCard({ node, onAction, isLoading }: NodeCardProps) {
|
||||
const statusColor = node.is_disabled
|
||||
? 'text-dark-500'
|
||||
: node.is_connected && node.is_node_online
|
||||
? 'text-emerald-400'
|
||||
: 'text-red-400';
|
||||
? 'text-success-400'
|
||||
: 'text-error-400';
|
||||
|
||||
const statusText = node.is_disabled
|
||||
? t('admin.remnawave.nodes.disabled', 'Disabled')
|
||||
@@ -313,8 +301,8 @@ function NodeCard({ node, onAction, isLoading }: NodeCardProps) {
|
||||
disabled={isLoading}
|
||||
className={`rounded-lg p-2 transition-colors disabled:opacity-50 ${
|
||||
node.is_disabled
|
||||
? 'bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30'
|
||||
: 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
|
||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||
: 'bg-error-500/20 text-error-400 hover:bg-error-500/30'
|
||||
}`}
|
||||
title={
|
||||
node.is_disabled
|
||||
@@ -351,11 +339,11 @@ function SquadCard({ squad, onSelect }: SquadCardProps) {
|
||||
{squad.display_name || squad.name}
|
||||
</h3>
|
||||
{squad.is_synced ? (
|
||||
<span className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-400">
|
||||
<span className="rounded-full bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||
{t('admin.remnawave.squads.synced', 'Synced')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-orange-500/20 px-2 py-0.5 text-xs text-orange-400">
|
||||
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{t('admin.remnawave.squads.notSynced', 'Not synced')}
|
||||
</span>
|
||||
)}
|
||||
@@ -374,7 +362,7 @@ function SquadCard({ squad, onSelect }: SquadCardProps) {
|
||||
)}
|
||||
<span>{squad.inbounds_count} inbounds</span>
|
||||
{squad.is_available !== undefined && (
|
||||
<span className={squad.is_available ? 'text-emerald-400' : 'text-red-400'}>
|
||||
<span className={squad.is_available ? 'text-success-400' : 'text-error-400'}>
|
||||
{squad.is_available ? '✓ Available' : '✗ Unavailable'}
|
||||
</span>
|
||||
)}
|
||||
@@ -412,7 +400,7 @@ function SyncCard({ title, description, onAction, isLoading, lastResult }: SyncC
|
||||
<p className="mt-1 text-xs text-dark-400">{description}</p>
|
||||
{lastResult && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${lastResult.success ? 'text-emerald-400' : 'text-red-400'}`}
|
||||
className={`mt-2 text-xs ${lastResult.success ? 'text-success-400' : 'text-error-400'}`}
|
||||
>
|
||||
{lastResult.message}
|
||||
</p>
|
||||
@@ -678,7 +666,7 @@ function NodesTab({
|
||||
<button
|
||||
onClick={onRestartAll}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-2 rounded-lg bg-orange-500/20 px-3 py-1.5 text-orange-400 transition-colors hover:bg-orange-500/30 disabled:opacity-50"
|
||||
className="flex items-center gap-2 rounded-lg bg-warning-500/20 px-3 py-1.5 text-warning-400 transition-colors hover:bg-warning-500/30 disabled:opacity-50"
|
||||
>
|
||||
<ArrowPathIcon />
|
||||
{t('admin.remnawave.nodes.restartAll', 'Restart All')}
|
||||
@@ -830,7 +818,7 @@ function SyncTab({
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||
autoSyncStatus.enabled
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-dark-600 text-dark-400'
|
||||
}`}
|
||||
>
|
||||
@@ -864,9 +852,9 @@ function SyncTab({
|
||||
<p
|
||||
className={
|
||||
autoSyncStatus.is_running
|
||||
? 'text-orange-400'
|
||||
? 'text-warning-400'
|
||||
: autoSyncStatus.last_run_success
|
||||
? 'text-emerald-400'
|
||||
? 'text-success-400'
|
||||
: 'text-dark-200'
|
||||
}
|
||||
>
|
||||
@@ -1104,14 +1092,9 @@ export default function AdminRemnawave() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Link>
|
||||
<div className="rounded-lg bg-purple-500/20 p-2">
|
||||
<ServerIcon className="h-6 w-6 text-purple-400" />
|
||||
<AdminBackButton />
|
||||
<div className="rounded-lg bg-accent-500/20 p-2">
|
||||
<ServerIcon className="h-6 w-6 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
@@ -1126,11 +1109,11 @@ export default function AdminRemnawave() {
|
||||
{/* Connection Status Badge */}
|
||||
<div
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs ${
|
||||
isConfigured ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'
|
||||
isConfigured ? 'bg-success-500/20 text-success-400' : 'bg-error-500/20 text-error-400'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${isConfigured ? 'bg-emerald-400' : 'bg-red-400'}`}
|
||||
className={`h-2 w-2 rounded-full ${isConfigured ? 'bg-success-400' : 'bg-error-400'}`}
|
||||
/>
|
||||
{isConfigured
|
||||
? t('admin.remnawave.connected', 'Connected')
|
||||
@@ -1140,8 +1123,8 @@ export default function AdminRemnawave() {
|
||||
|
||||
{/* Configuration Error */}
|
||||
{status?.configuration_error && (
|
||||
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-4">
|
||||
<p className="text-sm text-red-400">{status.configuration_error}</p>
|
||||
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-4">
|
||||
<p className="text-sm text-error-400">{status.configuration_error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1275,7 +1258,9 @@ export default function AdminRemnawave() {
|
||||
<div>
|
||||
<p className="text-dark-500">Available</p>
|
||||
<p
|
||||
className={selectedSquad.is_available ? 'text-emerald-400' : 'text-red-400'}
|
||||
className={
|
||||
selectedSquad.is_available ? 'text-success-400' : 'text-error-400'
|
||||
}
|
||||
>
|
||||
{selectedSquad.is_available ? 'Yes' : 'No'}
|
||||
</p>
|
||||
@@ -1284,7 +1269,7 @@ export default function AdminRemnawave() {
|
||||
<p className="text-dark-500">Trial Eligible</p>
|
||||
<p
|
||||
className={
|
||||
selectedSquad.is_trial_eligible ? 'text-emerald-400' : 'text-dark-400'
|
||||
selectedSquad.is_trial_eligible ? 'text-success-400' : 'text-dark-400'
|
||||
}
|
||||
>
|
||||
{selectedSquad.is_trial_eligible ? 'Yes' : 'No'}
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { serversApi, ServerListItem, ServerDetail, ServerUpdateRequest } from '../api/servers';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SyncIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -369,12 +358,7 @@ export default function AdminServers() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.servers.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.servers.subtitle')}</p>
|
||||
|
||||
@@ -4,13 +4,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
||||
import { themeColorsApi } from '../api/themeColors';
|
||||
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
|
||||
import { MenuIcon, MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
|
||||
import { MENU_SECTIONS, MenuItem, formatSettingKey, AdminBackButton } from '../components/admin';
|
||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
||||
import { BrandingTab } from '../components/admin/BrandingTab';
|
||||
import { ThemeTab } from '../components/admin/ThemeTab';
|
||||
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
||||
import { SettingsTab } from '../components/admin/SettingsTab';
|
||||
import { SettingsSidebar } from '../components/admin/SettingsSidebar';
|
||||
import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs';
|
||||
import {
|
||||
SettingsSearch,
|
||||
SettingsSearchMobile,
|
||||
@@ -23,19 +23,13 @@ export default function AdminSettings() {
|
||||
// State
|
||||
const [activeSection, setActiveSection] = useState('branding');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
// Favorites hook
|
||||
const { favorites, toggleFavorite, isFavorite } = useFavoriteSettings();
|
||||
|
||||
// Scroll to top on mount and section change (fix for mobile webviews)
|
||||
// Scroll to top on section change
|
||||
useEffect(() => {
|
||||
// Use requestAnimationFrame for smoother scroll on mobile
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}, [activeSection]);
|
||||
|
||||
// Queries
|
||||
@@ -173,57 +167,92 @@ export default function AdminSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-safe flex min-h-screen">
|
||||
{/* Mobile overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
<>
|
||||
{/* Mobile Layout */}
|
||||
<div className="space-y-4 lg:hidden">
|
||||
<SettingsMobileTabs
|
||||
activeSection={activeSection}
|
||||
setActiveSection={setActiveSection}
|
||||
favoritesCount={favorites.length}
|
||||
/>
|
||||
)}
|
||||
<SettingsSearchMobile searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
|
||||
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<SettingsSidebar
|
||||
activeSection={activeSection}
|
||||
setActiveSection={setActiveSection}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
setMobileMenuOpen={setMobileMenuOpen}
|
||||
favoritesCount={favorites.length}
|
||||
/>
|
||||
{/* Desktop Layout - fixed sidebar, scrollable content */}
|
||||
<div className="hidden h-[calc(100vh-120px)] lg:flex">
|
||||
{/* Fixed Sidebar */}
|
||||
<div className="w-64 shrink-0 overflow-y-auto border-r border-dark-700/50">
|
||||
<div className="border-b border-dark-700/50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton />
|
||||
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="space-y-1 p-2">
|
||||
{MENU_SECTIONS.map((section, sectionIdx) => (
|
||||
<div key={section.id}>
|
||||
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
|
||||
{section.items.map((item) => {
|
||||
const isActive = activeSection === item.id;
|
||||
const hasIcon = item.iconType === 'star';
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveSection(item.id)}
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
|
||||
isActive
|
||||
? 'bg-accent-500/10 text-accent-400'
|
||||
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{hasIcon && (
|
||||
<svg
|
||||
className={`h-4 w-4 ${isActive ? 'fill-current' : ''}`}
|
||||
fill={isActive ? 'currentColor' : 'none'}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
|
||||
{item.id === 'favorites' && favorites.length > 0 && (
|
||||
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{favorites.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="min-w-0 flex-1">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-30 border-b border-dark-700/50 bg-dark-900/95 p-3 backdrop-blur-xl sm:p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
|
||||
<h2 className="truncate text-lg font-semibold text-dark-100 sm:text-xl">
|
||||
{/* Scrollable Content */}
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h2 className="truncate text-xl font-semibold text-dark-100">
|
||||
{t(`admin.settings.${activeSection}`)}
|
||||
</h2>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<SettingsSearch
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
resultsCount={filteredSettings.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSearchMobile searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
|
||||
|
||||
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3 sm:p-4 lg:p-6">{renderContent()}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
tariffsApi,
|
||||
TariffListItem,
|
||||
@@ -11,19 +10,9 @@ import {
|
||||
PeriodPrice,
|
||||
ServerInfo,
|
||||
} from '../api/tariffs';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -130,7 +119,7 @@ function TariffTypeSelect({ onSelect, onClose }: TariffTypeSelectProps) {
|
||||
className="group w-full rounded-xl bg-dark-700 p-4 text-left transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-lg bg-amber-500/20 p-3 text-amber-400 group-hover:bg-amber-500/30">
|
||||
<div className="rounded-lg bg-warning-500/20 p-3 text-warning-400 group-hover:bg-warning-500/30">
|
||||
<SunIcon />
|
||||
</div>
|
||||
<div>
|
||||
@@ -962,7 +951,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-amber-500/20 p-2 text-amber-400">
|
||||
<div className="rounded-lg bg-warning-500/20 p-2 text-warning-400">
|
||||
<SunIcon />
|
||||
</div>
|
||||
<div>
|
||||
@@ -985,7 +974,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-amber-400 text-amber-400'
|
||||
? 'border-b-2 border-warning-400 text-warning-400'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
@@ -1009,7 +998,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
placeholder={t('admin.tariffs.nameExampleDaily')}
|
||||
/>
|
||||
</div>
|
||||
@@ -1022,15 +1011,15 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
rows={2}
|
||||
placeholder={t('admin.tariffs.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Daily Price */}
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-amber-400">
|
||||
<div className="rounded-lg border border-warning-500/30 bg-warning-500/10 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-warning-400">
|
||||
{t('admin.tariffs.dailyPriceLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1040,7 +1029,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onChange={(e) =>
|
||||
setDailyPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)
|
||||
}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
step={0.1}
|
||||
/>
|
||||
@@ -1061,7 +1050,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
type="number"
|
||||
value={trafficLimitGb}
|
||||
onChange={(e) => setTrafficLimitGb(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||
@@ -1083,7 +1072,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
type="number"
|
||||
value={deviceLimit}
|
||||
onChange={(e) => setDeviceLimit(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
@@ -1099,7 +1088,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onChange={(e) =>
|
||||
setTierLevel(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))
|
||||
}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
@@ -1123,14 +1112,14 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className={`cursor-pointer rounded-lg p-3 transition-colors ${
|
||||
isSelected
|
||||
? 'border border-amber-500/50 bg-amber-500/20'
|
||||
? 'border border-warning-500/50 bg-warning-500/20'
|
||||
: 'border border-transparent bg-dark-700 hover:bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||
isSelected ? 'bg-amber-500 text-white' : 'bg-dark-600'
|
||||
isSelected ? 'bg-accent-500 text-white' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <CheckIcon />}
|
||||
@@ -1165,7 +1154,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onChange={(e) =>
|
||||
setDevicePriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)
|
||||
}
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
@@ -1182,7 +1171,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onChange={(e) =>
|
||||
setMaxDeviceLimit(Math.max(0, parseInt(e.target.value) || 0))
|
||||
}
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
@@ -1200,7 +1189,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
type="button"
|
||||
onClick={() => setTrafficTopupEnabled(!trafficTopupEnabled)}
|
||||
className={`relative h-6 w-10 rounded-full transition-colors ${
|
||||
trafficTopupEnabled ? 'bg-amber-500' : 'bg-dark-600'
|
||||
trafficTopupEnabled ? 'bg-warning-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -1222,7 +1211,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onChange={(e) =>
|
||||
setMaxTopupTrafficGb(Math.max(0, parseInt(e.target.value) || 0))
|
||||
}
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||
@@ -1250,7 +1239,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
[String(gb)]: price,
|
||||
}));
|
||||
}}
|
||||
className="w-20 rounded border border-dark-500 bg-dark-600 px-2 py-1 text-sm text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-20 rounded border border-dark-500 bg-dark-600 px-2 py-1 text-sm text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
@@ -1310,7 +1299,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onClick={() => setTrafficResetMode(option.value)}
|
||||
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||
trafficResetMode === option.value
|
||||
? 'border border-amber-500 bg-amber-500/20'
|
||||
? 'border border-warning-500 bg-warning-500/20'
|
||||
: 'border border-dark-500 bg-dark-600 hover:border-dark-400'
|
||||
}`}
|
||||
>
|
||||
@@ -1322,7 +1311,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
<p className="mt-0.5 text-xs text-dark-400">{t(option.descKey)}</p>
|
||||
</div>
|
||||
{trafficResetMode === option.value && (
|
||||
<span className="text-amber-400">
|
||||
<span className="text-warning-400">
|
||||
<CheckIcon />
|
||||
</span>
|
||||
)}
|
||||
@@ -1346,7 +1335,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name || dailyPriceKopeks <= 0 || isLoading}
|
||||
className="rounded-lg bg-amber-500 px-4 py-2 text-white transition-colors hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? t('admin.tariffs.savingButton') : t('admin.tariffs.saveButton')}
|
||||
</button>
|
||||
@@ -1465,12 +1454,7 @@ export default function AdminTariffs() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.tariffs.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.tariffs.subtitle')}</p>
|
||||
@@ -1511,7 +1495,7 @@ export default function AdminTariffs() {
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="truncate font-medium text-dark-100">{tariff.name}</h3>
|
||||
{tariff.is_daily ? (
|
||||
<span className="rounded bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400">
|
||||
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{t('admin.tariffs.dailyType')}
|
||||
</span>
|
||||
) : (
|
||||
@@ -1532,7 +1516,7 @@ export default function AdminTariffs() {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
{tariff.is_daily && tariff.daily_price_kopeks > 0 && (
|
||||
<span className="text-amber-400">
|
||||
<span className="text-warning-400">
|
||||
{(tariff.daily_price_kopeks / 100).toFixed(2)}{' '}
|
||||
{t('admin.tariffs.currencyPerDay')}
|
||||
</span>
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin';
|
||||
import { ticketsApi } from '../api/tickets';
|
||||
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
function AdminMessageMedia({
|
||||
message,
|
||||
@@ -220,12 +208,7 @@ export default function AdminTickets() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
|
||||
{t('admin.tickets.title')}
|
||||
</h1>
|
||||
|
||||
849
src/pages/AdminUserDetail.tsx
Normal file
849
src/pages/AdminUserDetail.tsx
Normal file
@@ -0,0 +1,849 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import {
|
||||
adminUsersApi,
|
||||
type UserDetailResponse,
|
||||
type UserAvailableTariff,
|
||||
type PanelSyncStatusResponse,
|
||||
type UpdateSubscriptionRequest,
|
||||
} from '../api/adminUsers';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MinusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SyncIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TelegramIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ Components ============
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
active: 'bg-success-500/20 text-success-400 border-success-500/30',
|
||||
blocked: 'bg-error-500/20 text-error-400 border-error-500/30',
|
||||
deleted: 'bg-dark-600 text-dark-400 border-dark-500',
|
||||
trial: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
|
||||
expired: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
|
||||
disabled: 'bg-dark-600 text-dark-400 border-dark-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`rounded-full border px-2 py-0.5 text-xs ${styles[status] || styles.active}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Main Page ============
|
||||
|
||||
export default function AdminUserDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
|
||||
const [user, setUser] = useState<UserDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'subscription' | 'balance' | 'sync'>('info');
|
||||
const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null);
|
||||
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
// Balance form
|
||||
const [balanceAmount, setBalanceAmount] = useState('');
|
||||
const [balanceDescription, setBalanceDescription] = useState('');
|
||||
|
||||
// Subscription form
|
||||
const [subAction, setSubAction] = useState<string>('extend');
|
||||
const [subDays, setSubDays] = useState('30');
|
||||
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
|
||||
|
||||
const userId = id ? parseInt(id, 10) : null;
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await adminUsersApi.getUser(userId);
|
||||
setUser(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
navigate('/admin/users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId, navigate]);
|
||||
|
||||
const loadSyncStatus = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const data = await adminUsersApi.getSyncStatus(userId);
|
||||
setSyncStatus(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load sync status:', error);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const loadTariffs = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const data = await adminUsersApi.getAvailableTariffs(userId, true);
|
||||
setTariffs(data.tariffs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tariffs:', error);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || isNaN(userId)) {
|
||||
navigate('/admin/users');
|
||||
return;
|
||||
}
|
||||
loadUser();
|
||||
}, [userId, loadUser, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'sync') loadSyncStatus();
|
||||
if (activeTab === 'subscription') loadTariffs();
|
||||
}, [activeTab, loadSyncStatus, loadTariffs]);
|
||||
|
||||
const handleUpdateBalance = async (isAdd: boolean) => {
|
||||
if (!balanceAmount || !userId) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const amount = Math.abs(parseFloat(balanceAmount) * 100);
|
||||
await adminUsersApi.updateBalance(userId, {
|
||||
amount_kopeks: isAdd ? amount : -amount,
|
||||
description:
|
||||
balanceDescription ||
|
||||
(isAdd
|
||||
? t('admin.users.detail.balance.addByAdmin')
|
||||
: t('admin.users.detail.balance.subtractByAdmin')),
|
||||
});
|
||||
await loadUser();
|
||||
setBalanceAmount('');
|
||||
setBalanceDescription('');
|
||||
} catch (error) {
|
||||
console.error('Failed to update balance:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSubscription = async () => {
|
||||
if (!userId) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const data: UpdateSubscriptionRequest = {
|
||||
action: subAction as UpdateSubscriptionRequest['action'],
|
||||
...(subAction === 'extend' ? { days: parseInt(subDays) } : {}),
|
||||
...(subAction === 'change_tariff' && selectedTariffId
|
||||
? { tariff_id: selectedTariffId }
|
||||
: {}),
|
||||
...(subAction === 'create'
|
||||
? {
|
||||
days: parseInt(subDays),
|
||||
...(selectedTariffId ? { tariff_id: selectedTariffId } : {}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await adminUsersApi.updateSubscription(userId, data);
|
||||
await loadUser();
|
||||
} catch (error) {
|
||||
console.error('Failed to update subscription:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockUser = async () => {
|
||||
if (!userId || !confirm(t('admin.users.confirm.block'))) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.blockUser(userId);
|
||||
await loadUser();
|
||||
} catch (error) {
|
||||
console.error('Failed to block user:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnblockUser = async () => {
|
||||
if (!userId) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.unblockUser(userId);
|
||||
await loadUser();
|
||||
} catch (error) {
|
||||
console.error('Failed to unblock user:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncFromPanel = async () => {
|
||||
if (!userId) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.syncFromPanel(userId, {
|
||||
update_subscription: true,
|
||||
update_traffic: true,
|
||||
});
|
||||
await loadUser();
|
||||
await loadSyncStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to sync from panel:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncToPanel = async () => {
|
||||
if (!userId) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.syncToPanel(userId, { create_if_missing: true });
|
||||
await loadUser();
|
||||
await loadSyncStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to sync to panel:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
|
||||
<p className="text-dark-400">{t('admin.users.notFound')}</p>
|
||||
<button
|
||||
onClick={() => navigate('/admin/users')}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/users" />
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-lg font-bold text-white">
|
||||
{user.first_name?.[0] || user.username?.[0] || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-dark-100">{user.full_name}</div>
|
||||
<div className="flex items-center gap-2 text-sm text-dark-400">
|
||||
<TelegramIcon />
|
||||
{user.telegram_id}
|
||||
{user.username && <span>@{user.username}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={loadUser} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
|
||||
<RefreshIcon className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex rounded-xl border border-dark-700 bg-dark-800/50">
|
||||
{(['info', 'subscription', 'balance', 'sync'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-accent-400 text-accent-400'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{tab === 'info' && t('admin.users.detail.tabs.info')}
|
||||
{tab === 'subscription' && t('admin.users.detail.tabs.subscription')}
|
||||
{tab === 'balance' && t('admin.users.detail.tabs.balance')}
|
||||
{tab === 'sync' && t('admin.users.detail.tabs.sync')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
{/* Info Tab */}
|
||||
{activeTab === 'info' && (
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-800/50 p-3">
|
||||
<span className="text-dark-400">{t('admin.users.detail.status')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={user.status} />
|
||||
{user.status === 'active' ? (
|
||||
<button
|
||||
onClick={handleBlockUser}
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-error-500/20 px-3 py-1 text-xs text-error-400 transition-colors hover:bg-error-500/30"
|
||||
>
|
||||
{t('admin.users.actions.block')}
|
||||
</button>
|
||||
) : user.status === 'blocked' ? (
|
||||
<button
|
||||
onClick={handleUnblockUser}
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-success-500/20 px-3 py-1 text-xs text-success-400 transition-colors hover:bg-success-500/30"
|
||||
>
|
||||
{t('admin.users.actions.unblock')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Email</div>
|
||||
<div className="text-dark-100">{user.email || '-'}</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.language')}</div>
|
||||
<div className="text-dark-100">{user.language}</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.registration')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.created_at)}</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.lastActivity')}
|
||||
</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.totalSpent')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{formatWithCurrency(user.total_spent_kopeks / 100)}
|
||||
</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.purchases')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.purchase_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-200">
|
||||
{t('admin.users.detail.referral.title')}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.referrals_count}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.referrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.earned')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.commission_percent || 0}%
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.commission')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restrictions */}
|
||||
{(user.restriction_topup || user.restriction_subscription) && (
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-error-400">
|
||||
{t('admin.users.detail.restrictions.title')}
|
||||
</div>
|
||||
{user.restriction_topup && (
|
||||
<div className="text-xs text-error-300">
|
||||
{t('admin.users.detail.restrictions.topup')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_subscription && (
|
||||
<div className="text-xs text-error-300">
|
||||
{t('admin.users.detail.restrictions.subscription')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_reason && (
|
||||
<div className="mt-1 text-xs text-dark-400">
|
||||
{t('admin.users.detail.restrictions.reason')}: {user.restriction_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription Tab */}
|
||||
{activeTab === 'subscription' && (
|
||||
<div className="space-y-4">
|
||||
{user.subscription ? (
|
||||
<>
|
||||
{/* Current subscription */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.current')}
|
||||
</span>
|
||||
<StatusBadge status={user.subscription.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.tariff')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.tariff_name ||
|
||||
t('admin.users.detail.subscription.notSpecified')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.validUntil')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.subscription.end_date)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.traffic')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.traffic_used_gb.toFixed(1)} /{' '}
|
||||
{user.subscription.traffic_limit_gb} {t('common.units.gb')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.devices')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.subscription.device_limit}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.actions')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={subAction}
|
||||
onChange={(e) => setSubAction(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="extend">{t('admin.users.detail.subscription.extend')}</option>
|
||||
<option value="change_tariff">
|
||||
{t('admin.users.detail.subscription.changeTariff')}
|
||||
</option>
|
||||
<option value="cancel">{t('admin.users.detail.subscription.cancel')}</option>
|
||||
<option value="activate">
|
||||
{t('admin.users.detail.subscription.activate')}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
{subAction === 'extend' && (
|
||||
<input
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
)}
|
||||
|
||||
{subAction === 'change_tariff' && (
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
onChange={(e) =>
|
||||
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">
|
||||
{t('admin.users.detail.subscription.selectTariff')}
|
||||
</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}{' '}
|
||||
{!tariffItem.is_available &&
|
||||
t('admin.users.detail.subscription.unavailable')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleUpdateSubscription}
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-accent-500 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading
|
||||
? t('admin.users.actions.applying')
|
||||
: t('admin.users.actions.apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-4 text-center text-dark-400">
|
||||
{t('admin.users.detail.subscription.noActive')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
onChange={(e) =>
|
||||
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">{t('admin.users.detail.subscription.selectTariff')}</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubAction('create');
|
||||
handleUpdateSubscription();
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading
|
||||
? t('admin.users.detail.subscription.creating')
|
||||
: t('admin.users.detail.subscription.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Balance Tab */}
|
||||
{activeTab === 'balance' && (
|
||||
<div className="space-y-4">
|
||||
{/* Current balance */}
|
||||
<div className="rounded-xl border border-accent-500/30 bg-gradient-to-r from-accent-500/20 to-accent-700/20 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.users.detail.balance.current')}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-dark-100">
|
||||
{formatWithCurrency(user.balance_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add/subtract form */}
|
||||
<div className="space-y-3 rounded-xl bg-dark-800/50 p-4">
|
||||
<input
|
||||
type="number"
|
||||
value={balanceAmount}
|
||||
onChange={(e) => setBalanceAmount(e.target.value)}
|
||||
placeholder={t('admin.users.detail.balance.amountPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={balanceDescription}
|
||||
onChange={(e) => setBalanceDescription(e.target.value)}
|
||||
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(true)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon /> {t('admin.users.detail.balance.add')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(false)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
||||
>
|
||||
<MinusIcon /> {t('admin.users.detail.balance.subtract')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent transactions */}
|
||||
{user.recent_transactions.length > 0 && (
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.balance.recentTransactions')}
|
||||
</div>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{user.recent_transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between border-b border-dark-700 py-2 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm text-dark-200">{tx.description || tx.type}</div>
|
||||
<div className="text-xs text-dark-500">{formatDate(tx.created_at)}</div>
|
||||
</div>
|
||||
<div
|
||||
className={tx.amount_kopeks >= 0 ? 'text-success-400' : 'text-error-400'}
|
||||
>
|
||||
{tx.amount_kopeks >= 0 ? '+' : ''}
|
||||
{formatWithCurrency(tx.amount_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync Tab */}
|
||||
{activeTab === 'sync' && (
|
||||
<div className="space-y-4">
|
||||
{/* Sync status */}
|
||||
{syncStatus && (
|
||||
<div
|
||||
className={`rounded-xl border p-4 ${syncStatus.has_differences ? 'border-warning-500/30 bg-warning-500/10' : 'border-success-500/30 bg-success-500/10'}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{syncStatus.has_differences ? (
|
||||
<span className="font-medium text-warning-400">
|
||||
{t('admin.users.detail.sync.hasDifferences')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-success-400">
|
||||
{t('admin.users.detail.sync.synced')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncStatus.differences.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
{syncStatus.differences.map((diff, i) => (
|
||||
<div key={i} className="text-xs text-dark-300">
|
||||
• {diff}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.bot')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_status || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.users.detail.sync.until')}:</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_end_date
|
||||
? new Date(syncStatus.bot_subscription_end_date).toLocaleDateString(
|
||||
locale,
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_squads?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.panel')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_status || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.users.detail.sync.until')}:</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_expire_at
|
||||
? new Date(syncStatus.panel_expire_at).toLocaleDateString(locale)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_squads?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UUID info */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">Remnawave UUID</div>
|
||||
<div className="break-all font-mono text-sm text-dark-100">
|
||||
{syncStatus?.remnawave_uuid ||
|
||||
user.remnawave_uuid ||
|
||||
t('admin.users.detail.sync.notLinked')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSyncFromPanel}
|
||||
disabled={actionLoading}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-accent-500/30 bg-accent-500/20 py-3 text-accent-400 transition-colors hover:bg-accent-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
{t('admin.users.detail.sync.fromPanel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncToPanel}
|
||||
disabled={actionLoading}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-accent-500/30 bg-accent-500/20 py-3 text-accent-400 transition-colors hover:bg-accent-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
{t('admin.users.detail.sync.toPanel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import {
|
||||
adminUsersApi,
|
||||
type UserListItem,
|
||||
type UserDetailResponse,
|
||||
type UsersStatsResponse,
|
||||
type UserAvailableTariff,
|
||||
type PanelSyncStatusResponse,
|
||||
type UpdateSubscriptionRequest,
|
||||
} from '../api/adminUsers';
|
||||
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
@@ -37,12 +29,6 @@ const ChevronRightIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const XMarkIcon = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
@@ -53,28 +39,6 @@ const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MinusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SyncIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TelegramIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
@@ -92,11 +56,11 @@ interface StatCardProps {
|
||||
|
||||
function StatCard({ title, value, subtitle, color }: StatCardProps) {
|
||||
const colors = {
|
||||
blue: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
green: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
yellow: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
red: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
purple: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
blue: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
|
||||
green: 'bg-success-500/20 text-success-400 border-success-500/30',
|
||||
yellow: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
|
||||
red: 'bg-error-500/20 text-error-400 border-error-500/30',
|
||||
purple: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -110,11 +74,11 @@ function StatCard({ title, value, subtitle, color }: StatCardProps) {
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
active: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
blocked: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
active: 'bg-success-500/20 text-success-400 border-success-500/30',
|
||||
blocked: 'bg-error-500/20 text-error-400 border-error-500/30',
|
||||
deleted: 'bg-dark-600 text-dark-400 border-dark-500',
|
||||
trial: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
expired: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
trial: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
|
||||
expired: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
|
||||
disabled: 'bg-dark-600 text-dark-400 border-dark-500',
|
||||
};
|
||||
|
||||
@@ -129,19 +93,19 @@ function StatusBadge({ status }: { status: string }) {
|
||||
|
||||
interface UserRowProps {
|
||||
user: UserListItem;
|
||||
onSelect: (user: UserListItem) => void;
|
||||
onClick: () => void;
|
||||
formatAmount: (rubAmount: number) => string;
|
||||
}
|
||||
|
||||
function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
function UserRow({ user, onClick, formatAmount }: UserRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(user)}
|
||||
onClick={onClick}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-all hover:border-dark-600 hover:bg-dark-800"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600 font-medium text-white">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 font-medium text-white">
|
||||
{user.first_name?.[0] || user.username?.[0] || '?'}
|
||||
</div>
|
||||
|
||||
@@ -161,10 +125,10 @@ function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-xs ${
|
||||
user.subscription_status === 'active'
|
||||
? 'border-emerald-500/30 bg-emerald-500/20 text-emerald-400'
|
||||
? 'border-success-500/30 bg-success-500/20 text-success-400'
|
||||
: user.subscription_status === 'trial'
|
||||
? 'border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'border-amber-500/30 bg-amber-500/20 text-amber-400'
|
||||
? 'border-accent-500/30 bg-accent-500/20 text-accent-400'
|
||||
: 'border-warning-500/30 bg-warning-500/20 text-warning-400'
|
||||
}`}
|
||||
>
|
||||
{user.subscription_status === 'active'
|
||||
@@ -192,787 +156,12 @@ function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ============ User Detail Modal ============
|
||||
|
||||
interface UserDetailModalProps {
|
||||
userId: number;
|
||||
onClose: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
const [user, setUser] = useState<UserDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'subscription' | 'balance' | 'sync'>('info');
|
||||
const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null);
|
||||
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
// Balance form
|
||||
const [balanceAmount, setBalanceAmount] = useState('');
|
||||
const [balanceDescription, setBalanceDescription] = useState('');
|
||||
|
||||
// Subscription form
|
||||
const [subAction, setSubAction] = useState<string>('extend');
|
||||
const [subDays, setSubDays] = useState('30');
|
||||
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await adminUsersApi.getUser(userId);
|
||||
setUser(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const loadSyncStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await adminUsersApi.getSyncStatus(userId);
|
||||
setSyncStatus(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load sync status:', error);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const loadTariffs = useCallback(async () => {
|
||||
try {
|
||||
const data = await adminUsersApi.getAvailableTariffs(userId, true);
|
||||
setTariffs(data.tariffs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tariffs:', error);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'sync') loadSyncStatus();
|
||||
if (activeTab === 'subscription') loadTariffs();
|
||||
}, [activeTab, loadSyncStatus, loadTariffs]);
|
||||
|
||||
const handleUpdateBalance = async (isAdd: boolean) => {
|
||||
if (!balanceAmount) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const amount = Math.abs(parseFloat(balanceAmount) * 100);
|
||||
await adminUsersApi.updateBalance(userId, {
|
||||
amount_kopeks: isAdd ? amount : -amount,
|
||||
description:
|
||||
balanceDescription ||
|
||||
(isAdd
|
||||
? t('admin.users.detail.balance.addByAdmin')
|
||||
: t('admin.users.detail.balance.subtractByAdmin')),
|
||||
});
|
||||
await loadUser();
|
||||
setBalanceAmount('');
|
||||
setBalanceDescription('');
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to update balance:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSubscription = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const data: UpdateSubscriptionRequest = {
|
||||
action: subAction as UpdateSubscriptionRequest['action'],
|
||||
...(subAction === 'extend' ? { days: parseInt(subDays) } : {}),
|
||||
...(subAction === 'change_tariff' && selectedTariffId
|
||||
? { tariff_id: selectedTariffId }
|
||||
: {}),
|
||||
...(subAction === 'create'
|
||||
? {
|
||||
days: parseInt(subDays),
|
||||
...(selectedTariffId ? { tariff_id: selectedTariffId } : {}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await adminUsersApi.updateSubscription(userId, data);
|
||||
await loadUser();
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to update subscription:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockUser = async () => {
|
||||
if (!confirm(t('admin.users.confirm.block'))) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.blockUser(userId);
|
||||
await loadUser();
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to block user:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnblockUser = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.unblockUser(userId);
|
||||
await loadUser();
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to unblock user:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncFromPanel = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.syncFromPanel(userId, {
|
||||
update_subscription: true,
|
||||
update_traffic: true,
|
||||
});
|
||||
await loadUser();
|
||||
await loadSyncStatus();
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to sync from panel:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncToPanel = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.syncToPanel(userId, { create_if_missing: true });
|
||||
await loadUser();
|
||||
await loadSyncStatus();
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to sync to panel:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||
<div className="rounded-2xl bg-dark-800 p-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-dark-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600 text-lg font-bold text-white">
|
||||
{user.first_name?.[0] || user.username?.[0] || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-dark-100">{user.full_name}</div>
|
||||
<div className="flex items-center gap-2 text-sm text-dark-400">
|
||||
<TelegramIcon />
|
||||
{user.telegram_id}
|
||||
{user.username && <span>@{user.username}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
|
||||
<XMarkIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-dark-700">
|
||||
{(['info', 'subscription', 'balance', 'sync'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-blue-400 text-blue-400'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{tab === 'info' && t('admin.users.detail.tabs.info')}
|
||||
{tab === 'subscription' && t('admin.users.detail.tabs.subscription')}
|
||||
{tab === 'balance' && t('admin.users.detail.tabs.balance')}
|
||||
{tab === 'sync' && t('admin.users.detail.tabs.sync')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* Info Tab */}
|
||||
{activeTab === 'info' && (
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-900/50 p-3">
|
||||
<span className="text-dark-400">{t('admin.users.detail.status')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={user.status} />
|
||||
{user.status === 'active' ? (
|
||||
<button
|
||||
onClick={handleBlockUser}
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-rose-500/20 px-3 py-1 text-xs text-rose-400 transition-colors hover:bg-rose-500/30"
|
||||
>
|
||||
{t('admin.users.actions.block')}
|
||||
</button>
|
||||
) : user.status === 'blocked' ? (
|
||||
<button
|
||||
onClick={handleUnblockUser}
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-emerald-500/20 px-3 py-1 text-xs text-emerald-400 transition-colors hover:bg-emerald-500/30"
|
||||
>
|
||||
{t('admin.users.actions.unblock')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Email</div>
|
||||
<div className="text-dark-100">{user.email || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.language')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.language}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.registration')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.created_at)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.lastActivity')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.totalSpent')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{formatWithCurrency(user.total_spent_kopeks / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.purchases')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.purchase_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-200">
|
||||
{t('admin.users.detail.referral.title')}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.referrals_count}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.referrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.earned')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.commission_percent || 0}%
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.commission')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restrictions */}
|
||||
{(user.restriction_topup || user.restriction_subscription) && (
|
||||
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-rose-400">
|
||||
{t('admin.users.detail.restrictions.title')}
|
||||
</div>
|
||||
{user.restriction_topup && (
|
||||
<div className="text-xs text-rose-300">
|
||||
{t('admin.users.detail.restrictions.topup')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_subscription && (
|
||||
<div className="text-xs text-rose-300">
|
||||
{t('admin.users.detail.restrictions.subscription')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_reason && (
|
||||
<div className="mt-1 text-xs text-dark-400">
|
||||
{t('admin.users.detail.restrictions.reason')}: {user.restriction_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription Tab */}
|
||||
{activeTab === 'subscription' && (
|
||||
<div className="space-y-4">
|
||||
{user.subscription ? (
|
||||
<>
|
||||
{/* Current subscription */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.current')}
|
||||
</span>
|
||||
<StatusBadge status={user.subscription.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.tariff')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.tariff_name ||
|
||||
t('admin.users.detail.subscription.notSpecified')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.validUntil')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{formatDate(user.subscription.end_date)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.traffic')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.traffic_used_gb.toFixed(1)} /{' '}
|
||||
{user.subscription.traffic_limit_gb} {t('common.units.gb')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.devices')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.subscription.device_limit}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.actions')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={subAction}
|
||||
onChange={(e) => setSubAction(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="extend">
|
||||
{t('admin.users.detail.subscription.extend')}
|
||||
</option>
|
||||
<option value="change_tariff">
|
||||
{t('admin.users.detail.subscription.changeTariff')}
|
||||
</option>
|
||||
<option value="cancel">
|
||||
{t('admin.users.detail.subscription.cancel')}
|
||||
</option>
|
||||
<option value="activate">
|
||||
{t('admin.users.detail.subscription.activate')}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
{subAction === 'extend' && (
|
||||
<input
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
)}
|
||||
|
||||
{subAction === 'change_tariff' && (
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
onChange={(e) =>
|
||||
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">
|
||||
{t('admin.users.detail.subscription.selectTariff')}
|
||||
</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}{' '}
|
||||
{!tariffItem.is_available &&
|
||||
t('admin.users.detail.subscription.unavailable')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleUpdateSubscription}
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-blue-500 py-2 text-white transition-colors hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading
|
||||
? t('admin.users.actions.applying')
|
||||
: t('admin.users.actions.apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-4 text-center text-dark-400">
|
||||
{t('admin.users.detail.subscription.noActive')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
onChange={(e) =>
|
||||
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">{t('admin.users.detail.subscription.selectTariff')}</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubAction('create');
|
||||
handleUpdateSubscription();
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-emerald-500 py-2 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading
|
||||
? t('admin.users.detail.subscription.creating')
|
||||
: t('admin.users.detail.subscription.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Balance Tab */}
|
||||
{activeTab === 'balance' && (
|
||||
<div className="space-y-4">
|
||||
{/* Current balance */}
|
||||
<div className="rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/20 to-purple-500/20 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.users.detail.balance.current')}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-dark-100">
|
||||
{formatWithCurrency(user.balance_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add/subtract form */}
|
||||
<div className="space-y-3 rounded-xl bg-dark-900/50 p-4">
|
||||
<input
|
||||
type="number"
|
||||
value={balanceAmount}
|
||||
onChange={(e) => setBalanceAmount(e.target.value)}
|
||||
placeholder={t('admin.users.detail.balance.amountPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={balanceDescription}
|
||||
onChange={(e) => setBalanceDescription(e.target.value)}
|
||||
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(true)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-emerald-500 py-2 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon /> {t('admin.users.detail.balance.add')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(false)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-rose-500 py-2 text-white transition-colors hover:bg-rose-600 disabled:opacity-50"
|
||||
>
|
||||
<MinusIcon /> {t('admin.users.detail.balance.subtract')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent transactions */}
|
||||
{user.recent_transactions.length > 0 && (
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.balance.recentTransactions')}
|
||||
</div>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{user.recent_transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between border-b border-dark-700 py-2 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm text-dark-200">{tx.description || tx.type}</div>
|
||||
<div className="text-xs text-dark-500">{formatDate(tx.created_at)}</div>
|
||||
</div>
|
||||
<div
|
||||
className={tx.amount_kopeks >= 0 ? 'text-emerald-400' : 'text-rose-400'}
|
||||
>
|
||||
{tx.amount_kopeks >= 0 ? '+' : ''}
|
||||
{formatWithCurrency(tx.amount_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync Tab */}
|
||||
{activeTab === 'sync' && (
|
||||
<div className="space-y-4">
|
||||
{/* Sync status */}
|
||||
{syncStatus && (
|
||||
<div
|
||||
className={`rounded-xl border p-4 ${syncStatus.has_differences ? 'border-amber-500/30 bg-amber-500/10' : 'border-emerald-500/30 bg-emerald-500/10'}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{syncStatus.has_differences ? (
|
||||
<span className="font-medium text-amber-400">
|
||||
{t('admin.users.detail.sync.hasDifferences')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-emerald-400">
|
||||
{t('admin.users.detail.sync.synced')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncStatus.differences.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
{syncStatus.differences.map((diff, i) => (
|
||||
<div key={i} className="text-xs text-dark-300">
|
||||
• {diff}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.bot')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_status || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.until')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_end_date
|
||||
? new Date(syncStatus.bot_subscription_end_date).toLocaleDateString(
|
||||
locale,
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_squads?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.panel')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_status || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.until')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_expire_at
|
||||
? new Date(syncStatus.panel_expire_at).toLocaleDateString(locale)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_squads?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UUID info */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">Remnawave UUID</div>
|
||||
<div className="break-all font-mono text-sm text-dark-100">
|
||||
{syncStatus?.remnawave_uuid ||
|
||||
user.remnawave_uuid ||
|
||||
t('admin.users.detail.sync.notLinked')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSyncFromPanel}
|
||||
disabled={actionLoading}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-blue-500/30 bg-blue-500/20 py-3 text-blue-400 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
{t('admin.users.detail.sync.fromPanel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncToPanel}
|
||||
disabled={actionLoading}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-purple-500/30 bg-purple-500/20 py-3 text-purple-400 transition-colors hover:bg-purple-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
{t('admin.users.detail.sync.toPanel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Main Page ============
|
||||
|
||||
export default function AdminUsers() {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [users, setUsers] = useState<UserListItem[]>([]);
|
||||
const [stats, setStats] = useState<UsersStatsResponse | null>(null);
|
||||
@@ -983,7 +172,6 @@ export default function AdminUsers() {
|
||||
const [sortBy, setSortBy] = useState<string>('created_at');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||
|
||||
const limit = 20;
|
||||
|
||||
@@ -1038,12 +226,7 @@ export default function AdminUsers() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.users.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.users.subtitle')}</p>
|
||||
@@ -1155,7 +338,7 @@ export default function AdminUsers() {
|
||||
<div className="mb-4 space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="py-12 text-center text-dark-400">{t('admin.users.noData')}</div>
|
||||
@@ -1164,7 +347,7 @@ export default function AdminUsers() {
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
onSelect={(u) => setSelectedUserId(u.id)}
|
||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||
formatAmount={(amount) => formatWithCurrency(amount)}
|
||||
/>
|
||||
))
|
||||
@@ -1202,18 +385,6 @@ export default function AdminUsers() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User detail modal */}
|
||||
{selectedUserId && (
|
||||
<UserDetailModal
|
||||
userId={selectedUserId}
|
||||
onClose={() => setSelectedUserId(null)}
|
||||
onUpdate={() => {
|
||||
loadUsers();
|
||||
loadStats();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CogIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -143,18 +132,15 @@ export default function AdminWheel() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('admin.wheel.title')}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-sm ${
|
||||
config.is_enabled ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
|
||||
config.is_enabled
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-error-500/20 text-error-400'
|
||||
}`}
|
||||
>
|
||||
{config.is_enabled ? t('admin.wheel.enabled') : t('admin.wheel.disabled')}
|
||||
@@ -404,7 +390,7 @@ export default function AdminWheel() {
|
||||
deletePrizeMutation.mutate(prize.id);
|
||||
}
|
||||
}}
|
||||
className="btn-ghost text-red-400"
|
||||
className="btn-ghost text-error-400"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
@@ -432,13 +418,13 @@ export default function AdminWheel() {
|
||||
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.totalSpins')}</div>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<div className="text-3xl font-bold text-green-400">
|
||||
<div className="text-3xl font-bold text-success-400">
|
||||
{(stats.total_revenue_kopeks / 100).toFixed(0)}₽
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.revenue')}</div>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<div className="text-3xl font-bold text-yellow-400">
|
||||
<div className="text-3xl font-bold text-warning-400">
|
||||
{(stats.total_payout_kopeks / 100).toFixed(0)}₽
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.payouts')}</div>
|
||||
@@ -447,8 +433,8 @@ export default function AdminWheel() {
|
||||
<div
|
||||
className={`text-3xl font-bold ${
|
||||
stats.actual_rtp_percent <= stats.configured_rtp_percent
|
||||
? 'text-green-400'
|
||||
: 'text-red-400'
|
||||
? 'text-success-400'
|
||||
: 'text-error-400'
|
||||
}`}
|
||||
>
|
||||
{stats.actual_rtp_percent.toFixed(1)}%
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { balanceApi } from '../api/balance';
|
||||
import TopUpModal from '../components/TopUpModal';
|
||||
@@ -9,6 +11,33 @@ import { useCurrency } from '../hooks/useCurrency';
|
||||
import { useToast } from '../components/Toast';
|
||||
import type { PaymentMethod, PaginatedResponse, Transaction } from '../types';
|
||||
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
|
||||
// Icons
|
||||
const ChevronDownIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
|
||||
<svg className={className} 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>
|
||||
);
|
||||
|
||||
const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Balance() {
|
||||
const { t } = useTranslation();
|
||||
const { refreshUser } = useAuthStore();
|
||||
@@ -23,7 +52,7 @@ export default function Balance() {
|
||||
const { data: balanceData, refetch: refetchBalance } = useQuery({
|
||||
queryKey: ['balance'],
|
||||
queryFn: balanceApi.getBalance,
|
||||
staleTime: 0, // Always refetch
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
|
||||
@@ -35,7 +64,6 @@ export default function Balance() {
|
||||
|
||||
// Handle payment return from payment gateway
|
||||
useEffect(() => {
|
||||
// Prevent duplicate handling in StrictMode
|
||||
if (paymentHandledRef.current) return;
|
||||
|
||||
const paymentStatus = searchParams.get('payment') || searchParams.get('status');
|
||||
@@ -48,14 +76,11 @@ export default function Balance() {
|
||||
if (isSuccess) {
|
||||
paymentHandledRef.current = true;
|
||||
|
||||
// Refetch balance and user data
|
||||
refetchBalance();
|
||||
refreshUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||
// Also invalidate subscription in case auto-activation happened
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
|
||||
// Show success toast
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: t('balance.paymentSuccess.title'),
|
||||
@@ -63,10 +88,10 @@ export default function Balance() {
|
||||
duration: 6000,
|
||||
});
|
||||
|
||||
// Clean URL from query params
|
||||
navigate('/balance', { replace: true });
|
||||
}
|
||||
}, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]);
|
||||
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
|
||||
const [promocode, setPromocode] = useState('');
|
||||
const [promocodeLoading, setPromocodeLoading] = useState(false);
|
||||
@@ -138,7 +163,6 @@ export default function Balance() {
|
||||
});
|
||||
setTransactionsPage(1);
|
||||
setPromocode('');
|
||||
// Refresh balance and transactions
|
||||
await refetchBalance();
|
||||
await refreshUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||
@@ -146,7 +170,6 @@ export default function Balance() {
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { detail?: string } } };
|
||||
const errorDetail = axiosError.response?.data?.detail || 'server_error';
|
||||
// Map backend error messages to translation keys
|
||||
const errorKey = errorDetail.toLowerCase().includes('not found')
|
||||
? 'not_found'
|
||||
: errorDetail.toLowerCase().includes('expired')
|
||||
@@ -163,213 +186,248 @@ export default function Balance() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('balance.title')}</h1>
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div variants={staggerItem}>
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('balance.title')}</h1>
|
||||
</motion.div>
|
||||
|
||||
{/* Balance Card */}
|
||||
<div className="bento-card bento-card-glow bg-gradient-to-br from-accent-500/10 to-transparent">
|
||||
<div className="mb-2 text-sm text-dark-400">{t('balance.currentBalance')}</div>
|
||||
<div className="text-4xl font-bold text-dark-50 sm:text-5xl">
|
||||
{formatAmount(balanceData?.balance_rubles || 0)}
|
||||
<span className="ml-2 text-2xl text-dark-400">{currencySymbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card className="bg-gradient-to-br from-accent-500/10 to-transparent" glow>
|
||||
<div className="mb-2 text-sm text-dark-400">{t('balance.currentBalance')}</div>
|
||||
<div className="text-4xl font-bold text-dark-50 sm:text-5xl">
|
||||
{formatAmount(balanceData?.balance_rubles || 0)}
|
||||
<span className="ml-2 text-2xl text-dark-400">{currencySymbol}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Promo Code Section */}
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('balance.promocode.title')}</h2>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={promocode}
|
||||
onChange={(e) => setPromocode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handlePromocodeActivate()}
|
||||
placeholder={t('balance.promocode.placeholder')}
|
||||
className="input flex-1"
|
||||
disabled={promocodeLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={handlePromocodeActivate}
|
||||
disabled={!promocode.trim() || promocodeLoading}
|
||||
className="btn-primary whitespace-nowrap px-6"
|
||||
>
|
||||
{promocodeLoading ? t('balance.promocode.activating') : t('balance.promocode.activate')}
|
||||
</button>
|
||||
</div>
|
||||
{promocodeError && (
|
||||
<div className="mt-3 rounded-lg border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{promocodeError}
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('balance.promocode.title')}
|
||||
</h2>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={promocode}
|
||||
onChange={(e) => setPromocode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handlePromocodeActivate()}
|
||||
placeholder={t('balance.promocode.placeholder')}
|
||||
className="input flex-1"
|
||||
disabled={promocodeLoading}
|
||||
/>
|
||||
<Button
|
||||
onClick={handlePromocodeActivate}
|
||||
disabled={!promocode.trim()}
|
||||
loading={promocodeLoading}
|
||||
>
|
||||
{t('balance.promocode.activate')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{promocodeSuccess && (
|
||||
<div className="mt-3 rounded-lg border border-success-500/30 bg-success-500/10 p-3 text-sm text-success-400">
|
||||
<div className="font-medium">{promocodeSuccess.message}</div>
|
||||
{promocodeSuccess.amount > 0 && (
|
||||
<div className="mt-1">
|
||||
{t('balance.promocode.balanceAdded', {
|
||||
amount: promocodeSuccess.amount.toFixed(2),
|
||||
})}
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
{promocodeError && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mt-3 rounded-linear border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400"
|
||||
>
|
||||
{promocodeError}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{promocodeSuccess && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mt-3 rounded-linear border border-success-500/30 bg-success-500/10 p-3 text-sm text-success-400"
|
||||
>
|
||||
<div className="font-medium">{promocodeSuccess.message}</div>
|
||||
{promocodeSuccess.amount > 0 && (
|
||||
<div className="mt-1">
|
||||
{t('balance.promocode.balanceAdded', {
|
||||
amount: promocodeSuccess.amount.toFixed(2),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Payment Methods */}
|
||||
{paymentMethods && paymentMethods.length > 0 && (
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('balance.topUpBalance')}</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{paymentMethods.map((method) => {
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
|
||||
defaultValue: '',
|
||||
});
|
||||
const translatedDesc = t(`balance.paymentMethods.${methodKey}.description`, {
|
||||
defaultValue: '',
|
||||
});
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('balance.topUpBalance')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{paymentMethods.map((method) => {
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
|
||||
defaultValue: '',
|
||||
});
|
||||
const translatedDesc = t(`balance.paymentMethods.${methodKey}.description`, {
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
disabled={!method.is_available}
|
||||
onClick={() => method.is_available && setSelectedMethod(method)}
|
||||
className={`bento-card-hover p-4 text-left transition-all ${
|
||||
method.is_available ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold text-dark-100">{translatedName || method.name}</div>
|
||||
{(translatedDesc || method.description) && (
|
||||
<div className="mt-1 text-sm text-dark-500">
|
||||
{translatedDesc || method.description}
|
||||
return (
|
||||
<Card
|
||||
key={method.id}
|
||||
interactive={method.is_available}
|
||||
className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''}
|
||||
onClick={() => method.is_available && setSelectedMethod(method)}
|
||||
>
|
||||
<div className="font-semibold text-dark-100">
|
||||
{translatedName || method.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 text-xs text-dark-600">
|
||||
{formatAmount(method.min_amount_kopeks / 100, 0)} –{' '}
|
||||
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{(translatedDesc || method.description) && (
|
||||
<div className="mt-1 text-sm text-dark-500">
|
||||
{translatedDesc || method.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 text-xs text-dark-600">
|
||||
{formatAmount(method.min_amount_kopeks / 100, 0)} –{' '}
|
||||
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="bento-card overflow-hidden">
|
||||
<button
|
||||
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
|
||||
className="flex w-full items-center justify-between text-left"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('balance.transactionHistory')}</h2>
|
||||
<svg
|
||||
className={`h-5 w-5 text-dark-400 transition-transform duration-200 ${isHistoryOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
{/* Transaction History */}
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
|
||||
className="flex w-full items-center justify-between text-left"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('balance.transactionHistory')}
|
||||
</h2>
|
||||
<ChevronDownIcon
|
||||
className={`h-5 w-5 text-dark-400 transition-transform duration-200 ${isHistoryOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isHistoryOpen && (
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : transactions?.items && transactions.items.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{transactions.items.map((tx) => {
|
||||
const isPositive = tx.amount_rubles >= 0;
|
||||
const displayAmount = Math.abs(tx.amount_rubles);
|
||||
const sign = isPositive ? '+' : '-';
|
||||
const colorClass = isPositive ? 'text-success-400' : 'text-error-400';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-3">
|
||||
<span className={getTypeBadge(tx.type)}>{getTypeLabel(tx.type)}</span>
|
||||
<span className="text-xs text-dark-500">
|
||||
{new Date(tx.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{tx.description && (
|
||||
<div className="text-sm text-dark-400">{tx.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-lg font-semibold ${colorClass}`}>
|
||||
{sign}
|
||||
{formatAmount(displayAmount)} {currencySymbol}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isHistoryOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<svg
|
||||
className="h-8 w-8 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-dark-400">{t('balance.noTransactions')}</div>
|
||||
</div>
|
||||
)}
|
||||
) : transactions?.items && transactions.items.length > 0 ? (
|
||||
<motion.div
|
||||
className="space-y-3"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
{transactions.items.map((tx) => {
|
||||
const isPositive = tx.amount_rubles >= 0;
|
||||
const displayAmount = Math.abs(tx.amount_rubles);
|
||||
const sign = isPositive ? '+' : '-';
|
||||
const colorClass = isPositive ? 'text-success-400' : 'text-error-400';
|
||||
|
||||
{transactions && transactions.pages > 1 && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-dark-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTransactionsPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={transactions.page <= 1}
|
||||
className={`btn-secondary min-w-[120px] flex-1 text-xs sm:flex-none sm:text-sm ${
|
||||
transactions.page <= 1 ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<div className="flex-1 text-center">
|
||||
{t('balance.page', { current: transactions.page, total: transactions.pages })}
|
||||
return (
|
||||
<motion.div
|
||||
key={tx.id}
|
||||
variants={staggerItem}
|
||||
className="flex items-center justify-between rounded-linear border border-dark-700/30 bg-dark-800/30 p-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-3">
|
||||
<span className={getTypeBadge(tx.type)}>
|
||||
{getTypeLabel(tx.type)}
|
||||
</span>
|
||||
<span className="text-xs text-dark-500">
|
||||
{new Date(tx.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{tx.description && (
|
||||
<div className="text-sm text-dark-400">{tx.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-lg font-semibold ${colorClass}`}>
|
||||
{sign}
|
||||
{formatAmount(displayAmount)} {currencySymbol}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-linear-lg bg-dark-800">
|
||||
<WalletIcon className="h-8 w-8 text-dark-500" />
|
||||
</div>
|
||||
<div className="text-dark-400">{t('balance.noTransactions')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transactions && transactions.pages > 1 && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-dark-500">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setTransactionsPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={transactions.page <= 1}
|
||||
className="min-w-[120px] flex-1 sm:flex-none"
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<div className="flex-1 text-center">
|
||||
{t('balance.page', {
|
||||
current: transactions.page,
|
||||
total: transactions.pages,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setTransactionsPage((prev) =>
|
||||
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1,
|
||||
)
|
||||
}
|
||||
disabled={transactions.page >= transactions.pages}
|
||||
className="min-w-[120px] flex-1 sm:flex-none"
|
||||
>
|
||||
{t('common.next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setTransactionsPage((prev) =>
|
||||
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1,
|
||||
)
|
||||
}
|
||||
disabled={transactions.page >= transactions.pages}
|
||||
className={`btn-secondary min-w-[120px] flex-1 text-xs sm:flex-none sm:text-sm ${
|
||||
transactions.page >= transactions.pages ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* TopUp Modal */}
|
||||
{selectedMethod && (
|
||||
<TopUpModal method={selectedMethod} onClose={() => setSelectedMethod(null)} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { subscriptionApi } from '../api/subscription';
|
||||
import { referralApi } from '../api/referral';
|
||||
@@ -12,15 +14,25 @@ import Onboarding, { useOnboarding } from '../components/Onboarding';
|
||||
import PromoOffersSection from '../components/PromoOffersSection';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
|
||||
// Icons
|
||||
const ArrowRightIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
const ArrowRightIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const SparklesIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -29,8 +41,8 @@ const SparklesIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
const ChevronRightIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
);
|
||||
@@ -45,16 +57,6 @@ const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SupportLottieIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
const { user, refreshUser } = useAuthStore();
|
||||
@@ -244,13 +246,6 @@ export default function Dashboard() {
|
||||
});
|
||||
}
|
||||
|
||||
steps.push({
|
||||
target: 'quick-actions',
|
||||
title: t('onboarding.steps.quickActions.title'),
|
||||
description: t('onboarding.steps.quickActions.description'),
|
||||
placement: 'top',
|
||||
});
|
||||
|
||||
return steps;
|
||||
}, [t, subscription]);
|
||||
|
||||
@@ -267,372 +262,373 @@ export default function Dashboard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
{/* Header */}
|
||||
<div data-onboarding="welcome">
|
||||
<motion.div variants={staggerItem} data-onboarding="welcome">
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
|
||||
{t('dashboard.welcome', { name: user?.first_name || user?.username || '' })}
|
||||
</h1>
|
||||
<p className="mt-1 text-dark-400">{t('dashboard.yourSubscription')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Subscription Status - Main Card */}
|
||||
{subLoading ? (
|
||||
<div className="bento-card">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="skeleton h-6 w-24" />
|
||||
<div className="skeleton h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="skeleton mb-2 h-4 w-20" />
|
||||
<div className="skeleton h-5 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="skeleton h-2 w-full rounded-full" />
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<div className="skeleton h-10 w-full rounded-xl" />
|
||||
<div className="skeleton h-10 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
) : subscription ? (
|
||||
<div className="bento-card">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.status')}</h2>
|
||||
<span className={subscription.is_active ? 'badge-success' : 'badge-error'}>
|
||||
{subscription.is_active ? t('subscription.active') : t('subscription.expired')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.expiresAt')}</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{new Date(subscription.end_date).toLocaleDateString()}
|
||||
</div>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="skeleton h-6 w-24" />
|
||||
<div className="skeleton h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
|
||||
<button
|
||||
onClick={() => refreshTrafficMutation.mutate()}
|
||||
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
||||
className="rounded-full p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={
|
||||
trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')
|
||||
}
|
||||
>
|
||||
<RefreshIcon
|
||||
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} /{' '}
|
||||
{subscription.traffic_limit_gb || '∞'} GB
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="skeleton mb-2 h-4 w-20" />
|
||||
<div className="skeleton h-5 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
|
||||
<div className="font-medium text-dark-100">{subscription.device_limit}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.timeLeft')}</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{subscription.days_left > 0
|
||||
? t('subscription.days', { count: subscription.days_left })
|
||||
: `${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic Progress */}
|
||||
{subscription.traffic_limit_gb > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
|
||||
<span className="text-dark-300">
|
||||
{(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent).toFixed(
|
||||
1,
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
<div className="skeleton h-2 w-full rounded-full" />
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<div className="skeleton h-10 w-full rounded-linear" />
|
||||
<div className="skeleton h-10 w-full rounded-linear" />
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : subscription ? (
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.status')}</h2>
|
||||
<span className={subscription.is_active ? 'badge-success' : 'badge-error'}>
|
||||
{subscription.is_active ? t('subscription.active') : t('subscription.expired')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.expiresAt')}</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{new Date(subscription.end_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
|
||||
style={{
|
||||
width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
|
||||
<button
|
||||
onClick={() => refreshTrafficMutation.mutate()}
|
||||
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
||||
className="rounded-full p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={
|
||||
trafficRefreshCooldown > 0
|
||||
? `${trafficRefreshCooldown}s`
|
||||
: t('common.refresh')
|
||||
}
|
||||
>
|
||||
<RefreshIcon
|
||||
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} /{' '}
|
||||
{subscription.traffic_limit_gb || '∞'} GB
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
|
||||
<div className="font-medium text-dark-100">{subscription.device_limit}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.timeLeft')}</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{subscription.days_left > 0
|
||||
? t('subscription.days', { count: subscription.days_left })
|
||||
: `${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`mt-6 grid gap-3 ${subscription.subscription_url ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
>
|
||||
<Link to="/subscription" className="btn-primary py-2.5 text-center text-sm">
|
||||
{t('dashboard.viewSubscription')}
|
||||
</Link>
|
||||
{subscription.subscription_url && (
|
||||
<button
|
||||
onClick={() => setShowConnectionModal(true)}
|
||||
className="btn-secondary py-2.5 text-sm"
|
||||
data-onboarding="connect-devices"
|
||||
>
|
||||
{t('subscription.getConfig')}
|
||||
</button>
|
||||
{/* Traffic Progress */}
|
||||
{subscription.traffic_limit_gb > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
|
||||
<span className="text-dark-300">
|
||||
{(
|
||||
trafficData?.traffic_used_percent ?? subscription.traffic_used_percent
|
||||
).toFixed(1)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
|
||||
style={{
|
||||
width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-6 grid gap-3 ${subscription.subscription_url ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
className="text-center text-xs sm:text-sm"
|
||||
>
|
||||
<Link to="/subscription">{t('dashboard.viewSubscription')}</Link>
|
||||
</Button>
|
||||
{subscription.subscription_url && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
className="text-center text-xs sm:text-sm"
|
||||
onClick={() => setShowConnectionModal(true)}
|
||||
data-onboarding="connect-devices"
|
||||
>
|
||||
{t('subscription.getConfig')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="bento-grid">
|
||||
<motion.div variants={staggerItem} className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4">
|
||||
{/* Balance */}
|
||||
<Link to="/balance" className="bento-card-hover group" data-onboarding="balance">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('balance.currentBalance')}</span>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat-value text-accent-400">
|
||||
{formatAmount(balanceData?.balance_rubles || 0)}
|
||||
<span className="ml-1 text-lg text-dark-400">{currencySymbol}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Card interactive glow asChild>
|
||||
<Link to="/balance" data-onboarding="balance">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('balance.currentBalance')}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 text-dark-600 transition-colors group-hover:text-accent-400" />
|
||||
</div>
|
||||
<div className="stat-value text-accent-400">
|
||||
{formatAmount(balanceData?.balance_rubles || 0)}
|
||||
<span className="ml-1 text-lg text-dark-400">{currencySymbol}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
{/* Subscription */}
|
||||
<Link
|
||||
to="/subscription"
|
||||
className="bento-card-hover group"
|
||||
data-onboarding="subscription-status"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('subscription.title')}</span>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
{subLoading ? (
|
||||
<div className="skeleton h-8 w-24" />
|
||||
) : subscription ? (
|
||||
<div className="stat-value">
|
||||
{subscription.days_left > 0 ? (
|
||||
<>
|
||||
{subscription.days_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.days')}</span>
|
||||
</>
|
||||
) : subscription.hours_left > 0 ? (
|
||||
<>
|
||||
{subscription.hours_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.hours')}</span>
|
||||
</>
|
||||
) : subscription.minutes_left > 0 ? (
|
||||
<>
|
||||
{subscription.minutes_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.minutes')}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-error-400">{t('subscription.expired')}</span>
|
||||
)}
|
||||
<Card interactive glow asChild>
|
||||
<Link to="/subscription" data-onboarding="subscription-status">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('subscription.title')}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 text-dark-600 transition-colors group-hover:text-accent-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="stat-value text-error-400">{t('subscription.inactive')}</div>
|
||||
)}
|
||||
</Link>
|
||||
{subLoading ? (
|
||||
<div className="skeleton h-8 w-24" />
|
||||
) : subscription ? (
|
||||
<div className="stat-value">
|
||||
{subscription.days_left > 0 ? (
|
||||
<>
|
||||
{subscription.days_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.days')}</span>
|
||||
</>
|
||||
) : subscription.hours_left > 0 ? (
|
||||
<>
|
||||
{subscription.hours_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.hours')}</span>
|
||||
</>
|
||||
) : subscription.minutes_left > 0 ? (
|
||||
<>
|
||||
{subscription.minutes_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.minutes')}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-error-400">{t('subscription.expired')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="stat-value text-error-400">{t('subscription.inactive')}</div>
|
||||
)}
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
{/* Referrals */}
|
||||
<Link to="/referral" className="bento-card-hover group">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</span>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
{refLoading ? (
|
||||
<div className="skeleton h-8 w-16" />
|
||||
) : (
|
||||
<div className="stat-value">{referralInfo?.total_referrals || 0}</div>
|
||||
)}
|
||||
</Link>
|
||||
<Card interactive glow asChild>
|
||||
<Link to="/referral">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 text-dark-600 transition-colors group-hover:text-accent-400" />
|
||||
</div>
|
||||
{refLoading ? (
|
||||
<div className="skeleton h-8 w-16" />
|
||||
) : (
|
||||
<div className="stat-value">{referralInfo?.total_referrals || 0}</div>
|
||||
)}
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
{/* Earnings */}
|
||||
<Link to="/referral" className="bento-card-hover group">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</span>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
{refLoading ? (
|
||||
<div className="skeleton h-8 w-20" />
|
||||
) : (
|
||||
<div className="stat-value text-success-400">
|
||||
{formatPositive(referralInfo?.total_earnings_rubles || 0)}
|
||||
<Card interactive glow asChild>
|
||||
<Link to="/referral">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 text-dark-600 transition-colors group-hover:text-accent-400" />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
{refLoading ? (
|
||||
<div className="skeleton h-8 w-20" />
|
||||
) : (
|
||||
<div className="stat-value text-success-400">
|
||||
{formatPositive(referralInfo?.total_earnings_rubles || 0)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Trial Activation */}
|
||||
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
|
||||
<div className="bento-card-glow border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-accent-500/20">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{trialInfo.requires_payment
|
||||
? t('subscription.trial.titlePaid', 'Trial Subscription')
|
||||
: t('subscription.trial.title', 'Free Trial')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('subscription.trial.description', 'Try our VPN service for free!')}
|
||||
</p>
|
||||
|
||||
<div className="mb-6 flex gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{trialInfo.duration_days}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('subscription.trial.days')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{trialInfo.traffic_limit_gb || '∞'}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">GB</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{trialInfo.device_limit}</div>
|
||||
<div className="text-xs text-dark-500">{t('subscription.trial.devices')}</div>
|
||||
</div>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card
|
||||
className="border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent"
|
||||
glow
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-linear-lg bg-accent-500/20">
|
||||
<SparklesIcon className="h-6 w-6 text-accent-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{trialInfo.requires_payment
|
||||
? t('subscription.trial.titlePaid', 'Trial Subscription')
|
||||
: t('subscription.trial.title', 'Free Trial')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('subscription.trial.description', 'Try our VPN service for free!')}
|
||||
</p>
|
||||
|
||||
{trialInfo.requires_payment && trialInfo.price_rubles > 0 && (
|
||||
<div className="mb-4 space-y-2 rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('subscription.trial.price', 'Activation price')}:
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-accent-400">
|
||||
{trialInfo.price_rubles.toFixed(2)} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('balance.currentBalance', 'Your balance')}:
|
||||
</span>
|
||||
<span
|
||||
className={`text-lg font-semibold ${(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? 'text-success-400' : 'text-warning-400'}`}
|
||||
>
|
||||
{formatAmount(balanceData?.balance_rubles || 0)} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
{(balanceData?.balance_kopeks || 0) < trialInfo.price_kopeks && (
|
||||
<div className="pt-1 text-xs text-warning-400">
|
||||
{t(
|
||||
'subscription.trial.insufficientBalance',
|
||||
'Top up your balance to activate',
|
||||
)}
|
||||
<div className="mb-6 flex gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{trialInfo.duration_days}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-dark-500">{t('subscription.trial.days')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{trialInfo.traffic_limit_gb || '∞'}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">GB</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{trialInfo.device_limit}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('subscription.trial.devices')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trialError && (
|
||||
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{trialError}
|
||||
</div>
|
||||
)}
|
||||
{trialInfo.requires_payment && trialInfo.price_rubles > 0 && (
|
||||
<div className="mb-4 space-y-2 rounded-linear-lg bg-dark-800/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('subscription.trial.price', 'Activation price')}:
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-accent-400">
|
||||
{trialInfo.price_rubles.toFixed(2)} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('balance.currentBalance', 'Your balance')}:
|
||||
</span>
|
||||
<span
|
||||
className={`text-lg font-semibold ${(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? 'text-success-400' : 'text-warning-400'}`}
|
||||
>
|
||||
{formatAmount(balanceData?.balance_rubles || 0)} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
{(balanceData?.balance_kopeks || 0) < trialInfo.price_kopeks && (
|
||||
<div className="pt-1 text-xs text-warning-400">
|
||||
{t(
|
||||
'subscription.trial.insufficientBalance',
|
||||
'Top up your balance to activate',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trialInfo.requires_payment && trialInfo.price_kopeks > 0 ? (
|
||||
(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? (
|
||||
<button
|
||||
onClick={() => activateTrialMutation.mutate()}
|
||||
disabled={activateTrialMutation.isPending}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{activateTrialMutation.isPending
|
||||
? t('common.loading', 'Loading...')
|
||||
: t('subscription.trial.payAndActivate', 'Pay from Balance & Activate')}
|
||||
</button>
|
||||
{trialError && (
|
||||
<div className="mb-4 rounded-linear-lg border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{trialError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trialInfo.requires_payment && trialInfo.price_kopeks > 0 ? (
|
||||
(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => activateTrialMutation.mutate()}
|
||||
loading={activateTrialMutation.isPending}
|
||||
>
|
||||
{t('subscription.trial.payAndActivate', 'Pay from Balance & Activate')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild variant="primary" fullWidth>
|
||||
<Link to="/balance">
|
||||
{t('subscription.trial.topUpToActivate', 'Top Up Balance')}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Link to="/balance" className="btn-primary block w-full text-center">
|
||||
{t('subscription.trial.topUpToActivate', 'Top Up Balance')}
|
||||
</Link>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
onClick={() => activateTrialMutation.mutate()}
|
||||
disabled={activateTrialMutation.isPending}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{activateTrialMutation.isPending
|
||||
? t('common.loading', 'Loading...')
|
||||
: t('subscription.trial.activate', 'Activate Free Trial')}
|
||||
</button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => activateTrialMutation.mutate()}
|
||||
loading={activateTrialMutation.isPending}
|
||||
>
|
||||
{t('subscription.trial.activate', 'Activate Free Trial')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Promo Offers */}
|
||||
<PromoOffersSection />
|
||||
<motion.div variants={staggerItem}>
|
||||
<PromoOffersSection />
|
||||
</motion.div>
|
||||
|
||||
{/* Fortune Wheel Banner */}
|
||||
{wheelConfig?.is_enabled && (
|
||||
<Link to="/wheel" className="bento-card-hover group flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Emoji */}
|
||||
<span className="text-3xl">🎰</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-dark-100">{t('wheel.banner.title')}</h3>
|
||||
<p className="text-sm text-dark-400">{t('wheel.banner.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-dark-500 transition-all duration-300 group-hover:translate-x-1 group-hover:text-accent-400">
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
</Link>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card interactive asChild>
|
||||
<Link to="/wheel" className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-3xl">🎰</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-dark-100">
|
||||
{t('wheel.banner.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-dark-400">{t('wheel.banner.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon className="h-5 w-5 flex-shrink-0 text-dark-500 transition-all duration-300 group-hover:translate-x-1 group-hover:text-accent-400" />
|
||||
</Link>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bento-card" data-onboarding="quick-actions">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">{t('dashboard.quickActions')}</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Link to="/balance" className="btn-secondary justify-center py-2.5 text-center text-sm">
|
||||
{t('dashboard.topUpBalance')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/subscription"
|
||||
state={{ scrollToExtend: true }}
|
||||
className="btn-secondary justify-center py-2.5 text-center text-sm"
|
||||
>
|
||||
{t('subscription.renew')}
|
||||
</Link>
|
||||
<Link to="/referral" className="btn-secondary justify-center py-2.5 text-center text-sm">
|
||||
{t('dashboard.inviteFriends')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/support"
|
||||
className="btn-secondary flex items-center justify-center gap-2 py-2.5 text-center text-sm"
|
||||
>
|
||||
<SupportLottieIcon />
|
||||
<span>{t('dashboard.getSupport')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Modal */}
|
||||
{showConnectionModal && <ConnectionModal onClose={() => setShowConnectionModal(false)} />}
|
||||
|
||||
@@ -644,6 +640,6 @@ export default function Dashboard() {
|
||||
onSkip={handleOnboardingComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { authApi } from '../api/auth';
|
||||
import {
|
||||
@@ -12,6 +13,10 @@ import {
|
||||
import { referralApi } from '../api/referral';
|
||||
import { brandingApi, type EmailAuthEnabled } from '../api/branding';
|
||||
import ChangeEmailModal from '../components/ChangeEmailModal';
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { Switch } from '@/components/primitives/Switch';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
|
||||
// Icons
|
||||
const CopyIcon = () => (
|
||||
@@ -191,7 +196,6 @@ export default function Profile() {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
// Валидация email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!email.trim() || !emailRegex.test(email.trim())) {
|
||||
setError(t('profile.invalidEmail', 'Please enter a valid email address'));
|
||||
@@ -212,433 +216,376 @@ export default function Profile() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('profile.title')}</h1>
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div variants={staggerItem}>
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('profile.title')}</h1>
|
||||
</motion.div>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">{t('profile.accountInfo')}</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">{t('profile.telegramId')}</span>
|
||||
<span className="font-medium text-dark-100">{user?.telegram_id}</span>
|
||||
</div>
|
||||
{user?.username && (
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">{t('profile.accountInfo')}</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">{t('profile.username')}</span>
|
||||
<span className="font-medium text-dark-100">@{user.username}</span>
|
||||
<span className="text-dark-400">{t('profile.telegramId')}</span>
|
||||
<span className="font-medium text-dark-100">{user?.telegram_id}</span>
|
||||
</div>
|
||||
{user?.username && (
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">{t('profile.username')}</span>
|
||||
<span className="font-medium text-dark-100">@{user.username}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">{t('profile.name')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{user?.first_name} {user?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-dark-400">{t('profile.registeredAt')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">{t('profile.name')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{user?.first_name} {user?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-dark-400">{t('profile.registeredAt')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Referral Link Widget */}
|
||||
{referralTerms?.is_enabled && referralLink && (
|
||||
<div className="bento-card">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('referral.yourLink')}</h2>
|
||||
<Link
|
||||
to="/referral"
|
||||
className="flex items-center gap-1 text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
<span className="text-sm">{t('referral.title')}</span>
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<input type="text" readOnly value={referralLink} className="input w-full text-sm" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyReferralLink}
|
||||
className={`btn-primary flex items-center gap-2 px-4 py-2 text-sm ${
|
||||
copied ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('referral.yourLink')}</h2>
|
||||
<Link
|
||||
to="/referral"
|
||||
className="flex items-center gap-1 text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
<span>{copied ? t('referral.copied') : t('referral.copyLink')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={shareReferralLink}
|
||||
className="btn-secondary flex items-center gap-2 px-4 py-2 text-sm"
|
||||
>
|
||||
<ShareIcon />
|
||||
<span className="hidden sm:inline">{t('referral.shareButton')}</span>
|
||||
</button>
|
||||
<span className="text-sm">{t('referral.title')}</span>
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-dark-500">
|
||||
{t('referral.shareHint', { percent: referralInfo?.commission_percent || 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<input type="text" readOnly value={referralLink} className="input w-full text-sm" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={copyReferralLink}
|
||||
variant={copied ? 'primary' : 'primary'}
|
||||
className={copied ? 'bg-success-500 hover:bg-success-500' : ''}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
<span className="ml-2">
|
||||
{copied ? t('referral.copied') : t('referral.copyLink')}
|
||||
</span>
|
||||
</Button>
|
||||
<Button onClick={shareReferralLink} variant="secondary">
|
||||
<ShareIcon />
|
||||
<span className="ml-2 hidden sm:inline">{t('referral.shareButton')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-dark-500">
|
||||
{t('referral.shareHint', { percent: referralInfo?.commission_percent || 0 })}
|
||||
</p>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Email Section - only show when email auth is enabled */}
|
||||
{isEmailAuthEnabled && (
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">{t('profile.emailAuth')}</h2>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">{t('profile.emailAuth')}</h2>
|
||||
|
||||
{user?.email ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">Email</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-dark-100">{user.email}</span>
|
||||
{user.email_verified ? (
|
||||
<span className="badge-success">{t('profile.verified')}</span>
|
||||
) : (
|
||||
<span className="badge-warning">{t('profile.notVerified')}</span>
|
||||
)}
|
||||
{user?.email ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">Email</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-dark-100">{user.email}</span>
|
||||
{user.email_verified ? (
|
||||
<span className="badge-success">{t('profile.verified')}</span>
|
||||
) : (
|
||||
<span className="badge-warning">{t('profile.notVerified')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!user.email_verified && (
|
||||
<div className="rounded-linear border border-warning-500/30 bg-warning-500/10 p-4">
|
||||
<p className="mb-4 text-sm text-warning-400">
|
||||
{t('profile.verificationRequired')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => resendVerificationMutation.mutate()}
|
||||
loading={resendVerificationMutation.isPending}
|
||||
>
|
||||
{t('profile.resendVerification')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.email_verified && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
|
||||
<button
|
||||
onClick={() => setShowChangeEmailModal(true)}
|
||||
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
<PencilIcon />
|
||||
<span>{t('profile.changeEmail.button')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-dark-400">{t('profile.linkEmailDescription')}</p>
|
||||
|
||||
{!user.email_verified && (
|
||||
<div className="rounded-xl border border-warning-500/30 bg-warning-500/10 p-4">
|
||||
<p className="mb-4 text-sm text-warning-400">
|
||||
{t('profile.verificationRequired')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => resendVerificationMutation.mutate()}
|
||||
disabled={resendVerificationMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{resendVerificationMutation.isPending
|
||||
? t('common.loading')
|
||||
: t('profile.resendVerification')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{user.email_verified && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
|
||||
<button
|
||||
onClick={() => setShowChangeEmailModal(true)}
|
||||
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
<PencilIcon />
|
||||
<span>{t('profile.changeEmail.button')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-dark-400">{t('profile.linkEmailDescription')}</p>
|
||||
<div>
|
||||
<label className="label">{t('auth.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('profile.passwordPlaceholder')}
|
||||
className="input"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-dark-500">{t('profile.passwordHint')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">{t('auth.confirmPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('profile.confirmPasswordPlaceholder')}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">{t('auth.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('profile.passwordPlaceholder')}
|
||||
className="input"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-dark-500">{t('profile.passwordHint')}</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="rounded-linear border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">{t('auth.confirmPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('profile.confirmPasswordPlaceholder')}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
{success && (
|
||||
<div className="rounded-linear border border-success-500/30 bg-success-500/10 p-4 text-sm text-success-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" fullWidth loading={registerEmailMutation.isPending}>
|
||||
{t('profile.linkEmail')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || success) && user?.email && (
|
||||
<div className="mt-4">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||
<div className="rounded-linear border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-sm text-success-400">
|
||||
<div className="rounded-linear border border-success-500/30 bg-success-500/10 p-4 text-sm text-success-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={registerEmailMutation.isPending}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{registerEmailMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
t('profile.linkEmail')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || success) && user?.email && (
|
||||
<div className="mt-4">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-sm text-success-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Notification Settings */}
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">
|
||||
{t('profile.notifications.title')}
|
||||
</h2>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">
|
||||
{t('profile.notifications.title')}
|
||||
</h2>
|
||||
|
||||
{notificationsLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : notificationSettings ? (
|
||||
<div className="space-y-6">
|
||||
{/* Subscription Expiry */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.subscriptionExpiry')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.subscriptionExpiryDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleNotificationToggle(
|
||||
'subscription_expiry_enabled',
|
||||
!notificationSettings.subscription_expiry_enabled,
|
||||
)
|
||||
}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
notificationSettings.subscription_expiry_enabled
|
||||
? 'bg-accent-500'
|
||||
: 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
notificationSettings.subscription_expiry_enabled ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{notificationSettings.subscription_expiry_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.daysBeforeExpiry')}
|
||||
</span>
|
||||
<select
|
||||
value={notificationSettings.subscription_expiry_days}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('subscription_expiry_days', Number(e.target.value))
|
||||
}
|
||||
className="input w-20 py-1"
|
||||
>
|
||||
{[1, 2, 3, 5, 7, 14].map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{notificationsLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Traffic Warning */}
|
||||
<div className="space-y-3 border-t border-dark-800/50 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.trafficWarning')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.trafficWarningDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleNotificationToggle(
|
||||
'traffic_warning_enabled',
|
||||
!notificationSettings.traffic_warning_enabled,
|
||||
)
|
||||
}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
notificationSettings.traffic_warning_enabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
notificationSettings.traffic_warning_enabled ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{notificationSettings.traffic_warning_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.atPercent')}
|
||||
</span>
|
||||
<select
|
||||
value={notificationSettings.traffic_warning_percent}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('traffic_warning_percent', Number(e.target.value))
|
||||
) : notificationSettings ? (
|
||||
<div className="space-y-6">
|
||||
{/* Subscription Expiry */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.subscriptionExpiry')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.subscriptionExpiryDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.subscription_expiry_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationToggle('subscription_expiry_enabled', checked)
|
||||
}
|
||||
className="input w-20 py-1"
|
||||
>
|
||||
{[50, 70, 80, 90, 95].map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}%
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Balance Low */}
|
||||
<div className="space-y-3 border-t border-dark-800/50 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.balanceLow')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.balanceLowDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleNotificationToggle(
|
||||
'balance_low_enabled',
|
||||
!notificationSettings.balance_low_enabled,
|
||||
)
|
||||
}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
notificationSettings.balance_low_enabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
notificationSettings.balance_low_enabled ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{notificationSettings.subscription_expiry_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.daysBeforeExpiry')}
|
||||
</span>
|
||||
<select
|
||||
value={notificationSettings.subscription_expiry_days}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('subscription_expiry_days', Number(e.target.value))
|
||||
}
|
||||
className="input w-20 py-1"
|
||||
>
|
||||
{[1, 2, 3, 5, 7, 14].map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{notificationSettings.balance_low_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.threshold')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={notificationSettings.balance_low_threshold}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('balance_low_threshold', Number(e.target.value))
|
||||
|
||||
{/* Traffic Warning */}
|
||||
<div className="space-y-3 border-t border-dark-800/50 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.trafficWarning')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.trafficWarningDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.traffic_warning_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationToggle('traffic_warning_enabled', checked)
|
||||
}
|
||||
min={0}
|
||||
className="input w-24 py-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* News */}
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">{t('profile.notifications.news')}</p>
|
||||
<p className="text-sm text-dark-400">{t('profile.notifications.newsDesc')}</p>
|
||||
{notificationSettings.traffic_warning_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.atPercent')}
|
||||
</span>
|
||||
<select
|
||||
value={notificationSettings.traffic_warning_percent}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('traffic_warning_percent', Number(e.target.value))
|
||||
}
|
||||
className="input w-20 py-1"
|
||||
>
|
||||
{[50, 70, 80, 90, 95].map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}%
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleNotificationToggle('news_enabled', !notificationSettings.news_enabled)
|
||||
}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
notificationSettings.news_enabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
notificationSettings.news_enabled ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
|
||||
{/* Balance Low */}
|
||||
<div className="space-y-3 border-t border-dark-800/50 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.balanceLow')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.balanceLowDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.balance_low_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationToggle('balance_low_enabled', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{notificationSettings.balance_low_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.threshold')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={notificationSettings.balance_low_threshold}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('balance_low_threshold', Number(e.target.value))
|
||||
}
|
||||
min={0}
|
||||
className="input w-24 py-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* News */}
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">{t('profile.notifications.news')}</p>
|
||||
<p className="text-sm text-dark-400">{t('profile.notifications.newsDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.news_enabled}
|
||||
onCheckedChange={(checked) => handleNotificationToggle('news_enabled', checked)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Promo Offers */}
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.promoOffers')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.promoOffersDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleNotificationToggle(
|
||||
'promo_offers_enabled',
|
||||
!notificationSettings.promo_offers_enabled,
|
||||
)
|
||||
}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
notificationSettings.promo_offers_enabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
notificationSettings.promo_offers_enabled ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
|
||||
{/* Promo Offers */}
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.promoOffers')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.promoOffersDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.promo_offers_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationToggle('promo_offers_enabled', checked)
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-dark-400">{t('profile.notifications.unavailable')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-dark-400">{t('profile.notifications.unavailable')}</p>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Change Email Modal */}
|
||||
{showChangeEmailModal && user?.email && (
|
||||
@@ -647,6 +594,6 @@ export default function Profile() {
|
||||
currentEmail={user.email}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,8 +138,8 @@ export default function Referral() {
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('referral.title')}</h1>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="bento-grid">
|
||||
<div className="bento-card-hover">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 md:gap-4">
|
||||
<div className="bento-card-hover col-span-2 md:col-span-1">
|
||||
<div className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</div>
|
||||
<div className="stat-value mt-1">{info?.total_referrals || 0}</div>
|
||||
<div className="mt-1 text-sm text-dark-500">
|
||||
@@ -152,7 +152,7 @@ export default function Referral() {
|
||||
{formatPositive(info?.total_earnings_rubles || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bento-card-hover col-span-2 sm:col-span-1">
|
||||
<div className="bento-card-hover">
|
||||
<div className="text-sm text-dark-400">{t('referral.stats.commissionRate')}</div>
|
||||
<div className="stat-value mt-1 text-accent-400">{info?.commission_percent || 0}%</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { AxiosError } from 'axios';
|
||||
import { motion } from 'framer-motion';
|
||||
import { subscriptionApi } from '../api/subscription';
|
||||
import { promoApi } from '../api/promo';
|
||||
import type {
|
||||
@@ -17,6 +18,8 @@ import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
||||
import i18n from '../i18n';
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
|
||||
// Helper to extract error message from axios/api errors
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
@@ -596,227 +599,213 @@ export default function Subscription() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('subscription.title')}</h1>
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div variants={staggerItem}>
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('subscription.title')}</h1>
|
||||
</motion.div>
|
||||
|
||||
{/* Current Subscription */}
|
||||
{subscription ? (
|
||||
<div className="bento-card">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('subscription.currentPlan')}
|
||||
</h2>
|
||||
{subscription.tariff_name && (
|
||||
<div className="mt-1 text-sm text-accent-400">{subscription.tariff_name}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
subscription.is_active
|
||||
? subscription.is_trial
|
||||
? 'badge-warning'
|
||||
: 'badge-success'
|
||||
: 'badge-error'
|
||||
}
|
||||
>
|
||||
{subscription.is_trial
|
||||
? t('subscription.trialStatus')
|
||||
: subscription.is_active
|
||||
? t('subscription.active')
|
||||
: t('subscription.expired')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connection Data - Top Priority */}
|
||||
{subscription.subscription_url && (
|
||||
<div className="mb-6 rounded-xl border border-accent-500/30 bg-accent-500/10 p-4">
|
||||
<div className="mb-3 font-medium text-dark-100">
|
||||
{t('subscription.connectionInfo')}
|
||||
</div>
|
||||
|
||||
{/* Get Config Button */}
|
||||
<button
|
||||
onClick={() => setShowConnectionModal(true)}
|
||||
className="btn-primary mb-3 flex w-full items-center justify-center gap-2 py-3"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
{t('subscription.getConfig')}
|
||||
</button>
|
||||
|
||||
{/* Subscription URL - hidden when hide_subscription_link is true */}
|
||||
{!subscription.hide_subscription_link && (
|
||||
<div className="flex gap-2">
|
||||
<code className="scrollbar-hide flex-1 overflow-x-auto break-all rounded-lg border border-dark-700/50 bg-dark-900/50 px-3 py-2 text-xs text-dark-300">
|
||||
{subscription.subscription_url}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyUrl}
|
||||
className={`btn-secondary px-3 ${copied ? 'border-success-500/30 text-success-400' : ''}`}
|
||||
title={t('subscription.copyLink')}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.daysLeft')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{subscription.days_left > 0 ? (
|
||||
t('subscription.days', { count: subscription.days_left })
|
||||
) : subscription.hours_left > 0 ? (
|
||||
`${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`
|
||||
) : subscription.minutes_left > 0 ? (
|
||||
t('subscription.minutes', { count: subscription.minutes_left })
|
||||
) : (
|
||||
<span className="text-error-400">{t('subscription.expired')}</span>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('subscription.currentPlan')}
|
||||
</h2>
|
||||
{subscription.tariff_name && (
|
||||
<div className="mt-1 text-sm text-accent-400">{subscription.tariff_name}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
subscription.is_active
|
||||
? subscription.is_trial
|
||||
? 'badge-warning'
|
||||
: 'badge-success'
|
||||
: 'badge-error'
|
||||
}
|
||||
>
|
||||
{subscription.is_trial
|
||||
? t('subscription.trialStatus')
|
||||
: subscription.is_active
|
||||
? t('subscription.active')
|
||||
: t('subscription.expired')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.expiresAt')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{new Date(subscription.end_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
|
||||
|
||||
{/* Connection Data - Top Priority */}
|
||||
{subscription.subscription_url && (
|
||||
<div className="mb-6 rounded-xl border border-accent-500/30 bg-accent-500/10 p-4">
|
||||
<div className="mb-3 font-medium text-dark-100">
|
||||
{t('subscription.connectionInfo')}
|
||||
</div>
|
||||
|
||||
{/* Get Config Button */}
|
||||
<button
|
||||
onClick={() => refreshTrafficMutation.mutate()}
|
||||
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
||||
className="rounded-full p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={
|
||||
trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')
|
||||
}
|
||||
onClick={() => setShowConnectionModal(true)}
|
||||
className="btn-primary mb-3 flex w-full items-center justify-center gap-2 py-3"
|
||||
>
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
{t('subscription.getConfig')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} /{' '}
|
||||
{subscription.traffic_limit_gb || '∞'} GB
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">{subscription.device_limit}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Servers */}
|
||||
{subscription.servers && subscription.servers.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 text-sm text-dark-500">{t('subscription.serversLabel')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.servers.map((server) => (
|
||||
<span
|
||||
key={server.uuid}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-dark-700/50 bg-dark-800/50 px-3 py-1.5 text-sm text-dark-200"
|
||||
>
|
||||
{server.country_code && (
|
||||
<span className="text-base">{getFlagEmoji(server.country_code)}</span>
|
||||
)}
|
||||
{server.name}
|
||||
</span>
|
||||
))}
|
||||
{/* Subscription URL - hidden when hide_subscription_link is true */}
|
||||
{!subscription.hide_subscription_link && (
|
||||
<div className="flex gap-2">
|
||||
<code className="scrollbar-hide flex-1 overflow-x-auto break-all rounded-lg border border-dark-700/50 bg-dark-900/50 px-3 py-2 text-xs text-dark-300">
|
||||
{subscription.subscription_url}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyUrl}
|
||||
className={`btn-secondary px-3 ${copied ? 'border-success-500/30 text-success-400' : ''}`}
|
||||
title={t('subscription.copyLink')}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Traffic Usage */}
|
||||
{subscription.traffic_limit_gb > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
|
||||
<span className="text-dark-300">
|
||||
{(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent).toFixed(
|
||||
1,
|
||||
<div className="mb-6 grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.daysLeft')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{subscription.days_left > 0 ? (
|
||||
t('subscription.days', { count: subscription.days_left })
|
||||
) : subscription.hours_left > 0 ? (
|
||||
`${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`
|
||||
) : subscription.minutes_left > 0 ? (
|
||||
t('subscription.minutes', { count: subscription.minutes_left })
|
||||
) : (
|
||||
<span className="text-error-400">{t('subscription.expired')}</span>
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
|
||||
style={{
|
||||
width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.expiresAt')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{new Date(subscription.end_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
|
||||
<button
|
||||
onClick={() => refreshTrafficMutation.mutate()}
|
||||
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
||||
className="rounded-full p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={
|
||||
trafficRefreshCooldown > 0
|
||||
? `${trafficRefreshCooldown}s`
|
||||
: t('common.refresh')
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} /{' '}
|
||||
{subscription.traffic_limit_gb || '∞'} GB
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{subscription.device_limit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Purchased Traffic Packages */}
|
||||
{subscription.traffic_purchases && subscription.traffic_purchases.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 text-sm text-dark-500">{t('subscription.purchasedTraffic')}</div>
|
||||
<div className="space-y-3">
|
||||
{subscription.traffic_purchases.map((purchase) => (
|
||||
{/* Servers */}
|
||||
{subscription.servers && subscription.servers.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 text-sm text-dark-500">{t('subscription.serversLabel')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.servers.map((server) => (
|
||||
<span
|
||||
key={server.uuid}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-dark-700/50 bg-dark-800/50 px-3 py-1.5 text-sm text-dark-200"
|
||||
>
|
||||
{server.country_code && (
|
||||
<span className="text-base">{getFlagEmoji(server.country_code)}</span>
|
||||
)}
|
||||
{server.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Traffic Usage */}
|
||||
{subscription.traffic_limit_gb > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
|
||||
<span className="text-dark-300">
|
||||
{(
|
||||
trafficData?.traffic_used_percent ?? subscription.traffic_used_percent
|
||||
).toFixed(1)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
key={purchase.id}
|
||||
className="rounded-lg border border-dark-700/50 bg-dark-800/50 p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-5 w-5 text-accent-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-base font-semibold text-dark-100">
|
||||
{purchase.traffic_gb} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="text-sm text-dark-400">
|
||||
{purchase.days_remaining === 0 ? (
|
||||
<span className="text-orange-500">{t('subscription.expired')}</span>
|
||||
) : (
|
||||
<span>
|
||||
{t('subscription.days', { count: purchase.days_remaining })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
|
||||
style={{
|
||||
width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Purchased Traffic Packages */}
|
||||
{subscription.traffic_purchases && subscription.traffic_purchases.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 text-sm text-dark-500">
|
||||
{t('subscription.purchasedTraffic')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{subscription.traffic_purchases.map((purchase) => (
|
||||
<div
|
||||
key={purchase.id}
|
||||
className="rounded-lg border border-dark-700/50 bg-dark-800/50 p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
className="h-5 w-5 text-accent-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -825,85 +814,118 @@ export default function Subscription() {
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{t('subscription.trafficResetAt')}:{' '}
|
||||
{new Date(purchase.expires_at).toLocaleDateString(undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})}
|
||||
<span className="text-base font-semibold text-dark-100">
|
||||
{purchase.traffic_gb} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="text-sm text-dark-400">
|
||||
{purchase.days_remaining === 0 ? (
|
||||
<span className="text-orange-500">{t('subscription.expired')}</span>
|
||||
) : (
|
||||
<span>
|
||||
{t('subscription.days', { count: purchase.days_remaining })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{t('subscription.trafficResetAt')}:{' '}
|
||||
{new Date(purchase.expires_at).toLocaleDateString(undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-1.5 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-accent-500 to-accent-600 transition-all duration-300"
|
||||
style={{ width: `${purchase.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-dark-500">
|
||||
<span>{new Date(purchase.created_at).toLocaleDateString()}</span>
|
||||
<span>{new Date(purchase.expires_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-1.5 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-accent-500 to-accent-600 transition-all duration-300"
|
||||
style={{ width: `${purchase.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-dark-500">
|
||||
<span>{new Date(purchase.created_at).toLocaleDateString()}</span>
|
||||
<span>{new Date(purchase.expires_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Autopay Toggle - hide for daily tariffs */}
|
||||
{!subscription.is_trial && !subscription.is_daily && (
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 py-4">
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">{t('subscription.autoRenewal')}</div>
|
||||
<div className="text-sm text-dark-500">
|
||||
{t('subscription.daysBeforeExpiry', { count: subscription.autopay_days_before })}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => autopayMutation.mutate(!subscription.autopay_enabled)}
|
||||
disabled={autopayMutation.isPending}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
subscription.autopay_enabled ? 'bg-accent-500' : 'bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
subscription.autopay_enabled ? 'translate-x-6' : 'translate-x-0'
|
||||
)}
|
||||
|
||||
{/* Autopay Toggle - hide for daily tariffs */}
|
||||
{!subscription.is_trial && !subscription.is_daily && (
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 py-4">
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">{t('subscription.autoRenewal')}</div>
|
||||
<div className="text-sm text-dark-500">
|
||||
{t('subscription.daysBeforeExpiry', {
|
||||
count: subscription.autopay_days_before,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => autopayMutation.mutate(!subscription.autopay_enabled)}
|
||||
disabled={autopayMutation.isPending}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
subscription.autopay_enabled ? 'bg-accent-500' : 'bg-dark-700'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<span
|
||||
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
subscription.autopay_enabled ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="card py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<svg
|
||||
className="h-8 w-8 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mb-4 text-dark-400">{t('subscription.noSubscription')}</div>
|
||||
</div>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card className="py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<svg
|
||||
className="h-8 w-8 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mb-4 text-dark-400">{t('subscription.noSubscription')}</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Daily Subscription Pause */}
|
||||
{subscription && subscription.is_daily && !subscription.is_trial && (
|
||||
<div className="bento-card">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
@@ -1021,12 +1043,12 @@ export default function Subscription() {
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Additional Options (Buy Devices) */}
|
||||
{subscription && subscription.is_active && !subscription.is_trial && (
|
||||
<div className="bento-card">
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('subscription.additionalOptions.title')}
|
||||
</h2>
|
||||
@@ -1764,12 +1786,12 @@ export default function Subscription() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* My Devices Section */}
|
||||
{subscription && (
|
||||
<div className="bento-card">
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.myDevices')}</h2>
|
||||
{devicesData && devicesData.devices.length > 0 && (
|
||||
@@ -1855,12 +1877,12 @@ export default function Subscription() {
|
||||
) : (
|
||||
<div className="py-8 text-center text-dark-400">{t('subscription.noDevices')}</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tariffs Section - Combined Purchase/Extend/Switch like MiniApp */}
|
||||
{isTariffsMode && tariffs.length > 0 && (
|
||||
<div ref={tariffsCardRef} className="bento-card">
|
||||
<Card ref={tariffsCardRef}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{subscription?.is_daily && !subscription?.is_trial
|
||||
@@ -2823,12 +2845,12 @@ export default function Subscription() {
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Purchase/Extend Section - Classic Mode */}
|
||||
{classicOptions && classicOptions.periods.length > 0 && (
|
||||
<div ref={tariffsCardRef} className="bento-card">
|
||||
<Card ref={tariffsCardRef}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{subscription && !subscription.is_trial
|
||||
@@ -3231,11 +3253,11 @@ export default function Subscription() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Connection Modal */}
|
||||
{showConnectionModal && <ConnectionModal onClose={() => setShowConnectionModal(false)} />}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ticketsApi } from '../api/tickets';
|
||||
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 { Card } from '@/components/data-display/Card';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
|
||||
const log = logger.createLogger('Support');
|
||||
|
||||
@@ -145,6 +150,7 @@ export default function Support() {
|
||||
log.debug('Component loaded');
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
@@ -303,7 +309,7 @@ export default function Support() {
|
||||
const supportUsername = supportConfig.support_username || '@support';
|
||||
log.debug('Opening profile:', supportUsername);
|
||||
return {
|
||||
title: t('support.ticketsDisabled'),
|
||||
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'),
|
||||
message: t('support.contactSupport', { username: supportUsername }),
|
||||
buttonText: t('support.contactUs'),
|
||||
buttonAction: () => {
|
||||
@@ -352,7 +358,7 @@ export default function Support() {
|
||||
|
||||
if (supportConfig.support_type === 'url' && supportConfig.support_url) {
|
||||
return {
|
||||
title: t('support.ticketsDisabled'),
|
||||
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'),
|
||||
message: t('support.useExternalLink'),
|
||||
buttonText: t('support.openSupport'),
|
||||
buttonAction: () => {
|
||||
@@ -370,7 +376,7 @@ export default function Support() {
|
||||
const supportUsername = supportConfig.support_username || '@support';
|
||||
log.debug('Fallback: Opening profile:', supportUsername);
|
||||
return {
|
||||
title: t('support.ticketsDisabled'),
|
||||
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'),
|
||||
message: t('support.contactSupport', { username: supportUsername }),
|
||||
buttonText: t('support.contactUs'),
|
||||
buttonAction: () => {
|
||||
@@ -403,7 +409,7 @@ export default function Support() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-12 max-w-md">
|
||||
<div className="bento-card text-center">
|
||||
<Card className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<svg
|
||||
className="h-8 w-8 text-dark-400"
|
||||
@@ -421,10 +427,10 @@ export default function Support() {
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-semibold text-dark-100">{supportMessage.title}</h2>
|
||||
<p className="mb-6 text-dark-400">{supportMessage.message}</p>
|
||||
<button onClick={supportMessage.buttonAction} className="btn-primary w-full">
|
||||
<Button onClick={supportMessage.buttonAction} fullWidth>
|
||||
{supportMessage.buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -462,25 +468,32 @@ export default function Support() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div
|
||||
variants={staggerItem}
|
||||
className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('support.title')}</h1>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCreateForm(true);
|
||||
setSelectedTicket(null);
|
||||
setCreateAttachment(null);
|
||||
}}
|
||||
className="btn-primary"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('support.newTicket')}
|
||||
</button>
|
||||
</div>
|
||||
<span className="ml-2">{t('support.newTicket')}</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<motion.div variants={staggerItem} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Tickets List */}
|
||||
<div className="bento-card lg:col-span-1">
|
||||
<Card className="lg:col-span-1">
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('support.yourTickets')}</h2>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -535,10 +548,10 @@ export default function Support() {
|
||||
<div className="text-dark-400">{t('support.noTickets')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Ticket Detail / Create Form */}
|
||||
<div className="bento-card lg:col-span-2">
|
||||
<Card className="lg:col-span-2">
|
||||
{showCreateForm ? (
|
||||
<div>
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">
|
||||
@@ -621,33 +634,24 @@ export default function Support() {
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || createAttachment?.uploading}
|
||||
className="btn-primary"
|
||||
disabled={createAttachment?.uploading}
|
||||
loading={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<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('support.creating')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<SendIcon />
|
||||
{t('support.send')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
<SendIcon />
|
||||
<span className="ml-2">{t('support.send')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setCreateAttachment(null);
|
||||
}}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -764,21 +768,13 @@ export default function Support() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
replyMutation.isPending ||
|
||||
!replyMessage.trim() ||
|
||||
replyAttachment?.uploading
|
||||
}
|
||||
className="btn-primary"
|
||||
disabled={!replyMessage.trim() || replyAttachment?.uploading}
|
||||
loading={replyMutation.isPending}
|
||||
>
|
||||
{replyMutation.isPending ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
) : (
|
||||
<SendIcon />
|
||||
)}
|
||||
</button>
|
||||
<SendIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{rateLimitError && (
|
||||
<div className="mt-2 rounded-lg border border-error-500/30 bg-error-500/10 p-2 text-sm text-error-400">
|
||||
@@ -815,8 +811,8 @@ export default function Support() {
|
||||
<div className="text-dark-400">{t('support.selectTicket')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user