// app.jsx — Production SPA mount.
// Differences from designer's preview:
// - Role-bar (Лендинг/Кабинет/Заказ/Админ) скрыт (см. spa.html CSS).
// - Tweaks-панель не рендерится.
// - Текущая роль определяется URL: / → landing, /account → cabinet,
// /order/new → wizard, /admin → admin (вместо useState('landing')).
// - window.__nav делает реальный history.pushState.
const DEFAULT_TWEAKS = {
theme: 'obsidian',
displayFont: 'Instrument Serif',
accent: ['#d4a574', '#c47a8a', '#7a8fc4', '#8aa896'],
chartGlow: true,
noiseEnabled: true,
};
const PATH_TO_ROLE = (path) => {
if (path.startsWith('/order/new')) return 'wizard';
if (path.startsWith('/account')) return 'cabinet';
if (path.startsWith('/admin')) return 'admin';
if (path.startsWith('/matrica')) return 'matrica';
if (path === '/' || path === '' || path === '/index.html') return 'landing';
// Любой неизвестный путь обслуживается тем же spa.html (сервер отдаёт его
// со статусом 404 для навигации браузера) → рендерим интегрированную 404.
return 'notfound';
};
const ROLE_TO_PATH = (role) => ({
landing: '/',
cabinet: '/account',
wizard: '/order/new',
admin: '/admin',
matrica: '/matrica',
}[role] || '/');
// ——— Каталог продуктов: цены/названия тянутся с бэка (/api/catalog),
// чтобы не хардкодить их в компонентах. Заполняется в App, читается везде. ———
window.__catalog = window.__catalog || {};
const catalogPrice = (key, fallback) => {
const c = window.__catalog[key];
return c && typeof c.price_rub === 'number' ? c.price_rub : fallback;
};
const catalogTitle = (key, fallback) => {
const c = window.__catalog[key];
return c && c.title ? c.title : fallback;
};
const rubFmt = (n) => new Intl.NumberFormat('ru-RU').format(n) + ' ₽';
// Настройки сайта (реквизиты/контакты) — тянем с бэка, не хардкодим в компонентах.
window.__settings = window.__settings || {};
const setting = (key, fb) => {
const v = window.__settings[key];
return (v !== undefined && v !== '') ? v : (fb || '');
};
const ApplyTweaks = ({ t }) => {
React.useEffect(() => {
const root = document.documentElement;
root.dataset.theme = t.theme;
const a = Array.isArray(t.accent) ? t.accent : DEFAULT_TWEAKS.accent;
if (t.theme === 'obsidian') {
root.style.setProperty('--gold', a[0]);
root.style.setProperty('--rose', a[1]);
root.style.setProperty('--celest', a[2]);
root.style.setProperty('--sage', a[3]);
} else {
root.style.removeProperty('--gold');
root.style.removeProperty('--rose');
root.style.removeProperty('--celest');
root.style.removeProperty('--sage');
}
root.style.setProperty('--noise-opacity', t.noiseEnabled ? (t.theme === 'aurum' ? '0.04' : '0.06') : '0');
root.style.setProperty('--font-display', `'${t.displayFont}', serif`);
}, [t]);
return null;
};
const App = () => {
const [role, setRole] = React.useState(PATH_TO_ROLE(window.location.pathname));
const [cabSub, setCabSub] = React.useState('home');
const [admSub, setAdmSub] = React.useState('dashboard');
// Один раз тянем каталог цен с бэка; setCatTick форсит ре-рендер дерева,
// чтобы компоненты перечитали window.__catalog (с фолбэками до загрузки).
const [, setCatTick] = React.useState(0);
React.useEffect(() => {
fetch('/api/catalog', { credentials: 'same-origin' })
.then(r => r.json())
.then(d => { (d.services || []).forEach(s => { window.__catalog[s.id] = s; }); setCatTick(x => x + 1); })
.catch(() => {});
fetch('/api/settings', { credentials: 'same-origin' })
.then(r => r.json())
.then(d => { window.__settings = d || {}; setCatTick(x => x + 1); })
.catch(() => {});
}, []);
// popstate (back/forward buttons) syncs role
React.useEffect(() => {
const onPop = () => {
setRole(PATH_TO_ROLE(window.location.pathname));
window.scrollTo({ top: 0, behavior: 'auto' });
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
// Expose nav for buttons inside screens.
// Landing — public, используем pushState (быстро, без перезагрузки).
// Cabinet / Wizard / Admin — gated на сервере; делаем real navigation,
// чтобы middleware успел редиректнуть гостя на /login?next=…
React.useEffect(() => {
window.__nav = (to) => {
const path = ROLE_TO_PATH(to);
if (to === 'landing') {
if (window.location.pathname !== path) {
window.history.pushState({}, '', path);
}
setRole(to);
window.scrollTo({ top: 0, behavior: 'auto' });
return;
}
// Gated routes — пусть Go-роутер сам решит, пускать или редиректить
window.location.href = path;
};
// Logout helper used in cabinet/admin headers
window.__logout = async () => {
try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); } catch (_) {}
window.location.href = '/login';
};
}, []);
React.useEffect(() => {
// Honor an anchor hash on the public landing (e.g. arriving at /#catalog
// from the matrix page navbar). Sections mount after in-browser Babel
// boots, so poll a few frames for the target before giving up.
if (role === 'landing' && window.location.hash.length > 1) {
const id = decodeURIComponent(window.location.hash.slice(1));
let frames = 0;
const seek = () => {
const el = document.getElementById(id);
if (el) { el.scrollIntoView({ behavior: 'auto', block: 'start' }); return; }
if (frames++ < 60) requestAnimationFrame(seek);
};
requestAnimationFrame(seek);
return;
}
window.scrollTo({ top: 0, behavior: 'auto' });
}, [role, cabSub, admSub]);
return (
<>
{role === 'landing' && }
{role === 'matrica' && }
{role === 'cabinet' && }
{role === 'wizard' && }
{role === 'admin' && }
{role === 'notfound' && }
>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render();