Files
CrazeAnalytix/App.tsx
T
Christian Vidal WolfandQwen-Coder f93d92da0b Add complete Experiments tracking system
New Features:
- Experiments tab with full CRUD operations
- Create experiments by ASIN or product line groups
- Track pricing, advertising, content, and promotion experiments
- Performance analytics with baseline vs experiment comparison
- Visual experiment badges in Weekly Sales grid
- Experiment detail view with metrics and learnings

Technical Changes:
- Add Supabase experiments table migration
- New services/experiments.ts for CRUD + calculations
- New components: ExperimentsView, ExperimentDetail, ExperimentForm, ExperimentBadge
- Integrate experiment indicators in WeeklyGrid
- Add navigation tab (desktop + mobile)
- Type definitions in types.ts

Usage:
1. Run SQL migration in Supabase
2. Navigate to Experiments tab
3. Create experiments with ASINs, dates, hypothesis
4. View performance lift after completion
5. See active experiments marked in Weekly Sales

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-20 19:31:44 +01:00

1051 lines
45 KiB
TypeScript

import React, { useState, useMemo, useEffect, useCallback, Suspense, lazy } from 'react';
import FileUpload from './components/FileUpload';
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, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processVendorCSV } from './services/dataProcessor';
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, ActiveExperiment } from './types';
import { queryGemini } from './services/geminiService';
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
import { getActiveExperimentsForASINs } from './services/experiments';
// Lazy load heavy components for better initial performance
const DataGrid = lazy(() => import('./components/DataGrid'));
const WeeklyGrid = lazy(() => import('./components/WeeklyGrid'));
const TopMovers = lazy(() => import('./components/TopMovers'));
const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
const ForecastView = lazy(() => import('./components/ForecastView'));
const VendorDataView = lazy(() => import('./components/VendorDataView'));
const ExperimentsView = lazy(() => import('./components/ExperimentsView'));
const ExperimentDetail = lazy(() => import('./components/ExperimentDetail'));
const ExperimentForm = lazy(() => import('./components/ExperimentForm'));
// Loading fallback component
const LoadingSpinner = () => (
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
</div>
);
// New Refresh Icon
const RefreshIcon = ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-5 h-5"}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
);
// Hardcoded Permanent URL for Auto-Loading
// Using the original share link to leverage Dropbox's redirect for robust fetching.
const PERMANENT_DROPBOX_URL = "https://www.dropbox.com/scl/fi/b9zxn4z5i7sxwfakk5g5y/Amazon-Sell-Out-2023-2025.csv?rlkey=uoto6v0mm99py8nszy8ldtez8&st=pzn1zkrg&dl=0";
const PERMANENT_ADS_URL = "https://www.dropbox.com/scl/fi/wng8tep7awhvzd65amwad/Ads-Weekly.xlsx?rlkey=kcmoq8dxgibsb2eb8zz47xvyo&st=z02m4w3g&dl=0";
const App: React.FC = () => {
const [rawData, setRawData] = useState<SalesRecord[]>([]);
const [adsData, setAdsData] = useState<AdsRecord[]>([]);
const [trafficData, setTrafficData] = useState<TrafficRecord[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads' | 'forecast' | 'vendor' | 'experiments'>('dashboard');
const [forecastData, setForecastData] = useState<ProductForecastData[]>([]);
const [isChatOpen, setIsChatOpen] = useState(false);
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [stockMap, setStockMap] = useState<Map<string, number>>(new Map());
const [vendorStockMap, setVendorStockMap] = useState<Map<string, { eu: number; uk: number }>>(new Map());
const [cachedForecastRecords, setCachedForecastRecords] = useState<ForecastRecord[]>([]);
const [lastForecastFile, setLastForecastFile] = useState<string | null>(null);
const [buyBoxLostMap, setBuyBoxLostMap] = useState<Map<string, { countries: string[]; reasons: Record<string, string> }>>(new Map());
// Experiments state
const [experimentMap, setExperimentMap] = useState<Map<string, ActiveExperiment[]>>(new Map());
const [selectedExperimentId, setSelectedExperimentId] = useState<string | null>(null);
const [showCreateExperiment, setShowCreateExperiment] = useState(false);
const [preselectedAsins, setPreselectedAsins] = useState<string[]>([]);
const [preselectedMarketplace, setPreselectedMarketplace] = useState<string>('');
// Modal State
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
const [filters, setFilters] = useState<FilterState>({
customer: [],
year: [],
month: [],
line: [],
asin: [],
sku: [],
title: [],
week: [],
stock: [],
vendorStock: [],
woc: [],
bulkSearch: '',
});
const top50Mode = useMemo(() => {
const hasUK = filters.customer.includes('Amazon UK');
const hasEU = filters.customer.some(c => ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES'].includes(c));
if (hasUK) return 'uk';
return 'eu';
}, [filters.customer]);
// Calculate 4-week Sales Velocity Map (Context-Aware)
// MOVED HERE TO AVOID REFERENCE ERROR in handleForecastFetch
const velocityMap = useMemo(() => {
// Determine which dataset to use for velocity calculation based on region filter
// If 'uk', use UK data. If 'eu', use EU data.
// IMPOTANT: We do NOT filter by Week/Month here, so we get the full history for velocity calculation
const regionData = rawData.filter(r => {
if (top50Mode === 'uk') return r.customer.toLowerCase().includes('uk');
// for EU, exclude UK
return !r.customer.toLowerCase().includes('uk');
});
return calculateVelocityMap(regionData);
}, [rawData, top50Mode]);
// Handle Data Fetch (Simplified)
const handleDataFetch = useCallback(async () => {
setSyncing(true);
try {
console.log('[App] Fetching data from /api/fetch-data...');
const response = await fetch('/api/fetch-data');
if (!response.ok) {
throw new Error(`Failed to fetch CSV: ${response.status} ${response.statusText}`);
}
const csvText = await response.text();
const data = await processCSV(csvText);
await saveSalesData(data);
initializeData(data);
setActiveUrl(PERMANENT_DROPBOX_URL);
const now = new Date().toISOString();
setLastUpdated(now);
localStorage.setItem('craze_last_updated', now);
localStorage.setItem('craze_csv_url', PERMANENT_DROPBOX_URL);
setIsDataModalOpen(false);
console.log('[App] Successfully loaded', data.length, 'rows');
// Refresh forecast too
handleForecastFetch(data, data, filters);
} catch (error) {
console.error("Failed to fetch/parse CSV", error);
alert("Error loading data. Please refresh the page.");
throw error;
} finally {
setSyncing(false);
setLoading(false);
}
}, []);
const handleAdsFetch = useCallback(async () => {
setSyncing(true);
try {
console.log('[App] Fetching ads from /api/fetch-ads...');
const response = await fetch('/api/fetch-ads');
if (!response.ok) {
throw new Error(`Failed to fetch Ads: ${response.status} ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
const data = await processAdsExcel(buffer);
await saveAdsData(data);
setAdsData(data);
console.log('[App] Successfully loaded', data.length, 'ads records');
} catch (error) {
console.error("Failed to fetch/parse Ads", error);
} finally {
setSyncing(false);
}
}, []);
const handleTrafficFetch = useCallback(async () => {
try {
console.log('[App] Fetching traffic from /api/fetch-traffic...');
const response = await fetch('/api/fetch-traffic');
if (!response.ok) throw new Error(`Failed to fetch traffic: ${response.status}`);
const buffer = await response.arrayBuffer();
const data = await processTrafficExcel(buffer);
setTrafficData(data);
console.log('[App] Successfully loaded', data.length, 'traffic records');
} catch (error) {
console.error("Failed to fetch/parse Traffic", error);
}
}, []);
const handleStockFetch = useCallback(async () => {
try {
console.log('[App] Fetching stock from /api/fetch-stock...');
const response = await fetch('/api/fetch-stock');
if (!response.ok) throw new Error(`Failed to fetch stock: ${response.status}`);
const buffer = await response.arrayBuffer();
const data = await processStockExcel(buffer);
setStockMap(data);
console.log('[App] Successfully loaded stock for', data.size, 'normalized SKUs');
} catch (error) {
console.error("Failed to fetch/parse Stock", error);
}
}, []);
const handleVendorStockFetch = useCallback(async () => {
try {
console.log('[App] Fetching vendor stock (PANEU + UK) from Dropbox...');
// Fetch both PANEU and UK inventory in parallel
const [paneuResponse, ukResponse] = await Promise.all([
fetch('/api/fetch-paneu-stock'),
fetch('/api/fetch-uk-inventory')
]);
let combinedMap = new Map<string, { eu: number; uk: number }>();
// Process PANEU stock (EU countries)
if (paneuResponse.ok) {
const paneuBuffer = await paneuResponse.arrayBuffer();
combinedMap = await processVendorStockExcel(paneuBuffer);
console.log('[App] PANEU stock loaded:', combinedMap.size, 'ASINs');
} else {
console.warn('[App] PANEU fetch failed:', paneuResponse.status);
}
// Process UK inventory and merge with existing map
if (ukResponse.ok) {
const ukBuffer = await ukResponse.arrayBuffer();
combinedMap = await processUKInventoryExcel(ukBuffer, combinedMap);
console.log('[App] UK inventory merged. Total ASINs:', combinedMap.size);
} else {
console.warn('[App] UK inventory fetch failed:', ukResponse.status);
}
setVendorStockMap(combinedMap);
console.log('[App] Successfully loaded combined vendor stock for', combinedMap.size, 'ASINs');
} catch (error) {
console.error("Failed to fetch/parse Vendor Stock", error);
}
}, []);
const handleBuyBoxFetch = useCallback(async () => {
try {
console.log('[App] Fetching Buy Box data from /api/fetch-buybox...');
const response = await fetch('/api/fetch-buybox');
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);
(window as any).debugBuyBox = data;
console.log('[App] Successfully loaded Buy Box lost data for', data.size, 'ASINs. Access via window.debugBuyBox');
} catch (error) {
console.error("Failed to fetch/parse Buy Box data", error);
}
}, []);
const handleExperimentsFetch = useCallback(async () => {
try {
const allAsins = Array.from(new Set(rawData.map(r => r.asin.toUpperCase())));
const expMap = await getActiveExperimentsForASINs(allAsins);
setExperimentMap(expMap);
console.log('[App] Loaded experiments for', expMap.size, 'ASINs');
} catch (error) {
console.error("Failed to fetch experiments", error);
}
}, [rawData]);
const handleForecastFetch = useCallback(async (sales: SalesRecord[], globalSales: SalesRecord[], activeFilters: FilterState) => {
try {
const isUK = activeFilters.customer.includes('Amazon UK');
const filename = isUK ? '/fc UK 26.xlsx' : '/fc 26.xlsx';
let fcRecords = cachedForecastRecords;
if (lastForecastFile !== filename || cachedForecastRecords.length === 0) {
console.log(`[App] Fetching fresh forecast from ${filename}...`);
const response = await fetch(filename);
if (!response.ok) throw new Error(`Forecast file ${filename} not found`);
const buffer = await response.arrayBuffer();
fcRecords = await processForecastExcel(buffer);
setCachedForecastRecords(fcRecords);
setLastForecastFile(filename);
} else {
console.log(`[App] Using cached forecast for ${filename}`);
}
// Build precise metadata map from GLOBAL sales records to ensure info is found even if filtered for market
const meta = new Map<string, { sku: string; title: string; line: string }>();
const metaDataSource = globalSales.length > 0 ? globalSales : sales;
metaDataSource.forEach(r => {
const asin = r.asin.trim().toUpperCase();
const existing = meta.get(asin);
if (!existing || (r.title && r.title.length > (existing.title?.length || 0))) {
meta.set(asin, { sku: r.sku, title: r.title, line: r.line });
}
});
const viewData = calculateForecastViewData(sales, fcRecords, meta, activeFilters, velocityMap, globalSales.length > 0 ? globalSales : undefined);
setForecastData(viewData);
console.log('[App] Forecast calculation complete:', viewData.length, 'records');
} catch (error) {
console.warn("Forecast fetch failed:", error);
}
}, [velocityMap, cachedForecastRecords, lastForecastFile]);
// Update forecast when filters change
useEffect(() => {
if (rawData.length > 0) {
// Filter rawData to respect customer selections but keep all years (2025 for seasonality, 2026 for actuals)
const forecastRelevantData = filterData(rawData, {
...filters,
year: [], // Ensure we don't filter out 2025/2026 if a single year is selected in UI
month: [] // EXCLUDE month filtering to preserve seasonality calculation in calculateForecastViewData
});
handleForecastFetch(forecastRelevantData, rawData, filters);
}
}, [filters, rawData, handleForecastFetch]);
const initializeData = (data: SalesRecord[]) => {
setRawData(data);
setFilters({
customer: [],
year: [],
month: [],
line: [],
asin: [],
sku: [],
title: [],
week: [],
stock: [],
vendorStock: [],
woc: [],
});
};
const handleSkuDrillDown = useCallback((sku: string) => {
if (!sku) return;
setFilters(prev => ({
...prev,
sku: [sku]
}));
setView('ads');
}, []);
// 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL
useEffect(() => {
const initApp = async () => {
setLoading(true);
const currentStoredUrl = localStorage.getItem('craze_csv_url');
let shouldUseCache = true;
// Force refresh if URL has changed
if (currentStoredUrl !== PERMANENT_DROPBOX_URL) {
console.log("URL changed, forcing refresh...");
localStorage.setItem('craze_csv_url', PERMANENT_DROPBOX_URL);
setActiveUrl(PERMANENT_DROPBOX_URL);
shouldUseCache = false;
}
// Try to load from cache first IF the URL hasn't changed
let cachedData = null;
let cachedDate = null;
if (shouldUseCache) {
const result = await loadSalesData();
cachedData = result.data;
cachedDate = result.lastUpdated;
}
if (cachedData && cachedData.length > 0) {
console.log("Loaded data from cache:", cachedData.length, "rows");
initializeData(cachedData);
setLastUpdated(cachedDate);
setLoading(false);
// Trigger background sync to ensure data is fresh
handleDataFetch().catch(e => console.warn("Background sales sync failed", e));
} else {
console.log("Fetching fresh data from Permanent URL...");
handleDataFetch().catch(e => {
console.error("Initial fetch failed.");
});
}
// 1b. Load Ads from cache or auto-fetch
const { data: cachedAds, lastUpdated: adsLastUpdated } = await loadAdsData();
if (cachedAds && cachedAds.length > 0) {
console.log("Loaded ads from cache:", cachedAds.length, "records");
setAdsData(cachedAds);
// Trigger background sync
handleAdsFetch();
} else {
console.log("Fetching fresh Ads from URL...");
handleAdsFetch();
}
// 1c. Always fetch Traffic data (no caching for now)
console.log("Fetching Traffic data...");
handleTrafficFetch();
// 1d. Always fetch Stock data
console.log("Fetching Stock data...");
handleStockFetch();
handleVendorStockFetch();
handleBuyBoxFetch();
// 1e. Fetch Experiments data
console.log("Fetching Experiments data...");
handleExperimentsFetch();
// 1f. Fetch Forecast data
handleForecastFetch(cachedData || [], cachedData || [], filters);
};
initApp();
}, [handleDataFetch]);
// Handle uploaded Sales file (Manual)
const handleSalesUpload = async (file: File) => {
setSyncing(true);
try {
const data = await processCSV(file);
await saveSalesData(data);
initializeData(data);
setLastUpdated(new Date().toISOString());
setIsDataModalOpen(false);
// Also trigger ads fetch if not loaded to maintain sync
if (adsData.length === 0) handleAdsFetch();
} catch (error) {
console.error("Failed to parse CSV", error);
alert("Error parsing CSV. Please check the format.");
} finally {
setSyncing(false);
}
};
// Handle uploaded Ads file (Manual) - Supports both CSV and Excel
const handleAdsUpload = async (file: File) => {
setSyncing(true);
try {
const isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls');
const data = isExcel ? await processAdsExcel(file) : await processAdsCSV(file);
setAdsData(data);
await saveAdsData(data);
console.log("Ads loaded:", data.length, "records from", file.name);
setIsDataModalOpen(false);
} catch (error: any) {
console.error("Failed to parse Ads file", error);
alert(`Error parsing Ads file: ${error.message || 'Unknown error'}. Please check the format.`);
} finally {
setSyncing(false);
}
};
// Handle uploaded Traffic file (Manual)
const handleTrafficUpload = async (file: File) => {
setSyncing(true);
try {
const data = await processTrafficExcel(file);
setTrafficData(data);
console.log("Traffic loaded:", data.length, "records from", file.name);
setIsDataModalOpen(false);
} catch (error: any) {
console.error("Failed to parse Traffic file", error);
alert(`Error parsing Traffic file: ${error.message || 'Unknown error'}. Please check the format.`);
} finally {
setSyncing(false);
}
};
// Handle uploaded Vendor CSV (sends to Supabase via API with batching)
const handleVendorUpload = async (file: File) => {
setSyncing(true);
try {
console.log("[App] Parsing Vendor CSV on client...");
const rows = await processVendorCSV(file);
if (rows.length === 0) {
alert("No valid rows found in CSV.");
return;
}
console.log(`[App] Uploading ${rows.length} rows in batches...`);
const BATCH_SIZE = 500;
let totalUpserted = 0;
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
const batch = rows.slice(i, i + BATCH_SIZE);
const response = await fetch('/api/upload-vendor-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batch),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || `Batch ${i / BATCH_SIZE + 1} failed`);
totalUpserted += result.rowsUpserted;
console.log(`[App] Batch ${Math.floor(i / BATCH_SIZE) + 1} complete. Total: ${totalUpserted}`);
}
alert(`Vendor data uploaded: ${totalUpserted} rows processed successfully.`);
setIsDataModalOpen(false);
} catch (error: any) {
console.error("Failed to upload vendor data", error);
alert(`Error uploading vendor data: ${error.message}`);
} finally {
setSyncing(false);
}
};
// 2. Schedule Auto-Refresh (Background)
useEffect(() => {
const checkAndRefresh = () => {
const now = new Date();
const today = now.toISOString().split('T')[0]; // YYYY-MM-DD
const lastRefreshDate = localStorage.getItem('craze_last_refresh_date');
// Refresh if it's after 7 AM and we haven't refreshed today
if (now.getHours() >= 7 && lastRefreshDate !== today) {
console.log("Triggering daily data refresh...");
handleDataFetch().then(() => {
localStorage.setItem('craze_last_refresh_date', today);
console.log("Daily refresh successful.");
// Also refresh ads
handleAdsFetch();
}).catch(err => {
console.error("Daily refresh failed, will retry later.", err);
});
}
};
// Check immediately on load in case the user opens the app after 7 AM
checkAndRefresh();
// And then check periodically (e.g., every 15 minutes) in case app is left open across midnight
const interval = setInterval(checkAndRefresh, 15 * 60 * 1000);
return () => clearInterval(interval);
}, [handleDataFetch]);
// Derive Data
const globalAsinMetadata = useMemo(() => {
const metaMap = new Map<string, { sku: string; title: string; line: string }>();
rawData.forEach(r => {
const asin = r.asin.trim().toUpperCase();
const existing = metaMap.get(asin);
// Keep most complete title
if (!existing || (r.title && r.title.length > (existing.title?.length || 0))) {
metaMap.set(asin, { sku: r.sku, title: r.title, line: r.line });
}
});
return metaMap;
}, [rawData]);
const filteredData = useMemo(() => filterData(rawData, filters, stockMap, vendorStockMap, top50Mode), [rawData, filters, stockMap, vendorStockMap, top50Mode]);
const filteredAdsData = useMemo(() => filterAdsData(adsData, filters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode), [adsData, filters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode]);
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
// Calculate country-aware Top 50 Best Sellers for 2025
const top50Ranking2025 = useMemo(() => {
// Filter only 2025 data
const data2025 = rawData.filter(r => r.year === 2025);
const calculateTop50 = (entries: SalesRecord[]) => {
const asinTotals = new Map<string, number>();
entries.forEach(r => {
const asin = r.asin.trim().toUpperCase();
asinTotals.set(asin, (asinTotals.get(asin) || 0) + r.sellOut);
});
const sorted = Array.from(asinTotals.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 50);
const rankMap = new Map<string, number>();
sorted.forEach(([asin], index) => {
rankMap.set(asin, index + 1);
});
return rankMap;
};
const euData = data2025.filter(r => !r.customer.toLowerCase().includes('uk'));
const ukData = data2025.filter(r => r.customer.toLowerCase().includes('uk'));
return {
eu: calculateTop50(euData),
uk: calculateTop50(ukData)
};
}, [rawData]);
// Determine which dataset to use for velocity calculation based on top50Mode
// Combine Sales & Ads Data dynamically based on current filters
const combinedAdsData = useMemo(() => {
return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap);
}, [filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap]);
// Derived Data for Grid (YTD Filtered - Isolated)
// 1. Identify "Current Year" max week
const gridContext = useMemo(() => {
if (rawData.length === 0) return { minWeek: 1, maxWeek: 53, currentYear: new Date().getFullYear() };
const currentYear = Math.max(...rawData.map(r => r.year));
const currentYearData = rawData.filter(r => r.year === currentYear);
const maxWeek = Math.max(...currentYearData.map(r => r.week).filter(Boolean));
return { minWeek: 1, maxWeek: maxWeek || 53, currentYear };
}, [rawData]);
const ytdGridData = useMemo(() => {
// Smart YTD Logic:
// Only apply the "Max Week" cutoff if the user has NOT explicitly selected a specific time period.
// If Month or Week filters are active, we show exactly what was asked (e.g., Full Year 2024).
// If no time filters are active, we default to YTD (Like-for-Like) comparison.
const isTimeFilterActive = filters.month.length > 0 || filters.week.length > 0;
if (!combinedAdsData) return [];
if (isTimeFilterActive) {
return combinedAdsData;
}
return combinedAdsData.filter(item => {
if (!item.week) return true;
return item.week <= gridContext.maxWeek;
});
}, [combinedAdsData, gridContext.maxWeek, filters.month, filters.week]);
// Derived Data for Dashboard (YTD Filtered - Isolated)
// Dashboard needs aggregated data respecting the max week limit of current year.
// Dashboard needs aggregated data respecting the max week limit of current year...
// UNLESS the user explicitly filters for a period.
const ytdFilteredData = useMemo(() => {
const isTimeFilterActive = filters.month.length > 0 || filters.week.length > 0;
if (isTimeFilterActive) return filteredData;
return filteredData.filter(r => r.week <= gridContext.maxWeek);
}, [filteredData, gridContext.maxWeek, filters.month, filters.week]);
const ytdFilteredAdsData = useMemo(() => {
const isTimeFilterActive = filters.month.length > 0 || filters.week.length > 0;
if (isTimeFilterActive) return filteredAdsData;
return filteredAdsData.filter(r => r.week <= gridContext.maxWeek);
}, [filteredAdsData, gridContext.maxWeek, filters.month, filters.week]);
const ytdAggregatedData = useMemo(() => {
return aggregateData(ytdFilteredData);
}, [ytdFilteredData]);
// Derived Data for Views
const years = useMemo(() => getUniqueValues(rawData, 'year').sort().reverse(), [rawData]);
// Derive Context Data (Product Line Context when drilling down)
const contextAggregatedData = useMemo(() => {
// Check if we are filtering by specific items (SKU, ASIN, Title)
const hasItemFilters = filters.sku.length > 0 || filters.asin.length > 0 || filters.title.length > 0;
if (!hasItemFilters || filteredData.length === 0) {
return null;
}
// 1. Identify the Product Lines associated with the currently filtered items
const activeLines = Array.from(new Set(filteredData.map(r => r.line)));
// 2. Create a "broad" filter: Keep Year/Customer/Month, but CLEAR Item filters, and restrict to these Lines
const contextFilters: FilterState = {
...filters,
line: activeLines, // Force these lines
sku: [], // Clear specific item filters
asin: [],
title: [],
stock: []
};
// 3. Process this broader dataset
const broadData = filterData(rawData, contextFilters);
return aggregateData(broadData);
}, [rawData, filters, filteredData]);
// Derive Options for Filter Dropdowns
const filterOptions = useMemo(() => {
return {
customer: getUniqueValues(rawData, 'customer'),
year: getUniqueValues(rawData, 'year'),
month: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
line: getUniqueValues(rawData, 'line'),
asin: getUniqueValues(rawData, 'asin'),
sku: getUniqueValues(rawData, 'sku'),
title: getUniqueValues(rawData, 'title'),
week: Array.from(new Set(rawData.map(r => r.week).filter(w => w !== undefined))).sort((a, b) => (a as number) - (b as number)).map(w => `W${w}`),
stock: [
'Out of Stock (0)',
'In Stock (>0)',
'Low Stock (<10)',
...Array.from(new Set(Array.from(stockMap.values()).map(String))).sort((a, b) => parseFloat(a) - parseFloat(b))
]
};
}, [rawData, stockMap]);
const handleFilterChange = (key: keyof FilterState, value: string[]) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
const handleAskGemini = async (text: string) => {
return await queryGemini(text, aggregatedData, filteredData.length);
};
const disconnectUrl = async () => {
// Allow disconnecting to clear data, but the app will likely re-connect on next reload due to "Permanent" requirement
localStorage.removeItem('craze_csv_url');
await clearSalesData();
await clearAdsData();
setActiveUrl(null);
setRawData([]);
setAdsData([]);
};
return (
<div className="min-h-screen flex flex-col bg-background text-slate-200">
{/* Header */}
<header className="bg-slate-950/90 backdrop-blur border-b border-border py-2 px-3 md:py-4 md:px-6 relative z-40 shadow-2xl">
<div className="max-w-7xl mx-auto flex justify-between items-center">
<div className="flex items-center gap-3 md:gap-8">
{/* Logo Container - Smaller on mobile */}
<div className="h-10 w-32 md:h-16 md:w-64 relative flex-shrink-0">
<CrazeLogo />
</div>
{/* Title & Status */}
<div className="hidden lg:block border-l border-slate-700 pl-6">
<h1 className="text-xl font-bold tracking-tight text-white text-shadow-sm">Analytics Dashboard</h1>
<div className="flex items-center gap-3 mt-1.5">
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full">
<span className={`w-1.5 h-1.5 rounded-full bg-emerald-400 ${syncing ? 'animate-ping' : ''}`}></span>
<span className="text-[9px] text-emerald-400 font-black uppercase tracking-wider">Live Sync</span>
</div>
{lastUpdated && (
<span className="text-[9px] text-slate-500 font-bold uppercase tracking-wider">
Last: {new Date(lastUpdated).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3 md:gap-6">
{/* Data Source Settings */}
<button
onClick={() => setIsDataModalOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 rounded-xl text-sm font-black uppercase tracking-widest transition-all bg-slate-900 text-slate-300 border border-border hover:border-emerald-500/50 hover:text-white shadow-lg active:scale-95"
title="Upload Data"
>
<UploadIcon />
<span className="hidden sm:inline">Upload</span>
</button>
{/* Force Sync Action */}
<button
onClick={() => { handleDataFetch(); handleAdsFetch(); handleTrafficFetch(); handleVendorStockFetch(); handleBuyBoxFetch(); }}
disabled={syncing}
className={`flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 rounded-xl text-sm font-black uppercase tracking-widest transition-all
${syncing
? 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/30'
: 'bg-slate-900 text-slate-300 border border-border hover:border-indigo-500/50 hover:text-white shadow-lg active:scale-95'}`}
>
<RefreshIcon className={`w-4 h-4 ${syncing ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">{syncing ? 'Syncing...' : 'Sync Now'}</span>
</button>
{/* View Switcher - Hidden on mobile (shown in bottom nav instead) */}
<div className="hidden md:flex bg-slate-900 rounded-lg p-1 border border-border">
<button
onClick={() => setView('dashboard')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'dashboard' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<ChartIcon /> <span className="hidden lg:inline">Dashboard</span>
</button>
<button
onClick={() => setView('table')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'table' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<TableIcon /> <span className="hidden lg:inline">Grid</span>
</button>
<button
onClick={() => setView('weekly')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'weekly' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<TrendingIcon /> <span className="hidden lg:inline">Weekly Sales</span>
</button>
<button
onClick={() => setView('movers')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'movers' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<TrendingIcon /> <span className="hidden lg:inline">Movers</span>
</button>
<button
onClick={() => setView('ads')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'ads' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<MegaphoneIcon /> <span className="hidden lg:inline">Ads</span>
</button>
<button
onClick={() => setView('forecast')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'forecast' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<ChartIcon /> <span className="hidden lg:inline">Fc 26</span>
</button>
<button
onClick={() => setView('vendor')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'vendor' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<ChartIcon /> <span className="hidden lg:inline">Vendor</span>
</button>
<button
onClick={() => setView('experiments')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'experiments' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<span className="text-lg">🧪</span> <span className="hidden lg:inline">Experiments</span>
</button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 relative">
{loading ? (
// Initial loading spinner
<div className="flex flex-col items-center justify-center h-[80vh] gap-4">
<div className="w-16 h-16 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<h2 className="text-xl font-bold text-slate-300">Loading Dashboard...</h2>
<p className="text-sm text-slate-500">Syncing with Dropbox...</p>
</div>
) : (
<>
<FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />
<div className="mt-6">
<div className={view === 'dashboard' ? '' : 'hidden'}>
<Dashboard data={ytdAggregatedData} filterState={filters} adsData={ytdFilteredAdsData} stockMap={stockMap} />
</div>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'table' ? '' : 'hidden'}>
<DataGrid
data={ytdGridData}
filters={filters}
hasCustomerFilter={filters.customer.length > 0}
adsData={filteredAdsData}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
top50Ranking={top50Ranking2025}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
velocityMap={velocityMap}
buyBoxLostMap={buyBoxLostMap}
defaultSort={{ key: `total_sellOut_${gridContext.currentYear}`, direction: 'desc' }}
/>
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'weekly' ? '' : 'hidden'}>
<WeeklyGrid
data={combinedAdsData}
onDrillDown={handleSkuDrillDown}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
stockFilter={filters.stock}
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
vendorStockFilter={filters.vendorStock}
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
wocFilter={filters.woc}
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
customerFilters={filters.customer}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
top50Ranking={top50Ranking2025}
velocityMap={velocityMap}
buyBoxLostMap={buyBoxLostMap}
experimentMap={experimentMap}
onOpenExperiment={(id) => setSelectedExperimentId(id)}
/>
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'movers' ? '' : 'hidden'}>
<TopMovers data={filteredData} stockMap={stockMap} />
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'ads' ? '' : 'hidden'}>
<AdsPerformance
data={combinedAdsData}
filters={filters}
top50Ranking={top50Ranking2025}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
stockFilter={filters.stock}
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
vendorStockFilter={filters.vendorStock}
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
wocFilter={filters.woc}
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
buyBoxLostMap={buyBoxLostMap}
/>
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'forecast' ? '' : 'hidden'}>
<ForecastView
data={forecastData}
filters={filters}
top50Ranking={top50Ranking2025}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
stockFilter={filters.stock}
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
vendorStockFilter={filters.vendorStock}
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
wocFilter={filters.woc}
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
buyBoxLostMap={buyBoxLostMap}
/>
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'vendor' ? '' : 'hidden'}>
<VendorDataView />
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'experiments' ? '' : 'hidden'}>
<ExperimentsView
onOpenDetail={(id) => setSelectedExperimentId(id)}
onOpenCreate={() => {
setPreselectedAsins([]);
setPreselectedMarketplace(filters.customer[0] || '');
setShowCreateExperiment(true);
}}
/>
</div>
</Suspense>
</div>
</>
)}
</main>
{/* Chat Assistant */}
<AIChat onSendMessage={handleAskGemini} isOpen={isChatOpen} setIsOpen={setIsChatOpen} />
{/* Mobile Bottom Navigation */}
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-slate-950/95 backdrop-blur border-t border-border md:hidden pb-safe">
<div className="grid grid-cols-8 gap-0">
{([
{ key: 'dashboard' as const, icon: <ChartIcon />, label: 'Home' },
{ key: 'table' as const, icon: <TableIcon />, label: 'Grid' },
{ key: 'weekly' as const, icon: <TrendingIcon />, label: 'Weekly' },
{ key: 'movers' as const, icon: <TrendingIcon />, label: 'Movers' },
{ key: 'ads' as const, icon: <MegaphoneIcon />, label: 'Ads' },
{ key: 'forecast' as const, icon: <ChartIcon />, label: 'Fc 26' },
{ key: 'vendor' as const, icon: <ChartIcon />, label: 'Vendor' },
{ key: 'experiments' as const, icon: <span className="text-lg">🧪</span>, label: 'Exp' },
]).map(({ key, icon, label }) => (
<button
key={key}
onClick={() => setView(key)}
className={`flex flex-col items-center justify-center py-2 gap-0.5 transition-colors
${view === key ? 'text-primary' : 'text-slate-500 active:text-slate-300'}`}
>
{icon}
<span className="text-[9px] font-bold uppercase tracking-wider">{label}</span>
</button>
))}
</div>
</nav>
{/* DATA MODAL */}
{
isDataModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm animate-fade-in p-4">
<div className="bg-slate-950 border border-border rounded-2xl shadow-2xl w-full max-w-lg relative overflow-hidden">
<div className="bg-slate-900 px-6 py-4 border-b border-border flex justify-between items-center">
<h2 className="text-lg font-bold text-white">Data Source Settings</h2>
<button onClick={() => setIsDataModalOpen(false)} className="text-slate-400 hover:text-white transition-colors">
<CloseIcon />
</button>
</div>
<div className="p-6">
<FileUpload
onSalesUpload={handleSalesUpload}
onAdsUpload={handleAdsUpload}
onTrafficUpload={handleTrafficUpload}
onVendorUpload={handleVendorUpload}
onUrlSubmit={handleDataFetch}
isLoading={syncing}
activeUrl={activeUrl}
onDisconnect={disconnectUrl}
lastUpdated={lastUpdated}
/>
</div>
</div>
</div>
)
}
{/* Experiment Detail Modal */}
{selectedExperimentId && (
<ExperimentDetail
experimentId={selectedExperimentId}
onClose={() => setSelectedExperimentId(null)}
salesData={rawData}
/>
)}
{/* Create Experiment Modal */}
{showCreateExperiment && (
<ExperimentForm
onClose={() => {
setShowCreateExperiment(false);
setPreselectedAsins([]);
setPreselectedMarketplace('');
}}
onSuccess={() => {
handleExperimentsFetch();
}}
initialAsins={preselectedAsins}
initialMarketplace={preselectedMarketplace}
/>
)}
</div >
);
};
export default App;