Compare commits
2 Commits
74f043bf29
...
5dadd4bf2c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dadd4bf2c | |||
| 5cf60e7ee6 |
26
src/api.js
26
src/api.js
@@ -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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -29,6 +33,14 @@ function fmtMoney(n) {
|
|||||||
if (n == null) return '-';
|
if (n == null) return '-';
|
||||||
return n.toLocaleString('ko-KR') + '원';
|
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) {
|
function scoreColor(v, max = 100) {
|
||||||
const r = v / max;
|
const r = v / max;
|
||||||
if (r >= 0.6) return 'bm-score__value--high';
|
if (r >= 0.6) return 'bm-score__value--high';
|
||||||
@@ -326,19 +338,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 +375,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 +398,43 @@ function WriteTab() {
|
|||||||
} catch (e) { alert(e.message); }
|
} catch (e) { alert(e.message); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleMarket = async () => {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
navigator.clipboard.writeText(post.body).then(() => alert('본문이 클립보드에 복사되었습니다!'));
|
if (links.length === 0) {
|
||||||
|
alert('마케터 실행 전 브랜드커넥트 링크를 먼저 추가하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { task_id } = await startMarket(post.id);
|
||||||
|
marketPoll.start(task_id);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const activePoll = reviewPoll.task || regenPoll.task;
|
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';
|
const isProcessing = activePoll && activePoll.status !== 'succeeded' && activePoll.status !== 'failed';
|
||||||
|
|
||||||
if (posts.length === 0 && !post) {
|
if (posts.length === 0 && !post) {
|
||||||
@@ -396,7 +456,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 +474,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 +559,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 +568,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>
|
||||||
@@ -488,12 +617,13 @@ function PostsTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = (body) => {
|
const handleCopy = (body) => {
|
||||||
navigator.clipboard.writeText(body).then(() => alert('복사 완료!'));
|
copyHtmlToClipboard(body);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 +654,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 +665,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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user