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:
c0mrade
2026-01-31 22:06:36 +03:00
parent 929634aac4
commit b953ee0b8c
108 changed files with 11711 additions and 4542 deletions

View File

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

View 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

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
) : (

View File

@@ -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,
})}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

@@ -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)}%

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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