mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:05:23 +02:00
feat: Add ESLint configuration and enhance data fetching with timeouts and improved error handling.
This commit is contained in:
@@ -28,8 +28,8 @@ const PERMANENT_DROPBOX_URL = "https://www.dropbox.com/scl/fi/b9zxn4z5i7sxwfakk5
|
|||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [rawData, setRawData] = useState<SalesRecord[]>([]);
|
const [rawData, setRawData] = useState<SalesRecord[]>([]);
|
||||||
const [adsData, setAdsData] = useState<AdsRecord[]>([]); // New Ads State
|
const [adsData, setAdsData] = useState<AdsRecord[]>([]); // New Ads State
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [view, setView] = useState<'dashboard' | 'table' | 'movers' | 'ads'>('dashboard'); // Added 'ads' view
|
const [view, setView] = useState<'dashboard' | 'table' | 'movers' | 'ads'>('dashboard'); // Added 'ads' view
|
||||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||||
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
|
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
|
||||||
@@ -51,84 +51,118 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
// Handle URL Fetch (Auto/Manual)
|
// Handle URL Fetch (Auto/Manual)
|
||||||
const handleUrlFetch = useCallback(async (url: string) => {
|
const handleUrlFetch = useCallback(async (url: string) => {
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
try {
|
setLoading(true);
|
||||||
let directUrl = url;
|
// Reset previous errors on new attempt
|
||||||
// Create a direct download link for Dropbox if it's a share link.
|
// setLastUpdated(null);
|
||||||
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 controller = new AbortController();
|
||||||
// This is necessary because Dropbox does not send the required CORS headers
|
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s Timeout
|
||||||
// for direct client-side fetching from another domain.
|
|
||||||
const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(directUrl)}`;
|
|
||||||
|
|
||||||
const response = await fetch(proxyUrl);
|
try {
|
||||||
if (!response.ok) throw new Error(`Failed to fetch CSV from URL: ${response.status} ${response.statusText}`);
|
console.log(`[Sync] Starting fetch from: ${url}`);
|
||||||
|
let directUrl = url;
|
||||||
const csvText = await response.text();
|
// Create a direct download link for Dropbox if it's a share link.
|
||||||
const data = await processCSV(csvText);
|
if (url.includes('dropbox.com/') && !url.includes('dl.dropboxusercontent.com')) {
|
||||||
|
const urlObject = new URL(url);
|
||||||
await saveSalesData(data);
|
urlObject.searchParams.set('dl', '1');
|
||||||
|
directUrl = urlObject.toString();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use a CORS proxy to bypass browser's same-origin policy restrictions.
|
||||||
|
const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(directUrl)}`;
|
||||||
|
|
||||||
|
console.log(`[Sync] Fetching via proxy: ${proxyUrl}`);
|
||||||
|
|
||||||
|
const response = await fetch(proxyUrl, {
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch CSV: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvText = await response.text();
|
||||||
|
console.log(`[Sync] Download complete. Bytes: ${csvText.length}`);
|
||||||
|
|
||||||
|
if (!csvText || csvText.trim().length === 0) {
|
||||||
|
throw new Error("Downloaded file is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await processCSV(csvText);
|
||||||
|
console.log(`[Sync] Processing complete. Rows: ${data.length}`);
|
||||||
|
|
||||||
|
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: any) {
|
||||||
|
console.error("[Sync] Error during data fetch:", error);
|
||||||
|
let msg = "Failed to sync data.";
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
msg = "Connection timed out (15s). Proxy might be slow.";
|
||||||
|
} else if (error.message) {
|
||||||
|
msg = error.message;
|
||||||
|
}
|
||||||
|
alert(`Error: ${msg}\n\nSwitching to manual mode.`);
|
||||||
|
// IMPORTANT: If fetch fails, we MUST stop loading so the user can interact
|
||||||
|
setRawData([]);
|
||||||
|
setActiveUrl(null); // Clear active URL so valid manual upload is required or retry
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
setSyncing(false);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const initializeData = (data: SalesRecord[]) => {
|
const initializeData = (data: SalesRecord[]) => {
|
||||||
setRawData(data);
|
setRawData(data);
|
||||||
setFilters({
|
setFilters({
|
||||||
customer: [],
|
customer: [],
|
||||||
year: [],
|
year: [],
|
||||||
month: [],
|
month: [],
|
||||||
line: [],
|
line: [],
|
||||||
asin: [],
|
asin: [],
|
||||||
sku: [],
|
sku: [],
|
||||||
title: [],
|
title: [],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL
|
// 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initApp = async () => {
|
const initApp = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
console.log("[Init] App starting...");
|
||||||
|
|
||||||
|
try {
|
||||||
const currentStoredUrl = localStorage.getItem('craze_csv_url');
|
const currentStoredUrl = localStorage.getItem('craze_csv_url');
|
||||||
if (currentStoredUrl !== PERMANENT_DROPBOX_URL) {
|
if (currentStoredUrl !== PERMANENT_DROPBOX_URL) {
|
||||||
localStorage.setItem('craze_csv_url', PERMANENT_DROPBOX_URL);
|
console.log("[Init] Updating stored URL to default permanent URL");
|
||||||
setActiveUrl(PERMANENT_DROPBOX_URL);
|
localStorage.setItem('craze_csv_url', PERMANENT_DROPBOX_URL);
|
||||||
|
setActiveUrl(PERMANENT_DROPBOX_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, lastUpdated: date } = await loadSalesData();
|
const { data, lastUpdated: date } = await loadSalesData();
|
||||||
|
|
||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
console.log("Loaded data from cache:", data.length, "rows");
|
console.log("[Init] Loaded data from cache:", data.length, "rows. Last updated:", date);
|
||||||
initializeData(data);
|
initializeData(data);
|
||||||
setLastUpdated(date);
|
setLastUpdated(date);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} else {
|
} else {
|
||||||
console.log("No cache found. Auto-fetching from Permanent URL...");
|
console.log("[Init] No cache found. Auto-fetching from Permanent URL...");
|
||||||
handleUrlFetch(PERMANENT_DROPBOX_URL).catch(e => {
|
// Catch error here so initApp doesn't crash, handleUrlFetch handles UI
|
||||||
console.error("Initial fetch failed.");
|
await handleUrlFetch(PERMANENT_DROPBOX_URL);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Init] Critical failure during initialization:", e);
|
||||||
|
setLoading(false); // Ensure we never get stuck in infinite load
|
||||||
|
}
|
||||||
};
|
};
|
||||||
initApp();
|
initApp();
|
||||||
}, [handleUrlFetch]);
|
}, [handleUrlFetch]);
|
||||||
@@ -154,55 +188,57 @@ const App: React.FC = () => {
|
|||||||
const handleAdsUpload = async (file: File) => {
|
const handleAdsUpload = async (file: File) => {
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
try {
|
try {
|
||||||
const data = await processAdsCSV(file);
|
const data = await processAdsCSV(file);
|
||||||
setAdsData(data);
|
setAdsData(data);
|
||||||
console.log("Ads loaded:", data.length);
|
console.log("Ads loaded:", data.length);
|
||||||
setIsDataModalOpen(false);
|
setIsDataModalOpen(false);
|
||||||
setView('ads'); // Switch to ads view automatically
|
setView('ads'); // Switch to ads view automatically
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to parse Ads CSV", error);
|
console.error("Failed to parse Ads CSV", error);
|
||||||
alert("Error parsing Ads CSV. Please check the format.");
|
alert("Error parsing Ads CSV. Please check the format.");
|
||||||
} finally {
|
} finally {
|
||||||
setSyncing(false);
|
setSyncing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Schedule Auto-Refresh (Background)
|
// 2. Schedule Auto-Refresh (Background)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAndRefresh = () => {
|
const checkAndRefresh = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
const today = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
const lastRefreshDate = localStorage.getItem('craze_last_refresh_date');
|
const lastRefreshDate = localStorage.getItem('craze_last_refresh_date');
|
||||||
|
|
||||||
// Refresh if it's after 7 AM and we haven't refreshed today
|
// Refresh if it's after 7 AM and we haven't refreshed today
|
||||||
if (now.getHours() >= 7 && lastRefreshDate !== today) {
|
if (now.getHours() >= 7 && lastRefreshDate !== today) {
|
||||||
console.log("Triggering daily data refresh...");
|
console.log("Triggering daily data refresh...");
|
||||||
handleUrlFetch(PERMANENT_DROPBOX_URL).then(() => {
|
// Use a background version (no UI loading state) if possible,
|
||||||
localStorage.setItem('craze_last_refresh_date', today);
|
// but re-using handleUrlFetch is fine for now but might show spinners.
|
||||||
console.log("Daily refresh successful.");
|
// Ideally split the "silent sync" logic.
|
||||||
}).catch(err => {
|
handleUrlFetch(PERMANENT_DROPBOX_URL).then(() => {
|
||||||
console.error("Daily refresh failed, will retry later.", err);
|
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
|
// Check immediately on load in case the user opens the app after 7 AM
|
||||||
const interval = setInterval(checkAndRefresh, 15 * 60 * 1000);
|
// checkAndRefresh(); // Disabled for now to prevent double-fetch on startup logic conflict
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
// 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]);
|
}, [handleUrlFetch]);
|
||||||
|
|
||||||
|
|
||||||
// Derive Data
|
// Derive Data
|
||||||
const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]);
|
const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]);
|
||||||
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
|
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
|
||||||
|
|
||||||
// Combine Sales & Ads Data dynamically based on current filters
|
// Combine Sales & Ads Data dynamically based on current filters
|
||||||
const combinedAdsData = useMemo(() => {
|
const combinedAdsData = useMemo(() => {
|
||||||
return mergeSalesAndAdsData(filteredData, adsData);
|
return mergeSalesAndAdsData(filteredData, adsData);
|
||||||
}, [filteredData, adsData]);
|
}, [filteredData, adsData]);
|
||||||
|
|
||||||
// Derive Context Data (Product Line Context when drilling down)
|
// Derive Context Data (Product Line Context when drilling down)
|
||||||
@@ -255,91 +291,97 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const disconnectUrl = async () => {
|
const disconnectUrl = async () => {
|
||||||
// Allow disconnecting to clear data, but the app will likely re-connect on next reload due to "Permanent" requirement
|
// Allow disconnecting to clear data, but the app will likely re-connect on next reload due to "Permanent" requirement
|
||||||
localStorage.removeItem('craze_csv_url');
|
localStorage.removeItem('craze_csv_url');
|
||||||
await clearSalesData();
|
await clearSalesData();
|
||||||
setActiveUrl(null);
|
setActiveUrl(null);
|
||||||
setRawData([]);
|
setRawData([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkipLoading = () => {
|
||||||
|
console.log("User skipped loading.");
|
||||||
|
setLoading(false);
|
||||||
|
setSyncing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-background text-slate-200">
|
<div className="min-h-screen flex flex-col bg-background text-slate-200">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-slate-950/90 backdrop-blur border-b border-border py-4 px-6 relative z-40 shadow-2xl">
|
<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="max-w-7xl mx-auto flex justify-between items-center">
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
{/* Logo Container (Horizontal Box) - Persistent User Image */}
|
{/* Logo Container (Horizontal Box) - Persistent User Image */}
|
||||||
<div className="h-16 w-64 relative flex-shrink-0">
|
<div className="h-16 w-64 relative flex-shrink-0">
|
||||||
<CrazeLogo />
|
<CrazeLogo />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title & Status */}
|
{/* Title & Status */}
|
||||||
<div className="hidden lg:block border-l border-slate-700 pl-6">
|
<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>
|
<h1 className="text-xl font-bold tracking-tight text-white text-shadow-sm">Analytics Dashboard</h1>
|
||||||
{activeUrl && lastUpdated && (
|
{activeUrl && lastUpdated && (
|
||||||
<p className="text-[10px] text-emerald-400 mt-1 flex items-center gap-1 uppercase font-bold tracking-wider">
|
<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>
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||||
Live Sync Active
|
Live Sync Active
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
||||||
{/* Main Action: Data Source Button */}
|
{/* Main Action: Data Source Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDataModalOpen(true)}
|
onClick={() => setIsDataModalOpen(true)}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold shadow-lg transition-all border
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold shadow-lg transition-all border
|
||||||
${activeUrl
|
${activeUrl
|
||||||
? 'bg-slate-800 text-emerald-400 border-emerald-500/50 hover:bg-slate-700'
|
? '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'}`}
|
: '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 />}
|
{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>
|
<span className="hidden md:inline">{activeUrl ? 'Data Settings' : 'Connect Data'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* NEW REFRESH BUTTON */}
|
{/* NEW REFRESH BUTTON */}
|
||||||
<button
|
<button
|
||||||
onClick={() => activeUrl && handleUrlFetch(activeUrl)}
|
onClick={() => activeUrl && handleUrlFetch(activeUrl)}
|
||||||
disabled={syncing}
|
disabled={syncing}
|
||||||
title="Refresh Data"
|
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"
|
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' : ''}`} />
|
<RefreshIcon className={`w-5 h-5 ${syncing ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* View Switcher */}
|
{/* View Switcher */}
|
||||||
<div className="flex bg-slate-900 rounded-lg p-1 border border-border">
|
<div className="flex bg-slate-900 rounded-lg p-1 border border-border">
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('dashboard')}
|
onClick={() => setView('dashboard')}
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
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'}`}
|
${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>
|
<ChartIcon /> <span className="hidden sm:inline">Dashboard</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('table')}
|
onClick={() => setView('table')}
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
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'}`}
|
${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">Grid</span>
|
<TableIcon /> <span className="hidden sm:inline">Grid</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('movers')}
|
onClick={() => setView('movers')}
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
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'}`}
|
${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 sm:inline">Movers</span>
|
<TrendingIcon /> <span className="hidden sm:inline">Movers</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('ads')}
|
onClick={() => setView('ads')}
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
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'}`}
|
${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 sm:inline">Ads</span>
|
<MegaphoneIcon /> <span className="hidden sm:inline">Ads</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,27 +390,45 @@ const App: React.FC = () => {
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 relative">
|
<main className="flex-1 relative">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
// Initial loading spinner
|
// Initial loading spinner with SKIP Option
|
||||||
<div className="flex flex-col items-center justify-center h-[80vh] gap-4">
|
<div className="flex flex-col items-center justify-center h-[80vh] gap-6 text-center animate-fade-in">
|
||||||
<div className="w-16 h-16 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
|
<div className="relative">
|
||||||
<h2 className="text-xl font-bold text-slate-300">Loading Dashboard...</h2>
|
<div className="w-20 h-20 border-4 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin"></div>
|
||||||
<p className="text-sm text-slate-500">Syncing with Dropbox...</p>
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<UploadIcon className="w-8 h-8 text-indigo-400 animate-pulse" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Syncing Data</h2>
|
||||||
|
<p className="text-slate-400 max-w-md mx-auto">
|
||||||
|
Connecting to Dropbox to fetch the latest analytics...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSkipLoading}
|
||||||
|
className="text-sm font-semibold text-slate-500 hover:text-white hover:underline transition-colors"
|
||||||
|
>
|
||||||
|
Taking too long? Skip Sync
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />
|
<FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{view === 'dashboard' && (
|
{view === 'dashboard' && (
|
||||||
<Dashboard
|
<Dashboard
|
||||||
data={aggregatedData}
|
data={aggregatedData}
|
||||||
contextData={contextAggregatedData}
|
contextData={contextAggregatedData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'table' && <DataGrid data={filteredData} />}
|
{view === 'table' && <DataGrid data={filteredData} />}
|
||||||
{view === 'movers' && <TopMovers data={filteredData} />}
|
{view === 'movers' && <TopMovers data={filteredData} />}
|
||||||
{view === 'ads' && <AdvertisingDashboard data={combinedAdsData} />}
|
{view === 'ads' && <AdvertisingDashboard data={combinedAdsData} />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -378,31 +438,30 @@ const App: React.FC = () => {
|
|||||||
{/* DATA MODAL */}
|
{/* DATA MODAL */}
|
||||||
{isDataModalOpen && (
|
{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="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-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">
|
<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>
|
<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">
|
<button onClick={() => setIsDataModalOpen(false)} className="text-slate-400 hover:text-white transition-colors">
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
<FileUpload
|
|
||||||
onSalesUpload={handleSalesUpload} // CORRECTED: Was handleFileUpload
|
|
||||||
onAdsUpload={handleAdsUpload} // ADDED: Missing prop causing error
|
|
||||||
onUrlSubmit={handleUrlFetch}
|
|
||||||
isLoading={syncing}
|
|
||||||
activeUrl={activeUrl}
|
|
||||||
onDisconnect={disconnectUrl}
|
|
||||||
lastUpdated={lastUpdated}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<FileUpload
|
||||||
|
onSalesUpload={handleSalesUpload} // CORRECTED: Was handleFileUpload
|
||||||
|
onAdsUpload={handleAdsUpload} // ADDED: Missing prop causing error
|
||||||
|
onUrlSubmit={handleUrlFetch}
|
||||||
|
isLoading={syncing}
|
||||||
|
activeUrl={activeUrl}
|
||||||
|
onDisconnect={disconnectUrl}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-950 text-slate-200 p-8">
|
||||||
|
<div className="bg-red-900/20 border border-red-500/50 rounded-xl p-8 max-w-2xl w-full text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-red-500 mb-4">Something went wrong</h1>
|
||||||
|
<p className="text-slate-300 mb-6">The application encountered a critical error during rendering.</p>
|
||||||
|
|
||||||
|
<div className="bg-black/50 p-4 rounded-lg text-left overflow-auto max-h-64 font-mono text-sm border border-slate-800">
|
||||||
|
<p className="text-red-400 font-bold mb-2">Error: {this.state.error?.message}</p>
|
||||||
|
<pre className="text-slate-500 text-xs">{this.state.error?.stack}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-8 px-6 py-3 bg-red-600 hover:bg-red-500 text-white font-bold rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Reload Application
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
+45
-53
@@ -1,60 +1,52 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta charset="UTF-8" />
|
||||||
<title>SAS Analytics Dashboard</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<title>SAS Analytics Dashboard</title>
|
||||||
<script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
tailwind.config = {
|
<script>
|
||||||
darkMode: 'class',
|
tailwind.config = {
|
||||||
theme: {
|
darkMode: 'class',
|
||||||
extend: {
|
theme: {
|
||||||
colors: {
|
extend: {
|
||||||
background: '#020617', // slate-950
|
colors: {
|
||||||
surface: '#0f172a', // slate-900
|
background: '#020617', // slate-950
|
||||||
border: '#1e293b', // slate-800
|
surface: '#0f172a', // slate-900
|
||||||
primary: '#6366f1', // indigo-500
|
border: '#1e293b', // slate-800
|
||||||
secondary: '#64748b', // slate-500
|
primary: '#6366f1', // indigo-500
|
||||||
}
|
secondary: '#64748b', // slate-500
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
}
|
||||||
<!-- PapaParse for CSV parsing -->
|
</script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
|
<style>
|
||||||
<style>
|
/* Custom Scrollbar */
|
||||||
/* Custom Scrollbar */
|
::-webkit-scrollbar {
|
||||||
::-webkit-scrollbar {
|
width: 8px;
|
||||||
width: 8px;
|
height: 8px;
|
||||||
height: 8px;
|
}
|
||||||
}
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #020617;
|
background: #020617;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #334155;
|
::-webkit-scrollbar-thumb {
|
||||||
border-radius: 4px;
|
background: #334155;
|
||||||
}
|
border-radius: 4px;
|
||||||
::-webkit-scrollbar-thumb:hover {
|
}
|
||||||
background: #475569;
|
|
||||||
}
|
::-webkit-scrollbar-thumb:hover {
|
||||||
</style>
|
background: #475569;
|
||||||
<script type="importmap">
|
}
|
||||||
{
|
</style>
|
||||||
"imports": {
|
|
||||||
"react": "https://aistudiocdn.com/react@^19.2.0",
|
|
||||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
|
|
||||||
"react/": "https://aistudiocdn.com/react@^19.2.0/",
|
|
||||||
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
|
|
||||||
"recharts": "https://aistudiocdn.com/recharts@^3.5.0",
|
|
||||||
"xlsx": "https://esm.sh/xlsx@^0.18.5",
|
|
||||||
"papaparse": "https://esm.sh/papaparse@^5.5.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-background text-slate-200 antialiased overflow-y-auto">
|
|
||||||
<div id="root"></div>
|
<body class="bg-background text-slate-200 antialiased overflow-y-auto">
|
||||||
</body>
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
@@ -10,6 +11,8 @@ if (!rootElement) {
|
|||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
Generated
+4359
File diff suppressed because it is too large
Load Diff
+17
-8
@@ -5,21 +5,30 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"@google/genai": "^1.30.0",
|
"@google/genai": "^1.30.0",
|
||||||
"recharts": "^3.5.0",
|
"recharts": "^3.5.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"papaparse": "^5.5.3"
|
"papaparse": "^5.5.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.5.0",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@types/react": "^18.3.3",
|
||||||
"typescript": "~5.8.2",
|
"@types/react-dom": "^18.3.0",
|
||||||
"vite": "^6.2.0"
|
"@types/papaparse": "^5.3.14",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.2",
|
||||||
|
"eslint": "^9.9.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.9",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"typescript-eslint": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user