feat: add Amazon seller and buy box share metrics to product analysis

- Introduced `amazonIsSeller` and `amazonBuyboxSharePct90d` fields in KeepaData type.
- Updated database schema and queries to store Amazon seller status and buy box share percentage.
- Enhanced product analysis results with new metrics from Keepa API.
- Modified frontend components to display Amazon seller status and buy box share percentage.
- Implemented reanalysis functionality for products to refresh Amazon-related metrics.
This commit is contained in:
Victor Noguera
2026-04-14 18:26:22 -04:00
parent 4eff4a4a2a
commit 8d6b0f9e0f
9 changed files with 1085 additions and 55 deletions

View File

@@ -61,6 +61,8 @@ type ResultItem = {
seller_count: number | null;
monthly_sold: number | null;
verdict: "FBA" | "FBM" | "SKIP";
amazon_is_seller: number | null;
amazon_buybox_share_pct_90d: number | null;
confidence: number | null;
sellability_status: string | null;
reasoning: string | null;
@@ -89,6 +91,8 @@ type ProductListItem = {
sellability_status: string | null;
monthly_sold: number | null;
seller_count: number | null;
amazon_is_seller: number | null;
amazon_buybox_share_pct_90d: number | null;
sales_rank: number | null;
current_price: number | null;
avg_price_90d: number | null;
@@ -129,6 +133,11 @@ function formatCurrency(value: number | null | undefined): string {
}).format(value);
}
function formatAmazonSeller(value: number | null | undefined): string {
if (value === null || value === undefined) return "-";
return value === 1 ? "Yes" : "No";
}
function buildSortValue(sort: SortState): string {
return `${sort.field}:${sort.direction}`;
}
@@ -426,6 +435,7 @@ function RunDetails({
const [pageSize, setPageSize] = useState(25);
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
const [refreshTick, setRefreshTick] = useState(0);
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
const anomalies = useMemo(() => {
if (!results) return [] as ResultItem[];
@@ -485,6 +495,29 @@ function RunDetails({
};
}, [processType, runId]);
async function reanalyzeAsin(asin: string) {
if (reanalyzing[asin]) return;
setReanalyzing((prev) => ({ ...prev, [asin]: true }));
try {
const response = await fetch(
`/api/runs/${processType}/${runId}/asins/${encodeURIComponent(asin)}/reanalyze`,
{ method: "POST" },
);
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
window.alert(payload?.error ?? "Failed to re-analyze ASIN");
return;
}
setRefreshTick((tick) => tick + 1);
} finally {
setReanalyzing((prev) => {
const next = { ...prev };
delete next[asin];
return next;
});
}
}
return (
<div className="page">
<button className="back" onClick={onBack}>Back</button>
@@ -565,6 +598,8 @@ function RunDetails({
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_is_seller"))}>Amazon Seller</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_buybox_share_pct_90d"))}>Amazon Buy Box 90d %</button></th>
<th><button onClick={() => setSort(nextSort(sort, "sales_rank"))}>Sales Rank</button></th>
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</button></th>
<th><button onClick={() => setSort(nextSort(sort, "product_name"))}>Product</button></th>
@@ -573,11 +608,12 @@ function RunDetails({
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
<th className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
<th>Action</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={12}>Loading...</td></tr>
<tr><td colSpan={15}>Loading...</td></tr>
) : results?.items.length ? (
results.items.map((item) => (
<tr key={`${item.asin}-${item.fetched_at}`}>
@@ -585,6 +621,8 @@ function RunDetails({
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
<td>{formatNumber(item.monthly_sold)}</td>
<td>{formatNumber(item.seller_count)}</td>
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
<td>{formatNumber(item.amazon_buybox_share_pct_90d)}</td>
<td>{formatNumber(item.sales_rank)}</td>
<td>{formatCurrency(item.current_price)}</td>
<td title={item.reasoning || undefined}>{item.product_name || "-"}</td>
@@ -593,10 +631,18 @@ function RunDetails({
<td>{formatCurrency(item.avg_price_90d)}</td>
<td>{formatNumber(item.confidence)}</td>
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</td>
<td>
<button
onClick={() => reanalyzeAsin(item.asin)}
disabled={Boolean(reanalyzing[item.asin])}
>
{reanalyzing[item.asin] ? "Re-analyzing..." : "Re-analyze"}
</button>
</td>
</tr>
))
) : (
<tr><td colSpan={12}>No results found</td></tr>
<tr><td colSpan={15}>No results found</td></tr>
)}
</tbody>
</table>
@@ -622,6 +668,7 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
useEffect(() => {
setActiveVerdict(verdict);
@@ -650,6 +697,36 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
};
}, [search, activeVerdict, page, pageSize, sort]);
async function reanalyzeAsin(item: ProductListItem) {
const key = `${item.processType}:${item.runId}:${item.asin}`;
if (reanalyzing[key]) return;
setReanalyzing((prev) => ({ ...prev, [key]: true }));
try {
const response = await fetch(
`/api/runs/${item.processType}/${item.runId}/asins/${encodeURIComponent(item.asin)}/reanalyze`,
{ method: "POST" },
);
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
window.alert(payload?.error ?? "Failed to re-analyze ASIN");
return;
}
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
params.set("sort", buildSortValue(sort));
if (search) params.set("q", search);
if (activeVerdict) params.set("verdict", activeVerdict);
const res = await fetch(`/api/products?${params.toString()}`);
const payload = (await res.json()) as ProductListResponse;
setItems(payload);
} finally {
setReanalyzing((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
}
return (
<div className="page">
<button className="back" onClick={onBack}>Back</button>
@@ -679,6 +756,8 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_is_seller"))}>Amazon Seller</button></th>
<th><button onClick={() => setSort(nextSort(sort, "amazon_buybox_share_pct_90d"))}>Amazon Buy Box 90d %</button></th>
<th><button onClick={() => setSort(nextSort(sort, "sales_rank"))}>Sales Rank</button></th>
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</button></th>
<th className="product-col"><button onClick={() => setSort(nextSort(sort, "product_name"))}>Product</button></th>
@@ -687,11 +766,12 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
<th className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
<th>Action</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={12}>Loading...</td></tr>
<tr><td colSpan={15}>Loading...</td></tr>
) : items?.items.length ? (
items.items.map((item) => (
<tr key={`${item.processType}-${item.runId}-${item.asin}-${item.fetched_at}`}>
@@ -699,6 +779,8 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
<td>{formatNumber(item.monthly_sold)}</td>
<td>{formatNumber(item.seller_count)}</td>
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
<td>{formatNumber(item.amazon_buybox_share_pct_90d)}</td>
<td>{formatNumber(item.sales_rank)}</td>
<td>{formatCurrency(item.current_price)}</td>
<td className="product-col" title={item.reasoning || undefined}>{item.product_name || "-"}</td>
@@ -707,10 +789,18 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<td>{formatCurrency(item.avg_price_90d)}</td>
<td>{formatNumber(item.confidence)}</td>
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</td>
<td>
<button
onClick={() => reanalyzeAsin(item)}
disabled={Boolean(reanalyzing[`${item.processType}:${item.runId}:${item.asin}`])}
>
{reanalyzing[`${item.processType}:${item.runId}:${item.asin}`] ? "Re-analyzing..." : "Re-analyze"}
</button>
</td>
</tr>
))
) : (
<tr><td colSpan={12}>No products found</td></tr>
<tr><td colSpan={15}>No products found</td></tr>
)}
</tbody>
</table>