diff --git a/src/App.css b/src/App.css index 5cfea18..6206ce5 100644 --- a/src/App.css +++ b/src/App.css @@ -325,6 +325,32 @@ body { font-size: 0.875rem; } +/* Page Title with Actions (Labels toggle) */ +.page-title-with-actions { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + +.page-title-with-actions .page-title { + margin-bottom: 0; +} + +.toggle-with-label { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +.toggle-with-label .toggle-text { + font-size: 0.6875rem; + font-weight: 500; + color: var(--text-muted); +} + /* Filters - now uses .controls for consistency */ /* Stats Grid */ @@ -599,6 +625,58 @@ table tbody tr:hover { font-weight: 500; } +/* Period Display Banner */ +.period-display-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + background: var(--bg); + padding: 20px 32px; + border-radius: 12px; + margin-bottom: 24px; +} + +.period-display-banner .period-box { + text-align: center; + min-width: 180px; +} + +.period-display-banner .period-box.prev { + color: var(--text-secondary); +} + +.period-display-banner .period-box.curr { + color: var(--text-primary); +} + +.period-display-banner .period-label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 4px; +} + +.period-display-banner .period-value { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 2px; +} + +.period-display-banner .period-dates { + font-size: 0.8125rem; + color: var(--text-muted); +} + +.period-display-banner .period-vs { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-muted); + padding: 0 8px; +} + /* Comparison Metrics */ .comparison-grid { display: grid; diff --git a/src/App.js b/src/App.js index 589550a..b58f0bb 100644 --- a/src/App.js +++ b/src/App.js @@ -4,6 +4,7 @@ import Dashboard from './components/Dashboard'; import Comparison from './components/Comparison'; import Slides from './components/Slides'; import { fetchData } from './services/dataService'; +import { useLanguage } from './contexts/LanguageContext'; import './App.css'; function NavLink({ to, children }) { @@ -17,6 +18,7 @@ function NavLink({ to, children }) { } function App() { + const { t, dir, switchLanguage } = useLanguage(); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -24,9 +26,9 @@ function App() { const [dataSource, setDataSource] = useState('museums'); const dataSources = [ - { id: 'museums', label: 'Museums', enabled: true }, - { id: 'coffees', label: 'Coffees', enabled: false }, - { id: 'ecommerce', label: 'eCommerce', enabled: false } + { id: 'museums', labelKey: 'dataSources.museums', enabled: true }, + { id: 'coffees', labelKey: 'dataSources.coffees', enabled: false }, + { id: 'ecommerce', labelKey: 'dataSources.ecommerce', enabled: false } ]; useEffect(() => { @@ -48,26 +50,26 @@ function App() { if (loading) { return ( -
+
-

Loading data...

+

{t('app.loading')}

); } if (error) { return ( -
-

Unable to load data

+
+

{t('app.error')}

{error}

- +
); } return ( -
+
- } /> - } /> + } /> + } /> } /> @@ -149,7 +140,7 @@ function App() { - Dashboard + {t('nav.dashboard')} @@ -157,24 +148,18 @@ function App() { - Compare - - - - - - - - Slides + {t('nav.compare')}
diff --git a/src/components/ChartExport.js b/src/components/ChartExport.js index 29d12ad..4e984c0 100644 --- a/src/components/ChartExport.js +++ b/src/components/ChartExport.js @@ -1,7 +1,8 @@ import React, { useRef } from 'react'; +import JSZip from 'jszip'; // Wrapper component that adds PNG export to any chart -export function ExportableChart({ children, filename = 'chart', className = '' }) { +export function ExportableChart({ children, filename = 'chart', title = '', className = '', controls = null }) { const chartRef = useRef(null); const exportAsPNG = () => { @@ -11,21 +12,30 @@ export function ExportableChart({ children, filename = 'chart', className = '' } const canvas = chartContainer.querySelector('canvas'); if (!canvas) return; - // Create a new canvas with white background + // Create a new canvas with white background and title const exportCanvas = document.createElement('canvas'); const ctx = exportCanvas.getContext('2d'); - // Set dimensions with padding - const padding = 20; + // Set dimensions with padding and title space + const padding = 24; + const titleHeight = title ? 48 : 0; exportCanvas.width = canvas.width + (padding * 2); - exportCanvas.height = canvas.height + (padding * 2); + exportCanvas.height = canvas.height + (padding * 2) + titleHeight; // Fill white background ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); + // Draw title if provided (left-aligned, matching on-screen style) + if (title) { + ctx.fillStyle = '#1e293b'; + ctx.font = '600 20px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(title, padding, padding + 24); + } + // Draw the chart - ctx.drawImage(canvas, padding, padding); + ctx.drawImage(canvas, padding, padding + titleHeight); // Export const link = document.createElement('a'); @@ -35,23 +45,118 @@ export function ExportableChart({ children, filename = 'chart', className = '' } }; return ( -
- -
- {children} +
+ {title && ( +
+

{title}

+
+ {controls} + +
+
+ )} + {!title && controls &&
{controls}
} +
+
+ {children} +
); } +// 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 ( + + ); +} + export default ExportableChart; diff --git a/src/components/Comparison.js b/src/components/Comparison.js index 0018fba..eea6a24 100644 --- a/src/components/Comparison.js +++ b/src/components/Comparison.js @@ -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 ( -
-
-

Period Comparison

-

Select a period and year — automatically compares with the same period in the previous year

+
+
+
+

{t('comparison.title')}

+

{t('comparison.subtitle')}

+
+
+ {t('nav.labels')} +
+ + +
+
- + - + {preset !== 'custom' && ( - + setStartDate(e.target.value)} /> - + setEndDate(e.target.value)} /> )} - + - + -
-
-
{getPeriodLabel(ranges.prev.start, ranges.prev.end)}
-
{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}
-
-
-
{getPeriodLabel(ranges.curr.start, ranges.curr.end)}
-
{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}
-
-
+
+
+
{t('comparison.previousPeriod')}
+
{getPeriodLabel(ranges.prev.start, ranges.prev.end)}
+
{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}
+
+
{t('comparison.vs')}
+
+
{t('comparison.currentPeriod')}
+
{getPeriodLabel(ranges.curr.start, ranges.curr.end)}
+
{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}
+
+
+ {!hasData ? ( ) : ( <> {/* Desktop: Grid layout */} -
+
{metricCards.map((card, i) => ( {/* Desktop: Show both charts */} -
+
-
-

{metricOptions.find(m => m.value === chartMetric)?.label} Trend

-
-
- {granularityOptions.map(opt => ( - - ))} -
-
- {metricOptions.map(opt => ( - - ))} -
-
-
- + m.value === chartMetric)?.label} - ${t('comparison.trend')}`} + className="chart-container" + controls={ + <> +
+ {granularityOptions.map(opt => ( + + ))} +
+
+ {metricOptions.map(opt => ( + + ))} +
+ + } + >
-
-

{metricOptions.find(m => m.value === chartMetric)?.label} by Museum

-
+ m.value === chartMetric)?.label} - ${t('comparison.byMuseum')}`} + className="chart-container" + controls={
{metricOptions.map(opt => (
-
-
- + } + >
@@ -644,7 +674,7 @@ function Comparison({ data, showDataLabels }) {
-

{metricOptions.find(m => m.value === chartMetric)?.label} Trend

+

{metricOptions.find(m => m.value === chartMetric)?.label} - {t('comparison.trend')}

{granularityOptions.map(opt => ( + +
+
- + - + - + - + - + {/* Desktop: Grid */} -
- - - - +
+ + + +
{/* Mobile: Stats Carousel */} @@ -381,28 +392,28 @@ function Dashboard({ data, showDataLabels }) { {!hasData ? ( ) : ( <> -
-

Quarterly Comparison: 2024 vs 2025

+
+

{t('dashboard.quarterlyComparison')}

- - - - - - - - - + + + + + + + + + @@ -429,58 +440,58 @@ function Dashboard({ data, showDataLabels }) { {/* Desktop: Charts Grid */} -
+
-

Revenue Trends

-
- - -
- + + + +
+ } + >
{filters.museum === 'all' && (
-

Visitors by Museum

- - + +
)} {filters.museum === 'all' && (
-

Revenue by Museum

- +
)}
-

Quarterly Revenue (YoY)

- - + +
-

District Performance

- +
-

Capture Rate vs Umrah Pilgrims

- + 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 }) { >
-

Revenue Trends

+

{t('dashboard.revenueTrends')}

- - + +
@@ -541,9 +552,9 @@ function Dashboard({ data, showDataLabels }) { {filters.museum === 'all' && (
-

Visitors by Museum

+

{t('dashboard.visitorsByMuseum')}

- +
@@ -552,7 +563,7 @@ function Dashboard({ data, showDataLabels }) { {filters.museum === 'all' && (
-

Revenue by Museum

+

{t('dashboard.revenueByMuseum')}

@@ -562,16 +573,16 @@ function Dashboard({ data, showDataLabels }) {
-

Quarterly Revenue (YoY)

+

{t('dashboard.quarterlyRevenue')}

- +
-

District Performance

+

{t('dashboard.districtPerformance')}

@@ -580,13 +591,13 @@ function Dashboard({ data, showDataLabels }) {
-

Capture Rate vs Umrah Pilgrims

+

{t('dashboard.captureRateChart')}

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 } } } diff --git a/src/components/Slides.js b/src/components/Slides.js index 407a250..e682c2d 100644 --- a/src/components/Slides.js +++ b/src/components/Slides.js @@ -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 (
-

Presentation Builder

-

Create slides with charts and export as HTML or PDF

+

{t('slides.title')}

+

{t('slides.subtitle')}

@@ -187,7 +190,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)} - Add Slide + {t('slides.addSlide')} {slides.length > 0 && ( <> @@ -195,13 +198,13 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)} - Preview + {t('slides.preview')} )} @@ -209,11 +212,11 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
-

Slides ({slides.length})

+

{t('slides.slidesCount')} ({slides.length})

{slides.length === 0 ? (
-

No slides yet

- +

{t('slides.noSlides')}

+
) : (
@@ -245,6 +248,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)} districts={districts} districtMuseumMap={districtMuseumMap} data={data} + chartTypes={CHART_TYPES} + metrics={METRICS} /> )}
@@ -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 (
- + onUpdate({ title: e.target.value })} - placeholder="Enter slide title" + placeholder={t('slides.slideTitle')} />
- +
- {CHART_TYPES.map(type => ( + {chartTypes.map(type => (
); } -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 (
-
{formatCompactCurrency(metrics.revenue)}
-
Revenue
+
{formatCompactCurrency(metricsData.revenue)}
+
{t('metrics.revenue')}
-
{formatCompact(metrics.visitors)}
-
Visitors
+
{formatCompact(metricsData.visitors)}
+
{t('metrics.visitors')}
-
{formatCompact(metrics.tickets)}
-
Tickets
+
{formatCompact(metricsData.tickets)}
+
{t('metrics.tickets')}
); @@ -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,

{slide?.title}

- {slide && } + {slide && }
{currentSlide + 1} / {slides.length} @@ -468,7 +485,7 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
- +
); diff --git a/src/components/shared/Carousel.jsx b/src/components/shared/Carousel.jsx index 7d6637c..032ea59 100644 --- a/src/components/shared/Carousel.jsx +++ b/src/components/shared/Carousel.jsx @@ -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 ( -
+
{React.Children.map(children, (child, i) => ( -
+
{child}
))} @@ -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] && ( {labels[i]} diff --git a/src/components/shared/EmptyState.jsx b/src/components/shared/EmptyState.jsx index 20dab2e..bc06edd 100644 --- a/src/components/shared/EmptyState.jsx +++ b/src/components/shared/EmptyState.jsx @@ -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 ( -
-
{icon}
-

{title}

-

{message}

+
+ + {title && ( +

{title}

+ )} + {message && ( +

{message}

+ )} {action && ( - )} diff --git a/src/components/shared/FilterControls.jsx b/src/components/shared/FilterControls.jsx index e2fbbd1..481b811 100644 --- a/src/components/shared/FilterControls.jsx +++ b/src/components/shared/FilterControls.jsx @@ -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 (
-
setExpanded(!expanded)}> -

{title}

+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleExpanded(); + } + }} + > +

{displayTitle}

{onReset && expanded && ( )} -
-
+ +
{children}
@@ -37,7 +90,7 @@ function FilterControls({ function FilterGroup({ label, children }) { return (
- + {label && } {children}
); diff --git a/src/components/shared/StatCard.jsx b/src/components/shared/StatCard.jsx index a8ea393..80d2e6a 100644 --- a/src/components/shared/StatCard.jsx +++ b/src/components/shared/StatCard.jsx @@ -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 (

{title}

{value}
+ {subtitle && ( +
{subtitle}
+ )} {change !== null && (
- {isPositive ? '↑' : '↓'} {Math.abs(change).toFixed(1)}% {changeLabel} + {isPositive ? '↑' : '↓'} + {Math.abs(change).toFixed(1)}% + {changeLabel}
)}
diff --git a/src/index.js b/src/index.js index 593edf1..48dc746 100644 --- a/src/index.js +++ b/src/index.js @@ -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( - + + + );
QuarterRev 2024Rev 2025ChangeVisitors 2024Visitors 2025ChangeCapture 2024Capture 2025{t('table.quarter')}{t('table.rev2024')}{t('table.rev2025')}{t('table.change')}{t('table.visitors2024')}{t('table.visitors2025')}{t('table.change')}{t('table.capture2024')}{t('table.capture2025')}