Files
app_umbrix/app/page.tsx

661 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// Главная страница приложения (Home Page)
// Показывает статус подписки пользователя и кнопки действий
// URL: https://app.umbrix.net/
'use client'; // Next.js 13: это клиентский компонент (с useState, useEffect)
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import QRCodeModal from '@/components/QRCodeModal';
import ReferralModal from '@/components/ReferralModal';
import SetupWizard from '@/components/SetupWizard';
import { marzbanApi } from '@/lib/marzban-api';
import { getSubscriptionUrl, MARZBAN_SUBSCRIPTION_URL } from '@/lib/constants';
import {
Shield,
Settings,
Gift,
DollarSign,
Wrench,
User,
HelpCircle,
ExternalLink,
X,
Key,
CreditCard,
Lock,
Eye,
Ban,
MessageCircle,
UserPlus,
QrCode,
Copy,
ChevronRight,
Share2,
} from 'lucide-react';
export default function Home() {
const router = useRouter();
const [hasSubscription, setHasSubscription] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isKeyMenuOpen, setIsKeyMenuOpen] = useState(false);
const [isQROpen, setIsQROpen] = useState(false);
const [isReferralOpen, setIsReferralOpen] = useState(false);
const [isSetupWizardOpen, setIsSetupWizardOpen] = useState(false);
const [subscriptionStatus, setSubscriptionStatus] = useState<'active' | 'expired' | 'none'>('none');
const [expiryDate, setExpiryDate] = useState<string>('');
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [subscriptionToken, setSubscriptionToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const subscriptionUrl = subscriptionToken ? getSubscriptionUrl(subscriptionToken) : MARZBAN_SUBSCRIPTION_URL;
const showToastNotification = (message: string) => {
setToastMessage(message);
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
};
// Загружаем подписку при монтировании - ЧЕРЕЗ API, НЕ localStorage!
useEffect(() => {
const loadSubscription = async () => {
// Получаем Telegram данные
const telegramWebApp = (window as any).Telegram?.WebApp;
const telegramId = telegramWebApp?.initDataUnsafe?.user?.id;
const telegramUsername = telegramWebApp?.initDataUnsafe?.user?.username;
console.log('🔍 Loading subscription for:', { telegramId, telegramUsername });
if (!telegramId && !telegramUsername) {
console.log('⚠️ No Telegram data - probably opened in browser');
setIsLoading(false);
return;
}
try {
// Запрашиваем подписку по Telegram ID через API
const params = new URLSearchParams();
if (telegramId) params.append('telegramId', telegramId.toString());
if (telegramUsername) params.append('telegramUsername', telegramUsername);
const response = await fetch(`/api/user-subscription?${params.toString()}`);
const data = await response.json();
console.log('📥 API response:', data);
if (data.success && data.hasSubscription) {
setSubscriptionToken(data.token);
setHasSubscription(true);
const status = data.status === 'active' ? 'active' : 'expired';
setSubscriptionStatus(status);
if (data.expire) {
setExpiryDate(marzbanApi.formatExpireDate(data.expire));
}
} else {
console.log('❌ No subscription found');
}
} catch (error) {
console.error('Failed to load subscription:', error);
}
setIsLoading(false);
};
loadSubscription();
}, []); // Загружаем при каждом монтировании компонента
// ОТКЛЮЧАЕМ автоматическую проверку API при загрузке (чтобы не зависала страница)
// API проверка будет только на странице /subscription/[token]
// useEffect(() => {
// async function checkSubscription() {
// try {
// const userData = await marzbanApi.getUserInfo(subscriptionToken);
//
// if (userData.status === 'active') {
// setHasSubscription(true);
// setSubscriptionStatus('active');
// setExpiryDate(marzbanApi.formatExpireDate(userData.expire));
// } else if (userData.status === 'expired') {
// setHasSubscription(false);
// setSubscriptionStatus('expired');
// setExpiryDate(marzbanApi.formatExpireDate(userData.expire));
// } else {
// setHasSubscription(false);
// setSubscriptionStatus('none');
// }
// } catch (error) {
// console.error('Failed to check subscription:', error);
// setHasSubscription(false);
// setSubscriptionStatus('none');
// }
// }
//
// checkSubscription();
// }, []);
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showToastNotification('✅ Скопировано в буфер обмена!');
} catch (err) {
console.error('Failed to copy:', err);
showToastNotification('❌ Ошибка копирования');
}
};
const shareReferralLink = async () => {
if (!subscriptionToken) {
showToastNotification('Сначала активируйте подписку');
return;
}
// Генерируем реферальную ссылку — используем полный subscriptionToken (= Marzban username)
const userId = subscriptionToken;
const botUsername = process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME || 'Dorod_vps_bot';
const referralUrl = `https://t.me/${botUsername}?start=ref_${userId}`;
const shareText = `🚀 Попробуй Umbrix VPN - быстрый и безопасный VPN!\n\n✨ Получи 7 дней бесплатно по моей ссылке:\n${referralUrl}`;
// Проверяем поддержку Web Share API
if (navigator.share) {
try {
await navigator.share({
title: 'Umbrix VPN - Пригласи друга!',
text: shareText,
url: referralUrl,
});
} catch (err) {
// Пользователь отменил или ошибка
if ((err as Error).name !== 'AbortError') {
console.error('Share failed:', err);
// Fallback: копируем в буфер
copyToClipboard(shareText);
}
}
} else {
// Fallback для браузеров без поддержки Share API
copyToClipboard(shareText);
}
};
const handleActivateTrial = async () => {
setIsLoading(true);
try {
// Получаем referrerId из URL query ИЛИ из Telegram start_param
const urlParams = new URLSearchParams(window.location.search);
let referrerId = urlParams.get('ref');
// Также проверяем Telegram start_param (приоритетнее)
const telegramWebApp = (window as any).Telegram?.WebApp;
const startParam = telegramWebApp?.initDataUnsafe?.start_param;
if (startParam && startParam.startsWith('ref_')) {
referrerId = startParam.replace('ref_', '');
}
// Получаем реальные данные Telegram
const tgUser = telegramWebApp?.initDataUnsafe?.user;
// Создаем trial подписку через API
const response = await fetch('/api/create-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
planType: 'trial',
telegramId: tgUser?.id || Date.now(),
telegramUsername: tgUser?.username || undefined,
firstName: tgUser?.first_name || undefined,
referrerId: referrerId || undefined,
}),
});
const data = await response.json();
if (data.success) {
// Сохраняем токен
setSubscriptionToken(data.token);
setHasSubscription(true);
setSubscriptionStatus('active');
setExpiryDate(data.expiryDate);
localStorage.setItem('subscriptionToken', data.token);
// Открываем Setup Wizard
setIsSetupWizardOpen(true);
} else {
showToastNotification('❌ Ошибка создания подписки');
}
} catch (error) {
console.error('Failed to activate trial:', error);
showToastNotification('❌ Ошибка активации');
} finally {
setIsLoading(false);
}
};
return (
<div
className="min-h-screen flex flex-col"
style={{ background: 'var(--bg-app)' }}
>
{/* Header */}
<header
className="flex items-center justify-between p-4 border-b sticky top-0 z-40 backdrop-blur-sm"
style={{ borderColor: 'var(--border)', background: 'var(--bg-card)' }}
>
<div className="flex items-center gap-2">
<Shield className="w-6 h-6" style={{ color: 'var(--primary)' }} />
<span
className="text-xl font-bold"
style={{ color: 'var(--text-white)' }}
>
Umbrix
</span>
</div>
<button
className="p-2 rounded-lg"
style={{ background: 'var(--bg-card)' }}
>
<Settings
className="w-5 h-5"
style={{ color: 'var(--text-primary)' }}
/>
</button>
</header>
{/* Бегущая строка с преимуществами */}
<div
className="w-full overflow-hidden border-b py-2"
style={{ borderColor: 'var(--border)', background: 'var(--bg-card)' }}
>
<div className="animate-marquee whitespace-nowrap flex items-center gap-8 text-sm">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" style={{ color: 'var(--primary)' }} />
<span className="text-slate-300">Без логов</span>
</div>
<div className="flex items-center gap-2">
<Lock className="w-4 h-4" style={{ color: 'var(--primary)' }} />
<span className="text-slate-300">Без регистрации</span>
</div>
<div className="flex items-center gap-2">
<Eye className="w-4 h-4 line-through opacity-50" style={{ color: 'var(--primary)' }} />
<span className="text-slate-300">Не отслеживаем</span>
</div>
<div className="flex items-center gap-2">
<Ban className="w-4 h-4" style={{ color: 'var(--primary)' }} />
<span className="text-slate-300">Не продаём ваш трафик</span>
</div>
{/* Дублируем для бесшовной анимации */}
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" style={{ color: 'var(--primary)' }} />
<span className="text-slate-300">Без логов</span>
</div>
<div className="flex items-center gap-2">
<Lock className="w-4 h-4" style={{ color: 'var(--primary)' }} />
<span className="text-slate-300">Без регистрации</span>
</div>
<div className="flex items-center gap-2">
<Eye className="w-4 h-4 line-through opacity-50" style={{ color: 'var(--primary)' }} />
<span className="text-slate-300">Не отслеживаем</span>
</div>
<div className="flex items-center gap-2">
<Ban className="w-4 h-4" style={{ color: 'var(--primary)' }} />
<span className="text-slate-300">Не продаём ваш трафик</span>
</div>
</div>
</div>
{/* Main Content */}
<main className="flex-1 flex flex-col items-center justify-center px-6 py-8">
{/* Hero Section - Логотип и статус */}
<div className="text-center mb-8">
<div className="text-6xl mb-4">🛡</div>
<h1 className="text-2xl font-bold mb-2">Umbrix VPN</h1>
<p className="text-sm opacity-70">Быстрый и безопасный VPN</p>
</div>
{/* Status */}
<div className="text-center mb-8">
{hasSubscription ? (
<Link
href={`/subscription/${subscriptionToken}`}
className="text-sm opacity-70 hover:opacity-100 transition-opacity cursor-pointer inline-block"
>
Моя подписка Активна до {expiryDate || '...'}
</Link>
) : subscriptionStatus === 'expired' ? (
<div className="text-sm opacity-70 mb-1">
Подписка истекла {expiryDate}
</div>
) : (
<div className="text-sm opacity-70 mb-1">
Нет подписки
</div>
)}
</div>
{/* Action Buttons */}
<div className="w-full max-w-md space-y-4">
{/* Показываем trial только если нет активной подписки */}
{!hasSubscription && (
<ActionButton
icon={<Gift className="w-5 h-5" />}
text={isLoading ? "Активация..." : "Попробовать 7 дней бесплатно"}
onClick={handleActivateTrial}
/>
)}
<ActionButton
icon={<DollarSign className="w-5 h-5" />}
text="Купить подписку от 99₽"
onClick={() => (window.location.href = '/plans')}
/>
</div>
</main>
{/* Bottom Navigation */}
<nav
className="border-t"
style={{ borderColor: 'var(--border)', background: 'var(--bg-card)' }}
>
<div className="flex items-center justify-around py-3">
<NavButton
icon={<User className="w-6 h-6" />}
label="Аккаунт"
onClick={() => setIsMenuOpen(true)}
/>
<NavButton
icon={<HelpCircle className="w-6 h-6" />}
label="Помощь"
href="/help"
/>
</div>
</nav>
{/* Account Menu Modal */}
{isMenuOpen && (
<div
className="fixed inset-0 z-50 flex items-end"
style={{ background: 'rgba(0, 0, 0, 0.5)' }}
onClick={() => setIsMenuOpen(false)}
>
<div
className="w-full rounded-t-3xl p-6 pb-8"
style={{ background: 'var(--bg-card)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold" style={{ color: 'var(--text-white)' }}>
Меню аккаунта
</h2>
<button
onClick={() => setIsMenuOpen(false)}
className="p-2 rounded-lg hover:opacity-70 transition-opacity"
style={{ background: 'var(--bg-elevated)' }}
>
<X className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
</button>
</div>
{/* Menu Items */}
<div className="space-y-2">
{hasSubscription ? (
<>
<Link href={`/subscription/${subscriptionToken}`}>
<MenuButton
icon={<Shield className="w-5 h-5" />}
label="📊 Моя подписка"
onClick={() => setIsMenuOpen(false)}
/>
</Link>
<MenuButton
icon={<Key className="w-5 h-5" />}
label="🔑 Ключ подписки"
onClick={() => {
setIsMenuOpen(false);
setIsKeyMenuOpen(true);
}}
/>
</>
) : (
<Link href="/plans">
<MenuButton
icon={<Shield className="w-5 h-5" />}
label="🛒 Купить подписку"
onClick={() => setIsMenuOpen(false)}
/>
</Link>
)}
<Link href="/plans">
<MenuButton
icon={<CreditCard className="w-5 h-5" />}
label="💳 Тарифы"
onClick={() => setIsMenuOpen(false)}
/>
</Link>
<MenuButton
icon={<MessageCircle className="w-5 h-5" />}
label="💬 Поддержка"
onClick={() => {
setIsMenuOpen(false);
router.push('/help');
}}
/>
<MenuButton
icon={<UserPlus className="w-5 h-5" />}
label="🎁 Пригласить друга"
onClick={() => {
setIsMenuOpen(false);
if (subscriptionToken) {
setIsReferralOpen(true);
} else {
showToastNotification('Сначала активируйте подписку');
}
}}
/>
</div>
</div>
</div>
)}
{/* Key Subscription Menu Modal */}
{isKeyMenuOpen && (
<div
className="fixed inset-0 z-50 flex items-end"
style={{ background: 'rgba(0, 0, 0, 0.5)' }}
onClick={() => setIsKeyMenuOpen(false)}
>
<div
className="w-full rounded-t-3xl p-6 pb-8"
style={{ background: 'var(--bg-card)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold" style={{ color: 'var(--text-white)' }}>
Ключ подписки
</h2>
<button
onClick={() => setIsKeyMenuOpen(false)}
className="p-2 rounded-lg hover:opacity-70 transition-opacity"
style={{ background: 'var(--bg-elevated)' }}
>
<X className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
</button>
</div>
{/* Menu Items */}
<div className="space-y-2">
<MenuButton
icon={<Copy className="w-5 h-5" />}
label="Скопировать ссылку"
onClick={() => {
copyToClipboard(subscriptionUrl);
setIsKeyMenuOpen(false);
}}
/>
<MenuButton
icon={<QrCode className="w-5 h-5" />}
label="Показать QR код"
onClick={() => {
setIsKeyMenuOpen(false);
setIsQROpen(true);
}}
/>
<MenuButton
icon={<ExternalLink className="w-5 h-5" />}
label="Поделиться подпиской"
onClick={() => {
// TODO: Проверка тарифа, затем share sheet
if (navigator.share) {
navigator.share({
title: 'VPN подписка',
text: 'Моя VPN подписка',
url: subscriptionUrl,
});
} else {
copyToClipboard(subscriptionUrl);
}
setIsKeyMenuOpen(false);
}}
/>
</div>
</div>
</div>
)}
{/* QR Code Modal */}
<QRCodeModal
isOpen={isQROpen}
onClose={() => setIsQROpen(false)}
url={subscriptionUrl}
title="QR код подписки"
/>
{/* Referral Modal — только если есть подписка */}
{subscriptionToken && (
<ReferralModal
isOpen={isReferralOpen}
onClose={() => setIsReferralOpen(false)}
referralUrl={`https://t.me/${process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME || 'Dorod_vps_bot'}?start=ref_${subscriptionToken}`}
onShare={() => {
shareReferralLink();
setIsReferralOpen(false);
}}
onCopy={() => {
const botUsername = process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME || 'Dorod_vps_bot';
const referralUrl = `https://t.me/${botUsername}?start=ref_${subscriptionToken}`;
copyToClipboard(referralUrl);
setIsReferralOpen(false);
}}
/>
)}
{/* Setup Wizard Modal */}
{subscriptionToken && (
<SetupWizard
isOpen={isSetupWizardOpen}
onClose={() => setIsSetupWizardOpen(false)}
subscriptionUrl={getSubscriptionUrl(subscriptionToken)}
username={subscriptionToken}
planType="trial"
expiryDate={expiryDate}
/>
)}
{/* Toast Notification */}
{showToast && (
<div className="fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 animate-fade-in">
<div className="bg-slate-800 text-white px-6 py-3 rounded-full shadow-lg border border-slate-700 flex items-center gap-2">
<span>{toastMessage}</span>
</div>
</div>
)}
</div>
);
}
function ActionButton({
icon,
text,
onClick,
}: {
icon: React.ReactNode;
text: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="w-full flex items-center gap-3 p-4 rounded-xl transition-all hover:opacity-80"
style={{ background: 'var(--bg-card)', color: 'var(--text-white)' }}
>
<div style={{ color: 'var(--primary)' }}>{icon}</div>
<span className="font-medium">{text}</span>
</button>
);
}
function NavButton({
icon,
label,
href,
onClick,
}: {
icon: React.ReactNode;
label: string;
href?: string;
onClick?: () => void;
}) {
if (onClick) {
return (
<button
onClick={onClick}
className="flex flex-col items-center gap-1 transition-all hover:opacity-80 cursor-pointer"
style={{ color: 'var(--text-primary)' }}
>
{icon}
<span className="text-xs">{label}</span>
</button>
);
}
return (
<Link href={href!}>
<div className="flex flex-col items-center gap-1 transition-all hover:opacity-80 cursor-pointer"
style={{ color: 'var(--text-primary)' }}
>
{icon}
<span className="text-xs">{label}</span>
</div>
</Link>
);
}
function MenuButton({
icon,
label,
onClick,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="w-full flex items-center justify-between p-4 rounded-xl transition-all hover:opacity-80"
style={{ background: 'var(--bg-elevated)', color: 'var(--text-white)' }}
>
<div className="flex items-center gap-3">
<div style={{ color: 'var(--primary)' }}>{icon}</div>
<span className="font-medium">{label}</span>
</div>
<ChevronRight className="w-5 h-5 opacity-50" />
</button>
);
}