c9cfb58896
Deploy HiHala Dashboard / deploy (push) Successful in 8s
- Add Settings link to desktop nav bar for admin users - Rewrite Settings page from table layout to responsive card list (fixes unusable mobile state) - Filter bar (Dashboard + Comparison): collapsible panel on mobile via display:contents trick; stacked full-width dropdowns replace horizontal scroll - Active filter count badge shown in collapsed filter header - AltMultiSelect dropdowns go full-width on mobile to prevent viewport overflow - Chart control separators hidden on mobile to avoid crowding - Metric grid: 2-col at ≤700px, 1-col at ≤480px - Comparison period cards: smaller font and tighter padding at ≤680px - Page shell padding reduced on mobile (48px→20px top, 24px→16px sides) - Settings page gets correct 80px bottom padding for mobile nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
370 lines
17 KiB
TypeScript
370 lines
17 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo, ReactNode, lazy, Suspense } from 'react';
|
|
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
|
|
|
const Settings = lazy(() => import('./components/Settings'));
|
|
const Comparison = lazy(() => import('./components/Comparison'));
|
|
const Dashboard = lazy(() => import('./components/Dashboard'));
|
|
import Login from './components/Login';
|
|
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
|
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
|
|
import { fetchSeasons } from './services/seasonsService';
|
|
import { parseAllowed } from './services/usersService';
|
|
import { useLanguage } from './contexts/LanguageContext';
|
|
import type { MuseumRecord, Season, 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>
|
|
);
|
|
}
|
|
|
|
function AppNav({ children }: { children: ReactNode }) {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
interface DataSource {
|
|
id: string;
|
|
labelKey: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
function App() {
|
|
const { t, dir, switchLanguage } = useLanguage();
|
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
|
const [userRole, setUserRole] = useState<string>('viewer');
|
|
const [userName, setUserName] = useState<string>('');
|
|
const [allowedMuseums, setAllowedMuseums] = useState<string[] | null>([]);
|
|
const [allowedChannels, setAllowedChannels] = useState<string[] | null>([]);
|
|
const [data, setData] = useState<MuseumRecord[]>([]);
|
|
const allMuseumsList = useMemo(() => getUniqueMuseums(data), [data]);
|
|
const allChannelsList = useMemo(() => getUniqueChannels(data), [data]);
|
|
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 [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
|
const [dataSource, setDataSource] = useState<string>('museums');
|
|
const [seasons, setSeasons] = useState<Season[]>([]);
|
|
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);
|
|
}
|
|
}, []);
|
|
|
|
const loadSeasons = useCallback(async () => {
|
|
const s = await fetchSeasons();
|
|
setSeasons(s);
|
|
}, []);
|
|
|
|
// Check auth on mount
|
|
useEffect(() => {
|
|
fetch('/auth/check', { credentials: 'include' })
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
setAuthenticated(d.authenticated);
|
|
if (d.authenticated) {
|
|
setUserRole(d.role || 'viewer');
|
|
setUserName(d.name || '');
|
|
setAllowedMuseums(parseAllowed(d.allowedMuseums));
|
|
setAllowedChannels(parseAllowed(d.allowedChannels));
|
|
loadData();
|
|
loadSeasons();
|
|
}
|
|
})
|
|
.catch(() => setAuthenticated(false));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const handleLogin = (name: string, role: string, rawMuseums: string, rawChannels: string) => {
|
|
setAuthenticated(true);
|
|
setUserName(name);
|
|
setUserRole(role);
|
|
setAllowedMuseums(parseAllowed(rawMuseums));
|
|
setAllowedChannels(parseAllowed(rawChannels));
|
|
loadData();
|
|
loadSeasons();
|
|
};
|
|
|
|
const handleRefresh = () => {
|
|
loadData(true);
|
|
};
|
|
|
|
// Auth check loading
|
|
if (authenticated === null) {
|
|
return (
|
|
<div className="app" dir={dir}>
|
|
<LoadingSkeleton />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Not authenticated — show login
|
|
if (!authenticated) {
|
|
return (
|
|
<div className="app" dir={dir}>
|
|
<Login onLogin={handleLogin} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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}>
|
|
<AppNav><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">
|
|
<span className="nav-brand-name">HiHala</span>
|
|
<span className="nav-brand-tag">Data</span>
|
|
<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>
|
|
{userRole === 'admin' && (
|
|
<NavLink to="/settings">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
</svg>
|
|
{t('nav.settings')}
|
|
</NavLink>
|
|
)}
|
|
<span className="nav-sep" aria-hidden="true" />
|
|
{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'}
|
|
{cacheInfo && (
|
|
<span className="sr-only">
|
|
{` (cached ${new Date(cacheInfo.timestamp || '').toLocaleString()})`}
|
|
</span>
|
|
)}
|
|
</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></AppNav>
|
|
|
|
<main>
|
|
<Suspense fallback={<LoadingSkeleton />}>
|
|
<Routes>
|
|
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
|
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
|
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
|
</Routes>
|
|
</Suspense>
|
|
</main>
|
|
|
|
{/* Mobile Bottom Navigation */}
|
|
<AppNav><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>
|
|
{userRole === 'admin' && (
|
|
<NavLink to="/settings" 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">
|
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
</svg>
|
|
<span>{t('nav.settings')}</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></AppNav>
|
|
</div>
|
|
</Router>
|
|
);
|
|
}
|
|
|
|
export default App;
|