// free-calc.jsx — бесплатные расчёты: вход во freemium-воронку. // // Две карточки: натальная карта (дата, время, место) и матрица судьбы (одна // дата). Обе живут здесь, а не внутри своих страниц: один и тот же расчёт // стоит и на главной, и на посадочной странице продукта, а своя копия // разошлась бы с продуктом при первой правке. // // Расчёт идёт по настоящим эфемеридам на бэкенде (/api/free/chart), а не по // таблице знаков в браузере: у бесплатного расчёта и у платного разбора должны // совпадать не только цифры, но и источник цифр. // // Переиспользует CardStars / rubFmt / catalogPrice из landing.jsx и PlaceField // из ui-select.jsx — общая babel-область, порядок загрузки в spa.html. const fcMaskTime = (s) => { const d = (s || '').replace(/\D/g, '').slice(0, 4); return d.length <= 2 ? d : d.slice(0, 2) + ':' + d.slice(2); }; const fcInputStyle = { padding: '14px 16px', borderRadius: '10px', border: '1px solid var(--line)', background: 'rgba(255,255,255,0.03)', color: 'var(--ink-primary)', fontSize: '17px', textAlign: 'center', letterSpacing: '0.04em', width: '100%', }; // FreeChartCard — бесплатный расчёт натальной карты. // // У матрицы хватало одной даты, здесь их три поля, и это не придирка к форме: // без времени и места нет ни Асцендента, ни домов, а Луна за сутки уходит на // тринадцать градусов и может оказаться в соседнем знаке. Поэтому время и // место — не «дополнительно», а то, ради чего расчёт вообще персональный; // без них честно показываем только Солнце и Луну и говорим об этом прямо. const FreeChartCard = () => { // calcKind — что именно человек считает. Уточнение шага в воронке: без него // «запустил расчёт» не отличает натальную карту от матрицы, а это разные // страницы и разные причины ухода. const calcKind = 'chart'; const [dob, setDob] = React.useState(''); const [time, setTime] = React.useState(''); const [place, setPlace] = React.useState(''); const [picked, setPicked] = React.useState(null); const [result, setResult] = React.useState(null); const [calculating, setCalculating] = React.useState(false); const [err, setErr] = React.useState(null); const [email, setEmail] = React.useState(''); const [leadConsent, setLeadConsent] = React.useState(false); const [leadSent, setLeadSent] = React.useState(false); const isoDob = () => { const m = (dob || '').match(/^(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})$/); return m ? `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}` : ''; }; const calc = async () => { const iso = isoDob(); if (!iso) { // Отказ на валидации — тоже отсев, и его причина отличается от «расчёт // упал»: чинится он текстом подсказки, а не кодом. window.astro?.track?.('calc_failed', 'validation:date'); setErr('Введи дату в формате ДД.ММ.ГГГГ'); return; } setErr(null); setCalculating(true); window.astro?.track?.('calc_started', calcKind); try { const r = await fetch('/api/free/chart', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ born_date: iso, born_time: time, place, // Выбранный из списка город идёт координатами: одноимённые города // различает только выбор, и «Ростов» иначе уезжает не в тот Ростов. // Пояс идёт вместе с ними: без него бэкенд читал время рождения как // UTC, и Асцендент расходился с платным расчётом на весь сдвиг пояса. ...(picked ? { lat: picked.lat, lng: picked.lng, tz: picked.tz_name || '' } : {}), }), }); const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Не удалось рассчитать'); setResult(d); window.astro?.track?.('calc_done', calcKind); } catch (e) { window.astro?.track?.('calc_failed', (e && e.message) || 'unknown'); setErr(e.message || 'Ошибка'); } finally { setCalculating(false); } }; const sendLead = async () => { if (!email.includes('@')) { setErr('Укажи корректный email'); return; } if (!leadConsent) { setErr('Подтверди согласие с условиями'); return; } setErr(null); try { await fetch('/api/free/lead', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.trim(), kind: 'chart', born_date: isoDob(), born_time: time, place, }), }); } catch (_) { /* best-effort: лид важен нам, а не человеку */ } window.astro?.track?.('email_given', calcKind); setLeadSent(true); }; const goOrder = () => { window.location.href = '/order/new?product=natal'; }; // Что показываем в результате. Асцендент появляется только когда есть и // время, и место: без них он не «примерный», а никакой. const placements = result ? [ { label: 'Солнце', glyph: '☉', v: result.sun }, { label: 'Луна', glyph: '☽', v: result.moon }, { label: 'Асцендент', glyph: '↑', v: result.ascendant }, ].filter(p => p.v) : []; return (
{!result ? (
Бесплатный расчёт карты
setDob(mxMaskDate(e.target.value))} placeholder="ДД.ММ.ГГГГ" inputMode="numeric" style={fcInputStyle} /> setTime(fcMaskTime(e.target.value))} placeholder="ЧЧ:ММ — время рождения" inputMode="numeric" style={{ ...fcInputStyle, fontSize: '15px' }} />
{ setPlace(s.name); setPicked(s); }} />
{err &&
{err}
}
Без регистрации · результат сразу на экране
Не знаешь время — оставь пустым: посчитаем Солнце и Луну
) : (
Твоя карта
{result.sun ? result.sun.sign : '—'}
Солнце · знак
{placements.map(p => (
{p.glyph} {p.v.sign}
{p.label}
))}
{!result.ascendant && ( // Не прячем недостающее: человек должен понимать, почему у него // две строки, а у подруги три, и что это чинится его же данными.
Асцендент считается от минуты и места рождения — укажи их, и он появится.
)}

{result.teaser}

{!leadSent && ( <>

Прислать разбор этой карты на почту — что означают эти позиции вместе?

setEmail(e.target.value)} placeholder="email — пришлём детали" style={{ ...fcInputStyle, fontSize: '15px', letterSpacing: 0 }} /> {err &&
{err}
} )} {leadSent &&
Готово — детали придут на email ✓
}
)}
); }; const mxMaskDate = (s) => { const d = (s || '').replace(/\D/g, '').slice(0, 8); const p = [d.slice(0, 2)]; if (d.length >= 3) p.push(d.slice(2, 4)); if (d.length >= 5) p.push(d.slice(4, 8)); return p.filter(Boolean).join('.'); }; // FreeCalcCard — сам бесплатный расчёт: дата → главный аркан → email → CTA. // // Отдельным компонентом, а не куском страницы «Матрица судьбы»: это вход во // всю freemium-воронку, и он должен стоять там, где на него смотрят. Пока // карточка жила внутри /matrica, на которую не вело ни одной ссылки, верх // воронки существовал только для тех, кто знает адрес наизусть. const FreeCalcCard = () => { const calcKind = 'matrix'; const [dob, setDob] = React.useState(''); const [result, setResult] = React.useState(null); const [calculating, setCalculating] = React.useState(false); const [err, setErr] = React.useState(null); const [email, setEmail] = React.useState(''); const [leadConsent, setLeadConsent] = React.useState(false); const [leadSent, setLeadSent] = React.useState(false); const isoDob = () => { const m = (dob || '').match(/^(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})$/); return m ? `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}` : ''; }; const calc = async () => { const iso = isoDob(); if (!iso) { // Отказ на валидации — тоже отсев, и его причина отличается от «расчёт // упал»: чинится он текстом подсказки, а не кодом. window.astro?.track?.('calc_failed', 'validation:date'); setErr('Введи дату в формате ДД.ММ.ГГГГ'); return; } setErr(null); setCalculating(true); window.astro?.track?.('calc_started', calcKind); try { const r = await fetch('/api/free/matrix', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ born_date: iso }), }); const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Не удалось рассчитать'); setResult(d); window.astro?.track?.('calc_done', calcKind); } catch (e) { window.astro?.track?.('calc_failed', (e && e.message) || 'unknown'); setErr(e.message || 'Ошибка'); } finally { setCalculating(false); } }; const sendLead = async () => { if (!email.includes('@')) { setErr('Укажи корректный email'); return; } if (!leadConsent) { setErr('Подтверди согласие с условиями'); return; } setErr(null); try { await fetch('/api/free/lead', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.trim(), kind: 'matrix', born_date: isoDob() }), }); } catch (_) { /* best-effort */ } window.astro?.track?.('email_given', calcKind); setLeadSent(true); }; const goOrder = () => { window.location.href = '/order/new?product=matrix'; }; const inputStyle = { padding: '14px 16px', borderRadius: '10px', border: '1px solid var(--line)', background: 'rgba(255,255,255,0.03)', color: 'var(--ink-primary)', fontSize: '17px', textAlign: 'center', letterSpacing: '0.04em', width: '100%', }; // Карточка free-calc: либо форма, либо результат. return (
{!result ? (
Бесплатный расчёт
setDob(mxMaskDate(e.target.value))} placeholder="ДД.ММ.ГГГГ" inputMode="numeric" style={inputStyle} /> {err &&
{err}
}
Без регистрации · результат сразу на экране
) : (
Твой главный аркан
{result.center?.arcanum}
{result.center?.name}

{result.teaser}

{/* Закрытые точки остаются на своих местах и подписаны: человек должен видеть, ЧЕГО ему не показали, иначе закрытая часть не читается как ценность. Аркан приходит с сервера только у открытых — вёрстка здесь ничего не прячет и прятать не может. */}
{(result.points || []).map(p => (
{p.locked ? '·' : p.arcanum}
{p.code}
))}
{!leadSent && ( <>

Открыть остальные {result.locked || 8} точки — предназначение, деньги, отношения, карма?

setEmail(e.target.value)} placeholder="email — пришлём детали" style={{ ...inputStyle, fontSize: '15px', letterSpacing: 0 }} /> )} {leadSent &&
Готово — детали придут на email ✓
}
)}
); }; // FreeCalcTabs — два бесплатных расчёта в одном блоке. // // Вкладками, а не двумя карточками рядом: рядом они заставляют выбирать до // того, как человек понял разницу, и половина уходит, не посчитав ничего. // Натальная карта первой — это основной продукт, а матрица дешевле входит // (нужна одна дата) и подхватывает тех, кто не знает времени рождения. const FREE_CALC_TABS = [ { id: 'natal', label: 'Натальная карта', hint: 'дата, время и место рождения' }, { id: 'matrix', label: 'Матрица судьбы', hint: 'нужна только дата' }, ]; const FreeCalcTabs = ({ initial }) => { const [tab, setTab] = React.useState(initial === 'matrix' ? 'matrix' : 'natal'); return (
{FREE_CALC_TABS.map(t => ( ))}
{(FREE_CALC_TABS.find(t => t.id === tab) || {}).hint}
{tab === 'natal' ? : }
); }; Object.assign(window, { FreeCalcCard, FreeChartCard, FreeCalcTabs });