"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}
))}
)}
>
)}
);
})}
)}
);
})}
);
}