675 lines
26 KiB
TypeScript
675 lines
26 KiB
TypeScript
/**
|
||
* 今日商機頁(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", 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}
|
||
</>
|
||
);
|
||
}
|