diff --git a/app/admin/components/AdminSidebar.tsx b/app/admin/components/AdminSidebar.tsx index 9892e0d..f237c0d 100644 --- a/app/admin/components/AdminSidebar.tsx +++ b/app/admin/components/AdminSidebar.tsx @@ -88,7 +88,7 @@ const NAV_ITEMS = [ }, { href: '/admin/marketing', - label: '마케팅 에셋', + label: '광고 관리', icon: ( ('channels'); + + // 광고 채널 상태 + const [channels, setChannels] = useState([]); + const [channelsLoading, setChannelsLoading] = useState(true); + const [channelsError, setChannelsError] = useState(null); + const [newChannel, setNewChannel] = useState({ name: '', url: '', memo: '' }); + const [creatingChannel, setCreatingChannel] = useState(false); + const [channelMutating, setChannelMutating] = useState(null); + const [editingMemoId, setEditingMemoId] = useState(null); + const [memoDraft, setMemoDraft] = useState(''); + const [preview, setPreview] = useState(null); const [copied, setCopied] = useState(null); const [checks, setChecks] = useState>({}); @@ -140,6 +164,105 @@ export default function MarketingPage() { const [activeTab, setActiveTab] = useState<'design' | 'pm' | 'quality' | 'marketing'>('design'); const [convertingPng, setConvertingPng] = useState(null); + async function loadChannels() { + setChannelsLoading(true); + setChannelsError(null); + try { + const res = await fetch('/api/admin/ad-channels'); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? '채널 로드 실패'); + setChannels(data.channels ?? []); + } catch (e) { + setChannelsError(e instanceof Error ? e.message : '채널 로드 실패'); + } finally { + setChannelsLoading(false); + } + } + + useEffect(() => { + if (section === 'channels') loadChannels(); + }, [section]); + + async function createChannel(e: React.FormEvent) { + e.preventDefault(); + if (!newChannel.name.trim()) { + setChannelsError('채널명을 입력해주세요.'); + return; + } + setCreatingChannel(true); + setChannelsError(null); + try { + const res = await fetch('/api/admin/ad-channels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: newChannel.name.trim(), + url: newChannel.url.trim() || undefined, + memo: newChannel.memo.trim() || undefined, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? '채널 등록 실패'); + setNewChannel({ name: '', url: '', memo: '' }); + await loadChannels(); + } catch (e) { + setChannelsError(e instanceof Error ? e.message : '채널 등록 실패'); + } finally { + setCreatingChannel(false); + } + } + + async function patchChannel(id: string, patch: Partial>) { + setChannelMutating(id); + setChannelsError(null); + try { + const res = await fetch(`/api/admin/ad-channels/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? '채널 수정 실패'); + await loadChannels(); + } catch (e) { + setChannelsError(e instanceof Error ? e.message : '채널 수정 실패'); + } finally { + setChannelMutating(null); + } + } + + async function toggleChannelStatus(channel: AdChannel) { + await patchChannel(channel.id, { status: channel.status === 'active' ? 'paused' : 'active' }); + } + + function startEditMemo(channel: AdChannel) { + setEditingMemoId(channel.id); + setMemoDraft(channel.memo ?? ''); + } + + async function saveMemo(id: string) { + await patchChannel(id, { memo: memoDraft.trim() || null }); + setEditingMemoId(null); + setMemoDraft(''); + } + + async function deleteChannel(id: string, name: string) { + const ok = confirm(`"${name}" 채널을 삭제하시겠습니까?`); + if (!ok) return; + setChannelMutating(id); + setChannelsError(null); + try { + const res = await fetch(`/api/admin/ad-channels/${id}`, { method: 'DELETE' }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? '채널 삭제 실패'); + await loadChannels(); + } catch (e) { + setChannelsError(e instanceof Error ? e.message : '채널 삭제 실패'); + } finally { + setChannelMutating(null); + } + } + useEffect(() => { const saved = localStorage.getItem('marketing_checks'); if (saved) setChecks(JSON.parse(saved)); @@ -235,11 +358,202 @@ export default function MarketingPage() { return (
+ {/* 헤더 */} +
+

광고 관리

+

광고 채널 운영 현황과 크몽·숨고 등록용 마케팅 에셋을 관리합니다.

+
+ + {/* 탭 스위처 */} +
+ {([ + { key: 'channels', label: '광고 채널' }, + { key: 'assets', label: '마케팅 에셋' }, + ] as const).map(({ key, label }) => ( + + ))} +
+ + {section === 'channels' && ( +
+ {channelsError && ( +
+ {channelsError} +
+ )} + + {/* 신규 채널 추가 폼 */} +
+
+ + setNewChannel({ ...newChannel, name: e.target.value })} + disabled={creatingChannel} + placeholder="예: 크몽 홈페이지 제작" + className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 text-sm" + /> +
+
+ + setNewChannel({ ...newChannel, url: e.target.value })} + disabled={creatingChannel} + placeholder="https://kmong.com/gig/..." + className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 text-sm" + /> +
+
+ + setNewChannel({ ...newChannel, memo: e.target.value })} + disabled={creatingChannel} + placeholder="비고" + className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 text-sm" + /> +
+ +
+ + {/* 채널 테이블 */} + {channelsLoading ? ( +
+
+
+ ) : channels.length === 0 ? ( +
+ 등록된 광고 채널이 없습니다 +
+ ) : ( +
+ + + + + + + + + + + + + {channels.map((channel) => ( + + + + + + + + + ))} + +
채널명URL상태메모등록일관리
{channel.name} + {channel.url ? ( + + {channel.url} + + ) : ( + - + )} + + + + {editingMemoId === channel.id ? ( +
+ setMemoDraft(e.target.value)} + autoFocus + className="w-full bg-slate-800 text-white border border-slate-700 rounded px-2 py-1 text-xs" + /> + + +
+ ) : ( + + )} +
+ {new Date(channel.created_at).toLocaleDateString('ko-KR')} + + +
+
+ )} +
+ )} + + {section === 'assets' && ( + <> {/* 헤더 */}
-

마케팅 에셋

+

마케팅 에셋

크몽·숨고 등록용 썸네일 및 배너 — 4대 전문가 품질 체크리스트 포함

)} + + )}
); }