feat: replace Vendor tab with BSR tracker

This commit is contained in:
Christian Vidal Wolf
2026-03-02 15:22:12 +01:00
parent 918da227ee
commit 243c44c7f7
7 changed files with 278 additions and 251 deletions
+20 -3
View File
@@ -5,8 +5,8 @@ import Dashboard from './components/Dashboard';
import FilterBar from './components/FilterBar'; import FilterBar from './components/FilterBar';
import AIChat from './components/AIChat'; import AIChat from './components/AIChat';
import CrazeLogo from './components/CrazeLogo'; import CrazeLogo from './components/CrazeLogo';
import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processVendorCSV } from './services/dataProcessor'; import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processVendorCSV, processBSRExcel } from './services/dataProcessor';
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, ActiveExperiment } from './types'; import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, ActiveExperiment, BSRRecord } from './types';
import { queryGemini } from './services/geminiService'; import { queryGemini } from './services/geminiService';
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons'; import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage'; import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
@@ -60,6 +60,7 @@ const App: React.FC = () => {
const [cachedForecastRecords, setCachedForecastRecords] = useState<ForecastRecord[]>([]); const [cachedForecastRecords, setCachedForecastRecords] = useState<ForecastRecord[]>([]);
const [lastForecastFile, setLastForecastFile] = useState<string | null>(null); const [lastForecastFile, setLastForecastFile] = useState<string | null>(null);
const [buyBoxLostMap, setBuyBoxLostMap] = useState<Map<string, { countries: string[]; reasons: Record<string, string> }>>(new Map()); const [buyBoxLostMap, setBuyBoxLostMap] = useState<Map<string, { countries: string[]; reasons: Record<string, string> }>>(new Map());
const [bsrData, setBsrData] = useState<BSRRecord[]>([]);
// Experiments state // Experiments state
const [experimentMap, setExperimentMap] = useState<Map<string, ActiveExperiment[]>>(new Map()); const [experimentMap, setExperimentMap] = useState<Map<string, ActiveExperiment[]>>(new Map());
@@ -248,6 +249,21 @@ const App: React.FC = () => {
} }
}, []); }, []);
const handleBSRFetch = useCallback(async () => {
try {
console.log('[App] Fetching BSR data from /api/fetch-bsr...');
const response = await fetch('/api/fetch-bsr');
if (!response.ok) throw new Error(`Failed to fetch BSR data: ${response.status}`);
const buffer = await response.arrayBuffer();
const data = await processBSRExcel(buffer);
setBsrData(data);
console.log('[App] Successfully loaded BSR data:', data.length, 'records');
} catch (error) {
console.error("Failed to fetch/parse BSR data", error);
}
}, []);
const handleExperimentsFetch = useCallback(async () => { const handleExperimentsFetch = useCallback(async () => {
try { try {
const expMap = await getActiveExperiments(rawData); const expMap = await getActiveExperiments(rawData);
@@ -400,7 +416,8 @@ const App: React.FC = () => {
handleStockFetch(), handleStockFetch(),
handleVendorStockFetch(), handleVendorStockFetch(),
handleBuyBoxFetch(), handleBuyBoxFetch(),
handleExperimentsFetch() handleExperimentsFetch(),
handleBSRFetch()
]).catch(e => console.warn("Background fetch failed", e)); ]).catch(e => console.warn("Background fetch failed", e));
// 1f. Fetch Forecast data // 1f. Fetch Forecast data
BIN
View File
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
import { promises as fs } from 'fs';
import path from 'path';
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
const filePath = path.join(process.cwd(), 'BSR.xlsx');
try {
await fs.access(filePath);
} catch {
return res.status(404).json({ error: 'BSR data file not found' });
}
const fileBuffer = await fs.readFile(filePath);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename="BSR.xlsx"');
res.setHeader('Cache-Control', 'public, max-age=3600');
// Convert Buffer to ArrayBuffer before sending for fetch compatibility on client
res.send(fileBuffer);
} catch (error) {
console.error('Error serving BSR Excel file:', error);
res.status(500).json({ error: 'Failed to serve BSR file' });
}
}
+165 -243
View File
@@ -1,8 +1,7 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar } from 'recharts';
import MultiSelectDropdown from './MultiSelectDropdown'; import MultiSelectDropdown from './MultiSelectDropdown';
import { fetchVendorData, fetchVendorFilterOptions, VendorDailyRow } from '../services/supabase'; import { BSRRecord } from '../types';
const MARKET_COLORS: Record<string, string> = { const MARKET_COLORS: Record<string, string> = {
DE: '#3b82f6', DE: '#3b82f6',
@@ -12,174 +11,121 @@ const MARKET_COLORS: Record<string, string> = {
ES: '#f59e0b', ES: '#f59e0b',
}; };
interface WeeklyBSR { interface VendorDataViewProps {
bsrData: BSRRecord[];
}
interface WeeklyChartPoint {
weekLabel: string; weekLabel: string;
[key: string]: number | string | null; // e.g. DE_bsr, UK_bsr [key: string]: number | string | null;
} }
interface WeeklyRating { const VendorDataView: React.FC<VendorDataViewProps> = ({ bsrData }) => {
weekLabel: string; // Filters
avg_rating: number | null;
num_reviews: number | null;
}
interface WeeklyBuyBox {
weekLabel: string;
[key: string]: number | string | null; // e.g. DE_amazon_pct
}
// Get ISO week from date string
function getISOWeek(dateStr: string): string {
const d = new Date(dateStr);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
const week1 = new Date(d.getFullYear(), 0, 4);
const weekNum = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7);
return `${d.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
}
function aggregateToWeekly(rows: VendorDailyRow[], markets: string[]) {
// Group by week
const byWeek = new Map<string, VendorDailyRow[]>();
for (const row of rows) {
const wk = getISOWeek(row.date);
if (!byWeek.has(wk)) byWeek.set(wk, []);
byWeek.get(wk)!.push(row);
}
const sortedWeeks = [...byWeek.keys()].sort();
// BSR: min rank per week per market (best rank)
const bsrData: WeeklyBSR[] = sortedWeeks.map(wk => {
const weekRows = byWeek.get(wk)!;
const point: WeeklyBSR = { weekLabel: wk.replace(/^\d{4}-/, '') };
for (const m of markets) {
const marketRows = weekRows.filter(r => r.market === m && r.bsr_detail_rank != null);
if (marketRows.length > 0) {
// Average BSR across ASINs for that market+week
const avg = marketRows.reduce((s, r) => s + r.bsr_detail_rank!, 0) / marketRows.length;
point[`${m}_bsr`] = Math.round(avg);
} else {
point[`${m}_bsr`] = null;
}
}
return point;
});
// Ratings: average across all markets/ASINs per week
const ratingData: WeeklyRating[] = sortedWeeks.map(wk => {
const weekRows = byWeek.get(wk)!;
const withRating = weekRows.filter(r => r.avg_rating != null);
const withReviews = weekRows.filter(r => r.num_reviews != null);
return {
weekLabel: wk.replace(/^\d{4}-/, ''),
avg_rating: withRating.length > 0
? Math.round((withRating.reduce((s, r) => s + r.avg_rating!, 0) / withRating.length) * 10) / 10
: null,
num_reviews: withReviews.length > 0
? Math.round(withReviews.reduce((s, r) => s + r.num_reviews!, 0) / withReviews.length)
: null,
};
});
// Buy Box: % of rows where Amazon has buybox, per market per week
const buyBoxData: WeeklyBuyBox[] = sortedWeeks.map(wk => {
const weekRows = byWeek.get(wk)!;
const point: WeeklyBuyBox = { weekLabel: wk.replace(/^\d{4}-/, '') };
for (const m of markets) {
const marketRows = weekRows.filter(r => r.market === m && r.amazon_has_buybox != null);
if (marketRows.length > 0) {
const amazonCount = marketRows.filter(r => r.amazon_has_buybox === true).length;
point[`${m}_pct`] = Math.round((amazonCount / marketRows.length) * 100);
} else {
point[`${m}_pct`] = null;
}
}
return point;
});
return { bsrData, ratingData, buyBoxData };
}
const VendorDataView: React.FC = () => {
const [vendorRows, setVendorRows] = useState<VendorDailyRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filter options
const [availableMarkets, setAvailableMarkets] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [availableYears, setAvailableYears] = useState<number[]>([]);
// Selected filters
const [selectedMarkets, setSelectedMarkets] = useState<string[]>([]); const [selectedMarkets, setSelectedMarkets] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTopCats, setSelectedTopCats] = useState<string[]>([]);
const [selectedYears, setSelectedYears] = useState<number[]>([]); const [selectedDetailCats, setSelectedDetailCats] = useState<string[]>([]);
const [asinSearch, setAsinSearch] = useState(''); const [asinSearch, setAsinSearch] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
// Load filter options on mount // Extract available filter options from the dataset
useEffect(() => { const { availableMarkets, availableTopCats, availableDetailCats } = useMemo(() => {
const loadFilters = async () => { const markets = new Set<string>();
try { const topCats = new Set<string>();
const opts = await fetchVendorFilterOptions(); const detailCats = new Set<string>();
setAvailableMarkets(opts.markets);
setAvailableTags(opts.tags);
// Extract years from date range
if (opts.dateRange) {
const minYear = new Date(opts.dateRange.min).getFullYear();
const maxYear = new Date(opts.dateRange.max).getFullYear();
const years: number[] = [];
for (let y = minYear; y <= maxYear; y++) years.push(y);
setAvailableYears(years);
}
} catch (e: any) {
console.error('Failed to load vendor filter options', e);
}
};
loadFilters();
}, []);
// Fetch data when filters change bsrData.forEach(r => {
const loadData = useCallback(async () => { if (r.market) markets.add(r.market);
setLoading(true); if (r.topLevelName) topCats.add(r.topLevelName);
setError(null); if (r.detailLevelName) detailCats.add(r.detailLevelName);
try {
const data = await fetchVendorData({
markets: selectedMarkets.length > 0 ? selectedMarkets : undefined,
tags: selectedTags.length > 0 ? selectedTags : undefined,
asins: asinSearch.trim() ? asinSearch.split(',').map(a => a.trim()).filter(Boolean) : undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
years: selectedYears.length > 0 ? selectedYears : undefined,
}); });
setVendorRows(data);
} catch (e: any) { return {
setError(e.message); availableMarkets: Array.from(markets).sort(),
} finally { availableTopCats: Array.from(topCats).sort(),
setLoading(false); availableDetailCats: Array.from(detailCats).sort()
};
}, [bsrData]);
// Apply filters
const filteredData = useMemo(() => {
let result = bsrData;
if (selectedMarkets.length > 0) {
result = result.filter(r => selectedMarkets.includes(r.market));
}
if (selectedTopCats.length > 0) {
result = result.filter(r => r.topLevelName && selectedTopCats.includes(r.topLevelName));
}
if (selectedDetailCats.length > 0) {
result = result.filter(r => r.detailLevelName && selectedDetailCats.includes(r.detailLevelName));
}
if (asinSearch.trim()) {
const terms = asinSearch.toLowerCase().split(',').map(t => t.trim()).filter(Boolean);
result = result.filter(r => terms.some(t => r.asin.toLowerCase().includes(t)));
} }
}, [selectedMarkets, selectedTags, selectedYears, asinSearch, dateFrom, dateTo]);
useEffect(() => { return result;
loadData(); }, [bsrData, selectedMarkets, selectedTopCats, selectedDetailCats, asinSearch]);
}, [loadData]);
// Determine active markets from data // Determine active markets in filtered data for series generation
const activeMarkets = useMemo(() => { const activeMarkets = useMemo(() => {
const markets = [...new Set(vendorRows.map(r => r.market))].sort(); const m = new Set(filteredData.map(r => r.market));
return markets.length > 0 ? markets : availableMarkets; return Array.from(m).sort();
}, [vendorRows, availableMarkets]); }, [filteredData]);
// Aggregate to weekly // Aggregate Data for Charts
const { bsrData, ratingData, buyBoxData } = useMemo( const { topBsrChartData, detailBsrChartData, ratingChartData } = useMemo(() => {
() => aggregateToWeekly(vendorRows, activeMarkets), const byWeek = new Map<number, BSRRecord[]>();
[vendorRows, activeMarkets] filteredData.forEach(r => {
); if (!byWeek.has(r.week)) byWeek.set(r.week, []);
byWeek.get(r.week)!.push(r);
});
// Empty state const sortedWeeks = Array.from(byWeek.keys()).sort((a, b) => a - b);
if (!loading && vendorRows.length === 0 && !error) {
const topBsrChartData: WeeklyChartPoint[] = [];
const detailBsrChartData: WeeklyChartPoint[] = [];
const ratingChartData: WeeklyChartPoint[] = [];
sortedWeeks.forEach(week => {
const row = byWeek.get(week)!;
const weekLabel = `Week ${week}`;
const topPoint: WeeklyChartPoint = { weekLabel };
const detailPoint: WeeklyChartPoint = { weekLabel };
const ratingPoint: WeeklyChartPoint = { weekLabel };
activeMarkets.forEach(m => {
const marketRows = row.filter(r => r.market === m);
// Top BSR
const topBsrRows = marketRows.filter(r => r.topLevelBSR != null);
topPoint[`${m}_bsr`] = topBsrRows.length > 0
? Math.round(topBsrRows.reduce((sum, r) => sum + r.topLevelBSR!, 0) / topBsrRows.length)
: null;
// Detail BSR
const detailBsrRows = marketRows.filter(r => r.detailLevelBSR != null);
detailPoint[`${m}_bsr`] = detailBsrRows.length > 0
? Math.round(detailBsrRows.reduce((sum, r) => sum + r.detailLevelBSR!, 0) / detailBsrRows.length)
: null;
// Rating
const ratingRows = marketRows.filter(r => r.avgRating != null);
ratingPoint[`${m}_rating`] = ratingRows.length > 0
? Math.round((ratingRows.reduce((sum, r) => sum + r.avgRating!, 0) / ratingRows.length) * 10) / 10
: null;
});
topBsrChartData.push(topPoint);
detailBsrChartData.push(detailPoint);
ratingChartData.push(ratingPoint);
});
return { topBsrChartData, detailBsrChartData, ratingChartData };
}, [filteredData, activeMarkets]);
if (bsrData.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4 text-center px-4"> <div className="flex flex-col items-center justify-center h-[60vh] gap-4 text-center px-4">
<div className="p-4 bg-emerald-500/10 rounded-full text-emerald-400"> <div className="p-4 bg-emerald-500/10 rounded-full text-emerald-400">
@@ -187,10 +133,9 @@ const VendorDataView: React.FC = () => {
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg> </svg>
</div> </div>
<h2 className="text-xl font-bold text-slate-200">No Vendor Data Yet</h2> <h2 className="text-xl font-bold text-slate-200">No BSR Data Yet</h2>
<p className="text-sm text-slate-400 max-w-md"> <p className="text-sm text-slate-400 max-w-md">
Upload a Vendor Central daily CSV to see BSR trends, ratings, and Buy Box data. Ensure BSR.xlsx is present and loading to see Top Level BSR, Detail Level BSR, and Average Rating trends.
Use the data source settings (top-right) to upload.
</p> </p>
</div> </div>
); );
@@ -207,30 +152,16 @@ const VendorDataView: React.FC = () => {
onChange={setSelectedMarkets} onChange={setSelectedMarkets}
/> />
<MultiSelectDropdown <MultiSelectDropdown
label="Product Line" label="Top Level Category"
options={availableTags} options={availableTopCats}
selected={selectedTags} selected={selectedTopCats}
onChange={setSelectedTags} onChange={setSelectedTopCats}
/> />
<MultiSelectDropdown <MultiSelectDropdown
label="Year" label="Detail Level Category"
options={availableYears.map(String)} options={availableDetailCats}
selected={selectedYears.map(String)} selected={selectedDetailCats}
onChange={(vals) => setSelectedYears(vals.map(v => parseInt(v)))} onChange={setSelectedDetailCats}
/>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
placeholder="From date"
/>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
placeholder="To date"
/> />
<input <input
type="text" type="text"
@@ -239,14 +170,12 @@ const VendorDataView: React.FC = () => {
onChange={e => setAsinSearch(e.target.value)} onChange={e => setAsinSearch(e.target.value)}
className="bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 w-64" className="bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 w-64"
/> />
{(selectedMarkets.length > 0 || selectedTags.length > 0 || selectedYears.length > 0 || dateFrom || dateTo || asinSearch) && ( {(selectedMarkets.length > 0 || selectedTopCats.length > 0 || selectedDetailCats.length > 0 || asinSearch) && (
<button <button
onClick={() => { onClick={() => {
setSelectedMarkets([]); setSelectedMarkets([]);
setSelectedTags([]); setSelectedTopCats([]);
setSelectedYears([]); setSelectedDetailCats([]);
setDateFrom('');
setDateTo('');
setAsinSearch(''); setAsinSearch('');
}} }}
className="text-xs text-slate-400 hover:text-white underline" className="text-xs text-slate-400 hover:text-white underline"
@@ -254,22 +183,15 @@ const VendorDataView: React.FC = () => {
Clear filters Clear filters
</button> </button>
)} )}
{loading && ( <span className="text-slate-500 text-xs ml-auto">{filteredData.length.toLocaleString()} records filtered</span>
<div className="flex items-center gap-2 text-slate-400 text-sm">
<div className="w-4 h-4 border-2 border-slate-600 border-t-indigo-400 rounded-full animate-spin"></div>
Loading...
</div>
)}
{error && <span className="text-red-400 text-sm">Error: {error}</span>}
<span className="text-slate-500 text-xs ml-auto">{vendorRows.length.toLocaleString()} daily records</span>
</div> </div>
{/* BSR Trend Chart */} {/* Top Level BSR Trend Chart */}
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4"> <div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4">
<h3 className="text-lg font-bold text-slate-200 mb-4">BSR Detail Category Rank (Weekly Avg)</h3> <h3 className="text-lg font-bold text-slate-200 mb-4">Top Level BSR Trend (Weekly Avg)</h3>
<p className="text-xs text-slate-500 mb-3">Lower rank = better position. Averaged across filtered ASINs per market.</p> <p className="text-xs text-slate-500 mb-3">Lower rank = better position. Averaged across filtered ASINs per market.</p>
<ResponsiveContainer width="100%" height={350}> <ResponsiveContainer width="100%" height={350}>
<LineChart data={bsrData}> <LineChart data={topBsrChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" /> <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" /> <XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
<YAxis reversed tick={{ fill: '#94a3b8', fontSize: 11 }} /> <YAxis reversed tick={{ fill: '#94a3b8', fontSize: 11 }} />
@@ -294,13 +216,42 @@ const VendorDataView: React.FC = () => {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{/* Ratings & Reviews Chart */} {/* Detail Level BSR Trend Chart */}
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4"> <div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4">
<h3 className="text-lg font-bold text-slate-200 mb-4">Average Rating & Reviews (Weekly)</h3> <h3 className="text-lg font-bold text-slate-200 mb-4">Detail Level BSR Trend (Weekly Avg)</h3>
<p className="text-xs text-slate-500 mb-3">Averaged across all filtered ASINs and markets.</p> <p className="text-xs text-slate-500 mb-3">Lower rank = better position. Averaged across filtered ASINs per market.</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <ResponsiveContainer width="100%" height={350}>
<ResponsiveContainer width="100%" height={300}> <LineChart data={detailBsrChartData}>
<LineChart data={ratingData}> <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
<YAxis reversed tick={{ fill: '#94a3b8', fontSize: 11 }} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#e2e8f0' }}
/>
<Legend />
{activeMarkets.map(m => (
<Line
key={m}
type="monotone"
dataKey={`${m}_bsr`}
name={`${m} BSR`}
stroke={MARKET_COLORS[m] || '#6b7280'}
strokeWidth={2}
dot={false}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
{/* Average Rating Chart */}
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4">
<h3 className="text-lg font-bold text-slate-200 mb-4">Average Rating (Weekly Avg)</h3>
<p className="text-xs text-slate-500 mb-3">Averaged across filtered ASINs per market.</p>
<ResponsiveContainer width="100%" height={350}>
<LineChart data={ratingChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" /> <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" /> <XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
<YAxis domain={[0, 5]} tick={{ fill: '#94a3b8', fontSize: 11 }} /> <YAxis domain={[0, 5]} tick={{ fill: '#94a3b8', fontSize: 11 }} />
@@ -308,49 +259,20 @@ const VendorDataView: React.FC = () => {
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }} contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#e2e8f0' }} labelStyle={{ color: '#e2e8f0' }}
/> />
<Line type="monotone" dataKey="avg_rating" name="Avg Rating" stroke="#f59e0b" strokeWidth={2} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={ratingData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#e2e8f0' }}
/>
<Bar dataKey="num_reviews" name="Avg Reviews" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Buy Box Chart */}
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4">
<h3 className="text-lg font-bold text-slate-200 mb-4">Amazon Buy Box Ownership (Weekly %)</h3>
<p className="text-xs text-slate-500 mb-3">Percentage of daily records where Amazon holds the Buy Box, per market.</p>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={buyBoxData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
<YAxis domain={[0, 100]} tick={{ fill: '#94a3b8', fontSize: 11 }} tickFormatter={(v) => `${v}%`} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#e2e8f0' }}
formatter={(value: number) => `${value}%`}
/>
<Legend /> <Legend />
{activeMarkets.map(m => ( {activeMarkets.map(m => (
<Bar <Line
key={m} key={m}
dataKey={`${m}_pct`} type="monotone"
name={`${m} Amazon BB %`} dataKey={`${m}_rating`}
fill={MARKET_COLORS[m] || '#6b7280'} name={`${m} Rating`}
radius={[4, 4, 0, 0]} stroke={MARKET_COLORS[m] || '#6b7280'}
strokeWidth={2}
dot={false}
connectNulls
/> />
))} ))}
</BarChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
-4
View File
@@ -10,7 +10,6 @@ import { VendorStockBadge } from './VendorStockBadge';
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons'; import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons';
import { ExcelFilter } from './ExcelFilter'; import { ExcelFilter } from './ExcelFilter';
import ExperimentTracker from './ExperimentTracker';
import { ExperimentBadge } from './ExperimentBadge'; import { ExperimentBadge } from './ExperimentBadge';
import { ActiveExperiment } from '../types'; import { ActiveExperiment } from '../types';
@@ -800,9 +799,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
return ( return (
<div className="flex flex-col gap-3 md:gap-4 animate-fade-in px-1 md:px-0"> <div className="flex flex-col gap-3 md:gap-4 animate-fade-in px-1 md:px-0">
{/* Experiment Tracker */}
<ExperimentTracker rows={rows} weeks={weeks} primaryMetric={primaryMetric} customerFilters={customerFilters} />
{/* Toolbar: Search & Pagination */} {/* Toolbar: Search & Pagination */}
<div className="flex flex-col lg:flex-row justify-between items-center gap-3 md:gap-4 bg-slate-900 border border-white/10 p-2 md:p-4 rounded-xl shadow-lg"> <div className="flex flex-col lg:flex-row justify-between items-center gap-3 md:gap-4 bg-slate-900 border border-white/10 p-2 md:p-4 rounded-xl shadow-lg">
<div className="flex flex-wrap items-center gap-4 w-full lg:w-auto"> <div className="flex flex-wrap items-center gap-4 w-full lg:w-auto">
+55 -1
View File
@@ -1,4 +1,4 @@
import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint, ForecastRecord, MonthlyForecastPoint, ProductForecastData, VendorCSVRow, VendorDailyRow } from '../types'; import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint, ForecastRecord, MonthlyForecastPoint, ProductForecastData, VendorCSVRow, VendorDailyRow, BSRRecord } from '../types';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import Papa from 'papaparse'; import Papa from 'papaparse';
@@ -657,6 +657,60 @@ export const processTrafficExcel = async (fileOrBuffer: File | ArrayBuffer): Pro
} }
}; };
// --- BSR DATA PARSING ---
export const processBSRExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<BSRRecord[]> => {
try {
const arrayBuffer = fileOrBuffer instanceof File
? await fileOrBuffer.arrayBuffer()
: fileOrBuffer;
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const allData: BSRRecord[] = [];
// Typically BSR is in the first sheet
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData: any[] = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
console.log(`Processing BSR sheet "${sheetName}": ${jsonData.length} rows`);
for (let i = 0; i < jsonData.length; i++) {
const row = jsonData[i];
const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']);
const marketRaw = getColumnValue(row, ['market', 'country', 'marketplace']);
const asinRaw = getColumnValue(row, ['asin']);
const topLevelBSRRaw = getColumnValue(row, ['Mean Weekly Top Level BSR']);
const topLevelNameRaw = getColumnValue(row, ['Top Level Category Name']);
const detailLevelBSRRaw = getColumnValue(row, ['Mean Weekly Detail Level BSR']);
const detailLevelNameRaw = getColumnValue(row, ['Detail Level Category Name']);
const avgRatingRaw = getColumnValue(row, ['Mean Weekly Average Rating']);
if (!weekRaw || !marketRaw || !asinRaw) continue;
const week = parseInt(String(weekRaw).match(/\\d+/)?.[0] || '0', 10);
if (isNaN(week) || week === 0) continue;
allData.push({
week,
market: String(marketRaw).trim(),
asin: String(asinRaw).trim(),
topLevelBSR: parseIntSafe(String(topLevelBSRRaw)),
topLevelName: topLevelNameRaw ? String(topLevelNameRaw).trim() : null,
detailLevelBSR: parseIntSafe(String(detailLevelBSRRaw)),
detailLevelName: detailLevelNameRaw ? String(detailLevelNameRaw).trim() : null,
avgRating: parseFloat(String(avgRatingRaw)) || null,
});
}
console.log(`Total BSR records loaded: ${allData.length}`);
return allData;
} catch (error) {
console.error("Error processing BSR Excel:", error);
throw error;
}
};
// --- DATA MERGING --- // --- DATA MERGING ---
export const mergeSalesAndAdsData = ( export const mergeSalesAndAdsData = (
+11
View File
@@ -280,6 +280,17 @@ export interface VendorCSVRow {
'Glance Views': string; 'Glance Views': string;
} }
export interface BSRRecord {
week: number;
market: string;
asin: string;
topLevelBSR: number | null;
topLevelName: string | null;
detailLevelBSR: number | null;
detailLevelName: string | null;
avgRating: number | null;
}
// Experiment Tracking Types // Experiment Tracking Types
export type ExperimentType = 'pricing' | 'advertising' | 'content' | 'promotion' | 'seo'; export type ExperimentType = 'pricing' | 'advertising' | 'content' | 'promotion' | 'seo';
export type ExperimentStatus = 'planned' | 'active' | 'completed' | 'paused'; export type ExperimentStatus = 'planned' | 'active' | 'completed' | 'paused';