// wizard.jsx — New reading order wizard const WIZARD_STEPS = [ { id: 'product', label: 'Продукт' }, { id: 'person', label: 'Персона' }, { id: 'data', label: 'Данные' }, { id: 'pay', label: 'Оплата' }, ]; // RELATIONSHIP_OPTIONS — необязательный вопрос анкеты. Пустое значение — // осознанный выбор пользователя, а не «не заполнил»: разбор просто не будет // говорить про партнёрство как про факт. const RELATIONSHIP_OPTIONS = [ { value: '', label: 'Не хочу говорить' }, { value: 'один', label: 'Один / одна' }, { value: 'в отношениях', label: 'В отношениях' }, { value: 'в браке', label: 'В браке' }, { value: 'в разводе', label: 'В разводе' }, { value: 'всё сложно', label: 'Всё сложно' }, ]; // Mapping наш UI-id → backend service-id. const PRODUCT_TO_SERVICE = { natal: 'natal', forecast: 'year_ahead', compat: 'compatibility', matrix: 'matrix' }; // Мультивалютность: у сервисов и наборов каталога есть currency ('RUB'|'USD') // и цены в минорных единицах (копейки / центы). Рубли показываем как раньше // (из price_rub), доллары — через Intl en-US из price_minor. const catalogService = (key) => (window.__catalog && window.__catalog[key]) || null; const usdFmt = (cents) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format((cents || 0) / 100); const servicePriceText = (key, fallbackRub) => { const s = catalogService(key); if (s && s.currency === 'USD') return usdFmt(s.price_minor); return `${catalogPrice(key, fallbackRub)} ₽`; }; const bundlePriceText = (b) => (b && b.currency === 'USD' ? usdFmt(b.price_minor) : `${b.price_rub} ₽`); const bundleBasePriceText = (b) => (b && b.currency === 'USD' ? usdFmt(b.base_price_minor) : `${b.base_price_rub} ₽`); const bundleDiscounted = (b) => (b.currency === 'USD' ? b.base_price_minor > b.price_minor : b.base_price_rub > b.price_rub); // Из чего состоит разбор — устройством, а не числом страниц. // // Раньше здесь стояло «PDF · 38 стр.», и это было враньём: страниц столько, // сколько глав собралось, а глав столько, сколько сказала карта. У прогноза // выходило 15 против обещанных 38. Число в витрине живёт своей жизнью — оно // не пересчитывается, когда мы добавляем или убираем главу, — а человек // сверяет обещание с тем, что открыл, и видит обман там, где была // невнимательность. Устройство разбора не врёт и не устаревает, а объём // человек оценивает по примеру, который лежит рядом. // WizardSupportContext — что человек оформляет прямо сейчас. // // Вопросы из оформления («не проходит оплата», «а время рождения обязательно») // приходят чаще всего, и до сих пор их задавали в пустоту: форма не знала, о // каком продукте речь, а человек на шаге оплаты не пойдёт искать раздел // поддержки в кабинете, которого у него может и не быть. const WizardSupportContext = ({ label, service }) => { React.useEffect(() => { window.SUPPORT_CONTEXT = label ? { label: 'Оформление · ' + label, service: service || '' } : null; return () => { window.SUPPORT_CONTEXT = null; }; }, [label, service]); return null; }; const PRODUCT_SHAPE = { natal: '6 глав · PDF и читалка', forecast: 'календарь года · помесячные окна', compat: 'синастрия двоих · от притяжения до конфликтов', matrix: 'октаграмма · от ядра до кармы', }; // Конвертация "DD.MM.YYYY" → "YYYY-MM-DD" (формат, который ждёт бэкенд). const dotDateToISO = (s) => { s = (s || '').trim(); const m = s.match(/^(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})$/); if (!m) return s; // оставим как есть, бэк сам ругнётся return `${m[3]}-${m[2].padStart(2,'0')}-${m[1].padStart(2,'0')}`; }; // MatrixOctagram — октаграмма матрицы судьбы (8 точек + центр, старшие арканы // Таро). arcana — карта code→аркан из расчёта: {a: 13, b: 1, ...}. Без неё // числа иллюстративные — форма нарисована, но чьи это арканы, ещё неизвестно. // // Порядок точек на экране идёт по геометрии матрицы (см. internal/matrix): // сверху месяц B, слева день A, справа год C, снизу D, между ними диагонали. const MATRIX_POSITIONS = ['b', 'g', 'c', 'h', 'd', 'i', 'a', 'f']; const MatrixOctagram = ({ size = 260, arcana = null }) => { const c = size / 2, R = size * 0.37, r = size * 0.072; const pts = [-90, -45, 0, 45, 90, 135, 180, 225].map(deg => { const a = deg * Math.PI / 180; return [c + R * Math.cos(a), c + R * Math.sin(a)]; }); // Пока расчёта нет — точки пустые. Нарисовать сюда чужие арканы значит // показать человеку не его матрицу под подписью «Превью матрицы». const nums = MATRIX_POSITIONS.map(code => (arcana && arcana[code]) || '·'); const center = (arcana && arcana.e) || '·'; const poly = (idx) => idx.map(i => pts[i].join(',')).join(' '); return ( {pts.map((p, i) => )} {pts.map((p, i) => ( {nums[i]} ))} {center} ); }; const Wizard = () => { const [step, setStep] = React.useState(0); const [product, setProduct] = React.useState(() => { const q = new URLSearchParams(window.location.search).get('product'); // daily — подписка; без неё карточка «Ежедневный прогноз» с главной // открывала визард на натальной карте. return ['natal', 'forecast', 'compat', 'matrix', 'daily'].includes(q) ? q : 'natal'; }); const [personMode, setPersonMode] = React.useState('saved'); // saved | new const [selectedPerson, setSelectedPerson] = React.useState(null); const [secondPerson, setSecondPerson] = React.useState(null); const [tip, setTip] = React.useState(false); // аудио-комментарий пока не реализован const [payMethod, setPayMethod] = React.useState('sbp'); // СБП по умолчанию: 0.7% против 2.8% картой // Backend state const [persons, setPersons] = React.useState([]); // Общий список для обоих выпадающих списков совместимости. // ВАЖНО: объявлять строго после persons — Babel с preset env превращает // const в var, поэтому обращение выше по файлу даёт undefined, а не TDZ-ошибку. const personOptions = React.useMemo( () => (persons || []).map(p => ({ value: p.id, label: p.name, hint: p.relation || '' })), [persons] ); const [user, setUser] = React.useState(null); const [loading, setLoading] = React.useState(true); const [submitting, setSubmitting] = React.useState(false); const [submitErr, setSubmitErr] = React.useState(null); // Промокод. Проверяется на сервере ДО оплаты той же функцией, что считает // цену заказа: применённый только на фронте код разошёлся бы с суммой // платежа, и человек увидел бы другую цифру уже на странице банка. // Набор выбран вместо отдельного разбора. Продукт при этом остаётся тем, // с которого набор начинается: данные рождения, превью карты и проверки — те // же, отличаются только цена и то, что уходит на сервер. const [bundle, setBundle] = React.useState(null); const [promo, setPromo] = React.useState(''); const [promoBusy, setPromoBusy] = React.useState(false); const [promoState, setPromoState] = React.useState(null); // Контролируемая форма ввода персоны (step 1 mode=new, step 2; для совместимости — Партнёр 1). const [form, setForm] = React.useState({ name: '', dob: '', time: '', place: '', placeLat: 0, placeLng: 0, placeTz: '', gender: '', saveAsPerson: true }); const setF = (k, v) => setForm(f => ({ ...f, [k]: v })); // Вторая форма — Партнёр 2 для совместимости (оба партнёра редактируемы на шаге «Данные»). const [form2, setForm2] = React.useState({ name: '', dob: '', time: '', place: '', placeLat: 0, placeLng: 0, placeTz: '', gender: '', saveAsPerson: true }); const setF2 = (k, v) => setForm2(f => ({ ...f, [k]: v })); // Совместимость: подтверждение права предоставить данные второго человека (152-ФЗ — третье лицо). const [partner2Consent, setPartner2Consent] = React.useState(false); // Подарок: разбор оформляется на другого человека. // giftNeedsData — даритель не знает данных рождения; их введёт получатель по // ссылке из письма. Это и практично (точное время знает мало кто), и чисто // по 152-ФЗ: данные вводит сам субъект, а не третье лицо. const [isGift, setIsGift] = React.useState(false); const [giftNeedsData, setGiftNeedsData] = React.useState(true); const [gift, setGift] = React.useState({ email: '', name: '', from: '', message: '' }); // Как подарок попадёт к человеку: письмом от нас или ссылкой из рук дарителя. // Второе — не редкость: адреса часто просто нет, зато есть мессенджер. const [giftDelivery, setGiftDelivery] = React.useState('email'); // Согласие на регулярное списание — отдельная явная галочка (astro-r9o.7). const [subConsent, setSubConsent] = React.useState(false); // Пояс и час доставки. Пояс берём из браузера: человек в визарде ещё не наш // подписчик, спрашивать его о том, что и так известно, — лишний шаг. const [subTZ, setSubTZ] = React.useState(() => browserTZ() || 'Europe/Moscow'); const [subHour, setSubHour] = React.useState(9); // Анкета «о себе». Карта показывает устройство человека, но не его // обстоятельства: один и тот же Марс в седьмом доме в двадцать лет и в сорок // — про разное. Все поля необязательные, пустая анкета ничего не ломает. const [ctx, setCtx] = React.useState({ occupation: '', relationship: '', focus: '' }); const setC = (k, v) => setCtx(prev => ({ ...prev, [k]: v })); const setG = (k, v) => setGift(g => ({ ...g, [k]: v })); // Совместимость дарить «вслепую» нельзя: получателю пришлось бы вводить // данные двух человек, включая чужие. Форму такого объёма он не заполнит. const giftBlind = isGift && giftNeedsData && product !== 'compat'; // Guest-checkout (не залогинен): email + согласие инлайн — фримиум без account-wall. const [guestEmail, setGuestEmail] = React.useState(''); const [guestConsent, setGuestConsent] = React.useState(false); const [guestMarketing, setGuestMarketing] = React.useState(false); // Персона → объект формы (born_date "YYYY-MM-DD" → "DD.MM.YYYY"). Для новой/пустой // персоны saveAsPerson=true (сохраним при заказе), для существующей — false (не дублируем). const personToForm = (p) => { if (!p) return { name: '', dob: '', time: '', place: '', placeLat: 0, placeLng: 0, placeTz: '', gender: '', saveAsPerson: true }; const iso = p.born_date || ''; const dot = iso.length >= 10 ? `${iso.slice(8, 10)}.${iso.slice(5, 7)}.${iso.slice(0, 4)}` : ''; return { name: p.name || '', dob: dot, time: dtNormTime(p.born_time), place: p.place || '', placeLat: p.lat || 0, placeLng: p.lon || 0, placeTz: p.tz_name || '', gender: p.gender || '', saveAsPerson: false }; }; React.useEffect(() => { (async () => { try { const [meR, psR] = await Promise.all([ fetch('/api/auth/me', { credentials: 'same-origin' }), fetch('/api/persons', { credentials: 'same-origin' }), ]); if (meR.ok) { const me = await meR.json(); setUser(me.user || me); } if (psR.ok) { const data = await psR.json(); const list = data.persons || []; setPersons(list); // Если в URL передан ?person= — выбираем именно его const qsPerson = new URLSearchParams(window.location.search).get('person'); const preselected = qsPerson && list.find(p => p.id === qsPerson); const def = preselected || list.find(p => p.is_default) || list[0]; if (def) setSelectedPerson(def.id); if (list.length > 1) setSecondPerson(list[1].id); // если нет ни одной персоны — переключаем на "новый" if (list.length === 0) setPersonMode('new'); } } catch (e) { console.warn('wizard.load', e); } finally { setLoading(false); } })(); }, []); // Превью карты — настоящий расчёт по уже введённым данным, а не картинка с // чужими позициями. Человек на этом шаге проверяет, те ли данные он подал: // нарисованный кому-то другому Асцендент проверять нечего, а ошибку в дате // видно именно по знакам. Ручка публичная и та же, что у бесплатного // расчёта на главной, — значит превью и покупка считаются одинаково. const [preview, setPreview] = React.useState(null); React.useEffect(() => { const iso = dotDateToISO(form.dob); if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) { setPreview(null); return; } let alive = true; // Пауза — против запроса на каждую цифру даты: поле заполняется посимвольно. const timer = setTimeout(async () => { try { const url = product === 'matrix' ? '/api/free/matrix' : '/api/free/chart'; const body = product === 'matrix' ? { born_date: iso, gender: form.gender || '' } : { born_date: iso, born_time: (form.time || '').trim(), place: (form.place || '').trim(), lat: form.placeLat || 0, lng: form.placeLng || 0, tz: form.placeTz || '', }; const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!r.ok) throw new Error('preview'); const d = await r.json(); if (alive) setPreview(d); } catch (e) { // Превью — не часть заказа: молча остаёмся на «данных не хватает». if (alive) setPreview(null); } }, 400); return () => { alive = false; clearTimeout(timer); }; }, [product, form.dob, form.time, form.place, form.placeLat, form.placeLng, form.placeTz, form.gender]); // Арканы матрицы: code («A».. «I») → число, в том виде, в каком их ждёт // октаграмма. const previewArcana = React.useMemo(() => { if (!preview || preview.kind !== 'matrix' || !preview.points) return null; const byCode = {}; preview.points.forEach(p => { byCode[(p.code || '').toLowerCase()] = p.arcanum; }); return byCode; }, [preview]); // Ответ прошлого продукта живёт до следующего запроса (пауза в 400 мс), // поэтому смотрим на kind, а не на сам факт ответа. const chartPreview = preview && preview.kind === 'chart' ? preview : null; // Позиции для колеса. Рисуем только те планеты, для которых есть глиф: // дальние (Уран, Нептун, Плутон) иначе встали бы пустыми кружками. const previewPlacements = React.useMemo(() => { const pts = (chartPreview && chartPreview.points) || []; return pts.filter(p => Planet[p.id]).map(p => ({ planet: p.id, deg: p.lon })); }, [chartPreview]); // Цены/названия тянутся с бэка (window.__catalog) с фолбэками. Ключи визарда // (forecast/compat) маппятся на каталожные (year_ahead/compatibility). const PRODUCTS = { natal: { key: 'natal', name: catalogTitle('natal', 'Натальная карта'), price: catalogPrice('natal', 1990), desc: 'Почему ты такая, какая есть', accent: 'gold', planets: ['sun','moon','mercury'] }, forecast: { key: 'year_ahead', name: catalogTitle('year_ahead', 'Прогноз на год'), price: catalogPrice('year_ahead', 2990), desc: 'Когда действовать, а когда ждать', accent: 'celest', planets: ['jupiter','saturn'] }, compat: { key: 'compatibility', name: catalogTitle('compatibility', 'Совместимость'), price: catalogPrice('compatibility', 2490), desc: 'Мой ли это человек', accent: 'rose', planets: ['venus','mars'] }, daily: { key: 'daily_sub', name: 'Ежедневный прогноз', price: subscriptionOffer().price_rub, desc: 'Каждое утро — про твой день', accent: 'celest', planets: ['moon','sun'], sub: true }, matrix: { key: 'matrix', name: catalogTitle('matrix', 'Матрица судьбы'), price: catalogPrice('matrix', 1490), desc: 'Предназначение по дате рождения', accent: 'sage', planets: ['sun','moon','mercury'] }, }; // Показываем только те наборы, что собираются из одних данных рождения. // Набор с совместимостью потребовал бы второго человека и своей ветки шага // «Данные» — до тех пор его честнее не предлагать вовсе, чем предложить и // сломаться на середине. const BUNDLE_ITEM_TO_PRODUCT = { natal: 'natal', year_ahead: 'forecast', matrix: 'matrix' }; const bundles = (typeof catalogBundles === 'function' ? catalogBundles() : []) .filter(b => (b.items || []).every(it => BUNDLE_ITEM_TO_PRODUCT[it.key])); const chooseBundle = (b) => { setBundle(b); // Подарок и набор пока не совместимы — снимаем выбор, а не показываем // ошибку после оплаты. setIsGift(false); // Первый разбор набора задаёт, что спрашивать дальше. const first = (b.items || [])[0]; setProduct(BUNDLE_ITEM_TO_PRODUCT[first && first.key] || 'natal'); }; // Код мог действовать только на прошлый продукт, поэтому смена продукта // сбрасывает результат проверки, а не тащит его в новый заказ. React.useEffect(() => { setPromoState(null); }, [product]); const promoApplied = !!(promoState && promoState.ok && promoState.product === PRODUCTS[product].key); const applyPromo = async () => { const code = promo.trim(); if (!code || promoBusy) return; setPromoBusy(true); try { const r = await fetch('/api/promo/check', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, product: PRODUCTS[product].key }), }); const d = await r.json(); if (d && d.ok) { // Цена приходит в валюте витрины: для USD — центы в price_minor, // для рублей — как раньше в price_rub. setPromoState({ ok: true, code: d.code, product: d.product, price: d.currency === 'USD' ? d.price_minor : d.price_rub, currency: d.currency || 'RUB', percent: d.discount_percent, label: d.discount_label }); window.astro?.track?.('promo_applied', d.code || ''); } else { setPromoState({ ok: false, error: (d && d.error) || 'Промокод не применился' }); } } catch (e) { setPromoState({ ok: false, error: 'Не удалось проверить промокод' }); } finally { setPromoBusy(false); } }; // Пришёл по ссылке приглашения: код лежит в адресе, а если человек успел // погулять по сайту — в куке с того перехода. Заставлять его набирать код, // который мы и так знаем, значит терять покупку на последнем шаге. const autoPromo = React.useRef(false); React.useEffect(() => { if (promo) return; const fromUrl = new URLSearchParams(location.search).get('promo'); const fromCookie = (document.cookie.match(/(?:^|;\s*)sp_promo=([^;]+)/) || [])[1]; const code = (fromUrl || fromCookie || '').trim(); if (code) setPromo(decodeURIComponent(code).toUpperCase()); }, []); React.useEffect(() => { if (autoPromo.current || !promo || promoState || !product) return; autoPromo.current = true; applyPromo(); }, [promo, product]); // Цена уже приходит со скидкой (её считает бэк). Здесь только показываем, // откуда она взялась: без объяснения цифра выглядит как ошибка прайса. const discountOf = (id) => catalogDiscount(PRODUCTS[id].key); const DiscountNote = ({ id, align }) => { const d = discountOf(id); if (!d) return null; // Зачёркнутая база — в валюте сервиса: центы для USD, рубли как раньше. const svc = catalogService(PRODUCTS[id].key); const baseText = svc && svc.currency === 'USD' ? usdFmt(svc.base_price_minor) : `${d.base} ₽`; return (
{d.strikethrough && d.base ? ( {baseText} ) : null} {d.label || `−${d.percent}%`}
); }; // Поля получателя подарка. Живут на шаге «Данные»: и когда даритель вводит // данные рождения сам, и когда их заполнит получатель. const renderGiftFields = () => (
Как передать подарок
{[ { id: 'email', title: 'Отправим письмом', sub: 'Нужен его email' }, { id: 'link', title: 'Дам ссылку сам', sub: 'Ссылка появится сразу после оплаты' }, ].map(o => ( ))}
Получатель
{giftDelivery === 'email' && ( )}
{giftDelivery === 'link' ? (giftBlind ? 'Сразу после оплаты покажем ссылку — передашь её сам. По ней получатель укажет свои данные рождения, и разбор соберётся.' : 'Сразу после оплаты покажем ссылку — передашь её сам. По ней получатель прочитает разбор и скачает PDF.') : (giftBlind ? 'После оплаты получателю уйдёт письмо со ссылкой. Разбор соберётся, как только он заполнит данные рождения.' : 'После оплаты соберём разбор и пришлём его получателю письмом. Ссылка личная — по ней он и прочитает, и скачает PDF.')}
); const next = () => setStep(Math.min(WIZARD_STEPS.length - 1, step + 1)); const prev = () => setStep(Math.max(0, step - 1)); // Шаг визарда — самое вероятное место отсева и единственное, где без // разметки не видно ничего: все четыре шага живут на одном адресе, и в // отчёте по путям они сливаются в одну строку /order/new. React.useEffect(() => { window.astro?.track?.('wizard_step', WIZARD_STEPS[step]?.id || String(step)); }, [step]); // Поля рождения — переиспользуются для обычной формы и для обоих партнёров совместимости. const renderBirthFields = (f, set, dateOnly = false) => (
{!dateOnly && ( )} {dateOnly ? (
Дата рождения set('dob', v)} />
) : (
Дата set('dob', v)} />
Время set('time', v)} />
)} {!dateOnly && ( )}
); // Префилл форм при переходе на step-2. React.useEffect(() => { if (step !== 2) return; if (product === 'compat') { // Оба партнёра — из выбранных персон (если выбраны), но полностью редактируемы. setForm(personToForm(persons.find(x => x.id === selectedPerson))); setForm2(personToForm(persons.find(x => x.id === secondPerson))); return; } if (personMode === 'saved' && selectedPerson) { setForm(personToForm(persons.find(x => x.id === selectedPerson))); } }, [step, product, personMode, selectedPerson, secondPerson, persons]); // Отправка заказа на бэкенд + переход на оплату. // Для compatibility — обязательны две сохранённые персоны. // Для natal/year_ahead — берём поля из step-2 формы (даже если step-1 picker // выбрал сохранённую персону, эти поля пред-заполнены и user мог их править). // isSub — выбран не разовый разбор, а подписка. Флоу общий до последнего // шага: персона и данные рождения нужны и там, и там — без натальной карты // ежедневку не посчитать. const isSub = product === 'daily'; // Валюта заказа задаётся витриной, а не пользователем: USD-сервисы и наборы // оплачиваются картой через lava.top, и выбора «СБП или карта» у них нет — // СБП умеет только рубли. Набор несёт валюту с собой, как и сервис. const orderSvc = bundle || catalogService(PRODUCTS[product].key); const isUSDOrder = !isSub && !!orderSvc && orderSvc.currency === 'USD'; // Итог «К оплате»: строкой — для показа, и отдельная проверка на ноль, чтобы // при полном промокоде кнопка честно говорила «Получить разбор», а не вела // за платёжной ссылкой на ноль. const orderPriceText = () => { if (bundle) return bundlePriceText(bundle); if (promoApplied) return promoState.currency === 'USD' ? usdFmt(promoState.price) : `${promoState.price + (tip ? 990 : 0)} ₽`; if (isUSDOrder) return usdFmt(orderSvc.price_minor); return `${PRODUCTS[product].price + (tip ? 990 : 0)} ₽`; }; const orderIsFree = () => { if (bundle) return (bundle.currency === 'USD' ? bundle.price_minor : bundle.price_rub) === 0; if (promoApplied) return promoState.price === 0; if (isUSDOrder) return orderSvc.price_minor === 0; return (PRODUCTS[product].price + (tip ? 990 : 0)) === 0; }; // Оформление подписки. Отдельно от submitOrder: подписка не создаёт заказ, // не идёт в платёжку и живёт в своей таблице. const submitSubscription = async () => { setSubmitting(true); setSubmitErr(null); try { if (!user) { // Гостевого оформления подписки пока нет: она привязана к аккаунту, // а passwordless-провижининг у нас умеет только checkout заказов. window.location.href = '/login?next=' + encodeURIComponent('/order/new?product=daily'); return; } if (!subConsent) throw new Error('Подтверди согласие на регулярное списание'); // Персона: либо выбранная, либо создаём из формы — подписке нужен id. let personID = personMode === 'saved' ? selectedPerson : null; const birth = { name: form.name.trim(), born_date: dotDateToISO(form.dob), born_time: form.time.trim(), tz_name: form.placeTz || '', place: form.place.trim(), lat: form.placeLat || 0, lon: form.placeLng || 0, gender: form.gender, }; if (!personID) { if (!birth.name || !birth.born_date || !birth.place) { throw new Error('Заполни имя, дату и место рождения'); } const pr = await fetch('/api/persons', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...birth, relation: '' }), }); const pd = await pr.json(); if (!pr.ok) throw new Error(pd.error || 'Не удалось сохранить персону'); personID = pd.id; } else if (birth.born_date) { // Выбранную персону обновляем правками с шага «Данные». await fetch(`/api/persons/${encodeURIComponent(personID)}`, { method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(birth), }).catch(() => {}); } const r = await fetch('/api/subscriptions', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ person_id: personID, consent_recurring: true, tz: subTZ, daily_hour: subHour, }), }); const d = await r.json(); if (!r.ok) throw new Error(d.error === 'subscription already active' ? 'Подписка уже оформлена — она в кабинете' : (d.error || `HTTP ${r.status}`)); window.location.href = '/account'; } catch (e) { console.error('subscription.submit', e); setSubmitErr(e.message || 'Не удалось оформить подписку'); setSubmitting(false); } }; const submitOrder = async () => { setSubmitting(true); setSubmitErr(null); // Что записать в персоны после успешного заказа. Именно после: у гостя // сессия появляется только вместе с заказом, до этого /api/persons — 401, // и введённые данные (в первую очередь место рождения) терялись. const pendingPersons = []; // Выбранную персону обновляем (PATCH), новую — создаём. Так место, дата и // время, введённые один раз, подставятся в следующий заказ и в кабинете. const persistPerson = async (t) => { const payload = { name: t.birth.name, born_date: t.birth.born_date, born_time: t.birth.born_time, tz_name: t.birth.tz_name || '', place: t.birth.place, lat: t.birth.lat || 0, lon: t.birth.lng || 0, gender: t.birth.gender || '', }; try { if (t.id) { await fetch(`/api/persons/${encodeURIComponent(t.id)}`, { method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); } else if (t.saveNew) { await fetch('/api/persons', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, relation: '' }), }); } } catch (_) { /* best-effort: заказ уже создан, ломать его нечем */ } }; try { const service = PRODUCT_TO_SERVICE[product]; // Продукт без цены в валюте витрины покупать нельзя: карточка на витрине // задизейблена, но прямую ссылку с ?product= никто не отменял. if (!bundle && !isSub) { const svc = catalogService(PRODUCTS[product].key); if (svc && svc.unavailable) throw new Error('Этот разбор сейчас недоступен'); } // Набор присылаем ключом: из чего он состоит и сколько стоит, знает // сервер. Услугу тоже оставляем — она задаёт, какие данные рождения // проверять, и на сервере всё равно перезаписывается составом набора. const body = bundle ? { service, bundle: bundle.key } : { service }; if (service === 'compatibility') { // Оба партнёра берутся из редактируемых форм шага «Данные» (inline), // поэтому любые правки пользователя попадают в заказ. const mkBirth = (f) => ({ name: f.name.trim(), born_date: dotDateToISO(f.dob), born_time: f.time.trim(), tz_name: f.placeTz || '', place: f.place.trim(), lat: f.placeLat || 0, lng: f.placeLng || 0, gender: f.gender, }); const b1 = mkBirth(form); const b2 = mkBirth(form2); if (!b1.name || !b1.born_date || !b1.place) throw new Error('Заполни данные первого человека: имя, дата и место рождения'); if (!b2.name || !b2.born_date || !b2.place) throw new Error('Заполни данные второго человека: имя, дата и место рождения'); if (!partner2Consent) throw new Error('Подтверди, что вправе предоставить данные второго человека'); body.partner1 = b1; body.partner2 = b2; body.partner2_consent = true; pendingPersons.push({ id: selectedPerson || null, saveNew: form.saveAsPerson, birth: b1 }); pendingPersons.push({ id: secondPerson || null, saveNew: form2.saveAsPerson, birth: b2 }); } else { // Прогноз на год требует период — по умолчанию ближайшие 12 месяцев. // Считаем до ветки по данным рождения: подарку «вслепую» период тоже нужен. if (service === 'year_ahead') { const ymd = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const from = new Date(); const to = new Date(from); to.setFullYear(to.getFullYear() + 1); body.from_date = ymd(from); body.to_date = ymd(to); } if (giftBlind) { // Данных рождения нет и быть не может — их введёт получатель. // Персону тоже не сохраняем: это не наш человек. } else { body.birth = { name: form.name.trim(), born_date: dotDateToISO(form.dob), born_time: form.time.trim(), tz_name: form.placeTz || '', place: form.place.trim(), lat: form.placeLat || 0, lng: form.placeLng || 0, gender: form.gender, }; if (!body.birth.born_date) { throw new Error('Укажи дату рождения'); } // Матрица судьбы — date-only: имя/место не обязательны. if (service !== 'matrix' && (!body.birth.name || !body.birth.place)) { throw new Error('Заполни имя, дату и место рождения'); } pendingPersons.push({ id: personMode === 'saved' ? (selectedPerson || null) : null, saveNew: form.saveAsPerson, birth: body.birth, }); } } // Анкета уходит отдельным блоком: в промпте она помечена как слова // клиента, а не как данные карты. Подарок «вслепую» её не собирает — // отвечать за получателя мы не можем. if (!giftBlind) { const c = {}; if (ctx.occupation.trim()) c.occupation = ctx.occupation.trim(); if (ctx.relationship) c.relationship = ctx.relationship; if (ctx.focus.trim()) c.focus = ctx.focus.trim(); if (Object.keys(c).length) body.context = c; } if (isGift) { const giftEmail = giftDelivery === 'email' ? gift.email.trim() : ''; if (giftDelivery === 'email' && (!giftEmail || !giftEmail.includes('@'))) { throw new Error('Укажи email получателя — на него уйдёт подарок'); } body.gift = { recipient_email: giftEmail, recipient_name: gift.name.trim(), sender_name: gift.from.trim(), message: gift.message.trim(), needs_birth_data: giftBlind, }; } if (promoApplied) body.promo_code = promoState.code; window.astro?.track?.('checkout_started', product || ''); let endpoint = '/api/orders'; if (!user) { // Гость: заказ без обязательной регистрации (создаст passwordless-аккаунт + сессию). const email = guestEmail.trim(); if (!email || !email.includes('@')) throw new Error('Укажи email — на него придёт разбор'); if (!guestConsent) throw new Error('Подтверди согласие с условиями, чтобы продолжить'); body.email = email; body.consent_pdn = true; body.consent_cross_border = true; body.consent_marketing = guestMarketing; endpoint = '/api/checkout/guest'; } const r = await fetch(endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (r.status === 401) { window.location.href = '/login?next=/order/new'; return; } if (r.status === 412) { window.location.href = '/consent'; return; } const data = await r.json(); if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`); for (const t of pendingPersons) await persistPerson(t); // Промокод покрыл всю стоимость: платить нечего, заказ уже в работе. // Идти за платёжной ссылкой на ноль рублей бессмысленно — банк её не // создаст, а человек увидит ошибку там, где всё получилось. if (data.paid) { window.location.href = `/order/${data.order_id}`; return; } // Платёж. Эндпоинт единый: какой провайдер за ним стоит (мок, Точка или // lava.top) — решает бэкенд по валюте заказа, фронт об этом знать не должен. // У USD-заказа один способ оплаты (карта), поэтому payment_method не // отправляем вовсе: выбирать там нечего. const payBody = { order_id: data.order_id }; if (data.currency !== 'USD') payBody.payment_method = payMethod; const pr = await fetch('/api/payments/create', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payBody), }); const pd = await pr.json(); if (!pr.ok) throw new Error(pd.error || `HTTP ${pr.status}`); // Отмечаем до перехода: дальше страницу забирает банк, и отсюда мы // больше ничего не узнаем. Очередь дожимается на pagehide. window.astro?.track?.('payment_redirect', data.currency === 'USD' ? 'card_usd' : (payMethod || '')); window.location.href = pd.confirmation_url || `/order/${data.order_id}`; } catch (e) { console.error('order.submit', e); // Причина в отчёте обязательна: без неё «слился на оплате» не отличается // от «у нас упало», а это два разных вывода и две разные починки. window.astro?.track?.('payment_failed', (e && e.message) || 'unknown'); setSubmitErr(e.message || 'Не удалось создать заказ'); setSubmitting(false); } }; return (
{/* Шапка сайта. Раньше её здесь не было вовсе: человек, зашедший в оформление по прямой ссылке, оставался без логотипа, без выхода и без признака того, под каким аккаунтом он покупает. «Назад» и шаги ниже остаются — это навигация внутри оформления, а не по сайту. */} {/* Background chart */}
{/* Header */}
Шаг {step + 1} / {WIZARD_STEPS.length}
{/* Stepper */}
{WIZARD_STEPS.map((s, i) => { const canGo = i < step; // на пройденные шаги можно вернуться и поправить return (
{ if (canGo) { setSubmitErr(null); setStep(i); } }} className="row gap-3" title={canGo ? 'Вернуться к шагу' : undefined} style={{ alignItems: 'center', opacity: i > step ? 0.4 : 1, cursor: canGo ? 'pointer' : 'default' }}>
{i < step ? : i + 1}
{s.label} {i < WIZARD_STEPS.length - 1 && }
); })}
{/* Step content */}
{step === 0 && ( <>

Какой разбор сейчас?

Выбери, что важно прямо сейчас. Каждый разбор основан на твоей натальной карте.

{Object.entries(PRODUCTS).map(([id, p]) => { // Продукт без цены в валюте витрины (unavailable) показываем // явно недоступным: пустое место выглядело бы багом каталога. const svc = catalogService(p.key); const unavail = !!(svc && svc.unavailable); return ( ); })}
{/* Наборы — отдельной полосой под продуктами, а не пятой карточкой в той же сетке: это другая единица покупки, и ставить её в один ряд с разборами значит предлагать выбрать одно из пяти там, где выбирают одно из четырёх плюс «сразу два». */} {bundles.length > 0 && (
Сразу вместе — дешевле
{bundles.map(b => { const active = bundle && bundle.key === b.key; return ( ); })}
)} {/* Про объём говорим честно и один раз: число страниц зависит от карты, и сравнивать человек будет не с цифрой, а с примером. */}

Объём зависит от карты: каких-то глав может не быть, а редкие конфигурации добавляют свои.{' '} Посмотреть пример разбора

)} {step === 1 && ( <>

Для кого разбор?

{giftBlind ? 'Подарок оформляется на другого человека — данные рождения он укажет сам.' : product === 'compat' ? 'Выбери двух из сохранённых — или оставь как есть и заполни данные вручную. Данные обоих можно отредактировать на следующем шаге.' : 'Выбери из сохранённых или добавь нового человека.'}

{/* Себе или в подарок — решает, что вообще спрашивать дальше. К подписке не применимо: она привязана к аккаунту. К набору тоже: у подарка своя ссылка и свой путь на КАЖДЫЙ разбор, и «подарить два сразу» — это отдельный продукт, а не галочка. */} {!isSub && !bundle && (
Кому достанется разбор
{[ { id: false, title: 'Себе', sub: 'Разбор придёт на мою почту' }, { id: true, title: 'В подарок', sub: 'Разбор получит другой человек' }, ].map(o => ( ))}
{isGift && (
Данные рождения получателя
{[ { id: true, title: 'Пусть заполнит сам', sub: 'Пришлём ему ссылку — точное время рождения знает только он' }, { id: false, title: 'Я их знаю', sub: 'Введу дату, время и место сам(а) — разбор придёт готовым' }, ].map(o => ( ))}
{product === 'compat' && (
Для совместимости данные обоих людей вводит даритель — получателю пришлось бы заполнять и чужие.
)}
)}
)} {giftBlind && (
Данные рождения спросим у получателя
На следующем шаге укажи, кому и куда отправить подарок. После оплаты получателю уйдёт письмо со ссылкой — он заполнит свои данные, и разбор соберётся в течение часа.
)} {/* Mode toggle (для совместимости скрыт — оба партнёра редактируются на шаге «Данные») */} {!giftBlind && product !== 'compat' && (
{[ { id: 'saved', label: 'Сохранённые персоны' }, { id: 'new', label: 'Новый человек' }, ].map(m => ( ))}
)} {!giftBlind && (personMode === 'saved' || product === 'compat') && ( <> {product === 'compat' && (
Партнёр 1
)}
{persons.length === 0 && !loading && (
У тебя пока нет сохранённых персон — добавь нового человека ниже.
)} {persons.map(p => { const selected = product === 'compat' ? (p.id === selectedPerson || p.id === secondPerson) : p.id === selectedPerson; const isoDate = p.born_date || ''; const showDate = isoDate.length >= 10 ? `${isoDate.slice(8,10)}.${isoDate.slice(5,7)}.${isoDate.slice(0,4)}` : '—'; return ( ); })} {product !== 'compat' && ( )}
)} {!giftBlind && personMode === 'new' && product !== 'compat' && (
Дата рождения setF('dob', v)} />
Время — если знаешь setF('time', v)} />
)} )} {step === 2 && ( <>

{giftBlind ? <>Кому подарок : <>Уточни данные}

{giftBlind ? (giftDelivery === 'link' ? 'Ссылку покажем сразу после оплаты — передашь её сам. Данные рождения получатель заполнит по ней.' : 'Укажи, куда отправить подарок. Данные рождения получатель заполнит сам по ссылке из письма.') : 'Расчёт идёт от твоей минуты рождения, а не от знака, — поэтому время важно: по нему считаются дома (карьера, отношения). Не помнишь — оставь пустым, разбор соберётся и так. Обычно время указано в свидетельстве о рождении, а восстановить его можно запросом в ЗАГС или в роддом.'}

{isGift && renderGiftFields()} {giftBlind ? null : product === 'compat' ? ( <>
Партнёр 1
{renderBirthFields(form, setF)}
Партнёр 2
{renderBirthFields(form2, setF2)}
) : product === 'matrix' ? (
Дата рождения setF('dob', v)} />
Дата setF('dob', v)} />
Время setF('time', v)} />
)} )} {step === 3 && isSub && ( <>

Подписка

Каждое утро — прогноз по твоей натальной карте. В кабинете, на почте и в мессенджере: каналы выбираешь сам.

Ежедневный прогноз {subscriptionOffer().price_rub} ₽/мес
{submitErr && (
{submitErr}
)}
Считаем по персоне, которую ты выбрал на прошлых шагах. Сменить её можно в кабинете.
)} {step === 3 && !isSub && ( <>

Оплата

Способ оплаты
{isUSDOrder ? ( // У долларового заказа способ один — карта через lava.top: // СБП живёт только в рублях, и рисовать выбор из одного // пункта значило бы имитировать решение, которого нет.
Банковская карта
Visa, Mastercard · оплата через lava.top
) : ( [ // Только то, что реально подключено у банка-эквайрера: // выбранный способ уходит в Точку один, и предложить // кошельки значило бы обещать форму, которой не будет. { id: 'sbp', label: 'СБП', sub: 'По QR или из приложения банка', recommended: true }, { id: 'card', label: 'Банковская карта', sub: 'МИР, Visa, Mastercard' }, ].map((m) => { const active = payMethod === m.id; return ( ); }) )}
PDF придёт на email, с которого ты вошла. Изменить — в кабинете.
{/* Summary */}
Заказ
{bundle ? bundle.title : PRODUCTS[product].name} {bundle ? bundlePriceText(bundle) : servicePriceText(PRODUCTS[product].key, PRODUCTS[product].price)}
{bundle && (
{(bundle.items || []).map(it => it.title).join(' · ')} — одной оплатой, поштучно {bundleBasePriceText(bundle)}
)} {discountOf(product) && (
{discountOf(product).expiresAt ? `Скидка действует до ${new Date(discountOf(product).expiresAt).toLocaleDateString('ru-RU', { day: '2-digit', month: 'long' })}` : discountOf(product).label}
)}
Для: {isGift ? `${gift.name || 'получателя'} · ${giftDelivery === 'link' ? 'ссылкой' : (gift.email || 'email не указан')}` : product === 'compat' ? `${persons.find(p => p.id === selectedPerson)?.name || '—'} × ${persons.find(p => p.id === secondPerson)?.name || '—'}` : (personMode === 'saved' && selectedPerson ? (persons.find(p => p.id === selectedPerson)?.name || 'персона') : (form.name || 'новая персона'))}
{isGift && (
{giftBlind ? 'Подарок · получатель сам укажет данные рождения по ссылке из письма' : 'Подарок · готовый разбор уйдёт получателю письмом'}
)} {tip && (
Аудио-комментарий астролога
)}
{ setPromo(e.target.value); if (promoState) setPromoState(null); }} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); applyPromo(); } }} placeholder="Промокод" aria-label="Промокод" style={{ flex: 1, minWidth: 0, padding: '10px 12px', borderRadius: '8px', border: '1px solid var(--line-soft)', background: 'transparent', color: 'inherit', fontSize: '14px', textTransform: 'uppercase' }} />
{promoState && (
{promoState.ok ? `${promoState.label || 'Промокод'} · −${promoState.percent}%` : promoState.error}
)}
К оплате {orderPriceText()}
{!user && (
)} {submitErr && (
{submitErr}
)} {/* Возражение «это же просто текст из нейросети» приходит ровно здесь, на кнопке оплаты, — поэтому механика повторяется рядом с ценой, а не остаётся на лендинге. */}

Позиции планет берутся из эфемерид Swiss Ephemeris — с точностью до 0,01° на твою минуту рождения, а не по знаку Солнца. Разбор идёт по методике школы Левин/Подводный, и каждую главу потом перечитывает второй редактор и вычёркивает всё, что подошло бы любому.

152-ФЗ · возврат 7 дней
)}
{/* Navigation — липкая полоса у нижнего края: на длинных шагах (выбор продукта, форма данных) кнопка «Дальше» иначе уезжает под фолд и юзер не понимает, что делать. */} {step < 3 && (
)}
); }; Object.assign(window, { Wizard });