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 ? (
+
Начать бесплатно
+ ) : (
+
alert('ЮKassa — скоро')}>
+ Подключить
+
+ )}
+
+ );
+ })}
+
+
+ {/* Стоимость операций */}
+
+
+ Стоимость генерации
+
+
+ {[
+ { 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 кр — картинка }
+
generate(false)} disabled={generating || !topic.trim()} className="btn-primary">
{generating ? (
diff --git a/components/Header.js b/components/Header.js
index fa8d62a..06f8194 100644
--- a/components/Header.js
+++ b/components/Header.js
@@ -11,10 +11,11 @@ export default function Header({ user }) {
useEffect(() => {
if (!user) return;
- fetch('/api/billing/balance')
- .then(r => r.json())
- .then(d => setCredits(d.isUnlimited ? '∞' : d.credits))
- .catch(() => {});
+ const refresh = () => fetch('/api/billing/balance').then(r => r.json())
+ .then(d => setCredits(d.isUnlimited ? '∞' : d.credits)).catch(() => {});
+ refresh();
+ window.addEventListener('credits-updated', refresh);
+ return () => window.removeEventListener('credits-updated', refresh);
}, [user?.id]);
async function logout() {
diff --git a/components/SystemSettings.js b/components/SystemSettings.js
index 7851091..fc35081 100644
--- a/components/SystemSettings.js
+++ b/components/SystemSettings.js
@@ -1,9 +1,13 @@
'use client';
import { useState, useEffect } from 'react';
-import { Loader2, Save, Eye, EyeOff, RefreshCw, Check, AlertCircle, BarChart3 } from 'lucide-react';
+import { Loader2, Save, Eye, EyeOff, RefreshCw, Check, AlertCircle, BarChart3, Coins } from 'lucide-react';
+import AdminBilling from './admin/AdminBilling';
+
+const TABS_SYS = [
+ { id: 'settings', label: 'Настройки API' },
+ { id: 'billing', label: 'Биллинг' },
+];
-// Категории, которые управляются здесь (в админке tool, а не в админке блога).
-// Категория `engine` (TELEGRAM_API_BASE и т.п.) намеренно живёт в zeropost.ru/admin.
const CATEGORIES = [
{ slug: 'ai_providers', title: 'AI провайдеры',
hint: 'Ключи, URL и модели для текстовой и картиночной генерации. Меняются на лету — рестарт engine не нужен. Курс USD↔РУБ и наценка реселлера тут же — влияют на расчёт стоимости в блоке «Расход AI» выше.' },
@@ -12,6 +16,7 @@ const CATEGORIES = [
];
export default function SystemSettings() {
+ const [sysTab, setSysTab] = useState('settings');
const [byCategory, setByCategory] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -60,6 +65,21 @@ export default function SystemSettings() {
return (
+ {/* Вкладки системной страницы */}
+
+ {TABS_SYS.map(t => (
+ setSysTab(t.id)}
+ className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
+ sysTab === t.id ? 'border-accent text-accent' : 'border-transparent text-gray-400 hover:text-gray-200'
+ }`}>
+ {t.label}
+
+ ))}
+
+
+ {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 && (
+
+ )}
+
+ );
+}