🔒 Аудит: безопасность, 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 {
|
try {
|
||||||
const {
|
const {
|
||||||
planType,
|
planType,
|
||||||
period = '1month', // NEW: период оплаты
|
period = '1month',
|
||||||
locationIds = [], // NEW: выбранные локации (ID нод)
|
locationIds = [],
|
||||||
telegramId,
|
telegramId,
|
||||||
telegramUsername,
|
telegramUsername,
|
||||||
firstName,
|
firstName,
|
||||||
@@ -79,7 +79,6 @@ export async function POST(request: NextRequest) {
|
|||||||
username = `user_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
username = `user_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ Generated username:', username);
|
|
||||||
logger.debug('✅ Generated username:', username);
|
logger.debug('✅ Generated username:', username);
|
||||||
logger.debug('📊 Telegram data:', { telegramId, telegramUsername, firstName, lastName });
|
logger.debug('📊 Telegram data:', { telegramId, telegramUsername, firstName, lastName });
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ function extractCountryCode(nodeName: string): string {
|
|||||||
|
|
||||||
// Конвертируем emoji флаг в код страны
|
// Конвертируем emoji флаг в код страны
|
||||||
const flag = flagMatch[0];
|
const flag = flagMatch[0];
|
||||||
const codePoints = [...flag].map(char => char.codePointAt(0)! - 0x1F1E6 + 65);
|
const codePoints = Array.from(flag).map(char => {
|
||||||
|
const codePoint = char.codePointAt(0);
|
||||||
|
return codePoint ? codePoint - 0x1F1E6 + 65 : 65;
|
||||||
|
});
|
||||||
return String.fromCharCode(...codePoints);
|
return String.fromCharCode(...codePoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
// app/api/proxy/[...path]/route.ts
|
// app/api/proxy/[...path]/route.ts
|
||||||
// API Proxy для обращения к Marzban серверу
|
// API Proxy для обращения к Marzban серверу подписок
|
||||||
|
// ТОЛЬКО разрешённые пути: /sub/{token}/*
|
||||||
|
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Белый список разрешённых path-паттернов (regex)
|
||||||
|
const ALLOWED_PATHS = [
|
||||||
|
/^sub\/[a-zA-Z0-9_-]+\/?$/, // /sub/{token}/
|
||||||
|
/^sub\/[a-zA-Z0-9_-]+\/info$/, // /sub/{token}/info
|
||||||
|
/^sub\/[a-zA-Z0-9_-]+\/usage$/, // /sub/{token}/usage
|
||||||
|
/^sub\/[a-zA-Z0-9_-]+\/(sing-box|clash-meta|clash|outline|v2ray|v2ray-json)$/, // configs
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPathAllowed(path: string): boolean {
|
||||||
|
return ALLOWED_PATHS.some((pattern) => pattern.test(path));
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -7,9 +22,19 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const path = params.path.join('/');
|
const path = params.path.join('/');
|
||||||
const baseUrl = 'https://umbrix2.3to3.sbs';
|
const baseUrl = 'https://umbrix2.3to3.sbs';
|
||||||
|
|
||||||
|
// Проверяем что путь в белом списке
|
||||||
|
if (!isPathAllowed(path)) {
|
||||||
|
logger.warn(`[Proxy] Blocked path: ${path}`);
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Forbidden: path not allowed' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const url = `${baseUrl}/${path}`;
|
const url = `${baseUrl}/${path}`;
|
||||||
|
|
||||||
console.log('[Proxy] Fetching:', url);
|
logger.debug('[Proxy] Fetching:', url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -17,27 +42,22 @@ export async function GET(
|
|||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'User-Agent': 'Umbrix-TelegramBot/1.0',
|
'User-Agent': 'Umbrix-TelegramBot/1.0',
|
||||||
},
|
},
|
||||||
// Отключаем кэш для актуальных данных
|
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('[Proxy] HTTP error:', response.status, response.statusText);
|
logger.error('[Proxy] HTTP error:', response.status);
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type');
|
const contentType = response.headers.get('content-type');
|
||||||
|
|
||||||
// Если это JSON - парсим
|
|
||||||
if (contentType?.includes('application/json')) {
|
if (contentType?.includes('application/json')) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('[Proxy] Success (JSON):', Object.keys(data));
|
|
||||||
return Response.json(data);
|
return Response.json(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если это текст/HTML - возвращаем как есть
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
console.log('[Proxy] Success (Text):', text.substring(0, 100));
|
|
||||||
return new Response(text, {
|
return new Response(text, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType || 'text/plain',
|
'Content-Type': contentType || 'text/plain',
|
||||||
@@ -45,46 +65,18 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Proxy] Error:', error);
|
logger.error('[Proxy] Error:', error);
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{ error: 'Failed to fetch data from subscription server' },
|
||||||
error: 'Failed to fetch data from Marzban server',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
url: url,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Также поддерживаем POST для будущих API calls
|
// POST запрещён — нет причин для POST к серверу подписок
|
||||||
export async function POST(
|
export async function POST() {
|
||||||
request: Request,
|
return Response.json(
|
||||||
{ params }: { params: { path: string[] } }
|
{ error: 'Method not allowed' },
|
||||||
) {
|
{ status: 405 }
|
||||||
const path = params.path.join('/');
|
);
|
||||||
const baseUrl = 'https://umbrix2.3to3.sbs';
|
|
||||||
const url = `${baseUrl}/${path}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return Response.json(data);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: 'Failed to post data' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import mysql from 'mysql2/promise';
|
import mysql from 'mysql2/promise';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
// Database connection config from ENV
|
// Database connection config from ENV
|
||||||
const dbConfig = {
|
const dbConfig = {
|
||||||
@@ -31,7 +32,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// Return mock data if DB not configured (dev mode)
|
// Return mock data if DB not configured (dev mode)
|
||||||
if (!isDbConfigured) {
|
if (!isDbConfigured) {
|
||||||
console.log('⚠️ Referral stats skipped: DB not configured');
|
logger.info('⚠️ Referral stats skipped: DB not configured');
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
hasReferrals: false,
|
hasReferrals: false,
|
||||||
@@ -98,7 +99,7 @@ export async function GET(request: NextRequest) {
|
|||||||
await connection.end();
|
await connection.end();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Referral stats error:', error);
|
logger.error('Referral stats error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import mysql from 'mysql2/promise';
|
import mysql from 'mysql2/promise';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
// Database connection config from ENV
|
// Database connection config from ENV
|
||||||
const dbConfig = {
|
const dbConfig = {
|
||||||
@@ -31,7 +32,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Return mock success if DB not configured (dev mode)
|
// Return mock success if DB not configured (dev mode)
|
||||||
if (!isDbConfigured) {
|
if (!isDbConfigured) {
|
||||||
console.log('⚠️ Referral tracking skipped: DB not configured');
|
logger.info('⚠️ Referral tracking skipped: DB not configured');
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Referral tracking disabled (dev mode)',
|
message: 'Referral tracking disabled (dev mode)',
|
||||||
@@ -138,7 +139,7 @@ export async function POST(request: NextRequest) {
|
|||||||
await connection.end();
|
await connection.end();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Referral track error:', error);
|
logger.error('Referral track error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
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 components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Hiddify Colors */
|
/* Hiddify Colors */
|
||||||
--primary: #2fbea5;
|
--primary: #2fbea5;
|
||||||
@@ -42,3 +46,8 @@ body {
|
|||||||
.animate-marquee {
|
.animate-marquee {
|
||||||
animation: marquee 20s linear infinite;
|
animation: marquee 20s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Safe area для iOS (нижняя полоска) */
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -253,8 +253,8 @@ export default function HelpPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Spacer для нижнего отступа */}
|
{/* Spacer для нижней навигации */}
|
||||||
<div className="h-8" />
|
<div className="h-20" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import './globals.css';
|
import './globals.css';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
|
import BottomNav from '@/components/BottomNav';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
export const viewport = {
|
|
||||||
width: 'device-width',
|
|
||||||
initialScale: 1,
|
|
||||||
maximumScale: 1,
|
|
||||||
userScalable: false, // Отключаем зум для mini app
|
|
||||||
themeColor: '#191f23', // Цвет темы Umbrix
|
|
||||||
};
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Umbrix VPN',
|
title: 'Umbrix VPN',
|
||||||
description: 'Быстрый и безопасный VPN сервис',
|
description: 'Быстрый и безопасный VPN сервис',
|
||||||
|
themeColor: '#191f23',
|
||||||
|
viewport: {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'Umbrix VPN',
|
title: 'Umbrix VPN',
|
||||||
description: 'Быстрый и безопасный VPN сервис',
|
description: 'Быстрый и безопасный VPN сервис',
|
||||||
@@ -46,7 +46,10 @@ export default function RootLayout({
|
|||||||
<head>
|
<head>
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js" async></script>
|
<script src="https://telegram.org/js/telegram-web-app.js" async></script>
|
||||||
</head>
|
</head>
|
||||||
<body className={inter.className}>{children}</body>
|
<body className={inter.className}>
|
||||||
|
{children}
|
||||||
|
<BottomNav />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
17
app/page.tsx
17
app/page.tsx
@@ -317,7 +317,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="w-full max-w-md space-y-3">
|
<div className="w-full max-w-md space-y-4">
|
||||||
{/* Показываем trial только если нет активной подписки */}
|
{/* Показываем trial только если нет активной подписки */}
|
||||||
{!hasSubscription && (
|
{!hasSubscription && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -331,13 +331,6 @@ export default function Home() {
|
|||||||
text="Купить подписку от 99₽"
|
text="Купить подписку от 99₽"
|
||||||
onClick={() => (window.location.href = '/plans')}
|
onClick={() => (window.location.href = '/plans')}
|
||||||
/>
|
/>
|
||||||
<Link href="/setup">
|
|
||||||
<ActionButton
|
|
||||||
icon={<Wrench className="w-5 h-5" />}
|
|
||||||
text="Настроить VPN"
|
|
||||||
onClick={() => {}}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -425,14 +418,6 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings">
|
|
||||||
<MenuButton
|
|
||||||
icon={<Settings className="w-5 h-5" />}
|
|
||||||
label="⚙️ Настройки"
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<MenuButton
|
<MenuButton
|
||||||
icon={<MessageCircle className="w-5 h-5" />}
|
icon={<MessageCircle className="w-5 h-5" />}
|
||||||
label="💬 Поддержка"
|
label="💬 Поддержка"
|
||||||
|
|||||||
@@ -1,82 +1,243 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { ArrowLeft, Check } from 'lucide-react';
|
import { ArrowLeft, Check, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export default function Plans() {
|
type PlanType = 'trial' | 'start' | 'plus' | 'max';
|
||||||
|
type Period = '1month' | '3months' | '6months' | '1year';
|
||||||
|
type Step = 'plan' | 'period' | 'locations' | 'processing';
|
||||||
|
|
||||||
|
interface Location {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
ping: string;
|
||||||
|
country: string;
|
||||||
|
flag: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlanConfig {
|
||||||
|
name: string;
|
||||||
|
badge: string;
|
||||||
|
maxLocations: number;
|
||||||
|
dataLimit: string;
|
||||||
|
prices: Record<Period, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLANS: Record<PlanType, PlanConfig> = {
|
||||||
|
trial: {
|
||||||
|
name: 'Пробный',
|
||||||
|
badge: '🎁 Пробный период',
|
||||||
|
maxLocations: 999,
|
||||||
|
dataLimit: 'Безлимит',
|
||||||
|
prices: {
|
||||||
|
'1month': 0,
|
||||||
|
'3months': 0,
|
||||||
|
'6months': 0,
|
||||||
|
'1year': 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
name: 'Старт',
|
||||||
|
badge: '🌍 Старт',
|
||||||
|
maxLocations: 1,
|
||||||
|
dataLimit: '50 ГБ',
|
||||||
|
prices: {
|
||||||
|
'1month': 149,
|
||||||
|
'3months': 399, // -11% (133₽/мес)
|
||||||
|
'6months': 719, // -20% (119₽/мес)
|
||||||
|
'1year': 1349, // -25% (112₽/мес)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plus: {
|
||||||
|
name: 'Плюс',
|
||||||
|
badge: '🌎 Плюс',
|
||||||
|
maxLocations: 3,
|
||||||
|
dataLimit: '299 ГБ',
|
||||||
|
prices: {
|
||||||
|
'1month': 249,
|
||||||
|
'3months': 649, // -13% (216₽/мес)
|
||||||
|
'6months': 1199, // -20% (199₽/мес)
|
||||||
|
'1year': 2249, // -25% (187₽/мес)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
name: 'Макс',
|
||||||
|
badge: '🌏 Макс',
|
||||||
|
maxLocations: 0, // Все локации автоматически, без выбора
|
||||||
|
dataLimit: 'Безлимит',
|
||||||
|
prices: {
|
||||||
|
'1month': 350,
|
||||||
|
'3months': 949, // -10% (316₽/мес)
|
||||||
|
'6months': 1799, // -14% (299₽/мес)
|
||||||
|
'1year': 3349, // -20% (279₽/мес)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERIOD_LABELS: Record<Period, { label: string; months: number }> = {
|
||||||
|
'1month': { label: '1 месяц', months: 1 },
|
||||||
|
'3months': { label: '3 месяца', months: 3 },
|
||||||
|
'6months': { label: '6 месяцев', months: 6 },
|
||||||
|
'1year': { label: '1 год', months: 12 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlansNew() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [step, setStep] = useState<Step>('plan');
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<PlanType | null>(null);
|
||||||
|
const [selectedPeriod, setSelectedPeriod] = useState<Period>('1month');
|
||||||
|
const [selectedLocations, setSelectedLocations] = useState<number[]>([]);
|
||||||
|
const [availableLocations, setAvailableLocations] = useState<Location[]>([]);
|
||||||
|
const [isLoadingLocations, setIsLoadingLocations] = useState(false);
|
||||||
|
const [isCreatingUser, setIsCreatingUser] = useState(false);
|
||||||
|
|
||||||
const handlePurchase = async (planType: string) => {
|
// Загрузить локации при переходе к шагу выбора
|
||||||
// Проверяем есть ли уже активная подписка ЧЕРЕЗ API
|
useEffect(() => {
|
||||||
const telegramWebApp = (window as any).Telegram?.WebApp;
|
if (step === 'locations' && availableLocations.length === 0) {
|
||||||
const telegramId = telegramWebApp?.initDataUnsafe?.user?.id;
|
loadLocations();
|
||||||
const telegramUsername = telegramWebApp?.initDataUnsafe?.user?.username;
|
|
||||||
|
|
||||||
if (telegramId || telegramUsername) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (telegramId) params.append('telegramId', telegramId.toString());
|
|
||||||
if (telegramUsername) params.append('telegramUsername', telegramUsername);
|
|
||||||
|
|
||||||
const checkResponse = await fetch(`/api/user-subscription?${params.toString()}`);
|
|
||||||
const checkData = await checkResponse.json();
|
|
||||||
|
|
||||||
if (checkData.success && checkData.hasSubscription) {
|
|
||||||
const confirmOverwrite = confirm('У вас уже есть активная подписка. Создать новую? (старая будет заменена)');
|
|
||||||
if (!confirmOverwrite) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
setIsLoading(true);
|
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);
|
||||||
|
|
||||||
|
// Trial период - сразу создаем без выбора периода и локаций
|
||||||
|
if (plan === 'trial') {
|
||||||
|
createUser(plan, '1month', []);
|
||||||
|
} else {
|
||||||
|
setStep('period');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePeriodSelect(period: Period) {
|
||||||
|
setSelectedPeriod(period);
|
||||||
|
|
||||||
|
// Только для PLUS показываем выбор локаций
|
||||||
|
// Для остальных тарифов - все локации автоматически
|
||||||
|
if (selectedPlan === 'plus') {
|
||||||
|
setStep('locations');
|
||||||
|
} else {
|
||||||
|
// Start и Max - создаем сразу со всеми локациями
|
||||||
|
createUser(selectedPlan!, period, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLocationToggle(locationId: number) {
|
||||||
|
if (!selectedPlan) return;
|
||||||
|
|
||||||
|
const maxLocations = PLANS[selectedPlan].maxLocations;
|
||||||
|
|
||||||
|
if (selectedLocations.includes(locationId)) {
|
||||||
|
setSelectedLocations(selectedLocations.filter(id => id !== locationId));
|
||||||
|
} else if (maxLocations === 999 || selectedLocations.length < maxLocations) {
|
||||||
|
setSelectedLocations([...selectedLocations, locationId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(planType: PlanType, period: Period, locationIds: number[]) {
|
||||||
|
setStep('processing');
|
||||||
|
setIsCreatingUser(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const telegramWebApp = (window as any).Telegram?.WebApp;
|
const telegramWebApp = (window as any).Telegram?.WebApp;
|
||||||
console.log('🔍 Telegram WebApp объект:', telegramWebApp);
|
|
||||||
console.log('🔍 initData:', telegramWebApp?.initData);
|
|
||||||
console.log('🔍 initDataUnsafe (FULL):', JSON.stringify(telegramWebApp?.initDataUnsafe, null, 2));
|
|
||||||
console.log('🔍 user (FULL):', JSON.stringify(telegramWebApp?.initDataUnsafe?.user, null, 2));
|
|
||||||
|
|
||||||
const user = telegramWebApp?.initDataUnsafe?.user;
|
const user = telegramWebApp?.initDataUnsafe?.user;
|
||||||
const telegramId = user?.id || null;
|
|
||||||
const telegramUsername = user?.username || null;
|
|
||||||
const firstName = user?.first_name || null;
|
|
||||||
const lastName = user?.last_name || null;
|
|
||||||
|
|
||||||
console.log('📤 Отправляем:', { planType, telegramId, telegramUsername, firstName, lastName });
|
|
||||||
|
|
||||||
// Вызываем API для создания реального пользователя в Marzban
|
|
||||||
const response = await fetch('/api/create-user', {
|
const response = await fetch('/api/create-user', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
planType: planType,
|
planType,
|
||||||
telegramId: telegramId,
|
period,
|
||||||
telegramUsername: telegramUsername,
|
locationIds,
|
||||||
firstName: firstName,
|
telegramId: user?.id || null,
|
||||||
lastName: lastName,
|
telegramUsername: user?.username || null,
|
||||||
|
firstName: user?.first_name || null,
|
||||||
|
lastName: user?.last_name || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!data.success) {
|
if (data.success) {
|
||||||
|
// Успех - возвращаемся на главную
|
||||||
|
router.push('/');
|
||||||
|
} else {
|
||||||
throw new Error(data.error || 'Failed to create subscription');
|
throw new Error(data.error || 'Failed to create subscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Просто возвращаемся на главную - там данные обновятся автоматически
|
|
||||||
router.push('/');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Purchase error:', error);
|
console.error('Create user error:', error);
|
||||||
alert('Ошибка при создании подписки. Попробуйте позже.');
|
alert('Ошибка при создании подписки. Попробуйте позже.');
|
||||||
|
setStep('plan');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsCreatingUser(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function handlePurchase() {
|
||||||
|
if (!selectedPlan || selectedLocations.length === 0) {
|
||||||
|
alert('Выберите хотя бы одну локацию');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createUser(selectedPlan, selectedPeriod, selectedLocations);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDiscount(period: Period): number {
|
||||||
|
const discounts = {
|
||||||
|
'1month': 0,
|
||||||
|
'3months': 10,
|
||||||
|
'6months': 15,
|
||||||
|
'1year': 20,
|
||||||
|
};
|
||||||
|
return discounts[period];
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateSavings(plan: PlanType, period: Period): number {
|
||||||
|
if (plan === 'trial') return 0;
|
||||||
|
|
||||||
|
const config = PLANS[plan];
|
||||||
|
const monthlyPrice = config.prices['1month'];
|
||||||
|
const months = PERIOD_LABELS[period].months;
|
||||||
|
const fullPrice = monthlyPrice * months;
|
||||||
|
const discountedPrice = config.prices[period];
|
||||||
|
return fullPrice - discountedPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (step === 'period') {
|
||||||
|
setStep('plan');
|
||||||
|
} else if (step === 'locations') {
|
||||||
|
setStep('period');
|
||||||
|
} else if (step === 'processing') {
|
||||||
|
setStep('locations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canProceedFromLocations =
|
||||||
|
selectedPlan &&
|
||||||
|
(PLANS[selectedPlan].maxLocations === 999 || selectedLocations.length >= PLANS[selectedPlan].maxLocations);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen"
|
className="min-h-screen"
|
||||||
@@ -87,93 +248,245 @@ export default function Plans() {
|
|||||||
className="flex items-center gap-3 p-4 border-b sticky top-0 z-10 backdrop-blur-sm"
|
className="flex items-center gap-3 p-4 border-b sticky top-0 z-10 backdrop-blur-sm"
|
||||||
style={{ borderColor: 'var(--border)', background: 'var(--bg-card)' }}
|
style={{ borderColor: 'var(--border)', background: 'var(--bg-card)' }}
|
||||||
>
|
>
|
||||||
<button onClick={() => router.push('/')} className="p-2 hover:bg-slate-700 rounded-lg transition-colors">
|
{step === 'plan' ? (
|
||||||
<ArrowLeft className="w-6 h-6" />
|
<button onClick={() => router.push('/')} className="p-2 hover:bg-slate-700 rounded-lg transition-colors">
|
||||||
</button>
|
<ArrowLeft className="w-6 h-6" />
|
||||||
<h1
|
</button>
|
||||||
className="text-xl font-bold"
|
) : step !== 'processing' && (
|
||||||
style={{ color: 'var(--text-white)' }}
|
<button onClick={goBack} className="p-2 hover:bg-slate-700 rounded-lg transition-colors">
|
||||||
>
|
<ArrowLeft className="w-6 h-6" />
|
||||||
Выбрать тариф
|
</button>
|
||||||
|
)}
|
||||||
|
<h1 className="text-xl font-bold" style={{ color: 'var(--text-white)' }}>
|
||||||
|
{step === 'plan' && 'Выбор тарифа'}
|
||||||
|
{step === 'period' && `Период подписки${selectedPlan ? ` · ${PLANS[selectedPlan].name}` : ''}`}
|
||||||
|
{step === 'locations' && 'Выбор локаций'}
|
||||||
|
{step === 'processing' && 'Создание подписки...'}
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="p-4">
|
<main className="p-4 pb-24">
|
||||||
{/* Trial - полная ширина */}
|
{/* Шаг 1: Выбор тарифа */}
|
||||||
<div className="mb-3">
|
{step === 'plan' && (
|
||||||
<PlanCard
|
<div className="space-y-3">
|
||||||
badge="🎁 Пробный период"
|
{/* Trial - полная ширина */}
|
||||||
title="7 дней бесплатно"
|
<PlanCard
|
||||||
price="0₽"
|
{...PLANS.trial}
|
||||||
features={[
|
title="7 дней бесплатно"
|
||||||
'Все тарифы доступны',
|
price="0₽"
|
||||||
'Безлимитный трафик',
|
features={['Все тарифы доступны', 'Безлимитный трафик', 'Любые локации']}
|
||||||
'Любые локации',
|
buttonText="Попробовать"
|
||||||
]}
|
isPrimary
|
||||||
buttonText="Попробовать"
|
onSelect={() => handlePlanSelect('trial')}
|
||||||
isPrimary
|
/>
|
||||||
onPurchase={() => handlePurchase('trial')}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid 2x2 для остальных тарифов */}
|
{/* Grid 2x2 для остальных */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Start */}
|
{(['start', 'plus', 'max'] as PlanType[]).map((planKey) => {
|
||||||
<PlanCard
|
const plan = PLANS[planKey];
|
||||||
badge="🌍 Старт"
|
return (
|
||||||
title="Базовый"
|
<PlanCard
|
||||||
price="100₽/мес"
|
key={planKey}
|
||||||
features={['1 устройство', '1 локация', '50 ГБ']}
|
{...plan}
|
||||||
buttonText="Выбрать"
|
title={plan.name}
|
||||||
onPurchase={() => handlePurchase('start')}
|
price={`${plan.prices['1month']}₽/мес`}
|
||||||
isLoading={isLoading}
|
features={[
|
||||||
/>
|
plan.maxLocations === 999 ? 'Все локации' : `${plan.maxLocations} локаций`,
|
||||||
|
plan.dataLimit,
|
||||||
|
'Безлимитная скорость',
|
||||||
|
]}
|
||||||
|
buttonText="Выбрать"
|
||||||
|
isPopular={planKey === 'max'}
|
||||||
|
onSelect={() => handlePlanSelect(planKey)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Plus */}
|
{/* Empty slot */}
|
||||||
<PlanCard
|
<div
|
||||||
badge="🌎 Плюс"
|
className="p-4 rounded-xl border flex items-center justify-center"
|
||||||
title="Расширенный"
|
style={{
|
||||||
price="299₽/мес"
|
background: 'var(--bg-card)',
|
||||||
features={['3 устройства', '3 локации', '299 ГБ']}
|
borderColor: 'var(--border)',
|
||||||
buttonText="Выбрать"
|
borderStyle: 'dashed',
|
||||||
onPurchase={() => handlePurchase('plus')}
|
}}
|
||||||
isLoading={isLoading}
|
>
|
||||||
/>
|
<div className="text-center opacity-50">
|
||||||
|
<div className="text-2xl mb-1">💰</div>
|
||||||
{/* Max */}
|
<div className="text-xs">Скоро новые</div>
|
||||||
<PlanCard
|
<div className="text-xs">тарифы</div>
|
||||||
badge="🌏 Макс"
|
</div>
|
||||||
title="Премиум"
|
</div>
|
||||||
price="350₽/мес"
|
|
||||||
features={['5 устройств', '15+ стран', 'Безлимит']}
|
|
||||||
buttonText="Выбрать"
|
|
||||||
isPopular
|
|
||||||
onPurchase={() => handlePurchase('max')}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Empty slot or promo */}
|
|
||||||
<div
|
|
||||||
className="p-4 rounded-xl border flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
background: 'var(--bg-card)',
|
|
||||||
borderColor: 'var(--border)',
|
|
||||||
borderStyle: 'dashed',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-center opacity-50">
|
|
||||||
<div className="text-2xl mb-1">💰</div>
|
|
||||||
<div className="text-xs">Скоро новые</div>
|
|
||||||
<div className="text-xs">тарифы</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* Шаг 2: Выбор периода */}
|
||||||
|
{step === 'period' && selectedPlan && (
|
||||||
|
<div>
|
||||||
|
{/* Информация о тарифе */}
|
||||||
|
<div className="mb-4 p-3 rounded-lg" style={{ background: 'var(--bg-card)' }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-slate-400">Выбран тариф</div>
|
||||||
|
<div className="text-lg font-bold">{PLANS[selectedPlan].name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-slate-400">Локации</div>
|
||||||
|
<div className="text-lg font-bold">
|
||||||
|
{selectedPlan === 'plus' ? '3 на выбор' : 'Все'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-slate-400">Трафик</div>
|
||||||
|
<div className="text-lg font-bold">{PLANS[selectedPlan].dataLimit}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid 2x2 для периодов */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{Object.entries(PERIOD_LABELS).map(([periodKey, { label, months }]) => {
|
||||||
|
const period = periodKey as Period;
|
||||||
|
const price = PLANS[selectedPlan].prices[period];
|
||||||
|
const discount = calculateDiscount(period);
|
||||||
|
const savings = calculateSavings(selectedPlan, period);
|
||||||
|
const isRecommended = period === '6months'; // Самый выгодный
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={period}
|
||||||
|
onClick={() => handlePeriodSelect(period)}
|
||||||
|
className="p-4 rounded-xl border-2 text-center transition-all active:scale-95 relative hover:opacity-90"
|
||||||
|
style={{
|
||||||
|
background: isRecommended ? 'linear-gradient(135deg, rgba(47, 190, 165, 0.15) 0%, rgba(68, 163, 52, 0.15) 100%)' : 'var(--bg-card)',
|
||||||
|
borderColor: selectedPeriod === period ? 'var(--primary)' : 'var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Badge "выгодно" */}
|
||||||
|
{isRecommended && (
|
||||||
|
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-green-500 text-white text-xs rounded-full">
|
||||||
|
выгодно
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Период */}
|
||||||
|
<div className="text-base font-semibold mb-1">{label}</div>
|
||||||
|
|
||||||
|
{/* Старая цена */}
|
||||||
|
{discount > 0 && (
|
||||||
|
<div className="text-xs text-slate-500 line-through mb-1">
|
||||||
|
{PLANS[selectedPlan].prices['1month'] * months} ₽
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Новая цена */}
|
||||||
|
<div className="text-2xl font-bold mb-1">{price} ₽</div>
|
||||||
|
|
||||||
|
{/* Скидка */}
|
||||||
|
{discount > 0 && (
|
||||||
|
<div className="text-xs px-2 py-1 bg-green-500/20 text-green-400 rounded inline-block">
|
||||||
|
-{discount}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Цена за месяц */}
|
||||||
|
{months > 1 && (
|
||||||
|
<div className="text-xs text-slate-400 mt-2">
|
||||||
|
{Math.round(price / months)} ₽/мес
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Шаг 3: Выбор локаций */}
|
||||||
|
{step === 'locations' && selectedPlan && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm text-slate-400">
|
||||||
|
{PLANS[selectedPlan].maxLocations === 999
|
||||||
|
? 'Доступны все локации'
|
||||||
|
: `Выберите до ${PLANS[selectedPlan].maxLocations} локаций`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingLocations ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-500" />
|
||||||
|
<p className="mt-2 text-slate-400">Загрузка локаций...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableLocations.map((location) => {
|
||||||
|
const isSelected = selectedLocations.includes(location.id);
|
||||||
|
const isDisabled =
|
||||||
|
!isSelected &&
|
||||||
|
PLANS[selectedPlan].maxLocations !== 999 &&
|
||||||
|
selectedLocations.length >= PLANS[selectedPlan].maxLocations;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={location.id}
|
||||||
|
onClick={() => handleLocationToggle(location.id)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`w-full p-4 rounded-xl border transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-600/20 border-blue-500'
|
||||||
|
: isDisabled
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: 'hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? undefined : 'var(--bg-card)',
|
||||||
|
borderColor: isSelected ? undefined : 'var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isSelected && <Check className="w-5 h-5 text-blue-400" />}
|
||||||
|
<span className="text-lg">{location.flag}</span>
|
||||||
|
<span className="font-medium">{location.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-400">{location.ping}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedLocations.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handlePurchase}
|
||||||
|
className="w-full py-4 rounded-xl font-bold mt-6 flex items-center justify-center gap-2 transition-opacity hover:opacity-80"
|
||||||
|
style={{ background: 'var(--primary)', color: 'var(--text-white)' }}
|
||||||
|
>
|
||||||
|
Создать подписку {PLANS[selectedPlan].prices[selectedPeriod]}₽
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Шаг 4: Processing */}
|
||||||
|
{step === 'processing' && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Loader2 className="w-16 h-16 animate-spin mx-auto mb-4" style={{ color: 'var(--primary)' }} />
|
||||||
|
<h2 className="text-xl font-bold mb-2">Создаем вашу подписку...</h2>
|
||||||
|
<p className="text-slate-400">Это займет несколько секунд</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spacer для нижней навигации */}
|
||||||
|
<div className="h-20" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlanCard Component
|
||||||
function PlanCard({
|
function PlanCard({
|
||||||
badge,
|
badge,
|
||||||
title,
|
title,
|
||||||
@@ -182,8 +495,7 @@ function PlanCard({
|
|||||||
buttonText,
|
buttonText,
|
||||||
isPrimary = false,
|
isPrimary = false,
|
||||||
isPopular = false,
|
isPopular = false,
|
||||||
onPurchase,
|
onSelect,
|
||||||
isLoading = false,
|
|
||||||
}: {
|
}: {
|
||||||
badge: string;
|
badge: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -192,65 +504,43 @@ function PlanCard({
|
|||||||
buttonText: string;
|
buttonText: string;
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
isPopular?: boolean;
|
isPopular?: boolean;
|
||||||
onPurchase: () => void;
|
onSelect: () => void;
|
||||||
isLoading?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="p-3 rounded-xl border relative"
|
className={`p-4 rounded-xl border relative`}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-card)',
|
background: 'var(--bg-card)',
|
||||||
borderColor: isPrimary
|
borderColor: isPopular ? 'var(--primary)' : 'var(--border)',
|
||||||
? 'var(--primary)'
|
|
||||||
: isPopular
|
|
||||||
? 'var(--primary)'
|
|
||||||
: 'var(--border)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPopular && (
|
{isPopular && (
|
||||||
<div
|
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full text-xs font-bold" style={{ background: 'var(--primary)', color: 'var(--text-white)' }}>
|
||||||
className="absolute -top-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold"
|
Популярный
|
||||||
style={{ background: 'var(--primary)', color: 'var(--text-white)' }}
|
|
||||||
>
|
|
||||||
⭐ ТОП
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs mb-1 opacity-70">{badge}</div>
|
<div className="text-sm opacity-80 mb-1">{badge}</div>
|
||||||
<h3
|
<h3 className="text-xl font-bold mb-1">{title}</h3>
|
||||||
className="font-bold mb-1 text-sm"
|
<p className="text-2xl font-bold mb-3" style={{ color: 'var(--primary)' }}>
|
||||||
style={{ color: 'var(--text-white)' }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
className="text-xl font-bold mb-2"
|
|
||||||
style={{ color: 'var(--primary)' }}
|
|
||||||
>
|
|
||||||
{price}
|
{price}
|
||||||
</div>
|
</p>
|
||||||
|
<ul className="space-y-2 mb-4">
|
||||||
<ul className="space-y-1.5 mb-3">
|
{features.map((feature, index) => (
|
||||||
{features.map((feature, i) => (
|
<li key={index} className="flex items-center gap-2 text-sm">
|
||||||
<li key={i} className="flex items-center gap-1.5 text-xs">
|
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||||
<Check
|
|
||||||
className="w-3.5 h-3.5 flex-shrink-0"
|
|
||||||
style={{ color: 'var(--success)' }}
|
|
||||||
/>
|
|
||||||
<span>{feature}</span>
|
<span>{feature}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onPurchase}
|
onClick={onSelect}
|
||||||
disabled={isLoading}
|
className="w-full py-2 rounded-lg font-medium transition-opacity hover:opacity-80"
|
||||||
className="w-full py-2 rounded-lg font-semibold text-xs transition-all hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
style={{
|
style={{
|
||||||
background: isPrimary ? 'var(--primary)' : 'var(--bg-elevated)',
|
background: isPrimary ? 'var(--primary)' : 'var(--bg-elevated)',
|
||||||
color: 'var(--text-white)',
|
color: 'var(--text-white)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLoading ? 'Оформление...' : buttonText}
|
{buttonText}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -283,6 +283,9 @@ export default function ReferralPage() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Spacer для нижней навигации */}
|
||||||
|
<div className="h-20" />
|
||||||
|
|
||||||
{/* Toast Notification */}
|
{/* Toast Notification */}
|
||||||
{showToast && (
|
{showToast && (
|
||||||
<div className="fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 animate-fade-in">
|
<div className="fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 animate-fade-in">
|
||||||
|
|||||||
@@ -202,13 +202,8 @@ export default function SetupPage() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Back Button */}
|
{/* Spacer для нижней навигации */}
|
||||||
<button
|
<div className="h-20" />
|
||||||
onClick={() => router.push('/')}
|
|
||||||
className="w-full p-4 bg-slate-800/50 hover:bg-slate-700/50 rounded-lg transition-colors text-slate-300"
|
|
||||||
>
|
|
||||||
Вернуться на главную
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -302,6 +302,9 @@ export default function SubscriptionPage() {
|
|||||||
>
|
>
|
||||||
🔄 Обновить данные
|
🔄 Обновить данные
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Spacer для нижней навигации */}
|
||||||
|
<div className="h-20" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
51
components/BottomNav.tsx
Normal file
51
components/BottomNav.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { ChevronLeft, Home, HelpCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function BottomNav() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// На главной не показываем
|
||||||
|
if (pathname === '/') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-40 border-t safe-area-bottom"
|
||||||
|
style={{ borderColor: 'var(--border)', background: 'var(--bg-card)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-around py-2 max-w-2xl mx-auto">
|
||||||
|
{/* Назад */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="flex flex-col items-center gap-1 px-4 py-1 transition-all active:scale-95"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-6 h-6" />
|
||||||
|
<span className="text-[10px]">Назад</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Главная */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
className="flex flex-col items-center gap-1 px-4 py-1 transition-all active:scale-95"
|
||||||
|
style={{ color: pathname === '/' ? 'var(--primary)' : 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
<Home className="w-6 h-6" />
|
||||||
|
<span className="text-[10px]">Главная</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Помощь */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/help')}
|
||||||
|
className="flex flex-col items-center gap-1 px-4 py-1 transition-all active:scale-95"
|
||||||
|
style={{ color: pathname === '/help' ? 'var(--primary)' : 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-6 h-6" />
|
||||||
|
<span className="text-[10px]">Помощь</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
358
components/OnboardingFlow.tsx
Normal file
358
components/OnboardingFlow.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
// OnboardingFlow.tsx - Полный onboarding с интеграцией Telegram WebApp SDK
|
||||||
|
// Steps: Welcome → Language → Plan → Create Account
|
||||||
|
// Uses: Telegram.WebApp.MainButton, .BackButton, .themeParams
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { CheckCircle, Globe, Shield, Zap, DollarSign } from 'lucide-react';
|
||||||
|
|
||||||
|
// Типы
|
||||||
|
type OnboardingStep = 'welcome' | 'language' | 'plan' | 'creating';
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
features: string[];
|
||||||
|
icon: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnboardingFlowProps {
|
||||||
|
referrerId?: string; // From URL ?ref=username
|
||||||
|
onComplete: (username: string, token: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLANS: Plan[] = [
|
||||||
|
{
|
||||||
|
id: 'basic',
|
||||||
|
name: 'Старт',
|
||||||
|
price: 149,
|
||||||
|
features: ['Безлимитный трафик', 'До 3 устройств', 'Стандартная скорость', 'Email поддержка'],
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plus',
|
||||||
|
name: 'Плюс',
|
||||||
|
price: 249,
|
||||||
|
features: ['Безлимитный трафик', 'До 5 устройств', 'Высокая скорость', 'Выбор локации', 'Приоритетная поддержка'],
|
||||||
|
icon: Zap,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'max',
|
||||||
|
name: 'Макс',
|
||||||
|
price: 350,
|
||||||
|
features: ['Безлимитный трафик', 'До 10 устройств', 'Максимальная скорость', 'Все локации', '24/7 поддержка'],
|
||||||
|
icon: DollarSign,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LANGUAGES = [
|
||||||
|
{ code: 'ru', name: 'Русский', flag: '🇷🇺' },
|
||||||
|
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
||||||
|
{ code: 'uz', name: 'Oʻzbekcha', flag: '🇺🇿' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function OnboardingFlow({ referrerId, onComplete }: OnboardingFlowProps) {
|
||||||
|
const [step, setStep] = useState<OnboardingStep>('welcome');
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState('ru');
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Telegram WebApp SDK
|
||||||
|
const [telegramWebApp, setTelegramWebApp] = useState<any>(null);
|
||||||
|
const [telegramUser, setTelegramUser] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize Telegram WebApp SDK
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const tg = (window as any).Telegram?.WebApp;
|
||||||
|
if (tg) {
|
||||||
|
setTelegramWebApp(tg);
|
||||||
|
setTelegramUser(tg.initDataUnsafe?.user);
|
||||||
|
tg.ready();
|
||||||
|
tg.expand();
|
||||||
|
|
||||||
|
// Тема применяется через CSS variables в globals.css
|
||||||
|
// НЕ переопределяем --tg-theme-* — таких переменных нет в проекте
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle MainButton clicks based on current step
|
||||||
|
useEffect(() => {
|
||||||
|
if (!telegramWebApp) return;
|
||||||
|
|
||||||
|
const handleMainButtonClick = () => {
|
||||||
|
if (step === 'welcome') {
|
||||||
|
setStep('language');
|
||||||
|
} else if (step === 'language' && selectedLanguage) {
|
||||||
|
setStep('plan');
|
||||||
|
} else if (step === 'plan' && selectedPlan) {
|
||||||
|
handleCreateAccount();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
telegramWebApp.MainButton.onClick(handleMainButtonClick);
|
||||||
|
|
||||||
|
// Update MainButton based on step
|
||||||
|
if (step === 'welcome') {
|
||||||
|
telegramWebApp.MainButton.text = '🚀 Начать';
|
||||||
|
telegramWebApp.MainButton.show();
|
||||||
|
telegramWebApp.BackButton.hide();
|
||||||
|
} else if (step === 'language') {
|
||||||
|
telegramWebApp.MainButton.text = selectedLanguage ? '➡️ Далее' : '⚠️ Выберите язык';
|
||||||
|
telegramWebApp.MainButton.show();
|
||||||
|
telegramWebApp.BackButton.show();
|
||||||
|
} else if (step === 'plan') {
|
||||||
|
telegramWebApp.MainButton.text = selectedPlan ? '✅ Создать аккаунт' : '⚠️ Выберите тариф';
|
||||||
|
telegramWebApp.MainButton.show();
|
||||||
|
telegramWebApp.BackButton.show();
|
||||||
|
} else if (step === 'creating') {
|
||||||
|
telegramWebApp.MainButton.hide();
|
||||||
|
telegramWebApp.BackButton.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
telegramWebApp.MainButton.offClick(handleMainButtonClick);
|
||||||
|
};
|
||||||
|
}, [step, selectedLanguage, selectedPlan, telegramWebApp]);
|
||||||
|
|
||||||
|
// Handle BackButton
|
||||||
|
useEffect(() => {
|
||||||
|
if (!telegramWebApp) return;
|
||||||
|
|
||||||
|
const handleBackButtonClick = () => {
|
||||||
|
if (step === 'language') {
|
||||||
|
setStep('welcome');
|
||||||
|
} else if (step === 'plan') {
|
||||||
|
setStep('language');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
telegramWebApp.BackButton.onClick(handleBackButtonClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
telegramWebApp.BackButton.offClick(handleBackButtonClick);
|
||||||
|
};
|
||||||
|
}, [step, telegramWebApp]);
|
||||||
|
|
||||||
|
const handleCreateAccount = async () => {
|
||||||
|
if (!telegramUser) {
|
||||||
|
setError('❌ Telegram user data not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep('creating');
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Генерируем username из Telegram данных
|
||||||
|
const username = telegramUser.username || `user_${telegramUser.id}`;
|
||||||
|
|
||||||
|
// Создаем аккаунт через API
|
||||||
|
const response = await fetch('/api/create-user', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
telegramId: telegramUser.id,
|
||||||
|
telegramUsername: telegramUser.username,
|
||||||
|
firstName: telegramUser.first_name,
|
||||||
|
lastName: telegramUser.last_name,
|
||||||
|
plan: selectedPlan,
|
||||||
|
language: selectedLanguage,
|
||||||
|
referrerId: referrerId || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Failed to create account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track referral if exists
|
||||||
|
if (referrerId) {
|
||||||
|
await fetch('/api/referral/track', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: data.username,
|
||||||
|
referrer_username: referrerId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success!
|
||||||
|
onComplete(data.username, data.token);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Create account error:', err);
|
||||||
|
setError(err.message);
|
||||||
|
setStep('plan'); // Вернуться к выбору тарифа
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render Welcome Step
|
||||||
|
if (step === 'welcome') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white p-6 flex flex-col items-center justify-center">
|
||||||
|
<div className="max-w-md w-full text-center space-y-6">
|
||||||
|
<div className="text-6xl mb-4">🚀</div>
|
||||||
|
<h1 className="text-3xl font-bold">Добро пожаловать в Umbrix VPN!</h1>
|
||||||
|
|
||||||
|
{referrerId && (
|
||||||
|
<div className="bg-blue-600/20 border border-blue-500 rounded-lg p-4">
|
||||||
|
<p className="text-lg">
|
||||||
|
🎁 Вы перешли по ссылке от <span className="font-bold">{referrerId}</span>!
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-300 mt-2">
|
||||||
|
Оба получите <span className="font-bold text-green-400">+7 дней бесплатно</span> при регистрации!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3 text-left">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-500 mt-1 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Надежная защита</p>
|
||||||
|
<p className="text-sm text-slate-400">Ваши данные в безопасности</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-500 mt-1 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Высокая скорость</p>
|
||||||
|
<p className="text-sm text-slate-400">Без ограничений трафика</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-500 mt-1 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Простая настройка</p>
|
||||||
|
<p className="text-sm text-slate-400">Работает за 2 минуты</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Нажмите <span className="font-bold">"Начать"</span> для создания аккаунта
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Language Selection Step
|
||||||
|
if (step === 'language') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white p-6 flex flex-col items-center justify-center">
|
||||||
|
<div className="max-w-md w-full space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<Globe className="w-16 h-16 mx-auto mb-4 text-blue-500" />
|
||||||
|
<h2 className="text-2xl font-bold">Выберите язык</h2>
|
||||||
|
<p className="text-slate-400 mt-2">Choose your language</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{LANGUAGES.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
onClick={() => setSelectedLanguage(lang.code)}
|
||||||
|
className={`w-full p-4 rounded-lg border-2 transition-all ${
|
||||||
|
selectedLanguage === lang.code
|
||||||
|
? 'bg-blue-600 border-blue-500'
|
||||||
|
: 'bg-slate-800/50 border-slate-700 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-3xl">{lang.flag}</span>
|
||||||
|
<span className="font-semibold">{lang.name}</span>
|
||||||
|
</div>
|
||||||
|
{selectedLanguage === lang.code && (
|
||||||
|
<CheckCircle className="w-6 h-6 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Plan Selection Step
|
||||||
|
if (step === 'plan') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white p-6 flex flex-col items-center justify-center">
|
||||||
|
<div className="max-w-4xl w-full space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold">Выберите тарифный план</h2>
|
||||||
|
<p className="text-slate-400 mt-2">7 дней бесплатно на любом тарифе!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{PLANS.map((plan) => {
|
||||||
|
const Icon = plan.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={plan.id}
|
||||||
|
onClick={() => setSelectedPlan(plan.id)}
|
||||||
|
className={`p-6 rounded-lg border-2 transition-all text-left ${
|
||||||
|
selectedPlan === plan.id
|
||||||
|
? 'bg-blue-600 border-blue-500 scale-105'
|
||||||
|
: 'bg-slate-800/50 border-slate-700 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Icon className="w-8 h-8" />
|
||||||
|
{selectedPlan === plan.id && (
|
||||||
|
<CheckCircle className="w-6 h-6 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold mb-2">{plan.name}</h3>
|
||||||
|
<p className="text-3xl font-bold mb-4">
|
||||||
|
{plan.price} ₽<span className="text-base font-normal text-slate-400">/мес</span>
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{plan.features.map((feature, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Creating Account Step
|
||||||
|
if (step === 'creating') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-16 w-16 border-b-4 border-blue-500 mx-auto mb-6"></div>
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Создаем ваш аккаунт...</h2>
|
||||||
|
<p className="text-slate-400">Подождите несколько секунд</p>
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 p-4 bg-red-600/20 border border-red-500 rounded-lg">
|
||||||
|
<p className="text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useTelegramWebApp() {
|
export function useTelegramWebApp() {
|
||||||
const [webApp, setWebApp] = useState<typeof window.Telegram.WebApp | null>(null);
|
const [webApp, setWebApp] = useState<any>(null);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ export class MarzbanApiClient {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('API endpoint returned error:', response.status, response.statusText);
|
if (typeof window === 'undefined') console.warn('API endpoint returned error:', response.status, response.statusText);
|
||||||
} catch (apiError) {
|
} catch (apiError) {
|
||||||
console.warn('API failed, falling back to HTML scraping:', apiError);
|
if (typeof window === 'undefined') console.warn('API failed, falling back to HTML scraping:', apiError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ❌ Fallback to HTML scraping (старый метод)
|
// ❌ Fallback to HTML scraping (старый метод)
|
||||||
@@ -91,7 +91,7 @@ export class MarzbanApiClient {
|
|||||||
this.cache.set(`info_${token}`, { data, timestamp: Date.now() });
|
this.cache.set(`info_${token}`, { data, timestamp: Date.now() });
|
||||||
return data;
|
return data;
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
console.error('Both API and HTML scraping failed:', fallbackError);
|
if (typeof window === 'undefined') console.error('Both API and HTML scraping failed:', fallbackError);
|
||||||
throw new Error('Не удалось загрузить данные подписки');
|
throw new Error('Не удалось загрузить данные подписки');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
@@ -56,10 +56,4 @@ export interface TelegramUserData {
|
|||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
// Window.Telegram декларация в hooks/useTelegramWebApp.ts
|
||||||
interface Window {
|
|
||||||
Telegram?: {
|
|
||||||
WebApp: TelegramWebApp;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user