442 lines
16 KiB
TypeScript
442 lines
16 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useCallback, useEffect, useState } from "react";
|
|||
|
|
import {
|
|||
|
|
ExternalLink,
|
|||
|
|
Loader2,
|
|||
|
|
MessageSquare,
|
|||
|
|
RefreshCw,
|
|||
|
|
Send,
|
|||
|
|
Sparkles,
|
|||
|
|
} from "lucide-react";
|
|||
|
|
import { Badge } from "@/components/ui/badge";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { THREADS_MAX_CHARS } from "@/lib/utils";
|
|||
|
|
|
|||
|
|
interface ReplyDraft {
|
|||
|
|
id: string;
|
|||
|
|
text: string;
|
|||
|
|
rationale?: string | null;
|
|||
|
|
status: string;
|
|||
|
|
publishedAt?: string | null;
|
|||
|
|
createdAt: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface InboundReply {
|
|||
|
|
id: string;
|
|||
|
|
text: string;
|
|||
|
|
authorName?: string | null;
|
|||
|
|
permalink?: string | null;
|
|||
|
|
postedAt?: string | null;
|
|||
|
|
likeCount?: number | null;
|
|||
|
|
sentiment?: string | null;
|
|||
|
|
intent?: string | null;
|
|||
|
|
status: string;
|
|||
|
|
createdAt: string;
|
|||
|
|
published: {
|
|||
|
|
id: string;
|
|||
|
|
text: string;
|
|||
|
|
permalink?: string | null;
|
|||
|
|
publishedAt?: string | null;
|
|||
|
|
} | null;
|
|||
|
|
replyDrafts: ReplyDraft[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const statusLabels: Record<string, { label: string; variant: "warning" | "secondary" | "success" }> = {
|
|||
|
|
NEW: { label: "待處理", variant: "warning" },
|
|||
|
|
DRAFTED: { label: "已生成草稿", variant: "secondary" },
|
|||
|
|
REPLIED: { label: "已回覆", variant: "success" },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sentimentLabels: Record<string, string> = {
|
|||
|
|
positive: "正面",
|
|||
|
|
neutral: "中性",
|
|||
|
|
negative: "負面",
|
|||
|
|
question: "提問",
|
|||
|
|
lead: "潛在客戶",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default function EngagementPage() {
|
|||
|
|
const [replies, setReplies] = useState<InboundReply[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|||
|
|
const [draftTexts, setDraftTexts] = useState<Record<string, string>>({});
|
|||
|
|
const { feedback, clearFeedback, showError, showSuccess } = useActionFeedback();
|
|||
|
|
const { isReady } = useCapabilities();
|
|||
|
|
const threadsApiReady = isReady("threadsApi");
|
|||
|
|
const aiReady = isReady("ai");
|
|||
|
|
|
|||
|
|
const load = useCallback(async (silent = false) => {
|
|||
|
|
if (!silent) setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/engagement/replies");
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
if (!res.ok) {
|
|||
|
|
showError(data.error ?? "無法載入留言", "載入失敗");
|
|||
|
|
setReplies([]);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setReplies(data.replies ?? []);
|
|||
|
|
setDraftTexts(
|
|||
|
|
Object.fromEntries(
|
|||
|
|
(data.replies ?? []).flatMap((reply: InboundReply) =>
|
|||
|
|
reply.replyDrafts.map((draft) => [draft.id, draft.text])
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
);
|
|||
|
|
} catch {
|
|||
|
|
showError("網路連線異常,請稍後再試", "載入失敗");
|
|||
|
|
setReplies([]);
|
|||
|
|
} finally {
|
|||
|
|
if (!silent) setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [showError]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
load();
|
|||
|
|
}, [load]);
|
|||
|
|
|
|||
|
|
async function syncReplies() {
|
|||
|
|
setBusy("sync");
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/engagement/replies", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ sync: true }),
|
|||
|
|
});
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
if (!res.ok) {
|
|||
|
|
showError(data.error ?? "同步失敗", "同步留言失敗");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
showSuccess("已從 Threads 同步留言");
|
|||
|
|
load(true);
|
|||
|
|
} catch {
|
|||
|
|
showError("網路連線異常,請稍後再試", "同步留言失敗");
|
|||
|
|
} finally {
|
|||
|
|
setBusy(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function generateDraft(replyId: string) {
|
|||
|
|
setBusy(`gen-${replyId}`);
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/engagement/replies/${replyId}/generate`, {
|
|||
|
|
method: "POST",
|
|||
|
|
});
|
|||
|
|
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 saveDraft(draftId: string) {
|
|||
|
|
setBusy(`save-${draftId}`);
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/engagement/reply-drafts/${draftId}`, {
|
|||
|
|
method: "PATCH",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ text: draftTexts[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 publishDraft(draftId: string) {
|
|||
|
|
setBusy(`publish-${draftId}`);
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/engagement/reply-drafts/${draftId}/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);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function copyText(text: string) {
|
|||
|
|
try {
|
|||
|
|
await navigator.clipboard.writeText(text);
|
|||
|
|
showSuccess("已複製回覆");
|
|||
|
|
} catch {
|
|||
|
|
showError("無法複製,請手動選取文字", "複製失敗");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pendingCount = replies.filter((r) => r.status === "NEW").length;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<PageHeader
|
|||
|
|
eyebrow="03 / ENGAGEMENT"
|
|||
|
|
title="互動經營"
|
|||
|
|
description="同步 Threads 貼文底下的留言,用 AI 分析情緒並生成回覆草稿,再一鍵發布回覆。"
|
|||
|
|
action={
|
|||
|
|
<Button
|
|||
|
|
size="lg"
|
|||
|
|
onClick={syncReplies}
|
|||
|
|
disabled={busy === "sync" || !threadsApiReady}
|
|||
|
|
title={!threadsApiReady ? "需先在連線設定綁定 Threads API" : undefined}
|
|||
|
|
>
|
|||
|
|
{busy === "sync" ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|||
|
|
同步留言
|
|||
|
|
</Button>
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{feedback && (
|
|||
|
|
<InlineAlert
|
|||
|
|
type={feedback.type}
|
|||
|
|
title={feedback.title}
|
|||
|
|
message={feedback.message}
|
|||
|
|
onDismiss={clearFeedback}
|
|||
|
|
className="mb-4"
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{!threadsApiReady && (
|
|||
|
|
<InlineAlert
|
|||
|
|
type="info"
|
|||
|
|
title="Threads API 尚未連線"
|
|||
|
|
message="同步留言與發布回覆需要 Threads 官方 API。可先綁定後再使用,或手動複製回覆至 Threads。"
|
|||
|
|
className="mb-4"
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loading ? (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{[0, 1, 2].map((i) => (
|
|||
|
|
<div key={i} className="skeleton h-56 animate-pulse" />
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
) : replies.length === 0 ? (
|
|||
|
|
<EmptyState
|
|||
|
|
icon={MessageSquare}
|
|||
|
|
title="尚無留言紀錄"
|
|||
|
|
description={
|
|||
|
|
threadsApiReady
|
|||
|
|
? "點上方「同步留言」從 Threads 拉取最新留言。"
|
|||
|
|
: "請先到連線設定綁定 Threads API,再同步留言。"
|
|||
|
|
}
|
|||
|
|
action={
|
|||
|
|
threadsApiReady ? (
|
|||
|
|
<Button onClick={syncReplies} disabled={busy === "sync"}>
|
|||
|
|
{busy === "sync" ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|||
|
|
立即同步
|
|||
|
|
</Button>
|
|||
|
|
) : (
|
|||
|
|
<Button asChild variant="outline">
|
|||
|
|
<a href="/connections">前往連線設定</a>
|
|||
|
|
</Button>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{pendingCount > 0 && (
|
|||
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|||
|
|
<Badge variant="warning">{pendingCount} 則待處理</Badge>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{replies.map((reply) => {
|
|||
|
|
const statusInfo = statusLabels[reply.status] ?? {
|
|||
|
|
label: reply.status,
|
|||
|
|
variant: "secondary" as const,
|
|||
|
|
};
|
|||
|
|
return (
|
|||
|
|
<Card key={reply.id}>
|
|||
|
|
<CardHeader>
|
|||
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|||
|
|
<div className="min-w-0">
|
|||
|
|
<CardTitle className="flex flex-wrap items-center gap-2">
|
|||
|
|
@{reply.authorName ?? "匿名"}
|
|||
|
|
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
|
|||
|
|
{reply.sentiment && (
|
|||
|
|
<Badge variant="outline">
|
|||
|
|
{sentimentLabels[reply.sentiment] ?? reply.sentiment}
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</CardTitle>
|
|||
|
|
<CardDescription>
|
|||
|
|
{reply.postedAt
|
|||
|
|
? new Date(reply.postedAt).toLocaleDateString("zh-TW", {
|
|||
|
|
month: "short",
|
|||
|
|
day: "numeric",
|
|||
|
|
hour: "2-digit",
|
|||
|
|
minute: "2-digit",
|
|||
|
|
})
|
|||
|
|
: ""}
|
|||
|
|
{reply.likeCount != null && ` · ${reply.likeCount} 讚`}
|
|||
|
|
</CardDescription>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{reply.status === "NEW" && (
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => generateDraft(reply.id)}
|
|||
|
|
disabled={busy === `gen-${reply.id}` || !aiReady}
|
|||
|
|
title={!aiReady ? "需先設定 AI API Key" : undefined}
|
|||
|
|
>
|
|||
|
|
{busy === `gen-${reply.id}` ? (
|
|||
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|||
|
|
) : (
|
|||
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|||
|
|
)}
|
|||
|
|
生成回覆
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
{reply.permalink && (
|
|||
|
|
<Button size="sm" variant="outline" asChild>
|
|||
|
|
<a href={reply.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">{reply.text}</p>
|
|||
|
|
{reply.intent && (
|
|||
|
|
<p className="mt-2 text-xs text-muted-foreground">意圖分析:{reply.intent}</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{reply.published && (
|
|||
|
|
<div className="rounded-lg border border-dashed border-border p-3">
|
|||
|
|
<p className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|||
|
|
你的貼文
|
|||
|
|
</p>
|
|||
|
|
<p className="line-clamp-2 text-[13px] leading-relaxed text-muted-foreground">
|
|||
|
|
{reply.published.text}
|
|||
|
|
</p>
|
|||
|
|
{reply.published.permalink && (
|
|||
|
|
<a
|
|||
|
|
href={reply.published.permalink}
|
|||
|
|
target="_blank"
|
|||
|
|
rel="noreferrer"
|
|||
|
|
className="mt-1 inline-flex items-center gap-1 text-[11px] text-primary hover:underline"
|
|||
|
|
>
|
|||
|
|
<ExternalLink className="h-3 w-3" />
|
|||
|
|
查看貼文
|
|||
|
|
</a>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{reply.replyDrafts.map((draft) => {
|
|||
|
|
const value = draftTexts[draft.id] ?? draft.text;
|
|||
|
|
const isPublished = draft.status === "PUBLISHED";
|
|||
|
|
return (
|
|||
|
|
<div key={draft.id} className="space-y-2 rounded-lg border border-border p-3">
|
|||
|
|
<div className="flex items-center justify-between gap-2">
|
|||
|
|
<div className="flex flex-wrap gap-1.5">
|
|||
|
|
<Badge variant={isPublished ? "success" : "warning"}>
|
|||
|
|
{isPublished ? "已發布" : "待發布"}
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
<span className="font-mono text-xs text-muted-foreground">
|
|||
|
|
{value.length}/{THREADS_MAX_CHARS}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<Textarea
|
|||
|
|
value={value}
|
|||
|
|
rows={3}
|
|||
|
|
onChange={(e) =>
|
|||
|
|
setDraftTexts((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">
|
|||
|
|
{!isPublished && (
|
|||
|
|
<>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => saveDraft(draft.id)}
|
|||
|
|
disabled={busy === `save-${draft.id}`}
|
|||
|
|
>
|
|||
|
|
儲存
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => copyText(value)}
|
|||
|
|
>
|
|||
|
|
複製
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
onClick={() => publishDraft(draft.id)}
|
|||
|
|
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>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|