thread-master/apps/web/src/pages/RadarTodayPage.tsx

588 lines
20 KiB
TypeScript
Raw Normal View History

2026-08-03 05:52:02 +00:00
/**
* demand-radar T551T552
* empty_reason
*/
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
2026-08-09 07:57:35 +00:00
import { ExplorePanel } from "../components/radar/ExplorePanel";
import { ManualImportPanel } from "../components/radar/ManualImportPanel";
import { Badge, Button, EmptyState, Select } from "../components/ui";
2026-08-03 05:52:02 +00:00
import type { BadgeTone } from "../components/ui";
import { useRepos } from "../data/DataContext";
import type {
IntentBand,
Opportunity,
RadarEmptyReason,
RadarToday,
ReplyVariant,
ReplyVariantKind,
2026-08-09 07:57:35 +00:00
ThreadsAccount,
2026-08-03 05:52:02 +00:00
} 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" };
2026-08-03 05:52:02 +00:00
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,
2026-08-09 07:57:35 +00:00
canSendOutbox,
2026-08-03 05:52:02 +00:00
onAccept,
onDismiss,
onGenerateReply,
onCopyReply,
onMarkUsed,
onOverrideBand,
}: {
o: Opportunity;
reply?: ReplyVariant;
busy: string;
2026-08-09 07:57:35 +00:00
/** false沒有可用的已連 Threads 帳號,一鍵送出要停用並指路去連帳號 */
canSendOutbox: boolean;
2026-08-03 05:52:02 +00:00
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 (
<article className={`hb-opp-card hb-opp-card--${o.intent_band}`}>
<header className="hb-opp-card__head">
<div className="hb-opp-card__meta">
<Badge tone={bandTone(o.intent_band)}>
{t(`radar.today.band.${o.intent_band}`)} · {o.intent_score}
</Badge>
{o.status !== "qualified" ? (
<Badge tone={statusTone(o.status)}>{t(`radar.today.status.${o.status}`)}</Badge>
) : null}
{o.region_detected ? (
<span
className={
o.region_match === "mismatch"
? "hb-opp-region--mismatch"
: o.region_match === "unknown"
? "hb-opp-region--unknown"
: undefined
}
>
{o.region_detected}
</span>
) : (
<span className="hb-opp-region--unknown">{t("radar.today.regionUnknown")}</span>
)}
<span className="radar-card__meta">{formatTimeAgo(o.posted_at)}</span>
</div>
</header>
<p className="hb-opp-card__text">{o.text}</p>
<p className="radar-card__meta">
@{o.author_handle}
{o.matched_service ? ` · ${o.matched_service}` : ""}
{o.matched_terms?.length ? ` · ${o.matched_terms.join("、")}` : ""}
</p>
{shownReply ? (
<pre className="radar-card__reply">{shownReply}</pre>
) : null}
{reasonsOpen ? (
<ul className="hb-opp-reasons">
{(o.reasons ?? []).map((r) => (
<li key={r.dimension} className="hb-opp-reason">
<strong>{t(`radar.today.dim.${r.dimension}`)}</strong>
<span>{r.score}</span>
<span>{r.reason}</span>
</li>
))}
</ul>
) : null}
{replyOpen ? (
<div className="hb-reply-variants">
<p className="hb-radar-section__hint">{t("radar.today.reply.hint")}</p>
<div className="hb-radar-actions">
{REPLY_VARIANTS.map((v) => (
<Button
key={v}
type="button"
variant="secondary"
disabled={busy === `reply-${o.id}`}
onClick={() => onGenerateReply(v)}
>
{t(`radar.today.reply.variant.${v}`)}
</Button>
))}
{shownReply ? (
<Button type="button" variant="ghost" onClick={onCopyReply}>
{t("radar.today.reply.copy")}
</Button>
) : null}
{activeReply && !activeReply.used_at ? (
<>
<Button
type="button"
variant="ghost"
disabled={busy === `mark-${o.id}`}
onClick={() => onMarkUsed("manual_copy")}
>
{t("radar.today.reply.markCopy")}
</Button>
{!isDm ? (
<Button
type="button"
variant="secondary"
2026-08-09 07:57:35 +00:00
disabled={busy === `mark-${o.id}` || !canSendOutbox}
title={canSendOutbox ? undefined : t("radar.today.reply.needAccount")}
2026-08-03 05:52:02 +00:00
onClick={() => onMarkUsed("outbox")}
>
{t("radar.today.reply.markOutbox")}
</Button>
) : null}
</>
) : null}
2026-08-09 07:57:35 +00:00
{!isDm && !canSendOutbox && activeReply && !activeReply.used_at ? (
<span className="hb-radar-section__hint">
{t("radar.today.reply.needAccount")}{" "}
<Link to="/app/crew">{t("nav.crew")}</Link>
</span>
) : null}
2026-08-03 05:52:02 +00:00
{activeReply?.used_at ? (
2026-08-09 07:57:35 +00:00
<span className="hb-radar-section__hint">
{activeReply.sent_channel === "outbox" && activeReply.outbox_id
? t("radar.today.reply.usedOutbox")
: t("radar.today.reply.used")}
</span>
2026-08-03 05:52:02 +00:00
) : null}
</div>
</div>
) : null}
{overrideOpen ? (
<div className="hb-radar-actions">
{(["high", "mid", "low"] as IntentBand[]).map((b) => (
<Button
key={b}
type="button"
variant="secondary"
disabled={busy === `override-${o.id}` || o.intent_band === b}
onClick={() => onOverrideBand(b)}
>
{t(`radar.today.band.${b}`)}
</Button>
))}
</div>
) : null}
<div className="radar-card__actions">
<a className="hb-btn hb-btn--ghost" href={o.permalink} target="_blank" rel="noreferrer">
{t("radar.today.action.open")}
</a>
{!isDone ? (
<>
<Button
type="button"
disabled={busy === `accept-${o.id}`}
onClick={onAccept}
>
{t("radar.today.action.accept")}
</Button>
<Button
type="button"
variant="ghost"
disabled={busy === `dismiss-${o.id}`}
onClick={onDismiss}
>
{t("radar.today.action.dismiss")}
</Button>
</>
) : null}
<Button type="button" variant="secondary" onClick={() => setReplyOpen((v) => !v)}>
{replyOpen ? t("radar.today.action.hideReply") : t("radar.today.action.reply")}
</Button>
<Button type="button" variant="ghost" onClick={() => setReasonsOpen((v) => !v)}>
{reasonsOpen ? t("radar.today.action.hideReasons") : t("radar.today.action.reasons")}
</Button>
<Button type="button" variant="ghost" onClick={() => setOverrideOpen((v) => !v)}>
{t("radar.today.action.override")}
</Button>
</div>
</article>
);
}
export function RadarTodayPage() {
const { t } = useI18n();
const repos = useRepos();
const formatErr = useFormatApiError();
const [data, setData] = useState<RadarToday | null>(null);
const [err, setErr] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [lowOpen, setLowOpen] = useState(false);
const [busy, setBusy] = useState("");
const [replies, setReplies] = useState<Record<string, ReplyVariant>>({});
2026-08-09 07:57:35 +00:00
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [sendAccountId, setSendAccountId] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [exploreOpen, setExploreOpen] = useState(false);
2026-08-03 05:52:02 +00:00
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
2026-08-09 07:57:35 +00:00
// 一鍵送出要知道用哪個帳號;多數人只有一個已連帳號,預設選第一個可用的即可。
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]);
2026-08-03 05:52:02 +00:00
async function run(key: string, action: () => Promise<void>, 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 () => {
2026-08-09 07:57:35 +00:00
const res = await repos.radar.markReplyUsed(
id,
r.id,
channel,
channel === "outbox" ? sendAccountId : undefined,
);
2026-08-03 05:52:02 +00:00
setReplies((m) => ({ ...m, [id]: res.reply }));
if (res.health_advice) setMsg(res.health_advice);
2026-08-09 07:57:35 +00:00
}, channel === "outbox" ? t("radar.today.msg.sent") : t("radar.today.msg.marked"));
2026-08-03 05:52:02 +00:00
}
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 (
<>
<PageHeader title={t("radar.today.title")} />
<div className="hb-radar-actions hb-radar-actions--toolbar">
<Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">
{t("radar.today.link.watches")}
</Link>
2026-08-09 07:57:35 +00:00
<Button
type="button"
variant="secondary"
onClick={() => {
setExploreOpen((v) => !v);
if (!exploreOpen) setImportOpen(false);
}}
>
{exploreOpen ? t("radar.explore.close") : t("radar.explore.open")}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
setImportOpen((v) => !v);
if (!importOpen) setExploreOpen(false);
}}
>
{importOpen ? t("radar.import.close") : t("radar.import.open")}
</Button>
2026-08-03 05:52:02 +00:00
<Link className="hb-btn hb-btn--ghost" to="/app/crm">
{t("radar.today.link.crm")}
</Link>
2026-08-09 07:57:35 +00:00
{accounts.length > 1 ? (
<Select
name="radar-send-account"
label={t("radar.today.sendAccount")}
value={sendAccountId}
onChange={(e) => setSendAccountId(e.target.value)}
>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
@{a.username}
</option>
))}
</Select>
) : null}
2026-08-03 05:52:02 +00:00
</div>
2026-08-09 07:57:35 +00:00
{exploreOpen ? <ExplorePanel onExplored={() => void load()} /> : null}
{importOpen ? <ManualImportPanel onImported={() => void load()} /> : null}
2026-08-03 05:52:02 +00:00
{err ? (
<p className="hb-banner-error" role="alert">
{err}
</p>
) : null}
{msg ? (
<p className="hb-banner-ok" role="status">
{msg}
</p>
) : null}
{loading && !data ? <p className="hb-radar-section__hint">{t("common.loading")}</p> : null}
{data ? (
<section className="hb-radar-page">
<div className="hb-radar-stats">
<div className="hb-radar-stat">
<span className="hb-radar-stat__value">{data.stats.total}</span>
<span className="hb-radar-stat__label">{t("radar.today.stats.total")}</span>
</div>
<div className="hb-radar-stat">
<span className="hb-radar-stat__value">{data.stats.high}</span>
<span className="hb-radar-stat__label">{t("radar.today.stats.high")}</span>
</div>
<div className="hb-radar-stat">
<span className="hb-radar-stat__value">{data.stats.mid}</span>
<span className="hb-radar-stat__label">{t("radar.today.stats.mid")}</span>
</div>
<div className="hb-radar-stat">
<span className="hb-radar-stat__value">{data.stats.low}</span>
<span className="hb-radar-stat__label">{t("radar.today.stats.low")}</span>
</div>
</div>
{data.truncated_count > 0 ? (
<p className="hb-radar-quota hb-radar-quota--full" role="status">
{t("radar.today.truncated", { n: data.truncated_count })}
</p>
) : null}
{data.last_swept_at ? (
<p className="hb-radar-section__hint">
{t("radar.today.lastSwept", { at: formatTimeAgo(data.last_swept_at) })}
</p>
) : null}
{data.stats.total === 0 ? (
<EmptyState
title={data.empty_hint || t("radar.today.empty.title")}
description={
data.empty_reason
? t(`radar.today.empty.reason.${data.empty_reason}`)
: t("radar.today.empty.fallback")
}
action={
action ? (
<Link className="hb-btn hb-btn--secondary" to={action.to}>
{t(action.labelKey)}
</Link>
) : null
}
/>
) : (
<>
<div className="hb-radar-section">
<h2 className="hb-radar-section__title">
{t("radar.today.group.high")} ({data.high.length})
</h2>
{data.high.length === 0 ? (
<p className="hb-radar-section__hint">{t("radar.today.group.empty")}</p>
) : (
data.high.map((o) => (
<OppCard
key={o.id}
o={o}
reply={replies[o.id]}
busy={busy}
2026-08-09 07:57:35 +00:00
canSendOutbox={Boolean(sendAccountId)}
2026-08-03 05:52:02 +00:00
onAccept={() => 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)}
/>
))
)}
</div>
<div className="hb-radar-section">
<h2 className="hb-radar-section__title">
{t("radar.today.group.mid")} ({data.mid.length})
</h2>
{data.mid.length === 0 ? (
<p className="hb-radar-section__hint">{t("radar.today.group.empty")}</p>
) : (
data.mid.map((o) => (
<OppCard
key={o.id}
o={o}
reply={replies[o.id]}
busy={busy}
2026-08-09 07:57:35 +00:00
canSendOutbox={Boolean(sendAccountId)}
2026-08-03 05:52:02 +00:00
onAccept={() => 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)}
/>
))
)}
</div>
<div className="hb-radar-section">
<button
type="button"
className="hb-btn hb-btn--ghost"
onClick={() => setLowOpen((v) => !v)}
aria-expanded={lowOpen}
>
{t("radar.today.group.low")} ({data.low.length}) ·{" "}
{lowOpen ? t("radar.today.group.collapse") : t("radar.today.group.expand")}
</button>
{lowOpen
? data.low.map((o) => (
<OppCard
key={o.id}
o={o}
reply={replies[o.id]}
busy={busy}
2026-08-09 07:57:35 +00:00
canSendOutbox={Boolean(sendAccountId)}
2026-08-03 05:52:02 +00:00
onAccept={() => 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}
</div>
</>
)}
</section>
) : null}
</>
);
}