diff --git a/app/api/auth/login/route.js b/app/api/auth/login/route.js index b04b286..50c898c 100644 --- a/app/api/auth/login/route.js +++ b/app/api/auth/login/route.js @@ -25,7 +25,17 @@ export async function POST(req) { s.email = user.email; s.isAdmin = !!user.is_admin; await s.save(); - return NextResponse.json({ ok: true, user }); + + // Инициализируем баланс нового пользователя (Free план, 50 кредитов) + try { + const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030'; + const ENGINE_SECRET = process.env.ENGINE_SECRET || ''; + await fetch(`${ENGINE_URL}/api/billing/balance`, { + headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) }, + }); + } catch {} + + return NextResponse.json({ ok: true, user, isNew: true }); } // login diff --git a/app/api/channels/route.js b/app/api/channels/route.js index be2288d..b12c751 100644 --- a/app/api/channels/route.js +++ b/app/api/channels/route.js @@ -21,6 +21,7 @@ export async function POST(req) { const channel = await engine.createChannel(user.id, body); return NextResponse.json(channel); } catch (err) { - return NextResponse.json({ error: err.message }, { status: 500 }); + const status = err.status === 402 ? 402 : 500; + return NextResponse.json({ error: err.message, code: err.code }, { status }); } } diff --git a/app/api/topics-bank/[channelId]/add/route.js b/app/api/topics-bank/[channelId]/add/route.js new file mode 100644 index 0000000..87cf56b --- /dev/null +++ b/app/api/topics-bank/[channelId]/add/route.js @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; + +const ENGINE_URL = process.env.ENGINE_URL || 'http://localhost:3030'; +const ENGINE_SECRET = process.env.ENGINE_SECRET || ''; + +export async function POST(req, { params }) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const body = await req.json(); + const res = await fetch(`${ENGINE_URL}/api/generate/topics-bank/${params.channelId}/add`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET }, + body: JSON.stringify(body), + }); + return NextResponse.json(await res.json()); +} diff --git a/app/api/topics-bank/[channelId]/refill/route.js b/app/api/topics-bank/[channelId]/refill/route.js new file mode 100644 index 0000000..9f71d38 --- /dev/null +++ b/app/api/topics-bank/[channelId]/refill/route.js @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; + +const ENGINE_URL = process.env.ENGINE_URL || 'http://localhost:3030'; +const ENGINE_SECRET = process.env.ENGINE_SECRET || ''; + +export async function POST(req, { params }) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const res = await fetch(`${ENGINE_URL}/api/generate/topics-bank/${params.channelId}/refill`, { + method: 'POST', headers: { 'x-internal-secret': ENGINE_SECRET }, + }); + return NextResponse.json(await res.json()); +} diff --git a/app/api/topics-bank/[channelId]/route.js b/app/api/topics-bank/[channelId]/route.js new file mode 100644 index 0000000..f5167f1 --- /dev/null +++ b/app/api/topics-bank/[channelId]/route.js @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; + +const ENGINE_URL = process.env.ENGINE_URL || 'http://localhost:3030'; +const ENGINE_SECRET = process.env.ENGINE_SECRET || ''; +const h = { 'x-internal-secret': ENGINE_SECRET }; + +export async function GET(req, { params }) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { searchParams } = new URL(req.url); + const res = await fetch(`${ENGINE_URL}/api/generate/topics-bank/${params.channelId}?${searchParams}`, { headers: h }); + return NextResponse.json(await res.json()); +} diff --git a/app/api/topics-bank/item/[id]/route.js b/app/api/topics-bank/item/[id]/route.js new file mode 100644 index 0000000..c022f07 --- /dev/null +++ b/app/api/topics-bank/item/[id]/route.js @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; + +const ENGINE_URL = process.env.ENGINE_URL || 'http://localhost:3030'; +const ENGINE_SECRET = process.env.ENGINE_SECRET || ''; + +export async function DELETE(req, { params }) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const res = await fetch(`${ENGINE_URL}/api/generate/topics-bank/item/${params.id}`, { + method: 'DELETE', headers: { 'x-internal-secret': ENGINE_SECRET }, + }); + return NextResponse.json(await res.json()); +} diff --git a/app/channels/new/page.js b/app/channels/new/page.js index d7e4fd3..41d89b1 100644 --- a/app/channels/new/page.js +++ b/app/channels/new/page.js @@ -89,7 +89,16 @@ export default function NewChannelPage() { }); const json = await res.json(); setBusy(false); - if (!res.ok) { setError(json.error || 'Ошибка'); return; } + if (!res.ok) { + if (json.code === 'CHANNEL_LIMIT_REACHED') { + setError(`${json.error} → `); + // Перенаправим на страницу тарифов через 2 сек + setTimeout(() => router.push('/plans'), 2000); + } else { + setError(json.error || 'Ошибка'); + } + return; + } router.push(`/channels/${json.id}`); } diff --git a/app/login/page.js b/app/login/page.js index bcffd19..ca29a4c 100644 --- a/app/login/page.js +++ b/app/login/page.js @@ -27,7 +27,8 @@ export default function LoginPage() { setError(data.error || 'Ошибка'); return; } - router.push('/'); + // Новый пользователь → онбординг, существующий → главная + router.push(data.isNew ? '/onboarding' : '/'); } return ( diff --git a/app/onboarding/page.js b/app/onboarding/page.js new file mode 100644 index 0000000..6326469 --- /dev/null +++ b/app/onboarding/page.js @@ -0,0 +1,178 @@ +'use client'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Sparkles, CheckCircle, ArrowRight, Loader2, Bot, Hash, Zap } from 'lucide-react'; + +const PLATFORMS = [ + { v: 'telegram', label: 'Telegram', icon: '✈️', desc: 'Канал или группа' }, + { v: 'vk', label: 'ВКонтакте', icon: '🔵', desc: 'Группа или паблик' }, + { v: 'max', label: 'MAX', icon: '🟣', desc: 'Мессенджер MAX' }, +]; + +const NICHES = [ + 'Технологии и ИИ', 'Бизнес и финансы', 'Маркетинг и SMM', + 'Здоровье и спорт', 'Образование', 'Развлечения', 'Новости', + 'Другое', +]; + +export default function OnboardingPage() { + const router = useRouter(); + const [step, setStep] = useState(1); + const [platform, setPlatform] = useState('telegram'); + const [name, setName] = useState(''); + const [niche, setNiche] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [done, setDone] = useState(false); + const [channel, setChannel] = useState(null); + + async function createChannel() { + if (!name.trim()) { setError('Введите название канала'); return; } + setBusy(true); + setError(''); + try { + const res = await fetch('/api/channels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name.trim(), platform, niche }), + }).then(r => r.json()); + + if (res.error) { setError(res.error); setBusy(false); return; } + setChannel(res); + setDone(true); + setStep(3); + } catch { setError('Ошибка соединения'); } + setBusy(false); + } + + return ( +
+
+ {/* Progress */} +
+ {[1,2,3].map(n => ( +
+
n ? 'bg-green-500 text-white' : + step === n ? 'bg-accent text-white' : + 'bg-surface2 text-gray-500' + }`}> + {step > n ? : n} +
+ {n < 3 &&
n ? 'bg-green-500' : 'bg-surface2'}`} />} +
+ ))} +
+ +
+ {/* Шаг 1 — платформа */} + {step === 1 && ( + <> +
+
👋
+

Добро пожаловать!

+

Создадим первый канал за пару минут

+
+

Выберите платформу:

+
+ {PLATFORMS.map(p => ( + + ))} +
+ + + )} + + {/* Шаг 2 — название и ниша */} + {step === 2 && ( + <> +
+

Расскажите о канале

+

AI будет генерировать контент в нужном стиле

+
+
+
+ + setName(e.target.value)} + className="input w-full" + placeholder="Например: Tech Insider RU" + autoFocus + /> +
+
+ +
+ {NICHES.map(n => ( + + ))} +
+
+
+ {error &&

{error}

} +
+ + +
+ + )} + + {/* Шаг 3 — готово */} + {step === 3 && ( + <> +
+
🎉
+

Канал создан!

+

Что делать дальше:

+
+
+ {[ + { icon: Bot, text: 'Подключите бота Telegram в настройках канала', color: 'text-blue-400' }, + { icon: Hash, text: 'AI сгенерирует темы постов автоматически', color: 'text-purple-400' }, + { icon: Zap, text: 'Напишите первый пост с помощью AI', color: 'text-yellow-400' }, + ].map(({ icon: Icon, text, color }) => ( +
+ + {text} +
+ ))} +
+
+ + +
+ + )} +
+ +

+ У вас 50 бесплатных кредитов для старта +

+
+
+ ); +} diff --git a/components/ChannelEdit.js b/components/ChannelEdit.js index fb79226..7be074f 100644 --- a/components/ChannelEdit.js +++ b/components/ChannelEdit.js @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { ArrowLeft, Save, Trash2, Loader2, Image as ImageIcon, Type, Palette, Plus, X, Sparkles, Plug } from 'lucide-react'; +import TopicBank from './TopicBank'; const GOALS = [ { v: 'educational', label: 'Обучение', desc: 'Объясняем, разбираем' }, @@ -492,6 +493,9 @@ export default function ChannelEdit({ channel }) { Единственная модель для генерации изображений. Параметр качества фиксирован провайдером.

+ + {/* Банк тем */} + )} diff --git a/components/TopicBank.js b/components/TopicBank.js new file mode 100644 index 0000000..20e0e7d --- /dev/null +++ b/components/TopicBank.js @@ -0,0 +1,126 @@ +'use client'; +import { useState, useEffect } from 'react'; +import { RefreshCw, Plus, Trash2, Loader2, Lightbulb, X } from 'lucide-react'; + +export default function TopicBank({ channelId }) { + const [topics, setTopics] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [refilling,setRefilling]= useState(false); + const [newTopic, setNewTopic] = useState(''); + const [adding, setAdding] = useState(false); + const [showAdd, setShowAdd] = useState(false); + + async function load() { + setLoading(true); + try { + const res = await fetch(`/api/topics-bank/${channelId}`).then(r => r.json()); + setTopics(res.topics || []); + setTotal(res.total_unused || 0); + } catch {} + setLoading(false); + } + + useEffect(() => { load(); }, [channelId]); + + async function refill() { + setRefilling(true); + await fetch(`/api/topics-bank/${channelId}/refill`, { method: 'POST' }); + await load(); + setRefilling(false); + } + + async function addManual() { + if (!newTopic.trim()) return; + setAdding(true); + await fetch(`/api/topics-bank/${channelId}/add`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topics: [newTopic.trim()] }), + }); + setNewTopic(''); + setShowAdd(false); + await load(); + setAdding(false); + } + + async function deleteTopic(id) { + await fetch(`/api/topics-bank/item/${id}`, { method: 'DELETE' }); + setTopics(t => t.filter(x => x.id !== id)); + setTotal(n => Math.max(0, n - 1)); + } + + const unusedTopics = topics.filter(t => !t.is_used); + const stockColor = total >= 5 ? 'text-green-400' : total > 0 ? 'text-yellow-400' : 'text-red-400'; + + return ( +
+
+
+

+ Банк тем +

+

+ {total === 0 ? 'Темы закончились — нужно пополнить' : `${total} тем в запасе`} +

+
+
+ + +
+
+ + {/* Добавить вручную */} + {showAdd && ( +
+ setNewTopic(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addManual()} + placeholder="Введите тему поста..." + className="input flex-1 text-sm py-1.5" + autoFocus + /> + + +
+ )} + + {loading &&
} + + {!loading && unusedTopics.length === 0 && ( +
+ Тем нет. Нажмите ↻ чтобы сгенерировать AI +
+ )} + + {!loading && unusedTopics.length > 0 && ( + + )} + +

+ Темы используются при автоматической генерации постов. При запасе <5 — AI пополняет автоматически. +

+
+ ); +} diff --git a/lib/engine.js b/lib/engine.js index dc5e145..d130ae0 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -1,7 +1,7 @@ /** * Engine client — единая точка вызовов к zeropost-engine */ -const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3040'; +const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030'; const ENGINE_SECRET = process.env.ENGINE_SECRET || 'zeropost_internal_2026'; async function call(path, options = {}) {