writeText 대신 clipboard.write로 text/html MIME 타입 복사하여 네이버 블로그 에디터에 붙여넣기 시 서식이 유지되도록 개선. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
697 lines
27 KiB
JavaScript
697 lines
27 KiB
JavaScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import {
|
|
getBlogMarketingStatus,
|
|
startResearch,
|
|
getResearchHistory,
|
|
getResearchDetail,
|
|
deleteResearch,
|
|
getBlogMarketingTask,
|
|
startGenerate,
|
|
startReview,
|
|
startRegenerate,
|
|
startMarket,
|
|
getBlogMarketingPosts,
|
|
getBlogMarketingPost,
|
|
deleteBlogMarketingPost,
|
|
publishBlogMarketingPost,
|
|
getBlogMarketingDashboard,
|
|
getBlogMarketingCommissions,
|
|
addBlogMarketingCommission,
|
|
deleteBlogMarketingCommission,
|
|
getBrandLinks,
|
|
createBrandLink,
|
|
deleteBrandLink,
|
|
} from '../../api';
|
|
import './BlogMarketing.css';
|
|
|
|
/* ────────────────────── 유틸 ────────────────────── */
|
|
function fmtDate(iso) {
|
|
if (!iso) return '';
|
|
return new Date(iso).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
function fmtMoney(n) {
|
|
if (n == null) return '-';
|
|
return n.toLocaleString('ko-KR') + '원';
|
|
}
|
|
function copyHtmlToClipboard(html) {
|
|
const blob = new Blob([html], { type: 'text/html' });
|
|
const plainBlob = new Blob([html.replace(/<[^>]*>/g, '')], { type: 'text/plain' });
|
|
navigator.clipboard.write([
|
|
new ClipboardItem({ 'text/html': blob, 'text/plain': plainBlob }),
|
|
]).then(() => alert('본문이 클립보드에 복사되었습니다! (서식 포함)'));
|
|
}
|
|
|
|
function scoreColor(v, max = 100) {
|
|
const r = v / max;
|
|
if (r >= 0.6) return 'bm-score__value--high';
|
|
if (r >= 0.3) return 'bm-score__value--mid';
|
|
return 'bm-score__value--low';
|
|
}
|
|
|
|
/* ────────────────────── 폴링 훅 ────────────────────── */
|
|
function usePollTask(onDone) {
|
|
const [taskId, setTaskId] = useState(null);
|
|
const [task, setTask] = useState(null);
|
|
const timer = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!taskId) return;
|
|
let cancelled = false;
|
|
const poll = async () => {
|
|
try {
|
|
const t = await getBlogMarketingTask(taskId);
|
|
if (cancelled) return;
|
|
setTask(t);
|
|
if (t.status === 'succeeded' || t.status === 'failed') {
|
|
setTaskId(null);
|
|
onDone?.(t);
|
|
} else {
|
|
timer.current = setTimeout(poll, 1500);
|
|
}
|
|
} catch {
|
|
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
|
}
|
|
};
|
|
poll();
|
|
return () => { cancelled = true; clearTimeout(timer.current); };
|
|
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return { taskId, task, start: setTaskId, clear: () => { setTaskId(null); setTask(null); } };
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════════════ */
|
|
export default function BlogMarketing() {
|
|
const [tab, setTab] = useState('dashboard');
|
|
const [status, setStatus] = useState(null);
|
|
|
|
useEffect(() => {
|
|
getBlogMarketingStatus().then(setStatus).catch(() => {});
|
|
}, []);
|
|
|
|
const tabs = [
|
|
{ id: 'dashboard', label: 'Dashboard' },
|
|
{ id: 'research', label: 'Research' },
|
|
{ id: 'write', label: 'Write' },
|
|
{ id: 'posts', label: 'Posts' },
|
|
];
|
|
|
|
return (
|
|
<div className="bm">
|
|
<header className="bm-header">
|
|
<h1>Blog Lab</h1>
|
|
{status && (
|
|
<div className="bm-status">
|
|
<span className={`bm-badge ${status.naver_api ? '' : 'bm-badge--off'}`}>
|
|
Naver {status.naver_api ? 'ON' : 'OFF'}
|
|
</span>
|
|
<span className={`bm-badge ${status.claude_api ? '' : 'bm-badge--off'}`}>
|
|
Claude {status.claude_api ? 'ON' : 'OFF'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
<nav className="bm-tabs">
|
|
{tabs.map(t => (
|
|
<button
|
|
key={t.id}
|
|
className={`bm-tab ${tab === t.id ? 'bm-tab--active' : ''}`}
|
|
onClick={() => setTab(t.id)}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{tab === 'dashboard' && <DashboardTab />}
|
|
{tab === 'research' && <ResearchTab onGenerate={(id) => { setTab('write'); }} />}
|
|
{tab === 'write' && <WriteTab />}
|
|
{tab === 'posts' && <PostsTab />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ══════════════════════ Dashboard 탭 ═════════════════════════════════════ */
|
|
function DashboardTab() {
|
|
const [data, setData] = useState(null);
|
|
|
|
useEffect(() => {
|
|
getBlogMarketingDashboard().then(setData).catch(() => {});
|
|
}, []);
|
|
|
|
if (!data) return <div className="bm-empty">로딩 중...</div>;
|
|
|
|
return (
|
|
<div>
|
|
<div className="bm-dash-cards">
|
|
<DashCard label="총 포스트" value={data.total_posts} />
|
|
<DashCard label="발행 완료" value={data.published_posts} />
|
|
<DashCard label="총 클릭" value={data.total_clicks.toLocaleString()} />
|
|
<DashCard label="총 수익" value={fmtMoney(data.total_revenue)} green />
|
|
</div>
|
|
|
|
{data.top_posts?.length > 0 && (
|
|
<div className="bm-dash-section">
|
|
<h3>Top 5 포스트 (수익 기준)</h3>
|
|
<div className="bm-top-posts">
|
|
{data.top_posts.map(p => (
|
|
<div key={p.id} className="bm-top-post">
|
|
<span className="bm-top-post__title">{p.title || '(제목 없음)'}</span>
|
|
<span className="bm-top-post__rev">{fmtMoney(p.total_revenue)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{data.monthly?.length > 0 && (
|
|
<div className="bm-dash-section">
|
|
<h3>월별 수익</h3>
|
|
<div className="bm-top-posts">
|
|
{data.monthly.map(m => (
|
|
<div key={m.month} className="bm-top-post">
|
|
<span className="bm-top-post__title">{m.month}</span>
|
|
<span style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginRight: 12 }}>
|
|
클릭 {m.clicks} / 구매 {m.purchases}
|
|
</span>
|
|
<span className="bm-top-post__rev">{fmtMoney(m.revenue)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DashCard({ label, value, green }) {
|
|
return (
|
|
<div className="bm-dash-card">
|
|
<div className="bm-dash-card__label">{label}</div>
|
|
<div className={`bm-dash-card__value ${green ? 'bm-dash-card__value--green' : ''}`}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ══════════════════════ Research 탭 ══════════════════════════════════════ */
|
|
function ResearchTab() {
|
|
const [keyword, setKeyword] = useState('');
|
|
const [analyses, setAnalyses] = useState([]);
|
|
const [expanded, setExpanded] = useState(null);
|
|
|
|
const loadHistory = useCallback(() => {
|
|
getResearchHistory(30).then(r => setAnalyses(r.analyses || [])).catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => { loadHistory(); }, [loadHistory]);
|
|
|
|
const poll = usePollTask((t) => {
|
|
if (t.status === 'succeeded') loadHistory();
|
|
});
|
|
|
|
const handleSearch = async () => {
|
|
if (!keyword.trim() || poll.taskId) return;
|
|
try {
|
|
const { task_id } = await startResearch(keyword.trim());
|
|
poll.start(task_id);
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id) => {
|
|
if (!confirm('이 분석을 삭제할까요?')) return;
|
|
await deleteResearch(id);
|
|
setAnalyses(prev => prev.filter(a => a.id !== id));
|
|
};
|
|
|
|
const handleGenerate = async (analysisId) => {
|
|
try {
|
|
const { task_id } = await startGenerate(analysisId);
|
|
alert(`글 생성 시작! (task: ${task_id.slice(0, 8)})\nWrite 탭에서 확인하세요.`);
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="bm-research-form">
|
|
<input
|
|
className="bm-research-input"
|
|
placeholder="분석할 키워드를 입력하세요 (예: 무선 이어폰 추천)"
|
|
value={keyword}
|
|
onChange={e => setKeyword(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
|
disabled={!!poll.taskId}
|
|
/>
|
|
<button className="bm-btn bm-btn--primary" onClick={handleSearch} disabled={!!poll.taskId}>
|
|
{poll.taskId ? <><span className="bm-spinner" /> 분석 중...</> : '분석'}
|
|
</button>
|
|
</div>
|
|
|
|
{poll.task && poll.task.status !== 'succeeded' && poll.task.status !== 'failed' && (
|
|
<div className="bm-progress">
|
|
<div className="bm-progress__bar">
|
|
<div className="bm-progress__fill" style={{ width: `${poll.task.progress || 0}%` }} />
|
|
</div>
|
|
<div className="bm-progress__text">{poll.task.message || '처리 중...'}</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bm-analyses">
|
|
{analyses.length === 0 && !poll.taskId && (
|
|
<div className="bm-empty">아직 분석 결과가 없습니다. 키워드를 입력해 첫 분석을 시작하세요!</div>
|
|
)}
|
|
{analyses.map(a => (
|
|
<div key={a.id} className="bm-analysis-card">
|
|
<div className="bm-analysis-card__header">
|
|
<span className="bm-analysis-card__keyword">{a.keyword}</span>
|
|
<span className="bm-analysis-card__date">{fmtDate(a.created_at)}</span>
|
|
</div>
|
|
<div className="bm-analysis-card__scores">
|
|
<div className="bm-score">
|
|
<span className="bm-score__label">경쟁도</span>
|
|
<span className={`bm-score__value ${scoreColor(a.competition)}`}>{a.competition}</span>
|
|
</div>
|
|
<div className="bm-score">
|
|
<span className="bm-score__label">기회</span>
|
|
<span className={`bm-score__value ${scoreColor(a.opportunity)}`}>{a.opportunity}</span>
|
|
</div>
|
|
<div className="bm-score">
|
|
<span className="bm-score__label">블로그</span>
|
|
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
|
{(a.blog_total || 0).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="bm-score">
|
|
<span className="bm-score__label">쇼핑</span>
|
|
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
|
{(a.shop_total || 0).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
{a.avg_price != null && (
|
|
<div className="bm-score">
|
|
<span className="bm-score__label">평균가</span>
|
|
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
|
{fmtMoney(a.avg_price)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{expanded === a.id && a.top_products?.length > 0 && (
|
|
<div className="bm-analysis-card__summary">
|
|
<strong>상위 상품:</strong>
|
|
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
|
{a.top_products.map((p, i) => (
|
|
<li key={i}>{p.title} — {fmtMoney(p.lprice)} ({p.mallName})</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bm-analysis-card__actions">
|
|
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => handleGenerate(a.id)}>
|
|
글 생성
|
|
</button>
|
|
<button
|
|
className="bm-btn bm-btn--secondary bm-btn--sm"
|
|
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
|
|
>
|
|
{expanded === a.id ? '접기' : '상세'}
|
|
</button>
|
|
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(a.id)}>
|
|
삭제
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ══════════════════════ Write 탭 ═════════════════════════════════════════ */
|
|
function WriteTab() {
|
|
const [posts, setPosts] = useState([]);
|
|
const [selected, setSelected] = useState(null);
|
|
const [post, setPost] = useState(null);
|
|
|
|
// 브랜드 링크 상태
|
|
const [links, setLinks] = useState([]);
|
|
const [showLinkForm, setShowLinkForm] = useState(false);
|
|
const [linkForm, setLinkForm] = useState({ url: '', product_name: '', description: '', placement_hint: '' });
|
|
|
|
const loadPosts = useCallback(() => {
|
|
Promise.all([
|
|
getBlogMarketingPosts('draft', 20),
|
|
getBlogMarketingPosts('marketed', 20),
|
|
]).then(([draftRes, marketedRes]) => {
|
|
const all = [...(draftRes.posts || []), ...(marketedRes.posts || [])];
|
|
all.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
setPosts(all);
|
|
if (all.length > 0 && !selected) setSelected(all[0].id);
|
|
}).catch(() => {});
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => { loadPosts(); }, [loadPosts]);
|
|
|
|
useEffect(() => {
|
|
if (!selected) { setPost(null); setLinks([]); return; }
|
|
getBlogMarketingPost(selected).then(setPost).catch(() => {});
|
|
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => setLinks([]));
|
|
}, [selected]);
|
|
|
|
const reviewPoll = usePollTask((t) => {
|
|
if (t.status === 'succeeded' && t.result_id) {
|
|
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
|
}
|
|
});
|
|
|
|
const regenPoll = usePollTask((t) => {
|
|
if (t.status === 'succeeded' && t.result_id) {
|
|
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
|
}
|
|
});
|
|
|
|
const marketPoll = usePollTask((t) => {
|
|
if (t.status === 'succeeded' && t.result_id) {
|
|
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
|
loadPosts();
|
|
}
|
|
});
|
|
|
|
const handleReview = async () => {
|
|
if (!post) return;
|
|
try {
|
|
const { task_id } = await startReview(post.id);
|
|
reviewPoll.start(task_id);
|
|
} catch (e) { alert(e.message); }
|
|
};
|
|
|
|
const handleRegenerate = async () => {
|
|
if (!post) return;
|
|
try {
|
|
const { task_id } = await startRegenerate(post.id);
|
|
regenPoll.start(task_id);
|
|
} catch (e) { alert(e.message); }
|
|
};
|
|
|
|
const handleMarket = async () => {
|
|
if (!post) return;
|
|
if (links.length === 0) {
|
|
alert('마케터 실행 전 브랜드커넥트 링크를 먼저 추가하세요.');
|
|
return;
|
|
}
|
|
try {
|
|
const { task_id } = await startMarket(post.id);
|
|
marketPoll.start(task_id);
|
|
} catch (e) { alert(e.message); }
|
|
};
|
|
|
|
const handleCopy = () => {
|
|
if (!post) return;
|
|
copyHtmlToClipboard(post.body);
|
|
};
|
|
|
|
const handleAddLink = async () => {
|
|
if (!linkForm.url.trim() || !linkForm.product_name.trim()) {
|
|
alert('URL과 상품명은 필수입니다.');
|
|
return;
|
|
}
|
|
try {
|
|
await createBrandLink({ ...linkForm, post_id: selected });
|
|
setLinkForm({ url: '', product_name: '', description: '', placement_hint: '' });
|
|
setShowLinkForm(false);
|
|
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => {});
|
|
} catch (e) { alert(e.message); }
|
|
};
|
|
|
|
const handleDeleteLink = async (linkId) => {
|
|
if (!confirm('이 링크를 삭제할까요?')) return;
|
|
await deleteBrandLink(linkId);
|
|
setLinks(prev => prev.filter(l => l.id !== linkId));
|
|
};
|
|
|
|
const activePoll = reviewPoll.task || regenPoll.task || marketPoll.task;
|
|
const isProcessing = activePoll && activePoll.status !== 'succeeded' && activePoll.status !== 'failed';
|
|
|
|
if (posts.length === 0 && !post) {
|
|
return (
|
|
<div className="bm-write-empty">
|
|
<div style={{ fontSize: '2rem', marginBottom: 8 }}>✍</div>
|
|
<p>아직 작성 중인 글이 없습니다.<br />Research 탭에서 키워드를 분석하고 글 생성을 시작하세요.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{posts.length > 1 && (
|
|
<div style={{ display: 'flex', gap: 6, marginBottom: 16, flexWrap: 'wrap' }}>
|
|
{posts.map(p => (
|
|
<button
|
|
key={p.id}
|
|
className={`bm-filter-btn ${selected === p.id ? 'bm-filter-btn--active' : ''}`}
|
|
onClick={() => setSelected(p.id)}
|
|
>
|
|
{p.title?.slice(0, 20) || `${p.status === 'marketed' ? 'Marketed' : 'Draft'} #${p.id}`}
|
|
{p.status === 'marketed' && <span style={{ marginLeft: 4, fontSize: '0.7rem', color: '#f59e0b' }}>[M]</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{isProcessing && activePoll && (
|
|
<div className="bm-progress">
|
|
<div className="bm-progress__bar">
|
|
<div className="bm-progress__fill" style={{ width: `${activePoll.progress || 0}%` }} />
|
|
</div>
|
|
<div className="bm-progress__text">{activePoll.message || '처리 중...'}</div>
|
|
</div>
|
|
)}
|
|
|
|
{post && (
|
|
<>
|
|
{/* 브랜드커넥트 링크 섹션 */}
|
|
<div className="bm-links-section" style={{ marginBottom: 16, padding: 12, background: 'rgba(255,255,255,0.04)', borderRadius: 8 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
<h4 style={{ margin: 0, fontSize: '0.9rem' }}>브랜드커넥트 링크 ({links.length})</h4>
|
|
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => setShowLinkForm(!showLinkForm)}>
|
|
{showLinkForm ? '취소' : '+ 링크 추가'}
|
|
</button>
|
|
</div>
|
|
|
|
{showLinkForm && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12, padding: 12, background: 'rgba(0,0,0,0.2)', borderRadius: 6 }}>
|
|
<input
|
|
className="bm-research-input"
|
|
placeholder="제휴 링크 URL (필수)"
|
|
value={linkForm.url}
|
|
onChange={e => setLinkForm(p => ({ ...p, url: e.target.value }))}
|
|
style={{ fontSize: '0.85rem' }}
|
|
/>
|
|
<input
|
|
className="bm-research-input"
|
|
placeholder="상품명 (필수)"
|
|
value={linkForm.product_name}
|
|
onChange={e => setLinkForm(p => ({ ...p, product_name: e.target.value }))}
|
|
style={{ fontSize: '0.85rem' }}
|
|
/>
|
|
<input
|
|
className="bm-research-input"
|
|
placeholder="상품 설명 (선택)"
|
|
value={linkForm.description}
|
|
onChange={e => setLinkForm(p => ({ ...p, description: e.target.value }))}
|
|
style={{ fontSize: '0.85rem' }}
|
|
/>
|
|
<input
|
|
className="bm-research-input"
|
|
placeholder="배치 힌트 (선택, 예: 본문 중간 자연스럽게)"
|
|
value={linkForm.placement_hint}
|
|
onChange={e => setLinkForm(p => ({ ...p, placement_hint: e.target.value }))}
|
|
style={{ fontSize: '0.85rem' }}
|
|
/>
|
|
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={handleAddLink}>등록</button>
|
|
</div>
|
|
)}
|
|
|
|
{links.length > 0 && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{links.map(l => (
|
|
<div key={l.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 8px', background: 'rgba(255,255,255,0.03)', borderRadius: 4, fontSize: '0.8rem' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<strong>{l.product_name}</strong>
|
|
{l.description && <span style={{ marginLeft: 8, color: 'rgba(255,255,255,.4)' }}>{l.description}</span>}
|
|
</div>
|
|
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDeleteLink(l.id)} style={{ fontSize: '0.7rem', padding: '2px 6px' }}>삭제</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bm-preview">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div className="bm-preview__title">{post.title || '(제목 없음)'}</div>
|
|
<span className={`bm-post-card__status bm-post-card__status--${post.status}`} style={{ fontSize: '0.75rem' }}>
|
|
{post.status}
|
|
</span>
|
|
</div>
|
|
<div className="bm-preview__body" dangerouslySetInnerHTML={{ __html: post.body }} />
|
|
{post.tags?.length > 0 && (
|
|
<div className="bm-preview__tags">
|
|
{post.tags.map((t, i) => <span key={i} className="bm-tag">#{t}</span>)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{post.review_detail && post.review_score != null && (
|
|
<div className="bm-review-box">
|
|
<h4>품질 리뷰 결과</h4>
|
|
<div className="bm-review-scores">
|
|
{Object.entries(post.review_detail.scores || {}).map(([k, v]) => (
|
|
<div key={k} className="bm-review-score">
|
|
<span className="bm-review-score__label">{k}</span>
|
|
<span className={`bm-review-score__val ${scoreColor(v, 10)}`}>{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className={`bm-review-total ${post.review_detail.pass ? 'bm-review-total--pass' : 'bm-review-total--fail'}`}>
|
|
총점: {post.review_score}/60 {post.review_detail.pass ? '(통과)' : '(미달)'}
|
|
</div>
|
|
{post.review_detail.feedback && (
|
|
<div className="bm-review-feedback">{post.review_detail.feedback}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="bm-write-actions">
|
|
{post.status === 'draft' && (
|
|
<button className="bm-btn bm-btn--primary" onClick={handleMarket} disabled={isProcessing} title={links.length === 0 ? '브랜드 링크를 먼저 추가하세요' : ''}>
|
|
{marketPoll.taskId ? <><span className="bm-spinner" /> 마케팅 중...</> : '마케터 실행'}
|
|
</button>
|
|
)}
|
|
<button className="bm-btn bm-btn--primary" onClick={handleReview} disabled={isProcessing}>
|
|
{reviewPoll.taskId ? <><span className="bm-spinner" /> 리뷰 중...</> : '품질 리뷰'}
|
|
</button>
|
|
<button className="bm-btn bm-btn--secondary" onClick={handleRegenerate} disabled={isProcessing}>
|
|
{regenPoll.taskId ? <><span className="bm-spinner" /> 재생성 중...</> : '재생성'}
|
|
</button>
|
|
<button className="bm-btn bm-btn--secondary" onClick={handleCopy}>
|
|
본문 복사
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ══════════════════════ Posts 탭 ═════════════════════════════════════════ */
|
|
function PostsTab() {
|
|
const [filter, setFilter] = useState('');
|
|
const [posts, setPosts] = useState([]);
|
|
const [publishModal, setPublishModal] = useState(null);
|
|
const [naverUrl, setNaverUrl] = useState('');
|
|
|
|
const load = useCallback(() => {
|
|
getBlogMarketingPosts(filter || undefined).then(r => setPosts(r.posts || [])).catch(() => {});
|
|
}, [filter]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const handleDelete = async (id) => {
|
|
if (!confirm('이 포스트를 삭제할까요?')) return;
|
|
await deleteBlogMarketingPost(id);
|
|
setPosts(prev => prev.filter(p => p.id !== id));
|
|
};
|
|
|
|
const handlePublish = async () => {
|
|
if (!publishModal) return;
|
|
await publishBlogMarketingPost(publishModal, naverUrl);
|
|
setPublishModal(null);
|
|
setNaverUrl('');
|
|
load();
|
|
};
|
|
|
|
const handleCopy = (body) => {
|
|
copyHtmlToClipboard(body);
|
|
};
|
|
|
|
const filters = [
|
|
{ id: '', label: '전체' },
|
|
{ id: 'draft', label: 'Draft' },
|
|
{ id: 'marketed', label: 'Marketed' },
|
|
{ id: 'reviewed', label: 'Reviewed' },
|
|
{ id: 'published', label: 'Published' },
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<div className="bm-posts-filter">
|
|
{filters.map(f => (
|
|
<button
|
|
key={f.id}
|
|
className={`bm-filter-btn ${filter === f.id ? 'bm-filter-btn--active' : ''}`}
|
|
onClick={() => setFilter(f.id)}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="bm-posts-list">
|
|
{posts.length === 0 && <div className="bm-empty">포스트가 없습니다.</div>}
|
|
{posts.map(p => (
|
|
<div key={p.id} className="bm-post-card">
|
|
<div className="bm-post-card__top">
|
|
<span className="bm-post-card__title">{p.title || '(제목 없음)'}</span>
|
|
<span className={`bm-post-card__status bm-post-card__status--${p.status}`}>
|
|
{p.status}
|
|
</span>
|
|
</div>
|
|
{p.excerpt && <div className="bm-post-card__excerpt">{p.excerpt}</div>}
|
|
<div className="bm-post-card__meta">
|
|
{p.review_score != null && <span>리뷰: {p.review_score}/60</span>}
|
|
{p.naver_url && <a href={p.naver_url} target="_blank" rel="noreferrer" style={{ color: '#10b981' }}>네이버 링크</a>}
|
|
<span>{fmtDate(p.created_at)}</span>
|
|
</div>
|
|
<div className="bm-post-card__actions">
|
|
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => handleCopy(p.body)}>복사</button>
|
|
{p.status !== 'published' && (
|
|
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => { setPublishModal(p.id); setNaverUrl(''); }}>
|
|
발행
|
|
</button>
|
|
)}
|
|
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(p.id)}>삭제</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{publishModal && (
|
|
<div className="bm-modal-overlay" onClick={() => setPublishModal(null)}>
|
|
<div className="bm-modal" onClick={e => e.stopPropagation()}>
|
|
<h3>네이버 블로그 발행</h3>
|
|
<p style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginBottom: 12 }}>
|
|
본문을 네이버 블로그에 붙여넣기한 후, 발행된 URL을 입력하세요.
|
|
</p>
|
|
<input
|
|
className="bm-modal__input"
|
|
placeholder="https://blog.naver.com/..."
|
|
value={naverUrl}
|
|
onChange={e => setNaverUrl(e.target.value)}
|
|
/>
|
|
<div className="bm-modal__buttons">
|
|
<button className="bm-btn bm-btn--secondary" onClick={() => setPublishModal(null)}>취소</button>
|
|
<button className="bm-btn bm-btn--primary" onClick={handlePublish}>발행 완료</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|