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

442 lines
16 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 { 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>
);
}