forked from admin/zeropost-tool
feat: P6 Inbox UI — InboxTab + API routes
ChannelView: вкладка 'Inbox' рядом с 'Аналитика' InboxTab.js: - Webhook setup кнопка (если не настроен) - Табы: Новые / Все / Отвечено / Игнорировано - Карточки сообщений с иконкой типа (question/praise/complaint/spam) - AI-предложенный ответ с кнопкой 'Использовать →' - Форма ответа прямо в карточке - Кнопки: Ответить / Игнорировать / Спам API routes: /api/inbox/[channelId], /reply, /status, /setup-webhook
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
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 || '';
|
||||||
|
|
||||||
|
async function engineReq(path, opts = {}) {
|
||||||
|
const { method = 'GET', body, userId } = opts;
|
||||||
|
const headers = { 'x-internal-secret': ENGINE_SECRET };
|
||||||
|
if (userId) headers['x-user-id'] = String(userId);
|
||||||
|
if (body) headers['Content-Type'] = 'application/json';
|
||||||
|
const res = await fetch(`${ENGINE_URL}${path}`, {
|
||||||
|
method, headers, body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 data = await engineReq(
|
||||||
|
`/api/inbox/${params.channelId}?${searchParams.toString()}`,
|
||||||
|
{ userId: user.id }
|
||||||
|
);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 || '';
|
||||||
|
|
||||||
|
async function enginePost(path, userId, body) {
|
||||||
|
const res = await fetch(`${ENGINE_URL}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-internal-secret': ENGINE_SECRET,
|
||||||
|
'x-user-id': String(userId),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 data = await enginePost(`/api/inbox/${params.id}/reply`, user.id, body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import ChannelAnalytics from './ChannelAnalytics';
|
|||||||
import FromUrlModal from './FromUrlModal';
|
import FromUrlModal from './FromUrlModal';
|
||||||
import PollModal from './PollModal';
|
import PollModal from './PollModal';
|
||||||
import HashtagSuggest from './HashtagSuggest';
|
import HashtagSuggest from './HashtagSuggest';
|
||||||
|
import InboxTab from './InboxTab';
|
||||||
|
|
||||||
const GOAL_LABELS = {
|
const GOAL_LABELS = {
|
||||||
educational: 'Обучение', news: 'Новости',
|
educational: 'Обучение', news: 'Новости',
|
||||||
@@ -356,7 +357,7 @@ export default function ChannelView({ channel }) {
|
|||||||
|
|
||||||
{/* Вкладки */}
|
{/* Вкладки */}
|
||||||
<div className="flex items-center gap-0.5 rounded-lg p-0.5 bg-surface2 border border-border self-start mb-2">
|
<div className="flex items-center gap-0.5 rounded-lg p-0.5 bg-surface2 border border-border self-start mb-2">
|
||||||
{[['generate','Создать пост'],['analytics','Аналитика']].map(([id,label]) => (
|
{[['generate','Создать пост'],['analytics','Аналитика'],['inbox','Inbox']].map(([id,label]) => (
|
||||||
<button key={id} onClick={() => setActiveTab(id)}
|
<button key={id} onClick={() => setActiveTab(id)}
|
||||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||||
${activeTab===id ? 'bg-surface text-text shadow-sm' : 'text-text-soft hover:text-text'}`}>
|
${activeTab===id ? 'bg-surface text-text shadow-sm' : 'text-text-soft hover:text-text'}`}>
|
||||||
@@ -369,6 +370,10 @@ export default function ChannelView({ channel }) {
|
|||||||
<ChannelAnalytics channelId={channel.id} channelName={channel.tg_username} />
|
<ChannelAnalytics channelId={channel.id} channelName={channel.tg_username} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'inbox' && (
|
||||||
|
<InboxTab channel={channel} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'generate' && <>
|
{activeTab === 'generate' && <>
|
||||||
{/* Generator */}
|
{/* Generator */}
|
||||||
<div className="card p-5 mb-6">
|
<div className="card p-5 mb-6">
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { MessageCircle, RefreshCw, Send, X, Ban, CheckCheck, Loader2, Bell, BellOff } from 'lucide-react';
|
||||||
|
|
||||||
|
const STATUS_TABS = [
|
||||||
|
{ v: 'new', label: 'Новые', color: 'text-accent' },
|
||||||
|
{ v: 'all', label: 'Все', color: 'text-gray-400' },
|
||||||
|
{ v: 'replied', label: 'Отвечено', color: 'text-green-400' },
|
||||||
|
{ v: 'ignored', label: 'Игнорировано', color: 'text-gray-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_ICONS = {
|
||||||
|
question: '❓',
|
||||||
|
praise: '👍',
|
||||||
|
complaint: '😤',
|
||||||
|
spam: '🚫',
|
||||||
|
other: '💬',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
question: 'border-blue-500/30 bg-blue-500/5',
|
||||||
|
praise: 'border-green-500/30 bg-green-500/5',
|
||||||
|
complaint: 'border-red-500/30 bg-red-500/5',
|
||||||
|
spam: 'border-gray-600 bg-gray-800/50 opacity-60',
|
||||||
|
other: 'border-border',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtDate(s) {
|
||||||
|
const d = new Date(s);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - d;
|
||||||
|
if (diff < 60000) return 'только что';
|
||||||
|
if (diff < 3600000) return Math.floor(diff/60000) + ' мин назад';
|
||||||
|
if (diff < 86400000) return Math.floor(diff/3600000) + 'ч назад';
|
||||||
|
return d.toLocaleDateString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InboxTab({ channel }) {
|
||||||
|
const [tab, setTab] = useState('new');
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [replyId, setReplyId] = useState(null);
|
||||||
|
const [replyText,setReplyText]= useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [webhookOk,setWebhookOk]= useState(channel.tg_webhook_enabled);
|
||||||
|
const [setupping,setSetuppping]= useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async (t = tab) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/inbox/${channel.id}?status=${t}&limit=40`).then(r => r.json());
|
||||||
|
setMessages(res.messages || []);
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
} catch {}
|
||||||
|
setLoading(false);
|
||||||
|
}, [channel.id, tab]);
|
||||||
|
|
||||||
|
useEffect(() => { load(tab); }, [tab]);
|
||||||
|
|
||||||
|
async function setupWebhook() {
|
||||||
|
setSetuppping(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/inbox/${channel.id}/setup-webhook`, { method: 'POST' }).then(r => r.json());
|
||||||
|
if (res.ok) { setWebhookOk(true); load(tab); }
|
||||||
|
else alert(res.error || 'Ошибка');
|
||||||
|
} catch { alert('Ошибка соединения'); }
|
||||||
|
setSetuppping(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply(msg) {
|
||||||
|
if (!replyText.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/inbox/${msg.id}/reply`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: replyText }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
if (res.ok) {
|
||||||
|
setReplyId(null);
|
||||||
|
setReplyText('');
|
||||||
|
load(tab);
|
||||||
|
} else alert(res.error || 'Ошибка');
|
||||||
|
} catch { alert('Ошибка'); }
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStatus(msgId, status) {
|
||||||
|
await fetch(`/api/inbox/${msgId}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
load(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCount = messages.filter(m => m.status === 'new').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Webhook setup */}
|
||||||
|
{!webhookOk && (
|
||||||
|
<div className="card p-4 border-yellow-500/30 bg-yellow-500/5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm flex items-center gap-2">
|
||||||
|
<BellOff className="w-4 h-4 text-yellow-400" />
|
||||||
|
Webhook не настроен
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
Нужно подключить webhook чтобы получать комментарии
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={setupWebhook} disabled={setupping} className="btn-primary text-sm px-3 py-1.5">
|
||||||
|
{setupping ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Bell className="w-3.5 h-3.5 mr-1" />Подключить</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{webhookOk && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-green-400">
|
||||||
|
<Bell className="w-3 h-3" /> Webhook активен — получаем комментарии
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{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}
|
||||||
|
{t.v === 'new' && newCount > 0 && (
|
||||||
|
<span className="ml-1.5 bg-accent text-white text-xs rounded-full px-1.5 py-0.5">{newCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => load(tab)} className="btn-ghost p-2">
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
|
||||||
|
|
||||||
|
{!loading && messages.length === 0 && (
|
||||||
|
<div className="py-12 text-center text-gray-500">
|
||||||
|
<MessageCircle className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<div className="text-sm">{tab === 'new' ? 'Новых комментариев нет' : 'Нет сообщений'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && messages.map(msg => (
|
||||||
|
<div key={msg.id} className={`card p-4 border ${TYPE_COLORS[msg.ai_type] || TYPE_COLORS.other}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base">{TYPE_ICONS[msg.ai_type] || '💬'}</span>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{msg.from_name || msg.from_username || 'Аноним'}
|
||||||
|
</span>
|
||||||
|
{msg.from_username && (
|
||||||
|
<span className="text-xs text-gray-500 ml-1">@{msg.from_username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500 shrink-0">
|
||||||
|
{msg.status === 'replied' && <CheckCheck className="w-3.5 h-3.5 text-green-400" />}
|
||||||
|
{fmtDate(msg.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<p className="text-sm leading-relaxed mb-3 text-gray-200">{msg.text}</p>
|
||||||
|
|
||||||
|
{/* AI Reply Suggestion */}
|
||||||
|
{msg.ai_reply && msg.status === 'new' && (
|
||||||
|
<div className="mb-3 p-3 rounded-lg bg-surface2 border border-accent/20">
|
||||||
|
<div className="text-xs text-accent mb-1.5 font-medium">✨ Предложенный ответ AI</div>
|
||||||
|
<p className="text-sm text-gray-300">{msg.ai_reply}</p>
|
||||||
|
<button
|
||||||
|
className="mt-2 text-xs text-accent hover:underline"
|
||||||
|
onClick={() => { setReplyId(msg.id); setReplyText(msg.ai_reply); }}
|
||||||
|
>
|
||||||
|
Использовать →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reply form */}
|
||||||
|
{replyId === msg.id ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={replyText}
|
||||||
|
onChange={e => setReplyText(e.target.value)}
|
||||||
|
className="input w-full text-sm resize-none"
|
||||||
|
placeholder="Текст ответа..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => sendReply(msg)} disabled={sending || !replyText.trim()}
|
||||||
|
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1.5">
|
||||||
|
{sending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setReplyId(null); setReplyText(''); }}
|
||||||
|
className="btn-ghost text-sm px-3 py-1.5">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : msg.status === 'new' ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => { setReplyId(msg.id); setReplyText(''); }}
|
||||||
|
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1">
|
||||||
|
<Send className="w-3 h-3" /> Ответить
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setStatus(msg.id, 'ignored')}
|
||||||
|
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1 text-gray-500">
|
||||||
|
<X className="w-3 h-3" /> Игнорировать
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setStatus(msg.id, 'spam')}
|
||||||
|
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1 text-red-500">
|
||||||
|
<Ban className="w-3 h-3" /> Спам
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{msg.status === 'replied' && `✓ Отвечено: ${msg.replied_text?.slice(0, 80)}...`}
|
||||||
|
{msg.status === 'ignored' && '— Проигнорировано'}
|
||||||
|
{msg.status === 'spam' && '🚫 Помечено как спам'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{total > messages.length && (
|
||||||
|
<p className="text-xs text-center text-gray-500">Показано {messages.length} из {total}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user