feat: add distributor research functionality with detailed candidate information and outreach options

This commit is contained in:
Victor Noguera
2026-05-25 15:30:41 -04:00
parent 9b45546476
commit 313677692b
4 changed files with 243 additions and 117 deletions

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(grep -v \"^$\")"
]
}
}

View File

@@ -2,6 +2,7 @@ import index from "./web/index.html";
import * as XLSX from "xlsx";
import { normalizeAsin } from "./asin.ts";
import { db, client } from "./db/index.ts";
import { eq } from "drizzle-orm";
import {
analysisRevisions,
productDistributorResearch,
@@ -639,8 +640,8 @@ function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[
rationale: String(item.rationale ?? "").trim(),
confidence: clampDistributorConfidence(item.confidence),
reputation: String(item.reputation ?? "").trim(),
contactInfo: String(item.contact_info ?? "").trim(),
outreachDraft: String(item.outreach_draft ?? "").trim(),
contactInfo: String(item.contact_info ?? item.contactInfo ?? "").trim(),
outreachDraft: String(item.outreach_draft ?? item.outreachDraft ?? "").trim(),
}))
.filter((item) => item.name.length > 0 && item.website.length > 0)
.slice(0, 10);
@@ -674,8 +675,8 @@ async function requestClaudeDistributorCandidates(context: Record<string, unknow
"For each distributor:",
"1. Identify whether they are an official brand distributor, authorized reseller, or national wholesaler.",
"2. Investigate their reputation: check for BBB accreditation, industry tenure, any known complaints or red flags, and whether they appear on the brand's own authorized-distributor list.",
"3. Find the most direct point-of-contact for new wholesale accounts — ideally a named buyer, vendor relations team, or wholesale inquiry email/phone from their website (not a generic contact form).",
"4. Draft a short, professional cold-outreach message (35 sentences) I can send to open a wholesale account inquiry. The message should: mention the specific product or brand, state that I sell on Amazon as an FBA seller, ask about minimum order quantities and wholesale pricing, and invite them to share an application or terms sheet.",
"3. Find the most direct point-of-contact for opening a new wholesale account. Search the distributor's website for a dedicated wholesale, reseller, or new-account page. Return AS MANY of these as you can find: full name and title of the wholesale/vendor relations contact, direct email address (e.g. wholesale@..., newaccounts@..., sales@...), direct phone number, and the URL of the wholesale application or inquiry page. If a named contact is not publicly listed, return the best department email and phone. Do NOT return a generic contact form URL as the only answer.",
"4. Draft a short, professional cold-outreach message (35 sentences) I can copy-paste and send. Tone: warm, genuine, and business-oriented — the goal is to start a relationship, not close a deal. Rules: (a) Praise the brand's reputation, quality, or market position sincerely — make it specific to what this brand is known for. (b) Frame the inquiry as a mutual growth opportunity; express eagerness to carry their line and help it reach more customers. (c) Do NOT mention Amazon, FBA, or online marketplaces anywhere in the message — present yourself simply as a retailer / reseller interested in carrying their products. (d) Ask about wholesale account requirements and invite them to share terms or an application. (e) Keep it concise and human — avoid corporate filler phrases.",
"",
"Return a raw JSON array. Each object must have exactly these keys:",
' "name" — distributor company name',
@@ -683,8 +684,8 @@ async function requestClaudeDistributorCandidates(context: Record<string, unknow
' "rationale" — why this distributor is a strong candidate (12 sentences)',
' "confidence" — integer 0100 reflecting how confident you are this is a real authorized source',
' "reputation" — summary of reputation findings (BBB status, years in business, any red flags)',
' "contact_info" — best point-of-contact found (name/title, email or phone, or wholesale inquiry URL)',
' "outreach_draft"— ready-to-send outreach message addressed to the contact above',
' "contact_info" — structured string with all contact details found: "Name: ..., Title: ..., Email: ..., Phone: ..., Wholesale page: ..."',
' "outreach_draft"— complete ready-to-send message addressed to the specific contact',
"",
"Product context:",
JSON.stringify(context, null, 2),
@@ -800,6 +801,9 @@ async function findDistributorsForStalkerProduct(runItemId: number) {
})),
};
const claude = await requestClaudeDistributorCandidates(promptContext);
await db
.delete(productDistributorResearch)
.where(eq(productDistributorResearch.runItemId, runItemId));
const [saved] = await db
.insert(productDistributorResearch)
.values({

View File

@@ -231,6 +231,9 @@ type ProductHistoryResponse = {
website: string;
rationale: string;
confidence: number;
reputation: string;
contactInfo: string;
outreachDraft: string;
}>;
raw_response: string | null;
}>;
@@ -309,6 +312,21 @@ function verdictBadgeClass(verdict: string): string {
return "badge badge-skip";
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
function copy() {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
return (
<button onClick={copy} style={{ fontSize: 12, height: 26, padding: "0 8px" }}>
{copied ? "Copied!" : "Copy"}
</button>
);
}
function TinyBar({ fba, fbm, skip }: { fba: number; fbm: number; skip: number }) {
const total = Math.max(1, fba + fbm + skip);
const fbaPct = (fba / total) * 100;
@@ -1196,9 +1214,11 @@ function StalkerExplorer({
function StalkerProductsExplorer({
onBack,
onOpenSellers,
onOpenProduct,
}: {
onBack: () => void;
onOpenSellers: () => void;
onOpenProduct: (asin: string, runItemId: number | null) => void;
}) {
const [results, setResults] = useState<StalkerProductsResponse | null>(null);
const [loading, setLoading] = useState(false);
@@ -1225,8 +1245,6 @@ function StalkerProductsExplorer({
const [showSellerIdColumn, setShowSellerIdColumn] = useState(false);
const [showSellerColumn, setShowSellerColumn] = useState(false);
const [showCategoryColumn, setShowCategoryColumn] = useState(false);
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
const [findingDistributors, setFindingDistributors] = useState<Record<string, boolean>>({});
function buildStalkerProductParams(includePaging: boolean): URLSearchParams {
const params = new URLSearchParams({
@@ -1318,64 +1336,6 @@ function StalkerProductsExplorer({
setPage(1);
}
async function reanalyzeStalkerItem(item: StalkerProductItem) {
if (item.run_item_id == null) {
window.alert("No analysis item available for this row yet.");
return;
}
const key = String(item.run_item_id);
if (reanalyzing[key]) return;
setReanalyzing((prev) => ({ ...prev, [key]: true }));
try {
const response = await fetch(`/api/stalker/products/${item.run_item_id}/reanalyze?provider=claude`, {
method: "POST",
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
window.alert(payload?.error ?? "Failed to re-run analysis");
return;
}
const params = buildStalkerProductParams(true);
const res = await fetch(`/api/stalker/products?${params.toString()}`);
const payload = (await res.json()) as StalkerProductsResponse;
setResults(payload);
} finally {
setReanalyzing((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
}
async function discoverDistributors(item: StalkerProductItem) {
if (item.run_item_id == null) {
window.alert("No analysis item available for this row yet.");
return;
}
const key = String(item.run_item_id);
if (findingDistributors[key]) return;
setFindingDistributors((prev) => ({ ...prev, [key]: true }));
try {
const response = await fetch(`/api/stalker/products/${item.run_item_id}/distributors`, {
method: "POST",
});
const payload = (await response.json().catch(() => null)) as {
error?: string;
} | null;
if (!response.ok) {
window.alert(payload?.error ?? "Failed to find distributors");
return;
}
} finally {
setFindingDistributors((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
}
const exportHref = `/api/stalker/products/export.xlsx?${buildStalkerProductParams(false).toString()}`;
return (
@@ -1502,20 +1462,9 @@ function StalkerProductsExplorer({
<td>{item.runId}</td>
<td>{formatDate(item.last_seen_at)}</td>
<td>
<div className="stalker-actions">
<button
onClick={() => reanalyzeStalkerItem(item)}
disabled={item.run_item_id == null || reanalyzing[String(item.run_item_id)]}
>
{item.run_item_id != null && reanalyzing[String(item.run_item_id)] ? "Re-running..." : "Re-run analysis"}
<button onClick={() => onOpenProduct(item.asin, item.run_item_id ?? null)}>
View details
</button>
<button
onClick={() => discoverDistributors(item)}
disabled={item.run_item_id == null || findingDistributors[String(item.run_item_id)]}
>
{item.run_item_id != null && findingDistributors[String(item.run_item_id)] ? "Finding..." : "Find distributors"}
</button>
</div>
</td>
</tr>
);
@@ -1541,30 +1490,79 @@ function StalkerProductsExplorer({
function ProductDetails({
asin,
runItemId,
onBack,
}: {
asin: string;
runItemId?: number | null;
onBack: () => void;
}) {
const [data, setData] = useState<ProductHistoryResponse | null>(null);
const [reanalyzing, setReanalyzing] = useState(false);
const [findingDistributors, setFindingDistributors] = useState(false);
const effectiveRunItemId = runItemId ?? data?.distributorResearch[0]?.run_item_id ?? null;
function load() {
fetch(`/api/products/${encodeURIComponent(asin)}`)
.then((r) => r.json())
.then((payload: ProductHistoryResponse) => setData(payload));
}
useEffect(() => {
let cancelled = false;
fetch(`/api/products/${encodeURIComponent(asin)}`)
.then((response) => response.json())
.then((payload: ProductHistoryResponse) => {
if (!cancelled) setData(payload);
});
return () => {
cancelled = true;
};
load();
}, [asin]);
async function reanalyze() {
if (effectiveRunItemId == null || reanalyzing) return;
setReanalyzing(true);
try {
const res = await fetch(`/api/stalker/products/${effectiveRunItemId}/reanalyze?provider=claude`, { method: "POST" });
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
window.alert(body?.error ?? "Failed to re-run analysis");
return;
}
load();
} finally {
setReanalyzing(false);
}
}
async function discoverDistributors() {
if (effectiveRunItemId == null || findingDistributors) return;
setFindingDistributors(true);
try {
const res = await fetch(`/api/stalker/products/${effectiveRunItemId}/distributors`, { method: "POST" });
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
window.alert(body?.error ?? "Failed to find distributors");
}
} catch {
// network error or timeout — job may have completed on the server anyway
} finally {
load();
setFindingDistributors(false);
}
}
return (
<div className="page">
<button className="back" onClick={onBack}>Back</button>
<div className="card">
<div className="section-header">
<h2>{data?.product.name ?? asin}</h2>
{effectiveRunItemId != null && (
<div className="button-row">
<button onClick={reanalyze} disabled={reanalyzing}>
{reanalyzing ? "Re-running..." : "Re-run analysis"}
</button>
<button onClick={discoverDistributors} disabled={findingDistributors}>
{findingDistributors ? "Finding distributors..." : "Find distributors"}
</button>
</div>
)}
</div>
<div className="meta-grid" style={{ marginTop: 12 }}>
<div className="meta"><strong>ASIN:</strong> <a href={`https://amazon.com/dp/${asin}`} target="_blank" rel="noreferrer">{asin}</a></div>
<div className="meta"><strong>Brand:</strong> {data?.product.brand ?? "-"}</div>
@@ -1593,31 +1591,62 @@ function ProductDetails({
</div>
<div className="card">
<h3>Distributor Research</h3>
<div className="table-wrap">
<table>
<thead><tr><th>Created</th><th>Provider</th><th>Model</th><th>Status</th><th>Distributors</th><th>Run Item</th></tr></thead>
<tbody>
{data?.distributorResearch.length ? data.distributorResearch.map((entry) => (
<tr key={entry.id}>
<td>{formatDate(entry.created_at)}</td>
<td>{entry.provider}</td>
<td>{entry.model}</td>
<td>{entry.status}</td>
<td className="reason-col">
{entry.distributors.length ? entry.distributors.map((distributor, idx) => (
<div key={`${entry.id}-${idx}`} style={{ marginBottom: 8 }}>
<div><strong>{distributor.name}</strong> ({formatNumber(distributor.confidence)}%)</div>
<div><a href={distributor.website} target="_blank" rel="noreferrer">{distributor.website}</a></div>
<div>{distributor.rationale || "-"}</div>
<div key={entry.id} className="dist-research-entry">
<div className="dist-entry-meta">
<span>{formatDate(entry.created_at)}</span>
<span className={`badge ${entry.status === "ok" ? "badge-ok" : "badge-failed"}`}>{entry.status}</span>
{entry.run_item_id != null && <span className="dist-entry-run">Run item {entry.run_item_id}</span>}
</div>
)) : "-"}
</td>
<td>{entry.run_item_id ?? "-"}</td>
</tr>
)) : <tr><td colSpan={6}>No distributor research yet</td></tr>}
</tbody>
</table>
{entry.distributors.length ? (
<div className="dist-candidates">
{entry.distributors.map((d, idx) => (
<div key={`${entry.id}-${idx}`} className="dist-candidate-card">
<div className="dist-candidate-header">
<strong>{d.name}</strong>
<span className="badge badge-empty">{formatNumber(d.confidence)}% confidence</span>
</div>
<div className="dist-field">
<span className="dist-label">Website</span>
<a href={d.website} target="_blank" rel="noreferrer">{d.website}</a>
</div>
{d.rationale && (
<div className="dist-field">
<span className="dist-label">Why a candidate</span>
<span>{d.rationale}</span>
</div>
)}
{d.reputation && (
<div className="dist-field">
<span className="dist-label">Reputation</span>
<span>{d.reputation}</span>
</div>
)}
{d.contactInfo && (
<div className="dist-field">
<span className="dist-label">Point of contact</span>
<span>{d.contactInfo}</span>
</div>
)}
{d.outreachDraft && (
<div className="dist-field dist-field-block">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span className="dist-label">Outreach draft</span>
<CopyButton text={d.outreachDraft} />
</div>
<pre className="dist-outreach">{d.outreachDraft}</pre>
</div>
)}
</div>
))}
</div>
) : (
<p style={{ color: "#888", marginTop: 8 }}>No distributor candidates found in this research run.</p>
)}
</div>
)) : (
<p style={{ color: "#888", marginTop: 8 }}>No distributor research yet. Use "Find distributors" to start.</p>
)}
</div>
<div className="card">
<h3>Observations</h3>
@@ -1648,7 +1677,7 @@ type AppRoute =
| { kind: "dashboard" }
| { kind: "run"; processType: ProcessType; runId: number }
| { kind: "products"; verdict: VerdictFilter }
| { kind: "product"; asin: string }
| { kind: "product"; asin: string; runItemId?: number | null }
| { kind: "stalker" }
| { kind: "stalker-products" };
@@ -1717,6 +1746,11 @@ function App() {
setRoute({ kind: "dashboard" });
}
function openProduct(asin: string, runItemId: number | null) {
history.pushState({}, "", `/products/${asin}`);
setRoute({ kind: "product", asin, runItemId });
}
if (route.kind === "run") {
return <RunDetails processType={route.processType} runId={route.runId} onBack={backToDashboard} />;
}
@@ -1726,7 +1760,7 @@ function App() {
}
if (route.kind === "product") {
return <ProductDetails asin={route.asin} onBack={backToDashboard} />;
return <ProductDetails asin={route.asin} runItemId={route.runItemId} onBack={backToDashboard} />;
}
if (route.kind === "stalker") {
@@ -1734,7 +1768,7 @@ function App() {
}
if (route.kind === "stalker-products") {
return <StalkerProductsExplorer onBack={backToDashboard} onOpenSellers={openStalker} />;
return <StalkerProductsExplorer onBack={backToDashboard} onOpenSellers={openStalker} onOpenProduct={openProduct} />;
}
return (

View File

@@ -148,6 +148,87 @@ td {
align-items: center;
}
.dist-research-entry {
padding: 16px 0;
border-top: 1px solid #eceef0;
}
.dist-research-entry:first-child {
border-top: none;
padding-top: 8px;
}
.dist-entry-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #5f6b7a;
margin-bottom: 12px;
}
.dist-entry-run {
font-size: 12px;
color: #8a95a0;
}
.dist-candidates {
display: flex;
flex-direction: column;
gap: 12px;
}
.dist-candidate-card {
border: 1px solid #e7e8ea;
border-radius: 10px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.dist-candidate-header {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
}
.dist-field {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.5;
}
.dist-field-block {
flex-direction: column;
gap: 4px;
}
.dist-label {
font-weight: 600;
color: #445060;
white-space: nowrap;
min-width: 120px;
}
.dist-field-block .dist-label {
min-width: unset;
}
.dist-outreach {
background: #f7f8fa;
border: 1px solid #e7e8ea;
border-radius: 8px;
padding: 12px 14px;
font-family: inherit;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
margin: 0;
}
th {
background: #fafafb;
font-weight: 600;