feat: add distributor research functionality with detailed candidate information and outreach options
This commit is contained in:
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(grep -v \"^$\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import index from "./web/index.html";
|
|||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import { normalizeAsin } from "./asin.ts";
|
import { normalizeAsin } from "./asin.ts";
|
||||||
import { db, client } from "./db/index.ts";
|
import { db, client } from "./db/index.ts";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
analysisRevisions,
|
analysisRevisions,
|
||||||
productDistributorResearch,
|
productDistributorResearch,
|
||||||
@@ -639,8 +640,8 @@ function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[
|
|||||||
rationale: String(item.rationale ?? "").trim(),
|
rationale: String(item.rationale ?? "").trim(),
|
||||||
confidence: clampDistributorConfidence(item.confidence),
|
confidence: clampDistributorConfidence(item.confidence),
|
||||||
reputation: String(item.reputation ?? "").trim(),
|
reputation: String(item.reputation ?? "").trim(),
|
||||||
contactInfo: String(item.contact_info ?? "").trim(),
|
contactInfo: String(item.contact_info ?? item.contactInfo ?? "").trim(),
|
||||||
outreachDraft: String(item.outreach_draft ?? "").trim(),
|
outreachDraft: String(item.outreach_draft ?? item.outreachDraft ?? "").trim(),
|
||||||
}))
|
}))
|
||||||
.filter((item) => item.name.length > 0 && item.website.length > 0)
|
.filter((item) => item.name.length > 0 && item.website.length > 0)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
@@ -674,8 +675,8 @@ async function requestClaudeDistributorCandidates(context: Record<string, unknow
|
|||||||
"For each distributor:",
|
"For each distributor:",
|
||||||
"1. Identify whether they are an official brand distributor, authorized reseller, or national wholesaler.",
|
"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.",
|
"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).",
|
"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 (3–5 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.",
|
"4. Draft a short, professional cold-outreach message (3–5 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:",
|
"Return a raw JSON array. Each object must have exactly these keys:",
|
||||||
' "name" — distributor company name',
|
' "name" — distributor company name',
|
||||||
@@ -683,8 +684,8 @@ async function requestClaudeDistributorCandidates(context: Record<string, unknow
|
|||||||
' "rationale" — why this distributor is a strong candidate (1–2 sentences)',
|
' "rationale" — why this distributor is a strong candidate (1–2 sentences)',
|
||||||
' "confidence" — integer 0–100 reflecting how confident you are this is a real authorized source',
|
' "confidence" — integer 0–100 reflecting how confident you are this is a real authorized source',
|
||||||
' "reputation" — summary of reputation findings (BBB status, years in business, any red flags)',
|
' "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)',
|
' "contact_info" — structured string with all contact details found: "Name: ..., Title: ..., Email: ..., Phone: ..., Wholesale page: ..."',
|
||||||
' "outreach_draft"— ready-to-send outreach message addressed to the contact above',
|
' "outreach_draft"— complete ready-to-send message addressed to the specific contact',
|
||||||
"",
|
"",
|
||||||
"Product context:",
|
"Product context:",
|
||||||
JSON.stringify(context, null, 2),
|
JSON.stringify(context, null, 2),
|
||||||
@@ -800,6 +801,9 @@ async function findDistributorsForStalkerProduct(runItemId: number) {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const claude = await requestClaudeDistributorCandidates(promptContext);
|
const claude = await requestClaudeDistributorCandidates(promptContext);
|
||||||
|
await db
|
||||||
|
.delete(productDistributorResearch)
|
||||||
|
.where(eq(productDistributorResearch.runItemId, runItemId));
|
||||||
const [saved] = await db
|
const [saved] = await db
|
||||||
.insert(productDistributorResearch)
|
.insert(productDistributorResearch)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
@@ -231,6 +231,9 @@ type ProductHistoryResponse = {
|
|||||||
website: string;
|
website: string;
|
||||||
rationale: string;
|
rationale: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
|
reputation: string;
|
||||||
|
contactInfo: string;
|
||||||
|
outreachDraft: string;
|
||||||
}>;
|
}>;
|
||||||
raw_response: string | null;
|
raw_response: string | null;
|
||||||
}>;
|
}>;
|
||||||
@@ -309,6 +312,21 @@ function verdictBadgeClass(verdict: string): string {
|
|||||||
return "badge badge-skip";
|
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 }) {
|
function TinyBar({ fba, fbm, skip }: { fba: number; fbm: number; skip: number }) {
|
||||||
const total = Math.max(1, fba + fbm + skip);
|
const total = Math.max(1, fba + fbm + skip);
|
||||||
const fbaPct = (fba / total) * 100;
|
const fbaPct = (fba / total) * 100;
|
||||||
@@ -1196,9 +1214,11 @@ function StalkerExplorer({
|
|||||||
function StalkerProductsExplorer({
|
function StalkerProductsExplorer({
|
||||||
onBack,
|
onBack,
|
||||||
onOpenSellers,
|
onOpenSellers,
|
||||||
|
onOpenProduct,
|
||||||
}: {
|
}: {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onOpenSellers: () => void;
|
onOpenSellers: () => void;
|
||||||
|
onOpenProduct: (asin: string, runItemId: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [results, setResults] = useState<StalkerProductsResponse | null>(null);
|
const [results, setResults] = useState<StalkerProductsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -1225,8 +1245,6 @@ function StalkerProductsExplorer({
|
|||||||
const [showSellerIdColumn, setShowSellerIdColumn] = useState(false);
|
const [showSellerIdColumn, setShowSellerIdColumn] = useState(false);
|
||||||
const [showSellerColumn, setShowSellerColumn] = useState(false);
|
const [showSellerColumn, setShowSellerColumn] = useState(false);
|
||||||
const [showCategoryColumn, setShowCategoryColumn] = 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 {
|
function buildStalkerProductParams(includePaging: boolean): URLSearchParams {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -1318,64 +1336,6 @@ function StalkerProductsExplorer({
|
|||||||
setPage(1);
|
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()}`;
|
const exportHref = `/api/stalker/products/export.xlsx?${buildStalkerProductParams(false).toString()}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1502,20 +1462,9 @@ function StalkerProductsExplorer({
|
|||||||
<td>{item.runId}</td>
|
<td>{item.runId}</td>
|
||||||
<td>{formatDate(item.last_seen_at)}</td>
|
<td>{formatDate(item.last_seen_at)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="stalker-actions">
|
<button onClick={() => onOpenProduct(item.asin, item.run_item_id ?? null)}>
|
||||||
<button
|
View details
|
||||||
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>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -1541,30 +1490,79 @@ function StalkerProductsExplorer({
|
|||||||
|
|
||||||
function ProductDetails({
|
function ProductDetails({
|
||||||
asin,
|
asin,
|
||||||
|
runItemId,
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
asin: string;
|
asin: string;
|
||||||
|
runItemId?: number | null;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [data, setData] = useState<ProductHistoryResponse | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
load();
|
||||||
fetch(`/api/products/${encodeURIComponent(asin)}`)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((payload: ProductHistoryResponse) => {
|
|
||||||
if (!cancelled) setData(payload);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [asin]);
|
}, [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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<button className="back" onClick={onBack}>Back</button>
|
<button className="back" onClick={onBack}>Back</button>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
<div className="section-header">
|
||||||
<h2>{data?.product.name ?? asin}</h2>
|
<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-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>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>
|
<div className="meta"><strong>Brand:</strong> {data?.product.brand ?? "-"}</div>
|
||||||
@@ -1593,31 +1591,62 @@ function ProductDetails({
|
|||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Distributor Research</h3>
|
<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) => (
|
{data?.distributorResearch.length ? data.distributorResearch.map((entry) => (
|
||||||
<tr key={entry.id}>
|
<div key={entry.id} className="dist-research-entry">
|
||||||
<td>{formatDate(entry.created_at)}</td>
|
<div className="dist-entry-meta">
|
||||||
<td>{entry.provider}</td>
|
<span>{formatDate(entry.created_at)}</span>
|
||||||
<td>{entry.model}</td>
|
<span className={`badge ${entry.status === "ok" ? "badge-ok" : "badge-failed"}`}>{entry.status}</span>
|
||||||
<td>{entry.status}</td>
|
{entry.run_item_id != null && <span className="dist-entry-run">Run item {entry.run_item_id}</span>}
|
||||||
<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>
|
</div>
|
||||||
)) : "-"}
|
{entry.distributors.length ? (
|
||||||
</td>
|
<div className="dist-candidates">
|
||||||
<td>{entry.run_item_id ?? "-"}</td>
|
{entry.distributors.map((d, idx) => (
|
||||||
</tr>
|
<div key={`${entry.id}-${idx}`} className="dist-candidate-card">
|
||||||
)) : <tr><td colSpan={6}>No distributor research yet</td></tr>}
|
<div className="dist-candidate-header">
|
||||||
</tbody>
|
<strong>{d.name}</strong>
|
||||||
</table>
|
<span className="badge badge-empty">{formatNumber(d.confidence)}% confidence</span>
|
||||||
</div>
|
</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>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Observations</h3>
|
<h3>Observations</h3>
|
||||||
@@ -1648,7 +1677,7 @@ type AppRoute =
|
|||||||
| { kind: "dashboard" }
|
| { kind: "dashboard" }
|
||||||
| { kind: "run"; processType: ProcessType; runId: number }
|
| { kind: "run"; processType: ProcessType; runId: number }
|
||||||
| { kind: "products"; verdict: VerdictFilter }
|
| { kind: "products"; verdict: VerdictFilter }
|
||||||
| { kind: "product"; asin: string }
|
| { kind: "product"; asin: string; runItemId?: number | null }
|
||||||
| { kind: "stalker" }
|
| { kind: "stalker" }
|
||||||
| { kind: "stalker-products" };
|
| { kind: "stalker-products" };
|
||||||
|
|
||||||
@@ -1717,6 +1746,11 @@ function App() {
|
|||||||
setRoute({ kind: "dashboard" });
|
setRoute({ kind: "dashboard" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openProduct(asin: string, runItemId: number | null) {
|
||||||
|
history.pushState({}, "", `/products/${asin}`);
|
||||||
|
setRoute({ kind: "product", asin, runItemId });
|
||||||
|
}
|
||||||
|
|
||||||
if (route.kind === "run") {
|
if (route.kind === "run") {
|
||||||
return <RunDetails processType={route.processType} runId={route.runId} onBack={backToDashboard} />;
|
return <RunDetails processType={route.processType} runId={route.runId} onBack={backToDashboard} />;
|
||||||
}
|
}
|
||||||
@@ -1726,7 +1760,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (route.kind === "product") {
|
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") {
|
if (route.kind === "stalker") {
|
||||||
@@ -1734,7 +1768,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (route.kind === "stalker-products") {
|
if (route.kind === "stalker-products") {
|
||||||
return <StalkerProductsExplorer onBack={backToDashboard} onOpenSellers={openStalker} />;
|
return <StalkerProductsExplorer onBack={backToDashboard} onOpenSellers={openStalker} onOpenProduct={openProduct} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -148,6 +148,87 @@ td {
|
|||||||
align-items: center;
|
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 {
|
th {
|
||||||
background: #fafafb;
|
background: #fafafb;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
Reference in New Issue
Block a user