2026-08-13 02:22:24 +00:00
|
|
|
import { useState } from "react";
|
|
|
|
|
import { Link } from "react-router-dom";
|
|
|
|
|
import type { Opportunity, OpportunityRemovalReason } from "../../domain/types";
|
2026-08-18 16:26:10 +00:00
|
|
|
import { useI18n } from "../../i18n/I18nContext";
|
2026-08-13 02:22:24 +00:00
|
|
|
import { Badge, Button, Select, Textarea } from "../ui";
|
|
|
|
|
import { formatTimeAgo } from "../../lib/time";
|
|
|
|
|
|
2026-08-18 16:26:10 +00:00
|
|
|
const REASON_KEYS = [
|
|
|
|
|
"pain_mismatch",
|
|
|
|
|
"provider_or_ad",
|
|
|
|
|
"stale",
|
|
|
|
|
"already_solved",
|
|
|
|
|
"duplicate",
|
|
|
|
|
"other",
|
|
|
|
|
] as const;
|
2026-08-13 02:22:24 +00:00
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
opportunity: Opportunity;
|
|
|
|
|
onOpen: (opportunity: Opportunity) => void;
|
|
|
|
|
onAccept: (opportunity: Opportunity) => void;
|
|
|
|
|
onComplete: (opportunity: Opportunity) => void;
|
|
|
|
|
onRemove: (opportunity: Opportunity, input: { reason: OpportunityRemovalReason; note?: string }) => void;
|
|
|
|
|
onRestore: (opportunity: Opportunity) => void;
|
|
|
|
|
busy?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete, onRemove, onRestore, busy = false }: Props) {
|
2026-08-18 16:26:10 +00:00
|
|
|
const { t } = useI18n();
|
2026-08-13 02:22:24 +00:00
|
|
|
const [removeOpen, setRemoveOpen] = useState(false);
|
|
|
|
|
const [reason, setReason] = useState<OpportunityRemovalReason>("pain_mismatch");
|
|
|
|
|
const [note, setNote] = useState("");
|
|
|
|
|
const reviewState = opportunity.review_state ?? (opportunity.status === "accepted" ? "completed" : opportunity.status === "dismissed" ? "removed" : "pending");
|
|
|
|
|
const pending = reviewState === "pending";
|
|
|
|
|
const score = opportunity.priority_score ?? opportunity.intent_score;
|
|
|
|
|
const evidence = opportunity.demand_evidence?.[0] ?? opportunity.product_matches?.[0]?.reasons?.[0]?.reason;
|
|
|
|
|
const accepted = opportunity.status === "accepted" || Boolean(opportunity.contact_id);
|
|
|
|
|
const priorityLabel = opportunity.priority_band === "high"
|
2026-08-18 16:26:10 +00:00
|
|
|
? t("radar.card.priority.high")
|
2026-08-13 02:22:24 +00:00
|
|
|
: opportunity.priority_band === "review"
|
2026-08-18 16:26:10 +00:00
|
|
|
? t("radar.card.priority.review")
|
2026-08-13 02:22:24 +00:00
|
|
|
: opportunity.priority_band === "low"
|
2026-08-18 16:26:10 +00:00
|
|
|
? t("radar.card.priority.low")
|
2026-08-13 02:22:24 +00:00
|
|
|
: opportunity.intent_band === "high"
|
2026-08-18 16:26:10 +00:00
|
|
|
? t("radar.inbox.band.high")
|
2026-08-13 02:22:24 +00:00
|
|
|
: opportunity.intent_band === "mid"
|
2026-08-18 16:26:10 +00:00
|
|
|
? t("radar.inbox.band.mid")
|
|
|
|
|
: t("radar.inbox.band.low");
|
2026-08-13 02:22:24 +00:00
|
|
|
|
|
|
|
|
function submitRemove() {
|
|
|
|
|
if (reason === "other" && !note.trim()) return;
|
|
|
|
|
onRemove(opportunity, { reason, note: note.trim() || undefined });
|
|
|
|
|
setRemoveOpen(false);
|
|
|
|
|
setNote("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<article className={`hb-opp-card hb-opp-card--${opportunity.intent_band}`} data-testid={`opportunity-card-${opportunity.id}`}>
|
|
|
|
|
<div className="hb-opp-card__meta">
|
|
|
|
|
<Badge tone={opportunity.priority_band === "high" || opportunity.intent_band === "high" ? "success" : opportunity.priority_band === "review" || opportunity.intent_band === "mid" ? "warning" : "neutral"}>
|
|
|
|
|
{priorityLabel} · {score}
|
|
|
|
|
</Badge>
|
2026-08-18 16:26:10 +00:00
|
|
|
{opportunity.primary_product_label ? <Badge tone="brand">{t("radar.card.fitProduct", { label: opportunity.primary_product_label })}</Badge> : <Badge tone="neutral">{t("radar.card.noProduct")}</Badge>}
|
|
|
|
|
<span>{t("radar.card.intent", { n: opportunity.intent_score })} · {formatTimeAgo(opportunity.posted_at)}</span>
|
2026-08-13 02:22:24 +00:00
|
|
|
</div>
|
|
|
|
|
<p className="hb-opp-card__text">{opportunity.text}</p>
|
2026-08-18 16:26:10 +00:00
|
|
|
{evidence ? <p className="hb-opp-card__evidence"><strong>{t("radar.card.why")}</strong>{evidence}</p> : null}
|
2026-08-13 02:22:24 +00:00
|
|
|
<div className="hb-opp-card__source">
|
2026-08-18 16:26:10 +00:00
|
|
|
<span>@{opportunity.author_handle || t("radar.card.unknownAuthor")}</span>
|
|
|
|
|
<a href={opportunity.permalink} target="_blank" rel="noreferrer">{t("radar.card.openOriginal")}</a>
|
2026-08-13 02:22:24 +00:00
|
|
|
</div>
|
2026-08-18 16:26:10 +00:00
|
|
|
<div className="hb-opp-card__actions" aria-label={t("radar.card.actionsAria")}>
|
|
|
|
|
{pending ? <Button type="button" variant="primary" disabled={busy} onClick={() => onComplete(opportunity)}>{t("radar.card.keep")}</Button> : null}
|
|
|
|
|
{pending ? <Button type="button" variant="danger" disabled={busy} onClick={() => setRemoveOpen((value) => !value)}>{t("radar.card.discard")}</Button> : null}
|
|
|
|
|
<Button type="button" variant="ghost" onClick={() => onOpen(opportunity)}>{t("radar.card.whyBtn")}</Button>
|
2026-08-13 02:22:24 +00:00
|
|
|
{(pending || reviewState === "completed") && !accepted ? (
|
2026-08-13 07:54:25 +00:00
|
|
|
<Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}>
|
2026-08-18 16:26:10 +00:00
|
|
|
{busy ? t("radar.card.busy") : t("radar.card.accept")}
|
2026-08-13 02:22:24 +00:00
|
|
|
</Button>
|
|
|
|
|
) : null}
|
2026-08-18 16:26:10 +00:00
|
|
|
{reviewState === "removed" ? <Button type="button" variant="secondary" disabled={busy} onClick={() => onRestore(opportunity)}>{t("radar.card.restore")}</Button> : null}
|
2026-08-13 02:22:24 +00:00
|
|
|
{reviewState === "completed" && accepted && opportunity.contact_id ? (
|
2026-08-18 16:26:10 +00:00
|
|
|
<Link className="hb-btn hb-btn--secondary" to={`/app/crm?contact=${encodeURIComponent(opportunity.contact_id)}`}>{t("radar.inbox.goCrm")}</Link>
|
2026-08-13 02:22:24 +00:00
|
|
|
) : null}
|
2026-08-18 16:26:10 +00:00
|
|
|
{reviewState === "completed" ? <Badge tone="success">{accepted ? t("radar.card.accepted") : t("radar.card.done")}</Badge> : null}
|
2026-08-13 02:22:24 +00:00
|
|
|
</div>
|
|
|
|
|
{removeOpen ? (
|
2026-08-18 16:26:10 +00:00
|
|
|
<div className="hb-opp-card__remove-form" aria-label={t("radar.card.removeAria")}>
|
|
|
|
|
<strong>{t("radar.card.removeTitle")}</strong>
|
|
|
|
|
<p className="hb-field__hint">{t("radar.card.removeHint")}</p>
|
|
|
|
|
<Select name={`remove-reason-${opportunity.id}`} label={t("radar.card.reason")} value={reason} onChange={(event) => setReason(event.target.value as OpportunityRemovalReason)}>
|
|
|
|
|
{REASON_KEYS.map((key) => <option key={key} value={key}>{t(`radar.card.reason.${key}`)}</option>)}
|
2026-08-13 02:22:24 +00:00
|
|
|
</Select>
|
2026-08-18 16:26:10 +00:00
|
|
|
{reason === "other" ? <Textarea name={`remove-note-${opportunity.id}`} label={t("radar.card.note")} value={note} onChange={(event) => setNote(event.target.value)} /> : null}
|
|
|
|
|
{reason === "duplicate" ? <p className="hb-field__hint">{t("radar.card.duplicateHint")}</p> : null}
|
2026-08-13 02:22:24 +00:00
|
|
|
<div className="hb-opp-card__actions">
|
2026-08-18 16:26:10 +00:00
|
|
|
<Button type="button" variant="danger" disabled={busy || (reason === "other" && !note.trim())} onClick={submitRemove}>{t("radar.card.confirmRemove")}</Button>
|
|
|
|
|
<Button type="button" variant="ghost" onClick={() => setRemoveOpen(false)}>{t("common.cancel")}</Button>
|
2026-08-13 02:22:24 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
}
|