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",
"chart.js": "^4.5.1",
"chartjs-plugin-datalabels": "^2.2.0",
"html2canvas": "^1.4.1",
"jszip": "^3.10.1",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.1",
@@ -5092,6 +5093,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@@ -5995,6 +6005,15 @@
"postcss": "^8.4"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-loader": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz",
@@ -8903,6 +8922,19 @@
}
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/htmlparser2": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
@@ -15946,6 +15978,15 @@
"node": ">=8"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -16509,6 +16550,15 @@
"node": ">= 0.4.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",

View File

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

View File

@@ -17,7 +17,7 @@
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Tajawal:wght@400;500;700&display=swap" rel="stylesheet">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.

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

View File

@@ -1,7 +1,8 @@
import React, { useRef } from 'react';
import JSZip from 'jszip';
// Wrapper component that adds PNG export to any chart
export function ExportableChart({ children, filename = 'chart', className = '' }) {
export function ExportableChart({ children, filename = 'chart', title = '', className = '', controls = null }) {
const chartRef = useRef(null);
const exportAsPNG = () => {
@@ -11,21 +12,30 @@ export function ExportableChart({ children, filename = 'chart', className = '' }
const canvas = chartContainer.querySelector('canvas');
if (!canvas) return;
// Create a new canvas with white background
// Create a new canvas with white background and title
const exportCanvas = document.createElement('canvas');
const ctx = exportCanvas.getContext('2d');
// Set dimensions with padding
const padding = 20;
// Set dimensions with padding and title space
const padding = 24;
const titleHeight = title ? 48 : 0;
exportCanvas.width = canvas.width + (padding * 2);
exportCanvas.height = canvas.height + (padding * 2);
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
// Fill white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
// Draw title if provided (left-aligned, matching on-screen style)
if (title) {
ctx.fillStyle = '#1e293b';
ctx.font = '600 20px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(title, padding, padding + 24);
}
// Draw the chart
ctx.drawImage(canvas, padding, padding);
ctx.drawImage(canvas, padding, padding + titleHeight);
// Export
const link = document.createElement('a');
@@ -35,9 +45,14 @@ export function ExportableChart({ children, filename = 'chart', className = '' }
};
return (
<div className={`exportable-chart ${className}`}>
<div className="exportable-chart-wrapper">
{title && (
<div className="chart-header-with-export">
<h2>{title}</h2>
<div className="chart-header-actions">
{controls}
<button
className="chart-export-btn"
className="chart-export-btn visible"
onClick={exportAsPNG}
title="Download as PNG"
>
@@ -47,10 +62,100 @@ export function ExportableChart({ children, filename = 'chart', className = '' }
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
</div>
</div>
)}
{!title && controls && <div className="chart-controls">{controls}</div>}
<div className={`exportable-chart ${className}`}>
<div ref={chartRef} className="chart-canvas-wrapper">
{children}
</div>
</div>
</div>
);
}
// Utility function to export all charts from a container as a ZIP
export async function exportAllCharts(containerSelector, zipFilename = 'charts') {
const container = document.querySelector(containerSelector);
if (!container) return;
const zip = new JSZip();
const chartWrappers = container.querySelectorAll('.exportable-chart-wrapper');
for (let i = 0; i < chartWrappers.length; i++) {
const wrapper = chartWrappers[i];
const canvas = wrapper.querySelector('canvas');
const titleEl = wrapper.querySelector('.chart-header-with-export h2');
const title = titleEl?.textContent || `chart-${i + 1}`;
if (!canvas) continue;
// Create export canvas with white background and title
const exportCanvas = document.createElement('canvas');
const ctx = exportCanvas.getContext('2d');
const padding = 32;
const titleHeight = 56;
exportCanvas.width = canvas.width + (padding * 2);
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
// White background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
// Draw title
ctx.fillStyle = '#1e293b';
ctx.font = '600 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(title, padding, padding + 28);
// Draw chart
ctx.drawImage(canvas, padding, padding + titleHeight);
// Convert to blob and add to zip
const dataUrl = exportCanvas.toDataURL('image/png', 1.0);
const base64Data = dataUrl.split(',')[1];
const safeFilename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF\s-]/g, '').replace(/\s+/g, '-');
zip.file(`${String(i + 1).padStart(2, '0')}-${safeFilename}.png`, base64Data, { base64: true });
}
// Generate and download ZIP
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${zipFilename}-${new Date().toISOString().split('T')[0]}.zip`;
link.click();
URL.revokeObjectURL(url);
}
// Button component for exporting all charts
export function ExportAllButton({ containerSelector, zipFilename, label, loadingLabel }) {
const [exporting, setExporting] = React.useState(false);
const handleExport = async () => {
setExporting(true);
try {
await exportAllCharts(containerSelector, zipFilename);
} finally {
setExporting(false);
}
};
return (
<button
className="btn-export-all"
onClick={handleExport}
disabled={exporting}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{exporting ? loadingLabel : label}
</button>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useRef, useCallback } from 'react';
import React, { useRef, useCallback, useState } from 'react';
function Carousel({
children,
@@ -8,25 +8,66 @@ function Carousel({
showLabels = true,
className = ''
}) {
const touchStart = useRef(null);
const touchStartX = useRef(null);
const touchStartY = useRef(null);
const trackRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState(0);
const itemCount = React.Children.count(children);
// Threshold for swipe detection
const SWIPE_THRESHOLD = 50;
const VELOCITY_THRESHOLD = 0.3;
const handleTouchStart = useCallback((e) => {
touchStart.current = e.touches[0].clientX;
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
setIsDragging(true);
setDragOffset(0);
}, []);
const handleTouchMove = useCallback((e) => {
if (!touchStartX.current || !isDragging) return;
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const diffX = currentX - touchStartX.current;
const diffY = currentY - touchStartY.current;
// Only handle horizontal swipes
if (Math.abs(diffX) > Math.abs(diffY)) {
e.preventDefault();
// Add resistance at edges
let offset = diffX;
if ((activeIndex === 0 && diffX > 0) || (activeIndex === itemCount - 1 && diffX < 0)) {
offset = diffX * 0.3; // Rubber band effect
}
setDragOffset(offset);
}
}, [isDragging, activeIndex, itemCount]);
const handleTouchEnd = useCallback((e) => {
if (!touchStart.current) return;
const diff = touchStart.current - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) {
if (!touchStartX.current || !isDragging) return;
const endX = e.changedTouches[0].clientX;
const diff = touchStartX.current - endX;
const velocity = Math.abs(diff) / 200; // Rough velocity calc
// Determine if we should change slide
if (Math.abs(diff) > SWIPE_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
if (diff > 0 && activeIndex < itemCount - 1) {
setActiveIndex(activeIndex + 1);
} else if (diff < 0 && activeIndex > 0) {
setActiveIndex(activeIndex - 1);
}
}
touchStart.current = null;
}, [activeIndex, setActiveIndex, itemCount]);
// Reset
touchStartX.current = null;
touchStartY.current = null;
setIsDragging(false);
setDragOffset(0);
}, [isDragging, activeIndex, setActiveIndex, itemCount]);
const handleKeyDown = useCallback((e) => {
if (e.key === 'ArrowLeft' && activeIndex > 0) {
@@ -36,18 +77,40 @@ function Carousel({
}
}, [activeIndex, setActiveIndex, itemCount]);
// Calculate transform
const baseTransform = -(activeIndex * 100);
const dragPercentage = trackRef.current ? (dragOffset / trackRef.current.offsetWidth) * 100 : 0;
const transform = baseTransform + dragPercentage;
return (
<div className={`carousel ${className}`} onKeyDown={handleKeyDown} tabIndex={0}>
<div
className={`carousel ${className}`}
onKeyDown={handleKeyDown}
tabIndex={0}
role="region"
aria-label="Carousel"
>
<div className="carousel-container">
<div className="carousel-viewport">
<div
ref={trackRef}
className="carousel-track"
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
style={{
transform: `translateX(${transform}%)`,
transition: isDragging ? 'none' : 'transform 400ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{React.Children.map(children, (child, i) => (
<div className="carousel-slide" key={i}>
<div
className="carousel-slide"
key={i}
role="tabpanel"
aria-hidden={activeIndex !== i}
aria-label={labels[i] || `Slide ${i + 1}`}
>
{child}
</div>
))}
@@ -64,6 +127,7 @@ function Carousel({
role="tab"
aria-selected={activeIndex === i}
aria-label={labels[i] || `Slide ${i + 1}`}
aria-controls={`slide-${i}`}
>
{showLabels && labels[i] && (
<span className="dot-label">{labels[i]}</span>

View File

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

View File

@@ -1,33 +1,86 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
function FilterControls({
children,
title = 'Filters',
title,
defaultExpanded = true,
onReset = null,
className = ''
}) {
const [expanded, setExpanded] = useState(defaultExpanded);
const { t } = useLanguage();
const displayTitle = title || t('filters.title');
// Start collapsed on mobile
const [expanded, setExpanded] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth > 768 ? defaultExpanded : false;
}
return defaultExpanded;
});
// Handle resize
useEffect(() => {
const handleResize = () => {
// Auto-expand on desktop, keep user preference on mobile
if (window.innerWidth > 768) {
setExpanded(true);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const toggleExpanded = () => {
setExpanded(!expanded);
};
return (
<div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}>
<div className="controls-header" onClick={() => setExpanded(!expanded)}>
<h3>{title}</h3>
<div
className="controls-header"
onClick={toggleExpanded}
role="button"
aria-expanded={expanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpanded();
}
}}
>
<h3>{displayTitle}</h3>
<div className="controls-header-actions">
{onReset && expanded && (
<button
className="controls-reset"
onClick={(e) => { e.stopPropagation(); onReset(); }}
onClick={(e) => {
e.stopPropagation();
onReset();
}}
aria-label={t('filters.reset') || 'Reset filters'}
>
Reset
{t('filters.reset') || 'Reset'}
</button>
)}
<button className="controls-toggle">
{expanded ? '▲ Hide' : '▼ Show'}
<button
className="controls-toggle"
aria-hidden="true"
>
{expanded ? '▲' : '▼'}
</button>
</div>
</div>
<div className="controls-body">
<div
className="controls-body"
style={{
display: expanded ? 'block' : 'none',
animation: expanded ? 'fadeIn 200ms ease' : 'none'
}}
>
{children}
</div>
</div>
@@ -37,7 +90,7 @@ function FilterControls({
function FilterGroup({ label, children }) {
return (
<div className="control-group">
<label>{label}</label>
{label && <label>{label}</label>}
{children}
</div>
);

View File

@@ -1,15 +1,20 @@
import React from 'react';
function StatCard({ title, value, change = null, changeLabel = 'YoY' }) {
function StatCard({ title, value, change = null, changeLabel = 'YoY', subtitle = null }) {
const isPositive = change !== null && change >= 0;
return (
<div className="stat-card">
<h3>{title}</h3>
<div className="stat-value">{value}</div>
{subtitle && (
<div className="stat-subtitle">{subtitle}</div>
)}
{change !== null && (
<div className={`stat-change ${isPositive ? 'positive' : 'negative'}`}>
{isPositive ? '↑' : '↓'} {Math.abs(change).toFixed(1)}% {changeLabel}
<span className="stat-change-arrow">{isPositive ? '↑' : '↓'}</span>
<span className="stat-change-value">{Math.abs(change).toFixed(1)}%</span>
<span className="stat-change-label">{changeLabel}</span>
</div>
)}
</div>

View File

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

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 ReactDOM from 'react-dom/client';
import { LanguageProvider } from './contexts/LanguageContext';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<LanguageProvider>
<App />
</LanguageProvider>
</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..."
}
}