feat: Complete mobile UX/UI overhaul
Major improvements: - New CSS design system with custom properties (tokens) - Consistent spacing scale (4px base) - Touch-friendly sizing (44px min targets) - Improved Carousel with better touch handling and rubber-band effect - Enhanced FilterControls (auto-collapse on mobile) - Better stat card styling with change indicators - Refined chart cards and toggle switches - Smoother transitions and micro-interactions - Better RTL support - Print styles - Responsive breakpoints for tablet (1024px), mobile (768px), and small mobile (375px) - Cleaner typography hierarchy - Subtle shadows and depth Components updated: - App.css: Complete rewrite with design tokens - Carousel.jsx: Better touch gestures with velocity detection - StatCard.jsx: Improved change indicator styling - FilterControls.jsx: Auto-collapse on mobile - EmptyState.jsx: Better accessibility - ChartExport.js: Cleaned up unused imports
This commit is contained in:
50
package-lock.json
generated
50
package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
@@ -5092,6 +5093,15 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
@@ -5995,6 +6005,15 @@
|
||||
"postcss": "^8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-loader": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz",
|
||||
@@ -8903,6 +8922,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
|
||||
@@ -15946,6 +15978,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
@@ -16509,6 +16550,15 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Tajawal:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
|
||||
2805
src/App.css
2805
src/App.css
File diff suppressed because it is too large
Load Diff
71
src/App.js
71
src/App.js
@@ -4,6 +4,7 @@ import Dashboard from './components/Dashboard';
|
||||
import Comparison from './components/Comparison';
|
||||
import Slides from './components/Slides';
|
||||
import { fetchData } from './services/dataService';
|
||||
import { useLanguage } from './contexts/LanguageContext';
|
||||
import './App.css';
|
||||
|
||||
function NavLink({ to, children }) {
|
||||
@@ -17,6 +18,7 @@ function NavLink({ to, children }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { t, dir, switchLanguage } = useLanguage();
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -24,9 +26,9 @@ function App() {
|
||||
const [dataSource, setDataSource] = useState('museums');
|
||||
|
||||
const dataSources = [
|
||||
{ id: 'museums', label: 'Museums', enabled: true },
|
||||
{ id: 'coffees', label: 'Coffees', enabled: false },
|
||||
{ id: 'ecommerce', label: 'eCommerce', enabled: false }
|
||||
{ id: 'museums', labelKey: 'dataSources.museums', enabled: true },
|
||||
{ id: 'coffees', labelKey: 'dataSources.coffees', enabled: false },
|
||||
{ id: 'ecommerce', labelKey: 'dataSources.ecommerce', enabled: false }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,26 +50,26 @@ function App() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loading-container" dir={dir}>
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading data...</p>
|
||||
<p>{t('app.loading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h2>Unable to load data</h2>
|
||||
<div className="error-container" dir={dir}>
|
||||
<h2>{t('app.error')}</h2>
|
||||
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>{error}</p>
|
||||
<button onClick={() => window.location.reload()}>Retry</button>
|
||||
<button onClick={() => window.location.reload()}>{t('app.retry')}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<div className="app" dir={dir}>
|
||||
<nav className="nav-bar">
|
||||
<div className="nav-content">
|
||||
<div className="nav-brand">
|
||||
@@ -86,7 +88,7 @@ function App() {
|
||||
>
|
||||
{dataSources.map(src => (
|
||||
<option key={src.id} value={src.id} disabled={!src.enabled}>
|
||||
{src.label}{!src.enabled ? ' (soon)' : ''}
|
||||
{t(src.labelKey)}{!src.enabled ? ` (${t('dataSources.soon')})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -100,7 +102,7 @@ function App() {
|
||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
||||
</svg>
|
||||
Dashboard
|
||||
{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">
|
||||
@@ -110,33 +112,22 @@ function App() {
|
||||
<polyline points="18 14 22 10 18 6"/>
|
||||
<polyline points="6 10 2 14 6 18"/>
|
||||
</svg>
|
||||
Comparison
|
||||
</NavLink>
|
||||
<NavLink to="/slides">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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>
|
||||
Slides
|
||||
{t('nav.comparison')}
|
||||
</NavLink>
|
||||
<button
|
||||
className={`nav-label-toggle ${showDataLabels ? 'active' : ''}`}
|
||||
onClick={() => setShowDataLabels(!showDataLabels)}
|
||||
title="Show values on charts"
|
||||
className="nav-lang-toggle"
|
||||
onClick={switchLanguage}
|
||||
title="Switch language"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
{showDataLabels ? 'Labels On' : 'Labels Off'}
|
||||
{t('language.switch')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} />} />
|
||||
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} />} />
|
||||
<Route path="/slides" element={<Slides data={data} />} />
|
||||
</Routes>
|
||||
|
||||
@@ -149,7 +140,7 @@ function App() {
|
||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
<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">
|
||||
@@ -157,24 +148,18 @@ function App() {
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
<span>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">
|
||||
<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>Slides</span>
|
||||
<span>{t('nav.compare')}</span>
|
||||
</NavLink>
|
||||
<button
|
||||
className={`mobile-nav-item ${showDataLabels ? 'active' : ''}`}
|
||||
onClick={() => setShowDataLabels(!showDataLabels)}
|
||||
className="mobile-nav-item"
|
||||
onClick={switchLanguage}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
<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>Labels</span>
|
||||
<span>{t('language.switch')}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useRef } from 'react';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
// Wrapper component that adds PNG export to any chart
|
||||
export function ExportableChart({ children, filename = 'chart', className = '' }) {
|
||||
export function ExportableChart({ children, filename = 'chart', title = '', className = '', controls = null }) {
|
||||
const chartRef = useRef(null);
|
||||
|
||||
const exportAsPNG = () => {
|
||||
@@ -11,21 +12,30 @@ export function ExportableChart({ children, filename = 'chart', className = '' }
|
||||
const canvas = chartContainer.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
// Create a new canvas with white background
|
||||
// Create a new canvas with white background and title
|
||||
const exportCanvas = document.createElement('canvas');
|
||||
const ctx = exportCanvas.getContext('2d');
|
||||
|
||||
// Set dimensions with padding
|
||||
const padding = 20;
|
||||
// Set dimensions with padding and title space
|
||||
const padding = 24;
|
||||
const titleHeight = title ? 48 : 0;
|
||||
exportCanvas.width = canvas.width + (padding * 2);
|
||||
exportCanvas.height = canvas.height + (padding * 2);
|
||||
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
|
||||
|
||||
// Fill white background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||||
|
||||
// Draw title if provided (left-aligned, matching on-screen style)
|
||||
if (title) {
|
||||
ctx.fillStyle = '#1e293b';
|
||||
ctx.font = '600 20px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(title, padding, padding + 24);
|
||||
}
|
||||
|
||||
// Draw the chart
|
||||
ctx.drawImage(canvas, padding, padding);
|
||||
ctx.drawImage(canvas, padding, padding + titleHeight);
|
||||
|
||||
// Export
|
||||
const link = document.createElement('a');
|
||||
@@ -35,23 +45,118 @@ export function ExportableChart({ children, filename = 'chart', className = '' }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`exportable-chart ${className}`}>
|
||||
<button
|
||||
className="chart-export-btn"
|
||||
onClick={exportAsPNG}
|
||||
title="Download as PNG"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div ref={chartRef} className="chart-canvas-wrapper">
|
||||
{children}
|
||||
<div className="exportable-chart-wrapper">
|
||||
{title && (
|
||||
<div className="chart-header-with-export">
|
||||
<h2>{title}</h2>
|
||||
<div className="chart-header-actions">
|
||||
{controls}
|
||||
<button
|
||||
className="chart-export-btn visible"
|
||||
onClick={exportAsPNG}
|
||||
title="Download as PNG"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!title && controls && <div className="chart-controls">{controls}</div>}
|
||||
<div className={`exportable-chart ${className}`}>
|
||||
<div ref={chartRef} className="chart-canvas-wrapper">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility function to export all charts from a container as a ZIP
|
||||
export async function exportAllCharts(containerSelector, zipFilename = 'charts') {
|
||||
const container = document.querySelector(containerSelector);
|
||||
if (!container) return;
|
||||
|
||||
const zip = new JSZip();
|
||||
const chartWrappers = container.querySelectorAll('.exportable-chart-wrapper');
|
||||
|
||||
for (let i = 0; i < chartWrappers.length; i++) {
|
||||
const wrapper = chartWrappers[i];
|
||||
const canvas = wrapper.querySelector('canvas');
|
||||
const titleEl = wrapper.querySelector('.chart-header-with-export h2');
|
||||
const title = titleEl?.textContent || `chart-${i + 1}`;
|
||||
|
||||
if (!canvas) continue;
|
||||
|
||||
// Create export canvas with white background and title
|
||||
const exportCanvas = document.createElement('canvas');
|
||||
const ctx = exportCanvas.getContext('2d');
|
||||
|
||||
const padding = 32;
|
||||
const titleHeight = 56;
|
||||
exportCanvas.width = canvas.width + (padding * 2);
|
||||
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
|
||||
|
||||
// White background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||||
|
||||
// Draw title
|
||||
ctx.fillStyle = '#1e293b';
|
||||
ctx.font = '600 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(title, padding, padding + 28);
|
||||
|
||||
// Draw chart
|
||||
ctx.drawImage(canvas, padding, padding + titleHeight);
|
||||
|
||||
// Convert to blob and add to zip
|
||||
const dataUrl = exportCanvas.toDataURL('image/png', 1.0);
|
||||
const base64Data = dataUrl.split(',')[1];
|
||||
const safeFilename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF\s-]/g, '').replace(/\s+/g, '-');
|
||||
zip.file(`${String(i + 1).padStart(2, '0')}-${safeFilename}.png`, base64Data, { base64: true });
|
||||
}
|
||||
|
||||
// Generate and download ZIP
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${zipFilename}-${new Date().toISOString().split('T')[0]}.zip`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Button component for exporting all charts
|
||||
export function ExportAllButton({ containerSelector, zipFilename, label, loadingLabel }) {
|
||||
const [exporting, setExporting] = React.useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportAllCharts(containerSelector, zipFilename);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn-export-all"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
{exporting ? loadingLabel : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExportableChart;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Line, Bar } from 'react-chartjs-2';
|
||||
import { EmptyState, FilterControls } from './shared';
|
||||
import { ExportableChart } from './ChartExport';
|
||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import {
|
||||
filterDataByDateRange,
|
||||
calculateMetrics,
|
||||
@@ -39,7 +40,8 @@ const generatePresetDates = (year) => ({
|
||||
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
||||
});
|
||||
|
||||
function Comparison({ data, showDataLabels }) {
|
||||
function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
||||
const { t } = useLanguage();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Get available years from data
|
||||
@@ -140,8 +142,8 @@ function Comparison({ data, showDataLabels }) {
|
||||
};
|
||||
|
||||
const charts = [
|
||||
{ id: 'timeseries', label: 'Trend' },
|
||||
{ id: 'museum', label: 'By Museum' }
|
||||
{ id: 'timeseries', label: t('comparison.trend') },
|
||||
{ id: 'museum', label: t('comparison.byMuseum') }
|
||||
];
|
||||
|
||||
// Touch swipe handlers
|
||||
@@ -165,15 +167,16 @@ function Comparison({ data, showDataLabels }) {
|
||||
};
|
||||
|
||||
const granularityOptions = [
|
||||
{ value: 'day', label: 'Daily' },
|
||||
{ value: 'week', label: 'Weekly' }
|
||||
{ value: 'day', label: t('time.daily') },
|
||||
{ value: 'week', label: t('time.weekly') },
|
||||
{ value: 'month', label: t('time.monthly') }
|
||||
];
|
||||
|
||||
const metricOptions = [
|
||||
{ value: 'revenue', label: 'Revenue', field: 'revenue_incl_tax', format: 'currency' },
|
||||
{ value: 'visitors', label: 'Visitors', field: 'visits', format: 'number' },
|
||||
{ value: 'tickets', label: 'Tickets', field: 'tickets', format: 'number' },
|
||||
{ value: 'avgRevenue', label: 'Avg Rev/Visitor', field: null, format: 'currency' }
|
||||
{ value: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax', format: 'currency' },
|
||||
{ value: 'visitors', label: t('metrics.visitors'), field: 'visits', format: 'number' },
|
||||
{ value: 'tickets', label: t('metrics.tickets'), field: 'tickets', format: 'number' },
|
||||
{ value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' }
|
||||
];
|
||||
|
||||
const getMetricValue = useCallback((rows, metric) => {
|
||||
@@ -271,19 +274,19 @@ function Comparison({ data, showDataLabels }) {
|
||||
const captureRateChange = captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null;
|
||||
|
||||
const cards = [
|
||||
{ title: 'Revenue', prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true },
|
||||
{ title: 'Visitors', prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
|
||||
{ title: 'Tickets', prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange },
|
||||
{ title: 'Avg Rev/Visitor', prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true }
|
||||
{ title: t('metrics.revenue'), prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true },
|
||||
{ title: t('metrics.visitors'), prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
|
||||
{ title: t('metrics.tickets'), prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange },
|
||||
{ title: t('metrics.avgRevenue'), prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true }
|
||||
];
|
||||
if (pilgrimCounts) {
|
||||
cards.push({ title: 'Pilgrims', prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: pilgrimsChange, pendingMessage: 'Data not published yet' });
|
||||
cards.push({ title: t('metrics.pilgrims'), prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: pilgrimsChange, pendingMessage: t('comparison.pendingData') });
|
||||
}
|
||||
if (captureRates) {
|
||||
cards.push({ title: 'Capture Rate', prev: captureRates.prev, curr: captureRates.curr, change: captureRateChange, isPercent: true, pendingMessage: 'Data not published yet' });
|
||||
cards.push({ title: t('metrics.captureRate'), prev: captureRates.prev, curr: captureRates.curr, change: captureRateChange, isPercent: true, pendingMessage: t('comparison.pendingData') });
|
||||
}
|
||||
return cards;
|
||||
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates]);
|
||||
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]);
|
||||
|
||||
const handleCardTouchStart = (e) => {
|
||||
touchStartCard.current = e.touches[0].clientX;
|
||||
@@ -338,7 +341,11 @@ function Comparison({ data, showDataLabels }) {
|
||||
const daysDiff = Math.floor((rowDate - start) / (1000 * 60 * 60 * 24));
|
||||
|
||||
let key;
|
||||
if (granularity === 'week') {
|
||||
if (granularity === 'month') {
|
||||
// Group by month number (relative to start)
|
||||
const monthsDiff = (rowDate.getFullYear() - start.getFullYear()) * 12 + (rowDate.getMonth() - start.getMonth());
|
||||
key = monthsDiff + 1;
|
||||
} else if (granularity === 'week') {
|
||||
key = Math.floor(daysDiff / 7) + 1;
|
||||
} else {
|
||||
key = daysDiff + 1; // day number from start
|
||||
@@ -359,9 +366,15 @@ function Comparison({ data, showDataLabels }) {
|
||||
const currGrouped = groupByPeriod(currData, ranges.curr.start, chartMetric, chartGranularity);
|
||||
const maxKey = Math.max(...Object.keys(prevGrouped).map(Number), ...Object.keys(currGrouped).map(Number), 1);
|
||||
|
||||
const labels = Array.from({ length: maxKey }, (_, i) =>
|
||||
chartGranularity === 'week' ? `W${i + 1}` : `D${i + 1}`
|
||||
);
|
||||
const labels = Array.from({ length: maxKey }, (_, i) => {
|
||||
if (chartGranularity === 'month') {
|
||||
const startDate = new Date(ranges.curr.start);
|
||||
const monthNum = ((startDate.getMonth() + i) % 12) + 1;
|
||||
return String(monthNum);
|
||||
}
|
||||
if (chartGranularity === 'week') return `W${i + 1}`;
|
||||
return `D${i + 1}`;
|
||||
});
|
||||
|
||||
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
|
||||
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
|
||||
@@ -423,45 +436,54 @@ function Comparison({ data, showDataLabels }) {
|
||||
...baseOptions,
|
||||
plugins: {
|
||||
...baseOptions.plugins,
|
||||
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } }
|
||||
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="comparison">
|
||||
<div className="page-title">
|
||||
<h1>Period Comparison</h1>
|
||||
<p>Select a period and year — automatically compares with the same period in the previous year</p>
|
||||
<div className="comparison" id="comparison-container">
|
||||
<div className="page-title-with-actions">
|
||||
<div className="page-title">
|
||||
<h1>{t('comparison.title')}</h1>
|
||||
<p>{t('comparison.subtitle')}</p>
|
||||
</div>
|
||||
<div className="toggle-with-label">
|
||||
<span className="toggle-text">{t('nav.labels')}</span>
|
||||
<div className="toggle-switch">
|
||||
<button className={!showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(false)}>{t('toggle.off')}</button>
|
||||
<button className={showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(true)}>{t('toggle.on')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterControls title="Select Period" onReset={resetFilters}>
|
||||
<FilterControls title={t('comparison.selectPeriod')} onReset={resetFilters}>
|
||||
<FilterControls.Row>
|
||||
<FilterControls.Group label="Period">
|
||||
<FilterControls.Group label={t('comparison.period')}>
|
||||
<select value={preset} onChange={e => setPreset(e.target.value)}>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="jan">January</option>
|
||||
<option value="feb">February</option>
|
||||
<option value="mar">March</option>
|
||||
<option value="apr">April</option>
|
||||
<option value="may">May</option>
|
||||
<option value="jun">June</option>
|
||||
<option value="jul">July</option>
|
||||
<option value="aug">August</option>
|
||||
<option value="sep">September</option>
|
||||
<option value="oct">October</option>
|
||||
<option value="nov">November</option>
|
||||
<option value="dec">December</option>
|
||||
<option value="q1">Q1</option>
|
||||
<option value="q2">Q2</option>
|
||||
<option value="q3">Q3</option>
|
||||
<option value="q4">Q4</option>
|
||||
<option value="h1">H1</option>
|
||||
<option value="h2">H2</option>
|
||||
<option value="full">Full Year</option>
|
||||
<option value="custom">{t('comparison.custom')}</option>
|
||||
<option value="jan">{t('months.january')}</option>
|
||||
<option value="feb">{t('months.february')}</option>
|
||||
<option value="mar">{t('months.march')}</option>
|
||||
<option value="apr">{t('months.april')}</option>
|
||||
<option value="may">{t('months.may')}</option>
|
||||
<option value="jun">{t('months.june')}</option>
|
||||
<option value="jul">{t('months.july')}</option>
|
||||
<option value="aug">{t('months.august')}</option>
|
||||
<option value="sep">{t('months.september')}</option>
|
||||
<option value="oct">{t('months.october')}</option>
|
||||
<option value="nov">{t('months.november')}</option>
|
||||
<option value="dec">{t('months.december')}</option>
|
||||
<option value="q1">{t('time.q1')}</option>
|
||||
<option value="q2">{t('time.q2')}</option>
|
||||
<option value="q3">{t('time.q3')}</option>
|
||||
<option value="q4">{t('time.q4')}</option>
|
||||
<option value="h1">{t('time.h1')}</option>
|
||||
<option value="h2">{t('time.h2')}</option>
|
||||
<option value="full">{t('time.fullYear')}</option>
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
{preset !== 'custom' && (
|
||||
<FilterControls.Group label="Year">
|
||||
<FilterControls.Group label={t('filters.year')}>
|
||||
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
||||
{availableYears.map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
@@ -471,51 +493,55 @@ function Comparison({ data, showDataLabels }) {
|
||||
)}
|
||||
{preset === 'custom' && (
|
||||
<>
|
||||
<FilterControls.Group label="From">
|
||||
<FilterControls.Group label={t('comparison.from')}>
|
||||
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label="To">
|
||||
<FilterControls.Group label={t('comparison.to')}>
|
||||
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
|
||||
</FilterControls.Group>
|
||||
</>
|
||||
)}
|
||||
<FilterControls.Group label="District">
|
||||
<FilterControls.Group label={t('filters.district')}>
|
||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
||||
<option value="all">All Districts</option>
|
||||
<option value="all">{t('filters.allDistricts')}</option>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label="Museum">
|
||||
<FilterControls.Group label={t('filters.museum')}>
|
||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
||||
<option value="all">All Museums</option>
|
||||
<option value="all">{t('filters.allMuseums')}</option>
|
||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
</FilterControls.Row>
|
||||
<div className="period-display">
|
||||
<div className="period-box">
|
||||
<div className="label">{getPeriodLabel(ranges.prev.start, ranges.prev.end)}</div>
|
||||
<div className="dates">{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}</div>
|
||||
</div>
|
||||
<div className="period-box">
|
||||
<div className="label">{getPeriodLabel(ranges.curr.start, ranges.curr.end)}</div>
|
||||
<div className="dates">{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</FilterControls>
|
||||
|
||||
<div className="period-display-banner" id="comparison-period">
|
||||
<div className="period-box prev">
|
||||
<div className="period-label">{t('comparison.previousPeriod')}</div>
|
||||
<div className="period-value">{getPeriodLabel(ranges.prev.start, ranges.prev.end)}</div>
|
||||
<div className="period-dates">{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}</div>
|
||||
</div>
|
||||
<div className="period-vs">{t('comparison.vs')}</div>
|
||||
<div className="period-box curr">
|
||||
<div className="period-label">{t('comparison.currentPeriod')}</div>
|
||||
<div className="period-value">{getPeriodLabel(ranges.curr.start, ranges.curr.end)}</div>
|
||||
<div className="period-dates">{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasData ? (
|
||||
<EmptyState
|
||||
icon="📈"
|
||||
title="No data for this period"
|
||||
message="No records found for the selected date range and filters."
|
||||
title={t('comparison.noData')}
|
||||
message={t('comparison.noDataMessage')}
|
||||
action={resetFilters}
|
||||
actionLabel="Reset Filters"
|
||||
actionLabel={t('filters.reset')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop: Grid layout */}
|
||||
<div className="comparison-grid desktop-only">
|
||||
<div className="comparison-grid desktop-only" id="comparison-metrics">
|
||||
{metricCards.map((card, i) => (
|
||||
<MetricCard
|
||||
key={i}
|
||||
@@ -575,43 +601,48 @@ function Comparison({ data, showDataLabels }) {
|
||||
</div>
|
||||
|
||||
{/* Desktop: Show both charts */}
|
||||
<div className="charts-grid desktop-only">
|
||||
<div className="charts-grid desktop-only" id="comparison-charts">
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2>
|
||||
<div className="chart-selectors">
|
||||
<div className="toggle-switch">
|
||||
{granularityOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartGranularity === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartGranularity(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="chart-metric-selector">
|
||||
{metricOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartMetric === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartMetric(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExportableChart filename="trend-comparison" className="chart-container">
|
||||
<ExportableChart
|
||||
filename="trend-comparison"
|
||||
title={`${metricOptions.find(m => m.value === chartMetric)?.label} - ${t('comparison.trend')}`}
|
||||
className="chart-container"
|
||||
controls={
|
||||
<>
|
||||
<div className="toggle-switch">
|
||||
{granularityOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartGranularity === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartGranularity(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="chart-metric-selector">
|
||||
{metricOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartMetric === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartMetric(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Line data={timeSeriesChart} options={chartOptions} />
|
||||
</ExportableChart>
|
||||
</div>
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} by Museum</h2>
|
||||
<div className="chart-selectors">
|
||||
<ExportableChart
|
||||
filename="museum-comparison"
|
||||
title={`${metricOptions.find(m => m.value === chartMetric)?.label} - ${t('comparison.byMuseum')}`}
|
||||
className="chart-container"
|
||||
controls={
|
||||
<div className="chart-metric-selector">
|
||||
{metricOptions.map(opt => (
|
||||
<button
|
||||
@@ -623,9 +654,8 @@ function Comparison({ data, showDataLabels }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExportableChart filename="museum-comparison" className="chart-container">
|
||||
}
|
||||
>
|
||||
<Bar data={museumChart} options={chartOptions} />
|
||||
</ExportableChart>
|
||||
</div>
|
||||
@@ -644,7 +674,7 @@ function Comparison({ data, showDataLabels }) {
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2>
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} - {t('comparison.trend')}</h2>
|
||||
<div className="toggle-switch">
|
||||
{granularityOptions.map(opt => (
|
||||
<button
|
||||
@@ -678,7 +708,7 @@ function Comparison({ data, showDataLabels }) {
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} by Museum</h2>
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} - {t('comparison.byMuseum')}</h2>
|
||||
</div>
|
||||
<div className="chart-selectors-inline">
|
||||
<div className="chart-metric-selector">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Line, Doughnut, Bar } from 'react-chartjs-2';
|
||||
import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
|
||||
import { ExportableChart } from './ChartExport';
|
||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import {
|
||||
filterData,
|
||||
calculateMetrics,
|
||||
@@ -28,7 +29,8 @@ const defaultFilters = {
|
||||
|
||||
const filterKeys = ['year', 'district', 'museum', 'quarter'];
|
||||
|
||||
function Dashboard({ data, showDataLabels }) {
|
||||
function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
const { t } = useLanguage();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Initialize filters from URL or defaults
|
||||
@@ -67,17 +69,17 @@ function Dashboard({ data, showDataLabels }) {
|
||||
|
||||
// Stat cards for carousel
|
||||
const statCards = useMemo(() => [
|
||||
{ title: 'Total Revenue', value: formatCurrency(metrics.revenue), hasYoy: true },
|
||||
{ title: 'Total Visitors', value: formatNumber(metrics.visitors) },
|
||||
{ title: 'Total Tickets', value: formatNumber(metrics.tickets) },
|
||||
{ title: 'Avg Rev/Visitor', value: formatCurrency(metrics.avgRevPerVisitor) }
|
||||
], [metrics]);
|
||||
{ title: t('metrics.totalRevenue'), value: formatCurrency(metrics.revenue), hasYoy: true },
|
||||
{ title: t('metrics.totalVisitors'), value: formatNumber(metrics.visitors) },
|
||||
{ title: t('metrics.totalTickets'), value: formatNumber(metrics.tickets) },
|
||||
{ title: t('metrics.avgRevenue'), value: formatCurrency(metrics.avgRevPerVisitor) }
|
||||
], [metrics, t]);
|
||||
|
||||
// Chart carousel labels
|
||||
const chartLabels = useMemo(() => {
|
||||
const labels = ['Revenue Trend', 'Visitors', 'Revenue', 'Quarterly', 'District', 'Capture Rate'];
|
||||
const labels = [t('charts.revenueTrend'), t('charts.visitors'), t('charts.revenue'), t('charts.quarterly'), t('charts.district'), t('charts.captureRate')];
|
||||
return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2);
|
||||
}, [filters.museum]);
|
||||
}, [filters.museum, t]);
|
||||
|
||||
// Dynamic lists from data
|
||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
||||
@@ -248,7 +250,7 @@ function Dashboard({ data, showDataLabels }) {
|
||||
color: '#1e293b',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 3,
|
||||
font: { size: 9, weight: 600 },
|
||||
font: { size: 10, weight: 600 },
|
||||
anchor: 'end',
|
||||
align: 'top',
|
||||
offset: 6
|
||||
@@ -274,7 +276,7 @@ function Dashboard({ data, showDataLabels }) {
|
||||
color: '#1e293b',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 3,
|
||||
font: { size: 9, weight: 600 },
|
||||
font: { size: 10, weight: 600 },
|
||||
anchor: 'start',
|
||||
align: 'bottom',
|
||||
offset: 6
|
||||
@@ -314,50 +316,59 @@ function Dashboard({ data, showDataLabels }) {
|
||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="page-title">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Real-time museum analytics from Google Sheets</p>
|
||||
<div className="dashboard" id="dashboard-container">
|
||||
<div className="page-title-with-actions">
|
||||
<div className="page-title">
|
||||
<h1>{t('dashboard.title')}</h1>
|
||||
<p>{t('dashboard.subtitle')}</p>
|
||||
</div>
|
||||
<div className="toggle-with-label">
|
||||
<span className="toggle-text">{t('nav.labels')}</span>
|
||||
<div className="toggle-switch">
|
||||
<button className={!showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(false)}>{t('toggle.off')}</button>
|
||||
<button className={showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(true)}>{t('toggle.on')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterControls title="Filters" onReset={resetFilters}>
|
||||
<FilterControls title={t('filters.title')} onReset={resetFilters}>
|
||||
<FilterControls.Row>
|
||||
<FilterControls.Group label="Year">
|
||||
<FilterControls.Group label={t('filters.year')}>
|
||||
<select value={filters.year} onChange={e => setFilters({...filters, year: e.target.value})}>
|
||||
<option value="all">All Years</option>
|
||||
<option value="all">{t('filters.allYears')}</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label="District">
|
||||
<FilterControls.Group label={t('filters.district')}>
|
||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
||||
<option value="all">All Districts</option>
|
||||
<option value="all">{t('filters.allDistricts')}</option>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label="Museum">
|
||||
<FilterControls.Group label={t('filters.museum')}>
|
||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
||||
<option value="all">All Museums</option>
|
||||
<option value="all">{t('filters.allMuseums')}</option>
|
||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label="Quarter">
|
||||
<FilterControls.Group label={t('filters.quarter')}>
|
||||
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
|
||||
<option value="all">All Quarters</option>
|
||||
<option value="1">Q1</option>
|
||||
<option value="2">Q2</option>
|
||||
<option value="3">Q3</option>
|
||||
<option value="4">Q4</option>
|
||||
<option value="all">{t('filters.allQuarters')}</option>
|
||||
<option value="1">{t('time.q1')}</option>
|
||||
<option value="2">{t('time.q2')}</option>
|
||||
<option value="3">{t('time.q3')}</option>
|
||||
<option value="4">{t('time.q4')}</option>
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
</FilterControls.Row>
|
||||
</FilterControls>
|
||||
|
||||
{/* Desktop: Grid */}
|
||||
<div className="stats-grid desktop-only">
|
||||
<StatCard title="Total Revenue" value={formatCurrency(metrics.revenue)} change={yoyChange} />
|
||||
<StatCard title="Total Visitors" value={formatNumber(metrics.visitors)} />
|
||||
<StatCard title="Total Tickets" value={formatNumber(metrics.tickets)} />
|
||||
<StatCard title="Avg Revenue/Visitor" value={formatCurrency(metrics.avgRevPerVisitor)} />
|
||||
<div className="stats-grid desktop-only" id="dashboard-stats">
|
||||
<StatCard title={t('metrics.totalRevenue')} value={formatCurrency(metrics.revenue)} change={yoyChange} />
|
||||
<StatCard title={t('metrics.totalVisitors')} value={formatNumber(metrics.visitors)} />
|
||||
<StatCard title={t('metrics.totalTickets')} value={formatNumber(metrics.tickets)} />
|
||||
<StatCard title={t('metrics.avgRevenuePerVisitor')} value={formatCurrency(metrics.avgRevPerVisitor)} />
|
||||
</div>
|
||||
|
||||
{/* Mobile: Stats Carousel */}
|
||||
@@ -381,28 +392,28 @@ function Dashboard({ data, showDataLabels }) {
|
||||
{!hasData ? (
|
||||
<EmptyState
|
||||
icon="📊"
|
||||
title="No data found"
|
||||
message="No records match your current filters. Try adjusting your selection."
|
||||
title={t('dashboard.noData')}
|
||||
message={t('dashboard.noDataMessage')}
|
||||
action={resetFilters}
|
||||
actionLabel="Reset Filters"
|
||||
actionLabel={t('filters.reset')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="chart-card full-width" style={{marginBottom: '16px'}}>
|
||||
<h2>Quarterly Comparison: 2024 vs 2025</h2>
|
||||
<div className="chart-card full-width" style={{marginBottom: '16px'}} id="quarterly-table">
|
||||
<h2>{t('dashboard.quarterlyComparison')}</h2>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th>Rev 2024</th>
|
||||
<th>Rev 2025</th>
|
||||
<th>Change</th>
|
||||
<th>Visitors 2024</th>
|
||||
<th>Visitors 2025</th>
|
||||
<th>Change</th>
|
||||
<th>Capture 2024</th>
|
||||
<th>Capture 2025</th>
|
||||
<th>{t('table.quarter')}</th>
|
||||
<th>{t('table.rev2024')}</th>
|
||||
<th>{t('table.rev2025')}</th>
|
||||
<th>{t('table.change')}</th>
|
||||
<th>{t('table.visitors2024')}</th>
|
||||
<th>{t('table.visitors2025')}</th>
|
||||
<th>{t('table.change')}</th>
|
||||
<th>{t('table.capture2024')}</th>
|
||||
<th>{t('table.capture2025')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -429,58 +440,58 @@ function Dashboard({ data, showDataLabels }) {
|
||||
</div>
|
||||
|
||||
{/* Desktop: Charts Grid */}
|
||||
<div className="charts-grid desktop-only">
|
||||
<div className="charts-grid desktop-only" id="dashboard-charts">
|
||||
<div className="chart-card full-width">
|
||||
<h2>Revenue Trends</h2>
|
||||
<div className="toggle-switch toggle-corner">
|
||||
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
|
||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
|
||||
</div>
|
||||
<ExportableChart filename="revenue-trend" className="chart-container">
|
||||
<ExportableChart
|
||||
filename="revenue-trend"
|
||||
title={t('dashboard.revenueTrends')}
|
||||
className="chart-container"
|
||||
controls={
|
||||
<div className="toggle-switch">
|
||||
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>{t('time.daily')}</button>
|
||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
||||
</ExportableChart>
|
||||
</div>
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="chart-card half-width">
|
||||
<h2>Visitors by Museum</h2>
|
||||
<ExportableChart filename="visitors-by-museum" className="chart-container">
|
||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 11}}}}}} />
|
||||
<ExportableChart filename="visitors-by-museum" title={t('dashboard.visitorsByMuseum')} className="chart-container">
|
||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 13}}}}}} />
|
||||
</ExportableChart>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="chart-card half-width">
|
||||
<h2>Revenue by Museum</h2>
|
||||
<ExportableChart filename="revenue-by-museum" className="chart-container">
|
||||
<ExportableChart filename="revenue-by-museum" title={t('dashboard.revenueByMuseum')} className="chart-container">
|
||||
<Bar data={museumData.revenue} options={baseOptions} />
|
||||
</ExportableChart>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chart-card half-width">
|
||||
<h2>Quarterly Revenue (YoY)</h2>
|
||||
<ExportableChart filename="quarterly-yoy" className="chart-container">
|
||||
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 11}}}}}} />
|
||||
<ExportableChart filename="quarterly-yoy" title={t('dashboard.quarterlyRevenue')} className="chart-container">
|
||||
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}}} />
|
||||
</ExportableChart>
|
||||
</div>
|
||||
|
||||
<div className="chart-card half-width">
|
||||
<h2>District Performance</h2>
|
||||
<ExportableChart filename="district-performance" className="chart-container">
|
||||
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
|
||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
</ExportableChart>
|
||||
</div>
|
||||
|
||||
<div className="chart-card full-width">
|
||||
<h2>Capture Rate vs Umrah Pilgrims</h2>
|
||||
<ExportableChart filename="capture-rate" className="chart-container">
|
||||
<ExportableChart filename="capture-rate" title={t('dashboard.captureRateChart')} className="chart-container">
|
||||
<Line data={captureRateData} options={{
|
||||
...baseOptions,
|
||||
plugins: {
|
||||
...baseOptions.plugins,
|
||||
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } },
|
||||
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } },
|
||||
tooltip: {
|
||||
...baseOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
@@ -499,17 +510,17 @@ function Dashboard({ data, showDataLabels }) {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: { color: chartColors.grid },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
||||
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
||||
border: { display: false },
|
||||
title: { display: true, text: 'Capture Rate (%)', font: { size: 10 }, color: chartColors.secondary }
|
||||
title: { display: true, text: 'Capture Rate (%)', font: { size: 12 }, color: chartColors.secondary }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
||||
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
||||
border: { display: false },
|
||||
title: { display: true, text: 'Pilgrims', font: { size: 10 }, color: chartColors.tertiary }
|
||||
title: { display: true, text: 'Pilgrims', font: { size: 12 }, color: chartColors.tertiary }
|
||||
}
|
||||
}
|
||||
}} />
|
||||
@@ -527,10 +538,10 @@ function Dashboard({ data, showDataLabels }) {
|
||||
>
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Revenue Trends</h2>
|
||||
<h2>{t('dashboard.revenueTrends')}</h2>
|
||||
<div className="toggle-switch toggle-corner">
|
||||
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
|
||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
|
||||
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>{t('time.daily')}</button>
|
||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
|
||||
@@ -541,9 +552,9 @@ function Dashboard({ data, showDataLabels }) {
|
||||
{filters.museum === 'all' && (
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Visitors by Museum</h2>
|
||||
<h2>{t('dashboard.visitorsByMuseum')}</h2>
|
||||
<div className="chart-container">
|
||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 12, font: {size: 10}}}}}} />
|
||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 12, font: {size: 12}}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -552,7 +563,7 @@ function Dashboard({ data, showDataLabels }) {
|
||||
{filters.museum === 'all' && (
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Revenue by Museum</h2>
|
||||
<h2>{t('dashboard.revenueByMuseum')}</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={museumData.revenue} options={baseOptions} />
|
||||
</div>
|
||||
@@ -562,16 +573,16 @@ function Dashboard({ data, showDataLabels }) {
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Quarterly Revenue (YoY)</h2>
|
||||
<h2>{t('dashboard.quarterlyRevenue')}</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 8, font: {size: 10}}}}}} />
|
||||
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 8, font: {size: 12}}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>District Performance</h2>
|
||||
<h2>{t('dashboard.districtPerformance')}</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
</div>
|
||||
@@ -580,13 +591,13 @@ function Dashboard({ data, showDataLabels }) {
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Capture Rate vs Umrah Pilgrims</h2>
|
||||
<h2>{t('dashboard.captureRateChart')}</h2>
|
||||
<div className="chart-container">
|
||||
<Line data={captureRateData} options={{
|
||||
...baseOptions,
|
||||
plugins: {
|
||||
...baseOptions.plugins,
|
||||
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 10, padding: 8, font: { size: 9 } } },
|
||||
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 10, padding: 8, font: { size: 13 } } },
|
||||
tooltip: {
|
||||
...baseOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
@@ -605,14 +616,14 @@ function Dashboard({ data, showDataLabels }) {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: { color: chartColors.grid },
|
||||
ticks: { font: { size: 9 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
||||
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
||||
border: { display: false }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { font: { size: 9 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
||||
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
||||
border: { display: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Line, Bar } from 'react-chartjs-2';
|
||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import {
|
||||
filterDataByDateRange,
|
||||
calculateMetrics,
|
||||
@@ -12,20 +13,21 @@ import {
|
||||
} from '../services/dataService';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
const CHART_TYPES = [
|
||||
{ id: 'trend', label: 'Revenue Trend', icon: '📈' },
|
||||
{ id: 'museum-bar', label: 'By Museum', icon: '📊' },
|
||||
{ id: 'kpi-cards', label: 'KPI Summary', icon: '🎯' },
|
||||
{ id: 'comparison', label: 'YoY Comparison', icon: '⚖️' }
|
||||
];
|
||||
|
||||
const METRICS = [
|
||||
{ id: 'revenue', label: 'Revenue', field: 'revenue_incl_tax' },
|
||||
{ id: 'visitors', label: 'Visitors', field: 'visits' },
|
||||
{ id: 'tickets', label: 'Tickets', field: 'tickets' }
|
||||
];
|
||||
|
||||
function Slides({ data }) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const CHART_TYPES = useMemo(() => [
|
||||
{ id: 'trend', label: t('slides.revenueTrend'), icon: '📈' },
|
||||
{ id: 'museum-bar', label: t('slides.byMuseum'), icon: '📊' },
|
||||
{ id: 'kpi-cards', label: t('slides.kpiSummary'), icon: '🎯' },
|
||||
{ id: 'comparison', label: t('slides.yoyComparison'), icon: '⚖️' }
|
||||
], [t]);
|
||||
|
||||
const METRICS = useMemo(() => [
|
||||
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' },
|
||||
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
|
||||
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
|
||||
], [t]);
|
||||
const [slides, setSlides] = useState([]);
|
||||
const [editingSlide, setEditingSlide] = useState(null);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
@@ -171,6 +173,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
currentSlide={currentPreviewSlide}
|
||||
setCurrentSlide={setCurrentPreviewSlide}
|
||||
onExit={() => setPreviewMode(false)}
|
||||
metrics={METRICS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -178,8 +181,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
return (
|
||||
<div className="slides-builder">
|
||||
<div className="page-title">
|
||||
<h1>Presentation Builder</h1>
|
||||
<p>Create slides with charts and export as HTML or PDF</p>
|
||||
<h1>{t('slides.title')}</h1>
|
||||
<p>{t('slides.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="slides-toolbar">
|
||||
@@ -187,7 +190,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Add Slide
|
||||
{t('slides.addSlide')}
|
||||
</button>
|
||||
{slides.length > 0 && (
|
||||
<>
|
||||
@@ -195,13 +198,13 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<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>
|
||||
Preview
|
||||
{t('slides.preview')}
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={exportAsHTML}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Export HTML
|
||||
{t('slides.exportHtml')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -209,11 +212,11 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
|
||||
<div className="slides-workspace">
|
||||
<div className="slides-list">
|
||||
<h3>Slides ({slides.length})</h3>
|
||||
<h3>{t('slides.slidesCount')} ({slides.length})</h3>
|
||||
{slides.length === 0 ? (
|
||||
<div className="empty-slides">
|
||||
<p>No slides yet</p>
|
||||
<button onClick={addSlide}>Add your first slide</button>
|
||||
<p>{t('slides.noSlides')}</p>
|
||||
<button onClick={addSlide}>{t('slides.addFirst')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="slides-thumbnails">
|
||||
@@ -245,6 +248,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
districts={districts}
|
||||
districtMuseumMap={districtMuseumMap}
|
||||
data={data}
|
||||
chartTypes={CHART_TYPES}
|
||||
metrics={METRICS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -252,7 +257,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
);
|
||||
}
|
||||
|
||||
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data }) {
|
||||
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }) {
|
||||
const { t } = useLanguage();
|
||||
const availableMuseums = useMemo(() =>
|
||||
getMuseumsForDistrict(districtMuseumMap, slide.district),
|
||||
[districtMuseumMap, slide.district]
|
||||
@@ -261,19 +267,19 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data }) {
|
||||
return (
|
||||
<div className="slide-editor">
|
||||
<div className="editor-section">
|
||||
<label>Slide Title</label>
|
||||
<label>{t('slides.slideTitle')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slide.title}
|
||||
onChange={e => onUpdate({ title: e.target.value })}
|
||||
placeholder="Enter slide title"
|
||||
placeholder={t('slides.slideTitle')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="editor-section">
|
||||
<label>Chart Type</label>
|
||||
<label>{t('slides.chartType')}</label>
|
||||
<div className="chart-type-grid">
|
||||
{CHART_TYPES.map(type => (
|
||||
{chartTypes.map(type => (
|
||||
<button
|
||||
key={type.id}
|
||||
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
|
||||
@@ -287,35 +293,35 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data }) {
|
||||
</div>
|
||||
|
||||
<div className="editor-section">
|
||||
<label>Metric</label>
|
||||
<label>{t('slides.metric')}</label>
|
||||
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}>
|
||||
{METRICS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
{metrics.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="editor-row">
|
||||
<div className="editor-section">
|
||||
<label>Start Date</label>
|
||||
<label>{t('slides.startDate')}</label>
|
||||
<input type="date" value={slide.startDate} onChange={e => onUpdate({ startDate: e.target.value })} />
|
||||
</div>
|
||||
<div className="editor-section">
|
||||
<label>End Date</label>
|
||||
<label>{t('slides.endDate')}</label>
|
||||
<input type="date" value={slide.endDate} onChange={e => onUpdate({ endDate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-row">
|
||||
<div className="editor-section">
|
||||
<label>District</label>
|
||||
<label>{t('filters.district')}</label>
|
||||
<select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}>
|
||||
<option value="all">All Districts</option>
|
||||
<option value="all">{t('filters.allDistricts')}</option>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="editor-section">
|
||||
<label>Museum</label>
|
||||
<label>{t('filters.museum')}</label>
|
||||
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
|
||||
<option value="all">All Museums</option>
|
||||
<option value="all">{t('filters.allMuseums')}</option>
|
||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -329,20 +335,28 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data }) {
|
||||
checked={slide.showComparison}
|
||||
onChange={e => onUpdate({ showComparison: e.target.checked })}
|
||||
/>
|
||||
Show Year-over-Year Comparison
|
||||
{t('slides.showYoY')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="slide-preview-box">
|
||||
<h4>Preview</h4>
|
||||
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} />
|
||||
<h4>{t('slides.preview')}</h4>
|
||||
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlidePreview({ slide, data, districts, districtMuseumMap }) {
|
||||
// Static field mapping for charts (Chart.js labels don't need i18n)
|
||||
const METRIC_FIELDS = {
|
||||
revenue: { field: 'revenue_incl_tax', label: 'Revenue' },
|
||||
visitors: { field: 'visits', label: 'Visitors' },
|
||||
tickets: { field: 'tickets', label: 'Tickets' }
|
||||
};
|
||||
|
||||
function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
||||
const { t } = useLanguage();
|
||||
const filteredData = useMemo(() =>
|
||||
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
@@ -351,7 +365,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap }) {
|
||||
[data, slide.startDate, slide.endDate, slide.district, slide.museum]
|
||||
);
|
||||
|
||||
const metrics = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
||||
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
||||
const baseOptions = useMemo(() => createBaseOptions(false), []);
|
||||
|
||||
const getMetricValue = useCallback((rows, metric) => {
|
||||
@@ -369,10 +383,11 @@ function SlidePreview({ slide, data, districts, districtMuseumMap }) {
|
||||
});
|
||||
|
||||
const sortedDates = Object.keys(grouped).sort();
|
||||
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
||||
return {
|
||||
labels: sortedDates.map(d => d.substring(5)),
|
||||
datasets: [{
|
||||
label: METRICS.find(m => m.id === slide.metric)?.label,
|
||||
label: metricLabel,
|
||||
data: sortedDates.map(d => getMetricValue(grouped[d], slide.metric)),
|
||||
borderColor: chartColors.primary,
|
||||
backgroundColor: chartColors.primary + '20',
|
||||
@@ -380,7 +395,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap }) {
|
||||
tension: 0.4
|
||||
}]
|
||||
};
|
||||
}, [filteredData, slide.metric, getMetricValue]);
|
||||
}, [filteredData, slide.metric, getMetricValue, metrics]);
|
||||
|
||||
const museumData = useMemo(() => {
|
||||
const byMuseum = {};
|
||||
@@ -391,31 +406,32 @@ function SlidePreview({ slide, data, districts, districtMuseumMap }) {
|
||||
});
|
||||
|
||||
const museums = Object.keys(byMuseum).sort();
|
||||
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
||||
return {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
label: METRICS.find(m => m.id === slide.metric)?.label,
|
||||
label: metricLabel,
|
||||
data: museums.map(m => getMetricValue(byMuseum[m], slide.metric)),
|
||||
backgroundColor: chartColors.primary,
|
||||
borderRadius: 6
|
||||
}]
|
||||
};
|
||||
}, [filteredData, slide.metric, getMetricValue]);
|
||||
}, [filteredData, slide.metric, getMetricValue, metrics]);
|
||||
|
||||
if (slide.chartType === 'kpi-cards') {
|
||||
return (
|
||||
<div className="preview-kpis">
|
||||
<div className="preview-kpi">
|
||||
<div className="kpi-value">{formatCompactCurrency(metrics.revenue)}</div>
|
||||
<div className="kpi-label">Revenue</div>
|
||||
<div className="kpi-value">{formatCompactCurrency(metricsData.revenue)}</div>
|
||||
<div className="kpi-label">{t('metrics.revenue')}</div>
|
||||
</div>
|
||||
<div className="preview-kpi">
|
||||
<div className="kpi-value">{formatCompact(metrics.visitors)}</div>
|
||||
<div className="kpi-label">Visitors</div>
|
||||
<div className="kpi-value">{formatCompact(metricsData.visitors)}</div>
|
||||
<div className="kpi-label">{t('metrics.visitors')}</div>
|
||||
</div>
|
||||
<div className="preview-kpi">
|
||||
<div className="kpi-value">{formatCompact(metrics.tickets)}</div>
|
||||
<div className="kpi-label">Tickets</div>
|
||||
<div className="kpi-value">{formatCompact(metricsData.tickets)}</div>
|
||||
<div className="kpi-label">{t('metrics.tickets')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -436,7 +452,8 @@ function SlidePreview({ slide, data, districts, districtMuseumMap }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit }) {
|
||||
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit, metrics }) {
|
||||
const { t } = useLanguage();
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||
setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1));
|
||||
@@ -459,7 +476,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
||||
<div className="preview-slide">
|
||||
<h1 className="preview-title">{slide?.title}</h1>
|
||||
<div className="preview-content">
|
||||
{slide && <SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} />}
|
||||
{slide && <SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />}
|
||||
</div>
|
||||
<div className="preview-footer">
|
||||
<span>{currentSlide + 1} / {slides.length}</span>
|
||||
@@ -468,7 +485,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
||||
<div className="preview-controls">
|
||||
<button onClick={() => setCurrentSlide(prev => Math.max(prev - 1, 0))} disabled={currentSlide === 0}>←</button>
|
||||
<button onClick={() => setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1))} disabled={currentSlide === slides.length - 1}>→</button>
|
||||
<button onClick={onExit}>Exit</button>
|
||||
<button onClick={onExit}>{t('slides.exit')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import React, { useRef, useCallback, useState } from 'react';
|
||||
|
||||
function Carousel({
|
||||
children,
|
||||
@@ -8,25 +8,66 @@ function Carousel({
|
||||
showLabels = true,
|
||||
className = ''
|
||||
}) {
|
||||
const touchStart = useRef(null);
|
||||
const touchStartX = useRef(null);
|
||||
const touchStartY = useRef(null);
|
||||
const trackRef = useRef(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState(0);
|
||||
const itemCount = React.Children.count(children);
|
||||
|
||||
|
||||
// Threshold for swipe detection
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
const VELOCITY_THRESHOLD = 0.3;
|
||||
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
touchStart.current = e.touches[0].clientX;
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
setIsDragging(true);
|
||||
setDragOffset(0);
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (!touchStartX.current || !isDragging) return;
|
||||
|
||||
const currentX = e.touches[0].clientX;
|
||||
const currentY = e.touches[0].clientY;
|
||||
const diffX = currentX - touchStartX.current;
|
||||
const diffY = currentY - touchStartY.current;
|
||||
|
||||
// Only handle horizontal swipes
|
||||
if (Math.abs(diffX) > Math.abs(diffY)) {
|
||||
e.preventDefault();
|
||||
// Add resistance at edges
|
||||
let offset = diffX;
|
||||
if ((activeIndex === 0 && diffX > 0) || (activeIndex === itemCount - 1 && diffX < 0)) {
|
||||
offset = diffX * 0.3; // Rubber band effect
|
||||
}
|
||||
setDragOffset(offset);
|
||||
}
|
||||
}, [isDragging, activeIndex, itemCount]);
|
||||
|
||||
const handleTouchEnd = useCallback((e) => {
|
||||
if (!touchStart.current) return;
|
||||
const diff = touchStart.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (!touchStartX.current || !isDragging) return;
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const diff = touchStartX.current - endX;
|
||||
const velocity = Math.abs(diff) / 200; // Rough velocity calc
|
||||
|
||||
// Determine if we should change slide
|
||||
if (Math.abs(diff) > SWIPE_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
||||
if (diff > 0 && activeIndex < itemCount - 1) {
|
||||
setActiveIndex(activeIndex + 1);
|
||||
} else if (diff < 0 && activeIndex > 0) {
|
||||
setActiveIndex(activeIndex - 1);
|
||||
}
|
||||
}
|
||||
touchStart.current = null;
|
||||
}, [activeIndex, setActiveIndex, itemCount]);
|
||||
|
||||
// Reset
|
||||
touchStartX.current = null;
|
||||
touchStartY.current = null;
|
||||
setIsDragging(false);
|
||||
setDragOffset(0);
|
||||
}, [isDragging, activeIndex, setActiveIndex, itemCount]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'ArrowLeft' && activeIndex > 0) {
|
||||
@@ -36,18 +77,40 @@ function Carousel({
|
||||
}
|
||||
}, [activeIndex, setActiveIndex, itemCount]);
|
||||
|
||||
// Calculate transform
|
||||
const baseTransform = -(activeIndex * 100);
|
||||
const dragPercentage = trackRef.current ? (dragOffset / trackRef.current.offsetWidth) * 100 : 0;
|
||||
const transform = baseTransform + dragPercentage;
|
||||
|
||||
return (
|
||||
<div className={`carousel ${className}`} onKeyDown={handleKeyDown} tabIndex={0}>
|
||||
<div
|
||||
className={`carousel ${className}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="Carousel"
|
||||
>
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
|
||||
style={{
|
||||
transform: `translateX(${transform}%)`,
|
||||
transition: isDragging ? 'none' : 'transform 400ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{React.Children.map(children, (child, i) => (
|
||||
<div className="carousel-slide" key={i}>
|
||||
<div
|
||||
className="carousel-slide"
|
||||
key={i}
|
||||
role="tabpanel"
|
||||
aria-hidden={activeIndex !== i}
|
||||
aria-label={labels[i] || `Slide ${i + 1}`}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
@@ -64,6 +127,7 @@ function Carousel({
|
||||
role="tab"
|
||||
aria-selected={activeIndex === i}
|
||||
aria-label={labels[i] || `Slide ${i + 1}`}
|
||||
aria-controls={`slide-${i}`}
|
||||
>
|
||||
{showLabels && labels[i] && (
|
||||
<span className="dot-label">{labels[i]}</span>
|
||||
|
||||
@@ -2,18 +2,29 @@ import React from 'react';
|
||||
|
||||
function EmptyState({
|
||||
icon = '📊',
|
||||
title = 'No data found',
|
||||
message = 'Try adjusting your filters',
|
||||
action,
|
||||
actionLabel = 'Reset Filters'
|
||||
title,
|
||||
message,
|
||||
action = null,
|
||||
actionLabel = 'Try Again',
|
||||
className = ''
|
||||
}) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">{icon}</div>
|
||||
<h3 className="empty-state-title">{title}</h3>
|
||||
<p className="empty-state-message">{message}</p>
|
||||
<div className={`empty-state ${className}`}>
|
||||
<div className="empty-state-icon" role="img" aria-hidden="true">
|
||||
{icon}
|
||||
</div>
|
||||
{title && (
|
||||
<h3 className="empty-state-title">{title}</h3>
|
||||
)}
|
||||
{message && (
|
||||
<p className="empty-state-message">{message}</p>
|
||||
)}
|
||||
{action && (
|
||||
<button className="empty-state-action" onClick={action}>
|
||||
<button
|
||||
className="empty-state-action"
|
||||
onClick={action}
|
||||
type="button"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,33 +1,86 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
function FilterControls({
|
||||
children,
|
||||
title = 'Filters',
|
||||
title,
|
||||
defaultExpanded = true,
|
||||
onReset = null,
|
||||
className = ''
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const { t } = useLanguage();
|
||||
const displayTitle = title || t('filters.title');
|
||||
|
||||
// Start collapsed on mobile
|
||||
const [expanded, setExpanded] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth > 768 ? defaultExpanded : false;
|
||||
}
|
||||
return defaultExpanded;
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
// Auto-expand on desktop, keep user preference on mobile
|
||||
if (window.innerWidth > 768) {
|
||||
setExpanded(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpanded(!expanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}>
|
||||
<div className="controls-header" onClick={() => setExpanded(!expanded)}>
|
||||
<h3>{title}</h3>
|
||||
<div
|
||||
className="controls-header"
|
||||
onClick={toggleExpanded}
|
||||
role="button"
|
||||
aria-expanded={expanded}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleExpanded();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h3>{displayTitle}</h3>
|
||||
<div className="controls-header-actions">
|
||||
{onReset && expanded && (
|
||||
<button
|
||||
className="controls-reset"
|
||||
onClick={(e) => { e.stopPropagation(); onReset(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReset();
|
||||
}}
|
||||
aria-label={t('filters.reset') || 'Reset filters'}
|
||||
>
|
||||
Reset
|
||||
{t('filters.reset') || 'Reset'}
|
||||
</button>
|
||||
)}
|
||||
<button className="controls-toggle">
|
||||
{expanded ? '▲ Hide' : '▼ Show'}
|
||||
<button
|
||||
className="controls-toggle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{expanded ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="controls-body">
|
||||
|
||||
<div
|
||||
className="controls-body"
|
||||
style={{
|
||||
display: expanded ? 'block' : 'none',
|
||||
animation: expanded ? 'fadeIn 200ms ease' : 'none'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,7 +90,7 @@ function FilterControls({
|
||||
function FilterGroup({ label, children }) {
|
||||
return (
|
||||
<div className="control-group">
|
||||
<label>{label}</label>
|
||||
{label && <label>{label}</label>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
function StatCard({ title, value, change = null, changeLabel = 'YoY' }) {
|
||||
function StatCard({ title, value, change = null, changeLabel = 'YoY', subtitle = null }) {
|
||||
const isPositive = change !== null && change >= 0;
|
||||
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<h3>{title}</h3>
|
||||
<div className="stat-value">{value}</div>
|
||||
{subtitle && (
|
||||
<div className="stat-subtitle">{subtitle}</div>
|
||||
)}
|
||||
{change !== null && (
|
||||
<div className={`stat-change ${isPositive ? 'positive' : 'negative'}`}>
|
||||
{isPositive ? '↑' : '↓'} {Math.abs(change).toFixed(1)}% {changeLabel}
|
||||
<span className="stat-change-arrow">{isPositive ? '↑' : '↓'}</span>
|
||||
<span className="stat-change-value">{Math.abs(change).toFixed(1)}%</span>
|
||||
<span className="stat-change-label">{changeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const chartColors = {
|
||||
export const createDataLabelConfig = (showDataLabels) => ({
|
||||
display: showDataLabels,
|
||||
color: '#1e293b',
|
||||
font: { size: 10, weight: 600 },
|
||||
font: { size: 11, weight: 600 },
|
||||
anchor: 'end',
|
||||
align: 'end',
|
||||
offset: 4,
|
||||
@@ -74,19 +74,19 @@ export const createBaseOptions = (showDataLabels) => ({
|
||||
backgroundColor: '#1e293b',
|
||||
padding: 12,
|
||||
cornerRadius: 8,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 11 }
|
||||
titleFont: { size: 14 },
|
||||
bodyFont: { size: 13 }
|
||||
},
|
||||
datalabels: createDataLabelConfig(showDataLabels)
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8' }
|
||||
ticks: { font: { size: 12 }, color: '#94a3b8' }
|
||||
},
|
||||
y: {
|
||||
grid: { color: chartColors.grid },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8' },
|
||||
ticks: { font: { size: 12 }, color: '#94a3b8' },
|
||||
border: { display: false }
|
||||
}
|
||||
}
|
||||
|
||||
82
src/contexts/LanguageContext.js
Normal file
82
src/contexts/LanguageContext.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import en from '../locales/en.json';
|
||||
import ar from '../locales/ar.json';
|
||||
|
||||
const translations = { en, ar };
|
||||
|
||||
const LanguageContext = createContext();
|
||||
|
||||
export function LanguageProvider({ children }) {
|
||||
const [lang, setLang] = useState(() => {
|
||||
// Check localStorage first, then browser preference
|
||||
const saved = localStorage.getItem('hihala-lang');
|
||||
if (saved && translations[saved]) return saved;
|
||||
|
||||
// Check browser language
|
||||
const browserLang = navigator.language?.split('-')[0];
|
||||
if (browserLang === 'ar') return 'ar';
|
||||
return 'en';
|
||||
});
|
||||
|
||||
const dir = lang === 'ar' ? 'rtl' : 'ltr';
|
||||
|
||||
// Apply direction to document
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = dir;
|
||||
document.documentElement.lang = lang;
|
||||
localStorage.setItem('hihala-lang', lang);
|
||||
}, [lang, dir]);
|
||||
|
||||
// Translation function with dot notation support
|
||||
const t = useCallback((key, fallback) => {
|
||||
const keys = key.split('.');
|
||||
let value = translations[lang];
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
// Try English fallback
|
||||
value = translations.en;
|
||||
for (const ek of keys) {
|
||||
if (value && typeof value === 'object' && ek in value) {
|
||||
value = value[ek];
|
||||
} else {
|
||||
return fallback || key;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : (fallback || key);
|
||||
}, [lang]);
|
||||
|
||||
// Switch language
|
||||
const switchLanguage = useCallback(() => {
|
||||
setLang(prev => prev === 'en' ? 'ar' : 'en');
|
||||
}, []);
|
||||
|
||||
// Set specific language
|
||||
const setLanguage = useCallback((newLang) => {
|
||||
if (translations[newLang]) {
|
||||
setLang(newLang);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ lang, dir, t, switchLanguage, setLanguage }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext);
|
||||
if (!context) {
|
||||
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default LanguageContext;
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<LanguageProvider>
|
||||
<App />
|
||||
</LanguageProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
155
src/locales/ar.json
Normal file
155
src/locales/ar.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "هاي هلا داتا",
|
||||
"loading": "جارٍ تحميل البيانات...",
|
||||
"error": "تعذر تحميل البيانات",
|
||||
"retry": "إعادة المحاولة"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "لوحة التحكم",
|
||||
"comparison": "المقارنة",
|
||||
"compare": "مقارنة",
|
||||
"slides": "الشرائح",
|
||||
"labels": "التسميات",
|
||||
"labelsOn": "التسميات مفعّلة",
|
||||
"labelsOff": "التسميات معطّلة",
|
||||
"labelsTooltip": "عرض القيم على الرسوم البيانية"
|
||||
},
|
||||
"toggle": {
|
||||
"on": "تشغيل",
|
||||
"off": "إيقاف"
|
||||
},
|
||||
"dataSources": {
|
||||
"museums": "المتاحف",
|
||||
"coffees": "المقاهي",
|
||||
"ecommerce": "التجارة الإلكترونية",
|
||||
"soon": "قريباً"
|
||||
},
|
||||
"filters": {
|
||||
"title": "الفلاتر",
|
||||
"year": "السنة",
|
||||
"district": "المنطقة",
|
||||
"museum": "المتحف",
|
||||
"quarter": "الربع",
|
||||
"allYears": "كل السنوات",
|
||||
"allDistricts": "كل المناطق",
|
||||
"allMuseums": "كل المتاحف",
|
||||
"allQuarters": "كل الأرباع",
|
||||
"reset": "إعادة تعيين الفلاتر"
|
||||
},
|
||||
"metrics": {
|
||||
"revenue": "الإيرادات",
|
||||
"totalRevenue": "إجمالي الإيرادات",
|
||||
"visitors": "الزوار",
|
||||
"totalVisitors": "إجمالي الزوار",
|
||||
"tickets": "التذاكر",
|
||||
"totalTickets": "إجمالي التذاكر",
|
||||
"avgRevenue": "متوسط الإيراد/زائر",
|
||||
"avgRevenuePerVisitor": "متوسط الإيراد لكل زائر",
|
||||
"pilgrims": "المعتمرون",
|
||||
"captureRate": "نسبة الاستقطاب"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "لوحة التحكم",
|
||||
"subtitle": "تحليلات المتاحف المباشرة من جداول بيانات Google",
|
||||
"noData": "لا توجد بيانات",
|
||||
"noDataMessage": "لا توجد سجلات تطابق الفلاتر الحالية. حاول تعديل اختيارك.",
|
||||
"quarterlyComparison": "مقارنة ربع سنوية: 2024 مقابل 2025",
|
||||
"revenueTrends": "اتجاهات الإيرادات",
|
||||
"visitorsByMuseum": "الزوار حسب المتحف",
|
||||
"revenueByMuseum": "الإيرادات حسب المتحف",
|
||||
"quarterlyRevenue": "الإيرادات الربعية (سنوي)",
|
||||
"districtPerformance": "أداء المناطق",
|
||||
"captureRateChart": "نسبة الاستقطاب مقابل المعتمرين"
|
||||
},
|
||||
"table": {
|
||||
"quarter": "الربع",
|
||||
"rev2024": "إيرادات 2024",
|
||||
"rev2025": "إيرادات 2025",
|
||||
"change": "التغيير",
|
||||
"visitors2024": "زوار 2024",
|
||||
"visitors2025": "زوار 2025",
|
||||
"capture2024": "استقطاب 2024",
|
||||
"capture2025": "استقطاب 2025"
|
||||
},
|
||||
"time": {
|
||||
"daily": "يومي",
|
||||
"weekly": "أسبوعي",
|
||||
"monthly": "شهري",
|
||||
"q1": "ر1",
|
||||
"q2": "ر2",
|
||||
"q3": "ر3",
|
||||
"q4": "ر4",
|
||||
"h1": "ن1",
|
||||
"h2": "ن2",
|
||||
"fullYear": "السنة كاملة"
|
||||
},
|
||||
"months": {
|
||||
"january": "يناير",
|
||||
"february": "فبراير",
|
||||
"march": "مارس",
|
||||
"april": "أبريل",
|
||||
"may": "مايو",
|
||||
"june": "يونيو",
|
||||
"july": "يوليو",
|
||||
"august": "أغسطس",
|
||||
"september": "سبتمبر",
|
||||
"october": "أكتوبر",
|
||||
"november": "نوفمبر",
|
||||
"december": "ديسمبر"
|
||||
},
|
||||
"comparison": {
|
||||
"title": "مقارنة الفترات",
|
||||
"subtitle": "اختر فترة وسنة — تتم المقارنة تلقائياً مع نفس الفترة من السنة السابقة",
|
||||
"selectPeriod": "اختر الفترة",
|
||||
"period": "الفترة",
|
||||
"from": "من",
|
||||
"to": "إلى",
|
||||
"custom": "مخصص",
|
||||
"previousPeriod": "الفترة السابقة",
|
||||
"currentPeriod": "الفترة الحالية",
|
||||
"vs": "مقابل",
|
||||
"noData": "لا توجد بيانات لهذه الفترة",
|
||||
"noDataMessage": "لم يتم العثور على سجلات للنطاق الزمني والفلاتر المحددة.",
|
||||
"trend": "الاتجاه",
|
||||
"byMuseum": "حسب المتحف",
|
||||
"pendingData": "البيانات لم تُنشر بعد"
|
||||
},
|
||||
"slides": {
|
||||
"title": "منشئ العروض التقديمية",
|
||||
"subtitle": "إنشاء شرائح مع رسوم بيانية وتصديرها كـ HTML أو PDF",
|
||||
"addSlide": "إضافة شريحة",
|
||||
"preview": "معاينة",
|
||||
"exportHtml": "تصدير HTML",
|
||||
"slidesCount": "الشرائح",
|
||||
"noSlides": "لا توجد شرائح بعد",
|
||||
"addFirst": "أضف شريحتك الأولى",
|
||||
"slideTitle": "عنوان الشريحة",
|
||||
"chartType": "نوع الرسم البياني",
|
||||
"metric": "المقياس",
|
||||
"startDate": "تاريخ البداية",
|
||||
"endDate": "تاريخ النهاية",
|
||||
"showYoY": "إظهار مقارنة سنة بسنة",
|
||||
"exit": "خروج",
|
||||
"revenueTrend": "اتجاه الإيرادات",
|
||||
"byMuseum": "حسب المتحف",
|
||||
"kpiSummary": "ملخص مؤشرات الأداء",
|
||||
"yoyComparison": "مقارنة سنوية"
|
||||
},
|
||||
"charts": {
|
||||
"revenueTrend": "اتجاه الإيرادات",
|
||||
"visitors": "الزوار",
|
||||
"revenue": "الإيرادات",
|
||||
"quarterly": "ربع سنوي",
|
||||
"district": "المنطقة",
|
||||
"captureRate": "نسبة الاستقطاب"
|
||||
},
|
||||
"language": {
|
||||
"switch": "EN"
|
||||
},
|
||||
"export": {
|
||||
"exportAll": "تصدير كل الرسوم البيانية",
|
||||
"exportSlides": "تصدير للعرض التقديمي",
|
||||
"exporting": "جارٍ التصدير..."
|
||||
}
|
||||
}
|
||||
155
src/locales/en.json
Normal file
155
src/locales/en.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "HiHala Data",
|
||||
"loading": "Loading data...",
|
||||
"error": "Unable to load data",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"comparison": "Comparison",
|
||||
"compare": "Compare",
|
||||
"slides": "Slides",
|
||||
"labels": "Labels",
|
||||
"labelsOn": "Labels On",
|
||||
"labelsOff": "Labels Off",
|
||||
"labelsTooltip": "Show values on charts"
|
||||
},
|
||||
"toggle": {
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
},
|
||||
"dataSources": {
|
||||
"museums": "Museums",
|
||||
"coffees": "Coffees",
|
||||
"ecommerce": "eCommerce",
|
||||
"soon": "soon"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filters",
|
||||
"year": "Year",
|
||||
"district": "District",
|
||||
"museum": "Museum",
|
||||
"quarter": "Quarter",
|
||||
"allYears": "All Years",
|
||||
"allDistricts": "All Districts",
|
||||
"allMuseums": "All Museums",
|
||||
"allQuarters": "All Quarters",
|
||||
"reset": "Reset Filters"
|
||||
},
|
||||
"metrics": {
|
||||
"revenue": "Revenue",
|
||||
"totalRevenue": "Total Revenue",
|
||||
"visitors": "Visitors",
|
||||
"totalVisitors": "Total Visitors",
|
||||
"tickets": "Tickets",
|
||||
"totalTickets": "Total Tickets",
|
||||
"avgRevenue": "Avg Rev/Visitor",
|
||||
"avgRevenuePerVisitor": "Avg Revenue/Visitor",
|
||||
"pilgrims": "Pilgrims",
|
||||
"captureRate": "Capture Rate"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "Real-time museum analytics from Google Sheets",
|
||||
"noData": "No data found",
|
||||
"noDataMessage": "No records match your current filters. Try adjusting your selection.",
|
||||
"quarterlyComparison": "Quarterly Comparison: 2024 vs 2025",
|
||||
"revenueTrends": "Revenue Trends",
|
||||
"visitorsByMuseum": "Visitors by Museum",
|
||||
"revenueByMuseum": "Revenue by Museum",
|
||||
"quarterlyRevenue": "Quarterly Revenue (YoY)",
|
||||
"districtPerformance": "District Performance",
|
||||
"captureRateChart": "Capture Rate vs Umrah Pilgrims"
|
||||
},
|
||||
"table": {
|
||||
"quarter": "Quarter",
|
||||
"rev2024": "Rev 2024",
|
||||
"rev2025": "Rev 2025",
|
||||
"change": "Change",
|
||||
"visitors2024": "Visitors 2024",
|
||||
"visitors2025": "Visitors 2025",
|
||||
"capture2024": "Capture 2024",
|
||||
"capture2025": "Capture 2025"
|
||||
},
|
||||
"time": {
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"q1": "Q1",
|
||||
"q2": "Q2",
|
||||
"q3": "Q3",
|
||||
"q4": "Q4",
|
||||
"h1": "H1",
|
||||
"h2": "H2",
|
||||
"fullYear": "Full Year"
|
||||
},
|
||||
"months": {
|
||||
"january": "January",
|
||||
"february": "February",
|
||||
"march": "March",
|
||||
"april": "April",
|
||||
"may": "May",
|
||||
"june": "June",
|
||||
"july": "July",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "October",
|
||||
"november": "November",
|
||||
"december": "December"
|
||||
},
|
||||
"comparison": {
|
||||
"title": "Period Comparison",
|
||||
"subtitle": "Select a period and year — automatically compares with the same period in the previous year",
|
||||
"selectPeriod": "Select Period",
|
||||
"period": "Period",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"custom": "Custom",
|
||||
"previousPeriod": "Previous Period",
|
||||
"currentPeriod": "Current Period",
|
||||
"vs": "vs",
|
||||
"noData": "No data for this period",
|
||||
"noDataMessage": "No records found for the selected date range and filters.",
|
||||
"trend": "Trend",
|
||||
"byMuseum": "By Museum",
|
||||
"pendingData": "Data not published yet"
|
||||
},
|
||||
"slides": {
|
||||
"title": "Presentation Builder",
|
||||
"subtitle": "Create slides with charts and export as HTML or PDF",
|
||||
"addSlide": "Add Slide",
|
||||
"preview": "Preview",
|
||||
"exportHtml": "Export HTML",
|
||||
"slidesCount": "Slides",
|
||||
"noSlides": "No slides yet",
|
||||
"addFirst": "Add your first slide",
|
||||
"slideTitle": "Slide Title",
|
||||
"chartType": "Chart Type",
|
||||
"metric": "Metric",
|
||||
"startDate": "Start Date",
|
||||
"endDate": "End Date",
|
||||
"showYoY": "Show Year-over-Year Comparison",
|
||||
"exit": "Exit",
|
||||
"revenueTrend": "Revenue Trend",
|
||||
"byMuseum": "By Museum",
|
||||
"kpiSummary": "KPI Summary",
|
||||
"yoyComparison": "YoY Comparison"
|
||||
},
|
||||
"charts": {
|
||||
"revenueTrend": "Revenue Trend",
|
||||
"visitors": "Visitors",
|
||||
"revenue": "Revenue",
|
||||
"quarterly": "Quarterly",
|
||||
"district": "District",
|
||||
"captureRate": "Capture Rate"
|
||||
},
|
||||
"language": {
|
||||
"switch": "عربي"
|
||||
},
|
||||
"export": {
|
||||
"exportAll": "Export All Charts",
|
||||
"exportSlides": "Export for Slides",
|
||||
"exporting": "Exporting..."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user