// 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: 'Source Serif 4', accent: ['#c9a961', '#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.startsWith('/natalnaya-karta')) return 'natal'; 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', natal: '/natalnaya-karta', }[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; }; // Наборы: несколько разборов одной покупкой. Приходят тем же /api/catalog — // цену набора, как и цену продукта, считает сервер. window.__bundles = window.__bundles || []; const catalogBundles = () => window.__bundles || []; // Есть ли у продукта витрина. Ссылку «пример» рисуем только там, где пример // реально назначен: обещать пример, которого нет, — обещать пустую страницу. const catalogHasExample = (key) => !!(window.__catalog[key] && window.__catalog[key].has_example); // Скидка по продукту. Считает её бэк (pricing.Quoter) — здесь только показ. // strikethrough приходит оттуда же: постоянную «цену для подписчиков» // зачёркивать нельзя, старая цена для него никогда не действовала. const catalogDiscount = (key) => { const c = window.__catalog[key]; if (!c || !c.discount_percent) return null; return { percent: c.discount_percent, label: c.discount_label || '', reason: c.discount_reason || '', base: c.base_price_rub, strikethrough: !!c.strikethrough, expiresAt: c.discount_expires_at || null, }; }; // Лучшая живая скидка — для баннера в кабинете. window.__discount = window.__discount || null; // Подписка на ежедневный прогноз: цена и длина триала. Отдельно от services — // это не разовый разбор, и скидки на неё не действуют. window.__subscription = window.__subscription || null; const subscriptionOffer = () => window.__subscription || { price_rub: 299, trial_days: 7 }; const rubFmt = (n) => new Intl.NumberFormat('ru-RU').format(n) + ' ₽'; // Цена продукта для ПОКАЗА — в валюте витрины. У USD-каталога price_rub всё // ещё приходит (это рублёвая цена того же продукта), и показать её иностранцу // с знаком ₽ значит наврать про цену. null = за USD не продаётся: показывать // такой продукт с ценой нельзя. const catalogPriceText = (key, fallbackRub) => { const c = window.__catalog[key]; if (c && c.currency === 'USD') { if (!c.price_minor) return null; return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(c.price_minor / 100); } return rubFmt(catalogPrice(key, fallbackRub)); }; // Зачёркнутая базовая цена — в валюте витрины. const catalogBasePriceText = (key) => { const c = window.__catalog[key]; if (!c) return ''; if (c.currency === 'USD') { return c.base_price_minor ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(c.base_price_minor / 100) : ''; } return rubFmt(c.base_price_rub); }; // Можно ли зачёркивать базовую: сравнение идёт в валюте витрины, а не в рублях. const catalogHasStrikethrough = (key) => { const c = window.__catalog[key]; const d = catalogDiscount(key); if (!c || !d || !d.strikethrough) return false; if (c.currency === 'USD') return (c.base_price_minor || 0) > (c.price_minor || 0); return d.base > catalogPrice(key, 0); }; // Сумма заказа в его валюте: у USD-заказов amount_rub = 0. const moneyText = (currency, minor, fallbackRUB) => { if (currency && currency !== 'RUB') { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(Number(minor) / 100); } return rubFmt(fallbackRUB != null ? fallbackRUB : Number(minor) / 100); }; // Настройки сайта (реквизиты/контакты) — тянем с бэка, не хардкодим в компонентах. 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'); // Инлайновое свойство на перебивает токен из CSS, поэтому смена // заголовочной гарнитуры правкой одного app.css ничего не давала: страница // возвращалась к тому, что стоит в DEFAULT_TWEAKS. Фолбэки держим теми же, // что в токене, иначе при недоступных Google Fonts заголовки уезжают в // системный serif, а не в Georgia. root.style.setProperty('--font-display', `'${t.displayFont}', Georgia, 'Times New Roman', serif`); }, [t]); return null; }; const App = () => { const [role, setRole] = React.useState(PATH_TO_ROLE(window.location.pathname)); // Вход в воронку. Роль уже вычислена из адреса — разбирать его второй раз // ради метрики значило бы завести вторую правду о том, где человек стоит. // Кабинет, админку и 404 не считаем: это не вход, а возвращение своих. React.useEffect(() => { const t = window.astro?.track; if (!t) return; if (role === 'landing') t('landing_view'); else if (role === 'matrica') t('tool_view', 'matrix'); else if (role === 'natal') t('tool_view', 'natal'); }, [role]); // ?ticket= приходит из письма «мы ответили»: без этого ссылка приводила // на «Сегодня», и переписку человек искал руками. const [cabSub, setCabSub] = React.useState( () => (new URLSearchParams(location.search).get('ticket') ? 'support' : 'home')); // Раздел админки берём из адреса: /admin/orders/{id} приходит из алертов, и // показывать по такой ссылке дашборд — значит заставлять искать заказ руками. const [admSub, setAdmSub] = React.useState(() => { const m = /^\/admin\/([a-z]+)/.exec(location.pathname); return m ? m[1] : '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; }); window.__bundles = d.bundles || []; window.__discount = d.discount || null; window.__subscription = d.subscription || null; 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 === 'natal' && } {role === 'cabinet' && } {role === 'wizard' && } {role === 'admin' && } {role === 'notfound' && } ); }; const root = ReactDOM.createRoot(document.getElementById('root')); root.render();