Files
gooseek/backend/webui/src/components/settings/BillingTab.tsx
home 08bd41e75c feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul
- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService)
- Add travel orchestrator with parallel collectors (events, POI, hotels, flights)
- Add 2GIS road routing with transport cost calculation (car/bus/taxi)
- Add TravelMap (2GIS MapGL) and TravelWidgets components
- Add useTravelChat hook for streaming travel agent responses
- Add finance heatmap providers refactor
- Add SearXNG settings, API proxy routes, Docker compose updates
- Update Dockerfiles, config, types, and all UI pages for consistency

Made-with: Cursor
2026-03-01 21:58:32 +03:00

327 lines
13 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 } from 'react';
import { motion } from 'framer-motion';
import { Wallet, CreditCard, Plus, Check, Zap, Crown, Building2, ArrowRight, Receipt, Download, Clock } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
interface Plan {
id: string;
name: string;
price: number;
priceMonthly: number;
icon: React.ElementType;
color: string;
features: string[];
limits: {
apiRequests: string;
llmRequests: string;
storage: string;
};
}
const plans: Plan[] = [
{
id: 'free',
name: 'Free',
price: 0,
priceMonthly: 0,
icon: Zap,
color: 'text-muted',
features: ['Базовый поиск', 'История запросов', 'Ограниченный AI'],
limits: {
apiRequests: '1,000/день',
llmRequests: '50/день',
storage: '100 MB',
},
},
{
id: 'pro',
name: 'Pro',
price: 990,
priceMonthly: 990,
icon: Crown,
color: 'text-accent',
features: ['Расширенный поиск', 'Приоритетный AI', 'Экспорт данных', 'API доступ'],
limits: {
apiRequests: '10,000/день',
llmRequests: '500/день',
storage: '1 GB',
},
},
{
id: 'business',
name: 'Business',
price: 4990,
priceMonthly: 4990,
icon: Building2,
color: 'text-amber-600',
features: ['Всё из Pro', 'Безлимитный AI', 'Приоритетная поддержка', 'Команды', 'SLA 99.9%'],
limits: {
apiRequests: '100,000/день',
llmRequests: '5,000/день',
storage: '10 GB',
},
},
];
const mockTransactions = [
{ id: '1', date: '2026-02-28', amount: 990, type: 'subscription', description: 'Подписка Pro' },
{ id: '2', date: '2026-02-15', amount: 500, type: 'topup', description: 'Пополнение баланса' },
{ id: '3', date: '2026-01-28', amount: 990, type: 'subscription', description: 'Подписка Pro' },
];
export function BillingTab() {
const { user } = useAuth();
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
const [showTopup, setShowTopup] = useState(false);
const [topupAmount, setTopupAmount] = useState('');
if (!user) return null;
const currentPlan = plans.find(p => p.id === user.tier) || plans[0];
const handleUpgrade = (planId: string) => {
setSelectedPlan(planId);
};
const handleTopup = () => {
const amount = parseInt(topupAmount, 10);
if (isNaN(amount) || amount < 100) return;
setShowTopup(false);
setTopupAmount('');
};
return (
<div className="space-y-6">
{/* Balance Card */}
<Section title="Баланс" icon={Wallet}>
<div className="p-4 bg-gradient-to-br from-accent/20 via-accent/10 to-transparent border border-accent/30 rounded-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted">Текущий баланс</p>
<p className="text-3xl font-bold text-primary mt-1">{(user.balance ?? 0).toLocaleString('ru-RU')} </p>
</div>
<button
onClick={() => setShowTopup(!showTopup)}
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all text-sm font-medium"
>
<Plus className="w-4 h-4" />
Пополнить
</button>
</div>
{showTopup && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mt-4 pt-4 border-t border-accent/20"
>
<div className="flex gap-2 mb-3">
{[100, 500, 1000, 5000].map((amount) => (
<button
key={amount}
onClick={() => setTopupAmount(amount.toString())}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${
topupAmount === amount.toString()
? 'bg-accent text-white'
: 'bg-surface/50 text-secondary hover:text-primary hover:bg-surface'
}`}
>
{amount}
</button>
))}
</div>
<div className="flex gap-2">
<input
type="number"
value={topupAmount}
onChange={(e) => setTopupAmount(e.target.value)}
placeholder="Сумма"
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-sm text-primary focus:outline-none focus:border-accent"
/>
<button
onClick={handleTopup}
disabled={!topupAmount || parseInt(topupAmount, 10) < 100}
className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover transition-all disabled:opacity-50"
>
Оплатить
</button>
</div>
<p className="text-xs text-muted mt-2">Минимальная сумма: 100 </p>
</motion.div>
)}
</div>
</Section>
{/* Current Plan */}
<Section title="Текущий тариф" icon={currentPlan.icon}>
<div className="p-4 bg-elevated/40 border border-border/40 rounded-xl">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl bg-surface flex items-center justify-center ${currentPlan.color}`}>
<currentPlan.icon className="w-5 h-5" />
</div>
<div>
<h3 className="font-medium text-primary">{currentPlan.name}</h3>
<p className="text-sm text-muted">
{currentPlan.price === 0 ? 'Бесплатно' : `${currentPlan.price.toLocaleString('ru-RU')} ₽/мес`}
</p>
</div>
</div>
{user.tier !== 'business' && (
<button
onClick={() => handleUpgrade(user.tier === 'free' ? 'pro' : 'business')}
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent/10 text-accent rounded-lg text-sm font-medium hover:bg-accent/20 transition-all"
>
Улучшить
<ArrowRight className="w-4 h-4" />
</button>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<div className="p-3 bg-surface/30 rounded-lg">
<p className="text-xs text-muted">API запросы</p>
<p className="text-sm font-medium text-primary mt-0.5">{currentPlan.limits.apiRequests}</p>
</div>
<div className="p-3 bg-surface/30 rounded-lg">
<p className="text-xs text-muted">AI запросы</p>
<p className="text-sm font-medium text-primary mt-0.5">{currentPlan.limits.llmRequests}</p>
</div>
<div className="p-3 bg-surface/30 rounded-lg">
<p className="text-xs text-muted">Хранилище</p>
<p className="text-sm font-medium text-primary mt-0.5">{currentPlan.limits.storage}</p>
</div>
</div>
</div>
</Section>
{/* Plans Comparison */}
{selectedPlan && (
<Section title="Выбор тарифа" icon={Crown}>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{plans.map((plan) => (
<motion.div
key={plan.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`p-4 rounded-xl border transition-all cursor-pointer ${
selectedPlan === plan.id
? 'bg-accent/10 border-accent'
: 'bg-elevated/40 border-border/40 hover:border-border'
} ${user.tier === plan.id ? 'ring-2 ring-accent/50' : ''}`}
onClick={() => setSelectedPlan(plan.id)}
>
<div className="flex items-center gap-2 mb-3">
<plan.icon className={`w-5 h-5 ${plan.color}`} />
<span className="font-medium text-primary">{plan.name}</span>
{user.tier === plan.id && (
<span className="text-xs bg-accent/20 text-accent px-1.5 py-0.5 rounded">Текущий</span>
)}
</div>
<p className="text-2xl font-bold text-primary mb-3">
{plan.price === 0 ? 'Бесплатно' : `${plan.price.toLocaleString('ru-RU')}`}
{plan.price > 0 && <span className="text-sm font-normal text-muted">/мес</span>}
</p>
<ul className="space-y-2">
{plan.features.map((feature, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm text-secondary">
<Check className="w-4 h-4 text-accent flex-shrink-0" />
{feature}
</li>
))}
</ul>
{user.tier !== plan.id && plan.id !== 'free' && (
<button
onClick={(e) => { e.stopPropagation(); handleUpgrade(plan.id); }}
className="w-full mt-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent-hover transition-all"
>
{plans.findIndex(p => p.id === user.tier) < plans.findIndex(p => p.id === plan.id) ? 'Улучшить' : 'Выбрать'}
</button>
)}
</motion.div>
))}
</div>
</Section>
)}
{/* Payment Methods */}
<Section title="Способы оплаты" icon={CreditCard}>
<div className="p-4 bg-elevated/40 border border-border/40 rounded-xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-surface flex items-center justify-center">
<CreditCard className="w-5 h-5 text-muted" />
</div>
<p className="text-sm text-muted">Карты не добавлены</p>
</div>
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-surface border border-border text-secondary rounded-lg text-sm hover:text-primary hover:border-border/80 transition-all">
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
</div>
</Section>
{/* Transaction History */}
<Section title="История платежей" icon={Receipt}>
<div className="space-y-2">
{mockTransactions.length === 0 ? (
<div className="p-4 bg-elevated/40 border border-border/40 rounded-xl text-center">
<Clock className="w-8 h-8 text-muted mx-auto mb-2" />
<p className="text-sm text-muted">История платежей пуста</p>
</div>
) : (
mockTransactions.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between p-3 bg-elevated/40 border border-border/40 rounded-xl"
>
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
tx.type === 'topup' ? 'bg-success/10' : 'bg-accent/10'
}`}>
{tx.type === 'topup' ? (
<Plus className="w-4 h-4 text-success" />
) : (
<Receipt className="w-4 h-4 text-accent" />
)}
</div>
<div>
<p className="text-sm text-primary">{tx.description}</p>
<p className="text-xs text-muted">{new Date(tx.date).toLocaleDateString('ru-RU')}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`text-sm font-medium ${tx.type === 'topup' ? 'text-success' : 'text-primary'}`}>
{tx.type === 'topup' ? '+' : '-'}{tx.amount.toLocaleString('ru-RU')}
</span>
<button className="p-1.5 text-muted hover:text-secondary transition-colors">
<Download className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
</Section>
</div>
);
}
function Section({ title, icon: Icon, children }: { title: string; icon: React.ElementType; children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-3"
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted" />
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider">{title}</h2>
</div>
{children}
</motion.div>
);
}