haixunMaster/app/(dashboard)/outreach/page.tsx

626 lines
25 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
"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<OutreachTarget[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [selectMode, setSelectMode] = useState(false);
const [selectedDraftIds, setSelectedDraftIds] = useState<Set<string>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
const [placementTopics, setPlacementTopics] = useState<PlacementTopic[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [draftText, setDraftText] = useState<Record<string, string>>({});
const [productDrafts, setProductDrafts] = useState<Record<string, string>>({});
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 (
<div>
<PageHeader
eyebrow="02 / FIND YOUR TA"
title="找出正在需要你的人"
description="從 Threads 訊號辨識高意向受眾,再套入你的人設與產品脈絡,產出自然、不硬銷的開場話術。"
action={<Button asChild size="lg"><Link href="/scans?mode=placement"><Radar className="h-4 w-4" /></Link></Button>}
/>
{feedback && (
<InlineAlert
type={feedback.type}
title={feedback.title}
message={feedback.message}
onDismiss={clearFeedback}
className="mb-4"
/>
)}
{!loading && targets.length > 0 && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2">
<p className="text-xs text-muted-foreground"> {total} · {page}/{totalPages} </p>
<div className="flex flex-wrap gap-2">
{selectMode ? (
<>
<Button size="sm" variant="outline" onClick={toggleAllPageDrafts} disabled={deletableDraftIds.length === 0}>
{allPageDraftsSelected ? "取消全選" : "全選本頁"}
</Button>
<Button size="sm" variant="outline" onClick={deleteSelectedDrafts} disabled={selectedDraftIds.size === 0 || batchDeleting}>
{batchDeleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
{selectedDraftIds.size}
</Button>
<Button size="sm" variant="ghost" onClick={exitSelectMode}></Button>
</>
) : (
<Button size="sm" variant="outline" onClick={() => setSelectMode(true)}>
</Button>
)}
</div>
</div>
)}
{loading ? (
<div className="space-y-4">
{[0, 1, 2].map((i) => <div key={i} className="skeleton h-56 animate-pulse" />)}
</div>
) : targets.length === 0 ? (
placementTopics.length > 0 ? (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
{placementTopics.length} TA
</div>
<div className="grid gap-4 sm:grid-cols-2">
{placementTopics.map((topic) => (
<Card key={topic.id}>
<CardHeader className="pb-3">
<CardTitle className="truncate">{topic.label}</CardTitle>
<CardDescription className="truncate">{topic.query}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{topic.latestScan ? (
<p className="text-[13px] text-muted-foreground">
{new Date(topic.latestScan.createdAt).toLocaleDateString("zh-TW", { month: "short", day: "numeric" })}
{" · "}
{topic.latestScan.itemCount}
</p>
) : (
<p className="text-[13px] text-muted-foreground"></p>
)}
<div className="flex flex-wrap gap-2">
{topic.latestScan ? (
<Button size="sm" asChild>
<Link href={`/scans/${topic.id}/results`}>
<ScanSearch className="h-3.5 w-3.5" />
</Link>
</Button>
) : (
<Button size="sm" variant="outline" asChild>
<Link href={`/scans/${topic.id}`}>
<ScanSearch className="h-3.5 w-3.5" />
</Link>
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex justify-center pt-2">
<Button asChild variant="outline" size="sm">
<Link href="/scans?mode=placement"><Radar className="h-4 w-4" /></Link>
</Button>
</div>
</div>
) : (
<EmptyState
icon={Target}
title="還沒有高意向受眾"
description="建立找 TA 主題並海巡後AI 會從中辨識高意向受眾並生成開場話術。"
action={<Button asChild><Link href="/scans?mode=placement"><Radar className="h-4 w-4" /></Link></Button>}
/>
)
) : (
<div className="space-y-4">
{hasPlacementTargets && placementTopicId && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription>
調
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ProductContextForm
compact
value={productDrafts[placementTopicId] ?? ""}
onChange={(value) =>
setProductDrafts((prev) => ({ ...prev, [placementTopicId]: value }))
}
/>
<Button
size="sm"
variant="outline"
disabled={busy === `product-${placementTopicId}`}
onClick={() => saveProductContext(placementTopicId)}
>
{busy === `product-${placementTopicId}` ? "儲存中…" : "儲存品牌與產品"}
</Button>
</CardContent>
</Card>
)}
{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 (
<Card key={target.id}>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>
@{target.scanItem.authorName ?? "匿名"} · {target.scanItem.scan.topic.label}
</CardTitle>
<CardDescription>
{target.scanItem.likeCount ?? 0} / {target.scanItem.replyCount ?? 0}
</CardDescription>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge variant={target.status === "LOW_RELEVANCE" ? "warning" : "success"}>
{Math.round((target.relevance ?? 0) * 100)}%
</Badge>
<Badge variant="outline">{target.status}</Badge>
</div>
</div>
<div className="flex flex-wrap gap-2">
{isPlacement && (
<Button
size="sm"
variant="outline"
disabled={busy === `regen-${target.id}`}
onClick={() => regenerateTarget(target)}
>
{busy === `regen-${target.id}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
)}
{target.scanItem.permalink && (
<Button size="sm" variant="outline" asChild>
<a href={target.scanItem.permalink} target="_blank" rel="noreferrer">
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-border bg-muted/50 p-3">
<p className="text-[13px] leading-relaxed">{target.scanItem.text}</p>
{productSummary && (
<p className="mt-2 text-xs text-muted-foreground">{productSummary}</p>
)}
{(target.scanItem.placementReason || target.reason) && (
<p className="mt-2 text-xs text-muted-foreground">
{target.scanItem.placementReason ?? target.reason}
</p>
)}
</div>
{target.drafts.map((draft) => {
const value = draftText[draft.id] ?? draft.text;
return (
<div key={draft.id} className="relative space-y-2 rounded-lg border border-border p-3">
{selectMode && draft.status !== "PUBLISHED" && (
<button
type="button"
aria-label="選取留言草稿"
onClick={() => toggleDraftSelection(draft.id)}
className={`absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded border text-xs ${
selectedDraftIds.has(draft.id)
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background"
}`}
>
{selectedDraftIds.has(draft.id) ? "✓" : ""}
</button>
)}
<div className="flex items-center justify-between gap-2">
<div className={`flex flex-wrap gap-1.5 ${selectMode ? "pr-8" : ""}`}>
<Badge variant={draft.status === "PUBLISHED" ? "success" : "warning"}>{draft.status}</Badge>
{draft.angle && <Badge variant="outline">{draft.angle}</Badge>}
</div>
<span className="text-xs text-muted-foreground">{value.length}/{THREADS_MAX_CHARS}</span>
</div>
<Textarea
value={value}
rows={3}
onChange={(e) => setDraftText((prev) => ({ ...prev, [draft.id]: e.target.value }))}
/>
{draft.rationale && <p className="text-xs text-muted-foreground">{draft.rationale}</p>}
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => saveDraft(draft.id)} disabled={busy === `save-${draft.id}`}>
</Button>
<Button size="sm" variant="outline" onClick={() => copyText(value)}>
<Copy className="h-3.5 w-3.5" />
</Button>
{draft.status !== "PUBLISHED" && (
<Button
size="sm"
variant="outline"
onClick={() => deleteDraft(draft)}
disabled={busy === `delete-${draft.id}`}
>
{busy === `delete-${draft.id}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
{draft.status !== "PUBLISHED" && (
<Button
size="sm"
onClick={() => publishDraft(draft)}
disabled={busy === `publish-${draft.id}` || !threadsApiReady}
title={!threadsApiReady ? "需先在連線設定綁定 Threads API" : undefined}
>
{busy === `publish-${draft.id}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
</div>
);
})}
</CardContent>
</Card>
);
})}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 pt-2">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground"> {page} / {totalPages} </span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((value) => Math.min(totalPages, value + 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
)}
</div>
);
}