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

675 lines
26 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";
2026-08-13 02:22:24 +00:00
import { useSearchParams } from "react-router-dom";
2026-08-03 05:52:02 +00:00
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 {
2026-08-13 02:22:24 +00:00
Brand,
BrandProduct,
2026-08-03 05:52:02 +00:00
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";
2026-08-13 02:22:24 +00:00
import { ProductMatchDetails } from "../components/radar/ProductMatchDetails";
import { PrimaryProductPicker } from "../components/radar/PrimaryProductPicker";
2026-08-03 05:52:02 +00:00
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";
}
2026-08-13 02:22:24 +00:00
function emptyAction(reason?: RadarEmptyReason): { to: string; labelKey?: string; label?: string } | null {
2026-08-03 05:52:02 +00:00
switch (reason) {
case "no_profile":
2026-08-18 16:26:10 +00:00
return { to: "/app/brands", labelKey: "radar.today.empty.goBrands" };
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" };
2026-08-13 02:22:24 +00:00
case "no_eligible_product_match":
return { to: "/app/radar/opportunities", labelKey: "radar.today.empty.goAll" };
2026-08-03 05:52:02 +00:00
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,
2026-08-13 02:22:24 +00:00
onSetPrimary,
2026-08-03 05:52:02 +00:00
}: {
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;
2026-08-13 02:22:24 +00:00
onSetPrimary: (productId: string, reason: string) => void;
2026-08-03 05:52:02 +00:00
}) {
const { t } = useI18n();
const [reasonsOpen, setReasonsOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(Boolean(reply?.text || o.default_reply?.text));
const [overrideOpen, setOverrideOpen] = useState(false);
2026-08-13 02:22:24 +00:00
const [productsOpen, setProductsOpen] = useState(false);
2026-08-03 05:52:02 +00:00
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>
2026-08-13 02:22:24 +00:00
{o.product_matches?.length ? (
<div className="hb-opp-products">
<div className="hb-opp-products__summary">
<div className="hb-opp-products__primary">
2026-08-18 16:26:10 +00:00
<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}
2026-08-13 02:22:24 +00:00
</div>
<Button type="button" variant="ghost" aria-expanded={productsOpen} onClick={() => setProductsOpen((v) => !v)}>
2026-08-18 16:26:10 +00:00
{productsOpen ? t("radar.today.hideEvidence") : t("radar.today.showMatches", { n: o.product_matches.length })}
2026-08-13 02:22:24 +00:00
</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>
2026-08-18 16:26:10 +00:00
) : <span className="hb-radar-section__hint">{t("radar.today.genericJudge")}</span>}
2026-08-13 02:22:24 +00:00
2026-08-03 05:52:02 +00:00
{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">
{!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}
2026-08-13 02:22:24 +00:00
<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>
2026-08-03 05:52:02 +00:00
</div>
</article>
);
}
export function RadarTodayPage() {
const { t } = useI18n();
const repos = useRepos();
const formatErr = useFormatApiError();
2026-08-13 02:22:24 +00:00
const [urlParams, setUrlParams] = useSearchParams();
2026-08-03 05:52:02 +00:00
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-13 02:22:24 +00:00
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 });
}
2026-08-03 05:52:02 +00:00
const load = useCallback(async () => {
2026-08-13 02:22:24 +00:00
const next = await repos.radar.getToday({ brand_id: filterBrand || undefined, product_id: filterProduct || undefined, fit_band: filterBand || undefined });
2026-08-03 05:52:02 +00:00
setData(next);
2026-08-13 02:22:24 +00:00
}, [repos.radar, filterBrand, filterProduct, filterBand]);
2026-08-03 05:52:02 +00:00
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"));
}
2026-08-13 02:22:24 +00:00
async function setPrimary(id: string, productId: string, reason: string) {
await run(`primary-${id}`, async () => {
await repos.radar.setPrimaryProduct(id, productId, reason);
await load();
2026-08-18 16:26:10 +00:00
}, t("radar.today.msg.primarySet"));
2026-08-13 02:22:24 +00:00
}
2026-08-03 05:52:02 +00:00
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")} />
2026-08-13 02:22:24 +00:00
<section className="hb-radar-intro">
<div>
2026-08-18 16:26:10 +00:00
<strong>{t("radar.today.introTitle")}</strong>
<p>{t("radar.today.introBody")}</p>
2026-08-13 02:22:24 +00:00
</div>
2026-08-18 16:26:10 +00:00
<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>
2026-08-13 02:22:24 +00:00
</nav>
</section>
2026-08-18 16:26:10 +00:00
<section className="hb-radar-filter-panel" aria-label={t("radar.today.filterAria")}>
2026-08-13 02:22:24 +00:00
<div className="hb-radar-filter-panel__head">
2026-08-18 16:26:10 +00:00
<strong>{t("radar.today.filterTitle")}</strong>
<span>{t("radar.today.filterHint")}</span>
2026-08-13 02:22:24 +00:00
</div>
<div className="hb-radar-filter-grid">
2026-08-18 16:26:10 +00:00
{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>
2026-08-13 02:22:24 +00:00
</div>
</section>
<div className="hb-radar-utility-actions">
2026-08-18 16:26:10 +00:00
<span>{t("radar.today.needMore")}</span>
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}>
2026-08-18 16:26:10 +00:00
{action.labelKey ? t(action.labelKey) : action.label}
2026-08-03 05:52:02 +00:00
</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)}
2026-08-13 02:22:24 +00:00
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
2026-08-03 05:52:02 +00:00
/>
))
)}
</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)}
2026-08-13 02:22:24 +00:00
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
2026-08-03 05:52:02 +00:00
/>
))
)}
</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)}
2026-08-13 02:22:24 +00:00
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
2026-08-03 05:52:02 +00:00
/>
))
: null}
</div>
</>
)}
</section>
) : null}
</>
);
}