Blog Lab 페이지 추가 (블로그 마케팅 수익화)

4탭 구성: Dashboard, Research, Write, Posts
- BlogMarketing.jsx/css: 키워드 분석, AI 글 생성, 품질 리뷰, 발행 관리
- api.js: blog-marketing API 함수 15개 추가
- routes.jsx + Icons.jsx: Blog Lab 네비게이션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 19:59:28 +09:00
parent e8e45391ae
commit 74f043bf29
5 changed files with 813 additions and 0 deletions

View File

@@ -0,0 +1,566 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
getBlogMarketingStatus,
startResearch,
getResearchHistory,
getResearchDetail,
deleteResearch,
getBlogMarketingTask,
startGenerate,
startReview,
startRegenerate,
getBlogMarketingPosts,
getBlogMarketingPost,
deleteBlogMarketingPost,
publishBlogMarketingPost,
getBlogMarketingDashboard,
getBlogMarketingCommissions,
addBlogMarketingCommission,
deleteBlogMarketingCommission,
} 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 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 loadDrafts = useCallback(() => {
getBlogMarketingPosts('draft', 20).then(r => {
const drafts = r.posts || [];
setPosts(drafts);
if (drafts.length > 0 && !selected) setSelected(drafts[0].id);
}).catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadDrafts(); }, [loadDrafts]);
useEffect(() => {
if (!selected) { setPost(null); return; }
getBlogMarketingPost(selected).then(setPost).catch(() => {});
}, [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 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 handleCopy = () => {
if (!post) return;
navigator.clipboard.writeText(post.body).then(() => alert('본문이 클립보드에 복사되었습니다!'));
};
const activePoll = reviewPoll.task || regenPoll.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 }}>&#9997;</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) || `Draft #${p.id}`}
</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-preview">
<div className="bm-preview__title">{post.title || '(제목 없음)'}</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}/50 {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">
<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) => {
navigator.clipboard.writeText(body).then(() => alert('복사 완료!'));
};
const filters = [
{ id: '', label: '전체' },
{ id: 'draft', label: 'Draft' },
{ 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}/50</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)}><EFBFBD><EFBFBD></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>
);
}