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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user