Files
app_umbrix/app/page.tsx

661 lines
23 KiB
TypeScript
Raw Normal View History

// Главная страница приложения (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 || 'Chat_8n8_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
✨ Реферальная система: БД, API, UI **База данных:** - Создана таблица referrals в MariaDB (193.168.175.128) - Поля: username, referrer_username, referral_count, bonus_days_earned - Foreign keys к users таблице с CASCADE/SET NULL **API Endpoints:** - POST /api/referral/track - отслеживание новых рефералов - Автоматический расчёт бонусов: +7 дней за каждого - Milestone bonus: +30 дней за каждые 5 рефералов - Обновление expire даты реферера в users таблице - GET /api/referral/stats?username=xxx - статистика - Возвращает количество рефералов, бонусные дни - Список приглашённых пользователей со статусами **Интеграция:** - POST /api/create-user принимает referrerId параметр - Автоматический вызов /api/referral/track после создания юзера - Параметр ref из URL при активации trial **UI:** - /app/referral/page.tsx - страница статистики - 3 KPI карточки: рефералов, бонусных дней, milestone - Реферальная ссылка с кнопкой копирования - Список приглашённых юзеров с иконками статуса - Инфоблок о механике начисления бонусов **ReferralModal обновлён:** - Добавлена кнопка «Моя статистика» → /referral - Перенос Share/Copy кнопок на второй/третий план **Зависимости:** - mysql2@3.16.3 - для подключения к MariaDB **Логика бонусов:** - +7 дней за каждого успешного реферала - +30 дней бонус за каждые 5 рефералов (milestone) - Автоматическое обновление expire поля в users таблице - Сохранение всех бонусов в bonus_days_earned
2026-02-06 20:51:40 +03:00
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;
✨ Реферальная система: БД, API, UI **База данных:** - Создана таблица referrals в MariaDB (193.168.175.128) - Поля: username, referrer_username, referral_count, bonus_days_earned - Foreign keys к users таблице с CASCADE/SET NULL **API Endpoints:** - POST /api/referral/track - отслеживание новых рефералов - Автоматический расчёт бонусов: +7 дней за каждого - Milestone bonus: +30 дней за каждые 5 рефералов - Обновление expire даты реферера в users таблице - GET /api/referral/stats?username=xxx - статистика - Возвращает количество рефералов, бонусные дни - Список приглашённых пользователей со статусами **Интеграция:** - POST /api/create-user принимает referrerId параметр - Автоматический вызов /api/referral/track после создания юзера - Параметр ref из URL при активации trial **UI:** - /app/referral/page.tsx - страница статистики - 3 KPI карточки: рефералов, бонусных дней, milestone - Реферальная ссылка с кнопкой копирования - Список приглашённых юзеров с иконками статуса - Инфоблок о механике начисления бонусов **ReferralModal обновлён:** - Добавлена кнопка «Моя статистика» → /referral - Перенос Share/Copy кнопок на второй/третий план **Зависимости:** - mysql2@3.16.3 - для подключения к MariaDB **Логика бонусов:** - +7 дней за каждого успешного реферала - +30 дней бонус за каждые 5 рефералов (milestone) - Автоматическое обновление expire поля в users таблице - Сохранение всех бонусов в bonus_days_earned
2026-02-06 20:51:40 +03:00
// Создаем 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,
✨ Реферальная система: БД, API, UI **База данных:** - Создана таблица referrals в MariaDB (193.168.175.128) - Поля: username, referrer_username, referral_count, bonus_days_earned - Foreign keys к users таблице с CASCADE/SET NULL **API Endpoints:** - POST /api/referral/track - отслеживание новых рефералов - Автоматический расчёт бонусов: +7 дней за каждого - Milestone bonus: +30 дней за каждые 5 рефералов - Обновление expire даты реферера в users таблице - GET /api/referral/stats?username=xxx - статистика - Возвращает количество рефералов, бонусные дни - Список приглашённых пользователей со статусами **Интеграция:** - POST /api/create-user принимает referrerId параметр - Автоматический вызов /api/referral/track после создания юзера - Параметр ref из URL при активации trial **UI:** - /app/referral/page.tsx - страница статистики - 3 KPI карточки: рефералов, бонусных дней, milestone - Реферальная ссылка с кнопкой копирования - Список приглашённых юзеров с иконками статуса - Инфоблок о механике начисления бонусов **ReferralModal обновлён:** - Добавлена кнопка «Моя статистика» → /referral - Перенос Share/Copy кнопок на второй/третий план **Зависимости:** - mysql2@3.16.3 - для подключения к MariaDB **Логика бонусов:** - +7 дней за каждого успешного реферала - +30 дней бонус за каждые 5 рефералов (milestone) - Автоматическое обновление expire поля в users таблице - Сохранение всех бонусов в bonus_days_earned
2026-02-06 20:51:40 +03:00
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 || 'Chat_8n8_bot'}?start=ref_${subscriptionToken}`}
onShare={() => {
shareReferralLink();
setIsReferralOpen(false);
}}
onCopy={() => {
const botUsername = process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME || 'Chat_8n8_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>
);
}