merge: resolve ChannelView icon conflict, keep History + Search/Camera/ExternalLink/Link2

This commit is contained in:
Alexey Pavlov
2026-06-15 10:28:42 +03:00
95 changed files with 8926 additions and 130 deletions
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/autogen/${params.category}`, {
method: 'PATCH',
headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function POST(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/autogen/${params.category}/run`, {
method: 'POST', headers: h(user.id),
});
return NextResponse.json(await res.json());
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function DELETE(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/autogen/queue/${params.id}`, {
method: 'DELETE', headers: h(user.id),
});
return NextResponse.json(await res.json());
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function GET() {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/autogen`, { headers: h(user.id), cache: 'no-store' });
return NextResponse.json(await res.json());
}
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/autogen/queue`, {
method: 'POST',
headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function DELETE(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics/${params.id}`, { method: 'DELETE', headers: h(user.id) });
return NextResponse.json(await res.json());
}
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics/generate`, {
method: 'POST', headers: { ...h(user.id), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
// GET — список тем
export async function GET(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(req.url);
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics?${searchParams}`, { headers: h(user.id), cache: 'no-store' });
return NextResponse.json(await res.json());
}
// POST — добавить тему
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics`, {
method: 'POST', headers: { ...h(user.id), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/credit-costs/${params.operation}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/credit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function GET() {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/dashboard`, {
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
cache: 'no-store',
});
return NextResponse.json(await res.json());
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/email/test`, {
method: 'POST', headers: { ...h(user.id), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function GET(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(req.url);
const res = await fetch(
`${ENGINE_URL}/api/admin/logs?${searchParams}`,
{
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
cache: 'no-store',
}
);
return NextResponse.json(await res.json());
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/plans/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/promos/${params.id}`, {
method: 'PATCH', headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
export async function DELETE(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/promos/${params.id}`, {
method: 'DELETE', headers: h(user.id),
});
return NextResponse.json(await res.json());
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function GET(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/promos`, { headers: h(user.id) });
return NextResponse.json(await res.json());
}
export async function POST(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/promos`, {
method: 'POST',
headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function POST(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/queue/${params.id}/retry`, {
method: 'POST',
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
});
return NextResponse.json(await res.json());
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function GET() {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/queue`, { headers: h(user.id), cache: 'no-store' });
return NextResponse.json(await res.json());
}
export async function DELETE() {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/queue/stuck`, { method: 'DELETE', headers: h(user.id) });
return NextResponse.json(await res.json());
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function PUT(req, { params }) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const { key } = await params;
const body = await req.json();
const row = await engine.updateSetting(key, body?.value);
return NextResponse.json(row);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/session';
import { engine } from '@/lib/engine';
// GET /api/admin/settings?category=photo_search
export async function GET(req) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const { searchParams } = new URL(req.url);
const category = searchParams.get('category') || undefined;
const rows = await engine.listSettings(category);
return NextResponse.json(rows);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/session';
import { engine } from '@/lib/engine';
// GET /api/admin/usage/summary?range=today&group_by=service
export async function GET(req) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const { searchParams } = new URL(req.url);
const params = {};
if (searchParams.get('range')) params.range = searchParams.get('range');
if (searchParams.get('group_by')) params.group_by = searchParams.get('group_by');
if (searchParams.get('service')) params.service = searchParams.get('service');
const data = await engine.usageSummary(params);
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function GET(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/users/${params.id}`, { headers: h(user.id) });
return NextResponse.json(await res.json());
}
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json();
const res = await fetch(`${ENGINE_URL}/api/admin/users/${params.id}`, {
method: 'PATCH',
headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+22 -4
View File
@@ -16,19 +16,33 @@ export async function POST(req) {
}
const hash = await bcrypt.hash(password, 10);
const { rows } = await q(
`INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name`,
`INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name,is_admin`,
[email, hash]
);
const user = rows[0];
const s = await getSession();
s.userId = user.id;
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
const { rows } = await q(`SELECT id,email,password,name FROM users WHERE email=$1`, [email]);
const { rows } = await q(
`SELECT id,email,password,name,is_admin FROM users WHERE email=$1`,
[email]
);
if (!rows.length) {
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
}
@@ -41,6 +55,10 @@ export async function POST(req) {
s.userId = user.id;
s.email = user.email;
s.name = user.name;
s.isAdmin = !!user.is_admin;
await s.save();
return NextResponse.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } });
return NextResponse.json({
ok: true,
user: { id: user.id, email: user.email, name: user.name, isAdmin: !!user.is_admin },
});
}
+13
View File
@@ -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 }); }
}
+12
View File
@@ -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 }); }
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function POST(req) {
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/billing/apply-promo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(req) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const data = await engine.getBillingBalance(user.id);
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+15
View File
@@ -0,0 +1,15 @@
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) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { plan_code } = await req.json();
const data = await engine.call('/api/billing/checkout', {
userId: user.id, method: 'POST', body: { plan_code },
});
return NextResponse.json(data);
} catch (err) { return NextResponse.json({ error: err.message }, { status: 500 }); }
}
+17
View File
@@ -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 });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(req) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
try {
const data = await engine.getTransactions(Object.fromEntries(searchParams));
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
// GET /api/calendar?from=&to=&channel_id=
export async function GET(request) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
const params = {};
if (searchParams.get('from')) params.from = searchParams.get('from');
if (searchParams.get('to')) params.to = searchParams.get('to');
if (searchParams.get('channel_id')) params.channel_id = searchParams.get('channel_id');
try {
const data = await engine.getCalendar(user.id, params);
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
// PATCH /api/calendar — reschedule user_post (drag & drop)
export async function PATCH(request) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id, scheduled_at } = await request.json();
if (!id || !scheduled_at) {
return NextResponse.json({ error: 'id and scheduled_at required' }, { status: 400 });
}
try {
const post = await engine.updatePost(user.id, id, { scheduled_at, status: 'scheduled' });
return NextResponse.json(post);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1: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 { searchParams } = new URL(req.url);
const body = await req.json().catch(() => ({}));
const res = await fetch(
`${ENGINE_URL}/api/channels/${params.id}/drafts/generate?count=${searchParams.get('count') || body.count || 3}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
}
);
return NextResponse.json(await res.json());
}
+26
View File
@@ -0,0 +1,26 @@
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();
try {
const res = await fetch(`${ENGINE_URL}/api/channels/${params.id}/poll`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-internal-secret': ENGINE_SECRET,
'x-user-id': String(user.id),
},
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+2 -1
View File
@@ -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 });
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1: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().catch(() => ({}));
const res = await fetch(`${ENGINE_URL}/api/drafts/${params.id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1: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/drafts/${params.id}/reject`, {
method: 'POST',
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
});
return NextResponse.json(await res.json());
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
function h(userId) {
return { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(userId) };
}
// PATCH /api/drafts/:id
export async function PATCH(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/drafts/${params.id}`, {
method: 'PATCH',
headers: { ...h(user.id), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
// DELETE /api/drafts/:id
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/drafts/${params.id}`, { method: 'DELETE', headers: h(user.id) });
return NextResponse.json(await res.json());
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
function eHeaders(userId) {
return { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(userId) };
}
// GET /api/drafts — все черновики пользователя
export async function GET(req) {
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/drafts?${searchParams}`, { headers: eHeaders(user.id) });
return NextResponse.json(await res.json());
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function POST(request) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();
if (!body.channelId || !body.url) {
return NextResponse.json({ error: 'channelId and url required' }, { status: 400 });
}
try {
const data = await engine.generateFromUrl(user.id, body);
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
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) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
try {
const res = await fetch(`${ENGINE_URL}/api/generate/hashtags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-internal-secret': ENGINE_SECRET,
'x-user-id': String(user.id),
},
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
@@ -0,0 +1,16 @@
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 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/inbox/${params.channelId}?${searchParams.toString()}`,
{ headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) } }
);
return NextResponse.json(await res.json());
}
@@ -0,0 +1,15 @@
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/inbox/${params.channelId}/setup-webhook`, {
method: 'POST',
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
});
return NextResponse.json(await res.json());
}
+17
View File
@@ -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().catch(() => ({}));
const res = await fetch(`${ENGINE_URL}/api/inbox/${params.id}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
@@ -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().catch(() => ({}));
const res = await fetch(`${ENGINE_URL}/api/inbox/${params.id}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json());
}
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(request, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
try {
const { channelId } = await params;
const data = await engine.getBestTime(channelId, Object.fromEntries(searchParams));
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(request, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
try {
const { channelId } = await params;
const data = await engine.getChannelMetrics(channelId, Object.fromEntries(searchParams));
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function PATCH(req, { params }) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const { id } = await params;
const body = await req.json();
const note = await engine.updateNote(id, body);
return NextResponse.json(note);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function DELETE(req, { params }) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const { id } = await params;
await engine.deleteNote(id);
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(req) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const notes = await engine.listNotes();
return NextResponse.json(notes);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function POST(req) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
try {
const body = await req.json();
const note = await engine.createNote(body);
return NextResponse.json(note);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+18
View File
@@ -0,0 +1,18 @@
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) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const body = await req.json();
const data = await engine.photoSearchByQuery(body);
return NextResponse.json(data);
} catch (err) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: err.status || 500 }
);
}
}
+14
View File
@@ -0,0 +1,14 @@
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) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const data = await engine.photoSearchProfiles();
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
+14
View File
@@ -0,0 +1,14 @@
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) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const data = await engine.photoSearchQuota();
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
}
}
@@ -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());
}
@@ -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());
}
+14
View File
@@ -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());
}
+14
View File
@@ -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());
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(req) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { searchParams } = new URL(req.url);
try {
const data = await engine.usageSummary(Object.fromEntries(searchParams));
return NextResponse.json(data);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+216
View File
@@ -0,0 +1,216 @@
'use client';
import { useState, useEffect } from 'react';
import { Coins, RefreshCw, TrendingDown, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
import Link from 'next/link';
import BackButton from '@/components/BackButton';
const TYPE_LABELS = {
spend_image: { label: 'Генерация картинки', sign: '-', color: 'text-red-400' },
spend_text_post: { label: 'Генерация поста', sign: '-', color: 'text-red-400' },
spend_article: { label: 'Генерация статьи', sign: '-', color: 'text-red-400' },
spend_autopublish:{ label: 'Публикация', sign: '-', color: 'text-gray-400' },
plan_credit: { label: 'Начисление по тарифу',sign: '+', color: 'text-green-400' },
topup: { label: 'Пополнение', sign: '+', color: 'text-green-400' },
bonus: { label: 'Бонус', sign: '+', color: 'text-blue-400' },
refund: { label: 'Возврат', sign: '+', color: 'text-blue-400' },
};
function fmtDate(s) {
const d = new Date(s);
return d.toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
}
export default function BillingPage() {
const [balance, setBalance] = useState(null);
const [txs, setTxs] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const PER_PAGE = 30;
async function load(p = 0) {
setLoading(true);
try {
const [balRes, txRes] = await Promise.all([
fetch('/api/billing/balance').then(r => r.json()),
fetch(`/api/billing/transactions?limit=${PER_PAGE}&offset=${p * PER_PAGE}`).then(r => r.json()),
]);
setBalance(balRes);
setTxs(txRes.transactions || []);
setTotal(txRes.total || 0);
} catch {}
setLoading(false);
}
useEffect(() => { load(0); }, []);
const PLAN_COLORS = { free: 'text-gray-400', starter: 'text-blue-400', pro: 'text-purple-400', business: 'text-yellow-400' };
return (
<main className="max-w-3xl mx-auto p-4 sm:p-6">
<BackButton />
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold flex items-center gap-2">
<Coins className="w-5 h-5 text-accent" /> Баланс и кредиты
</h1>
<button onClick={() => load(page)} className="btn-ghost p-2">
<RefreshCw className="w-4 h-4" />
</button>
</div>
{/* Баланс */}
{balance && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
<div className="card p-4 col-span-2 sm:col-span-1 border-accent/40 bg-accent/5">
<div className="text-3xl font-bold text-accent">
{balance.isUnlimited ? '∞' : balance.credits}
</div>
<div className="text-xs text-gray-400 mt-1">кредитов осталось</div>
</div>
<div className="card p-4">
<div className={`text-lg font-bold ${PLAN_COLORS[balance.plan] || 'text-gray-300'}`}>
{balance.planName}
</div>
<div className="text-xs text-gray-400 mt-1">текущий тариф</div>
</div>
<div className="card p-4">
<div className="text-sm font-medium text-gray-300">
{balance.resetAt ? new Date(balance.resetAt).toLocaleDateString('ru-RU') : '—'}
</div>
<div className="text-xs text-gray-400 mt-1">сброс кредитов</div>
</div>
</div>
)}
{/* CTA апгрейд */}
{balance?.plan === 'free' && (
<div className="card p-4 mb-6 border-accent/30 bg-accent/5 flex items-center justify-between">
<div>
<div className="font-medium text-sm">Хотите больше кредитов?</div>
<div className="text-xs text-gray-400 mt-0.5">Starter 500 кредитов за 490/мес</div>
</div>
<Link href="/plans" className="btn-primary text-sm px-4 py-1.5 flex items-center gap-1">
Тарифы <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
)}
{/* Промокод */}
<PromoForm onApplied={() => load(0)} />
{/* Стоимость операций */}
<div className="card p-4 mb-6">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-3">Стоимость операций</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
{ label: 'Картинка', credits: 5, icon: '🖼' },
{ label: 'Пост', credits: 2, icon: '✍️' },
{ label: 'Статья', credits: 5, icon: '📝' },
{ label: 'Публикация', credits: 0, icon: '📤' },
].map(op => (
<div key={op.label} className="text-center p-2 rounded-lg bg-surface2">
<div className="text-lg">{op.icon}</div>
<div className="text-xs text-gray-300 mt-1">{op.label}</div>
<div className="text-sm font-bold text-accent mt-0.5">
{op.credits === 0 ? 'бесплатно' : `${op.credits} кр`}
</div>
</div>
))}
</div>
</div>
{/* История транзакций */}
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">История</h2>
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && (
<div className="card overflow-hidden">
{txs.length === 0 && (
<div className="py-8 text-center text-gray-500 text-sm">Транзакций пока нет</div>
)}
{txs.map(tx => {
const meta = TYPE_LABELS[tx.type] || { label: tx.type, sign: tx.amount > 0 ? '+' : '-', color: 'text-gray-400' };
return (
<div key={tx.id} className="flex items-center justify-between px-4 py-3 border-b border-border last:border-0 hover:bg-surface2/50">
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{tx.description || meta.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{fmtDate(tx.created_at)}</div>
</div>
<div className="text-right ml-4">
<div className={`font-bold text-sm ${meta.color}`}>
{meta.sign}{Math.abs(tx.amount)} кр
</div>
<div className="text-xs text-gray-500">= {tx.balance_after === -1 ? '∞' : tx.balance_after} кр</div>
</div>
</div>
);
})}
</div>
)}
{/* Пагинация */}
{total > PER_PAGE && (
<div className="flex justify-center gap-2 mt-4">
<button disabled={page === 0} onClick={() => { setPage(p => p-1); load(page-1); }} className="btn-ghost px-3 py-1.5 text-sm disabled:opacity-40"> Назад</button>
<span className="text-sm text-gray-500 self-center">{page+1} / {Math.ceil(total/PER_PAGE)}</span>
<button disabled={(page+1)*PER_PAGE >= total} onClick={() => { setPage(p => p+1); load(page+1); }} className="btn-ghost px-3 py-1.5 text-sm disabled:opacity-40">Вперёд </button>
</div>
)}
</main>
);
}
function PromoForm({ onApplied }) {
const [code, setCode] = useState('');
const [msg, setMsg] = useState('');
const [busy, setBusy] = useState(false);
const [show, setShow] = useState(false);
async function apply() {
if (!code.trim()) return;
setBusy(true);
const res = await fetch('/api/billing/apply-promo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code.trim().toUpperCase() }),
}).then(r => r.json());
setBusy(false);
if (res.ok) {
setMsg(res.message);
setCode('');
setShow(false);
onApplied?.();
} else {
setMsg('Ошибка: ' + (res.error || 'неизвестно'));
}
setTimeout(() => setMsg(''), 4000);
}
return (
<div className="mb-6">
{!show ? (
<button onClick={() => setShow(true)} className="text-sm text-gray-500 hover:text-accent transition-colors">
🎁 Есть промокод?
</button>
) : (
<div className="card p-4">
<div className="flex gap-2">
<input
value={code}
onChange={e => setCode(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && apply()}
placeholder="ВВЕДИТЕ КОД"
className="input flex-1 font-mono text-sm py-1.5 tracking-widest"
autoFocus
maxLength={32}
/>
<button onClick={apply} disabled={busy || !code.trim()} className="btn-primary px-4 text-sm">
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Применить'}
</button>
<button onClick={() => { setShow(false); setCode(''); }} className="btn-ghost px-3 text-sm"></button>
</div>
{msg && <p className={`text-xs mt-2 ${msg.startsWith('Ошибка') ? 'text-red-400' : 'text-green-400'}`}>{msg}</p>}
</div>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from 'next/navigation';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
import Header from '@/components/Header';
import CalendarView from '@/components/CalendarView';
export const metadata = { title: 'Календарь публикаций — ZeroPost' };
export default async function CalendarPage() {
const user = await requireUser();
if (!user) redirect('/login');
let channels = [];
try {
channels = await engine.listChannels(user.id);
} catch (e) {
console.error('[Calendar] listChannels failed:', e.message);
}
return (
<>
<Header user={user} />
<main className="max-w-7xl mx-auto p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Календарь публикаций</h1>
<p className="text-sm text-gray-500 mt-1">
Планируй и отслеживай выход постов по всем каналам
</p>
</div>
</div>
<CalendarView channels={channels} />
</main>
</>
);
}
+72 -17
View File
@@ -49,7 +49,8 @@ export default function NewChannelPage() {
const [name, setName] = useState('');
const [niche, setNiche] = useState('');
const [audience, setAudience] = useState('');
const [goal, setGoal] = useState('educational');
const [goals, setGoals] = useState(['educational']); // multi-select, отправляем как CSV
const [customGoal, setCustomGoal] = useState(''); // поле для своей цели
const [language, setLanguage] = useState('ru');
// Шаг 2 — стиль
@@ -70,7 +71,7 @@ export default function NewChannelPage() {
setBusy(true);
setError('');
const data = {
name, niche, audience, goal, language, region: 'ru',
name, niche, audience, goal: goals.join(','), language, region: 'ru',
style: {
tone, formality, humor,
post_length: postLength,
@@ -88,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}`);
}
@@ -150,22 +160,67 @@ export default function NewChannelPage() {
/>
</div>
<div>
<label className="label">Цель канала</label>
<label className="label">Цель канала <span className="text-gray-500 font-normal">(можно несколько)</span></label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{GOALS.map(g => (
<button
key={g.v}
type="button"
onClick={() => setGoal(g.v)}
className={`p-2.5 rounded-lg border text-left transition-colors ${
goal === g.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>
<div className="text-sm font-medium">{g.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{g.desc}</div>
</button>
))}
{GOALS.map(g => {
const on = goals.includes(g.v);
return (
<button
key={g.v}
type="button"
onClick={() => setGoals(on ? goals.filter(x => x !== g.v) : [...goals, g.v])}
className={`p-2.5 rounded-lg border text-left transition-colors ${
on ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>
<div className="text-sm font-medium">{g.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{g.desc}</div>
</button>
);
})}
</div>
{/* Своя цель */}
<div className="flex gap-2 mt-2">
<input
className="input text-sm flex-1"
placeholder="Своя цель — введи и нажми +"
value={customGoal}
onChange={e => setCustomGoal(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
const v = customGoal.trim();
if (v && !goals.includes(v)) setGoals([...goals, v]);
setCustomGoal('');
}
}}
/>
<button
type="button"
onClick={() => {
const v = customGoal.trim();
if (v && !goals.includes(v)) setGoals([...goals, v]);
setCustomGoal('');
}}
disabled={!customGoal.trim()}
className="btn-primary px-3 disabled:opacity-40"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Выбранные кастомные цели — чипы */}
{goals.filter(g => !GOALS.find(x => x.v === g)).length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{goals.filter(g => !GOALS.find(x => x.v === g)).map(g => (
<span key={g} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent/15 border border-accent/40 text-xs">
{g}
<button type="button" onClick={() => setGoals(goals.filter(x => x !== g))} className="hover:text-red-400">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
<div>
<label className="label">Язык постов</label>
+225
View File
@@ -0,0 +1,225 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Clock, Check, X, Edit3, Trash2, RefreshCw, Loader2, Calendar, Image as ImgIcon, Zap } from 'lucide-react';
import Link from 'next/link';
import BackButton from '@/components/BackButton';
const STATUS_TABS = [
{ v: 'pending', label: 'Ожидают', color: 'text-accent' },
{ v: 'approved', label: 'Одобрено', color: 'text-green-400' },
{ v: 'rejected', label: 'Отклонено', color: 'text-gray-500' },
];
function timeAgo(s) {
const d = new Date(s), now = new Date();
const diff = now - d;
if (diff < 3600000) return Math.floor(diff / 60000) + ' мин назад';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'ч назад';
return d.toLocaleDateString('ru-RU');
}
export default function DraftsPage() {
const [tab, setTab] = useState('pending');
const [drafts, setDrafts] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading]= useState(true);
const [editing, setEditing]= useState(null); // draft id
const [editText,setEditText]=useState('');
const [schedMap,setSchedMap]=useState({}); // draftId → scheduledAt
const [busy, setBusy] = useState({});
const load = useCallback(async (t = tab) => {
setLoading(true);
try {
const res = await fetch(`/api/drafts?status=${t}&limit=50`).then(r => r.json());
setDrafts(res.drafts || []);
setTotal(res.total || 0);
} catch {}
setLoading(false);
}, [tab]);
useEffect(() => { load(tab); }, [tab]);
async function doApprove(id) {
setBusy(b => ({ ...b, [id]: true }));
const res = await fetch(`/api/drafts/${id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scheduled_at: schedMap[id] || null }),
}).then(r => r.json());
setBusy(b => ({ ...b, [id]: false }));
if (res.ok) load(tab); else alert(res.error);
}
async function doReject(id) {
setBusy(b => ({ ...b, [id]: true }));
await fetch(`/api/drafts/${id}/reject`, { method: 'POST' });
setBusy(b => ({ ...b, [id]: false }));
load(tab);
}
async function doDelete(id) {
if (!confirm('Удалить черновик?')) return;
await fetch(`/api/drafts/${id}`, { method: 'DELETE' });
load(tab);
}
async function saveEdit(id) {
await fetch(`/api/drafts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: editText }),
});
setEditing(null);
load(tab);
}
const pendingCount = tab === 'pending' ? total : 0;
return (
<main className="max-w-3xl mx-auto p-4 sm:p-6">
<BackButton />
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<Zap className="w-5 h-5 text-accent" /> Черновики
</h1>
<p className="text-sm text-gray-400 mt-0.5">
{tab === 'pending' && total > 0
? `${total} ${total === 1 ? 'пост ждёт' : 'поста ждут'} одобрения`
: 'Авто-генерированные и пакетные посты на проверку'}
</p>
</div>
<button onClick={() => load(tab)} className="btn-ghost p-2">
<RefreshCw className="w-4 h-4" />
</button>
</div>
{/* Табы */}
<div className="flex gap-1 mb-5">
{STATUS_TABS.map(t => (
<button key={t.v} onClick={() => setTab(t.v)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
tab === t.v ? `bg-accent/10 ${t.color} font-medium` : 'text-gray-500 hover:text-gray-300'
}`}>
{t.label}
</button>
))}
</div>
{loading && <div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && drafts.length === 0 && (
<div className="py-16 text-center text-gray-500">
<Zap className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div className="text-sm">
{tab === 'pending'
? <>Нет черновиков. Включите авто-генерацию в <Link href="/" className="text-accent hover:underline">настройках канала</Link> или сгенерируйте вручную.</>
: 'Нет записей'}
</div>
</div>
)}
<div className="space-y-4">
{drafts.map(draft => (
<div key={draft.id} className="card p-4 space-y-3">
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-gray-300">{draft.channel_name}</span>
{draft.platform && <span className="text-xs text-gray-500 px-1.5 py-0.5 bg-surface2 rounded">{draft.platform}</span>}
</div>
<div className="flex items-center gap-1 text-xs text-gray-500">
<Clock className="w-3 h-3" />
{timeAgo(draft.created_at)}
</div>
</div>
{/* Тема */}
{draft.topic && (
<div className="text-xs text-accent/80 flex items-center gap-1">
<span>💡</span> {draft.topic}
</div>
)}
{/* Текст */}
{editing === draft.id ? (
<div className="space-y-2">
<textarea
rows={6}
value={editText}
onChange={e => setEditText(e.target.value)}
className="input w-full text-sm resize-none font-mono"
autoFocus
/>
<div className="flex gap-2">
<button onClick={() => saveEdit(draft.id)} className="btn-primary text-sm px-3 py-1.5">Сохранить</button>
<button onClick={() => setEditing(null)} className="btn-ghost text-sm px-3 py-1.5">Отмена</button>
</div>
</div>
) : (
<div className="text-sm text-gray-200 whitespace-pre-wrap leading-relaxed bg-surface2 rounded-lg p-3 max-h-48 overflow-y-auto">
{draft.text}
</div>
)}
{/* Изображение */}
{draft.image_url && (
<div className="flex items-center gap-2 text-xs text-gray-500">
<ImgIcon className="w-3.5 h-3.5" />
<span>Картинка прикреплена</span>
<a href={draft.image_url} target="_blank" rel="noreferrer" className="text-accent hover:underline">просмотр</a>
</div>
)}
{/* Действия */}
{draft.status === 'pending' && (
<div className="flex flex-wrap items-center gap-2 pt-1">
{/* Время публикации */}
<input
type="datetime-local"
value={schedMap[draft.id] || ''}
onChange={e => setSchedMap(m => ({ ...m, [draft.id]: e.target.value }))}
className="input text-xs py-1.5 w-48"
title="Время публикации (оставьте пустым для ближайшего слота)"
/>
<button onClick={() => doApprove(draft.id)} disabled={busy[draft.id]}
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1.5">
{busy[draft.id] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
Одобрить
</button>
<button onClick={() => { setEditing(draft.id); setEditText(draft.text); }}
className="btn-ghost text-sm px-3 py-1.5 flex items-center gap-1.5">
<Edit3 className="w-3.5 h-3.5" /> Редактировать
</button>
<button onClick={() => doReject(draft.id)} disabled={busy[draft.id]}
className="btn-ghost text-sm px-3 py-1.5 text-gray-500 flex items-center gap-1.5">
<X className="w-3.5 h-3.5" /> Отклонить
</button>
</div>
)}
{draft.status === 'approved' && (
<div className="flex items-center gap-2 text-xs text-green-400">
<Check className="w-3.5 h-3.5" />
Одобрен · запланирован на {draft.scheduled_at ? new Date(draft.scheduled_at).toLocaleString('ru-RU') : '—'}
</div>
)}
{draft.status === 'rejected' && (
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">Отклонён</span>
<button onClick={() => doDelete(draft.id)} className="btn-ghost p-1.5 text-gray-600 hover:text-red-400">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
))}
</div>
</main>
);
}
+173
View File
@@ -0,0 +1,173 @@
import Link from 'next/link';
import { Sparkles, Zap, Calendar, BarChart3, MessageCircle, Globe, ArrowRight, Check } from 'lucide-react';
const FEATURES = [
{ icon: Zap, title: 'AI генерация постов', desc: 'Claude пишет посты под твою нишу и стиль. Тексты, которые хочется читать.' },
{ icon: Calendar, title: 'Отложенная публикация', desc: 'Планируй контент на неделю вперёд. Автопостинг в нужное время.' },
{ icon: Globe, title: 'Telegram, VK, MAX', desc: 'Один интерфейс для всех платформ. Публикуй везде одновременно.' },
{ icon: Sparkles, title: 'Авто-черновики', desc: 'Каждое утро 3 новых поста на проверку. Ты только одобряешь лучшее.' },
{ icon: BarChart3, title: 'Аналитика канала', desc: 'Видишь что работает. Охват, реакции, лучшее время для публикации.' },
{ icon: MessageCircle, title: 'Inbox и AI-ответы', desc: 'Комментарии приходят в одно место. AI предлагает ответы за тебя.' },
];
const PLANS = [
{
name: 'Free', price: 0, credits: 50, channels: 1,
features: ['1 канал', '50 кредитов/мес', 'AI генерация постов', 'Планировщик'],
cta: 'Начать бесплатно', ctaHref: '/register', accent: false,
},
{
name: 'Starter', price: 490, credits: 500, channels: 2,
features: ['2 канала', '500 кредитов/мес', 'Авто-черновики', 'Аналитика', 'Inbox'],
cta: 'Попробовать', ctaHref: '/register', accent: true,
},
{
name: 'Pro', price: 1490, credits: 2000, channels: 5,
features: ['5 каналов', '2000 кредитов/мес', 'Все платформы', 'Хештеги AI', 'Опросы TG'],
cta: 'Выбрать Pro', ctaHref: '/register', accent: false,
},
{
name: 'Business', price: 3990, credits: -1, channels: -1,
features: ['Безлимит каналов', 'Безлимит кредитов', 'Приоритетная поддержка', 'API доступ'],
cta: 'Связаться', ctaHref: 'mailto:hello@zeropost.ru', accent: false,
},
];
export default function LandingPage() {
return (
<div className="min-h-screen bg-background text-text">
{/* Nav */}
<nav className="border-b border-border sticky top-0 bg-background/90 backdrop-blur z-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2 font-bold text-lg">
<Sparkles className="w-5 h-5 text-accent" /> ZeroPost
</Link>
<div className="flex items-center gap-3">
<Link href="/login" className="btn-ghost text-sm px-4 py-2">Войти</Link>
<Link href="/register" className="btn-primary text-sm px-4 py-2">Попробовать бесплатно</Link>
</div>
</div>
</nav>
{/* Hero */}
<section className="max-w-4xl mx-auto px-4 sm:px-6 pt-20 pb-16 text-center">
<div className="inline-flex items-center gap-2 text-xs text-accent bg-accent/10 px-3 py-1.5 rounded-full mb-6">
<Sparkles className="w-3.5 h-3.5" /> AI-контент для Telegram и VK
</div>
<h1 className="text-4xl sm:text-5xl font-bold leading-tight mb-5">
Ведите канал на автопилоте.<br />
<span className="text-accent">AI пишет, ты одобряешь.</span>
</h1>
<p className="text-gray-400 text-lg max-w-2xl mx-auto mb-8">
ZeroPost генерирует посты для Telegram и VK, планирует публикации и отвечает на комментарии.
Тратьте 10 минут в день вместо 2 часов.
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link href="/register"
className="btn-primary px-6 py-3 text-base flex items-center gap-2">
Начать бесплатно <ArrowRight className="w-4 h-4" />
</Link>
<Link href="/login" className="btn-ghost px-6 py-3 text-base">
Уже есть аккаунт
</Link>
</div>
<p className="text-xs text-gray-500 mt-4">50 кредитов бесплатно · Без карты</p>
</section>
{/* Features */}
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-20">
<h2 className="text-2xl font-bold text-center mb-10">Что умеет ZeroPost</h2>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{FEATURES.map(f => (
<div key={f.title} className="card p-5">
<f.icon className="w-8 h-8 text-accent mb-3" />
<h3 className="font-semibold mb-2">{f.title}</h3>
<p className="text-sm text-gray-400 leading-relaxed">{f.desc}</p>
</div>
))}
</div>
</section>
{/* How it works */}
<section className="max-w-4xl mx-auto px-4 sm:px-6 pb-20">
<h2 className="text-2xl font-bold text-center mb-10">Как это работает</h2>
<div className="grid gap-6 sm:grid-cols-3">
{[
{ step: '1', title: 'Добавь канал', desc: 'Подключи Telegram, VK или MAX. Укажи нишу и стиль.' },
{ step: '2', title: 'AI генерирует', desc: 'Каждое утро — свежие черновики. Редактируй, одобряй.' },
{ step: '3', title: 'Публикуй в один клик', desc: 'Запланируй или публикуй сейчас. Всё само.' },
].map(s => (
<div key={s.step} className="text-center">
<div className="w-10 h-10 rounded-full bg-accent/10 text-accent font-bold text-lg flex items-center justify-center mx-auto mb-3">
{s.step}
</div>
<h3 className="font-semibold mb-2">{s.title}</h3>
<p className="text-sm text-gray-400">{s.desc}</p>
</div>
))}
</div>
</section>
{/* Pricing */}
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-20">
<h2 className="text-2xl font-bold text-center mb-2">Тарифы</h2>
<p className="text-gray-400 text-center text-sm mb-10">Начни бесплатно, масштабируй по мере роста</p>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{PLANS.map(plan => (
<div key={plan.name} className={`card p-5 flex flex-col ${plan.accent ? 'border-accent bg-accent/5' : ''}`}>
{plan.accent && (
<div className="text-xs text-accent font-medium mb-2"> Популярный</div>
)}
<div className="font-bold text-lg">{plan.name}</div>
<div className="text-3xl font-bold mt-1 mb-1">
{plan.price === 0 ? <span className="text-accent">0</span> : `${plan.price}`}
{plan.price > 0 && <span className="text-sm font-normal text-gray-500">/мес</span>}
</div>
<div className="text-xs text-gray-500 mb-4">
{plan.credits === -1 ? '∞ кредитов' : `${plan.credits} кредитов/мес`}
</div>
<ul className="space-y-2 flex-1 mb-5">
{plan.features.map(f => (
<li key={f} className="text-sm text-gray-300 flex items-center gap-2">
<Check className="w-3.5 h-3.5 text-green-400 shrink-0" /> {f}
</li>
))}
</ul>
<Link href={plan.ctaHref}
className={`py-2.5 px-4 rounded-lg text-sm font-medium text-center transition-colors ${
plan.accent ? 'btn-primary' : 'btn-ghost border border-border'
}`}>
{plan.cta}
</Link>
</div>
))}
</div>
</section>
{/* CTA */}
<section className="max-w-2xl mx-auto px-4 sm:px-6 pb-20 text-center">
<h2 className="text-3xl font-bold mb-4">Готовы попробовать?</h2>
<p className="text-gray-400 mb-6">50 бесплатных кредитов. Без карты. Настройка за 5 минут.</p>
<Link href="/register" className="btn-primary px-8 py-3 text-base inline-flex items-center gap-2">
Создать аккаунт <ArrowRight className="w-4 h-4" />
</Link>
</section>
{/* Footer */}
<footer className="border-t border-border py-8">
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex flex-wrap items-center justify-between gap-4 text-sm text-gray-500">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-accent" />
<span className="font-medium text-gray-300">ZeroPost</span>
<span>· AI-автоматизация контента</span>
</div>
<div className="flex gap-4">
<Link href="/login" className="hover:text-gray-300">Войти</Link>
<Link href="/register" className="hover:text-gray-300">Регистрация</Link>
<a href="mailto:hello@zeropost.ru" className="hover:text-gray-300">Контакты</a>
</div>
</div>
</footer>
</div>
);
}
+2 -1
View File
@@ -27,7 +27,8 @@ export default function LoginPage() {
setError(data.error || 'Ошибка');
return;
}
router.push('/');
// Новый пользователь → онбординг, существующий → главная
router.push(data.isNew ? '/onboarding' : '/');
}
return (
+168
View File
@@ -0,0 +1,168 @@
'use client';
import { useState, useEffect } from 'react';
import { Pin, PinOff, Trash2, Plus, Save, Eye, EyeOff, Loader2, MessageCircle, Check, ExternalLink } from 'lucide-react';
const EMPTY = { title: '', content: '', author: 'Редактор', is_pinned: false };
export default function NotesPage() {
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | note object
const [form, setForm] = useState(EMPTY);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
async function load() {
setLoading(true);
try {
const r = await fetch('/api/notes');
setNotes(await r.json());
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
}
useEffect(() => { load(); }, []);
function startNew() {
setForm(EMPTY);
setEditing('new');
setErr('');
}
function startEdit(note) {
setForm({ title: note.title || '', content: note.content, author: note.author, is_pinned: note.is_pinned });
setEditing(note);
setErr('');
}
async function save() {
if (!form.content.trim()) { setErr('Текст заметки обязателен'); return; }
setSaving(true); setErr('');
try {
const body = { ...form, title: form.title.trim() || null };
if (editing === 'new') {
await fetch('/api/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} else {
await fetch(`/api/notes/${editing.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
}
setEditing(null);
await load();
} catch (e) { setErr(e.message); }
finally { setSaving(false); }
}
async function togglePublish(note) {
await fetch(`/api/notes/${note.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_published: !note.is_published }) });
await load();
}
async function togglePin(note) {
await fetch(`/api/notes/${note.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_pinned: !note.is_pinned }) });
await load();
}
async function del(note) {
if (!confirm(`Удалить заметку «${note.title || note.content.slice(0, 40)}»?`)) return;
await fetch(`/api/notes/${note.id}`, { method: 'DELETE' });
await load();
}
const fmt = (d) => new Date(d).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
return (
<main className="max-w-3xl mx-auto p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<MessageCircle className="w-5 h-5 text-accent" />
<h1 className="text-xl font-bold">Заметки редактора</h1>
<a href="https://zeropost.ru/notes" target="_blank" rel="noreferrer" className="text-gray-500 hover:text-accent">
<ExternalLink className="w-4 h-4" />
</a>
</div>
<button onClick={startNew} className="btn-primary text-sm flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Новая заметка
</button>
</div>
{/* Форма создания/редактирования */}
{editing && (
<div className="card p-5 mb-5 border-accent/30">
<div className="text-sm font-semibold mb-3">{editing === 'new' ? 'Новая заметка' : 'Редактировать'}</div>
<input
className="input text-sm mb-3"
placeholder="Заголовок (опц.)"
value={form.title}
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
/>
<textarea
className="input text-sm min-h-[120px] mb-3"
placeholder="Текст заметки. Коротко и по делу — виден блок на главной странице."
value={form.content}
onChange={e => setForm(f => ({ ...f, content: e.target.value }))}
maxLength={1000}
/>
<div className="flex items-center gap-4 mb-3">
<input
className="input text-sm flex-1"
placeholder="Автор"
value={form.author}
onChange={e => setForm(f => ({ ...f, author: e.target.value }))}
/>
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input type="checkbox" checked={form.is_pinned} onChange={e => setForm(f => ({ ...f, is_pinned: e.target.checked }))} className="accent-accent w-4 h-4" />
Закрепить
</label>
</div>
<div className="text-xs text-gray-500 mb-3">{form.content.length}/1000 символов</div>
{err && <div className="text-xs text-red-400 mb-3">{err}</div>}
<div className="flex gap-2">
<button onClick={save} disabled={saving} className="btn-primary text-sm">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{saving ? 'Сохраняю...' : 'Сохранить'}
</button>
<button onClick={() => setEditing(null)} className="btn-ghost text-sm">Отмена</button>
</div>
</div>
)}
{loading && <div className="text-center py-12"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && notes.length === 0 && (
<div className="card p-8 text-center text-gray-500 text-sm">
Заметок пока нет. Нажми «Новая заметка» чтобы добавить первую.
</div>
)}
<div className="space-y-3">
{notes.map(note => (
<div key={note.id} className={`card p-4 ${!note.is_published ? 'opacity-60' : ''}`}>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{note.is_pinned && (
<div className="flex items-center gap-1 text-xs text-accent mb-1">
<Pin className="w-3 h-3" /> закреплено
</div>
)}
{note.title && <div className="font-semibold text-sm mb-1">{note.title}</div>}
<p className="text-sm text-gray-300 whitespace-pre-line line-clamp-4">{note.content}</p>
<div className="text-xs text-gray-500 mt-2">{note.author} · {fmt(note.created_at)}{!note.is_published ? ' · скрыта' : ''}</div>
</div>
<div className="flex flex-col gap-1 shrink-0">
<button onClick={() => startEdit(note)} className="btn-ghost p-1.5 text-xs" title="Редактировать"></button>
<button onClick={() => togglePin(note)} className="btn-ghost p-1.5" title={note.is_pinned ? 'Открепить' : 'Закрепить'}>
{note.is_pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
<button onClick={() => togglePublish(note)} className="btn-ghost p-1.5" title={note.is_published ? 'Скрыть' : 'Показать'}>
{note.is_published ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button onClick={() => del(note)} className="btn-ghost p-1.5 hover:text-red-400" title="Удалить">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
</main>
);
}
+178
View File
@@ -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 (
<main className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-xl">
{/* Progress */}
<div className="flex items-center justify-center gap-2 mb-8">
{[1,2,3].map(n => (
<div key={n} className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-colors ${
step > n ? 'bg-green-500 text-white' :
step === n ? 'bg-accent text-white' :
'bg-surface2 text-gray-500'
}`}>
{step > n ? <CheckCircle className="w-4 h-4" /> : n}
</div>
{n < 3 && <div className={`w-12 h-0.5 ${step > n ? 'bg-green-500' : 'bg-surface2'}`} />}
</div>
))}
</div>
<div className="card p-6 sm:p-8">
{/* Шаг 1 — платформа */}
{step === 1 && (
<>
<div className="text-center mb-6">
<div className="text-3xl mb-2">👋</div>
<h1 className="text-xl font-bold">Добро пожаловать!</h1>
<p className="text-gray-400 text-sm mt-1">Создадим первый канал за пару минут</p>
</div>
<p className="text-sm font-medium mb-3">Выберите платформу:</p>
<div className="grid grid-cols-3 gap-3 mb-6">
{PLATFORMS.map(p => (
<button key={p.v} onClick={() => setPlatform(p.v)}
className={`p-4 rounded-xl border-2 text-center transition-all ${
platform === p.v ? 'border-accent bg-accent/10' : 'border-border hover:border-accent/40'
}`}>
<div className="text-2xl mb-1">{p.icon}</div>
<div className="text-sm font-medium">{p.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{p.desc}</div>
</button>
))}
</div>
<button onClick={() => setStep(2)} className="btn-primary w-full py-3 flex items-center justify-center gap-2">
Далее <ArrowRight className="w-4 h-4" />
</button>
</>
)}
{/* Шаг 2 — название и ниша */}
{step === 2 && (
<>
<div className="text-center mb-6">
<h1 className="text-xl font-bold">Расскажите о канале</h1>
<p className="text-gray-400 text-sm mt-1">AI будет генерировать контент в нужном стиле</p>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="label mb-1.5">Название канала *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="input w-full"
placeholder="Например: Tech Insider RU"
autoFocus
/>
</div>
<div>
<label className="label mb-1.5">Ниша / тематика</label>
<div className="flex flex-wrap gap-2">
{NICHES.map(n => (
<button key={n} onClick={() => setNiche(n === niche ? '' : n)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
niche === n ? 'border-accent bg-accent/10 text-accent' : 'border-border hover:border-accent/40'
}`}>
{n}
</button>
))}
</div>
</div>
</div>
{error && <p className="text-red-400 text-sm mb-3">{error}</p>}
<div className="flex gap-3">
<button onClick={() => setStep(1)} className="btn-ghost px-4">Назад</button>
<button onClick={createChannel} disabled={busy || !name.trim()}
className="btn-primary flex-1 py-3 flex items-center justify-center gap-2">
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Sparkles className="w-4 h-4" />Создать канал</>}
</button>
</div>
</>
)}
{/* Шаг 3 — готово */}
{step === 3 && (
<>
<div className="text-center mb-6">
<div className="text-4xl mb-3">🎉</div>
<h1 className="text-xl font-bold">Канал создан!</h1>
<p className="text-gray-400 text-sm mt-1">Что делать дальше:</p>
</div>
<div className="space-y-3 mb-6">
{[
{ 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 }) => (
<div key={text} className="flex items-start gap-3 p-3 rounded-lg bg-surface2">
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<span className="text-sm">{text}</span>
</div>
))}
</div>
<div className="space-y-2">
<button onClick={() => router.push(channel ? `/channels/${channel.id}` : '/')}
className="btn-primary w-full py-3 flex items-center justify-center gap-2">
Начать работу <ArrowRight className="w-4 h-4" />
</button>
<button onClick={() => router.push(channel ? `/channels/${channel.id}/edit` : '/')}
className="btn-ghost w-full py-2.5 text-sm text-gray-400">
Настроить канал подробнее
</button>
</div>
</>
)}
</div>
<p className="text-center text-xs text-gray-600 mt-4">
У вас 50 бесплатных кредитов для старта
</p>
</div>
</main>
);
}
+27 -30
View File
@@ -15,7 +15,7 @@ const GOAL_LABELS = {
export default async function HomePage() {
const user = await requireUser();
if (!user) redirect('/login');
if (!user) redirect('/landing');
let channels = [];
try {
@@ -43,50 +43,47 @@ export default async function HomePage() {
{channels.length === 0 ? (
<div className="card p-12 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-surface2 mb-4">
<MessageSquare className="w-7 h-7 text-gray-500" />
</div>
<h2 className="text-lg font-semibold mb-1">Пока пусто</h2>
<p className="text-sm text-gray-500 mb-6">
Создай первый канал, чтобы начать генерировать посты
</p>
<MessageSquare className="w-12 h-12 mx-auto mb-4 text-accent opacity-50" />
<h2 className="text-xl font-semibold mb-2">Нет каналов</h2>
<p className="text-gray-500 mb-6">Добавь первый канал чтобы начать генерировать контент</p>
<Link href="/channels/new" className="btn-primary">
<Plus className="w-4 h-4" />
Создать канал
Создать первый канал
</Link>
</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{channels.map(ch => (
<Link
key={ch.id}
href={`/channels/${ch.id}`}
className="card p-5 hover:border-accent/40 transition-colors group"
>
<Link key={ch.id} href={`/channels/${ch.id}`} className="card p-5 hover:border-accent/40 transition-colors group">
<div className="flex items-start justify-between mb-3">
<h3 className="font-semibold group-hover:text-accent transition-colors">
{ch.name}
</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400">
{GOAL_LABELS[ch.goal] || ch.goal}
<div>
<h3 className="font-semibold group-hover:text-accent transition-colors">{ch.name}</h3>
{ch.tg_username && (
<span className="text-xs text-gray-500">@{ch.tg_username}</span>
)}
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${
ch.platform === 'telegram' ? 'bg-blue-500/20 text-blue-400' :
ch.platform === 'vk' ? 'bg-blue-600/20 text-blue-500' :
'bg-purple-500/20 text-purple-400'
}`}>
{ch.platform || 'telegram'}
</span>
</div>
{ch.niche && (
<p className="text-xs text-gray-500 line-clamp-2 mb-3">
{ch.niche}
</p>
<p className="text-sm text-gray-400 mb-3 line-clamp-2">{ch.niche}</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-500">
{ch.audience && (
<div className="flex items-center gap-3 text-xs text-gray-500">
{ch.goal && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
Есть ЦА
<Target className="w-3 h-3" />
{GOAL_LABELS[ch.goal] || ch.goal}
</span>
)}
{ch.style?.example_posts?.length > 0 && (
{ch.language && (
<span className="flex items-center gap-1">
<Target className="w-3 h-3 text-accent" />
{ch.style.example_posts.length} пример{ch.style.example_posts.length === 1 ? '' : 'а'}
<Users className="w-3 h-3" />
{ch.language.toUpperCase()}
</span>
)}
</div>
+165
View File
@@ -0,0 +1,165 @@
'use client';
import { useState, useEffect } from 'react';
import { Check, Zap, Loader2 } from 'lucide-react';
import Link from 'next/link';
import BackButton from '@/components/BackButton';
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">
<BackButton />
<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={async () => {
try {
const res = await fetch('/api/billing/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan_code: plan.code }),
}).then(r => r.json());
if (res.confirmationUrl) window.location.href = res.confirmationUrl;
else alert(res.error || 'Ошибка создания платежа');
} catch { alert('Ошибка соединения'); }
}}>
Подключить
</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>
);
}
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Loader2, Eye, EyeOff, Sparkles } from 'lucide-react';
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [pass, setPass] = useState('');
const [pass2, setPass2] = useState('');
const [name, setName] = useState('');
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function submit() {
if (!email.trim() || !pass) { setError('Заполните email и пароль'); return; }
if (pass.length < 6) { setError('Пароль минимум 6 символов'); return; }
if (pass !== pass2) { setError('Пароли не совпадают'); return; }
setBusy(true); setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password: pass, name: name.trim() || undefined, mode: 'register' }),
}).then(r => r.json());
if (!res.ok) { setError(res.error || 'Ошибка'); setBusy(false); return; }
router.push(res.isNew ? '/onboarding' : '/');
} catch { setError('Ошибка соединения'); setBusy(false); }
}
return (
<main className="min-h-screen flex items-center justify-center p-4 bg-background">
{/* Background glow */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full bg-accent/5 blur-3xl" />
</div>
<div className="w-full max-w-md relative">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-2 text-2xl font-bold">
<Sparkles className="w-7 h-7 text-accent" />
ZeroPost
</Link>
<p className="text-gray-400 text-sm mt-2">Создайте аккаунт это бесплатно</p>
</div>
<div className="card p-6 sm:p-8 space-y-4">
<h1 className="font-bold text-xl text-center">Регистрация</h1>
<div>
<label className="label mb-1.5">Имя <span className="text-gray-500 text-xs">(необязательно)</span></label>
<input value={name} onChange={e => setName(e.target.value)}
placeholder="Алексей"
className="input w-full" autoFocus />
</div>
<div>
<label className="label mb-1.5">Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()}
placeholder="you@example.com"
className="input w-full" />
</div>
<div>
<label className="label mb-1.5">Пароль</label>
<div className="relative">
<input type={show ? 'text' : 'password'}
value={pass} onChange={e => setPass(e.target.value)}
placeholder="Минимум 6 символов"
className="input w-full pr-10" />
<button onClick={() => setShow(s => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500">
{show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="label mb-1.5">Повторите пароль</label>
<input type="password" value={pass2}
onChange={e => setPass2(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()}
placeholder="Ещё раз"
className="input w-full" />
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button onClick={submit} disabled={busy}
className="btn-primary w-full py-3 text-base font-medium flex items-center justify-center gap-2">
{busy ? <Loader2 className="w-5 h-5 animate-spin" /> : <><Sparkles className="w-4 h-4" />Зарегистрироваться</>}
</button>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-gray-500">или</span>
<div className="flex-1 h-px bg-border" />
</div>
<Link href="/login" className="btn-ghost w-full py-2.5 text-center text-sm">
Уже есть аккаунт? Войти
</Link>
</div>
{/* Бонус */}
<div className="mt-4 text-center text-xs text-gray-500">
🎁 При регистрации <span className="text-accent">50 бесплатных кредитов</span>
</div>
<p className="text-center text-xs text-gray-600 mt-3">
Регистрируясь, вы принимаете{' '}
<Link href="/terms" className="hover:text-gray-400">условия использования</Link>
</p>
</div>
</main>
);
}
+175
View File
@@ -0,0 +1,175 @@
'use client';
import { useState, useEffect } from 'react';
import { Loader2, TrendingUp, Zap, Image as Img, RefreshCw } from 'lucide-react';
const PERIODS = [
{ v: 'today', label: 'Сегодня' },
{ v: 'week', label: '7 дней' },
{ v: 'month', label: '30 дней' },
{ v: 'alltime', label: 'Всё время' },
];
const PROVIDER_LABELS = {
'aiprimetech': 'aiprimetech.io',
'routerai': 'routerai.ru',
'nyxos': 'Nyxos Plus',
'aiguoguo': 'aiguoguo',
};
const TYPE_LABELS = {
'chat': '💬 Текст',
'image': '🖼 Изображение',
'image_via_responses': '🖼 Изображение (responses)',
'article': '📝 Статья',
'topic': '🔍 Топики',
};
function fmt(n) { return Number(n || 0).toFixed(2); }
function fmtInt(n) { return Number(n || 0).toLocaleString('ru-RU'); }
export default function SpendingPage() {
const [period, setPeriod] = useState('month');
const [data, setData] = useState(null);
const [byProvider, setByProvider] = useState(null);
const [loading, setLoading] = useState(true);
async function load(p) {
setLoading(true);
try {
const [r1, r2] = await Promise.all([
fetch(`/api/usage/summary?range=${p}&group_by=request_type`).then(r => r.json()),
fetch(`/api/usage/summary?range=${p}&group_by=provider`).then(r => r.json()),
]);
setData(r1);
setByProvider(r2);
} catch {}
setLoading(false);
}
useEffect(() => { load(period); }, [period]);
const totals = data?.totals || {};
// Расходы по ключевым провайдерам
const aiprimetech = byProvider?.breakdown?.find(b => b.key === 'aiprimetech');
const routerai = byProvider?.breakdown?.find(b => b.key === 'routerai');
const nyxos = byProvider?.breakdown?.find(b => b.key === 'nyxos' || b.key?.includes('nyxos'));
return (
<main className="max-w-5xl mx-auto p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-accent" /> Расходы на AI
</h1>
<p className="text-sm text-gray-500 mt-0.5">Только aiprimetech.io и routerai.ru</p>
</div>
<div className="flex gap-1">
{PERIODS.map(p => (
<button key={p.v} onClick={() => setPeriod(p.v)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${period === p.v ? 'bg-accent text-white' : 'btn-ghost'}`}>
{p.label}
</button>
))}
<button onClick={() => load(period)} className="btn-ghost p-1.5 ml-1">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{loading && <div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && data && (
<>
{/* Итого */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ label: 'Итого, ₽', value: `${fmt(totals.cost_rub)}`, accent: true },
{ label: 'Запросов', value: fmtInt(totals.calls) },
{ label: 'Токенов', value: fmtInt((totals.prompt_tokens||0) + (totals.completion_tokens||0)) },
{ label: 'Картинок', value: fmtInt(totals.image_count) },
].map(s => (
<div key={s.label} className={`card p-4 ${s.accent ? 'border-accent/40 bg-accent/5' : ''}`}>
<div className={`text-2xl font-bold ${s.accent ? 'text-accent' : ''}`}>{s.value}</div>
<div className="text-xs text-gray-500 mt-1">{s.label}</div>
</div>
))}
</div>
{/* По провайдерам */}
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">По провайдерам</h2>
<div className="grid sm:grid-cols-2 gap-3 mb-6">
{[
{ key: 'aiprimetech', label: 'aiprimetech.io', icon: '💬', desc: 'Текстовая генерация', data: aiprimetech },
{ key: 'routerai', label: 'routerai.ru', icon: '🖼', desc: 'Генерация изображений', data: routerai },
].map(p => {
const d = p.data;
return (
<div key={p.key} className="card p-5">
<div className="flex items-center gap-2 mb-3">
<span className="text-xl">{p.icon}</span>
<div>
<div className="font-semibold text-sm">{p.label}</div>
<div className="text-xs text-gray-500">{p.desc}</div>
</div>
<div className="ml-auto text-right">
<div className="text-lg font-bold text-accent"> {fmt(d?.cost_rub)}</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-400">
<div><div className="font-medium text-gray-200">{fmtInt(d?.calls)}</div>запросов</div>
<div><div className="font-medium text-gray-200">{d?.failed||0}</div>ошибок</div>
<div><div className="font-medium text-gray-200">{fmtInt(d?.image_count||((d?.prompt_tokens||0)+(d?.completion_tokens||0)))}</div>{p.key==='routerai'?'картинок':'токенов'}</div>
</div>
</div>
);
})}
</div>
{/* Разбивка по типу запроса */}
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">По типу операции</h2>
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-surface2 text-xs text-gray-400">
<tr>
<th className="px-4 py-2.5 text-left">Операция</th>
<th className="px-4 py-2.5 text-right">Запросов</th>
<th className="px-4 py-2.5 text-right">Ошибок</th>
<th className="px-4 py-2.5 text-right">Токены / Картинки</th>
<th className="px-4 py-2.5 text-right">Стоимость, </th>
</tr>
</thead>
<tbody>
{(data.breakdown || []).map((row, i) => (
<tr key={i} className="border-t border-border hover:bg-surface2/50">
<td className="px-4 py-2.5">{TYPE_LABELS[row.key] || row.key}</td>
<td className="px-4 py-2.5 text-right text-gray-300">{fmtInt(row.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400">{row.failed || 0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs">
{row.image_count > 0
? `${row.image_count} шт.`
: fmtInt((row.prompt_tokens||0)+(row.completion_tokens||0))}
</td>
<td className="px-4 py-2.5 text-right font-medium"> {fmt(row.cost_rub)}</td>
</tr>
))}
{(!data.breakdown?.length) && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-500 text-sm">Нет данных за период</td></tr>
)}
</tbody>
<tfoot className="bg-surface2 border-t border-border">
<tr>
<td className="px-4 py-2.5 font-semibold text-sm">Итого</td>
<td className="px-4 py-2.5 text-right font-semibold">{fmtInt(totals.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400 font-semibold">{totals.failed||0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs"></td>
<td className="px-4 py-2.5 text-right font-bold text-accent"> {fmt(totals.cost_rub)}</td>
</tr>
</tfoot>
</table>
</div>
</>
)}
</main>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from 'next/navigation';
import { requireUser } from '@/lib/session';
import Header from '@/components/Header';
import AdminPanel from '@/components/AdminPanel';
export const dynamic = 'force-dynamic';
export default async function SystemPage({ searchParams }) {
const user = await requireUser();
if (!user) redirect('/login');
if (!user.isAdmin) redirect('/');
return (
<>
<Header user={user} />
<AdminPanel initialSection={searchParams?.section || 'dashboard'} />
</>
);
}