Files
app_umbrix/app/plans/page.tsx

587 lines
21 KiB
TypeScript
Raw Normal View History

'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
});
// ⚠️ ВАЖНО: Если Telegram не передал user.id - блокируем создание
if (!user?.id) {
console.error('❌ USER CREATION BLOCKED: No Telegram user ID!');
alert('❌ Откройте приложение через Telegram бота\n\nПриложение должно быть открыто в Telegram Mini App, а не в браузере.');
setStep('plan');
setIsCreatingUser(false);
return;
}
const requestBody = {
planType,
period,
locationIds,
telegramId: user.id, // Теперь 100% есть
telegramUsername: user?.username || null,
firstName: user?.first_name || null,
lastName: user?.last_name || null,
referrerId: referrerId || null, // Добавляем referrerId
};
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>
);
}