"use client"; import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { ChevronLeft, ChevronRight, Copy, ExternalLink, Loader2, RefreshCw, Radar, ScanSearch, Send, Target, Trash2 } from "lucide-react"; import { ProductContextForm } from "@/components/product-context-form"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { EmptyState } from "@/components/layout/empty-state"; import { PageHeader } from "@/components/layout/page-header"; import { InlineAlert } from "@/components/ui/inline-alert"; import { Textarea } from "@/components/ui/textarea"; import { notify } from "@/lib/notifications/store"; import { useCapabilities } from "@/lib/capabilities/context"; import { useActionFeedback } from "@/lib/use-action-feedback"; import { hasProductContext, summarizeProductContext } from "@/lib/types/product-context"; import { isPlacementGoal } from "@/lib/types/topic-goal"; import { parseFetchJson, THREADS_MAX_CHARS } from "@/lib/utils"; interface OutreachDraft { id: string; text: string; angle?: string | null; rationale?: string | null; status: string; } interface OutreachTarget { id: string; status: string; relevance?: number | null; reason?: string | null; scanItem: { id: string; text: string; authorName?: string | null; permalink?: string | null; likeCount?: number | null; replyCount?: number | null; placementReason?: string | null; placementScore?: number | null; scan: { topic: { id: string; label: string; topicGoal?: string | null; productContext?: string | null; brief?: string | null; }; scanGoal?: string | null; }; }; drafts: OutreachDraft[]; } interface PlacementTopic { id: string; label: string; query: string; topicGoal: string; scanCount: number; latestScan: { id: string; createdAt: string; itemCount: number; } | null; } const PAGE_SIZE = 10; export default function OutreachPage() { const [targets, setTargets] = useState([]); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [totalPages, setTotalPages] = useState(1); const [selectMode, setSelectMode] = useState(false); const [selectedDraftIds, setSelectedDraftIds] = useState>(new Set()); const [batchDeleting, setBatchDeleting] = useState(false); const [placementTopics, setPlacementTopics] = useState([]); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(null); const [draftText, setDraftText] = useState>({}); const [productDrafts, setProductDrafts] = useState>({}); const { feedback, clearFeedback, showError, showSuccess, showWarning } = useActionFeedback(); const { isReady } = useCapabilities(); const threadsApiReady = isReady("threadsApi"); const load = useCallback(async (silent = false) => { if (!silent) setLoading(true); try { const [outreachRes, topicsRes] = await Promise.all([ fetch(`/api/outreach?page=${page}&limit=${PAGE_SIZE}`), fetch("/api/topics"), ]); const [data, topicsData] = await Promise.all([ parseFetchJson<{ targets?: OutreachTarget[]; total?: number; totalPages?: number; error?: string }>(outreachRes), parseFetchJson<{ topics?: PlacementTopic[]; error?: string }>(topicsRes), ]); if (!outreachRes.ok) { showError(data.error ?? "無法載入受眾名單", "載入失敗"); setTargets([]); setTotal(0); setTotalPages(1); return; } const rows = data.targets ?? []; setTargets(rows); setTotal(data.total ?? rows.length); setTotalPages(data.totalPages ?? 1); if (page > (data.totalPages ?? 1)) setPage(Math.max(1, data.totalPages ?? 1)); setPlacementTopics((topicsData.topics ?? []).filter((t) => isPlacementGoal(t.topicGoal))); setDraftText( Object.fromEntries( rows.flatMap((target: OutreachTarget) => target.drafts.map((draft) => [draft.id, draft.text])) ) ); setProductDrafts((prev) => { const next = { ...prev }; for (const target of rows as OutreachTarget[]) { const topic = target.scanItem.scan.topic; if (next[topic.id] === undefined) { next[topic.id] = topic.productContext ?? ""; } } return next; }); } catch { showError("網路連線異常,請稍後再試", "載入失敗"); setTargets([]); setTotal(0); setTotalPages(1); } finally { if (!silent) setLoading(false); } }, [page, showError]); useEffect(() => { load(); }, [load]); useEffect(() => { setSelectedDraftIds(new Set()); }, [page]); async function saveDraft(draftId: string) { setBusy(`save-${draftId}`); try { const res = await fetch(`/api/outreach/drafts/${draftId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: draftText[draftId], status: "EDITED" }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showError(data.error ?? "無法儲存草稿", "儲存失敗"); return; } showSuccess("草稿已儲存"); load(true); } catch { showError("網路連線異常,請稍後再試", "儲存失敗"); } finally { setBusy(null); } } async function deleteDraft(draft: OutreachDraft) { if (!confirm("確定刪除這則留言草稿?刪除後無法復原。")) return; setBusy(`delete-${draft.id}`); try { const res = await fetch(`/api/outreach/drafts/${draft.id}`, { method: "DELETE" }); const data = await res.json().catch(() => ({})); if (!res.ok) { showError(data.error ?? "無法刪除留言草稿", "刪除失敗"); return; } setDraftText((prev) => { const next = { ...prev }; delete next[draft.id]; return next; }); showSuccess("留言草稿已刪除"); load(true); } catch { showError("網路連線異常,請稍後再試", "刪除失敗"); } finally { setBusy(null); } } function toggleDraftSelection(id: string) { setSelectedDraftIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function exitSelectMode() { setSelectMode(false); setSelectedDraftIds(new Set()); } async function deleteSelectedDrafts() { const ids = [...selectedDraftIds]; if (ids.length === 0) return; if (!confirm(`確定刪除選取的 ${ids.length} 則留言草稿?刪除後無法復原。`)) return; setBatchDeleting(true); try { const res = await fetch("/api/outreach/drafts", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showError(data.error ?? "無法批次刪除留言草稿", "批次刪除失敗"); return; } showSuccess(`已刪除 ${data.deleted ?? ids.length} 則留言草稿`); exitSelectMode(); await load(true); } catch { showError("網路連線異常,請稍後再試", "批次刪除失敗"); } finally { setBatchDeleting(false); } } async function copyText(text: string) { await navigator.clipboard.writeText(text); showSuccess("已複製留言"); } const placementTopicId = targets.find((t) => isPlacementGoal(t.scanItem.scan.scanGoal ?? t.scanItem.scan.topic.topicGoal) )?.scanItem.scan.topic.id; async function saveProductContext(topicId: string) { setBusy(`product-${topicId}`); try { const res = await fetch("/api/topics", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: topicId, productContext: productDrafts[topicId] ?? "" }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showError(data.error ?? "無法儲存品牌與產品", "儲存失敗"); return false; } showSuccess("品牌與產品已儲存"); load(true); return true; } catch { showError("網路連線異常,請稍後再試", "儲存失敗"); return false; } finally { setBusy(null); } } async function regenerateTarget(target: OutreachTarget) { const topicId = target.scanItem.scan.topic.id; const productContext = productDrafts[topicId] ?? ""; if (!hasProductContext(productContext)) { showWarning("在上方區塊填寫品牌與產品後,再重新生成。", "請先填寫品牌與產品"); return; } setBusy(`regen-${target.id}`); try { const res = await fetch("/api/outreach/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scanItemId: target.scanItem.id, productContext, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { notify({ type: "error", title: "重新生成失敗", message: data.error }); return; } notify({ type: "success", title: "已重新生成回覆草稿" }); load(true); } catch { notify({ type: "error", title: "重新生成失敗", message: "網路連線異常,請稍後再試" }); } finally { setBusy(null); } } async function publishDraft(draft: OutreachDraft) { setBusy(`publish-${draft.id}`); try { const res = await fetch(`/api/outreach/drafts/${draft.id}/publish`, { method: "POST" }); const data = await res.json().catch(() => ({})); if (!res.ok) { notify({ type: "error", title: "發布留言失敗", message: data.error }); return; } notify({ type: "success", title: "留言已發布到 Threads" }); load(true); } catch { notify({ type: "error", title: "發布留言失敗", message: "網路連線異常,請稍後再試" }); } finally { setBusy(null); } } const hasPlacementTargets = targets.some((t) => isPlacementGoal(t.scanItem.scan.scanGoal ?? t.scanItem.scan.topic.topicGoal) ); const deletableDraftIds = targets.flatMap((target) => target.drafts.filter((draft) => draft.status !== "PUBLISHED").map((draft) => draft.id) ); const allPageDraftsSelected = deletableDraftIds.length > 0 && deletableDraftIds.every((id) => selectedDraftIds.has(id)); function toggleAllPageDrafts() { setSelectedDraftIds((prev) => { const next = new Set(prev); if (allPageDraftsSelected) deletableDraftIds.forEach((id) => next.delete(id)); else deletableDraftIds.forEach((id) => next.add(id)); return next; }); } return (
挖掘新受眾} /> {feedback && ( )} {!loading && targets.length > 0 && (

共 {total} 個目標 · 第 {page}/{totalPages} 頁

{selectMode ? ( <> ) : ( )}
)} {loading ? (
{[0, 1, 2].map((i) =>
)}
) : targets.length === 0 ? ( placementTopics.length > 0 ? (
你已有 {placementTopics.length} 個找 TA 主題。到海巡成果頁點「獲客留言」,生成的對象會出現在這裡。
{placementTopics.map((topic) => ( {topic.label} {topic.query} {topic.latestScan ? (

最近海巡 {new Date(topic.latestScan.createdAt).toLocaleDateString("zh-TW", { month: "short", day: "numeric" })} {" · "} {topic.latestScan.itemCount} 篇

) : (

尚未海巡

)}
{topic.latestScan ? ( ) : ( )}
))}
) : ( 開始挖掘} /> ) ) : (
{hasPlacementTargets && placementTopicId && ( 品牌與產品 調整後可重新生成,讓留言更自然帶到你的品牌(不會硬銷)。 setProductDrafts((prev) => ({ ...prev, [placementTopicId]: value })) } /> )} {targets.map((target) => { const isPlacement = isPlacementGoal( target.scanItem.scan.scanGoal ?? target.scanItem.scan.topic.topicGoal ); const productSummary = summarizeProductContext( productDrafts[target.scanItem.scan.topic.id] ?? target.scanItem.scan.topic.productContext ); return (
@{target.scanItem.authorName ?? "匿名"} · {target.scanItem.scan.topic.label} {target.scanItem.likeCount ?? 0} 讚 / {target.scanItem.replyCount ?? 0} 留言
相關度 {Math.round((target.relevance ?? 0) * 100)}% {target.status}
{isPlacement && ( )} {target.scanItem.permalink && ( )}

{target.scanItem.text}

{productSummary && (

置入參考:{productSummary}

)} {(target.scanItem.placementReason || target.reason) && (

{target.scanItem.placementReason ?? target.reason}

)}
{target.drafts.map((draft) => { const value = draftText[draft.id] ?? draft.text; return (
{selectMode && draft.status !== "PUBLISHED" && ( )}
{draft.status} {draft.angle && {draft.angle}}
{value.length}/{THREADS_MAX_CHARS}