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

626 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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