Compare commits

...

1 Commits

Author SHA1 Message Date
fahed dd512444fb feat(i18n): display Arabic museum names from NocoDB when language is Arabic
Deploy HiHala Dashboard / deploy (push) Successful in 12s
Fetches Museums table (Name + NameAr columns) at startup and passes translations
to Dashboard and Comparison. Museum filter dropdown, trend chart datasets, and
museum bar/pie chart labels all switch to Arabic when lang === 'ar'. Internal
filter state stays as English keys to match DailySales museum_name field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 13:40:44 +03:00
5 changed files with 45 additions and 16 deletions
+11 -3
View File
@@ -7,7 +7,7 @@ const Dashboard = lazy(() => import('./components/Dashboard'));
const Report = lazy(() => import('./components/Report'));
import Login from './components/Login';
import LoadingSkeleton from './components/shared/LoadingSkeleton';
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels, fetchMuseumTranslations } from './services/dataService';
import { fetchSeasons } from './services/seasonsService';
import { parseAllowed } from './services/usersService';
import { useLanguage } from './contexts/LanguageContext';
@@ -59,6 +59,7 @@ function App() {
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
const [dataSource, setDataSource] = useState<string>('museums');
const [seasons, setSeasons] = useState<Season[]>([]);
const [museumTranslations, setMuseumTranslations] = useState<Record<string, string>>({});
const [theme, setTheme] = useState<string>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('hihala_theme') || 'light';
@@ -118,6 +119,11 @@ function App() {
setSeasons(s);
}, []);
const loadMuseumTranslations = useCallback(async () => {
const t = await fetchMuseumTranslations();
setMuseumTranslations(t);
}, []);
// Check auth on mount
useEffect(() => {
fetch('/auth/check', { credentials: 'include' })
@@ -131,6 +137,7 @@ function App() {
setAllowedChannels(parseAllowed(d.allowedChannels));
loadData();
loadSeasons();
loadMuseumTranslations();
}
})
.catch(() => setAuthenticated(false));
@@ -145,6 +152,7 @@ function App() {
setAllowedChannels(parseAllowed(rawChannels));
loadData();
loadSeasons();
loadMuseumTranslations();
};
const handleRefresh = () => {
@@ -327,8 +335,8 @@ function App() {
<main>
<Suspense fallback={<LoadingSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} museumTranslations={museumTranslations} />} />
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} museumTranslations={museumTranslations} />} />
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
{userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />}
</Routes>
+7 -5
View File
@@ -22,6 +22,7 @@ interface Props {
includeVAT: boolean;
allowedMuseums: string[] | null;
allowedChannels: string[] | null;
museumTranslations?: Record<string, string>;
lang?: 'en' | 'ar';
}
@@ -65,8 +66,9 @@ function PeriodCard({ role, hint, start, end, variant, onChange, availableYears,
}
// ─── main page ────────────────────────────────────────────────────
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) {
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels, museumTranslations = {} }: Props) {
const { lang: activeLang, setLanguage } = useLanguage();
const tr = (name: string) => (activeLang === 'ar' && museumTranslations[name]) ? museumTranslations[name] : name;
const L = activeLang === 'ar' ? AR : EN;
const curr = currentMonth();
const [currStart, setCurrStart] = useState(curr.start);
@@ -167,7 +169,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
const museumDatasets = museumList.map((museum, idx) => {
const mg = group(currData.filter(r => r.museum_name === museum), currStart);
return {
label: museum,
label: tr(museum),
data: labels.map((_,i) => mg[i+1]||0),
borderColor: chartPalette[idx % chartPalette.length],
backgroundColor: 'transparent',
@@ -200,13 +202,13 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
all.forEach(m => { pb[m]=getVal(prevData.filter(r => r.museum_name===m), metric); cb[m]=getVal(currData.filter(r => r.museum_name===m), metric); });
const active = all.filter(m => pb[m]>0 || cb[m]>0);
return {
labels: active,
labels: active.map(tr),
datasets: [
{ label:periodLabel(prevStart,prevEnd), data:active.map(m => pb[m]), backgroundColor:chartColors.muted+'cc', borderRadius:4 },
{ label:periodLabel(currStart,currEnd), data:active.map(m => cb[m]), backgroundColor:chartColors.primary, borderRadius:4 },
]
};
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]);
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal, activeLang, museumTranslations]);
const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
const { chartOpts } = useMemo(() => {
@@ -326,7 +328,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
<div className="alt-filter-sep" />
<AltMultiSelect value={selDistricts} options={districts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
<AltMultiSelect value={selChannels} options={channels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
<AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
<AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} labelFn={activeLang === 'ar' ? tr : undefined} />
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
</div>
</div>
+7 -5
View File
@@ -22,12 +22,14 @@ interface Props {
setIncludeVAT: (v: boolean) => void;
allowedMuseums: string[] | null;
allowedChannels: string[] | null;
museumTranslations?: Record<string, string>;
lang?: 'en' | 'ar';
}
// ─── main page ────────────────────────────────────────────────────
export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: Props) {
export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels, museumTranslations = {} }: Props) {
const { lang: activeLang, setLanguage } = useLanguage();
const tr = (name: string) => (activeLang === 'ar' && museumTranslations[name]) ? museumTranslations[name] : name;
const L = activeLang === 'ar' ? AR : EN;
const curr = currentMonth();
const [start, setStart] = useState(curr.start);
@@ -128,7 +130,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const museumDatasets = museumList.map((museum, idx) => {
const mg = group(filteredData.filter(r => r.museum_name === museum), start);
return {
label: museum,
label: tr(museum),
data: labels.map((_,i) => mg[i+1]||0),
borderColor: chartPalette[idx % chartPalette.length],
backgroundColor: 'transparent',
@@ -159,8 +161,8 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
const g = groupByMuseum(filteredData, includeVAT);
const getM = (d: typeof g[string]) => metric==='visitors' ? d.visitors : metric==='tickets' ? d.tickets : d.revenue;
const entries = Object.entries(g).sort((a,b) => getM(b[1]) - getM(a[1]));
return { labels:entries.map(([k]) => k), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette, borderRadius:4 }] };
}, [filteredData, includeVAT, metric]);
return { labels:entries.map(([k]) => tr(k)), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette, borderRadius:4 }] };
}, [filteredData, includeVAT, metric, activeLang, museumTranslations]);
const channelData = useMemo(() => {
const g = groupByChannel(filteredData, includeVAT);
@@ -296,7 +298,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
<div className="alt-filter-sep" />
<AltMultiSelect value={selDistricts} options={allDistricts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
<AltMultiSelect value={selChannels} options={allChannels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
<AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
<AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} labelFn={activeLang === 'ar' ? tr : undefined} />
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
<div className="alt-filter-spacer" />
<div className="alt-vat-toggle">
+5 -3
View File
@@ -1,11 +1,13 @@
import React, { useState, useRef, useEffect } from 'react';
// ─── multi-select ─────────────────────────────────────────────────
export default function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
export default function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel, labelFn }: {
value: string[]; options: string[];
onChange: (vals: string[]) => void;
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
labelFn?: (opt: string) => string;
}) {
const display = labelFn ?? ((opt: string) => opt);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -15,7 +17,7 @@ export default function AltMultiSelect({ value, options, onChange, allLabel, cou
}, [open]);
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v !== opt) : [...value, opt]);
const label = value.length === 0 ? allLabel : value.length === 1 ? value[0] : countLabel(value.length);
const label = value.length === 0 ? allLabel : value.length === 1 ? display(value[0]) : countLabel(value.length);
return (
<div ref={ref} className="altms">
@@ -32,7 +34,7 @@ export default function AltMultiSelect({ value, options, onChange, allLabel, cou
<label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt) ? ' altms-option--checked' : ''}`}>
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
<span className="altms-opt-label">{opt}</span>
<span className="altms-opt-label">{display(opt)}</span>
</label>
))}
</div>
+15
View File
@@ -75,6 +75,21 @@ export let umrahData: UmrahData = {
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
};
export async function fetchMuseumTranslations(): Promise<Record<string, string>> {
try {
const tables = await discoverTableIds();
if (!tables['Museums']) return {};
const rows = await fetchNocoDBTable<{ Name: string; NameAr: string }>(tables['Museums']);
const map: Record<string, string> = {};
for (const r of rows) {
if (r.Name && r.NameAr) map[r.Name] = r.NameAr;
}
return map;
} catch {
return {};
}
}
export async function fetchPilgrimStats(): Promise<UmrahData> {
try {
const tables = await discoverTableIds();