Files
hihala-dashboard/src/App.tsx
fahed 802ff28754
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Update default theme to light and fix data source subtitle
Default to light theme instead of system preference, and update
dashboard subtitle to reflect VivaTicket as the data source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:03:17 +03:00

294 lines
13 KiB
TypeScript

import React, { useState, useEffect, useCallback, ReactNode, lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
const Dashboard = lazy(() => import('./components/Dashboard'));
const Comparison = lazy(() => import('./components/Comparison'));
const Slides = lazy(() => import('./components/Slides'));
import LoadingSkeleton from './components/shared/LoadingSkeleton';
import { fetchData, getCacheStatus, refreshData } from './services/dataService';
import { useLanguage } from './contexts/LanguageContext';
import type { MuseumRecord, CacheStatus, DataErrorType } from './types';
import { DataError } from './types';
import './App.css';
interface NavLinkProps {
to: string;
children: ReactNode;
className?: string;
}
function NavLink({ to, children, className }: NavLinkProps) {
const location = useLocation();
const isActive = location.pathname === to;
return (
<Link to={to} className={`nav-link ${isActive ? 'active' : ''} ${className || ''}`}>
{children}
</Link>
);
}
interface DataSource {
id: string;
labelKey: string;
enabled: boolean;
}
function App() {
const { t, dir, switchLanguage } = useLanguage();
const [data, setData] = useState<MuseumRecord[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [refreshing, setRefreshing] = useState<boolean>(false);
const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null);
const [isOffline, setIsOffline] = useState<boolean>(false);
const [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null);
const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
const [dataSource, setDataSource] = useState<string>('museums');
const [theme, setTheme] = useState<string>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('hihala_theme') || 'light';
}
return 'light';
});
useEffect(() => {
const root = document.documentElement;
if (theme === 'system') {
root.removeAttribute('data-theme');
} else {
root.setAttribute('data-theme', theme);
}
localStorage.setItem('hihala_theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => {
if (prev === 'system') return 'dark';
if (prev === 'dark') return 'light';
return 'system';
});
};
const dataSources: DataSource[] = [
{ id: 'museums', labelKey: 'dataSources.museums', enabled: true },
{ id: 'coffees', labelKey: 'dataSources.coffees', enabled: false },
{ id: 'ecommerce', labelKey: 'dataSources.ecommerce', enabled: false }
];
const loadData = useCallback(async (forceRefresh: boolean = false) => {
try {
setLoading(!forceRefresh);
setRefreshing(forceRefresh);
const result = forceRefresh ? await refreshData() : await fetchData();
setData(result.data);
setError(null);
setIsOffline(result.fromCache);
// Update cache info
const status = getCacheStatus();
setCacheInfo(status);
} catch (err) {
const type = err instanceof DataError ? err.type : 'unknown';
setError({ message: (err as Error).message, type });
console.error(err);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleRefresh = () => {
loadData(true);
};
if (loading) {
return (
<div className="app" dir={dir}>
<LoadingSkeleton />
</div>
);
}
if (error) {
return (
<div className="error-container" dir={dir}>
<h2>{t('app.error')}</h2>
<p className="error-message">
{t(`errors.${error.type}`)}
</p>
<button onClick={() => loadData()}>{t('app.retry')}</button>
</div>
);
}
return (
<Router>
<div className="app" dir={dir}>
<nav className="nav-bar" aria-label={t('nav.dashboard')}>
<div className="nav-content">
<div className="nav-brand">
<svg className="nav-brand-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="4" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="11" width="7" height="10" rx="1"/>
</svg>
<span className="nav-brand-text">
HiHala Data
<select
className="data-source-select"
value={dataSource}
onChange={e => setDataSource(e.target.value)}
aria-label={t('dataSources.museums')}
>
{dataSources.map(src => (
<option key={src.id} value={src.id} disabled={!src.enabled}>
{t(src.labelKey)}{!src.enabled ? ` (${t('dataSources.soon')})` : ''}
</option>
))}
</select>
</span>
</div>
<div className="nav-links">
<NavLink to="/">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="9" rx="1"/>
<rect x="14" y="3" width="7" height="5" rx="1"/>
<rect x="14" y="12" width="7" height="9" rx="1"/>
<rect x="3" y="16" width="7" height="5" rx="1"/>
</svg>
{t('nav.dashboard')}
</NavLink>
<NavLink to="/comparison">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
<polyline points="18 14 22 10 18 6"/>
<polyline points="6 10 2 14 6 18"/>
</svg>
{t('nav.comparison')}
</NavLink>
{isOffline && (
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp || '').toLocaleString()}` : ''}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
<path d="M10.71 5.05A16 16 0 0 1 22.58 9"/>
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<line x1="12" y1="20" x2="12.01" y2="20"/>
</svg>
{t('app.offline') || 'Offline'}
</span>
)}
<button
className={`nav-refresh-btn ${refreshing ? 'refreshing' : ''}`}
onClick={handleRefresh}
disabled={refreshing}
aria-label={t('app.refresh') || 'Refresh data'}
title={t('app.refresh') || 'Refresh data'}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</button>
<button
className="nav-lang-toggle"
onClick={toggleTheme}
aria-label={`Theme: ${theme}`}
title={`Theme: ${theme}`}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{theme === 'dark' ? (
<><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></>
) : theme === 'light' ? (
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
) : (
<><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 0 0 20V2z"/></>
)}
</svg>
</button>
<button
className="nav-lang-toggle"
onClick={switchLanguage}
aria-label={t('language.switch')}
title="Switch language"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
{t('language.switch')}
</button>
</div>
</div>
</nav>
<main>
<Suspense fallback={<LoadingSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
<Route path="/slides" element={<Slides data={data} />} />
</Routes>
</Suspense>
</main>
{/* Mobile Bottom Navigation */}
<nav className="mobile-nav" aria-label="Mobile navigation">
<NavLink to="/" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="9" rx="1"/>
<rect x="14" y="3" width="7" height="5" rx="1"/>
<rect x="14" y="12" width="7" height="9" rx="1"/>
<rect x="3" y="16" width="7" height="5" rx="1"/>
</svg>
<span>{t('nav.dashboard')}</span>
</NavLink>
<NavLink to="/comparison" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
<span>{t('nav.compare')}</span>
</NavLink>
<NavLink to="/slides" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<span>{t('nav.slides')}</span>
</NavLink>
<button
className="mobile-nav-item"
onClick={switchLanguage}
aria-label={t('language.switch')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
<span>{t('language.switch')}</span>
</button>
</nav>
</div>
</Router>
);
}
export default App;