"use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { ChevronDown, ChevronUp, ExternalLink, Flame, Loader2, MessageSquarePlus, ScanSearch, Sparkles, Table2, } from "lucide-react"; import { ViralAnalysisPanel } from "@/components/viral-analysis-panel"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { FeatureGate } from "@/components/layout/feature-gate"; import { ScanPipelineSummary } from "@/components/inspiration/scan-pipeline-summary"; import { EmptyState } from "@/components/layout/empty-state"; import { notify } from "@/lib/notifications/store"; import { parseMediaUrls, parseViralAnalysis } from "@/lib/types/viral"; import { hasProductContext, summarizeProductContext } from "@/lib/types/product-context"; import { isPlacementGoal } from "@/lib/types/topic-goal"; import { cn } from "@/lib/utils"; interface Reply { id: string; text: string; authorName?: string | null; likeCount?: number | null; } interface OutreachDraftPreview { id: string; text: string; angle?: string | null; rationale?: string | null; } interface OutreachTargetPreview { id: string; reason?: string | null; drafts: OutreachDraftPreview[]; } export interface ScanItem { id: string; text: string; authorName?: string | null; permalink?: string | null; likeCount?: number | null; replyCount?: number | null; score: number; searchTag?: string | null; relevanceScore?: number | null; placementScore?: number | null; placementReason?: string | null; qualityTier?: string | null; qualityReason?: string | null; combinedScore?: number | null; mediaUrls?: string | null; mediaType?: string | null; viralAnalysis?: string | null; replies: Reply[]; outreachTargets?: OutreachTargetPreview[]; } export interface Scan { id: string; createdAt: string; scanMode?: string; scanGoal?: string | null; scanTags?: string | null; searchSource?: string | null; repliesFetched?: boolean; repliesCount?: number; topic: { id: string; label: string; query: string; topicGoal?: string | null; productContext?: string | null; }; items: ScanItem[]; } function truncateText(text: string, max = 72) { const trimmed = (text ?? "").replace(/\s+/g, " ").trim(); if (!trimmed) return "(無內文)"; if (trimmed.length <= max) return trimmed; return `${trimmed.slice(0, max)}…`; } function CollapsibleTrigger({ onToggle, className, children, }: { onToggle: () => void; className?: string; children: React.ReactNode; }) { return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggle(); } }} className={cn("cursor-pointer text-left outline-none focus-visible:ring-2 focus-visible:ring-ring", className)} > {children}
); } function parseScanTags(raw: string | null | undefined): string[] { if (!raw) return []; try { return JSON.parse(raw) as string[]; } catch { return []; } } interface TopicScanResultsProps { scans: Scan[]; onReload: () => Promise; emptyAction?: React.ReactNode; } export function TopicScanResults({ scans, onReload, emptyAction }: TopicScanResultsProps) { const router = useRouter(); const [scanExpanded, setScanExpanded] = useState>({}); const [itemExpanded, setItemExpanded] = useState>({}); const [expanded, setExpanded] = useState>({}); const [generating, setGenerating] = useState(null); const [matrixGen, setMatrixGen] = useState(null); const [batchAnalyzing, setBatchAnalyzing] = useState(null); const [generatingReplyId, setGeneratingReplyId] = useState(null); const [viralExpanded, setViralExpanded] = useState>({}); async function handleGenerate(scanId: string) { setGenerating(scanId); const res = await fetch("/api/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scanId }), }); const data = await res.json(); setGenerating(null); if (!res.ok) { notify({ type: "error", title: "生成草稿失敗", message: data.error }); return; } notify({ type: "success", title: "草稿已生成", message: `共 ${data.drafts?.length ?? 0} 篇`, href: "/", }); } async function handleGenerateMatrix(scanId: string) { setMatrixGen(scanId); const res = await fetch("/api/generate-matrix", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scanId }), }); const data = await res.json(); setMatrixGen(null); if (!res.ok) { notify({ type: "error", title: "生成內容矩陣失敗", message: data.error }); return; } notify({ type: "success", title: "內容矩陣已生成", message: `共 ${data.drafts?.length ?? 0} 篇`, href: "/matrix", }); router.push("/matrix"); } async function handleGenerateReply(scanItemId: string, productContext: string) { if (!hasProductContext(productContext)) { notify({ type: "warning", title: "請先填寫品牌與產品", message: "請到主題設定選擇品牌與產品後再生成回覆。", }); return; } setGeneratingReplyId(scanItemId); const res = await fetch("/api/outreach/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scanItemId, productContext }), }); const data = await res.json(); setGeneratingReplyId(null); if (!res.ok) { notify({ type: "error", title: "生成回覆失敗", message: data.error }); return; } setItemExpanded((prev) => ({ ...prev, [scanItemId]: true })); await onReload(); notify({ type: "success", title: "回覆草稿已生成", message: "請審核後再到主動互動頁發布", href: "/outreach", }); } function toggleScan(id: string) { setScanExpanded((prev) => ({ ...prev, [id]: !prev[id] })); } function toggleItemDetail(id: string) { setItemExpanded((prev) => ({ ...prev, [id]: !prev[id] })); } function toggleReplies(id: string) { setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); } function toggleViral(id: string) { setViralExpanded((prev) => ({ ...prev, [id]: !prev[id] })); } function expandAllScans() { setScanExpanded(Object.fromEntries(scans.map((s) => [s.id, true]))); } function collapseAllScans() { setScanExpanded(Object.fromEntries(scans.map((s) => [s.id, false]))); } function revealAnalyzedItems(itemIds: string[], scanId: string) { setScanExpanded((prev) => ({ ...prev, [scanId]: true })); setItemExpanded((prev) => { const next = { ...prev }; for (const id of itemIds) next[id] = true; return next; }); setViralExpanded((prev) => { const next = { ...prev }; for (const id of itemIds) next[id] = true; return next; }); } async function handleBatchAnalyze(scanId: string) { setBatchAnalyzing(scanId); setScanExpanded((prev) => ({ ...prev, [scanId]: true })); notify({ type: "info", title: "爆款分析進行中", message: "正在分析 Top5 貼文,約需 1~3 分鐘,請稍候…", }); try { const res = await fetch("/api/analyze-viral", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scanId, limit: 5 }), }); const data = await res.json(); if (!res.ok) { notify({ type: "error", title: "批次爆款分析失敗", message: data.error }); return; } const results = (data.results ?? []) as Array<{ item?: { id?: string } }>; const itemIds = results .map((row) => row.item?.id) .filter((id): id is string => typeof id === "string"); if (itemIds.length === 0) { notify({ type: "warning", title: "沒有可分析的貼文", message: "請確認有通過品質篩選(優質/可參考)的素材。", }); return; } revealAnalyzedItems(itemIds, scanId); await onReload(); revealAnalyzedItems(itemIds, scanId); notify({ type: "success", title: "爆款分析完成", message: `共 ${itemIds.length} 篇,已自動展開結果`, }); } catch { notify({ type: "error", title: "批次爆款分析失敗", message: "連線中斷或逾時,請稍後再試。", }); } finally { setBatchAnalyzing(null); } } if (scans.length === 0) { return ( ); } return (
{scans.map((scan, si) => { const placementScan = isPlacementGoal(scan.scanGoal ?? scan.topic.topicGoal); const tags = parseScanTags(scan.scanTags); const visibleItems = scan.items; const isScanOpen = scanExpanded[scan.id] ?? false; const analyzedCount = scan.items.filter((i) => i.viralAnalysis).length; return (
toggleScan(scan.id)} className="group min-w-0 flex-1" >
{new Date(scan.createdAt).toLocaleString("zh-TW")} {scan.scanMode === "multi-tag" && tags.length > 0 && `${tags.length} 個標籤`} {analyzedCount > 0 && `${tags.length > 0 ? " · " : ""}${analyzedCount} 篇已爆款分析`} {!isScanOpen && " · 點擊展開貼文"}
{scan.searchSource === "browser" ? "瀏覽器爬蟲" : scan.searchSource === "web" ? "網路搜尋" : scan.searchSource === "hybrid" ? "API + 網搜" : "官方 API"}
{!isScanOpen && tags.length > 0 && (
{tags.slice(0, 6).map((tag) => ( {tag} ))} {tags.length > 6 && ( +{tags.length - 6} )}
)}
{!placementScan && ( <> )} {placementScan && ( )}
{isScanOpen && tags.length > 0 && (
{tags.map((tag) => ( {tag} ))}
)} {isScanOpen && placementScan && (

品牌與產品

{summarizeProductContext(scan.topic.productContext) ?? "尚未設定,請到主題設定或品牌與產品頁選用"}

)}
{batchAnalyzing === scan.id && (
爆款分析 Top5 進行中…完成後會自動展開分析結果(約 1~3 分鐘)
)} {isScanOpen && ( {visibleItems.map((item) => { const isItemOpen = itemExpanded[item.id] ?? false; const outreachTarget = item.outreachTargets?.[0]; const hasDrafts = (outreachTarget?.drafts.length ?? 0) > 0; return (
toggleItemDetail(item.id)} className="min-w-0 flex-1" >
{item.searchTag && ( {item.searchTag} )} @{item.authorName ?? "匿名"} {item.likeCount ?? 0} 讚 {(item.replyCount ?? 0) > 0 && ( {item.replyCount} 則留言 )} {hasDrafts && ( 已有草稿 )} {item.viralAnalysis && !isItemOpen && ( 已分析 )}
{item.permalink ? ( e.stopPropagation()} > {isItemOpen ? item.text : truncateText(item.text)} ) : (

{isItemOpen ? item.text : truncateText(item.text)}

)}
{item.permalink && ( e.stopPropagation()} > 原文 )}
{isItemOpen && ( <>
{placementScan ? ( ) : ( )} {item.replies.length > 0 && ( )}
{placementScan && hasDrafts && outreachTarget && (
{outreachTarget.drafts.map((draft) => (
{draft.angle && (

{draft.angle}

)}

{draft.text}

{draft.rationale && (

{draft.rationale}

)}
))}
)} {!placementScan && viralExpanded[item.id] && ( )} {expanded[item.id] && item.replies.length > 0 && (
{item.replies.map((reply) => (

@{reply.authorName ?? "匿名"} · {reply.likeCount ?? 0} 讚

{reply.text}

))}
)} )}
); })}
)}
); })}
); }