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:
26
src/api.js
26
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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' && <span style={{ marginLeft: 4, fontSize: '0.7rem', color: '#f59e0b' }}>[M]</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -413,8 +466,71 @@ function WriteTab() {
|
||||
|
||||
{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__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 }} />
|
||||
{post.tags?.length > 0 && (
|
||||
<div className="bm-preview__tags">
|
||||
@@ -435,7 +551,7 @@ function WriteTab() {
|
||||
))}
|
||||
</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 ? '(통과)' : '(미달)'}
|
||||
총점: {post.review_score}/60 {post.review_detail.pass ? '(통과)' : '(미달)'}
|
||||
</div>
|
||||
{post.review_detail.feedback && (
|
||||
<div className="bm-review-feedback">{post.review_detail.feedback}</div>
|
||||
@@ -444,6 +560,11 @@ function WriteTab() {
|
||||
)}
|
||||
|
||||
<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>
|
||||
@@ -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() {
|
||||
</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.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>
|
||||
@@ -535,7 +657,7 @@ function PostsTab() {
|
||||
발행
|
||||
</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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user