🔒 Аудит: безопасность, TypeScript, UI, BottomNav

Безопасность:
- proxy: белый список путей (только /sub/*), POST заблокирован
- console.log заменён на logger (утечки URL/данных)
- OnboardingFlow: убраны --tg-theme-* (не существуют в проекте)

TypeScript (0 ошибок):
- tsconfig target es5→es2017 (regex /u flag fix)
- layout.tsx: viewport перенесён в metadata (Next.js 13.5)
- telegram-webhook: fix text possibly undefined
- hooks/useTelegramWebApp: fix Object possibly undefined
- types/telegram: убрана дублирующая Window декларация

UI:
- BottomNav: новый компонент (Назад/Главная/Помощь)
- safe-area-bottom CSS класс добавлен в globals.css
- dashboard: spacer h-20, toast поднят над BottomNav
- OnboardingFlow: цены 149/249/350₽ (были 200/350/500₽)

Очистка:
- page_NEW.tsx удалён локально (не был в git)
This commit is contained in:
Umbrix Dev
2026-02-08 18:59:02 +03:00
parent 3dffb35eaa
commit 33b20b979f
21 changed files with 1446 additions and 266 deletions

View File

@@ -23,8 +23,8 @@ export async function POST(request: NextRequest) {
try { try {
const { const {
planType, planType,
period = '1month', // NEW: период оплаты period = '1month',
locationIds = [], // NEW: выбранные локации (ID нод) locationIds = [],
telegramId, telegramId,
telegramUsername, telegramUsername,
firstName, firstName,
@@ -79,7 +79,6 @@ export async function POST(request: NextRequest) {
username = `user_${Date.now()}_${Math.random().toString(36).substring(7)}`; username = `user_${Date.now()}_${Math.random().toString(36).substring(7)}`;
} }
console.log('✅ Generated username:', username);
logger.debug('✅ Generated username:', username); logger.debug('✅ Generated username:', username);
logger.debug('📊 Telegram data:', { telegramId, telegramUsername, firstName, lastName }); logger.debug('📊 Telegram data:', { telegramId, telegramUsername, firstName, lastName });

View File

@@ -50,7 +50,10 @@ function extractCountryCode(nodeName: string): string {
// Конвертируем emoji флаг в код страны // Конвертируем emoji флаг в код страны
const flag = flagMatch[0]; const flag = flagMatch[0];
const codePoints = [...flag].map(char => char.codePointAt(0)! - 0x1F1E6 + 65); const codePoints = Array.from(flag).map(char => {
const codePoint = char.codePointAt(0);
return codePoint ? codePoint - 0x1F1E6 + 65 : 65;
});
return String.fromCharCode(...codePoints); return String.fromCharCode(...codePoints);
} }

View File

@@ -1,5 +1,20 @@
// app/api/proxy/[...path]/route.ts // app/api/proxy/[...path]/route.ts
// API Proxy для обращения к Marzban серверу // API Proxy для обращения к Marzban серверу подписок
// ТОЛЬКО разрешённые пути: /sub/{token}/*
import { logger } from '@/lib/logger';
// Белый список разрешённых path-паттернов (regex)
const ALLOWED_PATHS = [
/^sub\/[a-zA-Z0-9_-]+\/?$/, // /sub/{token}/
/^sub\/[a-zA-Z0-9_-]+\/info$/, // /sub/{token}/info
/^sub\/[a-zA-Z0-9_-]+\/usage$/, // /sub/{token}/usage
/^sub\/[a-zA-Z0-9_-]+\/(sing-box|clash-meta|clash|outline|v2ray|v2ray-json)$/, // configs
];
function isPathAllowed(path: string): boolean {
return ALLOWED_PATHS.some((pattern) => pattern.test(path));
}
export async function GET( export async function GET(
request: Request, request: Request,
@@ -7,9 +22,19 @@ export async function GET(
) { ) {
const path = params.path.join('/'); const path = params.path.join('/');
const baseUrl = 'https://umbrix2.3to3.sbs'; const baseUrl = 'https://umbrix2.3to3.sbs';
// Проверяем что путь в белом списке
if (!isPathAllowed(path)) {
logger.warn(`[Proxy] Blocked path: ${path}`);
return Response.json(
{ error: 'Forbidden: path not allowed' },
{ status: 403 }
);
}
const url = `${baseUrl}/${path}`; const url = `${baseUrl}/${path}`;
console.log('[Proxy] Fetching:', url); logger.debug('[Proxy] Fetching:', url);
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@@ -17,27 +42,22 @@ export async function GET(
'Accept': 'application/json', 'Accept': 'application/json',
'User-Agent': 'Umbrix-TelegramBot/1.0', 'User-Agent': 'Umbrix-TelegramBot/1.0',
}, },
// Отключаем кэш для актуальных данных
cache: 'no-store', cache: 'no-store',
}); });
if (!response.ok) { if (!response.ok) {
console.error('[Proxy] HTTP error:', response.status, response.statusText); logger.error('[Proxy] HTTP error:', response.status);
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type');
// Если это JSON - парсим
if (contentType?.includes('application/json')) { if (contentType?.includes('application/json')) {
const data = await response.json(); const data = await response.json();
console.log('[Proxy] Success (JSON):', Object.keys(data));
return Response.json(data); return Response.json(data);
} }
// Если это текст/HTML - возвращаем как есть
const text = await response.text(); const text = await response.text();
console.log('[Proxy] Success (Text):', text.substring(0, 100));
return new Response(text, { return new Response(text, {
headers: { headers: {
'Content-Type': contentType || 'text/plain', 'Content-Type': contentType || 'text/plain',
@@ -45,46 +65,18 @@ export async function GET(
}); });
} catch (error) { } catch (error) {
console.error('[Proxy] Error:', error); logger.error('[Proxy] Error:', error);
return Response.json( return Response.json(
{ { error: 'Failed to fetch data from subscription server' },
error: 'Failed to fetch data from Marzban server',
details: error instanceof Error ? error.message : 'Unknown error',
url: url,
},
{ status: 500 } { status: 500 }
); );
} }
} }
// Также поддерживаем POST для будущих API calls // POST запрещён — нет причин для POST к серверу подписок
export async function POST( export async function POST() {
request: Request, return Response.json(
{ params }: { params: { path: string[] } } { error: 'Method not allowed' },
) { { status: 405 }
const path = params.path.join('/'); );
const baseUrl = 'https://umbrix2.3to3.sbs';
const url = `${baseUrl}/${path}`;
try {
const body = await request.json();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
return Response.json(data);
} catch (error) {
return Response.json(
{ error: 'Failed to post data' },
{ status: 500 }
);
}
} }

View File

@@ -3,6 +3,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
import { logger } from '@/lib/logger';
// Database connection config from ENV // Database connection config from ENV
const dbConfig = { const dbConfig = {
@@ -31,7 +32,7 @@ export async function GET(request: NextRequest) {
// Return mock data if DB not configured (dev mode) // Return mock data if DB not configured (dev mode)
if (!isDbConfigured) { if (!isDbConfigured) {
console.log('⚠️ Referral stats skipped: DB not configured'); logger.info('⚠️ Referral stats skipped: DB not configured');
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
hasReferrals: false, hasReferrals: false,
@@ -98,7 +99,7 @@ export async function GET(request: NextRequest) {
await connection.end(); await connection.end();
} }
} catch (error) { } catch (error) {
console.error('Referral stats error:', error); logger.error('Referral stats error:', error);
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,

View File

@@ -3,6 +3,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
import { logger } from '@/lib/logger';
// Database connection config from ENV // Database connection config from ENV
const dbConfig = { const dbConfig = {
@@ -31,7 +32,7 @@ export async function POST(request: NextRequest) {
// Return mock success if DB not configured (dev mode) // Return mock success if DB not configured (dev mode)
if (!isDbConfigured) { if (!isDbConfigured) {
console.log('⚠️ Referral tracking skipped: DB not configured'); logger.info('⚠️ Referral tracking skipped: DB not configured');
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Referral tracking disabled (dev mode)', message: 'Referral tracking disabled (dev mode)',
@@ -138,7 +139,7 @@ export async function POST(request: NextRequest) {
await connection.end(); await connection.end();
} }
} catch (error) { } catch (error) {
console.error('Referral track error:', error); logger.error('Referral track error:', error);
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,

View File

@@ -0,0 +1,189 @@
// Telegram Bot Webhook Handler
// POST /api/telegram-webhook
// Принимает вебхуки от Telegram и обрабатывает команды
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || '';
const WEBAPP_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://app.umbrix.net';
interface TelegramUpdate {
message?: {
message_id: number;
from: {
id: number;
first_name: string;
last_name?: string;
username?: string;
};
chat: {
id: number;
};
text?: string;
};
}
interface InlineKeyboardButton {
text: string;
web_app?: { url: string };
url?: string;
}
async function sendMessage(
chatId: number,
text: string,
replyMarkup?: { inline_keyboard: InlineKeyboardButton[][] }
) {
const url = `https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`;
const body = {
chat_id: chatId,
text: text,
parse_mode: 'HTML',
reply_markup: replyMarkup,
};
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
logger.error('Telegram API error:', await response.text());
}
} catch (error) {
logger.error('Failed to send Telegram message:', error);
}
}
export async function POST(request: NextRequest) {
try {
const update: TelegramUpdate = await request.json();
logger.debug('📨 Telegram webhook received:', update);
if (!update.message || !update.message.text) {
return NextResponse.json({ ok: true });
}
const { message } = update;
const { chat, from } = message;
const text = message.text!;
// Обработка команды /start
if (text.startsWith('/start')) {
const parts = text.split(' ');
const startParam = parts[1]; // Например: "ref_john_doe"
let webAppUrl = WEBAPP_URL;
let welcomeText = `👋 Привет, ${from.first_name}!\n\n`;
// Если есть реферальный параметр
if (startParam && startParam.startsWith('ref_')) {
const referrerId = startParam.replace('ref_', '');
webAppUrl = `${WEBAPP_URL}?ref=${encodeURIComponent(referrerId)}`;
welcomeText += `🎁 Вы перешли по реферальной ссылке от <b>${referrerId}</b>!\n\n`;
welcomeText += `При регистрации вы получите:\n`;
welcomeText += `✅ <b>7 дней бесплатно</b>\n`;
welcomeText += `А ваш друг получит <b>+7 дней</b> к подписке!\n\n`;
logger.info(`🎁 Referral link opened: ${from.id} -> ${referrerId}`);
} else {
welcomeText += `🚀 <b>Umbrix VPN</b> - быстрый и безопасный VPN!\n\n`;
welcomeText += `✅ Надежная защита данных\n`;
welcomeText += `✅ Высокая скорость соединения\n`;
welcomeText += `✅ Простая настройка\n\n`;
}
welcomeText += `Нажмите кнопку ниже, чтобы начать:`;
const keyboard = {
inline_keyboard: [
[
{
text: '🚀 Открыть Umbrix',
web_app: { url: webAppUrl },
},
],
],
};
await sendMessage(chat.id, welcomeText, keyboard);
return NextResponse.json({ ok: true });
}
// Обработка других команд
if (text === '/help') {
const helpText = `
<b>Доступные команды:</b>
/start - Открыть приложение
/help - Показать эту справку
/referral - Получить свою реферальную ссылку
<b>Как получить реферальную ссылку?</b>
1. Откройте приложение
2. Перейдите в раздел "Реферальная программа"
3. Скопируйте ссылку и отправьте друзьям!
<b>Условия реферальной программы:</b>
За каждого друга: <b>+7 дней</b> бесплатно
За 5 друзей: <b>1 месяц в подарок</b>
• При оплате: <b>10% скидка</b>
`;
await sendMessage(chat.id, helpText.trim());
return NextResponse.json({ ok: true });
}
if (text === '/referral') {
const referralText = `
🎁 <b>Ваша реферальная ссылка:</b>
Чтобы получить ссылку, откройте приложение и перейдите в раздел "Пригласить друга".
Там вы найдете свою уникальную ссылку и сможете поделиться ей!
`;
const keyboard = {
inline_keyboard: [
[
{
text: '🚀 Открыть приложение',
web_app: { url: WEBAPP_URL },
},
],
],
};
await sendMessage(chat.id, referralText.trim(), keyboard);
return NextResponse.json({ ok: true });
}
// Неизвестная команда
await sendMessage(
chat.id,
'Неизвестная команда. Используйте /help для списка команд.'
);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('❌ Telegram webhook error:', error);
return NextResponse.json(
{ ok: false, error: 'Internal server error' },
{ status: 500 }
);
}
}
// Для проверки что webhook работает
export async function GET() {
return NextResponse.json({
status: 'ok',
message: 'Telegram webhook endpoint is running'
});
}

304
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,304 @@
// app/dashboard/page.tsx - Main dashboard for existing users
// Shows subscription status, QR code, referral stats, quick actions
'use client';
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 { marzbanApi } from '@/lib/marzban-api';
import { getSubscriptionUrl } from '@/lib/constants';
import {
Shield,
Settings,
Gift,
HelpCircle,
QrCode,
Copy,
Share2,
CheckCircle,
AlertCircle,
Clock,
Users,
} from 'lucide-react';
export default function Dashboard() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [subscriptionToken, setSubscriptionToken] = useState<string | null>(null);
const [subscriptionStatus, setSubscriptionStatus] = useState<'active' | 'expired' | 'trial'>('active');
const [expiryDate, setExpiryDate] = useState<string>('');
const [daysRemaining, setDaysRemaining] = useState<number>(0);
const [username, setUsername] = useState<string>('');
const [isQROpen, setIsQROpen] = useState(false);
const [isReferralOpen, setIsReferralOpen] = useState(false);
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [referralCount, setReferralCount] = useState(0);
const [bonusDays, setBonusDays] = useState(0);
const subscriptionUrl = subscriptionToken ? getSubscriptionUrl(subscriptionToken) : '';
useEffect(() => {
loadUserData();
}, []);
const loadUserData = async () => {
try {
// Get Telegram data
const telegramWebApp = (window as any).Telegram?.WebApp;
const telegramId = telegramWebApp?.initDataUnsafe?.user?.id;
const telegramUsername = telegramWebApp?.initDataUnsafe?.user?.username;
if (!telegramId && !telegramUsername) {
console.log('❌ No Telegram data - redirecting to home');
router.push('/');
return;
}
// Load subscription
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();
if (!data.success || !data.hasSubscription) {
console.log('❌ No subscription - redirecting to home');
router.push('/');
return;
}
setSubscriptionToken(data.token);
setUsername(data.username);
setSubscriptionStatus(data.status === 'active' ? 'active' : 'expired');
if (data.expire) {
setExpiryDate(marzbanApi.formatExpireDate(data.expire));
// Calculate days remaining
const expireTimestamp = data.expire * 1000;
const now = Date.now();
const diff = expireTimestamp - now;
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
setDaysRemaining(days > 0 ? days : 0);
}
// Load referral stats
const referralResponse = await fetch(`/api/referral/stats?username=${data.username}`);
const referralData = await referralResponse.json();
if (referralData.success) {
setReferralCount(referralData.referral_count || 0);
setBonusDays(referralData.bonus_days_earned || 0);
}
} catch (error) {
console.error('Failed to load user data:', error);
router.push('/');
} finally {
setIsLoading(false);
}
};
const showToastNotification = (message: string) => {
setToastMessage(message);
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showToastNotification('✅ Скопировано в буфер обмена!');
} catch (err) {
console.error('Failed to copy:', err);
showToastNotification('❌ Ошибка копирования');
}
};
const shareReferralLink = async () => {
const botUsername = process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME || 'Dorod_vps_bot';
const referralUrl = `https://t.me/${botUsername}?start=ref_${username}`;
const telegramWebApp = (window as any).Telegram?.WebApp;
if (telegramWebApp?.openTelegramLink) {
const shareText = encodeURIComponent(`🚀 Попробуй Umbrix VPN! Получи 7 дней бесплатно по моей ссылке:\n${referralUrl}`);
telegramWebApp.openTelegramLink(`https://t.me/share/url?url=${referralUrl}&text=${shareText}`);
} else {
copyToClipboard(referralUrl);
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-4 border-blue-500 mx-auto mb-6"></div>
<p className="text-slate-400">Загрузка...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white">
{/* Header */}
<header className="border-b border-slate-700 bg-slate-900/50 backdrop-blur-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold">🚀 Umbrix VPN</h1>
<Link href="/help">
<button className="p-2 hover:bg-slate-700 rounded-lg transition-colors">
<HelpCircle className="h-6 w-6" />
</button>
</Link>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 py-8 space-y-6">
{/* Subscription Status Card */}
<div className="bg-slate-800/50 border border-slate-700 rounded-lg p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-2xl font-bold mb-2">Ваша подписка</h2>
<p className="text-slate-400">@{username}</p>
</div>
{subscriptionStatus === 'active' ? (
<div className="flex items-center gap-2 px-3 py-1 bg-green-600/20 border border-green-500 rounded-full">
<CheckCircle className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium">Активна</span>
</div>
) : (
<div className="flex items-center gap-2 px-3 py-1 bg-red-600/20 border border-red-500 rounded-full">
<AlertCircle className="h-4 w-4 text-red-400" />
<span className="text-sm font-medium">Истекла</span>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-slate-900/50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-5 w-5 text-blue-400" />
<span className="text-sm text-slate-400">Осталось дней</span>
</div>
<p className="text-3xl font-bold">{daysRemaining}</p>
<p className="text-sm text-slate-400 mt-1">{expiryDate}</p>
</div>
<div className="bg-slate-900/50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Users className="h-5 w-5 text-purple-400" />
<span className="text-sm text-slate-400">Рефералы</span>
</div>
<p className="text-3xl font-bold">{referralCount}</p>
<p className="text-sm text-slate-400 mt-1">+{bonusDays} дней заработано</p>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setIsQROpen(true)}
className="bg-slate-800/50 hover:bg-slate-700 border border-slate-700 rounded-lg p-6 flex flex-col items-center gap-3 transition-colors"
>
<QrCode className="h-8 w-8 text-blue-500" />
<span className="font-semibold">QR Код</span>
</button>
<button
onClick={() => copyToClipboard(subscriptionUrl)}
className="bg-slate-800/50 hover:bg-slate-700 border border-slate-700 rounded-lg p-6 flex flex-col items-center gap-3 transition-colors"
>
<Copy className="h-8 w-8 text-green-500" />
<span className="font-semibold">Копировать ссылку</span>
</button>
<button
onClick={shareReferralLink}
className="bg-slate-800/50 hover:bg-slate-700 border border-slate-700 rounded-lg p-6 flex flex-col items-center gap-3 transition-colors"
>
<Share2 className="h-8 w-8 text-purple-500" />
<span className="font-semibold">Пригласить друга</span>
</button>
<Link href="/referral">
<button className="w-full bg-slate-800/50 hover:bg-slate-700 border border-slate-700 rounded-lg p-6 flex flex-col items-center gap-3 transition-colors">
<Gift className="h-8 w-8 text-yellow-500" />
<span className="font-semibold">Реферальная программа</span>
</button>
</Link>
</div>
{/* Referral Progress (if has referrals) */}
{referralCount > 0 && (
<div className="bg-gradient-to-r from-purple-600/20 to-blue-600/20 border border-purple-500/50 rounded-lg p-6">
<h3 className="text-lg font-bold mb-4">🎉 Прогресс реферальной программы</h3>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-slate-300">До следующего бонуса</span>
<span className="text-sm font-bold">{referralCount}/5</span>
</div>
<div className="h-3 bg-slate-900/50 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all"
style={{ width: `${(referralCount / 5) * 100}%` }}
/>
</div>
</div>
<p className="text-sm text-slate-300">
{referralCount >= 5
? '🎁 Вы получили месяц в подарок!'
: `Пригласите еще ${5 - referralCount} друзей и получите месяц бесплатно!`}
</p>
</div>
</div>
)}
{/* Setup Guide Link */}
<Link href="/setup">
<div className="bg-slate-800/50 border border-slate-700 rounded-lg p-6 flex items-center justify-between hover:bg-slate-700 transition-colors">
<div className="flex items-center gap-4">
<Shield className="h-8 w-8 text-blue-500" />
<div>
<h3 className="font-bold">Инструкция по настройке</h3>
<p className="text-sm text-slate-400">Как подключить VPN на вашем устройстве</p>
</div>
</div>
<Settings className="h-6 w-6 text-slate-400" />
</div>
</Link>
{/* Spacer для BottomNav */}
<div className="h-20" />
</main>
{/* Modals */}
{isQROpen && subscriptionUrl && (
<QRCodeModal isOpen={isQROpen} url={subscriptionUrl} onClose={() => setIsQROpen(false)} />
)}
{isReferralOpen && (
<ReferralModal
isOpen={isReferralOpen}
onClose={() => setIsReferralOpen(false)}
referralUrl={`https://t.me/${process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME || 'Dorod_vps_bot'}?start=ref_${username}`}
onShare={shareReferralLink}
onCopy={() => copyToClipboard(`https://t.me/${process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME || 'Dorod_vps_bot'}?start=ref_${username}`)}
/>
)}
{/* Toast */}
{showToast && (
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 bg-green-600 text-white px-6 py-3 rounded-lg shadow-lg z-50 animate-fade-in-up">
{toastMessage}
</div>
)}
</div>
);
}

View File

@@ -2,6 +2,10 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
:root { :root {
/* Hiddify Colors */ /* Hiddify Colors */
--primary: #2fbea5; --primary: #2fbea5;
@@ -42,3 +46,8 @@ body {
.animate-marquee { .animate-marquee {
animation: marquee 20s linear infinite; animation: marquee 20s linear infinite;
} }
/* Safe area для iOS (нижняя полоска) */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}

View File

@@ -253,8 +253,8 @@ export default function HelpPage() {
</div> </div>
</section> </section>
{/* Spacer для нижнего отступа */} {/* Spacer для нижней навигации */}
<div className="h-8" /> <div className="h-20" />
</div> </div>
</div> </div>
); );

View File

@@ -1,20 +1,20 @@
import './globals.css'; import './globals.css';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
import BottomNav from '@/components/BottomNav';
const inter = Inter({ subsets: ['latin'] }); const inter = Inter({ subsets: ['latin'] });
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false, // Отключаем зум для mini app
themeColor: '#191f23', // Цвет темы Umbrix
};
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Umbrix VPN', title: 'Umbrix VPN',
description: 'Быстрый и безопасный VPN сервис', description: 'Быстрый и безопасный VPN сервис',
themeColor: '#191f23',
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
},
openGraph: { openGraph: {
title: 'Umbrix VPN', title: 'Umbrix VPN',
description: 'Быстрый и безопасный VPN сервис', description: 'Быстрый и безопасный VPN сервис',
@@ -46,7 +46,10 @@ export default function RootLayout({
<head> <head>
<script src="https://telegram.org/js/telegram-web-app.js" async></script> <script src="https://telegram.org/js/telegram-web-app.js" async></script>
</head> </head>
<body className={inter.className}>{children}</body> <body className={inter.className}>
{children}
<BottomNav />
</body>
</html> </html>
); );
} }

View File

@@ -317,7 +317,7 @@ export default function Home() {
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="w-full max-w-md space-y-3"> <div className="w-full max-w-md space-y-4">
{/* Показываем trial только если нет активной подписки */} {/* Показываем trial только если нет активной подписки */}
{!hasSubscription && ( {!hasSubscription && (
<ActionButton <ActionButton
@@ -331,13 +331,6 @@ export default function Home() {
text="Купить подписку от 99₽" text="Купить подписку от 99₽"
onClick={() => (window.location.href = '/plans')} onClick={() => (window.location.href = '/plans')}
/> />
<Link href="/setup">
<ActionButton
icon={<Wrench className="w-5 h-5" />}
text="Настроить VPN"
onClick={() => {}}
/>
</Link>
</div> </div>
</main> </main>
@@ -425,14 +418,6 @@ export default function Home() {
/> />
</Link> </Link>
<Link href="/settings">
<MenuButton
icon={<Settings className="w-5 h-5" />}
label="⚙️ Настройки"
onClick={() => setIsMenuOpen(false)}
/>
</Link>
<MenuButton <MenuButton
icon={<MessageCircle className="w-5 h-5" />} icon={<MessageCircle className="w-5 h-5" />}
label="💬 Поддержка" label="💬 Поддержка"

View File

@@ -1,82 +1,243 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { ArrowLeft, Check } from 'lucide-react'; import { ArrowLeft, Check, ChevronRight, Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
export default function Plans() { type PlanType = 'trial' | 'start' | 'plus' | 'max';
type Period = '1month' | '3months' | '6months' | '1year';
type Step = 'plan' | 'period' | 'locations' | 'processing';
interface Location {
id: number;
name: string;
address: string;
ping: string;
country: string;
flag: string;
status: string;
}
interface PlanConfig {
name: string;
badge: string;
maxLocations: number;
dataLimit: string;
prices: Record<Period, number>;
}
const PLANS: Record<PlanType, PlanConfig> = {
trial: {
name: 'Пробный',
badge: '🎁 Пробный период',
maxLocations: 999,
dataLimit: 'Безлимит',
prices: {
'1month': 0,
'3months': 0,
'6months': 0,
'1year': 0,
},
},
start: {
name: 'Старт',
badge: '🌍 Старт',
maxLocations: 1,
dataLimit: '50 ГБ',
prices: {
'1month': 149,
'3months': 399, // -11% (133₽/мес)
'6months': 719, // -20% (119₽/мес)
'1year': 1349, // -25% (112₽/мес)
},
},
plus: {
name: 'Плюс',
badge: '🌎 Плюс',
maxLocations: 3,
dataLimit: '299 ГБ',
prices: {
'1month': 249,
'3months': 649, // -13% (216₽/мес)
'6months': 1199, // -20% (199₽/мес)
'1year': 2249, // -25% (187₽/мес)
},
},
max: {
name: 'Макс',
badge: '🌏 Макс',
maxLocations: 0, // Все локации автоматически, без выбора
dataLimit: 'Безлимит',
prices: {
'1month': 350,
'3months': 949, // -10% (316₽/мес)
'6months': 1799, // -14% (299₽/мес)
'1year': 3349, // -20% (279₽/мес)
},
},
};
const PERIOD_LABELS: Record<Period, { label: string; months: number }> = {
'1month': { label: '1 месяц', months: 1 },
'3months': { label: '3 месяца', months: 3 },
'6months': { label: '6 месяцев', months: 6 },
'1year': { label: '1 год', months: 12 },
};
export default function PlansNew() {
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [step, setStep] = useState<Step>('plan');
const [selectedPlan, setSelectedPlan] = useState<PlanType | null>(null);
const [selectedPeriod, setSelectedPeriod] = useState<Period>('1month');
const [selectedLocations, setSelectedLocations] = useState<number[]>([]);
const [availableLocations, setAvailableLocations] = useState<Location[]>([]);
const [isLoadingLocations, setIsLoadingLocations] = useState(false);
const [isCreatingUser, setIsCreatingUser] = useState(false);
const handlePurchase = async (planType: string) => { // Загрузить локации при переходе к шагу выбора
// Проверяем есть ли уже активная подписка ЧЕРЕЗ API useEffect(() => {
const telegramWebApp = (window as any).Telegram?.WebApp; if (step === 'locations' && availableLocations.length === 0) {
const telegramId = telegramWebApp?.initDataUnsafe?.user?.id; loadLocations();
const telegramUsername = telegramWebApp?.initDataUnsafe?.user?.username;
if (telegramId || telegramUsername) {
const params = new URLSearchParams();
if (telegramId) params.append('telegramId', telegramId.toString());
if (telegramUsername) params.append('telegramUsername', telegramUsername);
const checkResponse = await fetch(`/api/user-subscription?${params.toString()}`);
const checkData = await checkResponse.json();
if (checkData.success && checkData.hasSubscription) {
const confirmOverwrite = confirm('У вас уже есть активная подписка. Создать новую? (старая будет заменена)');
if (!confirmOverwrite) {
return;
}
}
} }
}, [step]);
async function loadLocations() {
setIsLoadingLocations(true);
try {
const response = await fetch('/api/nodes');
const data = await response.json();
if (data.success) {
setAvailableLocations(data.locations || []);
} else {
console.error('Failed to load locations:', data.error);
}
} catch (error) {
console.error('Error loading locations:', error);
} finally {
setIsLoadingLocations(false);
}
}
function handlePlanSelect(plan: PlanType) {
setSelectedPlan(plan);
setIsLoading(true); // Trial период - сразу создаем без выбора периода и локаций
if (plan === 'trial') {
createUser(plan, '1month', []);
} else {
setStep('period');
}
}
function handlePeriodSelect(period: Period) {
setSelectedPeriod(period);
// Только для PLUS показываем выбор локаций
// Для остальных тарифов - все локации автоматически
if (selectedPlan === 'plus') {
setStep('locations');
} else {
// Start и Max - создаем сразу со всеми локациями
createUser(selectedPlan!, period, []);
}
}
function handleLocationToggle(locationId: number) {
if (!selectedPlan) return;
const maxLocations = PLANS[selectedPlan].maxLocations;
if (selectedLocations.includes(locationId)) {
setSelectedLocations(selectedLocations.filter(id => id !== locationId));
} else if (maxLocations === 999 || selectedLocations.length < maxLocations) {
setSelectedLocations([...selectedLocations, locationId]);
}
}
async function createUser(planType: PlanType, period: Period, locationIds: number[]) {
setStep('processing');
setIsCreatingUser(true);
try { try {
const telegramWebApp = (window as any).Telegram?.WebApp; const telegramWebApp = (window as any).Telegram?.WebApp;
console.log('🔍 Telegram WebApp объект:', telegramWebApp);
console.log('🔍 initData:', telegramWebApp?.initData);
console.log('🔍 initDataUnsafe (FULL):', JSON.stringify(telegramWebApp?.initDataUnsafe, null, 2));
console.log('🔍 user (FULL):', JSON.stringify(telegramWebApp?.initDataUnsafe?.user, null, 2));
const user = telegramWebApp?.initDataUnsafe?.user; const user = telegramWebApp?.initDataUnsafe?.user;
const telegramId = user?.id || null;
const telegramUsername = user?.username || null;
const firstName = user?.first_name || null;
const lastName = user?.last_name || null;
console.log('📤 Отправляем:', { planType, telegramId, telegramUsername, firstName, lastName });
// Вызываем API для создания реального пользователя в Marzban
const response = await fetch('/api/create-user', { const response = await fetch('/api/create-user', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
planType: planType, planType,
telegramId: telegramId, period,
telegramUsername: telegramUsername, locationIds,
firstName: firstName, telegramId: user?.id || null,
lastName: lastName, telegramUsername: user?.username || null,
firstName: user?.first_name || null,
lastName: user?.last_name || null,
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (!data.success) { if (data.success) {
// Успех - возвращаемся на главную
router.push('/');
} else {
throw new Error(data.error || 'Failed to create subscription'); throw new Error(data.error || 'Failed to create subscription');
} }
// Просто возвращаемся на главную - там данные обновятся автоматически
router.push('/');
} catch (error) { } catch (error) {
console.error('Purchase error:', error); console.error('Create user error:', error);
alert('Ошибка при создании подписки. Попробуйте позже.'); alert('Ошибка при создании подписки. Попробуйте позже.');
setStep('plan');
} finally { } finally {
setIsLoading(false); setIsCreatingUser(false);
} }
}; }
function handlePurchase() {
if (!selectedPlan || selectedLocations.length === 0) {
alert('Выберите хотя бы одну локацию');
return;
}
createUser(selectedPlan, selectedPeriod, selectedLocations);
}
function calculateDiscount(period: Period): number {
const discounts = {
'1month': 0,
'3months': 10,
'6months': 15,
'1year': 20,
};
return discounts[period];
}
function calculateSavings(plan: PlanType, period: Period): number {
if (plan === 'trial') return 0;
const config = PLANS[plan];
const monthlyPrice = config.prices['1month'];
const months = PERIOD_LABELS[period].months;
const fullPrice = monthlyPrice * months;
const discountedPrice = config.prices[period];
return fullPrice - discountedPrice;
}
function goBack() {
if (step === 'period') {
setStep('plan');
} else if (step === 'locations') {
setStep('period');
} else if (step === 'processing') {
setStep('locations');
}
}
const canProceedFromLocations =
selectedPlan &&
(PLANS[selectedPlan].maxLocations === 999 || selectedLocations.length >= PLANS[selectedPlan].maxLocations);
return ( return (
<div <div
className="min-h-screen" className="min-h-screen"
@@ -87,93 +248,245 @@ export default function Plans() {
className="flex items-center gap-3 p-4 border-b sticky top-0 z-10 backdrop-blur-sm" className="flex items-center gap-3 p-4 border-b sticky top-0 z-10 backdrop-blur-sm"
style={{ borderColor: 'var(--border)', background: 'var(--bg-card)' }} style={{ borderColor: 'var(--border)', background: 'var(--bg-card)' }}
> >
<button onClick={() => router.push('/')} className="p-2 hover:bg-slate-700 rounded-lg transition-colors"> {step === 'plan' ? (
<ArrowLeft className="w-6 h-6" /> <button onClick={() => router.push('/')} className="p-2 hover:bg-slate-700 rounded-lg transition-colors">
</button> <ArrowLeft className="w-6 h-6" />
<h1 </button>
className="text-xl font-bold" ) : step !== 'processing' && (
style={{ color: 'var(--text-white)' }} <button onClick={goBack} className="p-2 hover:bg-slate-700 rounded-lg transition-colors">
> <ArrowLeft className="w-6 h-6" />
Выбрать тариф </button>
)}
<h1 className="text-xl font-bold" style={{ color: 'var(--text-white)' }}>
{step === 'plan' && 'Выбор тарифа'}
{step === 'period' && `Период подписки${selectedPlan ? ` · ${PLANS[selectedPlan].name}` : ''}`}
{step === 'locations' && 'Выбор локаций'}
{step === 'processing' && 'Создание подписки...'}
</h1> </h1>
</header> </header>
<main className="p-4"> <main className="p-4 pb-24">
{/* Trial - полная ширина */} {/* Шаг 1: Выбор тарифа */}
<div className="mb-3"> {step === 'plan' && (
<PlanCard <div className="space-y-3">
badge="🎁 Пробный период" {/* Trial - полная ширина */}
title="7 дней бесплатно" <PlanCard
price="0₽" {...PLANS.trial}
features={[ title="7 дней бесплатно"
'Все тарифы доступны', price="0₽"
'Безлимитный трафик', features={['Все тарифы доступны', 'Безлимитный трафик', 'Любые локации']}
'Любые локации', buttonText="Попробовать"
]} isPrimary
buttonText="Попробовать" onSelect={() => handlePlanSelect('trial')}
isPrimary />
onPurchase={() => handlePurchase('trial')}
isLoading={isLoading}
/>
</div>
{/* Grid 2x2 для остальных тарифов */} {/* Grid 2x2 для остальных */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{/* Start */} {(['start', 'plus', 'max'] as PlanType[]).map((planKey) => {
<PlanCard const plan = PLANS[planKey];
badge="🌍 Старт" return (
title="Базовый" <PlanCard
price="100₽/мес" key={planKey}
features={['1 устройство', '1 локация', '50 ГБ']} {...plan}
buttonText="Выбрать" title={plan.name}
onPurchase={() => handlePurchase('start')} price={`${plan.prices['1month']}₽/мес`}
isLoading={isLoading} features={[
/> plan.maxLocations === 999 ? 'Все локации' : `${plan.maxLocations} локаций`,
plan.dataLimit,
'Безлимитная скорость',
]}
buttonText="Выбрать"
isPopular={planKey === 'max'}
onSelect={() => handlePlanSelect(planKey)}
/>
);
})}
{/* Plus */} {/* Empty slot */}
<PlanCard <div
badge="🌎 Плюс" className="p-4 rounded-xl border flex items-center justify-center"
title="Расширенный" style={{
price="299₽/мес" background: 'var(--bg-card)',
features={['3 устройства', '3 локации', '299 ГБ']} borderColor: 'var(--border)',
buttonText="Выбрать" borderStyle: 'dashed',
onPurchase={() => handlePurchase('plus')} }}
isLoading={isLoading} >
/> <div className="text-center opacity-50">
<div className="text-2xl mb-1">💰</div>
{/* Max */} <div className="text-xs">Скоро новые</div>
<PlanCard <div className="text-xs">тарифы</div>
badge="🌏 Макс" </div>
title="Премиум" </div>
price="350₽/мес"
features={['5 устройств', '15+ стран', 'Безлимит']}
buttonText="Выбрать"
isPopular
onPurchase={() => handlePurchase('max')}
isLoading={isLoading}
/>
{/* Empty slot or promo */}
<div
className="p-4 rounded-xl border flex items-center justify-center"
style={{
background: 'var(--bg-card)',
borderColor: 'var(--border)',
borderStyle: 'dashed',
}}
>
<div className="text-center opacity-50">
<div className="text-2xl mb-1">💰</div>
<div className="text-xs">Скоро новые</div>
<div className="text-xs">тарифы</div>
</div> </div>
</div> </div>
</div> )}
{/* Шаг 2: Выбор периода */}
{step === 'period' && selectedPlan && (
<div>
{/* Информация о тарифе */}
<div className="mb-4 p-3 rounded-lg" style={{ background: 'var(--bg-card)' }}>
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-slate-400">Выбран тариф</div>
<div className="text-lg font-bold">{PLANS[selectedPlan].name}</div>
</div>
<div className="text-right">
<div className="text-xs text-slate-400">Локации</div>
<div className="text-lg font-bold">
{selectedPlan === 'plus' ? '3 на выбор' : 'Все'}
</div>
</div>
<div className="text-right">
<div className="text-xs text-slate-400">Трафик</div>
<div className="text-lg font-bold">{PLANS[selectedPlan].dataLimit}</div>
</div>
</div>
</div>
{/* Grid 2x2 для периодов */}
<div className="grid grid-cols-2 gap-3">
{Object.entries(PERIOD_LABELS).map(([periodKey, { label, months }]) => {
const period = periodKey as Period;
const price = PLANS[selectedPlan].prices[period];
const discount = calculateDiscount(period);
const savings = calculateSavings(selectedPlan, period);
const isRecommended = period === '6months'; // Самый выгодный
return (
<button
key={period}
onClick={() => handlePeriodSelect(period)}
className="p-4 rounded-xl border-2 text-center transition-all active:scale-95 relative hover:opacity-90"
style={{
background: isRecommended ? 'linear-gradient(135deg, rgba(47, 190, 165, 0.15) 0%, rgba(68, 163, 52, 0.15) 100%)' : 'var(--bg-card)',
borderColor: selectedPeriod === period ? 'var(--primary)' : 'var(--border)',
}}
>
{/* Badge "выгодно" */}
{isRecommended && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-green-500 text-white text-xs rounded-full">
выгодно
</div>
)}
{/* Период */}
<div className="text-base font-semibold mb-1">{label}</div>
{/* Старая цена */}
{discount > 0 && (
<div className="text-xs text-slate-500 line-through mb-1">
{PLANS[selectedPlan].prices['1month'] * months}
</div>
)}
{/* Новая цена */}
<div className="text-2xl font-bold mb-1">{price} </div>
{/* Скидка */}
{discount > 0 && (
<div className="text-xs px-2 py-1 bg-green-500/20 text-green-400 rounded inline-block">
-{discount}%
</div>
)}
{/* Цена за месяц */}
{months > 1 && (
<div className="text-xs text-slate-400 mt-2">
{Math.round(price / months)} /мес
</div>
)}
</button>
);
})}
</div>
</div>
)}
{/* Шаг 3: Выбор локаций */}
{step === 'locations' && selectedPlan && (
<div className="space-y-4">
<div className="text-sm text-slate-400">
{PLANS[selectedPlan].maxLocations === 999
? 'Доступны все локации'
: `Выберите до ${PLANS[selectedPlan].maxLocations} локаций`}
</div>
{isLoadingLocations ? (
<div className="text-center py-8">
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-500" />
<p className="mt-2 text-slate-400">Загрузка локаций...</p>
</div>
) : (
<div className="space-y-2">
{availableLocations.map((location) => {
const isSelected = selectedLocations.includes(location.id);
const isDisabled =
!isSelected &&
PLANS[selectedPlan].maxLocations !== 999 &&
selectedLocations.length >= PLANS[selectedPlan].maxLocations;
return (
<button
key={location.id}
onClick={() => handleLocationToggle(location.id)}
disabled={isDisabled}
className={`w-full p-4 rounded-xl border transition-colors ${
isSelected
? 'bg-blue-600/20 border-blue-500'
: isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:border-slate-600'
}`}
style={{
background: isSelected ? undefined : 'var(--bg-card)',
borderColor: isSelected ? undefined : 'var(--border)',
}}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
{isSelected && <Check className="w-5 h-5 text-blue-400" />}
<span className="text-lg">{location.flag}</span>
<span className="font-medium">{location.name}</span>
</div>
<span className="text-sm text-slate-400">{location.ping}</span>
</div>
</button>
);
})}
</div>
)}
{selectedLocations.length > 0 && (
<button
onClick={handlePurchase}
className="w-full py-4 rounded-xl font-bold mt-6 flex items-center justify-center gap-2 transition-opacity hover:opacity-80"
style={{ background: 'var(--primary)', color: 'var(--text-white)' }}
>
Создать подписку {PLANS[selectedPlan].prices[selectedPeriod]}
<ChevronRight className="w-5 h-5" />
</button>
)}
</div>
)}
{/* Шаг 4: Processing */}
{step === 'processing' && (
<div className="text-center py-12">
<Loader2 className="w-16 h-16 animate-spin mx-auto mb-4" style={{ color: 'var(--primary)' }} />
<h2 className="text-xl font-bold mb-2">Создаем вашу подписку...</h2>
<p className="text-slate-400">Это займет несколько секунд</p>
</div>
)}
{/* Spacer для нижней навигации */}
<div className="h-20" />
</main> </main>
</div> </div>
); );
} }
// PlanCard Component
function PlanCard({ function PlanCard({
badge, badge,
title, title,
@@ -182,8 +495,7 @@ function PlanCard({
buttonText, buttonText,
isPrimary = false, isPrimary = false,
isPopular = false, isPopular = false,
onPurchase, onSelect,
isLoading = false,
}: { }: {
badge: string; badge: string;
title: string; title: string;
@@ -192,65 +504,43 @@ function PlanCard({
buttonText: string; buttonText: string;
isPrimary?: boolean; isPrimary?: boolean;
isPopular?: boolean; isPopular?: boolean;
onPurchase: () => void; onSelect: () => void;
isLoading?: boolean;
}) { }) {
return ( return (
<div <div
className="p-3 rounded-xl border relative" className={`p-4 rounded-xl border relative`}
style={{ style={{
background: 'var(--bg-card)', background: 'var(--bg-card)',
borderColor: isPrimary borderColor: isPopular ? 'var(--primary)' : 'var(--border)',
? 'var(--primary)'
: isPopular
? 'var(--primary)'
: 'var(--border)',
}} }}
> >
{isPopular && ( {isPopular && (
<div <div className="absolute -top-2 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full text-xs font-bold" style={{ background: 'var(--primary)', color: 'var(--text-white)' }}>
className="absolute -top-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold" Популярный
style={{ background: 'var(--primary)', color: 'var(--text-white)' }}
>
ТОП
</div> </div>
)} )}
<div className="text-xs mb-1 opacity-70">{badge}</div> <div className="text-sm opacity-80 mb-1">{badge}</div>
<h3 <h3 className="text-xl font-bold mb-1">{title}</h3>
className="font-bold mb-1 text-sm" <p className="text-2xl font-bold mb-3" style={{ color: 'var(--primary)' }}>
style={{ color: 'var(--text-white)' }}
>
{title}
</h3>
<div
className="text-xl font-bold mb-2"
style={{ color: 'var(--primary)' }}
>
{price} {price}
</div> </p>
<ul className="space-y-2 mb-4">
<ul className="space-y-1.5 mb-3"> {features.map((feature, index) => (
{features.map((feature, i) => ( <li key={index} className="flex items-center gap-2 text-sm">
<li key={i} className="flex items-center gap-1.5 text-xs"> <Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<Check
className="w-3.5 h-3.5 flex-shrink-0"
style={{ color: 'var(--success)' }}
/>
<span>{feature}</span> <span>{feature}</span>
</li> </li>
))} ))}
</ul> </ul>
<button <button
onClick={onPurchase} onClick={onSelect}
disabled={isLoading} className="w-full py-2 rounded-lg font-medium transition-opacity hover:opacity-80"
className="w-full py-2 rounded-lg font-semibold text-xs transition-all hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ style={{
background: isPrimary ? 'var(--primary)' : 'var(--bg-elevated)', background: isPrimary ? 'var(--primary)' : 'var(--bg-elevated)',
color: 'var(--text-white)', color: 'var(--text-white)'
}} }}
> >
{isLoading ? 'Оформление...' : buttonText} {buttonText}
</button> </button>
</div> </div>
); );

View File

@@ -283,6 +283,9 @@ export default function ReferralPage() {
)} )}
</main> </main>
{/* Spacer для нижней навигации */}
<div className="h-20" />
{/* Toast Notification */} {/* Toast Notification */}
{showToast && ( {showToast && (
<div className="fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 animate-fade-in"> <div className="fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 animate-fade-in">

View File

@@ -202,13 +202,8 @@ export default function SetupPage() {
</a> </a>
</div> </div>
{/* Back Button */} {/* Spacer для нижней навигации */}
<button <div className="h-20" />
onClick={() => router.push('/')}
className="w-full p-4 bg-slate-800/50 hover:bg-slate-700/50 rounded-lg transition-colors text-slate-300"
>
Вернуться на главную
</button>
</div> </div>
</div> </div>
); );

View File

@@ -302,6 +302,9 @@ export default function SubscriptionPage() {
> >
🔄 Обновить данные 🔄 Обновить данные
</button> </button>
{/* Spacer для нижней навигации */}
<div className="h-20" />
</div> </div>
</div> </div>
); );

51
components/BottomNav.tsx Normal file
View File

@@ -0,0 +1,51 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { ChevronLeft, Home, HelpCircle } from 'lucide-react';
export default function BottomNav() {
const router = useRouter();
const pathname = usePathname();
// На главной не показываем
if (pathname === '/') return null;
return (
<nav
className="fixed bottom-0 left-0 right-0 z-40 border-t safe-area-bottom"
style={{ borderColor: 'var(--border)', background: 'var(--bg-card)' }}
>
<div className="flex items-center justify-around py-2 max-w-2xl mx-auto">
{/* Назад */}
<button
onClick={() => router.back()}
className="flex flex-col items-center gap-1 px-4 py-1 transition-all active:scale-95"
style={{ color: 'var(--text-primary)' }}
>
<ChevronLeft className="w-6 h-6" />
<span className="text-[10px]">Назад</span>
</button>
{/* Главная */}
<button
onClick={() => router.push('/')}
className="flex flex-col items-center gap-1 px-4 py-1 transition-all active:scale-95"
style={{ color: pathname === '/' ? 'var(--primary)' : 'var(--text-primary)' }}
>
<Home className="w-6 h-6" />
<span className="text-[10px]">Главная</span>
</button>
{/* Помощь */}
<button
onClick={() => router.push('/help')}
className="flex flex-col items-center gap-1 px-4 py-1 transition-all active:scale-95"
style={{ color: pathname === '/help' ? 'var(--primary)' : 'var(--text-primary)' }}
>
<HelpCircle className="w-6 h-6" />
<span className="text-[10px]">Помощь</span>
</button>
</div>
</nav>
);
}

View File

@@ -0,0 +1,358 @@
// OnboardingFlow.tsx - Полный onboarding с интеграцией Telegram WebApp SDK
// Steps: Welcome → Language → Plan → Create Account
// Uses: Telegram.WebApp.MainButton, .BackButton, .themeParams
'use client';
import { useState, useEffect } from 'react';
import { CheckCircle, Globe, Shield, Zap, DollarSign } from 'lucide-react';
// Типы
type OnboardingStep = 'welcome' | 'language' | 'plan' | 'creating';
interface Plan {
id: string;
name: string;
price: number;
features: string[];
icon: any;
}
interface OnboardingFlowProps {
referrerId?: string; // From URL ?ref=username
onComplete: (username: string, token: string) => void;
}
const PLANS: Plan[] = [
{
id: 'basic',
name: 'Старт',
price: 149,
features: ['Безлимитный трафик', 'До 3 устройств', 'Стандартная скорость', 'Email поддержка'],
icon: Shield,
},
{
id: 'plus',
name: 'Плюс',
price: 249,
features: ['Безлимитный трафик', 'До 5 устройств', 'Высокая скорость', 'Выбор локации', 'Приоритетная поддержка'],
icon: Zap,
},
{
id: 'max',
name: 'Макс',
price: 350,
features: ['Безлимитный трафик', 'До 10 устройств', 'Максимальная скорость', 'Все локации', '24/7 поддержка'],
icon: DollarSign,
},
];
const LANGUAGES = [
{ code: 'ru', name: 'Русский', flag: '🇷🇺' },
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'uz', name: 'Oʻzbekcha', flag: '🇺🇿' },
];
export default function OnboardingFlow({ referrerId, onComplete }: OnboardingFlowProps) {
const [step, setStep] = useState<OnboardingStep>('welcome');
const [selectedLanguage, setSelectedLanguage] = useState('ru');
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Telegram WebApp SDK
const [telegramWebApp, setTelegramWebApp] = useState<any>(null);
const [telegramUser, setTelegramUser] = useState<any>(null);
useEffect(() => {
// Initialize Telegram WebApp SDK
if (typeof window !== 'undefined') {
const tg = (window as any).Telegram?.WebApp;
if (tg) {
setTelegramWebApp(tg);
setTelegramUser(tg.initDataUnsafe?.user);
tg.ready();
tg.expand();
// Тема применяется через CSS variables в globals.css
// НЕ переопределяем --tg-theme-* — таких переменных нет в проекте
}
}
}, []);
// Handle MainButton clicks based on current step
useEffect(() => {
if (!telegramWebApp) return;
const handleMainButtonClick = () => {
if (step === 'welcome') {
setStep('language');
} else if (step === 'language' && selectedLanguage) {
setStep('plan');
} else if (step === 'plan' && selectedPlan) {
handleCreateAccount();
}
};
telegramWebApp.MainButton.onClick(handleMainButtonClick);
// Update MainButton based on step
if (step === 'welcome') {
telegramWebApp.MainButton.text = '🚀 Начать';
telegramWebApp.MainButton.show();
telegramWebApp.BackButton.hide();
} else if (step === 'language') {
telegramWebApp.MainButton.text = selectedLanguage ? '➡️ Далее' : '⚠️ Выберите язык';
telegramWebApp.MainButton.show();
telegramWebApp.BackButton.show();
} else if (step === 'plan') {
telegramWebApp.MainButton.text = selectedPlan ? '✅ Создать аккаунт' : '⚠️ Выберите тариф';
telegramWebApp.MainButton.show();
telegramWebApp.BackButton.show();
} else if (step === 'creating') {
telegramWebApp.MainButton.hide();
telegramWebApp.BackButton.hide();
}
return () => {
telegramWebApp.MainButton.offClick(handleMainButtonClick);
};
}, [step, selectedLanguage, selectedPlan, telegramWebApp]);
// Handle BackButton
useEffect(() => {
if (!telegramWebApp) return;
const handleBackButtonClick = () => {
if (step === 'language') {
setStep('welcome');
} else if (step === 'plan') {
setStep('language');
}
};
telegramWebApp.BackButton.onClick(handleBackButtonClick);
return () => {
telegramWebApp.BackButton.offClick(handleBackButtonClick);
};
}, [step, telegramWebApp]);
const handleCreateAccount = async () => {
if (!telegramUser) {
setError('❌ Telegram user data not available');
return;
}
setStep('creating');
setIsCreating(true);
setError(null);
try {
// Генерируем username из Telegram данных
const username = telegramUser.username || `user_${telegramUser.id}`;
// Создаем аккаунт через API
const response = await fetch('/api/create-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
telegramId: telegramUser.id,
telegramUsername: telegramUser.username,
firstName: telegramUser.first_name,
lastName: telegramUser.last_name,
plan: selectedPlan,
language: selectedLanguage,
referrerId: referrerId || null,
}),
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to create account');
}
// Track referral if exists
if (referrerId) {
await fetch('/api/referral/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: data.username,
referrer_username: referrerId,
}),
});
}
// Success!
onComplete(data.username, data.token);
} catch (err: any) {
console.error('Create account error:', err);
setError(err.message);
setStep('plan'); // Вернуться к выбору тарифа
setIsCreating(false);
}
};
// Render Welcome Step
if (step === 'welcome') {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white p-6 flex flex-col items-center justify-center">
<div className="max-w-md w-full text-center space-y-6">
<div className="text-6xl mb-4">🚀</div>
<h1 className="text-3xl font-bold">Добро пожаловать в Umbrix VPN!</h1>
{referrerId && (
<div className="bg-blue-600/20 border border-blue-500 rounded-lg p-4">
<p className="text-lg">
🎁 Вы перешли по ссылке от <span className="font-bold">{referrerId}</span>!
</p>
<p className="text-sm text-slate-300 mt-2">
Оба получите <span className="font-bold text-green-400">+7 дней бесплатно</span> при регистрации!
</p>
</div>
)}
<div className="space-y-3 text-left">
<div className="flex items-start gap-3">
<CheckCircle className="w-6 h-6 text-green-500 mt-1 flex-shrink-0" />
<div>
<p className="font-semibold">Надежная защита</p>
<p className="text-sm text-slate-400">Ваши данные в безопасности</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-6 h-6 text-green-500 mt-1 flex-shrink-0" />
<div>
<p className="font-semibold">Высокая скорость</p>
<p className="text-sm text-slate-400">Без ограничений трафика</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="w-6 h-6 text-green-500 mt-1 flex-shrink-0" />
<div>
<p className="font-semibold">Простая настройка</p>
<p className="text-sm text-slate-400">Работает за 2 минуты</p>
</div>
</div>
</div>
<p className="text-sm text-slate-400">
Нажмите <span className="font-bold">&quot;Начать&quot;</span> для создания аккаунта
</p>
</div>
</div>
);
}
// Render Language Selection Step
if (step === 'language') {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white p-6 flex flex-col items-center justify-center">
<div className="max-w-md w-full space-y-6">
<div className="text-center">
<Globe className="w-16 h-16 mx-auto mb-4 text-blue-500" />
<h2 className="text-2xl font-bold">Выберите язык</h2>
<p className="text-slate-400 mt-2">Choose your language</p>
</div>
<div className="space-y-3">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => setSelectedLanguage(lang.code)}
className={`w-full p-4 rounded-lg border-2 transition-all ${
selectedLanguage === lang.code
? 'bg-blue-600 border-blue-500'
: 'bg-slate-800/50 border-slate-700 hover:bg-slate-700'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-3xl">{lang.flag}</span>
<span className="font-semibold">{lang.name}</span>
</div>
{selectedLanguage === lang.code && (
<CheckCircle className="w-6 h-6 text-white" />
)}
</div>
</button>
))}
</div>
</div>
</div>
);
}
// Render Plan Selection Step
if (step === 'plan') {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white p-6 flex flex-col items-center justify-center">
<div className="max-w-4xl w-full space-y-6">
<div className="text-center">
<h2 className="text-2xl font-bold">Выберите тарифный план</h2>
<p className="text-slate-400 mt-2">7 дней бесплатно на любом тарифе!</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{PLANS.map((plan) => {
const Icon = plan.icon;
return (
<button
key={plan.id}
onClick={() => setSelectedPlan(plan.id)}
className={`p-6 rounded-lg border-2 transition-all text-left ${
selectedPlan === plan.id
? 'bg-blue-600 border-blue-500 scale-105'
: 'bg-slate-800/50 border-slate-700 hover:bg-slate-700'
}`}
>
<div className="flex items-center justify-between mb-4">
<Icon className="w-8 h-8" />
{selectedPlan === plan.id && (
<CheckCircle className="w-6 h-6 text-white" />
)}
</div>
<h3 className="text-xl font-bold mb-2">{plan.name}</h3>
<p className="text-3xl font-bold mb-4">
{plan.price} <span className="text-base font-normal text-slate-400">/мес</span>
</p>
<ul className="space-y-2">
{plan.features.map((feature, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<CheckCircle className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</button>
);
})}
</div>
</div>
</div>
);
}
// Render Creating Account Step
if (step === 'creating') {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-4 border-blue-500 mx-auto mb-6"></div>
<h2 className="text-2xl font-bold mb-2">Создаем ваш аккаунт...</h2>
<p className="text-slate-400">Подождите несколько секунд</p>
{error && (
<div className="mt-4 p-4 bg-red-600/20 border border-red-500 rounded-lg">
<p className="text-red-400">{error}</p>
</div>
)}
</div>
</div>
);
}
return null;
}

View File

@@ -145,7 +145,7 @@ declare global {
} }
export function useTelegramWebApp() { export function useTelegramWebApp() {
const [webApp, setWebApp] = useState<typeof window.Telegram.WebApp | null>(null); const [webApp, setWebApp] = useState<any>(null);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
useEffect(() => { useEffect(() => {

View File

@@ -69,9 +69,9 @@ export class MarzbanApiClient {
return data; return data;
} }
console.warn('API endpoint returned error:', response.status, response.statusText); if (typeof window === 'undefined') console.warn('API endpoint returned error:', response.status, response.statusText);
} catch (apiError) { } catch (apiError) {
console.warn('API failed, falling back to HTML scraping:', apiError); if (typeof window === 'undefined') console.warn('API failed, falling back to HTML scraping:', apiError);
} }
// ❌ Fallback to HTML scraping (старый метод) // ❌ Fallback to HTML scraping (старый метод)
@@ -91,7 +91,7 @@ export class MarzbanApiClient {
this.cache.set(`info_${token}`, { data, timestamp: Date.now() }); this.cache.set(`info_${token}`, { data, timestamp: Date.now() });
return data; return data;
} catch (fallbackError) { } catch (fallbackError) {
console.error('Both API and HTML scraping failed:', fallbackError); if (typeof window === 'undefined') console.error('Both API and HTML scraping failed:', fallbackError);
throw new Error('Не удалось загрузить данные подписки'); throw new Error('Не удалось загрузить данные подписки');
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,

View File

@@ -56,10 +56,4 @@ export interface TelegramUserData {
lastName: string | null; lastName: string | null;
} }
declare global { // Window.Telegram декларация в hooks/useTelegramWebApp.ts
interface Window {
Telegram?: {
WebApp: TelegramWebApp;
};
}
}