// 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();