2026-08-03 05:52:02 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 今日商機頁(demand-radar T551/T552)。
|
|
|
|
|
|
* 統計列+高/中/低分組;低意向預設收合;空狀態必顯示 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-13 02:22:24 +00:00
|
|
|
|
return { to: "/app/brands", label: "設定品牌與產品" };
|
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">
|
|
|
|
|
|
<span className="hb-opp-products__eyebrow">推薦產品</span>
|
|
|
|
|
|
<strong>{o.primary_product_label || "尚未指定主推產品"}</strong>
|
|
|
|
|
|
{o.primary_product_fit_score != null ? <Badge tone="brand">適配 {o.primary_product_fit_score}</Badge> : null}
|
|
|
|
|
|
{o.primary_product_overridden ? <Badge tone="warning">人工指定</Badge> : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button type="button" variant="ghost" aria-expanded={productsOpen} onClick={() => setProductsOpen((v) => !v)}>
|
|
|
|
|
|
{productsOpen ? "收合產品證據" : `查看 ${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">未指定產品(沿用通用商機判定)</span>}
|
|
|
|
|
|
|
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-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;
|
2026-08-13 02:22:24 +00:00
|
|
|
|
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;
|
2026-08-03 05:52:02 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<PageHeader title={t("radar.today.title")} />
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
<section className="hb-radar-intro">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>先看值得跟進的人,再決定怎麼回</strong>
|
|
|
|
|
|
<p>系統會把 Threads 貼文和你的產品痛點比對、合併重複貼文,再依商機分數排序。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<nav className="hb-radar-intro__actions" aria-label="商機雷達導覽">
|
|
|
|
|
|
<Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">管理每日巡邏</Link>
|
|
|
|
|
|
<Link className="hb-btn hb-btn--ghost" to="/app/radar/opportunities">查看全部結果</Link>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="hb-radar-filter-panel" aria-label="篩選今日商機">
|
|
|
|
|
|
<div className="hb-radar-filter-panel__head">
|
|
|
|
|
|
<strong>篩選今日商機</strong>
|
|
|
|
|
|
<span>先看全部;結果多時再縮小到品牌或產品。</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="hb-radar-filter-grid">
|
|
|
|
|
|
{brands.length ? <Select name="radar-filter-brand" label="品牌" 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="">全部品牌</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="產品" value={filterProduct} onChange={(e) => { setFilterProduct(e.target.value); updateFilterParam("product_id", e.target.value); }}><option value="">全部產品</option>{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}</Select> : null}
|
|
|
|
|
|
<Select name="radar-filter-fit" label="產品適配" value={filterBand} onChange={(e) => { setFilterBand(e.target.value); updateFilterParam("fit_band", e.target.value); }}><option value="">全部適配</option><option value="strong">高適配</option><option value="possible">可能</option><option value="weak">弱適配</option></Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hb-radar-utility-actions">
|
|
|
|
|
|
<span>沒有想看的貼文?</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}
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
{showSetupGuide ? (
|
|
|
|
|
|
<section className="hb-radar-setup" aria-label="第一次使用商機雷達">
|
|
|
|
|
|
<div className="hb-radar-setup__head">
|
|
|
|
|
|
<div><strong>第一次使用,照這三步就好</strong><span>完成後系統會每天自動巡邏。</span></div>
|
|
|
|
|
|
<span className="hb-radar-setup__progress">目前第 {setupStep} 步</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ol className="hb-radar-setup__steps">
|
|
|
|
|
|
<li className={setupStep === 1 ? "is-current" : setupStep > 1 ? "is-done" : ""}><span>1</span><div><strong>整理品牌與產品</strong><small>填入受眾、痛點與產品能力。</small></div><Link to="/app/brands">前往設定</Link></li>
|
|
|
|
|
|
<li className={setupStep === 2 ? "is-current" : setupStep > 2 ? "is-done" : ""}><span>2</span><div><strong>建立每日巡邏</strong><small>選產品後採用建議關鍵字。</small></div><Link to="/app/radar/watches">建立巡邏</Link></li>
|
|
|
|
|
|
<li className={setupStep === 3 ? "is-current" : ""}><span>3</span><div><strong>回來處理商機</strong><small>先看高分,再查看產品證據。</small></div></li>
|
|
|
|
|
|
</ol>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-08-03 05:52:02 +00:00
|
|
|
|
{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-13 02:22:24 +00:00
|
|
|
|
{action.label || (action.labelKey ? t(action.labelKey) : t("common.continue"))}
|
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}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|