feat: Initialize Craze Analytix project structure

Sets up the project with Vite, React, Tailwind CSS, Gemini AI integration, and necessary dependencies for data analysis. Includes initial configuration for TypeScript, Tailwind, and project metadata.
This commit is contained in:
Christian
2025-12-11 11:25:26 +01:00
parent 563e6110ce
commit 9ba63ab8f8
23 changed files with 4481 additions and 8 deletions
+416
View File
@@ -0,0 +1,416 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import FileUpload from './components/FileUpload';
import Dashboard from './components/Dashboard';
import DataGrid from './components/DataGrid';
import FilterBar from './components/FilterBar';
import AIChat from './components/AIChat';
import CrazeLogo from './components/CrazeLogo';
import TopMoversPage from './components/TopMoversPage'; // New import
import { SalesRecord, FilterState, AggregatedData } from './types';
import { processCSV, processExcel, filterData, aggregateData, getUniqueValues } from './services/dataProcessor';
import { queryGemini } from './services/geminiService';
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon } from './components/Icons';
import { loadSalesData, saveSalesData, clearSalesData } from './services/storage';
// 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>
);
// New Movers Icon
const MoversIcon = ({ 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}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18 9 11.25l4.306 4.305a11.164 11.164 0 0 0 5.814-5.815L21.75 6m0 0-3.5-3.5m3.5 3.5-3.5 3.5" />
</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&dl=0";
const App: React.FC = () => {
const [rawData, setRawData] = useState<SalesRecord[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [view, setView] = useState<'dashboard' | 'table' | 'topMovers'>('dashboard'); // Added 'topMovers'
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);
// API Key State
const [apiKey, setApiKey] = useState<string>(() => localStorage.getItem('gemini_api_key') || '');
// Modal State
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
// Filters State
const [filters, setFilters] = useState<FilterState>({
customer: [],
year: [],
month: [],
line: [],
asin: [],
sku: [],
title: [],
});
// Handle API Key Change
const handleApiKeyChange = (key: string) => {
setApiKey(key);
if (key) {
localStorage.setItem('gemini_api_key', key);
} else {
localStorage.removeItem('gemini_api_key');
}
};
// Handle URL Fetch (Auto/Manual)
const handleUrlFetch = useCallback(async (url: string) => {
setSyncing(true);
try {
let directUrl = url;
// Create a direct download link for Dropbox if it's a share link.
if (url.includes('dropbox.com/') && !url.includes('dl.dropboxusercontent.com')) {
const urlObject = new URL(url);
urlObject.searchParams.set('dl', '1');
directUrl = urlObject.toString();
}
// Use a CORS proxy to bypass browser's same-origin policy restrictions.
const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(directUrl)}`;
const response = await fetch(proxyUrl);
if (!response.ok) throw new Error(`Failed to fetch CSV from URL: ${response.status} ${response.statusText}`);
const csvText = await response.text();
const data = await processCSV(csvText);
await saveSalesData(data);
initializeData(data);
setActiveUrl(url); // Store the original user-facing URL
const now = new Date().toISOString();
setLastUpdated(now);
localStorage.setItem('craze_last_updated', now);
localStorage.setItem('craze_csv_url', url);
setIsDataModalOpen(false); // Close modal on success
} catch (error) {
console.error("Failed to fetch/parse CSV from URL", error);
// Don't alert on auto-fetch to avoid spamming the user on startup if offline
// alert("Error syncing data. Please check the URL.");
throw error; // re-throw to be caught by caller
} finally {
setSyncing(false);
setLoading(false);
}
}, []);
const initializeData = (data: SalesRecord[]) => {
setRawData(data);
setFilters({
customer: [],
year: [],
month: [],
line: [],
asin: [],
sku: [],
title: [],
});
};
// 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL
useEffect(() => {
const initApp = async () => {
setLoading(true);
const currentStoredUrl = localStorage.getItem('craze_csv_url');
if (currentStoredUrl !== PERMANENT_DROPBOX_URL) {
localStorage.setItem('craze_csv_url', PERMANENT_DROPBOX_URL);
setActiveUrl(PERMANENT_DROPBOX_URL);
}
const { data, lastUpdated: date } = await loadSalesData();
if (data && data.length > 0) {
console.log("Loaded data from cache:", data.length, "rows");
initializeData(data);
setLastUpdated(date);
setLoading(false);
} else {
console.log("No cache found. Auto-fetching from Permanent URL...");
handleUrlFetch(PERMANENT_DROPBOX_URL).catch(e => {
console.error("Initial fetch failed.");
});
}
};
initApp();
}, [handleUrlFetch]);
// Handle uploaded file (Manual)
const handleFileUpload = async (file: File) => {
setSyncing(true);
try {
let data: SalesRecord[] = [];
const lowerName = file.name.toLowerCase();
if (lowerName.endsWith('.csv')) {
data = await processCSV(file);
} else if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) {
data = await processExcel(file);
} else {
throw new Error("Unsupported file format");
}
await saveSalesData(data);
initializeData(data);
setLastUpdated(new Date().toISOString());
setIsDataModalOpen(false);
} catch (error) {
console.error("Failed to parse file", error);
alert("Error parsing file. Please check format (CSV or Excel).");
} 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...");
handleUrlFetch(PERMANENT_DROPBOX_URL).then(() => {
localStorage.setItem('craze_last_refresh_date', today);
console.log("Daily refresh successful.");
}).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);
}, [handleUrlFetch]);
// Derive Data
const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]);
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
// 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: []
};
// 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'),
};
}, [rawData]);
const handleFilterChange = (key: keyof FilterState, value: string[]) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
const handleAskGemini = async (text: string) => {
return await queryGemini(apiKey, 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();
setActiveUrl(null);
setRawData([]);
};
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-4 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-8">
{/* Logo Container (Horizontal Box) - Persistent User Image */}
<div className="h-16 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>
{activeUrl && lastUpdated && (
<p className="text-[10px] text-emerald-400 mt-1 flex items-center gap-1 uppercase font-bold tracking-wider">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
Live Sync Active
</p>
)}
</div>
</div>
<div className="flex items-center gap-4">
{/* Main Action: Data Source Button */}
<button
onClick={() => setIsDataModalOpen(true)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold shadow-lg transition-all border
${activeUrl
? 'bg-slate-800 text-emerald-400 border-emerald-500/50 hover:bg-slate-700'
: 'bg-indigo-600 text-white border-transparent hover:bg-indigo-500'}`}
>
{syncing ? <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div> : <UploadIcon />}
<span className="hidden md:inline">{activeUrl ? 'Data Settings' : 'Connect Data'}</span>
</button>
{/* NEW REFRESH BUTTON */}
<button
onClick={() => activeUrl && handleUrlFetch(activeUrl)}
disabled={syncing}
title="Refresh Data"
className="p-3 rounded-lg bg-slate-800 border border-border text-slate-400 hover:text-white hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshIcon className={`w-5 h-5 ${syncing ? 'animate-spin' : ''}`} />
</button>
{/* View Switcher */}
<div className="flex bg-slate-900 rounded-lg p-1 border border-border">
<button
onClick={() => setView('dashboard')}
className={`flex items-center gap-2 px-4 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 sm:inline">Dashboard</span>
</button>
<button
onClick={() => setView('table')}
className={`flex items-center gap-2 px-4 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 sm:inline">Data Grid</span>
</button>
<button // New Top Movers Button
onClick={() => setView('topMovers')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all
${view === 'topMovers' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<MoversIcon className="w-5 h-5" /> <span className="hidden sm:inline">Top Movers</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 is now relevant for all views including Top Movers */}
{(view === 'dashboard' || view === 'table' || view === 'topMovers') && (
<FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />
)}
<div className="mt-6">
{view === 'dashboard' ? (
<Dashboard
data={aggregatedData}
contextData={contextAggregatedData} // Pass context data
/>
) : view === 'table' ? (
<DataGrid data={filteredData} />
) : ( // New Top Movers View
<TopMoversPage
filteredData={filteredData} // Pass globally filtered data
/>
)}
</div>
</>
)}
</main>
{/* Chat Assistant - Now with API Key Props */}
<AIChat
onSendMessage={handleAskGemini}
isOpen={isChatOpen}
setIsOpen={setIsChatOpen}
apiKey={apiKey}
onApiKeyChange={handleApiKeyChange}
/>
{/* 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
onFileUpload={handleFileUpload}
onUrlSubmit={handleUrlFetch}
isLoading={syncing}
activeUrl={activeUrl}
onDisconnect={disconnectUrl}
lastUpdated={lastUpdated}
/>
</div>
</div>
</div>
)}
</div>
);
};
export default App;