diff --git a/.env.example b/.env.example
index 992a43d..b465417 100644
--- a/.env.example
+++ b/.env.example
@@ -1,8 +1,4 @@
-# NocoDB (primary data source)
+# NocoDB (PilgrimStats only — museum sales come from ERP API via server proxy)
VITE_NOCODB_URL=http://localhost:8090
-VITE_NOCODB_TOKEN=your_token_here
-VITE_NOCODB_BASE_ID=your_base_id_here
-
-# Google Sheets (fallback if NocoDB fails)
-VITE_SHEETS_ID=your_spreadsheet_id_here
-VITE_SHEETS_NAME=Consolidated Data
+VITE_NOCODB_TOKEN=your-token
+VITE_NOCODB_BASE_ID=your-base-id
diff --git a/server/index.js b/server/index.js
deleted file mode 100644
index 46a7fea..0000000
--- a/server/index.js
+++ /dev/null
@@ -1,271 +0,0 @@
-require('dotenv').config();
-const express = require('express');
-const cors = require('cors');
-const axios = require('axios');
-
-const app = express();
-app.use(cors());
-app.use(express.json());
-
-const PORT = process.env.SALLA_SERVER_PORT || 3001;
-
-// Salla OAuth Config
-const SALLA_CLIENT_ID = process.env.SALLA_CLIENT_ID;
-const SALLA_CLIENT_SECRET = process.env.SALLA_CLIENT_SECRET;
-const SALLA_REDIRECT_URI = process.env.SALLA_REDIRECT_URI || 'http://localhost:3001/auth/callback';
-
-// Token storage (in production, use a database)
-let accessToken = process.env.SALLA_ACCESS_TOKEN || null;
-let refreshToken = process.env.SALLA_REFRESH_TOKEN || null;
-
-// ============================================
-// OAuth Endpoints
-// ============================================
-
-// State for CSRF protection
-const crypto = require('crypto');
-let oauthState = null;
-
-// Step 1: Redirect to Salla authorization
-app.get('/auth/login', (req, res) => {
- oauthState = crypto.randomBytes(16).toString('hex');
-
- const authUrl = `https://accounts.salla.sa/oauth2/auth?` +
- `client_id=${SALLA_CLIENT_ID}` +
- `&redirect_uri=${encodeURIComponent(SALLA_REDIRECT_URI)}` +
- `&response_type=code` +
- `&scope=offline_access` +
- `&state=${oauthState}`;
-
- res.redirect(authUrl);
-});
-
-// Step 2: Handle OAuth callback
-app.get('/auth/callback', async (req, res) => {
- const { code, error, state } = req.query;
-
- if (error) {
- return res.status(400).json({ error: 'Authorization denied', details: error });
- }
-
- if (!code) {
- return res.status(400).json({ error: 'No authorization code received' });
- }
-
- // Verify state (optional check - some flows may not return state)
- if (oauthState && state && state !== oauthState) {
- return res.status(400).json({ error: 'Invalid state parameter' });
- }
-
- try {
- // Exchange code for tokens
- const response = await axios.post('https://accounts.salla.sa/oauth2/token', {
- client_id: SALLA_CLIENT_ID,
- client_secret: SALLA_CLIENT_SECRET,
- grant_type: 'authorization_code',
- code: code,
- redirect_uri: SALLA_REDIRECT_URI
- }, {
- headers: { 'Content-Type': 'application/json' }
- });
-
- accessToken = response.data.access_token;
- refreshToken = response.data.refresh_token;
-
- // Log tokens (save these to .env for persistence)
- console.log('\n========================================');
- console.log('🎉 SALLA CONNECTED SUCCESSFULLY!');
- console.log('========================================');
- console.log('Add these to your .env file:');
- console.log(`SALLA_ACCESS_TOKEN=${accessToken}`);
- console.log(`SALLA_REFRESH_TOKEN=${refreshToken}`);
- console.log('========================================\n');
-
- res.send(`
-
-
- ✅ Salla Connected!
- Authorization successful. You can close this window.
- Tokens have been logged to the console.
-
-
-
- `);
- } catch (err) {
- console.error('Token exchange failed:', err.response?.data || err.message);
- res.status(500).json({ error: 'Token exchange failed', details: err.response?.data });
- }
-});
-
-// Check auth status
-app.get('/auth/status', (req, res) => {
- res.json({
- connected: !!accessToken,
- hasRefreshToken: !!refreshToken
- });
-});
-
-// Refresh token
-async function refreshAccessToken() {
- if (!refreshToken) throw new Error('No refresh token available');
-
- const response = await axios.post('https://accounts.salla.sa/oauth2/token', {
- client_id: SALLA_CLIENT_ID,
- client_secret: SALLA_CLIENT_SECRET,
- grant_type: 'refresh_token',
- refresh_token: refreshToken
- });
-
- accessToken = response.data.access_token;
- if (response.data.refresh_token) {
- refreshToken = response.data.refresh_token;
- }
-
- return accessToken;
-}
-
-// ============================================
-// Salla API Proxy Endpoints
-// ============================================
-
-// Generic API caller with auto-refresh
-async function callSallaAPI(endpoint, method = 'GET', data = null) {
- if (!accessToken) throw new Error('Not authenticated. Visit /auth/login first.');
-
- try {
- const response = await axios({
- method,
- url: `https://api.salla.dev/admin/v2${endpoint}`,
- headers: {
- 'Authorization': `Bearer ${accessToken}`,
- 'Content-Type': 'application/json'
- },
- data
- });
- return response.data;
- } catch (err) {
- if (err.response?.status === 401) {
- // Try refresh
- await refreshAccessToken();
- return callSallaAPI(endpoint, method, data);
- }
- throw err;
- }
-}
-
-// Get store info
-app.get('/api/store', async (req, res) => {
- try {
- const data = await callSallaAPI('/store/info');
- res.json(data);
- } catch (err) {
- res.status(500).json({ error: err.message });
- }
-});
-
-// Get orders
-app.get('/api/orders', async (req, res) => {
- try {
- const { page = 1, per_page = 50, status } = req.query;
- let endpoint = `/orders?page=${page}&per_page=${per_page}`;
- if (status) endpoint += `&status=${status}`;
-
- const data = await callSallaAPI(endpoint);
- res.json(data);
- } catch (err) {
- res.status(500).json({ error: err.message });
- }
-});
-
-// Get order details
-app.get('/api/orders/:id', async (req, res) => {
- try {
- const data = await callSallaAPI(`/orders/${req.params.id}`);
- res.json(data);
- } catch (err) {
- res.status(500).json({ error: err.message });
- }
-});
-
-// Get products
-app.get('/api/products', async (req, res) => {
- try {
- const { page = 1, per_page = 50 } = req.query;
- const data = await callSallaAPI(`/products?page=${page}&per_page=${per_page}`);
- res.json(data);
- } catch (err) {
- res.status(500).json({ error: err.message });
- }
-});
-
-// Get customers
-app.get('/api/customers', async (req, res) => {
- try {
- const { page = 1, per_page = 50 } = req.query;
- const data = await callSallaAPI(`/customers?page=${page}&per_page=${per_page}`);
- res.json(data);
- } catch (err) {
- res.status(500).json({ error: err.message });
- }
-});
-
-// Get analytics/reports
-app.get('/api/analytics/summary', async (req, res) => {
- try {
- // Fetch multiple endpoints for a summary
- const [orders, products] = await Promise.all([
- callSallaAPI('/orders?per_page=100'),
- callSallaAPI('/products?per_page=100')
- ]);
-
- // Calculate summary
- const ordersList = orders.data || [];
- const totalRevenue = ordersList.reduce((sum, o) => sum + (o.amounts?.total?.amount || 0), 0);
- const avgOrderValue = ordersList.length > 0 ? totalRevenue / ordersList.length : 0;
-
- res.json({
- orders: {
- total: orders.pagination?.total || ordersList.length,
- recent: ordersList.length
- },
- products: {
- total: products.pagination?.total || (products.data?.length || 0)
- },
- revenue: {
- total: totalRevenue,
- average_order: avgOrderValue,
- currency: ordersList[0]?.amounts?.total?.currency || 'SAR'
- }
- });
- } catch (err) {
- res.status(500).json({ error: err.message });
- }
-});
-
-// ============================================
-// Start Server
-// ============================================
-
-app.listen(PORT, () => {
- console.log(`\n🚀 Salla Integration Server running on http://localhost:${PORT}`);
- console.log('\nEndpoints:');
- console.log(' GET /auth/login - Start OAuth flow');
- console.log(' GET /auth/callback - OAuth callback');
- console.log(' GET /auth/status - Check connection status');
- console.log(' GET /api/store - Store info');
- console.log(' GET /api/orders - List orders');
- console.log(' GET /api/products - List products');
- console.log(' GET /api/customers - List customers');
- console.log(' GET /api/analytics/summary - Dashboard summary');
-
- if (!SALLA_CLIENT_ID || !SALLA_CLIENT_SECRET) {
- console.log('\n⚠️ WARNING: SALLA_CLIENT_ID and SALLA_CLIENT_SECRET not set!');
- console.log(' Add them to server/.env file');
- }
-
- if (accessToken) {
- console.log('\n✅ Access token loaded from environment');
- } else {
- console.log('\n📝 Visit http://localhost:' + PORT + '/auth/login to connect Salla');
- }
-});
diff --git a/src/components/Comparison.tsx b/src/components/Comparison.tsx
index be0eb32..8b4d060 100644
--- a/src/components/Comparison.tsx
+++ b/src/components/Comparison.tsx
@@ -11,9 +11,8 @@ import {
formatCompact,
formatCompactCurrency,
umrahData,
- getUniqueDistricts,
- getDistrictMuseumMap,
- getMuseumsForDistrict,
+ getUniqueChannels,
+ getUniqueMuseums,
getLatestYear
} from '../services/dataService';
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
@@ -107,7 +106,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
return searchParams.get('to') || `${year}-01-31`;
});
const [filters, setFiltersState] = useState(() => ({
- district: searchParams.get('district') || 'all',
+ channel: searchParams.get('channel') || 'all',
museum: searchParams.get('museum') || 'all'
}));
@@ -125,7 +124,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
if (newFrom) params.set('from', newFrom);
if (newTo) params.set('to', newTo);
}
- if (newFilters?.district && newFilters.district !== 'all') params.set('district', newFilters.district);
+ if (newFilters?.channel && newFilters.channel !== 'all') params.set('channel', newFilters.channel);
if (newFilters?.museum && newFilters.museum !== 'all') params.set('museum', newFilters.museum);
setSearchParams(params, { replace: true });
}, [setSearchParams, latestYear]);
@@ -209,19 +208,18 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
if (metric === 'avgRevenue') {
- const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || r.revenue_incl_tax || 0)), 0);
+ const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || 0)), 0);
const visitors = rows.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
return visitors > 0 ? revenue / visitors : 0;
}
const fieldMap: Record = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
const field = fieldMap[metric];
- return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || r.revenue_incl_tax || 0)), 0);
+ return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || 0)), 0);
}, [revenueField]);
// Dynamic lists from data
- const districts = useMemo(() => getUniqueDistricts(data), [data]);
- const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
- const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
+ const channels = useMemo(() => getUniqueChannels(data), [data]);
+ const availableMuseums = useMemo(() => getUniqueMuseums(data), [data]);
// Year-over-year comparison: same dates, previous year
const ranges = useMemo(() => ({
@@ -246,7 +244,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
const hasData = prevData.length > 0 || currData.length > 0;
- const resetFilters = () => setFilters({ district: 'all', museum: 'all' });
+ const resetFilters = () => setFilters({ channel: 'all', museum: 'all' });
const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
@@ -577,10 +575,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
>
)}
-
-
-
- setFilters({...filters, district: e.target.value, museum: 'all'})}>
-
- {districts.map(d => )}
+
+ setFilters({...filters, channel: e.target.value})}>
+
+ {channels.map(c => )}
@@ -531,8 +529,8 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
-
-
+
+
@@ -633,9 +631,9 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
-
{t('dashboard.districtPerformance')}
+
{t('dashboard.channelPerformance')}
-
+
diff --git a/src/components/Slides.tsx b/src/components/Slides.tsx
index 3926898..37c9a51 100644
--- a/src/components/Slides.tsx
+++ b/src/components/Slides.tsx
@@ -7,14 +7,12 @@ import {
calculateMetrics,
formatCompact,
formatCompactCurrency,
- getUniqueDistricts,
- getDistrictMuseumMap,
- getMuseumsForDistrict
+ getUniqueChannels,
+ getUniqueMuseums
} from '../services/dataService';
import JSZip from 'jszip';
import type {
MuseumRecord,
- DistrictMuseumMap,
SlideConfig,
ChartTypeOption,
MetricOption,
@@ -25,8 +23,8 @@ import type {
interface SlideEditorProps {
slide: SlideConfig;
onUpdate: (updates: Partial) => void;
- districts: string[];
- districtMuseumMap: DistrictMuseumMap;
+ channels: string[];
+ museums: string[];
data: MuseumRecord[];
chartTypes: ChartTypeOption[];
metrics: MetricOption[];
@@ -35,16 +33,16 @@ interface SlideEditorProps {
interface SlidePreviewProps {
slide: SlideConfig;
data: MuseumRecord[];
- districts: string[];
- districtMuseumMap: DistrictMuseumMap;
+ channels: string[];
+ museums: string[];
metrics: MetricOption[];
}
interface PreviewModeProps {
slides: SlideConfig[];
data: MuseumRecord[];
- districts: string[];
- districtMuseumMap: DistrictMuseumMap;
+ channels: string[];
+ museums: string[];
currentSlide: number;
setCurrentSlide: React.Dispatch>;
onExit: () => void;
@@ -62,7 +60,7 @@ function Slides({ data }: SlidesProps) {
], [t]);
const METRICS: MetricOption[] = useMemo(() => [
- { id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' },
+ { id: 'revenue', label: t('metrics.revenue'), field: 'revenue_gross' },
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
], [t]);
@@ -71,8 +69,8 @@ function Slides({ data }: SlidesProps) {
const [previewMode, setPreviewMode] = useState(false);
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
- const districts = useMemo(() => getUniqueDistricts(data), [data]);
- const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
+ const channels = useMemo(() => getUniqueChannels(data), [data]);
+ const museums = useMemo(() => getUniqueMuseums(data), [data]);
const defaultSlideConfig: Omit = {
title: 'Slide Title',
@@ -80,7 +78,7 @@ function Slides({ data }: SlidesProps) {
metric: 'revenue',
startDate: '2026-01-01',
endDate: '2026-01-31',
- district: 'all',
+ channel: 'all',
museum: 'all',
showComparison: false
};
@@ -128,7 +126,7 @@ function Slides({ data }: SlidesProps) {
// Generate HTML for each slide
const slidesHTML = slides.map((slide, index) => {
- return generateSlideHTML(slide, index, data, districts, districtMuseumMap);
+ return generateSlideHTML(slide, index, data);
}).join('\n');
const fullHTML = `
@@ -185,7 +183,7 @@ function Slides({ data }: SlidesProps) {
${slidesHTML}