/** * 今日商機頁(demand-radar T551/T552)。 * 統計列+高/中/低分組;低意向預設收合;空狀態必顯示 empty_reason 與下一步。 */ import { useCallback, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { PageHeader } from "../components/layout/PageHeader"; import { ExplorePanel } from "../components/radar/ExplorePanel"; import { ManualImportPanel } from "../components/radar/ManualImportPanel"; import { Badge, Button, EmptyState, Select } from "../components/ui"; import type { BadgeTone } from "../components/ui"; import { useRepos } from "../data/DataContext"; import type { IntentBand, Opportunity, RadarEmptyReason, RadarToday, ReplyVariant, ReplyVariantKind, ThreadsAccount, } from "../domain/types"; import { useI18n } from "../i18n/I18nContext"; import { useFormatApiError } from "../lib/apiErrors"; import { formatTimeAgo } from "../lib/time"; import "../styles/radar.css"; const REPLY_VARIANTS: ReplyVariantKind[] = [ "public_comment", "dm", "no_sales", "professional", "humorous", ]; function bandTone(band: string): BadgeTone { if (band === "high") return "success"; if (band === "mid") return "warning"; return "neutral"; } function statusTone(status: string): BadgeTone { if (status === "accepted") return "brand"; if (status === "dismissed") return "neutral"; return "success"; } function emptyAction(reason?: RadarEmptyReason): { to: string; labelKey: string } | null { switch (reason) { case "no_profile": return { to: "/app/brands?tab=serviceProfile", labelKey: "radar.today.empty.goProfile" }; case "no_watch": case "all_watches_paused": case "not_swept_yet": case "sweep_failed": case "no_hit": return { to: "/app/radar/watches", labelKey: "radar.today.empty.goWatches" }; default: return { to: "/app/radar/watches", labelKey: "radar.today.empty.goWatches" }; } } function OppCard({ o, reply, busy, canSendOutbox, onAccept, onDismiss, onGenerateReply, onCopyReply, onMarkUsed, onOverrideBand, }: { o: Opportunity; reply?: ReplyVariant; busy: string; /** false=沒有可用的已連 Threads 帳號,一鍵送出要停用並指路去連帳號 */ canSendOutbox: boolean; onAccept: () => void; onDismiss: () => void; onGenerateReply: (variant: ReplyVariantKind) => void; onCopyReply: () => void; onMarkUsed: (channel: "outbox" | "manual_copy") => void; onOverrideBand: (band: IntentBand) => void; }) { const { t } = useI18n(); const [reasonsOpen, setReasonsOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(Boolean(reply?.text || o.default_reply?.text)); const [overrideOpen, setOverrideOpen] = useState(false); const activeReply = reply || o.default_reply; const shownReply = activeReply?.text; const isDm = activeReply?.variant === "dm"; const isDone = o.status === "accepted" || o.status === "dismissed"; return (
{t(`radar.today.band.${o.intent_band}`)} · {o.intent_score} {o.status !== "qualified" ? ( {t(`radar.today.status.${o.status}`)} ) : null} {o.region_detected ? ( {o.region_detected} ) : ( {t("radar.today.regionUnknown")} )} {formatTimeAgo(o.posted_at)}

{o.text}

@{o.author_handle} {o.matched_service ? ` · ${o.matched_service}` : ""} {o.matched_terms?.length ? ` · ${o.matched_terms.join("、")}` : ""}

{shownReply ? (
{shownReply}
) : null} {reasonsOpen ? ( ) : null} {replyOpen ? (

{t("radar.today.reply.hint")}

{REPLY_VARIANTS.map((v) => ( ))} {shownReply ? ( ) : null} {activeReply && !activeReply.used_at ? ( <> {!isDm ? ( ) : null} ) : null} {!isDm && !canSendOutbox && activeReply && !activeReply.used_at ? ( {t("radar.today.reply.needAccount")}{" "} {t("nav.crew")} ) : null} {activeReply?.used_at ? ( {activeReply.sent_channel === "outbox" && activeReply.outbox_id ? t("radar.today.reply.usedOutbox") : t("radar.today.reply.used")} ) : null}
) : null} {overrideOpen ? (
{(["high", "mid", "low"] as IntentBand[]).map((b) => ( ))}
) : null}
{t("radar.today.action.open")} {!isDone ? ( <> ) : null}
); } export function RadarTodayPage() { const { t } = useI18n(); const repos = useRepos(); const formatErr = useFormatApiError(); const [data, setData] = useState(null); const [err, setErr] = useState(null); const [msg, setMsg] = useState(null); const [loading, setLoading] = useState(true); const [lowOpen, setLowOpen] = useState(false); const [busy, setBusy] = useState(""); const [replies, setReplies] = useState>({}); const [accounts, setAccounts] = useState([]); const [sendAccountId, setSendAccountId] = useState(""); const [importOpen, setImportOpen] = useState(false); const [exploreOpen, setExploreOpen] = useState(false); const load = useCallback(async () => { const next = await repos.radar.getToday(); setData(next); }, [repos.radar]); useEffect(() => { let alive = true; setLoading(true); load() .then(() => { if (alive) setErr(null); }) .catch((e) => { if (alive) setErr(formatErr(e)); }) .finally(() => { if (alive) setLoading(false); }); return () => { alive = false; }; }, [load]); // eslint-disable-line react-hooks/exhaustive-deps // 一鍵送出要知道用哪個帳號;多數人只有一個已連帳號,預設選第一個可用的即可。 useEffect(() => { void repos.accounts .list() .then((list) => { const usable = list.filter((a) => a.is_usable); setAccounts(usable); setSendAccountId((prev) => (prev && usable.some((a) => a.id === prev) ? prev : usable[0]?.id || "")); }) .catch(() => undefined); }, [repos.accounts]); async function run(key: string, action: () => Promise, okMessage?: string) { setBusy(key); setErr(null); setMsg(null); try { await action(); if (okMessage) setMsg(okMessage); } catch (e) { setErr(formatErr(e)); } finally { setBusy(""); } } async function accept(id: string) { await run(`accept-${id}`, async () => { await repos.radar.acceptOpportunity(id); await load(); }, t("radar.today.msg.accepted")); } async function dismiss(id: string) { await run(`dismiss-${id}`, async () => { await repos.radar.dismissOpportunity(id); await load(); }, t("radar.today.msg.dismissed")); } async function generateReply(id: string, variant: ReplyVariantKind) { await run(`reply-${id}`, async () => { const r = await repos.radar.createReply(id, variant); setReplies((m) => ({ ...m, [id]: r })); }, t("radar.today.msg.replyReady")); } async function markUsed(id: string, channel: "outbox" | "manual_copy") { const r = replies[id] || data?.high.concat(data.mid, data.low).find((o) => o.id === id)?.default_reply; if (!r?.id) { setErr(t("radar.today.msg.needReply")); return; } await run(`mark-${id}`, async () => { const res = await repos.radar.markReplyUsed( id, r.id, channel, channel === "outbox" ? sendAccountId : undefined, ); setReplies((m) => ({ ...m, [id]: res.reply })); if (res.health_advice) setMsg(res.health_advice); }, channel === "outbox" ? t("radar.today.msg.sent") : t("radar.today.msg.marked")); } async function overrideBand(id: string, band: IntentBand) { await run(`override-${id}`, async () => { await repos.radar.overrideOpportunity(id, { band }); await load(); }, t("radar.today.msg.overridden")); } async function copyReply(text: string) { try { await navigator.clipboard.writeText(text); setMsg(t("radar.today.msg.copied")); } catch { setErr(t("radar.today.msg.copyFail")); } } const action = data ? emptyAction(data.empty_reason) : null; return ( <>
{t("radar.today.link.watches")} {t("radar.today.link.crm")} {accounts.length > 1 ? ( ) : null}
{exploreOpen ? void load()} /> : null} {importOpen ? void load()} /> : null} {err ? (

{err}

) : null} {msg ? (

{msg}

) : null} {loading && !data ?

{t("common.loading")}

: null} {data ? (
{data.stats.total} {t("radar.today.stats.total")}
{data.stats.high} {t("radar.today.stats.high")}
{data.stats.mid} {t("radar.today.stats.mid")}
{data.stats.low} {t("radar.today.stats.low")}
{data.truncated_count > 0 ? (

{t("radar.today.truncated", { n: data.truncated_count })}

) : null} {data.last_swept_at ? (

{t("radar.today.lastSwept", { at: formatTimeAgo(data.last_swept_at) })}

) : null} {data.stats.total === 0 ? ( {t(action.labelKey)} ) : null } /> ) : ( <>

{t("radar.today.group.high")} ({data.high.length})

{data.high.length === 0 ? (

{t("radar.today.group.empty")}

) : ( data.high.map((o) => ( void accept(o.id)} onDismiss={() => void dismiss(o.id)} onGenerateReply={(v) => void generateReply(o.id, v)} onMarkUsed={(ch) => void markUsed(o.id, ch)} onCopyReply={() => void copyReply(replies[o.id]?.text || o.default_reply?.text || "") } onOverrideBand={(b) => void overrideBand(o.id, b)} /> )) )}

{t("radar.today.group.mid")} ({data.mid.length})

{data.mid.length === 0 ? (

{t("radar.today.group.empty")}

) : ( data.mid.map((o) => ( void accept(o.id)} onDismiss={() => void dismiss(o.id)} onGenerateReply={(v) => void generateReply(o.id, v)} onMarkUsed={(ch) => void markUsed(o.id, ch)} onCopyReply={() => void copyReply(replies[o.id]?.text || o.default_reply?.text || "") } onOverrideBand={(b) => void overrideBand(o.id, b)} /> )) )}
{lowOpen ? data.low.map((o) => ( void accept(o.id)} onDismiss={() => void dismiss(o.id)} onGenerateReply={(v) => void generateReply(o.id, v)} onMarkUsed={(ch) => void markUsed(o.id, ch)} onCopyReply={() => void copyReply(replies[o.id]?.text || o.default_reply?.text || "") } onOverrideBand={(b) => void overrideBand(o.id, b)} /> )) : null}
)}
) : null} ); }