feat: billing complete — plans page, admin billing, credit cost hints
/plans: страница тарифов с карточками, стоимостью операций, FAQ /system → Биллинг: таблица пользователей с кредитами, ручное начисление ChannelView: badge стоимости (2кр текст + 5кр картинка) под кнопкой генерации Ошибка INSUFFICIENT_CREDITS → понятное сообщение После генерации — event credits-updated → обновление badge в header Header: подписка на credits-updated event API роуты: /api/billing/plans, /api/billing/admin/users, /api/billing/admin/credit
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Check, Zap, Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const PLAN_STYLE = {
|
||||
free: { color: 'border-border', badge: null, btnClass: 'btn-ghost' },
|
||||
starter: { color: 'border-blue-500/50', badge: null, btnClass: 'btn-primary' },
|
||||
pro: { color: 'border-purple-500/60', badge: 'Популярный', btnClass: 'bg-purple-600 hover:bg-purple-500 text-white px-4 py-2 rounded-lg font-medium transition-colors' },
|
||||
business: { color: 'border-yellow-500/40', badge: 'Для агентств', btnClass: 'bg-yellow-600 hover:bg-yellow-500 text-white px-4 py-2 rounded-lg font-medium transition-colors' },
|
||||
};
|
||||
|
||||
const FEATURES = {
|
||||
free: ['1 канал', '50 кредитов/мес', 'TG и VK публикация', 'Ручная генерация'],
|
||||
starter: ['2 канала', '500 кредитов/мес', 'Автогенерация постов', 'Календарь публикаций', 'Аналитика канала'],
|
||||
pro: ['5 каналов', '2000 кредитов/мес', 'Всё из Starter', 'Приоритетная генерация', 'История контента'],
|
||||
business: ['Без ограничений', 'Безлимит кредитов', 'Всё из Pro', 'Поддержка 24/7', 'API доступ'],
|
||||
};
|
||||
|
||||
export default function PlansPage() {
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [costs, setCosts] = useState({});
|
||||
const [balance, setBalance] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/billing/plans').then(r => r.json()),
|
||||
fetch('/api/billing/balance').then(r => r.json()).catch(() => null),
|
||||
]).then(([pd, bd]) => {
|
||||
setPlans(pd.plans || []);
|
||||
setCosts(Object.fromEntries((pd.costs || []).map(c => [c.operation, c.credits])));
|
||||
setBalance(bd);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) return (
|
||||
<main className="max-w-5xl mx-auto p-6 text-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto text-accent" />
|
||||
</main>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto p-4 sm:p-6">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-3xl font-bold mb-2">Тарифы</h1>
|
||||
<p className="text-gray-400">Выберите план под ваши задачи. Все планы включают публикацию в TG и VK.</p>
|
||||
{balance && (
|
||||
<p className="text-sm text-accent mt-2">
|
||||
Сейчас у вас: <strong>{balance.planName}</strong> · {balance.isUnlimited ? '∞' : balance.credits} кредитов
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Карточки планов */}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
|
||||
{plans.map(plan => {
|
||||
const style = PLAN_STYLE[plan.code] || PLAN_STYLE.free;
|
||||
const features = FEATURES[plan.code] || [];
|
||||
const isCurrent = balance?.plan === plan.code;
|
||||
const isUnlimited = plan.credits_month === -1;
|
||||
|
||||
return (
|
||||
<div key={plan.code} className={`card p-5 flex flex-col border-2 ${style.color} ${plan.code === 'pro' ? 'relative' : ''}`}>
|
||||
{style.badge && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 text-xs px-3 py-1 rounded-full bg-purple-600 text-white font-medium whitespace-nowrap">
|
||||
{style.badge}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<div className="text-lg font-bold">{plan.name}</div>
|
||||
<div className="mt-1">
|
||||
{plan.price_rub === 0
|
||||
? <span className="text-2xl font-bold">Бесплатно</span>
|
||||
: <><span className="text-2xl font-bold">₽{plan.price_rub}</span><span className="text-gray-400 text-sm">/мес</span></>
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-accent mt-1 font-medium">
|
||||
{isUnlimited ? '∞ кредитов' : `${plan.credits_month} кредитов/мес`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 flex-1 mb-5">
|
||||
{features.map(f => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm text-gray-300">
|
||||
<Check className="w-4 h-4 text-green-400 mt-0.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{isCurrent ? (
|
||||
<div className="w-full text-center py-2 rounded-lg bg-surface2 text-gray-400 text-sm">Текущий план</div>
|
||||
) : plan.price_rub === 0 ? (
|
||||
<Link href="/register" className={`w-full text-center py-2 rounded-lg text-sm ${style.btnClass}`}>Начать бесплатно</Link>
|
||||
) : (
|
||||
<button className={`w-full text-center py-2 text-sm ${style.btnClass}`}
|
||||
onClick={() => alert('ЮKassa — скоро')}>
|
||||
Подключить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Стоимость операций */}
|
||||
<div className="card p-5 mb-6">
|
||||
<h2 className="font-semibold mb-4 flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-accent" /> Стоимость генерации
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||
{[
|
||||
{ label: 'Картинка', op: 'image', icon: '🖼', note: 'gpt-5-image-mini' },
|
||||
{ label: 'Пост', op: 'text_post', icon: '✍️', note: 'aiprimetech' },
|
||||
{ label: 'Статья', op: 'article', icon: '📝', note: 'Claude Sonnet' },
|
||||
{ label: 'Публикация', op: 'autopublish',icon: '📤', note: 'TG / VK / MAX' },
|
||||
].map(op => (
|
||||
<div key={op.op} className="p-3 rounded-lg bg-surface2 text-center">
|
||||
<div className="text-2xl mb-1">{op.icon}</div>
|
||||
<div className="font-medium">{op.label}</div>
|
||||
<div className="text-accent font-bold mt-1">
|
||||
{(costs[op.op] || 0) === 0 ? 'бесплатно' : `${costs[op.op]} кр`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{op.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="card p-5">
|
||||
<h2 className="font-semibold mb-4">Часто спрашивают</h2>
|
||||
<div className="space-y-3 text-sm text-gray-400">
|
||||
{[
|
||||
['Что такое кредиты?', '1 кредит = 1 рубль. Кредиты списываются при каждой AI-генерации. Публикация постов — всегда бесплатна.'],
|
||||
['Что будет если кредиты закончатся?', 'Генерация будет заблокирована до пополнения. Уже опубликованные посты и автопостинг продолжают работать.'],
|
||||
['Переносятся ли кредиты на следующий месяц?', 'Нет, кредиты по тарифу сбрасываются раз в 30 дней. Дополнительно купленные кредиты не сгорают.'],
|
||||
['Можно ли купить кредиты отдельно?', 'Скоро. Сейчас кредиты начисляются только по тарифному плану.'],
|
||||
].map(([q, a]) => (
|
||||
<details key={q} className="group">
|
||||
<summary className="cursor-pointer font-medium text-gray-200 hover:text-white list-none flex items-center justify-between">
|
||||
{q} <span className="text-gray-500 group-open:rotate-180 transition-transform">▾</span>
|
||||
</summary>
|
||||
<p className="mt-2 pl-1">{a}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user