mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:05:23 +02:00
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>
1051 lines
45 KiB
TypeScript
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;
|