feat(blog-marketing): 브랜드커넥트 링크 UI + 버그 수정

- 삭제 버튼 한글 깨짐 수정 (삭�� → 삭제)
- 리뷰 점수 표시 /50 → /60 (6기준 60점 체계 반영)
- 브랜드커넥트 링크 관리 UI 추가 (추가/삭제/목록)
- 마케터 실행 버튼 추가 (draft → marketed 전환)
- Marketed 필터 추가 (PostsTab)
- api.js에 링크 CRUD + 마케터 API 함수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 02:09:54 +09:00
parent 74f043bf29
commit 5cf60e7ee6
2 changed files with 161 additions and 13 deletions

View File

@@ -506,3 +506,29 @@ export function getBlogMarketingDashboard() {
return apiGet('/api/blog-marketing/dashboard'); 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}`);
}

View File

@@ -9,6 +9,7 @@ import {
startGenerate, startGenerate,
startReview, startReview,
startRegenerate, startRegenerate,
startMarket,
getBlogMarketingPosts, getBlogMarketingPosts,
getBlogMarketingPost, getBlogMarketingPost,
deleteBlogMarketingPost, deleteBlogMarketingPost,
@@ -17,6 +18,9 @@ import {
getBlogMarketingCommissions, getBlogMarketingCommissions,
addBlogMarketingCommission, addBlogMarketingCommission,
deleteBlogMarketingCommission, deleteBlogMarketingCommission,
getBrandLinks,
createBrandLink,
deleteBrandLink,
} from '../../api'; } from '../../api';
import './BlogMarketing.css'; import './BlogMarketing.css';
@@ -326,19 +330,29 @@ function WriteTab() {
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [post, setPost] = useState(null); const [post, setPost] = useState(null);
const loadDrafts = useCallback(() => { // 브랜드 링크 상태
getBlogMarketingPosts('draft', 20).then(r => { const [links, setLinks] = useState([]);
const drafts = r.posts || []; const [showLinkForm, setShowLinkForm] = useState(false);
setPosts(drafts); const [linkForm, setLinkForm] = useState({ url: '', product_name: '', description: '', placement_hint: '' });
if (drafts.length > 0 && !selected) setSelected(drafts[0].id);
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(() => {}); }).catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadDrafts(); }, [loadDrafts]); useEffect(() => { loadPosts(); }, [loadPosts]);
useEffect(() => { useEffect(() => {
if (!selected) { setPost(null); return; } if (!selected) { setPost(null); setLinks([]); return; }
getBlogMarketingPost(selected).then(setPost).catch(() => {}); getBlogMarketingPost(selected).then(setPost).catch(() => {});
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => setLinks([]));
}, [selected]); }, [selected]);
const reviewPoll = usePollTask((t) => { 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 () => { const handleReview = async () => {
if (!post) return; if (!post) return;
try { try {
@@ -369,12 +390,43 @@ function WriteTab() {
} catch (e) { alert(e.message); } } 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 = () => { const handleCopy = () => {
if (!post) return; if (!post) return;
navigator.clipboard.writeText(post.body).then(() => alert('본문이 클립보드에 복사되었습니다!')); 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'; const isProcessing = activePoll && activePoll.status !== 'succeeded' && activePoll.status !== 'failed';
if (posts.length === 0 && !post) { if (posts.length === 0 && !post) {
@@ -396,7 +448,8 @@ function WriteTab() {
className={`bm-filter-btn ${selected === p.id ? 'bm-filter-btn--active' : ''}`} className={`bm-filter-btn ${selected === p.id ? 'bm-filter-btn--active' : ''}`}
onClick={() => setSelected(p.id)} 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' && <span style={{ marginLeft: 4, fontSize: '0.7rem', color: '#f59e0b' }}>[M]</span>}
</button> </button>
))} ))}
</div> </div>
@@ -413,8 +466,71 @@ function WriteTab() {
{post && ( {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 className="bm-preview">
<div className="bm-preview__title">{post.title || '(제목 없음)'}</div> <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 }} /> <div className="bm-preview__body" dangerouslySetInnerHTML={{ __html: post.body }} />
{post.tags?.length > 0 && ( {post.tags?.length > 0 && (
<div className="bm-preview__tags"> <div className="bm-preview__tags">
@@ -435,7 +551,7 @@ function WriteTab() {
))} ))}
</div> </div>
<div className={`bm-review-total ${post.review_detail.pass ? 'bm-review-total--pass' : 'bm-review-total--fail'}`}> <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 ? '(통과)' : '(미달)'} 총점: {post.review_score}/60 {post.review_detail.pass ? '(통과)' : '(미달)'}
</div> </div>
{post.review_detail.feedback && ( {post.review_detail.feedback && (
<div className="bm-review-feedback">{post.review_detail.feedback}</div> <div className="bm-review-feedback">{post.review_detail.feedback}</div>
@@ -444,6 +560,11 @@ function WriteTab() {
)} )}
<div className="bm-write-actions"> <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}> <button className="bm-btn bm-btn--primary" onClick={handleReview} disabled={isProcessing}>
{reviewPoll.taskId ? <><span className="bm-spinner" /> 리뷰 ...</> : '품질 리뷰'} {reviewPoll.taskId ? <><span className="bm-spinner" /> 리뷰 ...</> : '품질 리뷰'}
</button> </button>
@@ -494,6 +615,7 @@ function PostsTab() {
const filters = [ const filters = [
{ id: '', label: '전체' }, { id: '', label: '전체' },
{ id: 'draft', label: 'Draft' }, { id: 'draft', label: 'Draft' },
{ id: 'marketed', label: 'Marketed' },
{ id: 'reviewed', label: 'Reviewed' }, { id: 'reviewed', label: 'Reviewed' },
{ id: 'published', label: 'Published' }, { id: 'published', label: 'Published' },
]; ];
@@ -524,7 +646,7 @@ function PostsTab() {
</div> </div>
{p.excerpt && <div className="bm-post-card__excerpt">{p.excerpt}</div>} {p.excerpt && <div className="bm-post-card__excerpt">{p.excerpt}</div>}
<div className="bm-post-card__meta"> <div className="bm-post-card__meta">
{p.review_score != null && <span>리뷰: {p.review_score}/50</span>} {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>} {p.naver_url && <a href={p.naver_url} target="_blank" rel="noreferrer" style={{ color: '#10b981' }}>네이버 링크</a>}
<span>{fmtDate(p.created_at)}</span> <span>{fmtDate(p.created_at)}</span>
</div> </div>
@@ -535,7 +657,7 @@ function PostsTab() {
발행 발행
</button> </button>
)} )}
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(p.id)}><EFBFBD><EFBFBD></button> <button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(p.id)}></button>
</div> </div>
</div> </div>
))} ))}