✅ Что сделано: - app/page.tsx: Проверка source/utm_source параметров (n8n, chat, from_n8n) - app/plans/page.tsx: Аналогичная проверка для 3-step flow - app/api/create-user/route.ts: Разрешение создания без telegramId если source=n8n 🔓 ИСКЛЮЧЕНИЯ для n8n: - ?source=n8n - ?utm_source=n8n - ?utm_source=chat - ?from_n8n=true 🎯 ЛОГИКА: 1. Переход через Telegram Mini App → требуется tgUser.id ✅ 2. Переход из n8n (прямая ссылка) → НЕ требуется, генерируется временный ID ✅ 3. Переход из браузера напрямую → блокируется ❌ 💡 Для временных ID используется Date.now() но только если fromN8n=true
596 lines
21 KiB
TypeScript
596 lines
21 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { ArrowLeft, Check, ChevronRight, Loader2 } from 'lucide-react';
|
||
import { useRouter } from 'next/navigation';
|
||
|
||
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': 99,
|
||
'3months': 269, // -9% (89₽/мес)
|
||
'6months': 499, // -16% (83₽/мес)
|
||
'1year': 949, // -20% (79₽/мес)
|
||
},
|
||
},
|
||
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 [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 [referrerId, setReferrerId] = useState<string | null>(null);
|
||
|
||
// Получаем referrerId из Telegram start_param при монтировании
|
||
useEffect(() => {
|
||
const telegramWebApp = (window as any).Telegram?.WebApp;
|
||
const startParam = telegramWebApp?.initDataUnsafe?.start_param;
|
||
|
||
if (startParam && startParam.startsWith('ref_')) {
|
||
const refId = startParam.replace('ref_', '');
|
||
setReferrerId(refId);
|
||
console.log('🎁 Referral detected:', refId);
|
||
}
|
||
}, []);
|
||
|
||
// Загрузить локации при переходе к шагу выбора
|
||
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);
|
||
|
||
// 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;
|
||
const user = telegramWebApp?.initDataUnsafe?.user;
|
||
|
||
console.log('🔍 TELEGRAM USER DATA:', {
|
||
hasWebApp: !!telegramWebApp,
|
||
hasInitData: !!telegramWebApp?.initDataUnsafe,
|
||
hasUser: !!user,
|
||
user: user ? {
|
||
id: user.id,
|
||
username: user.username,
|
||
first_name: user.first_name,
|
||
last_name: user.last_name,
|
||
} : null
|
||
});
|
||
|
||
// 🔓 ИСКЛЮЧЕНИЕ: Проверяем переход из n8n
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const source = urlParams.get('source') || urlParams.get('utm_source');
|
||
const fromN8n = source === 'n8n' || source === 'chat' || urlParams.has('from_n8n');
|
||
|
||
// ⚠️ ВАЖНО: Если Telegram не передал user.id И это не n8n переход - блокируем
|
||
if (!user?.id && !fromN8n) {
|
||
console.error('❌ USER CREATION BLOCKED: No Telegram user ID!');
|
||
alert('❌ Откройте приложение через Telegram бота\n\nПриложение должно быть открыто в Telegram Mini App, а не в браузере.');
|
||
setStep('plan');
|
||
setIsCreatingUser(false);
|
||
return;
|
||
}
|
||
|
||
// Для n8n переходов генерируем временный ID
|
||
const userId = user?.id || Date.now();
|
||
|
||
const requestBody = {
|
||
planType,
|
||
period,
|
||
locationIds,
|
||
telegramId: userId, // Telegram ID или временный для n8n
|
||
telegramUsername: user?.username || null,
|
||
firstName: user?.first_name || null,
|
||
lastName: user?.last_name || null,
|
||
referrerId: referrerId || null,
|
||
source: fromN8n ? (source || 'n8n') : null, // Помечаем n8n переходы
|
||
};
|
||
|
||
console.log('📤 REQUEST BODY:', requestBody);
|
||
|
||
const response = await fetch('/api/create-user', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(requestBody),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
// Успех - возвращаемся на главную с параметром обновления
|
||
router.push('/?refresh=true');
|
||
} else {
|
||
throw new Error(data.error || 'Failed to create subscription');
|
||
}
|
||
} catch (error) {
|
||
console.error('Create user error:', error);
|
||
alert('Ошибка при создании подписки. Попробуйте позже.');
|
||
setStep('plan');
|
||
} finally {
|
||
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"
|
||
style={{ background: 'var(--bg-app)', color: 'var(--text-primary)' }}
|
||
>
|
||
{/* Header */}
|
||
<header
|
||
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)' }}
|
||
>
|
||
{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 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', '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)}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{/* 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>
|
||
)}
|
||
|
||
{/* Шаг 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,
|
||
price,
|
||
features,
|
||
buttonText,
|
||
isPrimary = false,
|
||
isPopular = false,
|
||
onSelect,
|
||
}: {
|
||
badge: string;
|
||
title: string;
|
||
price: string;
|
||
features: string[];
|
||
buttonText: string;
|
||
isPrimary?: boolean;
|
||
isPopular?: boolean;
|
||
onSelect: () => void;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={`p-4 rounded-xl border relative`}
|
||
style={{
|
||
background: 'var(--bg-card)',
|
||
borderColor: isPopular ? 'var(--primary)' : 'var(--border)',
|
||
}}
|
||
>
|
||
{isPopular && (
|
||
<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-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}
|
||
</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={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)'
|
||
}}
|
||
>
|
||
{buttonText}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|