🔒 Аудит: безопасность, 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:
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
189
app/api/telegram-webhook/route.ts
Normal file
189
app/api/telegram-webhook/route.ts
Normal 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
304
app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -253,8 +253,8 @@ export default function HelpPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Spacer для нижнего отступа */}
|
||||
<div className="h-8" />
|
||||
{/* Spacer для нижней навигации */}
|
||||
<div className="h-20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
<head>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js" async></script>
|
||||
</head>
|
||||
<body className={inter.className}>{children}</body>
|
||||
<body className={inter.className}>
|
||||
{children}
|
||||
<BottomNav />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
17
app/page.tsx
17
app/page.tsx
@@ -317,7 +317,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="w-full max-w-md space-y-3">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
{/* Показываем trial только если нет активной подписки */}
|
||||
{!hasSubscription && (
|
||||
<ActionButton
|
||||
@@ -331,13 +331,6 @@ export default function Home() {
|
||||
text="Купить подписку от 99₽"
|
||||
onClick={() => (window.location.href = '/plans')}
|
||||
/>
|
||||
<Link href="/setup">
|
||||
<ActionButton
|
||||
icon={<Wrench className="w-5 h-5" />}
|
||||
text="Настроить VPN"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -425,14 +418,6 @@ export default function Home() {
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings">
|
||||
<MenuButton
|
||||
icon={<Settings className="w-5 h-5" />}
|
||||
label="⚙️ Настройки"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<MenuButton
|
||||
icon={<MessageCircle className="w-5 h-5" />}
|
||||
label="💬 Поддержка"
|
||||
|
||||
@@ -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<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 [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
|
||||
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 (
|
||||
<div
|
||||
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"
|
||||
style={{ borderColor: 'var(--border)', background: 'var(--bg-card)' }}
|
||||
>
|
||||
<button onClick={() => router.push('/')} 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' ? (
|
||||
<button onClick={() => router.push('/')} className="p-2 hover:bg-slate-700 rounded-lg transition-colors">
|
||||
<ArrowLeft className="w-6 h-6" />
|
||||
</button>
|
||||
) : step !== 'processing' && (
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main className="p-4">
|
||||
{/* Trial - полная ширина */}
|
||||
<div className="mb-3">
|
||||
<PlanCard
|
||||
badge="🎁 Пробный период"
|
||||
title="7 дней бесплатно"
|
||||
price="0₽"
|
||||
features={[
|
||||
'Все тарифы доступны',
|
||||
'Безлимитный трафик',
|
||||
'Любые локации',
|
||||
]}
|
||||
buttonText="Попробовать"
|
||||
isPrimary
|
||||
onPurchase={() => handlePurchase('trial')}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<main className="p-4 pb-24">
|
||||
{/* Шаг 1: Выбор тарифа */}
|
||||
{step === 'plan' && (
|
||||
<div className="space-y-3">
|
||||
{/* Trial - полная ширина */}
|
||||
<PlanCard
|
||||
{...PLANS.trial}
|
||||
title="7 дней бесплатно"
|
||||
price="0₽"
|
||||
features={['Все тарифы доступны', 'Безлимитный трафик', 'Любые локации']}
|
||||
buttonText="Попробовать"
|
||||
isPrimary
|
||||
onSelect={() => handlePlanSelect('trial')}
|
||||
/>
|
||||
|
||||
{/* Grid 2x2 для остальных тарифов */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Start */}
|
||||
<PlanCard
|
||||
badge="🌍 Старт"
|
||||
title="Базовый"
|
||||
price="100₽/мес"
|
||||
features={['1 устройство', '1 локация', '50 ГБ']}
|
||||
buttonText="Выбрать"
|
||||
onPurchase={() => handlePurchase('start')}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{/* Grid 2x2 для остальных */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(['start', 'plus', 'max'] as PlanType[]).map((planKey) => {
|
||||
const plan = PLANS[planKey];
|
||||
return (
|
||||
<PlanCard
|
||||
key={planKey}
|
||||
{...plan}
|
||||
title={plan.name}
|
||||
price={`${plan.prices['1month']}₽/мес`}
|
||||
features={[
|
||||
plan.maxLocations === 999 ? 'Все локации' : `${plan.maxLocations} локаций`,
|
||||
plan.dataLimit,
|
||||
'Безлимитная скорость',
|
||||
]}
|
||||
buttonText="Выбрать"
|
||||
isPopular={planKey === 'max'}
|
||||
onSelect={() => handlePlanSelect(planKey)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Plus */}
|
||||
<PlanCard
|
||||
badge="🌎 Плюс"
|
||||
title="Расширенный"
|
||||
price="299₽/мес"
|
||||
features={['3 устройства', '3 локации', '299 ГБ']}
|
||||
buttonText="Выбрать"
|
||||
onPurchase={() => handlePurchase('plus')}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Max */}
|
||||
<PlanCard
|
||||
badge="🌏 Макс"
|
||||
title="Премиум"
|
||||
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>
|
||||
{/* Empty slot */}
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className="p-3 rounded-xl border relative"
|
||||
className={`p-4 rounded-xl border relative`}
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
borderColor: isPrimary
|
||||
? 'var(--primary)'
|
||||
: isPopular
|
||||
? 'var(--primary)'
|
||||
: 'var(--border)',
|
||||
borderColor: isPopular ? 'var(--primary)' : 'var(--border)',
|
||||
}}
|
||||
>
|
||||
{isPopular && (
|
||||
<div
|
||||
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 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)' }}>
|
||||
Популярный
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs mb-1 opacity-70">{badge}</div>
|
||||
<h3
|
||||
className="font-bold mb-1 text-sm"
|
||||
style={{ color: 'var(--text-white)' }}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<div
|
||||
className="text-xl font-bold mb-2"
|
||||
style={{ color: 'var(--primary)' }}
|
||||
>
|
||||
<div className="text-sm opacity-80 mb-1">{badge}</div>
|
||||
<h3 className="text-xl font-bold mb-1">{title}</h3>
|
||||
<p className="text-2xl font-bold mb-3" style={{ color: 'var(--primary)' }}>
|
||||
{price}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5 mb-3">
|
||||
{features.map((feature, i) => (
|
||||
<li key={i} className="flex items-center gap-1.5 text-xs">
|
||||
<Check
|
||||
className="w-3.5 h-3.5 flex-shrink-0"
|
||||
style={{ color: 'var(--success)' }}
|
||||
/>
|
||||
</p>
|
||||
<ul className="space-y-2 mb-4">
|
||||
{features.map((feature, index) => (
|
||||
<li key={index} className="flex items-center gap-2 text-sm">
|
||||
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={onPurchase}
|
||||
disabled={isLoading}
|
||||
className="w-full py-2 rounded-lg font-semibold text-xs transition-all hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={onSelect}
|
||||
className="w-full py-2 rounded-lg font-medium transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
background: isPrimary ? 'var(--primary)' : 'var(--bg-elevated)',
|
||||
color: 'var(--text-white)',
|
||||
color: 'var(--text-white)'
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Оформление...' : buttonText}
|
||||
{buttonText}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -283,6 +283,9 @@ export default function ReferralPage() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Spacer для нижней навигации */}
|
||||
<div className="h-20" />
|
||||
|
||||
{/* Toast Notification */}
|
||||
{showToast && (
|
||||
<div className="fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 animate-fade-in">
|
||||
|
||||
@@ -202,13 +202,8 @@ export default function SetupPage() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Back Button */}
|
||||
<button
|
||||
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>
|
||||
{/* Spacer для нижней навигации */}
|
||||
<div className="h-20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -302,6 +302,9 @@ export default function SubscriptionPage() {
|
||||
>
|
||||
🔄 Обновить данные
|
||||
</button>
|
||||
|
||||
{/* Spacer для нижней навигации */}
|
||||
<div className="h-20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user