Files
app_umbrix/app/subscription/[token]/page.tsx

309 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { marzbanApi, type MarzbanUser } from '@/lib/marzban-api';
import {
Copy, ExternalLink, Database, Calendar, TrendingUp,
ChevronLeft, AlertCircle, Check
} from 'lucide-react';
// Haptic feedback helper
const haptic = {
impact: (style?: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft') => {
if (typeof window !== 'undefined') {
const tg = (window as any).Telegram?.WebApp;
if (tg?.HapticFeedback?.impactOccurred) {
tg.HapticFeedback.impactOccurred(style || 'medium');
}
}
},
notification: (type: 'success' | 'warning' | 'error') => {
if (typeof window !== 'undefined') {
const tg = (window as any).Telegram?.WebApp;
if (tg?.HapticFeedback?.notificationOccurred) {
tg.HapticFeedback.notificationOccurred(type);
}
}
},
selection: () => {
if (typeof window !== 'undefined') {
const tg = (window as any).Telegram?.WebApp;
if (tg?.HapticFeedback?.selectionChanged) {
tg.HapticFeedback.selectionChanged();
}
}
}
};
export default function SubscriptionPage() {
const params = useParams();
const router = useRouter();
const token = params.token as string;
const [user, setUser] = useState<MarzbanUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!token) {
setError('Токен не найден');
setLoading(false);
return;
}
fetchUserData();
}, [token]);
const fetchUserData = async () => {
setLoading(true);
setError(null);
try {
// ✅ Используем новый Marzban API (с fallback на HTML scraping)
const userData = await marzbanApi.getUserInfo(token);
setUser(userData);
} catch (err: any) {
console.error('Failed to fetch user data:', err);
setError(err.message || 'Не удалось загрузить данные подписки');
} finally {
setLoading(false);
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
haptic?.notification('success');
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
haptic?.notification('error');
}
};
const openInApp = () => {
haptic?.impact('medium');
const subscriptionUrl = `https://umbrix2.3to3.sbs/sub/${token}/`;
// Пробуем открыть через deep link
const deepLink = `umbrix://import/${subscriptionUrl}`;
// Создаем невидимый iframe для попытки открыть приложение
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = deepLink;
document.body.appendChild(iframe);
// Через 2 секунды проверяем - если приложение не открылось, показываем страницу установки
setTimeout(() => {
document.body.removeChild(iframe);
// Если всё ещё на странице (приложение не открылось), перенаправляем на /setup
if (document.hasFocus()) {
router.push('/setup');
}
}, 2000);
};
if (loading) {
return (
<div className="min-h-screen bg-[var(--bg-app)] flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[var(--primary)] mx-auto"></div>
<p className="mt-4 text-slate-400">Загрузка данных...</p>
</div>
</div>
);
}
if (error || !user) {
return (
<div className="min-h-screen bg-[var(--bg-app)] flex items-center justify-center p-4">
<div className="bg-[var(--bg-card)] border border-slate-700 rounded-lg p-6 max-w-md w-full">
<div className="flex flex-col items-center text-center">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Ошибка загрузки</h2>
<p className="text-red-400 mb-6">{error || 'Подписка не найдена'}</p>
<button
onClick={() => router.push('/')}
className="px-6 py-3 bg-[var(--primary)] hover:bg-[var(--primary)]/80 rounded-lg transition-colors text-white font-medium"
>
Вернуться назад
</button>
</div>
</div>
</div>
);
}
const usedTrafficGB = user.used_traffic / (1024 * 1024 * 1024);
const totalTrafficGB = user.data_limit ? user.data_limit / (1024 * 1024 * 1024) : null;
const trafficPercent = totalTrafficGB ? (usedTrafficGB / totalTrafficGB) * 100 : 0;
const daysRemaining = marzbanApi.getDaysUntilExpire(user.expire);
return (
<div className="min-h-screen bg-[var(--bg-app)] text-white">
{/* Header with Back Button */}
<div className="bg-[var(--bg-card)] border-b border-slate-700 sticky top-0 z-10 backdrop-blur-sm">
<div className="max-w-2xl mx-auto p-4 flex items-center gap-3">
<button
onClick={() => router.back()}
className="p-2 hover:bg-slate-700 rounded-lg transition-colors"
>
<ChevronLeft className="h-5 w-5" />
</button>
<h1 className="text-xl font-bold">Моя подписка</h1>
</div>
</div>
{/* Main Content */}
<div className="max-w-2xl mx-auto p-4 space-y-4">
{/* User Info Card */}
<div className="bg-[var(--bg-card)] border border-slate-700 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex-1">
<h2 className="text-2xl font-semibold mb-2">{user.username}</h2>
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium border ${marzbanApi.getStatusColor(
user.status,
daysRemaining
)}`}
>
{marzbanApi.getStatusText(user.status)}
</span>
</div>
</div>
{/* Stats Grid */}
<div className="space-y-4 mt-6">
{/* Traffic Usage */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-400 flex items-center gap-2">
<Database className="h-4 w-4" />
Использовано трафика
</span>
<span className="font-medium">
{usedTrafficGB.toFixed(2)} GB
{totalTrafficGB && ` / ${totalTrafficGB.toFixed(0)} GB`}
</span>
</div>
{/* Progress Bar */}
{totalTrafficGB && (
<div className="w-full bg-slate-700 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all duration-300 ${
trafficPercent > 90
? 'bg-red-500'
: trafficPercent > 70
? 'bg-yellow-500'
: 'bg-[var(--primary)]'
}`}
style={{ width: `${Math.min(trafficPercent, 100)}%` }}
></div>
</div>
)}
{totalTrafficGB && (
<p className="text-xs text-slate-400">
Осталось: {(totalTrafficGB - usedTrafficGB).toFixed(2)} GB (
{(100 - trafficPercent).toFixed(1)}%)
</p>
)}
</div>
{/* Expiry Date */}
<div className="flex items-center justify-between text-sm pt-4 border-t border-slate-700">
<span className="text-slate-400 flex items-center gap-2">
<Calendar className="h-4 w-4" />
Действует до
</span>
<div className="text-right">
<p className="font-medium">{marzbanApi.formatExpireDate(user.expire)}</p>
{daysRemaining !== null && daysRemaining <= 30 && (
<p
className={`text-xs ${
daysRemaining <= 7 ? 'text-red-400' : 'text-yellow-400'
}`}
>
Осталось {daysRemaining} {daysRemaining === 1 ? 'день' : 'дней'}
</p>
)}
</div>
</div>
{/* Last Updated */}
<div className="flex items-center justify-between text-sm pt-2 border-t border-slate-700">
<span className="text-slate-400 flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Обновлено
</span>
<span className="font-medium text-slate-300">
{new Date(user.sub_updated_at).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="space-y-3">
<button
onClick={() => copyToClipboard(`https://umbrix2.3to3.sbs/sub/${token}/`)}
className="w-full bg-[var(--bg-card)] hover:bg-[var(--bg-elevated)] border border-slate-700 rounded-lg p-4 flex items-center justify-between transition-colors"
>
<span className="flex items-center gap-3">
{copied ? (
<>
<Check className="h-5 w-5 text-green-500" />
<span className="font-medium text-green-500">Скопировано!</span>
</>
) : (
<>
<Copy className="h-5 w-5 text-[var(--primary)]" />
<span className="font-medium">Скопировать ссылку подписки</span>
</>
)}
</span>
</button>
<button
onClick={openInApp}
className="w-full bg-[var(--primary)] hover:bg-[var(--primary)]/80 rounded-lg p-4 flex items-center justify-center gap-3 transition-colors font-medium"
>
<ExternalLink className="h-5 w-5" />
Открыть в Umbrix
</button>
</div>
{/* Info Card */}
<div className="bg-[var(--bg-card)] border border-slate-700 rounded-lg p-4">
<p className="text-sm text-slate-400 text-center">
💡 Скопируйте ссылку и вставьте её в приложение Umbrix для подключения к VPN
</p>
</div>
{/* Refresh Button */}
<button
onClick={() => {
marzbanApi.clearCache();
fetchUserData();
}}
className="w-full bg-slate-800/50 hover:bg-slate-700/50 rounded-lg p-3 text-sm text-slate-400 hover:text-white transition-colors"
>
🔄 Обновить данные
</button>
</div>
</div>
);
}