From 5cf60e7ee651c261c9bedd447b7c4414dcb94900 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 7 Apr 2026 02:09:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(blog-marketing):=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EB=93=9C=EC=BB=A4=EB=84=A5=ED=8A=B8=20=EB=A7=81=ED=81=AC=20UI?= =?UTF-8?q?=20+=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 삭제 버튼 한글 깨짐 수정 (삭�� → 삭제) - 리뷰 점수 표시 /50 → /60 (6기준 60점 체계 반영) - 브랜드커넥트 링크 관리 UI 추가 (추가/삭제/목록) - 마케터 실행 버튼 추가 (draft → marketed 전환) - Marketed 필터 추가 (PostsTab) - api.js에 링크 CRUD + 마케터 API 함수 추가 Co-Authored-By: Claude Opus 4.6 --- src/api.js | 26 ++++ src/pages/blog-marketing/BlogMarketing.jsx | 148 +++++++++++++++++++-- 2 files changed, 161 insertions(+), 13 deletions(-) diff --git a/src/api.js b/src/api.js index feb5c8c..9de8f3f 100644 --- a/src/api.js +++ b/src/api.js @@ -506,3 +506,29 @@ export function getBlogMarketingDashboard() { return apiGet('/api/blog-marketing/dashboard'); } +// 마케터 단계 +export function startMarket(postId) { + return apiPost(`/api/blog-marketing/market/${postId}`); +} + +// 브랜드커넥트 링크 CRUD +export function getBrandLinks(params = {}) { + const qs = new URLSearchParams(); + if (params.post_id) qs.set('post_id', String(params.post_id)); + if (params.keyword_id) qs.set('keyword_id', String(params.keyword_id)); + const q = qs.toString(); + return apiGet(`/api/blog-marketing/links${q ? '?' + q : ''}`); +} + +export function createBrandLink(data) { + return apiPost('/api/blog-marketing/links', data); +} + +export function updateBrandLink(id, data) { + return apiPut(`/api/blog-marketing/links/${id}`, data); +} + +export function deleteBrandLink(id) { + return apiDelete(`/api/blog-marketing/links/${id}`); +} + diff --git a/src/pages/blog-marketing/BlogMarketing.jsx b/src/pages/blog-marketing/BlogMarketing.jsx index 6473a5f..954b931 100644 --- a/src/pages/blog-marketing/BlogMarketing.jsx +++ b/src/pages/blog-marketing/BlogMarketing.jsx @@ -9,6 +9,7 @@ import { startGenerate, startReview, startRegenerate, + startMarket, getBlogMarketingPosts, getBlogMarketingPost, deleteBlogMarketingPost, @@ -17,6 +18,9 @@ import { getBlogMarketingCommissions, addBlogMarketingCommission, deleteBlogMarketingCommission, + getBrandLinks, + createBrandLink, + deleteBrandLink, } from '../../api'; import './BlogMarketing.css'; @@ -326,19 +330,29 @@ function WriteTab() { 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); + // 브랜드 링크 상태 + 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(() => { loadDrafts(); }, [loadDrafts]); + useEffect(() => { loadPosts(); }, [loadPosts]); useEffect(() => { - if (!selected) { setPost(null); return; } + 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) => { @@ -353,6 +367,13 @@ function WriteTab() { } }); + 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 { @@ -369,12 +390,43 @@ function WriteTab() { } 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; navigator.clipboard.writeText(post.body).then(() => alert('본문이 클립보드에 복사되었습니다!')); }; - const activePoll = reviewPoll.task || regenPoll.task; + 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) { @@ -396,7 +448,8 @@ function WriteTab() { className={`bm-filter-btn ${selected === p.id ? 'bm-filter-btn--active' : ''}`} onClick={() => setSelected(p.id)} > - {p.title?.slice(0, 20) || `Draft #${p.id}`} + {p.title?.slice(0, 20) || `${p.status === 'marketed' ? 'Marketed' : 'Draft'} #${p.id}`} + {p.status === 'marketed' && [M]} ))} @@ -413,8 +466,71 @@ function WriteTab() { {post && ( <> + {/* 브랜드커넥트 링크 섹션 */} +
+
+

브랜드커넥트 링크 ({links.length})

+ +
+ + {showLinkForm && ( +
+ setLinkForm(p => ({ ...p, url: e.target.value }))} + style={{ fontSize: '0.85rem' }} + /> + setLinkForm(p => ({ ...p, product_name: e.target.value }))} + style={{ fontSize: '0.85rem' }} + /> + setLinkForm(p => ({ ...p, description: e.target.value }))} + style={{ fontSize: '0.85rem' }} + /> + setLinkForm(p => ({ ...p, placement_hint: e.target.value }))} + style={{ fontSize: '0.85rem' }} + /> + +
+ )} + + {links.length > 0 && ( +
+ {links.map(l => ( +
+
+ {l.product_name} + {l.description && {l.description}} +
+ +
+ ))} +
+ )} +
+
-
{post.title || '(제목 없음)'}
+
+
{post.title || '(제목 없음)'}
+ + {post.status} + +
{post.tags?.length > 0 && (
@@ -435,7 +551,7 @@ function WriteTab() { ))}
- 총점: {post.review_score}/50 {post.review_detail.pass ? '(통과)' : '(미달)'} + 총점: {post.review_score}/60 {post.review_detail.pass ? '(통과)' : '(미달)'}
{post.review_detail.feedback && (
{post.review_detail.feedback}
@@ -444,6 +560,11 @@ function WriteTab() { )}
+ {post.status === 'draft' && ( + + )} @@ -494,6 +615,7 @@ function PostsTab() { const filters = [ { id: '', label: '전체' }, { id: 'draft', label: 'Draft' }, + { id: 'marketed', label: 'Marketed' }, { id: 'reviewed', label: 'Reviewed' }, { id: 'published', label: 'Published' }, ]; @@ -524,7 +646,7 @@ function PostsTab() {
{p.excerpt &&
{p.excerpt}
}
- {p.review_score != null && 리뷰: {p.review_score}/50} + {p.review_score != null && 리뷰: {p.review_score}/60} {p.naver_url && 네이버 링크} {fmtDate(p.created_at)}
@@ -535,7 +657,7 @@ function PostsTab() { 발행 )} - +
))}