// Editorial Luxury — shared components const { useState, useEffect, useRef } = React; const D = window.EH_DATA; const API = window.EH_API; // Logo const EditorialLogo = ({ light }) => ( Estetic Hall ESTETIC HALLбьюти-коворкинг ); // Header used on landing only const EditorialHeader = ({ dark }) => { const [scrolled, setScrolled] = useState(false); const [user, setUser] = useState(null); const [menuOpen, setMenuOpen] = useState(false); useEffect(() => { const f = () => setScrolled(window.scrollY > 60); window.addEventListener('scroll', f); return () => window.removeEventListener('scroll', f); }, []); useEffect(() => { let active = true; API.me().then(r => { if (active) setUser(r.user); }).catch(() => {}); return () => { active = false; }; }, []); useEffect(() => { const f = () => setMenuOpen(false); window.addEventListener('hashchange', f); return () => window.removeEventListener('hashchange', f); }, []); const cls = `eh-header ${dark ? 'dark' : ''} ${scrolled ? 'scrolled' : ''} ${menuOpen ? 'menu-open' : ''}`; const close = () => setMenuOpen(false); return (
{D.brand.phone && {D.brand.phone}} MAX {user ? ( {user.name.trim().charAt(0)} ) : ( Войти )} Онлайн запись
{menuOpen && (
Аренда и цены Аренда для мероприятий Галерея Отзывы Контакты {D.brand.phone && {D.brand.phone}} {user ? Личный кабинет : Войти} Онлайн запись →
)}
); }; // Footer const EditorialFooter = () => ( ); // Auth photo column (shared for login + register) const AuthPhoto = ({ quote, by, bg }) => (
Estetic Hall ESTETIC HALLбьюти-коворкинг
«{quote}»
— {by}
); // Room detail drawer (used by landing) — real schedule from API const isoLocal = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; const DOW = ['ВС','ПН','ВТ','СР','ЧТ','ПТ','СБ']; const MON = ['янв','фев','мар','апр','мая','июн','июл','авг','сен','окт','ноя','дек']; // Зоны, на которые можно тратить часы абонемента (синхронно с бэкендом eh_abonement_zones) const EH_ABONEMENT_ZONES = ['hair', 'nails', 'makeup', 'pedicure', 'barber']; // «5 июл» из ISO-даты const fmtRuD = (iso) => { const p = String(iso || '').split('-'); if (p.length !== 3) return iso; return `${parseInt(p[2], 10)} ${MON[parseInt(p[1], 10) - 1]}`; }; const RoomDrawer = ({ room, onClose, onBook }) => { const [photoIdx, setPhotoIdx] = useState(0); const [mode, setMode] = useState('hour'); // 'hour' — по часам | 'day' — весь день | 'month' — 30 дней const [selectedDay, setSelectedDay] = useState(0); const [selectedSlots, setSelectedSlots] = useState([]); const [busy, setBusy] = useState([]); const [loadingSlots, setLoadingSlots] = useState(true); const [quote, setQuote] = useState(null); // { price, originalPrice, discountPct, discountLabel } const [sub, setSub] = useState({ hours: 0, days: 0 }); // остатки абонементов (часы и дни) const [useAbonement, setUseAbonement] = useState(false); const [longDate, setLongDate] = useState(isoLocal(new Date())); // дата для режимов «день»/«месяц» const [longBusy, setLongBusy] = useState(null); // null = проверяем; { busy: [], busyDates: [] } const today = new Date(); today.setHours(0,0,0,0); const days = Array.from({length: 7}, (_, i) => { const d = new Date(today); d.setDate(today.getDate() + i); return d; }); const openH = parseInt(String(D.brand.open || '09:00').slice(0, 2), 10); const closeH = parseInt(String(D.brand.close || '21:00').slice(0, 2), 10); const hours = Array.from({length: Math.max(0, closeH - openH)}, (_, i) => openH + i); const photos = room ? room.photos.map(ehPhoto) : []; const maxLongDate = isoLocal(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)); // бронь до +90 дней // Подтягиваем остатки абонементов (тихо; если не авторизован — нули) useEffect(() => { if (!room) return; let active = true; setUseAbonement(false); API.mySubscription() .then(r => { if (active) setSub({ hours: (r.balanceHours != null ? r.balanceHours : r.balance) || 0, days: r.balanceDays || 0, }); }) .catch(() => { if (active) setSub({ hours: 0, days: 0 }); }); return () => { active = false; }; }, [room && room.id]); // Load real availability for the selected day useEffect(() => { if (!room) return; let active = true; setSelectedSlots([]); setLoadingSlots(true); API.availability(room.id, isoLocal(days[selectedDay])) .then(r => { if (active) setBusy(r.busy || []); }) .catch(() => { if (active) setBusy([]); }) .then(() => { if (active) setLoadingSlots(false); }); return () => { active = false; }; }, [room && room.id, selectedDay]); // Занятость для режимов «день» (1 дата) и «месяц» (30 дат подряд) useEffect(() => { if (!room || mode === 'hour') return; let active = true; setLongBusy(null); API.availability(room.id, longDate, mode === 'month' ? 30 : 1) .then(r => { if (active) setLongBusy({ busy: r.busy || [], busyDates: r.busyDates || [] }); }) .catch(() => { if (active) setLongBusy({ busy: [], busyDates: [] }); }); return () => { active = false; }; }, [room && room.id, mode, longDate]); // При смене режима сбрасываем расчёты useEffect(() => { setUseAbonement(false); setQuote(null); }, [mode]); // Каждый раз когда меняется выбор (часы или режим/дата) — запрашиваем у сервера итог со скидкой useEffect(() => { if (!room) return; if (mode === 'hour' && !selectedSlots.length) { setQuote(null); return; } let active = true; const t = setTimeout(() => { const body = mode === 'hour' ? { roomId: room.id, slots: selectedSlots } : { roomId: room.id, kind: mode }; API.quote(body) .then(q => { if (active) setQuote(q); }) .catch(() => { if (active) setQuote(null); }); }, 150); return () => { active = false; clearTimeout(t); }; }, [room && room.id, selectedSlots, mode]); if (!room) return null; const next = () => setPhotoIdx(i => (i + 1) % photos.length); const prev = () => setPhotoIdx(i => (i - 1 + photos.length) % photos.length); const now = new Date(); const isToday = isoLocal(days[selectedDay]) === isoLocal(now); const disabled = (h) => busy.includes(h) || (isToday && h <= now.getHours()); const toggleSlot = (h) => { if (disabled(h)) return; setSelectedSlots(s => s.includes(h) ? s.filter(x => x !== h) : [...s, h].sort((a,b) => a-b)); }; const total = quote ? quote.price : (mode === 'hour' ? selectedSlots.length * room.prices.hour : mode === 'day' ? room.prices.day : room.prices.month); const originalTotal = quote ? quote.originalPrice : total; const hasDiscount = quote && quote.discountPct > 0; const sel = days[selectedDay]; const dateLabel = `${sel.getDate()} ${MON[sel.getMonth()]}`; // Статус доступности в режимах «день»/«месяц» const dayFree = longBusy !== null && longBusy.busy.length === 0; const monthFree = longBusy !== null && longBusy.busyDates.length === 0; const monthEnd = isoLocal(new Date(new Date(longDate + 'T00:00:00').getTime() + 29 * 24 * 60 * 60 * 1000)); const st = room.status === 'free' ? {c:'var(--green)', t:'Свободен сегодня'} : room.status === 'partial' ? {c:'#d9a23c', t:'Частично занят'} : {c:'var(--red)', t:'Занят сегодня'}; return ( <>
); }; // Reveal-on-scroll wrapper const Reveal = ({ children, delay = 0, as: As = 'div', ...rest }) => { const ref = useRef(null); const [vis, setVis] = useState(false); useEffect(() => { const o = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVis(true); o.disconnect(); } }, { threshold: 0.12 }); if (ref.current) o.observe(ref.current); return () => o.disconnect(); }, []); return {children}; }; // ============================================ // ОПЛАТА ПЕРЕВОДОМ ПО QR (без эквайринга) // ============================================ // Платёжная строка по ГОСТ Р 56042-2014 (формат ST00012, кодировка UTF-8). // Такой QR сканируется в приложениях Сбербанка и большинства банков РФ — // реквизиты, сумма и назначение подставляются автоматически. function ehPaymentQRPayload(req, amount, purpose) { const parts = [ 'ST00012', 'Name=' + (req.name || ''), 'PersonalAcc=' + (req.account || ''), 'BankName=' + (req.bank || ''), 'BIC=' + (req.bic || ''), 'CorrespAcc=' + (req.corr || ''), ]; if (req.inn) parts.push('PayeeINN=' + req.inn); if (amount > 0) parts.push('Sum=' + Math.round(amount * 100)); // сумма в копейках if (purpose) parts.push('Purpose=' + purpose); return parts.join('|'); } // SVG-разметка QR-кода (пусто, если библиотека не загрузилась — покажем только реквизиты) function ehPaymentQRSvg(req, amount, purpose) { try { if (typeof qrcode === 'undefined') return ''; if (qrcode.stringToBytesFuncs && qrcode.stringToBytesFuncs['UTF-8']) { qrcode.stringToBytes = qrcode.stringToBytesFuncs['UTF-8']; } const qr = qrcode(0, 'M'); qr.addData(ehPaymentQRPayload(req, amount, purpose), 'Byte'); qr.make(); return qr.createSvgTag({ cellSize: 4, margin: 0, scalable: true }); } catch (e) { return ''; } } // Модалка «Оплатите переводом»: QR + реквизиты + пояснение про подтверждение админом. // payment: { amount, purpose, requisites } (requisites может не быть — возьмём из настроек) const PaymentModal = ({ payment, onClose, ctaHref, ctaLabel }) => { const [copied, setCopied] = useState(null); const p = payment || {}; const req = p.requisites && p.requisites.account ? p.requisites : (D.pay || {}); const amount = p.amount || 0; const purpose = p.purpose || ''; const svg = React.useMemo(() => ehPaymentQRSvg(req, amount, purpose), [req.account, amount, purpose]); if (!payment) return null; const copy = async (label, value) => { try { await navigator.clipboard.writeText(String(value)); } catch (e) { try { const ta = document.createElement('textarea'); ta.value = String(value); document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } catch (e2) { /* не вышло — покажем без галочки */ } } setCopied(label); setTimeout(() => setCopied(c => (c === label ? null : c)), 2000); }; const Row = ({ label, value, mono }) => (
{label} {value}
); return ( <>
Оплата переводом по QR
{amount.toLocaleString('ru-RU')}
{purpose}
{svg ? (
Откройте приложение банка → «Оплата по QR» и отсканируйте код. Сумма и реквизиты подставятся сами.
) : (
Переведите по реквизитам справа — сумма и назначение указаны выше.
)}
{req.inn ? : null} {req.corr ? : null}
Что дальше? После перевода администратор проверит поступление и подтвердит оплату — до этого момента статус будет «Ожидается подтверждение оплаты». Обычно проверка занимает немного времени в рабочие часы; место уже удержано за вами.
{ctaLabel || 'Я перевёл(а) — в кабинет →'}
); }; // Плавающая кнопка «Написать в MAX» — правый нижний угол, круглая, без подписи const MaxFab = () => { const url = (D.brand && D.brand.maxDm) || 'https://max.ru/+79204770302'; return ( ); }; // Expose globally Object.assign(window, { EditorialLogo, EditorialHeader, EditorialFooter, AuthPhoto, RoomDrawer, Reveal, MaxFab, PaymentModal, ehPaymentQRPayload });