Files
2026-06-15 23:25:48 +03:00

400 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 [goals, setGoals] = useState(['educational']); // multi-select, отправляем как CSV
const [customGoal, setCustomGoal] = useState(''); // поле для своей цели
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: goals.join(','), 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) {
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}`);
}
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="Например: PostCast 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">Цель канала <span className="text-gray-500 font-normal">(можно несколько)</span></label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{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>
<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>
);
}