From 9bd38bc645238ccde7c2eb4b96d7a4580d876570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=20=28Claude=29?= Date: Thu, 11 Jun 2026 18:42:54 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20billing=20complete=20=E2=80=94=20plans?= =?UTF-8?q?=20page,=20admin=20billing,=20credit=20cost=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- app/api/billing/admin/credit/route.js | 13 +++ app/api/billing/admin/users/route.js | 12 ++ app/api/billing/plans/route.js | 17 +++ app/plans/page.js | 153 ++++++++++++++++++++++++++ components/ChannelView.js | 17 ++- components/Header.js | 9 +- components/SystemSettings.js | 27 ++++- components/admin/AdminBilling.js | 142 ++++++++++++++++++++++++ 8 files changed, 380 insertions(+), 10 deletions(-) create mode 100644 app/api/billing/admin/credit/route.js create mode 100644 app/api/billing/admin/users/route.js create mode 100644 app/api/billing/plans/route.js create mode 100644 app/plans/page.js create mode 100644 components/admin/AdminBilling.js diff --git a/app/api/billing/admin/credit/route.js b/app/api/billing/admin/credit/route.js new file mode 100644 index 0000000..4509331 --- /dev/null +++ b/app/api/billing/admin/credit/route.js @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +export async function POST(req) { + const user = await requireUser(); + if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + try { + const body = await req.json(); + const data = await engine.adminCreditUser(body); + return NextResponse.json(data); + } catch (err) { return NextResponse.json({ error: err.message }, { status: 500 }); } +} diff --git a/app/api/billing/admin/users/route.js b/app/api/billing/admin/users/route.js new file mode 100644 index 0000000..74e0f2c --- /dev/null +++ b/app/api/billing/admin/users/route.js @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +export async function GET() { + const user = await requireUser(); + if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + try { + const data = await engine.adminGetBalances(); + return NextResponse.json(data); + } catch (err) { return NextResponse.json({ error: err.message }, { status: 500 }); } +} diff --git a/app/api/billing/plans/route.js b/app/api/billing/plans/route.js new file mode 100644 index 0000000..df032ed --- /dev/null +++ b/app/api/billing/plans/route.js @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; + +const ENGINE_URL = process.env.ENGINE_URL || 'http://localhost:3030'; +const ENGINE_SECRET = process.env.ENGINE_SECRET || ''; + +export async function GET() { + try { + const res = await fetch(`${ENGINE_URL}/api/billing/plans`, { + headers: { 'x-internal-secret': ENGINE_SECRET }, + cache: 'no-store', + }); + const data = await res.json(); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} diff --git a/app/plans/page.js b/app/plans/page.js new file mode 100644 index 0000000..30b73f4 --- /dev/null +++ b/app/plans/page.js @@ -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 ( +
+ +
+ ); + + return ( +
+
+

Тарифы

+

Выберите план под ваши задачи. Все планы включают публикацию в TG и VK.

+ {balance && ( +

+ Сейчас у вас: {balance.planName} · {balance.isUnlimited ? '∞' : balance.credits} кредитов +

+ )} +
+ + {/* Карточки планов */} +
+ {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 ( +
+ {style.badge && ( +
+ {style.badge} +
+ )} +
+
{plan.name}
+
+ {plan.price_rub === 0 + ? Бесплатно + : <>₽{plan.price_rub}/мес + } +
+
+ {isUnlimited ? '∞ кредитов' : `${plan.credits_month} кредитов/мес`} +
+
+ +
    + {features.map(f => ( +
  • + + {f} +
  • + ))} +
+ + {isCurrent ? ( +
Текущий план
+ ) : plan.price_rub === 0 ? ( + Начать бесплатно + ) : ( + + )} +
+ ); + })} +
+ + {/* Стоимость операций */} +
+

+ Стоимость генерации +

+
+ {[ + { 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 => ( +
+
{op.icon}
+
{op.label}
+
+ {(costs[op.op] || 0) === 0 ? 'бесплатно' : `${costs[op.op]} кр`} +
+
{op.note}
+
+ ))} +
+
+ + {/* FAQ */} +
+

Часто спрашивают

+
+ {[ + ['Что такое кредиты?', '1 кредит = 1 рубль. Кредиты списываются при каждой AI-генерации. Публикация постов — всегда бесплатна.'], + ['Что будет если кредиты закончатся?', 'Генерация будет заблокирована до пополнения. Уже опубликованные посты и автопостинг продолжают работать.'], + ['Переносятся ли кредиты на следующий месяц?', 'Нет, кредиты по тарифу сбрасываются раз в 30 дней. Дополнительно купленные кредиты не сгорают.'], + ['Можно ли купить кредиты отдельно?', 'Скоро. Сейчас кредиты начисляются только по тарифному плану.'], + ].map(([q, a]) => ( +
+ + {q} + +

{a}

+
+ ))} +
+
+
+ ); +} diff --git a/components/ChannelView.js b/components/ChannelView.js index 28bb551..4584257 100644 --- a/components/ChannelView.js +++ b/components/ChannelView.js @@ -231,7 +231,14 @@ export default function ChannelView({ channel }) { }), }); const job = await createRes.json(); - if (!createRes.ok) throw new Error(job.error || 'Ошибка'); + if (!createRes.ok) { + if (job.code === 'INSUFFICIENT_CREDITS') { + throw new Error(`Недостаточно кредитов: нужно ${job.cost}, есть ${job.credits}. Пополните баланс на странице тарифов.`); + } + throw new Error(job.error || 'Ошибка'); + } + // Триггер обновления баланса в header + if (job.credits_after !== null) window.dispatchEvent(new Event('credits-updated')); let final; for (let i = 0; i < 60; i++) { @@ -447,8 +454,12 @@ export default function ChannelView({ channel }) {
-
- ИИ напишет пост в стиле твоего канала с учётом примеров +
+
ИИ напишет пост в стиле твоего канала с учётом примеров
+
+ 2 кр — текст + {channel.image_enabled && + 5 кр — картинка} +
+ ))} +
+ + {sysTab === 'billing' && } + + {sysTab === 'settings' && (<> {CATEGORIES.map(cat => ( load()} /> ))} + )}
); } diff --git a/components/admin/AdminBilling.js b/components/admin/AdminBilling.js new file mode 100644 index 0000000..70639e9 --- /dev/null +++ b/components/admin/AdminBilling.js @@ -0,0 +1,142 @@ +'use client'; +import { useState, useEffect } from 'react'; +import { Loader2, Plus, RefreshCw, Search } from 'lucide-react'; + +const PLAN_BADGE = { + free: 'bg-gray-600 text-gray-200', + starter: 'bg-blue-600 text-white', + pro: 'bg-purple-600 text-white', + business: 'bg-yellow-600 text-black', +}; + +export default function AdminBillingPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [crediting, setCrediting] = useState(null); // user being credited + const [amount, setAmount] = useState(''); + const [desc, setDesc] = useState(''); + const [saving, setSaving] = useState(false); + + async function load() { + setLoading(true); + const res = await fetch('/api/billing/admin/users').then(r => r.json()); + setUsers(Array.isArray(res) ? res : []); + setLoading(false); + } + + useEffect(() => { load(); }, []); + + async function handleCredit(userId) { + if (!amount || isNaN(parseInt(amount))) return; + setSaving(true); + await fetch('/api/billing/admin/credit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: userId, amount: parseInt(amount), description: desc || undefined }), + }); + setCrediting(null); + setAmount(''); + setDesc(''); + setSaving(false); + load(); + } + + const filtered = users.filter(u => + !search || u.email?.toLowerCase().includes(search.toLowerCase()) || u.name?.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+
+

Балансы пользователей

+
+
+ + setSearch(e.target.value)} + placeholder="Поиск по email..." + className="input pl-8 py-1.5 text-sm w-48" + /> +
+ +
+
+ + {loading &&
} + + {!loading && ( +
+ + + + + + + + + + + + {filtered.map(u => ( + <> + + + + + + + + {crediting === u.id && ( + + + + )} + + ))} + {!filtered.length && ( + + )} + +
ПользовательТарифКредитыСбросДействия
+
{u.name || u.email}
+ {u.name &&
{u.email}
} +
+ + {u.plan_name || 'Free'} + + {u.credits ?? 0} + {u.reset_at ? new Date(u.reset_at).toLocaleDateString('ru-RU') : '—'} + + +
+
+ setAmount(e.target.value)} + placeholder="Кол-во кредитов" className="input py-1.5 text-sm w-36" + autoFocus + /> + setDesc(e.target.value)} + placeholder="Комментарий (необязательно)" className="input py-1.5 text-sm flex-1 min-w-40" + /> + + +
+
Пользователи не найдены
+
+ )} +
+ ); +}