// 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.sun ? result.sun.sign : '—'}
Солнце · знак
{placements.map(p => (
{p.glyph} {p.v.sign}
{p.label}
))}
{!result.ascendant && (
// Не прячем недостающее: человек должен понимать, почему у него
// две строки, а у подруги три, и что это чинится его же данными.
Асцендент считается от минуты и места рождения — укажи их, и он появится.
)}
{result.teaser}
{!leadSent && (
<>
Прислать разбор этой карты на почту — что означают эти позиции вместе?
{/* Закрытые точки остаются на своих местах и подписаны: человек должен
видеть, ЧЕГО ему не показали, иначе закрытая часть не читается как
ценность. Аркан приходит с сервера только у открытых — вёрстка
здесь ничего не прячет и прятать не может. */}
{(result.points || []).map(p => (
{p.locked ? '·' : p.arcanum}
{p.code}
))}
{!leadSent && (
<>
Открыть остальные {result.locked || 8} точки — предназначение, деньги, отношения, карма?
);
};
// 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 (