feat: zeropost-tool — Next.js 16 кабинет

- Auth: iron-session, регистрация/логин по email+password
- Дашборд со списком каналов
- 3-шаговая анкета создания канала (база/стиль/примеры+табу)
- Страница канала с генератором постов через polling
- Тёмная тема, Tailwind 3.4, accent emerald
- Прокси-API к zeropost-engine с x-user-id
- Совместимость с Next 16 async cookies/params
This commit is contained in:
Alexey Pavlov
2026-05-31 08:38:10 +03:00
parent 8e979c3045
commit 5dd975a9cd
26 changed files with 3334 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { q } from '@/lib/db';
import { getSession } from '@/lib/session';
export async function POST(req) {
const { email, password, mode = 'login' } = await req.json();
if (!email || !password) {
return NextResponse.json({ error: 'email и password обязательны' }, { status: 400 });
}
if (mode === 'register') {
const exists = await q(`SELECT id FROM users WHERE email=$1`, [email]);
if (exists.rows.length) {
return NextResponse.json({ error: 'Пользователь уже существует' }, { status: 400 });
}
const hash = await bcrypt.hash(password, 10);
const { rows } = await q(
`INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name`,
[email, hash]
);
const user = rows[0];
const s = await getSession();
s.userId = user.id;
s.email = user.email;
await s.save();
return NextResponse.json({ ok: true, user });
}
// login
const { rows } = await q(`SELECT id,email,password,name FROM users WHERE email=$1`, [email]);
if (!rows.length) {
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
}
const user = rows[0];
const ok = await bcrypt.compare(password, user.password);
if (!ok) {
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
}
const s = await getSession();
s.userId = user.id;
s.email = user.email;
s.name = user.name;
await s.save();
return NextResponse.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } });
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
export async function POST() {
const s = await getSession();
s.destroy();
return NextResponse.json({ ok: true });
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(_, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id } = await params;
const channel = await engine.getChannel(user.id, id);
return NextResponse.json(channel);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id } = await params;
const body = await req.json();
const channel = await engine.updateChannel(user.id, id, body);
return NextResponse.json(channel);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function DELETE(_, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id } = await params;
await engine.deleteChannel(user.id, 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 { 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 channels = await engine.listChannels(user.id);
return NextResponse.json(channels);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
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 channel = await engine.createChannel(user.id, body);
return NextResponse.json(channel);
} 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(_, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id } = await params;
const job = await engine.getJob(user.id, id);
return NextResponse.json(job);
} 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 body = await req.json();
const job = await engine.generate(user.id, body);
return NextResponse.json(job);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { notFound, redirect } from 'next/navigation';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
import Header from '@/components/Header';
import ChannelView from '@/components/ChannelView';
export default async function ChannelPage({ params }) {
const user = await requireUser();
if (!user) redirect('/login');
const { id } = await params;
let channel;
try {
channel = await engine.getChannel(user.id, id);
} catch {
notFound();
}
if (!channel) notFound();
return (
<>
<Header user={user} />
<ChannelView channel={channel} />
</>
);
}
+344
View File
@@ -0,0 +1,344 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, X, Plus, Sparkles } from 'lucide-react';
const GOALS = [
{ v: 'educational', label: 'Обучение', desc: 'Объясняем, разбираем' },
{ v: 'news', label: 'Новости', desc: 'Что произошло' },
{ v: 'entertainment', label: 'Развлечение', desc: 'Лёгкий контент, мемы' },
{ v: 'expert', label: 'Экспертный', desc: 'Глубокий анализ, инсайты' },
{ v: 'sales', label: 'Продажи', desc: 'Подвести к покупке' },
];
const TONES = [
{ v: 'friendly', label: 'Дружелюбный' },
{ v: 'serious', label: 'Серьёзный' },
{ v: 'ironic', label: 'Ироничный' },
{ v: 'provocative', label: 'Провокационный' },
{ v: 'academic', label: 'Академичный' },
];
const LENGTHS = [
{ v: 'short', label: 'Короткий', desc: 'до 300 знаков' },
{ v: 'medium', label: 'Средний', desc: '300-800' },
{ v: 'long', label: 'Длинный', desc: '800-2000' },
];
const EMOJI = [
{ v: 'none', label: 'Без эмодзи' },
{ v: 'moderate', label: 'Умеренно' },
{ v: 'active', label: 'Активно' },
];
const HUMOR = [
{ v: 'none', label: 'Без юмора' },
{ v: 'dry', label: 'Сухой/ирония' },
{ v: 'moderate', label: 'Умеренный' },
{ v: 'playful', label: 'Игривый' },
];
export default function NewChannelPage() {
const router = useRouter();
const [step, setStep] = useState(1);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
// Шаг 1 — база
const [name, setName] = useState('');
const [niche, setNiche] = useState('');
const [audience, setAudience] = useState('');
const [goal, setGoal] = useState('educational');
const [language, setLanguage] = useState('ru');
// Шаг 2 — стиль
const [tone, setTone] = useState('friendly');
const [formality, setFormality] = useState('informal');
const [humor, setHumor] = useState('moderate');
const [postLength, setPostLength] = useState('medium');
const [emojiLevel, setEmojiLevel] = useState('moderate');
const [hashtagsMode, setHashtagsMode] = useState('end');
// Шаг 3 — примеры и табу
const [examplePosts, setExamplePosts] = useState(['']);
const [bannedWords, setBannedWords] = useState('');
const [bannedTopics, setBannedTopics] = useState('');
async function submit() {
if (!name) { setError('Название канала обязательно'); setStep(1); return; }
setBusy(true);
setError('');
const data = {
name, niche, audience, goal, language, region: 'ru',
style: {
tone, formality, humor,
post_length: postLength,
emoji_level: emojiLevel,
hashtags_mode: hashtagsMode,
example_posts: examplePosts.map(s => s.trim()).filter(Boolean),
banned_words: bannedWords.split(',').map(s => s.trim()).filter(Boolean),
banned_topics: bannedTopics.split(',').map(s => s.trim()).filter(Boolean),
},
};
const res = await fetch('/api/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const json = await res.json();
setBusy(false);
if (!res.ok) { setError(json.error || 'Ошибка'); return; }
router.push(`/channels/${json.id}`);
}
return (
<main className="max-w-3xl mx-auto p-4 sm:p-6">
<Link href="/" className="btn-ghost mb-4 -ml-2">
<ArrowLeft className="w-4 h-4" /> Назад
</Link>
<div className="flex items-center gap-2 mb-2">
<Sparkles className="w-5 h-5 text-accent" />
<h1 className="text-2xl font-bold">Новый канал</h1>
</div>
<p className="text-sm text-gray-500 mb-6">
Чем точнее опишешь канал, тем лучше ИИ будет писать в его стиле
</p>
{/* Stepper */}
<div className="flex items-center gap-2 mb-8">
{[1, 2, 3].map(s => (
<div key={s} className="flex-1">
<div className={`h-1 rounded-full transition-colors ${s <= step ? 'bg-accent' : 'bg-border'}`} />
<div className={`text-xs mt-1.5 ${s === step ? 'text-accent font-medium' : 'text-gray-500'}`}>
{s}. {s === 1 ? 'О канале' : s === 2 ? 'Стиль' : 'Примеры и табу'}
</div>
</div>
))}
</div>
{error && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 mb-4">
{error}
</div>
)}
{step === 1 && (
<div className="card p-6 space-y-5">
<div>
<label className="label">Название канала <span className="text-red-400">*</span></label>
<input className="input" value={name} onChange={e => setName(e.target.value)} placeholder="Например: ZeroPost AI" />
</div>
<div>
<label className="label">Ниша / тематика</label>
<textarea
className="input min-h-[80px]"
value={niche}
onChange={e => setNiche(e.target.value)}
placeholder="Не общая категория, а конкретно: «Практические ИИ-инструменты для маркетологов в СНГ»"
/>
<div className="hint">Чем уже ниша, тем сильнее работают посты</div>
</div>
<div>
<label className="label">Целевая аудитория</label>
<textarea
className="input min-h-[80px]"
value={audience}
onChange={e => setAudience(e.target.value)}
placeholder="Кто читает: возраст, профессия, уровень, что им важно"
/>
</div>
<div>
<label className="label">Цель канала</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>
))}
</div>
</div>
<div>
<label className="label">Язык постов</label>
<select className="input" value={language} onChange={e => setLanguage(e.target.value)}>
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</div>
<div className="flex justify-end pt-2">
<button onClick={() => setStep(2)} className="btn-primary">Дальше</button>
</div>
</div>
)}
{step === 2 && (
<div className="card p-6 space-y-5">
<div>
<label className="label">Тон</label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{TONES.map(t => (
<button
key={t.v}
type="button"
onClick={() => setTone(t.v)}
className={`px-3 py-2 rounded-lg border text-sm transition-colors ${
tone === t.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>
{t.label}
</button>
))}
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="label">Обращение к читателю</label>
<div className="grid grid-cols-2 gap-2">
{[{ v: 'informal', l: 'На «ты»' }, { v: 'formal', l: 'На «вы»' }].map(o => (
<button
key={o.v}
type="button"
onClick={() => setFormality(o.v)}
className={`px-3 py-2 rounded-lg border text-sm transition-colors ${
formality === o.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>{o.l}</button>
))}
</div>
</div>
<div>
<label className="label">Юмор</label>
<select className="input" value={humor} onChange={e => setHumor(e.target.value)}>
{HUMOR.map(o => <option key={o.v} value={o.v}>{o.label}</option>)}
</select>
</div>
</div>
<div>
<label className="label">Длина постов</label>
<div className="grid grid-cols-3 gap-2">
{LENGTHS.map(l => (
<button
key={l.v}
type="button"
onClick={() => setPostLength(l.v)}
className={`p-2.5 rounded-lg border text-left transition-colors ${
postLength === l.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>
<div className="text-sm font-medium">{l.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{l.desc}</div>
</button>
))}
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="label">Эмодзи</label>
<select className="input" value={emojiLevel} onChange={e => setEmojiLevel(e.target.value)}>
{EMOJI.map(o => <option key={o.v} value={o.v}>{o.label}</option>)}
</select>
</div>
<div>
<label className="label">Хэштеги</label>
<select className="input" value={hashtagsMode} onChange={e => setHashtagsMode(e.target.value)}>
<option value="none">Не использовать</option>
<option value="end">В конце поста</option>
<option value="inline">Внутри текста</option>
</select>
</div>
</div>
<div className="flex justify-between pt-2">
<button onClick={() => setStep(1)} className="btn-ghost">Назад</button>
<button onClick={() => setStep(3)} className="btn-primary">Дальше</button>
</div>
</div>
)}
{step === 3 && (
<div className="card p-6 space-y-5">
<div>
<label className="label">Примеры «эталонных» постов</label>
<div className="hint mb-3">
Это самое важное! Скопируй 2-3 поста, которые звучат «как надо» из своего канала или у конкурентов. ИИ скопирует их ритм и стиль.
</div>
{examplePosts.map((ex, i) => (
<div key={i} className="relative mb-3">
<textarea
className="input min-h-[120px] pr-10"
value={ex}
onChange={e => {
const arr = [...examplePosts];
arr[i] = e.target.value;
setExamplePosts(arr);
}}
placeholder={`Пример поста №${i + 1}`}
/>
{examplePosts.length > 1 && (
<button
type="button"
onClick={() => setExamplePosts(examplePosts.filter((_, j) => j !== i))}
className="absolute top-2 right-2 p-1 text-gray-500 hover:text-red-400"
>
<X className="w-4 h-4" />
</button>
)}
</div>
))}
{examplePosts.length < 5 && (
<button
type="button"
onClick={() => setExamplePosts([...examplePosts, ''])}
className="btn-ghost text-sm"
>
<Plus className="w-4 h-4" /> Добавить пример
</button>
)}
</div>
<div>
<label className="label">Стоп-слова</label>
<input
className="input"
value={bannedWords}
onChange={e => setBannedWords(e.target.value)}
placeholder="революционный, уникальный, в современном мире"
/>
<div className="hint">Через запятую. Эти слова ИИ не будет использовать</div>
</div>
<div>
<label className="label">Запрещённые темы</label>
<input
className="input"
value={bannedTopics}
onChange={e => setBannedTopics(e.target.value)}
placeholder="политика, крипта, реклама конкурентов"
/>
<div className="hint">Через запятую. ИИ не будет затрагивать эти темы</div>
</div>
<div className="flex justify-between pt-2">
<button onClick={() => setStep(2)} className="btn-ghost">Назад</button>
<button onClick={submit} disabled={busy} className="btn-primary">
{busy ? 'Создаём...' : 'Создать канал'}
</button>
</div>
</div>
)}
</main>
);
}
+20
View File
@@ -0,0 +1,20 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html { @apply bg-bg text-gray-100; }
body { @apply font-sans antialiased; }
::selection { @apply bg-accent/30; }
}
@layer components {
.btn { @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed; }
.btn-primary { @apply btn bg-accent text-black hover:bg-accent2; }
.btn-ghost { @apply btn text-gray-300 hover:bg-surface2 hover:text-white; }
.btn-danger { @apply btn bg-red-600/10 text-red-400 hover:bg-red-600/20; }
.card { @apply bg-surface border border-border rounded-xl; }
.input { @apply w-full bg-surface2 border border-border rounded-lg px-3 py-2 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-accent transition-colors; }
.label { @apply block text-sm font-medium text-gray-300 mb-1.5; }
.hint { @apply text-xs text-gray-500 mt-1; }
}
+42
View File
@@ -0,0 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply bg-bg text-gray-100;
}
body {
@apply font-sans antialiased;
}
::selection {
@apply bg-accent/30;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-accent text-black hover:bg-accent2;
}
.btn-ghost {
@apply btn text-gray-300 hover:bg-surface2 hover:text-white;
}
.btn-danger {
@apply btn bg-red-600/10 text-red-400 hover:bg-red-600/20;
}
.card {
@apply bg-surface border border-border rounded-xl;
}
.input {
@apply w-full bg-surface2 border border-border rounded-lg px-3 py-2 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-accent transition-colors;
}
.label {
@apply block text-sm font-medium text-gray-300 mb-1.5;
}
.hint {
@apply text-xs text-gray-500 mt-1;
}
}
+22
View File
@@ -0,0 +1,22 @@
import './globals.css';
export const metadata = {
title: 'ZeroPost — ИИ-генерация постов для Telegram',
description: 'Инструмент для ведения Telegram-каналов с помощью ИИ',
};
export default function RootLayout({ children }) {
return (
<html lang="ru">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>{children}</body>
</html>
);
}
+94
View File
@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Sparkles } from 'lucide-react';
export default function LoginPage() {
const router = useRouter();
const [mode, setMode] = useState('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function submit(e) {
e.preventDefault();
setBusy(true);
setError('');
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, mode }),
});
const data = await res.json();
setBusy(false);
if (!res.ok) {
setError(data.error || 'Ошибка');
return;
}
router.push('/');
}
return (
<main className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="flex items-center gap-2 mb-8 justify-center">
<Sparkles className="w-7 h-7 text-accent" />
<span className="text-2xl font-bold">ZeroPost</span>
</div>
<div className="card p-8">
<h1 className="text-xl font-semibold mb-1">
{mode === 'login' ? 'Вход' : 'Регистрация'}
</h1>
<p className="text-sm text-gray-500 mb-6">
ИИ-ассистент для Telegram-каналов
</p>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="label">Email</label>
<input
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="input"
placeholder="you@example.com"
/>
</div>
<div>
<label className="label">Пароль</label>
<input
type="password"
required
minLength={6}
value={password}
onChange={e => setPassword(e.target.value)}
className="input"
placeholder="••••••••"
/>
</div>
{error && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{error}
</div>
)}
<button disabled={busy} className="btn-primary w-full">
{busy ? 'Подождите...' : (mode === 'login' ? 'Войти' : 'Зарегистрироваться')}
</button>
</form>
<button
onClick={() => { setMode(mode === 'login' ? 'register' : 'login'); setError(''); }}
className="w-full text-center text-sm text-gray-500 hover:text-gray-300 mt-6"
>
{mode === 'login' ? 'Нет аккаунта? Зарегистрироваться' : 'Уже есть аккаунт? Войти'}
</button>
</div>
</div>
</main>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
import Header from '@/components/Header';
import { Plus, MessageSquare, Users, Target } from 'lucide-react';
const GOAL_LABELS = {
educational: 'Обучение',
news: 'Новости',
entertainment: 'Развлечение',
expert: 'Экспертный',
sales: 'Продажи',
};
export default async function HomePage() {
const user = await requireUser();
if (!user) redirect('/login');
let channels = [];
try {
channels = await engine.listChannels(user.id);
} catch (err) {
console.error('listChannels failed:', err.message);
}
return (
<>
<Header user={user} />
<main className="max-w-6xl 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>
<Link href="/channels/new" className="btn-primary">
<Plus className="w-4 h-4" />
Добавить канал
</Link>
</div>
{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>
<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">
{channels.map(ch => (
<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}
</span>
</div>
{ch.niche && (
<p className="text-xs text-gray-500 line-clamp-2 mb-3">
{ch.niche}
</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-500">
{ch.audience && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
Есть ЦА
</span>
)}
{ch.style?.example_posts?.length > 0 && (
<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 ? '' : 'а'}
</span>
)}
</div>
</Link>
))}
</div>
)}
</main>
</>
);
}