Files
app_umbrix/app/plans/page.tsx
2026-02-09 05:06:46 +03:00

564 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
// Загрузить локации при переходе к шагу выбора
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
});
const requestBody = {
planType,
period,
locationIds,
telegramId: user?.id || null,
telegramUsername: user?.username || null,
firstName: user?.first_name || null,
lastName: user?.last_name || null,
};
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>
);
}