diff --git a/app/api/create-user/route.ts b/app/api/create-user/route.ts index e0a4a44..f0411e3 100644 --- a/app/api/create-user/route.ts +++ b/app/api/create-user/route.ts @@ -23,8 +23,8 @@ export async function POST(request: NextRequest) { try { const { planType, - period = '1month', // NEW: период оплаты - locationIds = [], // NEW: выбранные локации (ID нод) + period = '1month', + locationIds = [], telegramId, telegramUsername, firstName, @@ -79,7 +79,6 @@ export async function POST(request: NextRequest) { username = `user_${Date.now()}_${Math.random().toString(36).substring(7)}`; } - console.log('✅ Generated username:', username); logger.debug('✅ Generated username:', username); logger.debug('📊 Telegram data:', { telegramId, telegramUsername, firstName, lastName }); diff --git a/app/api/nodes/route.ts b/app/api/nodes/route.ts index 7e9d33e..16b0cbd 100644 --- a/app/api/nodes/route.ts +++ b/app/api/nodes/route.ts @@ -50,7 +50,10 @@ function extractCountryCode(nodeName: string): string { // Конвертируем emoji флаг в код страны 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); } diff --git a/app/api/proxy/[...path]/route.ts b/app/api/proxy/[...path]/route.ts index be5da20..f1fb6a4 100644 --- a/app/api/proxy/[...path]/route.ts +++ b/app/api/proxy/[...path]/route.ts @@ -1,5 +1,20 @@ // 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( request: Request, @@ -7,9 +22,19 @@ export async function GET( ) { const path = params.path.join('/'); 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}`; - console.log('[Proxy] Fetching:', url); + logger.debug('[Proxy] Fetching:', url); try { const response = await fetch(url, { @@ -17,27 +42,22 @@ export async function GET( 'Accept': 'application/json', 'User-Agent': 'Umbrix-TelegramBot/1.0', }, - // Отключаем кэш для актуальных данных cache: 'no-store', }); 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}`); } const contentType = response.headers.get('content-type'); - // Если это JSON - парсим if (contentType?.includes('application/json')) { const data = await response.json(); - console.log('[Proxy] Success (JSON):', Object.keys(data)); return Response.json(data); } - // Если это текст/HTML - возвращаем как есть const text = await response.text(); - console.log('[Proxy] Success (Text):', text.substring(0, 100)); return new Response(text, { headers: { 'Content-Type': contentType || 'text/plain', @@ -45,46 +65,18 @@ export async function GET( }); } catch (error) { - console.error('[Proxy] Error:', error); + logger.error('[Proxy] Error:', error); return Response.json( - { - error: 'Failed to fetch data from Marzban server', - details: error instanceof Error ? error.message : 'Unknown error', - url: url, - }, + { error: 'Failed to fetch data from subscription server' }, { status: 500 } ); } } -// Также поддерживаем POST для будущих API calls -export async function POST( - request: Request, - { params }: { params: { path: string[] } } -) { - 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 } - ); - } +// POST запрещён — нет причин для POST к серверу подписок +export async function POST() { + return Response.json( + { error: 'Method not allowed' }, + { status: 405 } + ); } diff --git a/app/api/referral/stats/route.ts b/app/api/referral/stats/route.ts index 43f59ed..81d91a9 100644 --- a/app/api/referral/stats/route.ts +++ b/app/api/referral/stats/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import mysql from 'mysql2/promise'; +import { logger } from '@/lib/logger'; // Database connection config from ENV const dbConfig = { @@ -31,7 +32,7 @@ export async function GET(request: NextRequest) { // Return mock data if DB not configured (dev mode) if (!isDbConfigured) { - console.log('⚠️ Referral stats skipped: DB not configured'); + logger.info('⚠️ Referral stats skipped: DB not configured'); return NextResponse.json({ success: true, hasReferrals: false, @@ -98,7 +99,7 @@ export async function GET(request: NextRequest) { await connection.end(); } } catch (error) { - console.error('Referral stats error:', error); + logger.error('Referral stats error:', error); return NextResponse.json( { success: false, diff --git a/app/api/referral/track/route.ts b/app/api/referral/track/route.ts index f118e19..433a6aa 100644 --- a/app/api/referral/track/route.ts +++ b/app/api/referral/track/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import mysql from 'mysql2/promise'; +import { logger } from '@/lib/logger'; // Database connection config from ENV const dbConfig = { @@ -31,7 +32,7 @@ export async function POST(request: NextRequest) { // Return mock success if DB not configured (dev mode) if (!isDbConfigured) { - console.log('⚠️ Referral tracking skipped: DB not configured'); + logger.info('⚠️ Referral tracking skipped: DB not configured'); return NextResponse.json({ success: true, message: 'Referral tracking disabled (dev mode)', @@ -138,7 +139,7 @@ export async function POST(request: NextRequest) { await connection.end(); } } catch (error) { - console.error('Referral track error:', error); + logger.error('Referral track error:', error); return NextResponse.json( { success: false, diff --git a/app/api/telegram-webhook/route.ts b/app/api/telegram-webhook/route.ts new file mode 100644 index 0000000..8349a2b --- /dev/null +++ b/app/api/telegram-webhook/route.ts @@ -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 += `🎁 Вы перешли по реферальной ссылке от ${referrerId}!\n\n`; + welcomeText += `При регистрации вы получите:\n`; + welcomeText += `✅ 7 дней бесплатно\n`; + welcomeText += `✅ А ваш друг получит +7 дней к подписке!\n\n`; + + logger.info(`🎁 Referral link opened: ${from.id} -> ${referrerId}`); + } else { + welcomeText += `🚀 Umbrix VPN - быстрый и безопасный 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 = ` +Доступные команды: + +/start - Открыть приложение +/help - Показать эту справку +/referral - Получить свою реферальную ссылку + +Как получить реферальную ссылку? +1. Откройте приложение +2. Перейдите в раздел "Реферальная программа" +3. Скопируйте ссылку и отправьте друзьям! + +Условия реферальной программы: +• За каждого друга: +7 дней бесплатно +• За 5 друзей: 1 месяц в подарок +• При оплате: 10% скидка + `; + + await sendMessage(chat.id, helpText.trim()); + return NextResponse.json({ ok: true }); + } + + if (text === '/referral') { + const referralText = ` +🎁 Ваша реферальная ссылка: + +Чтобы получить ссылку, откройте приложение и перейдите в раздел "Пригласить друга". + +Там вы найдете свою уникальную ссылку и сможете поделиться ей! + `; + + 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' + }); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..512585e --- /dev/null +++ b/app/dashboard/page.tsx @@ -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(null); + const [subscriptionStatus, setSubscriptionStatus] = useState<'active' | 'expired' | 'trial'>('active'); + const [expiryDate, setExpiryDate] = useState(''); + const [daysRemaining, setDaysRemaining] = useState(0); + const [username, setUsername] = useState(''); + 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 ( +
+
+
+

Загрузка...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

🚀 Umbrix VPN

+ + + +
+
+ + {/* Main Content */} +
+ {/* Subscription Status Card */} +
+
+
+

Ваша подписка

+

@{username}

+
+ {subscriptionStatus === 'active' ? ( +
+ + Активна +
+ ) : ( +
+ + Истекла +
+ )} +
+ +
+
+
+ + Осталось дней +
+

{daysRemaining}

+

{expiryDate}

+
+ +
+
+ + Рефералы +
+

{referralCount}

+

+{bonusDays} дней заработано

+
+
+
+ + {/* Quick Actions */} +
+ + + + + + + + + +
+ + {/* Referral Progress (if has referrals) */} + {referralCount > 0 && ( +
+

🎉 Прогресс реферальной программы

+
+
+
+ До следующего бонуса + {referralCount}/5 +
+
+
+
+
+

+ {referralCount >= 5 + ? '🎁 Вы получили месяц в подарок!' + : `Пригласите еще ${5 - referralCount} друзей и получите месяц бесплатно!`} +

+
+
+ )} + + {/* Setup Guide Link */} + +
+
+ +
+

Инструкция по настройке

+

Как подключить VPN на вашем устройстве

+
+
+ +
+ + + {/* Spacer для BottomNav */} +
+
+ + {/* Modals */} + {isQROpen && subscriptionUrl && ( + setIsQROpen(false)} /> + )} + + {isReferralOpen && ( + 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 && ( +
+ {toastMessage} +
+ )} +
+ ); +} diff --git a/app/globals.css b/app/globals.css index ec5f733..b5d33b3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,6 +2,10 @@ @tailwind components; @tailwind utilities; +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom, 0px); +} + :root { /* Hiddify Colors */ --primary: #2fbea5; @@ -42,3 +46,8 @@ body { .animate-marquee { animation: marquee 20s linear infinite; } + +/* Safe area для iOS (нижняя полоска) */ +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom, 0px); +} diff --git a/app/help/page.tsx b/app/help/page.tsx index 5c3c0c4..5e308e6 100644 --- a/app/help/page.tsx +++ b/app/help/page.tsx @@ -253,8 +253,8 @@ export default function HelpPage() { - {/* Spacer для нижнего отступа */} -
+ {/* Spacer для нижней навигации */} +
); diff --git a/app/layout.tsx b/app/layout.tsx index 7d61831..822d7aa 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,20 +1,20 @@ import './globals.css'; import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; +import BottomNav from '@/components/BottomNav'; 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 = { title: 'Umbrix VPN', description: 'Быстрый и безопасный VPN сервис', + themeColor: '#191f23', + viewport: { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, + }, openGraph: { title: 'Umbrix VPN', description: 'Быстрый и безопасный VPN сервис', @@ -46,7 +46,10 @@ export default function RootLayout({ - {children} + + {children} + + ); } diff --git a/app/page.tsx b/app/page.tsx index 8f134c7..226b778 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -317,7 +317,7 @@ export default function Home() { {/* Action Buttons */} -
+
{/* Показываем trial только если нет активной подписки */} {!hasSubscription && ( (window.location.href = '/plans')} /> - - } - text="Настроить VPN" - onClick={() => {}} - /> -
@@ -425,14 +418,6 @@ export default function Home() { /> - - } - label="⚙️ Настройки" - onClick={() => setIsMenuOpen(false)} - /> - - } label="💬 Поддержка" diff --git a/app/plans/page.tsx b/app/plans/page.tsx index 20339a0..e4d395d 100644 --- a/app/plans/page.tsx +++ b/app/plans/page.tsx @@ -1,82 +1,243 @@ 'use client'; -import { useState } from 'react'; -import { ArrowLeft, Check } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { ArrowLeft, Check, ChevronRight, Loader2 } from 'lucide-react'; 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; +} + +const PLANS: Record = { + 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 = { + '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 [isLoading, setIsLoading] = useState(false); + const [step, setStep] = useState('plan'); + const [selectedPlan, setSelectedPlan] = useState(null); + const [selectedPeriod, setSelectedPeriod] = useState('1month'); + const [selectedLocations, setSelectedLocations] = useState([]); + const [availableLocations, setAvailableLocations] = useState([]); + const [isLoadingLocations, setIsLoadingLocations] = useState(false); + const [isCreatingUser, setIsCreatingUser] = useState(false); - const handlePurchase = async (planType: string) => { - // Проверяем есть ли уже активная подписка ЧЕРЕЗ API - const telegramWebApp = (window as any).Telegram?.WebApp; - const telegramId = telegramWebApp?.initDataUnsafe?.user?.id; - 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; - } - } + // Загрузить локации при переходе к шагу выбора + useEffect(() => { + if (step === 'locations' && availableLocations.length === 0) { + loadLocations(); } + }, [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 { 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 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', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - planType: planType, - telegramId: telegramId, - telegramUsername: telegramUsername, - firstName: firstName, - lastName: lastName, + planType, + period, + locationIds, + telegramId: user?.id || null, + telegramUsername: user?.username || null, + firstName: user?.first_name || null, + lastName: user?.last_name || null, }), }); const data = await response.json(); - if (!data.success) { + if (data.success) { + // Успех - возвращаемся на главную + router.push('/'); + } else { throw new Error(data.error || 'Failed to create subscription'); } - - // Просто возвращаемся на главную - там данные обновятся автоматически - router.push('/'); } catch (error) { - console.error('Purchase error:', error); + console.error('Create user error:', error); alert('Ошибка при создании подписки. Попробуйте позже.'); + setStep('plan'); } 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 (
- -

- Выбрать тариф + {step === 'plan' ? ( + + ) : step !== 'processing' && ( + + )} +

+ {step === 'plan' && 'Выбор тарифа'} + {step === 'period' && `Период подписки${selectedPlan ? ` · ${PLANS[selectedPlan].name}` : ''}`} + {step === 'locations' && 'Выбор локаций'} + {step === 'processing' && 'Создание подписки...'}

-
- {/* Trial - полная ширина */} -
- handlePurchase('trial')} - isLoading={isLoading} - /> -
+
+ {/* Шаг 1: Выбор тарифа */} + {step === 'plan' && ( +
+ {/* Trial - полная ширина */} + handlePlanSelect('trial')} + /> - {/* Grid 2x2 для остальных тарифов */} -
- {/* Start */} - handlePurchase('start')} - isLoading={isLoading} - /> + {/* Grid 2x2 для остальных */} +
+ {(['start', 'plus', 'max'] as PlanType[]).map((planKey) => { + const plan = PLANS[planKey]; + return ( + handlePlanSelect(planKey)} + /> + ); + })} - {/* Plus */} - handlePurchase('plus')} - isLoading={isLoading} - /> - - {/* Max */} - handlePurchase('max')} - isLoading={isLoading} - /> - - {/* Empty slot or promo */} -
-
-
💰
-
Скоро новые
-
тарифы
+ {/* Empty slot */} +
+
+
💰
+
Скоро новые
+
тарифы
+
+
-
+ )} + + {/* Шаг 2: Выбор периода */} + {step === 'period' && selectedPlan && ( +
+ {/* Информация о тарифе */} +
+
+
+
Выбран тариф
+
{PLANS[selectedPlan].name}
+
+
+
Локации
+
+ {selectedPlan === 'plus' ? '3 на выбор' : 'Все'} +
+
+
+
Трафик
+
{PLANS[selectedPlan].dataLimit}
+
+
+
+ + {/* Grid 2x2 для периодов */} +
+ {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 ( + + ); + })} +
+
+ )} + + {/* Шаг 3: Выбор локаций */} + {step === 'locations' && selectedPlan && ( +
+
+ {PLANS[selectedPlan].maxLocations === 999 + ? 'Доступны все локации' + : `Выберите до ${PLANS[selectedPlan].maxLocations} локаций`} +
+ + {isLoadingLocations ? ( +
+ +

Загрузка локаций...

+
+ ) : ( +
+ {availableLocations.map((location) => { + const isSelected = selectedLocations.includes(location.id); + const isDisabled = + !isSelected && + PLANS[selectedPlan].maxLocations !== 999 && + selectedLocations.length >= PLANS[selectedPlan].maxLocations; + + return ( + + ); + })} +
+ )} + + {selectedLocations.length > 0 && ( + + )} +
+ )} + + {/* Шаг 4: Processing */} + {step === 'processing' && ( +
+ +

Создаем вашу подписку...

+

Это займет несколько секунд

+
+ )} + + {/* Spacer для нижней навигации */} +
); } +// PlanCard Component function PlanCard({ badge, title, @@ -182,8 +495,7 @@ function PlanCard({ buttonText, isPrimary = false, isPopular = false, - onPurchase, - isLoading = false, + onSelect, }: { badge: string; title: string; @@ -192,65 +504,43 @@ function PlanCard({ buttonText: string; isPrimary?: boolean; isPopular?: boolean; - onPurchase: () => void; - isLoading?: boolean; + onSelect: () => void; }) { return (
{isPopular && ( -
- ⭐ ТОП +
+ Популярный
)} -
{badge}
-

- {title} -

-
+
{badge}
+

{title}

+

{price} -

- -
    - {features.map((feature, i) => ( -
  • - +

    +
      + {features.map((feature, index) => ( +
    • + {feature}
    • ))}
    -
); diff --git a/app/referral/page.tsx b/app/referral/page.tsx index 0651254..4936cbf 100644 --- a/app/referral/page.tsx +++ b/app/referral/page.tsx @@ -283,6 +283,9 @@ export default function ReferralPage() { )} + {/* Spacer для нижней навигации */} +
+ {/* Toast Notification */} {showToast && (
diff --git a/app/setup/page.tsx b/app/setup/page.tsx index f5502b2..ce8d12b 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -202,13 +202,8 @@ export default function SetupPage() {
- {/* Back Button */} - + {/* Spacer для нижней навигации */} +
); diff --git a/app/subscription/[token]/page.tsx b/app/subscription/[token]/page.tsx index 4bd8bf2..4573e56 100644 --- a/app/subscription/[token]/page.tsx +++ b/app/subscription/[token]/page.tsx @@ -302,6 +302,9 @@ export default function SubscriptionPage() { > 🔄 Обновить данные + + {/* Spacer для нижней навигации */} +
); diff --git a/components/BottomNav.tsx b/components/BottomNav.tsx new file mode 100644 index 0000000..f1d61b4 --- /dev/null +++ b/components/BottomNav.tsx @@ -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 ( + + ); +} diff --git a/components/OnboardingFlow.tsx b/components/OnboardingFlow.tsx new file mode 100644 index 0000000..07b4b34 --- /dev/null +++ b/components/OnboardingFlow.tsx @@ -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('welcome'); + const [selectedLanguage, setSelectedLanguage] = useState('ru'); + const [selectedPlan, setSelectedPlan] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + + // Telegram WebApp SDK + const [telegramWebApp, setTelegramWebApp] = useState(null); + const [telegramUser, setTelegramUser] = useState(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 ( +
+
+
🚀
+

Добро пожаловать в Umbrix VPN!

+ + {referrerId && ( +
+

+ 🎁 Вы перешли по ссылке от {referrerId}! +

+

+ Оба получите +7 дней бесплатно при регистрации! +

+
+ )} + +
+
+ +
+

Надежная защита

+

Ваши данные в безопасности

+
+
+
+ +
+

Высокая скорость

+

Без ограничений трафика

+
+
+
+ +
+

Простая настройка

+

Работает за 2 минуты

+
+
+
+ +

+ Нажмите "Начать" для создания аккаунта +

+
+
+ ); + } + + // Render Language Selection Step + if (step === 'language') { + return ( +
+
+
+ +

Выберите язык

+

Choose your language

+
+ +
+ {LANGUAGES.map((lang) => ( + + ))} +
+
+
+ ); + } + + // Render Plan Selection Step + if (step === 'plan') { + return ( +
+
+
+

Выберите тарифный план

+

7 дней бесплатно на любом тарифе!

+
+ +
+ {PLANS.map((plan) => { + const Icon = plan.icon; + return ( + + ); + })} +
+
+
+ ); + } + + // Render Creating Account Step + if (step === 'creating') { + return ( +
+
+
+

Создаем ваш аккаунт...

+

Подождите несколько секунд

+ {error && ( +
+

{error}

+
+ )} +
+
+ ); + } + + return null; +} diff --git a/hooks/useTelegramWebApp.ts b/hooks/useTelegramWebApp.ts index 66103a4..4b008ea 100644 --- a/hooks/useTelegramWebApp.ts +++ b/hooks/useTelegramWebApp.ts @@ -145,7 +145,7 @@ declare global { } export function useTelegramWebApp() { - const [webApp, setWebApp] = useState(null); + const [webApp, setWebApp] = useState(null); const [isReady, setIsReady] = useState(false); useEffect(() => { diff --git a/lib/marzban-api.ts b/lib/marzban-api.ts index 7537f21..5572a70 100644 --- a/lib/marzban-api.ts +++ b/lib/marzban-api.ts @@ -69,9 +69,9 @@ export class MarzbanApiClient { 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) { - 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 (старый метод) @@ -91,7 +91,7 @@ export class MarzbanApiClient { this.cache.set(`info_${token}`, { data, timestamp: Date.now() }); return data; } 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('Не удалось загрузить данные подписки'); } } diff --git a/tsconfig.json b/tsconfig.json index c714696..86824be 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, diff --git a/types/telegram.ts b/types/telegram.ts index 71c8dd5..e2f6123 100644 --- a/types/telegram.ts +++ b/types/telegram.ts @@ -56,10 +56,4 @@ export interface TelegramUserData { lastName: string | null; } -declare global { - interface Window { - Telegram?: { - WebApp: TelegramWebApp; - }; - } -} +// Window.Telegram декларация в hooks/useTelegramWebApp.ts