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:
fahed
2026-02-03 15:02:29 +03:00
parent f17e19f3f8
commit 0e5d285680
18 changed files with 2610 additions and 1610 deletions

50
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-plugin-datalabels": "^2.2.0", "chartjs-plugin-datalabels": "^2.2.0",
"html2canvas": "^1.4.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
@@ -5092,6 +5093,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.9.19", "version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@@ -5995,6 +6005,15 @@
"postcss": "^8.4" "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": { "node_modules/css-loader": {
"version": "6.11.0", "version": "6.11.0",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", "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": { "node_modules/htmlparser2": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
@@ -15946,6 +15978,15 @@
"node": ">=8" "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": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -16509,6 +16550,15 @@
"node": ">= 0.4.0" "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": { "node_modules/uuid": {
"version": "8.3.2", "version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",

View File

@@ -10,6 +10,7 @@
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-plugin-datalabels": "^2.2.0", "chartjs-plugin-datalabels": "^2.2.0",
"html2canvas": "^1.4.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",

View File

@@ -17,7 +17,7 @@
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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. Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build. It will be replaced with the URL of the `public` folder during the build.

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import Dashboard from './components/Dashboard';
import Comparison from './components/Comparison'; import Comparison from './components/Comparison';
import Slides from './components/Slides'; import Slides from './components/Slides';
import { fetchData } from './services/dataService'; import { fetchData } from './services/dataService';
import { useLanguage } from './contexts/LanguageContext';
import './App.css'; import './App.css';
function NavLink({ to, children }) { function NavLink({ to, children }) {
@@ -17,6 +18,7 @@ function NavLink({ to, children }) {
} }
function App() { function App() {
const { t, dir, switchLanguage } = useLanguage();
const [data, setData] = useState([]); const [data, setData] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -24,9 +26,9 @@ function App() {
const [dataSource, setDataSource] = useState('museums'); const [dataSource, setDataSource] = useState('museums');
const dataSources = [ const dataSources = [
{ id: 'museums', label: 'Museums', enabled: true }, { id: 'museums', labelKey: 'dataSources.museums', enabled: true },
{ id: 'coffees', label: 'Coffees', enabled: false }, { id: 'coffees', labelKey: 'dataSources.coffees', enabled: false },
{ id: 'ecommerce', label: 'eCommerce', enabled: false } { id: 'ecommerce', labelKey: 'dataSources.ecommerce', enabled: false }
]; ];
useEffect(() => { useEffect(() => {
@@ -48,26 +50,26 @@ function App() {
if (loading) { if (loading) {
return ( return (
<div className="loading-container"> <div className="loading-container" dir={dir}>
<div className="loading-spinner"></div> <div className="loading-spinner"></div>
<p>Loading data...</p> <p>{t('app.loading')}</p>
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="error-container"> <div className="error-container" dir={dir}>
<h2>Unable to load data</h2> <h2>{t('app.error')}</h2>
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>{error}</p> <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> </div>
); );
} }
return ( return (
<Router> <Router>
<div className="app"> <div className="app" dir={dir}>
<nav className="nav-bar"> <nav className="nav-bar">
<div className="nav-content"> <div className="nav-content">
<div className="nav-brand"> <div className="nav-brand">
@@ -86,7 +88,7 @@ function App() {
> >
{dataSources.map(src => ( {dataSources.map(src => (
<option key={src.id} value={src.id} disabled={!src.enabled}> <option key={src.id} value={src.id} disabled={!src.enabled}>
{src.label}{!src.enabled ? ' (soon)' : ''} {t(src.labelKey)}{!src.enabled ? ` (${t('dataSources.soon')})` : ''}
</option> </option>
))} ))}
</select> </select>
@@ -100,7 +102,7 @@ function App() {
<rect x="14" y="12" width="7" height="9" rx="1"/> <rect x="14" y="12" width="7" height="9" rx="1"/>
<rect x="3" y="16" width="7" height="5" rx="1"/> <rect x="3" y="16" width="7" height="5" rx="1"/>
</svg> </svg>
Dashboard {t('nav.dashboard')}
</NavLink> </NavLink>
<NavLink to="/comparison"> <NavLink to="/comparison">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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="18 14 22 10 18 6"/>
<polyline points="6 10 2 14 6 18"/> <polyline points="6 10 2 14 6 18"/>
</svg> </svg>
Comparison {t('nav.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
</NavLink> </NavLink>
<button <button
className={`nav-label-toggle ${showDataLabels ? 'active' : ''}`} className="nav-lang-toggle"
onClick={() => setShowDataLabels(!showDataLabels)} onClick={switchLanguage}
title="Show values on charts" title="Switch language"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> {t('language.switch')}
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
{showDataLabels ? 'Labels On' : 'Labels Off'}
</button> </button>
</div> </div>
</div> </div>
</nav> </nav>
<Routes> <Routes>
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} />} /> <Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} />} />
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} />} /> <Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} />} />
<Route path="/slides" element={<Slides data={data} />} /> <Route path="/slides" element={<Slides data={data} />} />
</Routes> </Routes>
@@ -149,7 +140,7 @@ function App() {
<rect x="14" y="12" width="7" height="9" rx="1"/> <rect x="14" y="12" width="7" height="9" rx="1"/>
<rect x="3" y="16" width="7" height="5" rx="1"/> <rect x="3" y="16" width="7" height="5" rx="1"/>
</svg> </svg>
<span>Dashboard</span> <span>{t('nav.dashboard')}</span>
</NavLink> </NavLink>
<NavLink to="/comparison" className="mobile-nav-item"> <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"> <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="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/> <line x1="6" y1="20" x2="6" y2="14"/>
</svg> </svg>
<span>Compare</span> <span>{t('nav.compare')}</span>
</NavLink>
<NavLink to="/slides" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
</NavLink> </NavLink>
<button <button
className={`mobile-nav-item ${showDataLabels ? 'active' : ''}`} className="mobile-nav-item"
onClick={() => setShowDataLabels(!showDataLabels)} onClick={switchLanguage}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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> </svg>
<span>Labels</span> <span>{t('language.switch')}</span>
</button> </button>
</nav> </nav>
</div> </div>

View File

@@ -1,7 +1,8 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import JSZip from 'jszip';
// Wrapper component that adds PNG export to any chart // 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 chartRef = useRef(null);
const exportAsPNG = () => { const exportAsPNG = () => {
@@ -11,21 +12,30 @@ export function ExportableChart({ children, filename = 'chart', className = '' }
const canvas = chartContainer.querySelector('canvas'); const canvas = chartContainer.querySelector('canvas');
if (!canvas) return; 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 exportCanvas = document.createElement('canvas');
const ctx = exportCanvas.getContext('2d'); const ctx = exportCanvas.getContext('2d');
// Set dimensions with padding // Set dimensions with padding and title space
const padding = 20; const padding = 24;
const titleHeight = title ? 48 : 0;
exportCanvas.width = canvas.width + (padding * 2); exportCanvas.width = canvas.width + (padding * 2);
exportCanvas.height = canvas.height + (padding * 2); exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
// Fill white background // Fill white background
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); 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 // Draw the chart
ctx.drawImage(canvas, padding, padding); ctx.drawImage(canvas, padding, padding + titleHeight);
// Export // Export
const link = document.createElement('a'); const link = document.createElement('a');
@@ -35,23 +45,118 @@ export function ExportableChart({ children, filename = 'chart', className = '' }
}; };
return ( return (
<div className={`exportable-chart ${className}`}> <div className="exportable-chart-wrapper">
<button {title && (
className="chart-export-btn" <div className="chart-header-with-export">
onClick={exportAsPNG} <h2>{title}</h2>
title="Download as PNG" <div className="chart-header-actions">
> {controls}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <button
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> className="chart-export-btn visible"
<polyline points="7 10 12 15 17 10"/> onClick={exportAsPNG}
<line x1="12" y1="15" x2="12" y2="3"/> title="Download as PNG"
</svg> >
</button> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<div ref={chartRef} className="chart-canvas-wrapper"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
{children} <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>
</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; export default ExportableChart;

View File

@@ -4,6 +4,7 @@ import { Line, Bar } from 'react-chartjs-2';
import { EmptyState, FilterControls } from './shared'; import { EmptyState, FilterControls } from './shared';
import { ExportableChart } from './ChartExport'; import { ExportableChart } from './ChartExport';
import { chartColors, createBaseOptions } from '../config/chartConfig'; import { chartColors, createBaseOptions } from '../config/chartConfig';
import { useLanguage } from '../contexts/LanguageContext';
import { import {
filterDataByDateRange, filterDataByDateRange,
calculateMetrics, calculateMetrics,
@@ -39,7 +40,8 @@ const generatePresetDates = (year) => ({
'full': { start: `${year}-01-01`, end: `${year}-12-31` } '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(); const [searchParams, setSearchParams] = useSearchParams();
// Get available years from data // Get available years from data
@@ -140,8 +142,8 @@ function Comparison({ data, showDataLabels }) {
}; };
const charts = [ const charts = [
{ id: 'timeseries', label: 'Trend' }, { id: 'timeseries', label: t('comparison.trend') },
{ id: 'museum', label: 'By Museum' } { id: 'museum', label: t('comparison.byMuseum') }
]; ];
// Touch swipe handlers // Touch swipe handlers
@@ -165,15 +167,16 @@ function Comparison({ data, showDataLabels }) {
}; };
const granularityOptions = [ const granularityOptions = [
{ value: 'day', label: 'Daily' }, { value: 'day', label: t('time.daily') },
{ value: 'week', label: 'Weekly' } { value: 'week', label: t('time.weekly') },
{ value: 'month', label: t('time.monthly') }
]; ];
const metricOptions = [ const metricOptions = [
{ value: 'revenue', label: 'Revenue', field: 'revenue_incl_tax', format: 'currency' }, { value: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax', format: 'currency' },
{ value: 'visitors', label: 'Visitors', field: 'visits', format: 'number' }, { value: 'visitors', label: t('metrics.visitors'), field: 'visits', format: 'number' },
{ value: 'tickets', label: 'Tickets', field: 'tickets', format: 'number' }, { value: 'tickets', label: t('metrics.tickets'), field: 'tickets', format: 'number' },
{ value: 'avgRevenue', label: 'Avg Rev/Visitor', field: null, format: 'currency' } { value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' }
]; ];
const getMetricValue = useCallback((rows, metric) => { 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 captureRateChange = captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null;
const cards = [ const cards = [
{ title: 'Revenue', prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true }, { title: t('metrics.revenue'), prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true },
{ title: 'Visitors', prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange }, { title: t('metrics.visitors'), prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
{ title: 'Tickets', prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange }, { title: t('metrics.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.avgRevenue'), prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true }
]; ];
if (pilgrimCounts) { 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) { 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; return cards;
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates]); }, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]);
const handleCardTouchStart = (e) => { const handleCardTouchStart = (e) => {
touchStartCard.current = e.touches[0].clientX; touchStartCard.current = e.touches[0].clientX;
@@ -338,7 +341,11 @@ function Comparison({ data, showDataLabels }) {
const daysDiff = Math.floor((rowDate - start) / (1000 * 60 * 60 * 24)); const daysDiff = Math.floor((rowDate - start) / (1000 * 60 * 60 * 24));
let key; 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; key = Math.floor(daysDiff / 7) + 1;
} else { } else {
key = daysDiff + 1; // day number from start 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 currGrouped = groupByPeriod(currData, ranges.curr.start, chartMetric, chartGranularity);
const maxKey = Math.max(...Object.keys(prevGrouped).map(Number), ...Object.keys(currGrouped).map(Number), 1); const maxKey = Math.max(...Object.keys(prevGrouped).map(Number), ...Object.keys(currGrouped).map(Number), 1);
const labels = Array.from({ length: maxKey }, (_, i) => const labels = Array.from({ length: maxKey }, (_, i) => {
chartGranularity === 'week' ? `W${i + 1}` : `D${i + 1}` 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 prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end); const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
@@ -423,45 +436,54 @@ function Comparison({ data, showDataLabels }) {
...baseOptions, ...baseOptions,
plugins: { plugins: {
...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 ( return (
<div className="comparison"> <div className="comparison" id="comparison-container">
<div className="page-title"> <div className="page-title-with-actions">
<h1>Period Comparison</h1> <div className="page-title">
<p>Select a period and year automatically compares with the same period in the previous year</p> <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> </div>
<FilterControls title="Select Period" onReset={resetFilters}> <FilterControls title={t('comparison.selectPeriod')} onReset={resetFilters}>
<FilterControls.Row> <FilterControls.Row>
<FilterControls.Group label="Period"> <FilterControls.Group label={t('comparison.period')}>
<select value={preset} onChange={e => setPreset(e.target.value)}> <select value={preset} onChange={e => setPreset(e.target.value)}>
<option value="custom">Custom</option> <option value="custom">{t('comparison.custom')}</option>
<option value="jan">January</option> <option value="jan">{t('months.january')}</option>
<option value="feb">February</option> <option value="feb">{t('months.february')}</option>
<option value="mar">March</option> <option value="mar">{t('months.march')}</option>
<option value="apr">April</option> <option value="apr">{t('months.april')}</option>
<option value="may">May</option> <option value="may">{t('months.may')}</option>
<option value="jun">June</option> <option value="jun">{t('months.june')}</option>
<option value="jul">July</option> <option value="jul">{t('months.july')}</option>
<option value="aug">August</option> <option value="aug">{t('months.august')}</option>
<option value="sep">September</option> <option value="sep">{t('months.september')}</option>
<option value="oct">October</option> <option value="oct">{t('months.october')}</option>
<option value="nov">November</option> <option value="nov">{t('months.november')}</option>
<option value="dec">December</option> <option value="dec">{t('months.december')}</option>
<option value="q1">Q1</option> <option value="q1">{t('time.q1')}</option>
<option value="q2">Q2</option> <option value="q2">{t('time.q2')}</option>
<option value="q3">Q3</option> <option value="q3">{t('time.q3')}</option>
<option value="q4">Q4</option> <option value="q4">{t('time.q4')}</option>
<option value="h1">H1</option> <option value="h1">{t('time.h1')}</option>
<option value="h2">H2</option> <option value="h2">{t('time.h2')}</option>
<option value="full">Full Year</option> <option value="full">{t('time.fullYear')}</option>
</select> </select>
</FilterControls.Group> </FilterControls.Group>
{preset !== 'custom' && ( {preset !== 'custom' && (
<FilterControls.Group label="Year"> <FilterControls.Group label={t('filters.year')}>
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}> <select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
{availableYears.map(y => ( {availableYears.map(y => (
<option key={y} value={y}>{y}</option> <option key={y} value={y}>{y}</option>
@@ -471,51 +493,55 @@ function Comparison({ data, showDataLabels }) {
)} )}
{preset === 'custom' && ( {preset === 'custom' && (
<> <>
<FilterControls.Group label="From"> <FilterControls.Group label={t('comparison.from')}>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} /> <input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
</FilterControls.Group> </FilterControls.Group>
<FilterControls.Group label="To"> <FilterControls.Group label={t('comparison.to')}>
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} /> <input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
</FilterControls.Group> </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'})}> <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>)} {districts.map(d => <option key={d} value={d}>{d}</option>)}
</select> </select>
</FilterControls.Group> </FilterControls.Group>
<FilterControls.Group label="Museum"> <FilterControls.Group label={t('filters.museum')}>
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}> <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>)} {availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
</select> </select>
</FilterControls.Group> </FilterControls.Group>
</FilterControls.Row> </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> </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 ? ( {!hasData ? (
<EmptyState <EmptyState
icon="📈" icon="📈"
title="No data for this period" title={t('comparison.noData')}
message="No records found for the selected date range and filters." message={t('comparison.noDataMessage')}
action={resetFilters} action={resetFilters}
actionLabel="Reset Filters" actionLabel={t('filters.reset')}
/> />
) : ( ) : (
<> <>
{/* Desktop: Grid layout */} {/* Desktop: Grid layout */}
<div className="comparison-grid desktop-only"> <div className="comparison-grid desktop-only" id="comparison-metrics">
{metricCards.map((card, i) => ( {metricCards.map((card, i) => (
<MetricCard <MetricCard
key={i} key={i}
@@ -575,43 +601,48 @@ function Comparison({ data, showDataLabels }) {
</div> </div>
{/* Desktop: Show both charts */} {/* 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-section">
<div className="chart-header"> <ExportableChart
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2> filename="trend-comparison"
<div className="chart-selectors"> title={`${metricOptions.find(m => m.value === chartMetric)?.label} - ${t('comparison.trend')}`}
<div className="toggle-switch"> className="chart-container"
{granularityOptions.map(opt => ( controls={
<button <>
key={opt.value} <div className="toggle-switch">
className={chartGranularity === opt.value ? 'active' : ''} {granularityOptions.map(opt => (
onClick={() => setChartGranularity(opt.value)} <button
> key={opt.value}
{opt.label} className={chartGranularity === opt.value ? 'active' : ''}
</button> onClick={() => setChartGranularity(opt.value)}
))} >
</div> {opt.label}
<div className="chart-metric-selector"> </button>
{metricOptions.map(opt => ( ))}
<button </div>
key={opt.value} <div className="chart-metric-selector">
className={chartMetric === opt.value ? 'active' : ''} {metricOptions.map(opt => (
onClick={() => setChartMetric(opt.value)} <button
> key={opt.value}
{opt.label} className={chartMetric === opt.value ? 'active' : ''}
</button> onClick={() => setChartMetric(opt.value)}
))} >
</div> {opt.label}
</div> </button>
</div> ))}
<ExportableChart filename="trend-comparison" className="chart-container"> </div>
</>
}
>
<Line data={timeSeriesChart} options={chartOptions} /> <Line data={timeSeriesChart} options={chartOptions} />
</ExportableChart> </ExportableChart>
</div> </div>
<div className="chart-section"> <div className="chart-section">
<div className="chart-header"> <ExportableChart
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} by Museum</h2> filename="museum-comparison"
<div className="chart-selectors"> title={`${metricOptions.find(m => m.value === chartMetric)?.label} - ${t('comparison.byMuseum')}`}
className="chart-container"
controls={
<div className="chart-metric-selector"> <div className="chart-metric-selector">
{metricOptions.map(opt => ( {metricOptions.map(opt => (
<button <button
@@ -623,9 +654,8 @@ function Comparison({ data, showDataLabels }) {
</button> </button>
))} ))}
</div> </div>
</div> }
</div> >
<ExportableChart filename="museum-comparison" className="chart-container">
<Bar data={museumChart} options={chartOptions} /> <Bar data={museumChart} options={chartOptions} />
</ExportableChart> </ExportableChart>
</div> </div>
@@ -644,7 +674,7 @@ function Comparison({ data, showDataLabels }) {
<div className="carousel-slide"> <div className="carousel-slide">
<div className="chart-section"> <div className="chart-section">
<div className="chart-header"> <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"> <div className="toggle-switch">
{granularityOptions.map(opt => ( {granularityOptions.map(opt => (
<button <button
@@ -678,7 +708,7 @@ function Comparison({ data, showDataLabels }) {
<div className="carousel-slide"> <div className="carousel-slide">
<div className="chart-section"> <div className="chart-section">
<div className="chart-header"> <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>
<div className="chart-selectors-inline"> <div className="chart-selectors-inline">
<div className="chart-metric-selector"> <div className="chart-metric-selector">

View File

@@ -4,6 +4,7 @@ import { Line, Doughnut, Bar } from 'react-chartjs-2';
import { Carousel, EmptyState, FilterControls, StatCard } from './shared'; import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
import { ExportableChart } from './ChartExport'; import { ExportableChart } from './ChartExport';
import { chartColors, createBaseOptions } from '../config/chartConfig'; import { chartColors, createBaseOptions } from '../config/chartConfig';
import { useLanguage } from '../contexts/LanguageContext';
import { import {
filterData, filterData,
calculateMetrics, calculateMetrics,
@@ -28,7 +29,8 @@ const defaultFilters = {
const filterKeys = ['year', 'district', 'museum', 'quarter']; const filterKeys = ['year', 'district', 'museum', 'quarter'];
function Dashboard({ data, showDataLabels }) { function Dashboard({ data, showDataLabels, setShowDataLabels }) {
const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
// Initialize filters from URL or defaults // Initialize filters from URL or defaults
@@ -67,17 +69,17 @@ function Dashboard({ data, showDataLabels }) {
// Stat cards for carousel // Stat cards for carousel
const statCards = useMemo(() => [ const statCards = useMemo(() => [
{ title: 'Total Revenue', value: formatCurrency(metrics.revenue), hasYoy: true }, { title: t('metrics.totalRevenue'), value: formatCurrency(metrics.revenue), hasYoy: true },
{ title: 'Total Visitors', value: formatNumber(metrics.visitors) }, { title: t('metrics.totalVisitors'), value: formatNumber(metrics.visitors) },
{ title: 'Total Tickets', value: formatNumber(metrics.tickets) }, { title: t('metrics.totalTickets'), value: formatNumber(metrics.tickets) },
{ title: 'Avg Rev/Visitor', value: formatCurrency(metrics.avgRevPerVisitor) } { title: t('metrics.avgRevenue'), value: formatCurrency(metrics.avgRevPerVisitor) }
], [metrics]); ], [metrics, t]);
// Chart carousel labels // Chart carousel labels
const chartLabels = useMemo(() => { 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); return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2);
}, [filters.museum]); }, [filters.museum, t]);
// Dynamic lists from data // Dynamic lists from data
const years = useMemo(() => getUniqueYears(data), [data]); const years = useMemo(() => getUniqueYears(data), [data]);
@@ -248,7 +250,7 @@ function Dashboard({ data, showDataLabels }) {
color: '#1e293b', color: '#1e293b',
backgroundColor: 'rgba(255, 255, 255, 0.9)', backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 3, borderRadius: 3,
font: { size: 9, weight: 600 }, font: { size: 10, weight: 600 },
anchor: 'end', anchor: 'end',
align: 'top', align: 'top',
offset: 6 offset: 6
@@ -274,7 +276,7 @@ function Dashboard({ data, showDataLabels }) {
color: '#1e293b', color: '#1e293b',
backgroundColor: 'rgba(255, 255, 255, 0.9)', backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 3, borderRadius: 3,
font: { size: 9, weight: 600 }, font: { size: 10, weight: 600 },
anchor: 'start', anchor: 'start',
align: 'bottom', align: 'bottom',
offset: 6 offset: 6
@@ -314,50 +316,59 @@ function Dashboard({ data, showDataLabels }) {
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]); const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
return ( return (
<div className="dashboard"> <div className="dashboard" id="dashboard-container">
<div className="page-title"> <div className="page-title-with-actions">
<h1>Dashboard</h1> <div className="page-title">
<p>Real-time museum analytics from Google Sheets</p> <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> </div>
<FilterControls title="Filters" onReset={resetFilters}> <FilterControls title={t('filters.title')} onReset={resetFilters}>
<FilterControls.Row> <FilterControls.Row>
<FilterControls.Group label="Year"> <FilterControls.Group label={t('filters.year')}>
<select value={filters.year} onChange={e => setFilters({...filters, year: e.target.value})}> <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>)} {years.map(y => <option key={y} value={y}>{y}</option>)}
</select> </select>
</FilterControls.Group> </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'})}> <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>)} {districts.map(d => <option key={d} value={d}>{d}</option>)}
</select> </select>
</FilterControls.Group> </FilterControls.Group>
<FilterControls.Group label="Museum"> <FilterControls.Group label={t('filters.museum')}>
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}> <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>)} {availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
</select> </select>
</FilterControls.Group> </FilterControls.Group>
<FilterControls.Group label="Quarter"> <FilterControls.Group label={t('filters.quarter')}>
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}> <select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
<option value="all">All Quarters</option> <option value="all">{t('filters.allQuarters')}</option>
<option value="1">Q1</option> <option value="1">{t('time.q1')}</option>
<option value="2">Q2</option> <option value="2">{t('time.q2')}</option>
<option value="3">Q3</option> <option value="3">{t('time.q3')}</option>
<option value="4">Q4</option> <option value="4">{t('time.q4')}</option>
</select> </select>
</FilterControls.Group> </FilterControls.Group>
</FilterControls.Row> </FilterControls.Row>
</FilterControls> </FilterControls>
{/* Desktop: Grid */} {/* Desktop: Grid */}
<div className="stats-grid desktop-only"> <div className="stats-grid desktop-only" id="dashboard-stats">
<StatCard title="Total Revenue" value={formatCurrency(metrics.revenue)} change={yoyChange} /> <StatCard title={t('metrics.totalRevenue')} value={formatCurrency(metrics.revenue)} change={yoyChange} />
<StatCard title="Total Visitors" value={formatNumber(metrics.visitors)} /> <StatCard title={t('metrics.totalVisitors')} value={formatNumber(metrics.visitors)} />
<StatCard title="Total Tickets" value={formatNumber(metrics.tickets)} /> <StatCard title={t('metrics.totalTickets')} value={formatNumber(metrics.tickets)} />
<StatCard title="Avg Revenue/Visitor" value={formatCurrency(metrics.avgRevPerVisitor)} /> <StatCard title={t('metrics.avgRevenuePerVisitor')} value={formatCurrency(metrics.avgRevPerVisitor)} />
</div> </div>
{/* Mobile: Stats Carousel */} {/* Mobile: Stats Carousel */}
@@ -381,28 +392,28 @@ function Dashboard({ data, showDataLabels }) {
{!hasData ? ( {!hasData ? (
<EmptyState <EmptyState
icon="📊" icon="📊"
title="No data found" title={t('dashboard.noData')}
message="No records match your current filters. Try adjusting your selection." message={t('dashboard.noDataMessage')}
action={resetFilters} action={resetFilters}
actionLabel="Reset Filters" actionLabel={t('filters.reset')}
/> />
) : ( ) : (
<> <>
<div className="chart-card full-width" style={{marginBottom: '16px'}}> <div className="chart-card full-width" style={{marginBottom: '16px'}} id="quarterly-table">
<h2>Quarterly Comparison: 2024 vs 2025</h2> <h2>{t('dashboard.quarterlyComparison')}</h2>
<div className="table-container"> <div className="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Quarter</th> <th>{t('table.quarter')}</th>
<th>Rev 2024</th> <th>{t('table.rev2024')}</th>
<th>Rev 2025</th> <th>{t('table.rev2025')}</th>
<th>Change</th> <th>{t('table.change')}</th>
<th>Visitors 2024</th> <th>{t('table.visitors2024')}</th>
<th>Visitors 2025</th> <th>{t('table.visitors2025')}</th>
<th>Change</th> <th>{t('table.change')}</th>
<th>Capture 2024</th> <th>{t('table.capture2024')}</th>
<th>Capture 2025</th> <th>{t('table.capture2025')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -429,58 +440,58 @@ function Dashboard({ data, showDataLabels }) {
</div> </div>
{/* Desktop: Charts Grid */} {/* Desktop: Charts Grid */}
<div className="charts-grid desktop-only"> <div className="charts-grid desktop-only" id="dashboard-charts">
<div className="chart-card full-width"> <div className="chart-card full-width">
<h2>Revenue Trends</h2> <ExportableChart
<div className="toggle-switch toggle-corner"> filename="revenue-trend"
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button> title={t('dashboard.revenueTrends')}
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button> className="chart-container"
</div> controls={
<ExportableChart filename="revenue-trend" className="chart-container"> <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}}}}} /> <Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
</ExportableChart> </ExportableChart>
</div> </div>
{filters.museum === 'all' && ( {filters.museum === 'all' && (
<div className="chart-card half-width"> <div className="chart-card half-width">
<h2>Visitors by Museum</h2> <ExportableChart filename="visitors-by-museum" title={t('dashboard.visitorsByMuseum')} className="chart-container">
<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: 13}}}}}} />
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 11}}}}}} />
</ExportableChart> </ExportableChart>
</div> </div>
)} )}
{filters.museum === 'all' && ( {filters.museum === 'all' && (
<div className="chart-card half-width"> <div className="chart-card half-width">
<h2>Revenue by Museum</h2> <ExportableChart filename="revenue-by-museum" title={t('dashboard.revenueByMuseum')} className="chart-container">
<ExportableChart filename="revenue-by-museum" className="chart-container">
<Bar data={museumData.revenue} options={baseOptions} /> <Bar data={museumData.revenue} options={baseOptions} />
</ExportableChart> </ExportableChart>
</div> </div>
)} )}
<div className="chart-card half-width"> <div className="chart-card half-width">
<h2>Quarterly Revenue (YoY)</h2> <ExportableChart filename="quarterly-yoy" title={t('dashboard.quarterlyRevenue')} className="chart-container">
<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: 13}}}}}} />
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 11}}}}}} />
</ExportableChart> </ExportableChart>
</div> </div>
<div className="chart-card half-width"> <div className="chart-card half-width">
<h2>District Performance</h2> <ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
<ExportableChart filename="district-performance" className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} /> <Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
</ExportableChart> </ExportableChart>
</div> </div>
<div className="chart-card full-width"> <div className="chart-card full-width">
<h2>Capture Rate vs Umrah Pilgrims</h2> <ExportableChart filename="capture-rate" title={t('dashboard.captureRateChart')} className="chart-container">
<ExportableChart filename="capture-rate" className="chart-container">
<Line data={captureRateData} options={{ <Line data={captureRateData} options={{
...baseOptions, ...baseOptions,
plugins: { plugins: {
...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: { tooltip: {
...baseOptions.plugins.tooltip, ...baseOptions.plugins.tooltip,
callbacks: { callbacks: {
@@ -499,17 +510,17 @@ function Dashboard({ data, showDataLabels }) {
type: 'linear', type: 'linear',
position: 'left', position: 'left',
grid: { color: chartColors.grid }, 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 }, 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: { y1: {
type: 'linear', type: 'linear',
position: 'right', position: 'right',
grid: { drawOnChartArea: false }, 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 }, 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="carousel-slide">
<div className="chart-card"> <div className="chart-card">
<h2>Revenue Trends</h2> <h2>{t('dashboard.revenueTrends')}</h2>
<div className="toggle-switch toggle-corner"> <div className="toggle-switch toggle-corner">
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button> <button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>{t('time.daily')}</button>
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button> <button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} /> <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' && ( {filters.museum === 'all' && (
<div className="carousel-slide"> <div className="carousel-slide">
<div className="chart-card"> <div className="chart-card">
<h2>Visitors by Museum</h2> <h2>{t('dashboard.visitorsByMuseum')}</h2>
<div className="chart-container"> <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> </div>
</div> </div>
@@ -552,7 +563,7 @@ function Dashboard({ data, showDataLabels }) {
{filters.museum === 'all' && ( {filters.museum === 'all' && (
<div className="carousel-slide"> <div className="carousel-slide">
<div className="chart-card"> <div className="chart-card">
<h2>Revenue by Museum</h2> <h2>{t('dashboard.revenueByMuseum')}</h2>
<div className="chart-container"> <div className="chart-container">
<Bar data={museumData.revenue} options={baseOptions} /> <Bar data={museumData.revenue} options={baseOptions} />
</div> </div>
@@ -562,16 +573,16 @@ function Dashboard({ data, showDataLabels }) {
<div className="carousel-slide"> <div className="carousel-slide">
<div className="chart-card"> <div className="chart-card">
<h2>Quarterly Revenue (YoY)</h2> <h2>{t('dashboard.quarterlyRevenue')}</h2>
<div className="chart-container"> <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>
</div> </div>
<div className="carousel-slide"> <div className="carousel-slide">
<div className="chart-card"> <div className="chart-card">
<h2>District Performance</h2> <h2>{t('dashboard.districtPerformance')}</h2>
<div className="chart-container"> <div className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} /> <Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
</div> </div>
@@ -580,13 +591,13 @@ function Dashboard({ data, showDataLabels }) {
<div className="carousel-slide"> <div className="carousel-slide">
<div className="chart-card"> <div className="chart-card">
<h2>Capture Rate vs Umrah Pilgrims</h2> <h2>{t('dashboard.captureRateChart')}</h2>
<div className="chart-container"> <div className="chart-container">
<Line data={captureRateData} options={{ <Line data={captureRateData} options={{
...baseOptions, ...baseOptions,
plugins: { plugins: {
...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: { tooltip: {
...baseOptions.plugins.tooltip, ...baseOptions.plugins.tooltip,
callbacks: { callbacks: {
@@ -605,14 +616,14 @@ function Dashboard({ data, showDataLabels }) {
type: 'linear', type: 'linear',
position: 'left', position: 'left',
grid: { color: chartColors.grid }, 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 } border: { display: false }
}, },
y1: { y1: {
type: 'linear', type: 'linear',
position: 'right', position: 'right',
grid: { drawOnChartArea: false }, 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 } border: { display: false }
} }
} }

View File

@@ -1,6 +1,7 @@
import React, { useState, useMemo, useCallback } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { Line, Bar } from 'react-chartjs-2'; import { Line, Bar } from 'react-chartjs-2';
import { chartColors, createBaseOptions } from '../config/chartConfig'; import { chartColors, createBaseOptions } from '../config/chartConfig';
import { useLanguage } from '../contexts/LanguageContext';
import { import {
filterDataByDateRange, filterDataByDateRange,
calculateMetrics, calculateMetrics,
@@ -12,20 +13,21 @@ import {
} from '../services/dataService'; } from '../services/dataService';
import JSZip from 'jszip'; 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 }) { 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 [slides, setSlides] = useState([]);
const [editingSlide, setEditingSlide] = useState(null); const [editingSlide, setEditingSlide] = useState(null);
const [previewMode, setPreviewMode] = useState(false); const [previewMode, setPreviewMode] = useState(false);
@@ -171,6 +173,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
currentSlide={currentPreviewSlide} currentSlide={currentPreviewSlide}
setCurrentSlide={setCurrentPreviewSlide} setCurrentSlide={setCurrentPreviewSlide}
onExit={() => setPreviewMode(false)} onExit={() => setPreviewMode(false)}
metrics={METRICS}
/> />
); );
} }
@@ -178,8 +181,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
return ( return (
<div className="slides-builder"> <div className="slides-builder">
<div className="page-title"> <div className="page-title">
<h1>Presentation Builder</h1> <h1>{t('slides.title')}</h1>
<p>Create slides with charts and export as HTML or PDF</p> <p>{t('slides.subtitle')}</p>
</div> </div>
<div className="slides-toolbar"> <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"> <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"/> <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg> </svg>
Add Slide {t('slides.addSlide')}
</button> </button>
{slides.length > 0 && ( {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"> <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"/> <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> </svg>
Preview {t('slides.preview')}
</button> </button>
<button className="btn-secondary" onClick={exportAsHTML}> <button className="btn-secondary" onClick={exportAsHTML}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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"/> <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> </svg>
Export HTML {t('slides.exportHtml')}
</button> </button>
</> </>
)} )}
@@ -209,11 +212,11 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
<div className="slides-workspace"> <div className="slides-workspace">
<div className="slides-list"> <div className="slides-list">
<h3>Slides ({slides.length})</h3> <h3>{t('slides.slidesCount')} ({slides.length})</h3>
{slides.length === 0 ? ( {slides.length === 0 ? (
<div className="empty-slides"> <div className="empty-slides">
<p>No slides yet</p> <p>{t('slides.noSlides')}</p>
<button onClick={addSlide}>Add your first slide</button> <button onClick={addSlide}>{t('slides.addFirst')}</button>
</div> </div>
) : ( ) : (
<div className="slides-thumbnails"> <div className="slides-thumbnails">
@@ -245,6 +248,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
districts={districts} districts={districts}
districtMuseumMap={districtMuseumMap} districtMuseumMap={districtMuseumMap}
data={data} data={data}
chartTypes={CHART_TYPES}
metrics={METRICS}
/> />
)} )}
</div> </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(() => const availableMuseums = useMemo(() =>
getMuseumsForDistrict(districtMuseumMap, slide.district), getMuseumsForDistrict(districtMuseumMap, slide.district),
[districtMuseumMap, slide.district] [districtMuseumMap, slide.district]
@@ -261,19 +267,19 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data }) {
return ( return (
<div className="slide-editor"> <div className="slide-editor">
<div className="editor-section"> <div className="editor-section">
<label>Slide Title</label> <label>{t('slides.slideTitle')}</label>
<input <input
type="text" type="text"
value={slide.title} value={slide.title}
onChange={e => onUpdate({ title: e.target.value })} onChange={e => onUpdate({ title: e.target.value })}
placeholder="Enter slide title" placeholder={t('slides.slideTitle')}
/> />
</div> </div>
<div className="editor-section"> <div className="editor-section">
<label>Chart Type</label> <label>{t('slides.chartType')}</label>
<div className="chart-type-grid"> <div className="chart-type-grid">
{CHART_TYPES.map(type => ( {chartTypes.map(type => (
<button <button
key={type.id} key={type.id}
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`} className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
@@ -287,35 +293,35 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data }) {
</div> </div>
<div className="editor-section"> <div className="editor-section">
<label>Metric</label> <label>{t('slides.metric')}</label>
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}> <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> </select>
</div> </div>
<div className="editor-row"> <div className="editor-row">
<div className="editor-section"> <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 })} /> <input type="date" value={slide.startDate} onChange={e => onUpdate({ startDate: e.target.value })} />
</div> </div>
<div className="editor-section"> <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 })} /> <input type="date" value={slide.endDate} onChange={e => onUpdate({ endDate: e.target.value })} />
</div> </div>
</div> </div>
<div className="editor-row"> <div className="editor-row">
<div className="editor-section"> <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' })}> <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>)} {districts.map(d => <option key={d} value={d}>{d}</option>)}
</select> </select>
</div> </div>
<div className="editor-section"> <div className="editor-section">
<label>Museum</label> <label>{t('filters.museum')}</label>
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}> <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>)} {availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
</select> </select>
</div> </div>
@@ -329,20 +335,28 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data }) {
checked={slide.showComparison} checked={slide.showComparison}
onChange={e => onUpdate({ showComparison: e.target.checked })} onChange={e => onUpdate({ showComparison: e.target.checked })}
/> />
Show Year-over-Year Comparison {t('slides.showYoY')}
</label> </label>
</div> </div>
)} )}
<div className="slide-preview-box"> <div className="slide-preview-box">
<h4>Preview</h4> <h4>{t('slides.preview')}</h4>
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} /> <SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} metrics={metrics} />
</div> </div>
</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(() => const filteredData = useMemo(() =>
filterDataByDateRange(data, slide.startDate, slide.endDate, { filterDataByDateRange(data, slide.startDate, slide.endDate, {
district: slide.district, district: slide.district,
@@ -351,7 +365,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap }) {
[data, slide.startDate, slide.endDate, slide.district, slide.museum] [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 baseOptions = useMemo(() => createBaseOptions(false), []);
const getMetricValue = useCallback((rows, metric) => { const getMetricValue = useCallback((rows, metric) => {
@@ -369,10 +383,11 @@ function SlidePreview({ slide, data, districts, districtMuseumMap }) {
}); });
const sortedDates = Object.keys(grouped).sort(); const sortedDates = Object.keys(grouped).sort();
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
return { return {
labels: sortedDates.map(d => d.substring(5)), labels: sortedDates.map(d => d.substring(5)),
datasets: [{ datasets: [{
label: METRICS.find(m => m.id === slide.metric)?.label, label: metricLabel,
data: sortedDates.map(d => getMetricValue(grouped[d], slide.metric)), data: sortedDates.map(d => getMetricValue(grouped[d], slide.metric)),
borderColor: chartColors.primary, borderColor: chartColors.primary,
backgroundColor: chartColors.primary + '20', backgroundColor: chartColors.primary + '20',
@@ -380,7 +395,7 @@ function SlidePreview({ slide, data, districts, districtMuseumMap }) {
tension: 0.4 tension: 0.4
}] }]
}; };
}, [filteredData, slide.metric, getMetricValue]); }, [filteredData, slide.metric, getMetricValue, metrics]);
const museumData = useMemo(() => { const museumData = useMemo(() => {
const byMuseum = {}; const byMuseum = {};
@@ -391,31 +406,32 @@ function SlidePreview({ slide, data, districts, districtMuseumMap }) {
}); });
const museums = Object.keys(byMuseum).sort(); const museums = Object.keys(byMuseum).sort();
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
return { return {
labels: museums, labels: museums,
datasets: [{ datasets: [{
label: METRICS.find(m => m.id === slide.metric)?.label, label: metricLabel,
data: museums.map(m => getMetricValue(byMuseum[m], slide.metric)), data: museums.map(m => getMetricValue(byMuseum[m], slide.metric)),
backgroundColor: chartColors.primary, backgroundColor: chartColors.primary,
borderRadius: 6 borderRadius: 6
}] }]
}; };
}, [filteredData, slide.metric, getMetricValue]); }, [filteredData, slide.metric, getMetricValue, metrics]);
if (slide.chartType === 'kpi-cards') { if (slide.chartType === 'kpi-cards') {
return ( return (
<div className="preview-kpis"> <div className="preview-kpis">
<div className="preview-kpi"> <div className="preview-kpi">
<div className="kpi-value">{formatCompactCurrency(metrics.revenue)}</div> <div className="kpi-value">{formatCompactCurrency(metricsData.revenue)}</div>
<div className="kpi-label">Revenue</div> <div className="kpi-label">{t('metrics.revenue')}</div>
</div> </div>
<div className="preview-kpi"> <div className="preview-kpi">
<div className="kpi-value">{formatCompact(metrics.visitors)}</div> <div className="kpi-value">{formatCompact(metricsData.visitors)}</div>
<div className="kpi-label">Visitors</div> <div className="kpi-label">{t('metrics.visitors')}</div>
</div> </div>
<div className="preview-kpi"> <div className="preview-kpi">
<div className="kpi-value">{formatCompact(metrics.tickets)}</div> <div className="kpi-value">{formatCompact(metricsData.tickets)}</div>
<div className="kpi-label">Tickets</div> <div className="kpi-label">{t('metrics.tickets')}</div>
</div> </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) => { const handleKeyDown = useCallback((e) => {
if (e.key === 'ArrowRight' || e.key === ' ') { if (e.key === 'ArrowRight' || e.key === ' ') {
setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1)); setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1));
@@ -459,7 +476,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
<div className="preview-slide"> <div className="preview-slide">
<h1 className="preview-title">{slide?.title}</h1> <h1 className="preview-title">{slide?.title}</h1>
<div className="preview-content"> <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>
<div className="preview-footer"> <div className="preview-footer">
<span>{currentSlide + 1} / {slides.length}</span> <span>{currentSlide + 1} / {slides.length}</span>
@@ -468,7 +485,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
<div className="preview-controls"> <div className="preview-controls">
<button onClick={() => setCurrentSlide(prev => Math.max(prev - 1, 0))} disabled={currentSlide === 0}></button> <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={() => 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>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import React, { useRef, useCallback } from 'react'; import React, { useRef, useCallback, useState } from 'react';
function Carousel({ function Carousel({
children, children,
@@ -8,25 +8,66 @@ function Carousel({
showLabels = true, showLabels = true,
className = '' 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); const itemCount = React.Children.count(children);
// Threshold for swipe detection
const SWIPE_THRESHOLD = 50;
const VELOCITY_THRESHOLD = 0.3;
const handleTouchStart = useCallback((e) => { 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) => { const handleTouchEnd = useCallback((e) => {
if (!touchStart.current) return; if (!touchStartX.current || !isDragging) return;
const diff = touchStart.current - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) { 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) { if (diff > 0 && activeIndex < itemCount - 1) {
setActiveIndex(activeIndex + 1); setActiveIndex(activeIndex + 1);
} else if (diff < 0 && activeIndex > 0) { } else if (diff < 0 && activeIndex > 0) {
setActiveIndex(activeIndex - 1); 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) => { const handleKeyDown = useCallback((e) => {
if (e.key === 'ArrowLeft' && activeIndex > 0) { if (e.key === 'ArrowLeft' && activeIndex > 0) {
@@ -36,18 +77,40 @@ function Carousel({
} }
}, [activeIndex, setActiveIndex, itemCount]); }, [activeIndex, setActiveIndex, itemCount]);
// Calculate transform
const baseTransform = -(activeIndex * 100);
const dragPercentage = trackRef.current ? (dragOffset / trackRef.current.offsetWidth) * 100 : 0;
const transform = baseTransform + dragPercentage;
return ( 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-container">
<div className="carousel-viewport"> <div className="carousel-viewport">
<div <div
ref={trackRef}
className="carousel-track" 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} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
{React.Children.map(children, (child, i) => ( {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} {child}
</div> </div>
))} ))}
@@ -64,6 +127,7 @@ function Carousel({
role="tab" role="tab"
aria-selected={activeIndex === i} aria-selected={activeIndex === i}
aria-label={labels[i] || `Slide ${i + 1}`} aria-label={labels[i] || `Slide ${i + 1}`}
aria-controls={`slide-${i}`}
> >
{showLabels && labels[i] && ( {showLabels && labels[i] && (
<span className="dot-label">{labels[i]}</span> <span className="dot-label">{labels[i]}</span>

View File

@@ -2,18 +2,29 @@ import React from 'react';
function EmptyState({ function EmptyState({
icon = '📊', icon = '📊',
title = 'No data found', title,
message = 'Try adjusting your filters', message,
action, action = null,
actionLabel = 'Reset Filters' actionLabel = 'Try Again',
className = ''
}) { }) {
return ( return (
<div className="empty-state"> <div className={`empty-state ${className}`}>
<div className="empty-state-icon">{icon}</div> <div className="empty-state-icon" role="img" aria-hidden="true">
<h3 className="empty-state-title">{title}</h3> {icon}
<p className="empty-state-message">{message}</p> </div>
{title && (
<h3 className="empty-state-title">{title}</h3>
)}
{message && (
<p className="empty-state-message">{message}</p>
)}
{action && ( {action && (
<button className="empty-state-action" onClick={action}> <button
className="empty-state-action"
onClick={action}
type="button"
>
{actionLabel} {actionLabel}
</button> </button>
)} )}

View File

@@ -1,33 +1,86 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
function FilterControls({ function FilterControls({
children, children,
title = 'Filters', title,
defaultExpanded = true, defaultExpanded = true,
onReset = null, onReset = null,
className = '' 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 ( return (
<div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}> <div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}>
<div className="controls-header" onClick={() => setExpanded(!expanded)}> <div
<h3>{title}</h3> 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"> <div className="controls-header-actions">
{onReset && expanded && ( {onReset && expanded && (
<button <button
className="controls-reset" 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>
)} )}
<button className="controls-toggle"> <button
{expanded ? '▲ Hide' : '▼ Show'} className="controls-toggle"
aria-hidden="true"
>
{expanded ? '▲' : '▼'}
</button> </button>
</div> </div>
</div> </div>
<div className="controls-body">
<div
className="controls-body"
style={{
display: expanded ? 'block' : 'none',
animation: expanded ? 'fadeIn 200ms ease' : 'none'
}}
>
{children} {children}
</div> </div>
</div> </div>
@@ -37,7 +90,7 @@ function FilterControls({
function FilterGroup({ label, children }) { function FilterGroup({ label, children }) {
return ( return (
<div className="control-group"> <div className="control-group">
<label>{label}</label> {label && <label>{label}</label>}
{children} {children}
</div> </div>
); );

View File

@@ -1,15 +1,20 @@
import React from 'react'; 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; const isPositive = change !== null && change >= 0;
return ( return (
<div className="stat-card"> <div className="stat-card">
<h3>{title}</h3> <h3>{title}</h3>
<div className="stat-value">{value}</div> <div className="stat-value">{value}</div>
{subtitle && (
<div className="stat-subtitle">{subtitle}</div>
)}
{change !== null && ( {change !== null && (
<div className={`stat-change ${isPositive ? 'positive' : 'negative'}`}> <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>
)} )}
</div> </div>

View File

@@ -41,7 +41,7 @@ export const chartColors = {
export const createDataLabelConfig = (showDataLabels) => ({ export const createDataLabelConfig = (showDataLabels) => ({
display: showDataLabels, display: showDataLabels,
color: '#1e293b', color: '#1e293b',
font: { size: 10, weight: 600 }, font: { size: 11, weight: 600 },
anchor: 'end', anchor: 'end',
align: 'end', align: 'end',
offset: 4, offset: 4,
@@ -74,19 +74,19 @@ export const createBaseOptions = (showDataLabels) => ({
backgroundColor: '#1e293b', backgroundColor: '#1e293b',
padding: 12, padding: 12,
cornerRadius: 8, cornerRadius: 8,
titleFont: { size: 12 }, titleFont: { size: 14 },
bodyFont: { size: 11 } bodyFont: { size: 13 }
}, },
datalabels: createDataLabelConfig(showDataLabels) datalabels: createDataLabelConfig(showDataLabels)
}, },
scales: { scales: {
x: { x: {
grid: { display: false }, grid: { display: false },
ticks: { font: { size: 10 }, color: '#94a3b8' } ticks: { font: { size: 12 }, color: '#94a3b8' }
}, },
y: { y: {
grid: { color: chartColors.grid }, grid: { color: chartColors.grid },
ticks: { font: { size: 10 }, color: '#94a3b8' }, ticks: { font: { size: 12 }, color: '#94a3b8' },
border: { display: false } border: { display: false }
} }
} }

View 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;

View File

@@ -1,10 +1,13 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { LanguageProvider } from './contexts/LanguageContext';
import App from './App'; import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <LanguageProvider>
<App />
</LanguageProvider>
</React.StrictMode> </React.StrictMode>
); );

155
src/locales/ar.json Normal file
View 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
View 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..."
}
}