diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..ec47392
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,3 @@
+# NocoDB Configuration (optional - only needed if using NocoDB as data source)
+REACT_APP_NOCODB_URL=http://localhost:8090
+REACT_APP_NOCODB_TOKEN=your_token_here
diff --git a/src/App.css b/src/App.css
index e770738..e6e7190 100644
--- a/src/App.css
+++ b/src/App.css
@@ -72,6 +72,78 @@ body {
font-size: 0.875rem;
}
+.error-container button:hover {
+ background: var(--text-secondary);
+}
+
+/* Empty State */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ text-align: center;
+ color: var(--text-secondary);
+}
+
+.empty-state-icon {
+ font-size: 3rem;
+ margin-bottom: 16px;
+ opacity: 0.5;
+}
+
+.empty-state-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 8px;
+}
+
+.empty-state-message {
+ font-size: 0.875rem;
+ color: var(--text-muted);
+ max-width: 300px;
+ margin-bottom: 20px;
+}
+
+.empty-state-action {
+ padding: 10px 20px;
+ background: var(--primary);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-weight: 500;
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.empty-state-action:hover {
+ background: #1d4ed8;
+}
+
+/* Skeleton Loader */
+.skeleton {
+ background: linear-gradient(90deg, var(--border) 25%, var(--bg) 50%, var(--border) 75%);
+ background-size: 200% 100%;
+ animation: skeleton-loading 1.5s infinite;
+ border-radius: 4px;
+}
+
+@keyframes skeleton-loading {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+.skeleton-text {
+ height: 1em;
+ margin-bottom: 0.5em;
+}
+
+.skeleton-text.lg { height: 2em; width: 60%; }
+.skeleton-text.sm { height: 0.75em; width: 40%; }
+
/* Navigation */
.nav-bar {
background: var(--surface);
@@ -280,6 +352,25 @@ body {
margin-bottom: 20px;
}
+.chart-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.chart-card-header h2 {
+ margin: 0;
+}
+
+.chart-card-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
.chart-container {
height: 280px;
position: relative;
@@ -349,6 +440,12 @@ table tbody tr:hover {
margin: 0;
}
+.controls-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
.controls h3 {
font-size: 0.75rem;
font-weight: 500;
@@ -357,6 +454,23 @@ table tbody tr:hover {
letter-spacing: 0.05em;
}
+.controls-reset {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+ padding: 4px 12px;
+ border-radius: 6px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.controls-reset:hover {
+ border-color: var(--danger);
+ color: var(--danger);
+}
+
.controls-toggle {
display: none;
background: none;
@@ -661,6 +775,16 @@ table tbody tr:hover {
}
/* Carousel - elegant card slider */
+.carousel {
+ outline: none;
+}
+
+.carousel:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 4px;
+ border-radius: 8px;
+}
+
.carousel-container {
position: relative;
margin: 0 -6px;
diff --git a/src/components/Comparison.js b/src/components/Comparison.js
index 2f64434..171924e 100644
--- a/src/components/Comparison.js
+++ b/src/components/Comparison.js
@@ -1,22 +1,10 @@
import React, { useState, useMemo, useCallback, useRef } from 'react';
import { Line, Bar } from 'react-chartjs-2';
-import {
- Chart as ChartJS,
- CategoryScale,
- LinearScale,
- PointElement,
- LineElement,
- BarElement,
- Title,
- Tooltip,
- Legend,
- Filler
-} from 'chart.js';
-import ChartDataLabels from 'chartjs-plugin-datalabels';
+import { EmptyState, FilterControls } from './shared';
+import { chartColors, createBaseOptions } from '../config/chartConfig';
import {
filterDataByDateRange,
calculateMetrics,
- formatCurrency,
formatCompact,
formatCompactCurrency,
umrahData,
@@ -26,17 +14,6 @@ import {
getLatestYear
} from '../services/dataService';
-ChartJS.register(
- CategoryScale, LinearScale, PointElement, LineElement,
- BarElement, Title, Tooltip, Legend, Filler, ChartDataLabels
-);
-
-const chartColors = {
- primary: '#2563eb',
- muted: '#94a3b8',
- grid: '#f1f5f9'
-};
-
// Generate preset dates for a given year
const generatePresetDates = (year) => ({
'jan': { start: `${year}-01-01`, end: `${year}-01-31` },
@@ -70,7 +47,6 @@ function Comparison({ data, showDataLabels }) {
const [filters, setFilters] = useState({ district: 'all', museum: 'all' });
const [chartMetric, setChartMetric] = useState('revenue');
const [chartGranularity, setChartGranularity] = useState('week');
- const [controlsExpanded, setControlsExpanded] = useState(true);
const [activeChart, setActiveChart] = useState(0);
const [activeCard, setActiveCard] = useState(0);
@@ -159,6 +135,9 @@ function Comparison({ data, showDataLabels }) {
const prevMetrics = useMemo(() => calculateMetrics(prevData), [prevData]);
const currMetrics = useMemo(() => calculateMetrics(currData), [currData]);
+
+ const hasData = prevData.length > 0 || currData.length > 0;
+ const resetFilters = () => setFilters({ district: 'all', museum: 'all' });
const calcChange = (prev, curr) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
@@ -204,31 +183,29 @@ function Comparison({ data, showDataLabels }) {
const captureRates = quarterData?.captureRate || null;
const pilgrimCounts = quarterData?.pilgrims || null;
- const changes = {
- revenue: calcChange(prevMetrics.revenue, currMetrics.revenue),
- visitors: calcChange(prevMetrics.visitors, currMetrics.visitors),
- tickets: calcChange(prevMetrics.tickets, currMetrics.tickets),
- avgRev: calcChange(prevMetrics.avgRevPerVisitor, currMetrics.avgRevPerVisitor),
- pilgrims: pilgrimCounts ? calcChange(pilgrimCounts.prev || 0, pilgrimCounts.curr || 0) : null,
- captureRate: captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null
- };
-
// Build cards array dynamically
const metricCards = useMemo(() => {
+ const revenueChange = calcChange(prevMetrics.revenue, currMetrics.revenue);
+ const visitorsChange = calcChange(prevMetrics.visitors, currMetrics.visitors);
+ const ticketsChange = calcChange(prevMetrics.tickets, currMetrics.tickets);
+ const avgRevChange = calcChange(prevMetrics.avgRevPerVisitor, currMetrics.avgRevPerVisitor);
+ const pilgrimsChange = pilgrimCounts ? calcChange(pilgrimCounts.prev || 0, pilgrimCounts.curr || 0) : null;
+ const captureRateChange = captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null;
+
const cards = [
- { title: 'Revenue', prev: prevMetrics.revenue, curr: currMetrics.revenue, change: changes.revenue, isCurrency: true },
- { title: 'Visitors', prev: prevMetrics.visitors, curr: currMetrics.visitors, change: changes.visitors },
- { title: 'Tickets', prev: prevMetrics.tickets, curr: currMetrics.tickets, change: changes.tickets },
- { title: 'Avg Rev/Visitor', prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: changes.avgRev, isCurrency: true }
+ { 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 }
];
if (pilgrimCounts) {
- cards.push({ title: 'Pilgrims', prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: changes.pilgrims, pendingMessage: 'Data not published yet' });
+ cards.push({ title: 'Pilgrims', prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: pilgrimsChange, pendingMessage: 'Data not published yet' });
}
if (captureRates) {
- cards.push({ title: 'Capture Rate', prev: captureRates.prev, curr: captureRates.curr, change: changes.captureRate, isPercent: true, pendingMessage: 'Data not published yet' });
+ cards.push({ title: 'Capture Rate', prev: captureRates.prev, curr: captureRates.curr, change: captureRateChange, isPercent: true, pendingMessage: 'Data not published yet' });
}
return cards;
- }, [prevMetrics, currMetrics, changes, pilgrimCounts, captureRates]);
+ }, [prevMetrics, currMetrics, pilgrimCounts, captureRates]);
const handleCardTouchStart = (e) => {
touchStartCard.current = e.touches[0].clientX;
@@ -345,34 +322,12 @@ function Comparison({ data, showDataLabels }) {
};
}, [data, prevData, currData, ranges, chartMetric, getMetricValue]);
+ const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
const chartOptions = {
- responsive: true,
- maintainAspectRatio: false,
+ ...baseOptions,
plugins: {
- legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } },
- tooltip: { backgroundColor: '#1e293b', padding: 12, cornerRadius: 8 },
- datalabels: {
- display: showDataLabels,
- color: '#1e293b',
- font: { size: 10, weight: 600 },
- anchor: 'end',
- align: 'end',
- offset: 4,
- padding: 4,
- backgroundColor: 'rgba(255, 255, 255, 0.85)',
- borderRadius: 3,
- formatter: (value) => {
- if (value == null) return '';
- if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
- if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
- if (value < 100 && value > 0) return value.toFixed(2);
- return Math.round(value).toLocaleString();
- }
- }
- },
- scales: {
- x: { grid: { display: false }, ticks: { font: { size: 10 }, color: '#94a3b8' } },
- y: { grid: { color: chartColors.grid }, ticks: { font: { size: 10 }, color: '#94a3b8' }, border: { display: false } }
+ ...baseOptions.plugins,
+ legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } }
}
};
@@ -383,17 +338,9 @@ function Comparison({ data, showDataLabels }) {
Year-over-year analysis — same period, different years
-
-
setControlsExpanded(!controlsExpanded)}>
-
Select Period
-
-
-
-
-
-
+
+
+
-
+
{preset === 'custom' && (
<>
-
-
+
setStartDate(e.target.value)} />
-
-
-
+
+
setEndDate(e.target.value)} />
-
+
>
)}
-
-
+
-
-
-
+
+
-
-
+
+
{ranges.prev.start.substring(0, 4)}
@@ -458,9 +397,18 @@ function Comparison({ data, showDataLabels }) {
{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}
-
-
+
+ {!hasData ? (
+
+ ) : (
+ <>
{/* Desktop: Grid layout */}
{metricCards.map((card, i) => (
@@ -661,6 +609,8 @@ function Comparison({ data, showDataLabels }) {
))}
+ >
+ )}
);
}
diff --git a/src/components/Dashboard.js b/src/components/Dashboard.js
index b0b34e9..48c86f4 100644
--- a/src/components/Dashboard.js
+++ b/src/components/Dashboard.js
@@ -1,19 +1,7 @@
-import React, { useState, useMemo, useRef } from 'react';
+import React, { useState, useMemo } from 'react';
import { Line, Doughnut, Bar } from 'react-chartjs-2';
-import {
- Chart as ChartJS,
- CategoryScale,
- LinearScale,
- PointElement,
- LineElement,
- BarElement,
- ArcElement,
- Title,
- Tooltip,
- Legend,
- Filler
-} from 'chart.js';
-import ChartDataLabels from 'chartjs-plugin-datalabels';
+import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
+import { chartColors, createBaseOptions } from '../config/chartConfig';
import {
filterData,
calculateMetrics,
@@ -29,38 +17,24 @@ import {
getMuseumsForDistrict
} from '../services/dataService';
-ChartJS.register(
- CategoryScale, LinearScale, PointElement, LineElement,
- BarElement, ArcElement, Title, Tooltip, Legend, Filler,
- ChartDataLabels
-);
-
-const chartColors = {
- primary: '#2563eb',
- secondary: '#7c3aed',
- tertiary: '#0891b2',
- muted: '#cbd5e1',
- grid: '#f1f5f9'
+const defaultFilters = {
+ year: 'all',
+ district: 'all',
+ museum: 'all',
+ quarter: 'all'
};
function Dashboard({ data, showDataLabels }) {
- const [filters, setFilters] = useState({
- year: 'all',
- district: 'all',
- museum: 'all',
- quarter: 'all'
- });
- const [filtersExpanded, setFiltersExpanded] = useState(true);
+ const [filters, setFilters] = useState(defaultFilters);
const [activeStatCard, setActiveStatCard] = useState(0);
const [activeChart, setActiveChart] = useState(0);
const [trendGranularity, setTrendGranularity] = useState('week');
- // Touch handlers for carousels
- const touchStartStat = useRef(null);
- const touchStartChart = useRef(null);
-
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
const metrics = useMemo(() => calculateMetrics(filteredData), [filteredData]);
+ const hasData = filteredData.length > 0;
+
+ const resetFilters = () => setFilters(defaultFilters);
// Stat cards for carousel
const statCards = useMemo(() => [
@@ -70,38 +44,11 @@ function Dashboard({ data, showDataLabels }) {
{ title: 'Avg Rev/Visitor', value: formatCurrency(metrics.avgRevPerVisitor) }
], [metrics]);
- const handleStatTouchStart = (e) => { touchStartStat.current = e.touches[0].clientX; };
- const handleStatTouchEnd = (e) => {
- if (!touchStartStat.current) return;
- const diff = touchStartStat.current - e.changedTouches[0].clientX;
- if (Math.abs(diff) > 50) {
- if (diff > 0 && activeStatCard < statCards.length - 1) setActiveStatCard(activeStatCard + 1);
- else if (diff < 0 && activeStatCard > 0) setActiveStatCard(activeStatCard - 1);
- }
- touchStartStat.current = null;
- };
-
- // Chart carousel - define charts array
- const dashboardCharts = useMemo(() => [
- { id: 'revenue-trend', label: 'Revenue Trend' },
- { id: 'visitors-museum', label: 'Visitors' },
- { id: 'revenue-museum', label: 'Revenue' },
- { id: 'quarterly-yoy', label: 'Quarterly' },
- { id: 'district', label: 'District' },
- { id: 'capture-rate', label: 'Capture Rate' }
- ], []);
-
- const handleChartTouchStart = (e) => { touchStartChart.current = e.touches[0].clientX; };
- const handleChartTouchEnd = (e) => {
- if (!touchStartChart.current) return;
- const diff = touchStartChart.current - e.changedTouches[0].clientX;
- const maxCharts = filters.museum === 'all' ? dashboardCharts.length : dashboardCharts.length - 2;
- if (Math.abs(diff) > 50) {
- if (diff > 0 && activeChart < maxCharts - 1) setActiveChart(activeChart + 1);
- else if (diff < 0 && activeChart > 0) setActiveChart(activeChart - 1);
- }
- touchStartChart.current = null;
- };
+ // Chart carousel labels
+ const chartLabels = useMemo(() => {
+ const labels = ['Revenue Trend', 'Visitors', 'Revenue', 'Quarterly', 'District', 'Capture Rate'];
+ return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2);
+ }, [filters.museum]);
// Dynamic lists from data
const years = useMemo(() => getUniqueYears(data), [data]);
@@ -306,7 +253,7 @@ function Dashboard({ data, showDataLabels }) {
}
]
};
- }, [data, filters.district, filters.museum]);
+ }, [data, filters.district, filters.museum, showDataLabels]);
// Quarterly table
const quarterlyTable = useMemo(() => {
@@ -335,38 +282,7 @@ function Dashboard({ data, showDataLabels }) {
});
}, [data, filters.district, filters.museum]);
- const dataLabelDefaults = {
- display: showDataLabels,
- color: '#1e293b',
- font: { size: 10, weight: 600 },
- anchor: 'end',
- align: 'end',
- offset: 4,
- padding: 4,
- backgroundColor: 'rgba(255, 255, 255, 0.85)',
- borderRadius: 3,
- formatter: (value) => {
- if (value == null) return '';
- if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
- if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
- if (value < 100 && value > 0) return value.toFixed(2);
- return Math.round(value).toLocaleString();
- }
- };
-
- const baseOptions = {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- legend: { display: false },
- tooltip: { backgroundColor: '#1e293b', padding: 12, cornerRadius: 8, titleFont: { size: 12 }, bodyFont: { size: 11 } },
- datalabels: dataLabelDefaults
- },
- scales: {
- x: { grid: { display: false }, ticks: { font: { size: 10 }, color: '#94a3b8' } },
- y: { grid: { color: chartColors.grid }, ticks: { font: { size: 10 }, color: '#94a3b8' }, border: { display: false } }
- }
- };
+ const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
return (
@@ -375,114 +291,74 @@ function Dashboard({ data, showDataLabels }) {
Real-time museum analytics from Google Sheets
-
-
setFiltersExpanded(!filtersExpanded)}>
-
Filters
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{/* Desktop: Grid */}
-
-
Total Revenue
-
{formatCurrency(metrics.revenue)}
- {yoyChange !== null && (
-
= 0 ? 'positive' : 'negative'}`}>
- {yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY
-
- )}
-
-
-
Total Visitors
-
{formatNumber(metrics.visitors)}
-
-
-
Total Tickets
-
{formatNumber(metrics.tickets)}
-
-
-
Avg Revenue/Visitor
-
{formatCurrency(metrics.avgRevPerVisitor)}
-
+
+
+
+
{/* Mobile: Stats Carousel */}
-
-
-
- {statCards.map((card, i) => (
-
-
-
{card.title}
-
{card.value}
- {card.hasYoy && yoyChange !== null && (
-
= 0 ? 'positive' : 'negative'}`}>
- {yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY
-
- )}
-
-
- ))}
-
-
-
-
+ c.title.replace('Total ', '').replace('Avg ', ''))}
+ >
{statCards.map((card, i) => (
-
+
))}
-
+
+ {!hasData ? (
+
+ ) : (
+ <>
Quarterly Comparison: 2024 vs 2025
@@ -619,8 +495,6 @@ function Dashboard({ data, showDataLabels }) {
@@ -722,17 +596,19 @@ function Dashboard({ data, showDataLabels }) {
- {(filters.museum === 'all' ? dashboardCharts : dashboardCharts.filter(c => !['visitors-museum', 'revenue-museum'].includes(c.id))).map((chart, i) => (
+ {chartLabels.map((label, i) => (
))}
+ >
+ )}
);
}
diff --git a/src/components/shared/Carousel.jsx b/src/components/shared/Carousel.jsx
new file mode 100644
index 0000000..7d6637c
--- /dev/null
+++ b/src/components/shared/Carousel.jsx
@@ -0,0 +1,78 @@
+import React, { useRef, useCallback } from 'react';
+
+function Carousel({
+ children,
+ activeIndex,
+ setActiveIndex,
+ labels = [],
+ showLabels = true,
+ className = ''
+}) {
+ const touchStart = useRef(null);
+ const itemCount = React.Children.count(children);
+
+ const handleTouchStart = useCallback((e) => {
+ touchStart.current = e.touches[0].clientX;
+ }, []);
+
+ const handleTouchEnd = useCallback((e) => {
+ if (!touchStart.current) return;
+ const diff = touchStart.current - e.changedTouches[0].clientX;
+ if (Math.abs(diff) > 50) {
+ if (diff > 0 && activeIndex < itemCount - 1) {
+ setActiveIndex(activeIndex + 1);
+ } else if (diff < 0 && activeIndex > 0) {
+ setActiveIndex(activeIndex - 1);
+ }
+ }
+ touchStart.current = null;
+ }, [activeIndex, setActiveIndex, itemCount]);
+
+ const handleKeyDown = useCallback((e) => {
+ if (e.key === 'ArrowLeft' && activeIndex > 0) {
+ setActiveIndex(activeIndex - 1);
+ } else if (e.key === 'ArrowRight' && activeIndex < itemCount - 1) {
+ setActiveIndex(activeIndex + 1);
+ }
+ }, [activeIndex, setActiveIndex, itemCount]);
+
+ return (
+
+
+
+
+ {React.Children.map(children, (child, i) => (
+
+ {child}
+
+ ))}
+
+
+
+
+
+ {Array.from({ length: itemCount }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
+
+export default Carousel;
diff --git a/src/components/shared/ChartCard.jsx b/src/components/shared/ChartCard.jsx
new file mode 100644
index 0000000..a9fa93b
--- /dev/null
+++ b/src/components/shared/ChartCard.jsx
@@ -0,0 +1,28 @@
+import React from 'react';
+
+function ChartCard({
+ title,
+ children,
+ className = '',
+ headerRight = null,
+ fullWidth = false,
+ halfWidth = false
+}) {
+ const sizeClass = fullWidth ? 'full-width' : halfWidth ? 'half-width' : '';
+
+ return (
+
+ {(title || headerRight) && (
+
+ {title &&
{title}
}
+ {headerRight &&
{headerRight}
}
+
+ )}
+
+ {children}
+
+
+ );
+}
+
+export default ChartCard;
diff --git a/src/components/shared/EmptyState.jsx b/src/components/shared/EmptyState.jsx
new file mode 100644
index 0000000..20dab2e
--- /dev/null
+++ b/src/components/shared/EmptyState.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+
+function EmptyState({
+ icon = '📊',
+ title = 'No data found',
+ message = 'Try adjusting your filters',
+ action,
+ actionLabel = 'Reset Filters'
+}) {
+ return (
+
+
{icon}
+
{title}
+
{message}
+ {action && (
+
+ )}
+
+ );
+}
+
+export default EmptyState;
diff --git a/src/components/shared/FilterControls.jsx b/src/components/shared/FilterControls.jsx
new file mode 100644
index 0000000..e2fbbd1
--- /dev/null
+++ b/src/components/shared/FilterControls.jsx
@@ -0,0 +1,53 @@
+import React, { useState } from 'react';
+
+function FilterControls({
+ children,
+ title = 'Filters',
+ defaultExpanded = true,
+ onReset = null,
+ className = ''
+}) {
+ const [expanded, setExpanded] = useState(defaultExpanded);
+
+ return (
+
+
setExpanded(!expanded)}>
+
{title}
+
+ {onReset && expanded && (
+
+ )}
+
+
+
+
+ {children}
+
+
+ );
+}
+
+function FilterGroup({ label, children }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+function FilterRow({ children }) {
+ return
{children}
;
+}
+
+FilterControls.Group = FilterGroup;
+FilterControls.Row = FilterRow;
+
+export default FilterControls;
diff --git a/src/components/shared/StatCard.jsx b/src/components/shared/StatCard.jsx
new file mode 100644
index 0000000..a8ea393
--- /dev/null
+++ b/src/components/shared/StatCard.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+
+function StatCard({ title, value, change = null, changeLabel = 'YoY' }) {
+ const isPositive = change !== null && change >= 0;
+
+ return (
+
+
{title}
+
{value}
+ {change !== null && (
+
+ {isPositive ? '↑' : '↓'} {Math.abs(change).toFixed(1)}% {changeLabel}
+
+ )}
+
+ );
+}
+
+export default StatCard;
diff --git a/src/components/shared/ToggleSwitch.jsx b/src/components/shared/ToggleSwitch.jsx
new file mode 100644
index 0000000..529520d
--- /dev/null
+++ b/src/components/shared/ToggleSwitch.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+
+function ToggleSwitch({ options, value, onChange, className = '' }) {
+ return (
+
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
+
+export default ToggleSwitch;
diff --git a/src/components/shared/index.js b/src/components/shared/index.js
new file mode 100644
index 0000000..d10bbb4
--- /dev/null
+++ b/src/components/shared/index.js
@@ -0,0 +1,6 @@
+export { default as Carousel } from './Carousel';
+export { default as ChartCard } from './ChartCard';
+export { default as EmptyState } from './EmptyState';
+export { default as FilterControls } from './FilterControls';
+export { default as StatCard } from './StatCard';
+export { default as ToggleSwitch } from './ToggleSwitch';
diff --git a/src/config/chartConfig.js b/src/config/chartConfig.js
new file mode 100644
index 0000000..902fcb1
--- /dev/null
+++ b/src/config/chartConfig.js
@@ -0,0 +1,97 @@
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ BarElement,
+ ArcElement,
+ Title,
+ Tooltip,
+ Legend,
+ Filler
+} from 'chart.js';
+import ChartDataLabels from 'chartjs-plugin-datalabels';
+
+// Register ChartJS components once
+ChartJS.register(
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ BarElement,
+ ArcElement,
+ Title,
+ Tooltip,
+ Legend,
+ Filler,
+ ChartDataLabels
+);
+
+export const chartColors = {
+ primary: '#2563eb',
+ secondary: '#7c3aed',
+ tertiary: '#0891b2',
+ success: '#059669',
+ danger: '#dc2626',
+ muted: '#94a3b8',
+ grid: '#f1f5f9'
+};
+
+export const createDataLabelConfig = (showDataLabels) => ({
+ display: showDataLabels,
+ color: '#1e293b',
+ font: { size: 10, weight: 600 },
+ anchor: 'end',
+ align: 'end',
+ offset: 4,
+ padding: 4,
+ backgroundColor: 'rgba(255, 255, 255, 0.85)',
+ borderRadius: 3,
+ formatter: (value) => {
+ if (value == null) return '';
+ if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
+ if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
+ if (value < 100 && value > 0) return value.toFixed(2);
+ return Math.round(value).toLocaleString();
+ }
+});
+
+export const createBaseOptions = (showDataLabels) => ({
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ backgroundColor: '#1e293b',
+ padding: 12,
+ cornerRadius: 8,
+ titleFont: { size: 12 },
+ bodyFont: { size: 11 }
+ },
+ datalabels: createDataLabelConfig(showDataLabels)
+ },
+ scales: {
+ x: {
+ grid: { display: false },
+ ticks: { font: { size: 10 }, color: '#94a3b8' }
+ },
+ y: {
+ grid: { color: chartColors.grid },
+ ticks: { font: { size: 10 }, color: '#94a3b8' },
+ border: { display: false }
+ }
+ }
+});
+
+export const lineDatasetDefaults = {
+ borderWidth: 2,
+ tension: 0.4,
+ fill: true,
+ pointRadius: 0,
+ pointHoverRadius: 4
+};
+
+export const barDatasetDefaults = {
+ borderRadius: 4
+};
diff --git a/src/services/dataService.js b/src/services/dataService.js
index 8ab3511..b0a3b9f 100644
--- a/src/services/dataService.js
+++ b/src/services/dataService.js
@@ -3,15 +3,12 @@ const SPREADSHEET_ID = '1rdK1e7jmfu-es4Ql0YwDYNBY2OvVihBjYaXTM-MHHqg';
const SHEET_NAME = 'Consolidated Data';
const SHEET_URL = `https://docs.google.com/spreadsheets/d/${SPREADSHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}`;
-// NocoDB configuration
-// Use relative URL for dev proxy, full URL for production
-const NOCODB_URL = process.env.NODE_ENV === 'production' ? 'http://localhost:8090' : '';
-const NOCODB_TOKEN = 'By-wCdkUm6N9JdfmNpGH2jd6LqEejwOXER7FMkgr';
+// NocoDB configuration - uses environment variables for security
+// Set REACT_APP_NOCODB_URL and REACT_APP_NOCODB_TOKEN in .env.local
+const NOCODB_URL = process.env.REACT_APP_NOCODB_URL || '';
+const NOCODB_TOKEN = process.env.REACT_APP_NOCODB_TOKEN || '';
-// Old flat table (for backwards compatibility)
-const NOCODB_TABLE_ID = 'mzcz8ktjybcjc79';
-
-// New normalized tables (Samaya Museums Statistics base)
+// Table IDs (not sensitive - just identifiers)
const NOCODB_TABLES = {
districts: 'm8cup7lesbet0sa',
museums: 'm1c7od7mdirffvu',