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

675 lines
26 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.

/**
* 今日商機頁demand-radar T551T552
* 統計列+高/中/低分組;低意向預設收合;空狀態必顯示 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", labelKey: "radar.today.empty.goBrands" };
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 (
<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>
{o.product_matches?.length ? (
<div className="hb-opp-products">
<div className="hb-opp-products__summary">
<div className="hb-opp-products__primary">
<span className="hb-opp-products__eyebrow">{t("radar.today.productEyebrow")}</span>
<strong>{o.primary_product_label || t("radar.today.noPrimary")}</strong>
{o.primary_product_fit_score != null ? <Badge tone="brand">{t("radar.today.fitScore", { n: o.primary_product_fit_score })}</Badge> : null}
{o.primary_product_overridden ? <Badge tone="warning">{t("radar.today.overridden")}</Badge> : null}
</div>
<Button type="button" variant="ghost" aria-expanded={productsOpen} onClick={() => setProductsOpen((v) => !v)}>
{productsOpen ? t("radar.today.hideEvidence") : t("radar.today.showMatches", { n: o.product_matches.length })}
</Button>
</div>
{productsOpen ? (
<div className="hb-opp-products__details">
{o.product_matches.map((m) => <ProductMatchDetails key={m.product_id} match={m} />)}
<PrimaryProductPicker matches={o.product_matches} currentId={o.primary_product_id} busy={busy === `primary-${o.id}`} onSet={onSetPrimary} />
</div>
) : null}
</div>
) : <span className="hb-radar-section__hint">{t("radar.today.genericJudge")}</span>}
{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"
disabled={busy === `mark-${o.id}` || !canSendOutbox}
title={canSendOutbox ? undefined : t("radar.today.reply.needAccount")}
onClick={() => onMarkUsed("outbox")}
>
{t("radar.today.reply.markOutbox")}
</Button>
) : null}
</>
) : null}
{!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}
{activeReply?.used_at ? (
<span className="hb-radar-section__hint">
{activeReply.sent_channel === "outbox" && activeReply.outbox_id
? t("radar.today.reply.usedOutbox")
: t("radar.today.reply.used")}
</span>
) : 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">
{!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}
<a className="hb-btn hb-btn--secondary" href={o.permalink} target="_blank" rel="noreferrer">
{t("radar.today.action.open")}
</a>
<div className="hb-opp-card__secondary-actions">
<Button type="button" variant="ghost" 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>
</div>
</article>
);
}
export function RadarTodayPage() {
const { t } = useI18n();
const repos = useRepos();
const formatErr = useFormatApiError();
const [urlParams, setUrlParams] = useSearchParams();
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>>({});
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
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<Brand[]>([]);
const [products, setProducts] = useState<BrandProduct[]>([]);
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).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<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 () => {
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();
}, t("radar.today.msg.primarySet"));
}
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")} />
<section className="hb-radar-intro">
<div>
<strong>{t("radar.today.introTitle")}</strong>
<p>{t("radar.today.introBody")}</p>
</div>
<nav className="hb-radar-intro__actions" aria-label={t("radar.today.navAria")}>
<Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.today.manageWatches")}</Link>
<Link className="hb-btn hb-btn--ghost" to="/app/radar/opportunities">{t("radar.today.viewAll")}</Link>
</nav>
</section>
<section className="hb-radar-filter-panel" aria-label={t("radar.today.filterAria")}>
<div className="hb-radar-filter-panel__head">
<strong>{t("radar.today.filterTitle")}</strong>
<span>{t("radar.today.filterHint")}</span>
</div>
<div className="hb-radar-filter-grid">
{brands.length ? <Select name="radar-filter-brand" label={t("radar.inbox.brand")} value={filterBrand} onChange={(e) => { const value = e.target.value; setFilterBrand(value); setFilterProduct(""); const next = new URLSearchParams(urlParams); if (value) next.set("brand_id", value); else next.delete("brand_id"); next.delete("product_id"); setUrlParams(next, { replace: true }); }}><option value="">{t("radar.inbox.allBrands")}</option>{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}</Select> : null}
{products.length ? <Select name="radar-filter-product" label={t("radar.inbox.product")} value={filterProduct} onChange={(e) => { setFilterProduct(e.target.value); updateFilterParam("product_id", e.target.value); }}><option value="">{t("radar.inbox.allProducts")}</option>{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}</Select> : null}
<Select name="radar-filter-fit" label={t("radar.today.fit")} value={filterBand} onChange={(e) => { setFilterBand(e.target.value); updateFilterParam("fit_band", e.target.value); }}><option value="">{t("radar.today.allFit")}</option><option value="strong">{t("radar.today.fit.strong")}</option><option value="possible">{t("radar.today.fit.possible")}</option><option value="weak">{t("radar.today.fit.weak")}</option></Select>
</div>
</section>
<div className="hb-radar-utility-actions">
<span>{t("radar.today.needMore")}</span>
<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>
<Link className="hb-btn hb-btn--ghost" to="/app/crm">
{t("radar.today.link.crm")}
</Link>
{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}
</div>
{exploreOpen ? <ExplorePanel onExplored={() => void load()} /> : null}
{importOpen ? <ManualImportPanel onImported={() => void load()} /> : null}
{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}>
{action.labelKey ? t(action.labelKey) : action.label}
</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}
canSendOutbox={Boolean(sendAccountId)}
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)}
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
/>
))
)}
</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}
canSendOutbox={Boolean(sendAccountId)}
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)}
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
/>
))
)}
</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}
canSendOutbox={Boolean(sendAccountId)}
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)}
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
/>
))
: null}
</div>
</>
)}
</section>
) : null}
</>
);
}