From 04b208969da4a2ef8459f6db5b36d93505be0b2b Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Thu, 29 Jan 2026 09:42:39 +0100 Subject: [PATCH] Add Buy Box Lost detection feature with visual warning badges --- App.tsx | 22 +++++++++++- components/AdsPerformance.tsx | 11 ++++-- components/BuyBoxWarningBadge.tsx | 28 +++++++++++++++ components/ForecastView.tsx | 10 ++++-- components/WeeklyGrid.tsx | 11 ++++-- services/dataProcessor.ts | 58 +++++++++++++++++++++++++++++++ types.ts | 6 ++++ 7 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 components/BuyBoxWarningBadge.tsx diff --git a/App.tsx b/App.tsx index a2420ac..f0d593d 100644 --- a/App.tsx +++ b/App.tsx @@ -5,7 +5,7 @@ import Dashboard from './components/Dashboard'; import FilterBar from './components/FilterBar'; import AIChat from './components/AIChat'; import CrazeLogo from './components/CrazeLogo'; -import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, calculateVelocityMap, getUniqueValues, processForecastExcel } from './services/dataProcessor'; +import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel } from './services/dataProcessor'; import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData } from './types'; import { queryGemini } from './services/geminiService'; import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons'; @@ -52,6 +52,7 @@ const App: React.FC = () => { const [vendorStockMap, setVendorStockMap] = useState>(new Map()); const [cachedForecastRecords, setCachedForecastRecords] = useState([]); const [lastForecastFile, setLastForecastFile] = useState(null); + const [buyBoxLostMap, setBuyBoxLostMap] = useState }>>(new Map()); // Modal State const [isDataModalOpen, setIsDataModalOpen] = useState(false); @@ -197,6 +198,21 @@ const App: React.FC = () => { } }, []); + const handleBuyBoxFetch = useCallback(async () => { + try { + console.log('[App] Fetching Buy Box data from /Buy_Box_tracker.xlsx...'); + const response = await fetch('/Buy_Box_tracker.xlsx'); + if (!response.ok) throw new Error(`Failed to fetch Buy Box data: ${response.status}`); + + const buffer = await response.arrayBuffer(); + const data = await processBuyBoxExcel(buffer); + setBuyBoxLostMap(data); + console.log('[App] Successfully loaded Buy Box lost data for', data.size, 'ASINs'); + } catch (error) { + console.error("Failed to fetch/parse Buy Box data", error); + } + }, []); + const handleForecastFetch = useCallback(async (sales: SalesRecord[], globalSales: SalesRecord[], activeFilters: FilterState) => { try { @@ -336,6 +352,7 @@ const App: React.FC = () => { console.log("Fetching Stock data..."); handleStockFetch(); handleVendorStockFetch(); + handleBuyBoxFetch(); // 1e. Fetch Forecast data handleForecastFetch(cachedData || [], cachedData || [], filters); @@ -694,6 +711,7 @@ const App: React.FC = () => { top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} top50Ranking={top50Ranking2025} velocityMap={velocityMap} + buyBoxLostMap={buyBoxLostMap} /> @@ -719,6 +737,7 @@ const App: React.FC = () => { wocFilter={filters.woc} onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} + buyBoxLostMap={buyBoxLostMap} /> @@ -738,6 +757,7 @@ const App: React.FC = () => { wocFilter={filters.woc} onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} + buyBoxLostMap={buyBoxLostMap} /> diff --git a/components/AdsPerformance.tsx b/components/AdsPerformance.tsx index 0a5f863..99f6545 100644 --- a/components/AdsPerformance.tsx +++ b/components/AdsPerformance.tsx @@ -5,6 +5,7 @@ import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons'; import { StockBadge } from './StockBadge'; import { Top50Badge } from './Top50Badge'; import { VendorStockBadge } from './VendorStockBadge'; +import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; import { InColumnStockFilter } from './InColumnStockFilter'; import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons'; @@ -24,6 +25,7 @@ interface AdsPerformanceProps { onVendorStockFilterChange: (newFilters: string[]) => void; wocFilter: string[]; onWocFilterChange: (newFilters: string[]) => void; + buyBoxLostMap?: Map }>; } type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits'; @@ -45,7 +47,8 @@ const AdsRow: React.FC<{ top50Mode: 'eu' | 'uk'; stockMap?: Map; vendorStockMap?: Map; -}> = React.memo(({ item, top50Ranking, top50Mode, stockMap, vendorStockMap }) => { + buyBoxLostMap?: Map }>; +}> = React.memo(({ item, top50Ranking, top50Mode, stockMap, vendorStockMap, buyBoxLostMap }) => { const asin = item.asin.trim().toUpperCase(); const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = []; @@ -78,6 +81,7 @@ const AdsRow: React.FC<{ )} + @@ -111,7 +115,8 @@ const AdsPerformance: React.FC = ({ onVendorStockFilterChange, wocFilter, onWocFilterChange, - top50Mode + top50Mode, + buyBoxLostMap }) => { const [searchTerm, setSearchTerm] = useState(''); const [showOnlyTop50, setShowOnlyTop50] = useState(false); @@ -475,7 +480,7 @@ const AdsPerformance: React.FC = ({ {filteredAndSorted.map((p) => ( - + ))} diff --git a/components/BuyBoxWarningBadge.tsx b/components/BuyBoxWarningBadge.tsx new file mode 100644 index 0000000..f5796de --- /dev/null +++ b/components/BuyBoxWarningBadge.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +interface BuyBoxWarningBadgeProps { + asin: string; + buyBoxLostMap?: Map }>; +} + +export const BuyBoxWarningBadge: React.FC = ({ asin, buyBoxLostMap }) => { + if (!buyBoxLostMap || !asin) return null; + + const data = buyBoxLostMap.get(asin.toUpperCase()); + if (!data || data.countries.length === 0) return null; + + const tooltipContent = data.countries + .map(c => `${c}: ${data.reasons[c] || 'Unknown'}`) + .join('\n'); + + return ( +
+ ⚠️ + BB Lost + {data.countries.join(', ')} +
+ ); +}; diff --git a/components/ForecastView.tsx b/components/ForecastView.tsx index 9ea6617..768a632 100644 --- a/components/ForecastView.tsx +++ b/components/ForecastView.tsx @@ -6,6 +6,7 @@ import { StockBadge } from './StockBadge'; import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons'; import { Top50Badge } from './Top50Badge'; import { VendorStockBadge } from './VendorStockBadge'; +import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; import { InColumnStockFilter } from './InColumnStockFilter'; import { PAN_EU_COUNTRIES } from '../services/dataProcessor'; import { @@ -26,6 +27,7 @@ interface ForecastViewProps { onVendorStockFilterChange: (newFilters: string[]) => void; wocFilter: string[]; onWocFilterChange: (newFilters: string[]) => void; + buyBoxLostMap?: Map }>; } const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; @@ -37,7 +39,8 @@ const ForecastRow: React.FC<{ top50Mode: 'eu' | 'uk'; stockMap?: Map; vendorStockMap?: Map; -}> = React.memo(({ item, activeMonths, top50Ranking, top50Mode, stockMap, vendorStockMap }) => { + buyBoxLostMap?: Map }>; +}> = React.memo(({ item, activeMonths, top50Ranking, top50Mode, stockMap, vendorStockMap, buyBoxLostMap }) => { const asin = item.asin.trim().toUpperCase(); const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = []; @@ -105,6 +108,7 @@ const ForecastRow: React.FC<{ )} {/* WOC Indicator preserved */} + @@ -174,7 +178,8 @@ const ForecastView: React.FC = ({ onVendorStockFilterChange, wocFilter, onWocFilterChange, - top50Mode + top50Mode, + buyBoxLostMap }) => { const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); @@ -504,6 +509,7 @@ const ForecastView: React.FC = ({ top50Mode={top50Mode} stockMap={stockMap} vendorStockMap={vendorStockMap} + buyBoxLostMap={buyBoxLostMap} /> ))} diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index 54c98b9..706f7b0 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -6,6 +6,7 @@ import { StockBadge } from './StockBadge'; import { InColumnStockFilter } from './InColumnStockFilter'; import { Top50Badge } from './Top50Badge'; import { VendorStockBadge } from './VendorStockBadge'; +import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons'; interface WeeklyGridProps { @@ -25,6 +26,8 @@ interface WeeklyGridProps { wocFilter: string[]; onWocFilterChange: (newFilters: string[]) => void; velocityMap?: Map; + buyBoxLostMap?: Map }>; + top50Mode?: 'eu' | 'uk'; } type SortConfig = { @@ -58,7 +61,8 @@ const WeeklyRow: React.FC<{ customerFilters: string[]; vendorStockMap?: Map; velocityMap?: Map; -}> = React.memo(({ row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig, renderGrowth, customerFilters, vendorStockMap, velocityMap }) => { + buyBoxLostMap?: Map }>; +}> = React.memo(({ row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig, renderGrowth, customerFilters, vendorStockMap, velocityMap, buyBoxLostMap }) => { const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = []; const asin = row.asin.trim().toUpperCase(); @@ -101,6 +105,7 @@ const WeeklyRow: React.FC<{ mode={top50Mode} avgWeeklySales={velocityMap?.get(asin)} /> + {row.line} @@ -159,7 +164,8 @@ const WeeklyGrid: React.FC = ({ onWocFilterChange, customerFilters, top50Mode, - velocityMap + velocityMap, + buyBoxLostMap }) => { // Pivot data - memoized const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]); @@ -638,6 +644,7 @@ const WeeklyGrid: React.FC = ({ renderGrowth={renderGrowth} customerFilters={customerFilters} velocityMap={velocityMap} + buyBoxLostMap={buyBoxLostMap} /> ))} {displayCount < sortedRows.length && ( diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 42d5b43..078bc93 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -1937,3 +1937,61 @@ export const processStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promi throw error; } }; + +// Sheet configuration for Buy Box tracking +const BB_SHEET_CONFIG: { sheet: string; country: string; reasonCol: number }[] = [ + { sheet: 'BB_FR', country: 'FR', reasonCol: 18 }, // Col S = index 18 + { sheet: 'BB_UK', country: 'UK', reasonCol: 9 }, // Col J = index 9 + { sheet: 'BB_DE', country: 'DE', reasonCol: 13 }, // Col N = index 13 + { sheet: 'BB_IT', country: 'IT', reasonCol: 12 }, // Col M = index 12 + { sheet: 'BB_ES', country: 'ES', reasonCol: 13 }, // Col N = index 13 +]; + +export const processBuyBoxExcel = async (fileOrBuffer: File | ArrayBuffer): Promise }>> => { + try { + const arrayBuffer = fileOrBuffer instanceof File + ? await fileOrBuffer.arrayBuffer() + : fileOrBuffer; + const workbook = XLSX.read(arrayBuffer, { type: 'array' }); + + // Map: ASIN -> { countries: [], reasons: {} } + const buyBoxMap = new Map }>(); + + for (const config of BB_SHEET_CONFIG) { + const worksheet = workbook.Sheets[config.sheet]; + if (!worksheet) { + console.warn(`[BuyBox] Sheet ${config.sheet} not found, skipping...`); + continue; + } + + const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); + + // Skip header row (index 0) + for (let i = 1; i < jsonData.length; i++) { + const row = jsonData[i]; + const asin = String(row[1] || '').trim().toUpperCase(); // Col B = index 1 + if (!asin || asin.length < 5) continue; + + const rawReason = String(row[config.reasonCol] || '').trim(); + const reason = rawReason || 'Unknown'; + + // Only add if there's actually a BB lost (non-empty reason or explicit entry) + if (!buyBoxMap.has(asin)) { + buyBoxMap.set(asin, { countries: [], reasons: {} }); + } + + const entry = buyBoxMap.get(asin)!; + if (!entry.countries.includes(config.country)) { + entry.countries.push(config.country); + } + entry.reasons[config.country] = reason; + } + } + + console.log(`[BuyBox] Processed ${buyBoxMap.size} ASINs with BB lost`); + return buyBoxMap; + } catch (error) { + console.error("Error processing Buy Box Excel:", error); + throw error; + } +}; diff --git a/types.ts b/types.ts index 1696ccc..ed1c58c 100644 --- a/types.ts +++ b/types.ts @@ -225,3 +225,9 @@ export interface ProductForecastData { avgWeeklySales: number; // Added for Weeks of Coverage monthlyData: Record; } + +export interface BuyBoxLostData { + asin: string; + countries: string[]; // ['DE', 'FR', 'IT', 'UK', 'ES'] + reasons: Record; // { DE: 'Amazon', FR: 'Unknown' } +}