/** * 今日商機頁(demand-radar T551/T552)。 * 統計列+高/中/低分組;低意向預設收合;空狀態必顯示 empty_reason 與下一步。 */ import { useCallback, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useSearchParams } 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 { Brand, BrandProduct, IntentBand, Opportunity, RadarEmptyReason, RadarToday, ReplyVariant, ReplyVariantKind, ThreadsAccount, } from "../domain/types"; import { ProductMatchDetails } from "../components/radar/ProductMatchDetails"; import { PrimaryProductPicker } from "../components/radar/PrimaryProductPicker"; 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; label?: string } | null { switch (reason) { case "no_profile": return { to: "/app/brands", label: "設定品牌與產品" }; 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" }; case "no_eligible_product_match": return { to: "/app/radar/opportunities", labelKey: "radar.today.empty.goAll" }; default: return { to: "/app/radar/watches", labelKey: "radar.today.empty.goWatches" }; } } function OppCard({ o, reply, busy, canSendOutbox, onAccept, onDismiss, onGenerateReply, onCopyReply, onMarkUsed, onOverrideBand, onSetPrimary, }: { 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; onSetPrimary: (productId: string, reason: string) => 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 [productsOpen, setProductsOpen] = 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("、")}` : ""}

{o.product_matches?.length ? (
推薦產品 {o.primary_product_label || "尚未指定主推產品"} {o.primary_product_fit_score != null ? 適配 {o.primary_product_fit_score} : null} {o.primary_product_overridden ? 人工指定 : null}
{productsOpen ? (
{o.product_matches.map((m) => )}
) : null}
) : 未指定產品(沿用通用商機判定)} {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}
{!isDone ? ( <> ) : null} {t("radar.today.action.open")}
); } export function RadarTodayPage() { const { t } = useI18n(); const repos = useRepos(); const formatErr = useFormatApiError(); const [urlParams, setUrlParams] = useSearchParams(); 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 [filterBrand, setFilterBrand] = useState(urlParams.get("brand_id") || ""); const [filterProduct, setFilterProduct] = useState(urlParams.get("product_id") || ""); const [filterBand, setFilterBand] = useState(urlParams.get("fit_band") || ""); const [brands, setBrands] = useState([]); const [products, setProducts] = useState([]); const scout = (repos as unknown as { scout?: { listBrands: () => Promise; listProducts: (id: string) => Promise } }).scout; useEffect(() => { if (!scout) return; void scout.listBrands().then(setBrands).catch(() => setBrands([])); }, [scout]); useEffect(() => { if (!scout || !filterBrand) { setProducts([]); return; } void scout.listProducts(filterBrand).then(setProducts).catch(() => setProducts([])); }, [scout, filterBrand]); function updateFilterParam(name: string, value: string) { const next = new URLSearchParams(urlParams); if (value) next.set(name, value); else next.delete(name); setUrlParams(next, { replace: true }); } const load = useCallback(async () => { const next = await repos.radar.getToday({ brand_id: filterBrand || undefined, product_id: filterProduct || undefined, fit_band: filterBand || undefined }); setData(next); }, [repos.radar, filterBrand, filterProduct, filterBand]); 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 setPrimary(id: string, productId: string, reason: string) { await run(`primary-${id}`, async () => { await repos.radar.setPrimaryProduct(id, productId, reason); await load(); }, "已設定主推產品;後續高分匹配不會覆蓋這個選擇。" ); } 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; const showSetupGuide = data?.stats.total === 0 && (data.empty_reason === "no_profile" || data.empty_reason === "no_watch"); const setupStep = data?.empty_reason === "no_profile" ? 1 : data?.empty_reason === "no_watch" ? 2 : 3; return ( <>
先看值得跟進的人,再決定怎麼回

系統會把 Threads 貼文和你的產品痛點比對、合併重複貼文,再依商機分數排序。

篩選今日商機 先看全部;結果多時再縮小到品牌或產品。
{brands.length ? : null} {products.length ? : null}
沒有想看的貼文? {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} {showSetupGuide ? (
第一次使用,照這三步就好完成後系統會每天自動巡邏。
目前第 {setupStep} 步
  1. 1 ? "is-done" : ""}>1
    整理品牌與產品填入受眾、痛點與產品能力。
    前往設定
  2. 2 ? "is-done" : ""}>2
    建立每日巡邏選產品後採用建議關鍵字。
    建立巡邏
  3. 3
    回來處理商機先看高分,再查看產品證據。
) : 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 ? ( {action.label || (action.labelKey ? t(action.labelKey) : t("common.continue"))} ) : 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)} onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)} /> )) )}

{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)} onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)} /> )) )}
{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)} onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)} /> )) : null}
)}
) : null} ); }