// Shared React components: Header, Footer, Breadcrumbs, Badges, Icons const { useState, useEffect, useRef, useMemo, Fragment } = React; // Случайный класс цвета из палитры employees (a0…a6). Фиксируется на сессию, // чтобы при переходе между страницами аватар не «мигал» разными цветами. function getMeAvatarClass() { try { const key = 'pe-me-avatar-idx'; let idx = sessionStorage.getItem(key); if (idx === null) { idx = String(Math.floor(Math.random() * 7)); sessionStorage.setItem(key, idx); } return 'a' + idx; } catch (e) { return 'a' + Math.floor(Math.random() * 7); } } // ===== ICONS ===== const Icon = { Search: (p) => , Menu: (p) => , Heart: (p) => , HeartFill: (p) => , BarChart: (p) => , Cart: (p) => , Bell: (p) => , Copy: (p) => , Minus: (p) => , Plus: (p) => , Star: (p) => , ChevronDown: (p) => , ChevronLeft: (p) => , ChevronRight: (p) => , Close: (p) => , Grid: (p) => , List: (p) => , Table: (p) => , Share: (p) => , Truck: (p) => , Card: (p) => , Shield: (p) => , Box: (p) => , Info: (p) => , Check: (p) => , Phone: (p) => , Mail: (p) => , Clock: (p) => , }; // ===== LOGO (real Figma SVG) ===== function Logo() { return ( Печной Эксперт ); } // ===== useIsMobile hook ===== // Реактивно отслеживает viewport ≤ 767 px. Используется в Header / Footer, // чтобы автоматически делегировать рендер на window.MobileHeader / // window.MobileFooter, если они зарегистрированы (mobile.jsx подключён). function useIsMobile() { const [m, setM] = React.useState(() => { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia('(max-width: 767px)').matches; }); React.useEffect(() => { if (!window.matchMedia) return; const mq = window.matchMedia('(max-width: 767px)'); const handler = (e) => setM(e.matches); if (mq.addEventListener) mq.addEventListener('change', handler); else if (mq.addListener) mq.addListener(handler); return () => { if (mq.removeEventListener) mq.removeEventListener('change', handler); else if (mq.removeListener) mq.removeListener(handler); }; }, []); return m; } // ===== HEADER (auto-responsive wrapper) ===== function Header(props) { const isMobile = useIsMobile(); if (isMobile && window.MobileHeader) { return React.createElement(window.MobileHeader, props); } return React.createElement(HeaderDesktop, props); } function HeaderDesktop({ cartCount = 3, favCount = 7, compareCount = 2 }) { const [compact, setCompact] = React.useState(false); const [query, setQuery] = React.useState(''); const searchInputRef = React.useRef(null); const compactRef = React.useRef(false); const tickingRef = React.useRef(false); React.useEffect(() => { // Hysteresis thresholds: enter compact at 80px, exit at 40px. // The gap prevents flicker when header shrink reduces page height // and bounces scrollY back across a single threshold. const ENTER = 80; const EXIT = 40; const update = () => { tickingRef.current = false; const y = window.scrollY; const now = compactRef.current; let next = now; if (!now && y > ENTER) next = true; else if (now && y < EXIT) next = false; if (next !== now) { compactRef.current = next; setCompact(next); } }; const onScroll = () => { if (tickingRef.current) return; tickingRef.current = true; window.requestAnimationFrame(update); }; window.addEventListener('scroll', onScroll, { passive: true }); update(); return () => window.removeEventListener('scroll', onScroll); }, []); return (
Екатеринбург
setQuery(e.target.value)} placeholder="Поиск товара по названию или артикулу" /> {query && ( )}
); } // ===== BREADCRUMBS ===== function Breadcrumbs({ items }) { return (
{items.map((it, i) => ( {i > 0 && /} {it.href ? {it.label} : {it.label}} ))}
); } // ===== FOOTER (auto-responsive wrapper) ===== function Footer(props) { const isMobile = useIsMobile(); if (isMobile && window.MobileFooter) { return React.createElement(window.MobileFooter, props); } return React.createElement(FooterDesktop, props); } function FooterDesktop() { return ( ); } // ===== PRODUCT PLACEHOLDER (bag icon — stands in for real product photo) ===== function ProductPlaceholder({ variant = 0, label = '' }) { return (
{label
); } // ===== ACCOUNT SUBNAV ===== // Навигация по разделам личного кабинета. Отображается на всех страницах ЛК. // Реализована как горизонтальная полоса из 5 кнопок-дропдаунов // (b2b-паттерн: компактно, привычно). Каждая кнопка — название группы; // по клику разворачивается меню со ссылками раздела. Кнопка группы, // в которой лежит активный раздел, подсвечивается белой капсулой; // внутри меню активный пункт тоже выделен. // active — id текущего раздела: // cart | orders | frequent | compare | // reports | act | upd | // addr-delivery | tk | addr-stores | // profile | orgs | staff | // notify | claims function SubNav({ active }) { const groups = [ { id: 'product', title: 'Продукт', items: [ { id: 'cart', label: 'Корзина', href: 'cart.html' }, { id: 'orders', label: 'Заказы', href: 'orders.html' }, { id: 'frequent', label: 'Частые покупки', href: 'frequent.html' }, { id: 'compare', label: 'Сравнение', href: 'compare.html' }, ]}, { id: 'finance', title: 'Финансы', items: [ { id: 'reports', label: 'Отчёты', href: 'reports.html' }, { id: 'act', label: 'Акт сверки', href: 'act.html' }, { id: 'upd', label: 'УПД', href: 'upd.html' }, ]}, { id: 'logistics', title: 'Логистика', items: [ { id: 'addr-delivery', label: 'Адреса доставки', href: '#' }, { id: 'tk', label: 'Транспортные компании', href: '#' }, { id: 'addr-stores', label: 'Адреса магазинов', href: '#' }, ]}, { id: 'legal', title: 'Юридическое взаимодействие', items: [ { id: 'profile', label: 'Профиль', href: '#' }, { id: 'orgs', label: 'Организации', href: 'organizations.html' }, { id: 'staff', label: 'Сотрудники', href: 'employees.html' }, ]}, { id: 'misc', title: 'Прочее', items: [ { id: 'notify', label: 'Уведомления', href: '#' }, { id: 'claims', label: 'Рекламации', href: 'claims.html' }, ]}, ]; const [openId, setOpenId] = React.useState(null); const rootRef = React.useRef(null); const isMobile = typeof useIsMobile === 'function' ? useIsMobile() : false; React.useEffect(() => { if (!openId) return; const onDocClick = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) setOpenId(null); }; const onKey = (e) => { if (e.key === 'Escape') setOpenId(null); }; document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); }; }, [openId]); // На мобиле .account-subnav горизонтально скроллится (overflow-x:auto), // из-за чего абсолютно-позиционированная выпадашка внутри группы // обрезается по вертикали (overflow:auto клипает оба направления). // Поэтому на мобиле меню открытой группы рендерим соседом — отдельной // плашкой ниже строки триггеров, вне скролл-контейнера. const openGroup = groups.find(g => g.id === openId) || null; return (
{openGroup && isMobile && (
{openGroup.items.map(it => ( {it.label} ))}
)}
); } Object.assign(window, { Icon, Logo, Header, HeaderDesktop, Breadcrumbs, Footer, FooterDesktop, ProductPlaceholder, SubNav, useIsMobile });