// wizard.jsx — New reading order wizard const WIZARD_STEPS = [ { id: 'product', label: 'Продукт' }, { id: 'person', label: 'Персона' }, { id: 'data', label: 'Данные' }, { id: 'pay', label: 'Оплата' }, ]; // Mapping наш UI-id → backend service-id. const PRODUCT_TO_SERVICE = { natal: 'natal', forecast: 'year_ahead', compat: 'compatibility', matrix: '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')}`; }; // Маска даты: из любых символов оставляем цифры и расставляем точки ДД.ММ.ГГГГ // по мере ввода. Бэкспейс работает естественно — точки убираются вместе с цифрами. const maskDate = (s) => { const d = (s || '').replace(/\D/g, '').slice(0, 8); const parts = []; parts.push(d.slice(0, 2)); if (d.length >= 3) parts.push(d.slice(2, 4)); if (d.length >= 5) parts.push(d.slice(4, 8)); return parts.filter(Boolean).join('.'); }; // Маска времени: цифры → чч:мм. const maskTime = (s) => { const d = (s || '').replace(/\D/g, '').slice(0, 4); return d.length <= 2 ? d : d.slice(0, 2) + ':' + d.slice(2, 4); }; // PlaceField — поле «место рождения» с автокомплитом от геокодера. Юзер выбирает // конкретный город (одноимённые различаются) → onPick фиксирует lat/lng/tz, и // бэкенд не геокодит свободный текст заново (без расползания данных). const PlaceField = ({ value, onChange, onPick }) => { const [sugg, setSugg] = React.useState([]); const [open, setOpen] = React.useState(false); const tRef = React.useRef(null); const fetchSugg = (q) => { clearTimeout(tRef.current); if ((q || '').trim().length < 3) { setSugg([]); setOpen(false); return; } tRef.current = setTimeout(async () => { try { const r = await fetch('/api/geocode/suggest?q=' + encodeURIComponent(q), { credentials: 'same-origin' }); const d = await r.json(); setSugg(d.suggestions || []); setOpen((d.suggestions || []).length > 0); } catch (_) { setSugg([]); } }, 300); }; return (
{ onChange(e.target.value); fetchSugg(e.target.value); }} onFocus={() => sugg.length && setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)} /> {open && sugg.length > 0 && (
{sugg.map((s, i) => (
{ onPick(s); setOpen(false); setSugg([]); }} style={{ padding: '8px 10px', cursor: 'pointer', fontSize: 13, borderRadius: 6, color: 'var(--ink-secondary)' }} onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-base)'; }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}> {s.name}
))}
)}
); }; // MatrixOctagram — декоративная октаграмма матрицы судьбы (8 точек + центр, // старшие арканы Таро). Для превью в визарде, как NatalChart у натальной — ради // гомогенности и имиджа. Числа иллюстративные (реальные считаются из даты). const MatrixOctagram = ({ size = 260 }) => { 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 = [1, 21, 20, 10, 12, 3, 13, 14]; const poly = (idx) => idx.map(i => pts[i].join(',')).join(' '); return ( {pts.map((p, i) => )} {pts.map((p, i) => ( {nums[i]} ))} 2 ); }; const Wizard = () => { const [step, setStep] = React.useState(0); const [product, setProduct] = React.useState(() => { const q = new URLSearchParams(window.location.search).get('product'); return ['natal', 'forecast', 'compat', 'matrix'].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([]); const [user, setUser] = React.useState(null); const [loading, setLoading] = React.useState(true); const [submitting, setSubmitting] = React.useState(false); const [submitErr, setSubmitErr] = 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: 'female', 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: 'male', saveAsPerson: true }); const setF2 = (k, v) => setForm2(f => ({ ...f, [k]: v })); // Совместимость: подтверждение права предоставить данные второго человека (152-ФЗ — третье лицо). const [partner2Consent, setPartner2Consent] = React.useState(false); // 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, fallbackGender) => { if (!p) return { name: '', dob: '', time: '', place: '', placeLat: 0, placeLng: 0, placeTz: '', gender: fallbackGender || 'female', 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: p.born_time || '', place: p.place || '', placeLat: p.lat || 0, placeLng: p.lon || 0, placeTz: p.tz_name || '', gender: p.gender || fallbackGender || 'female', 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); } })(); }, []); // Цены/названия тянутся с бэка (window.__catalog) с фолбэками. Ключи визарда // (forecast/compat) маппятся на каталожные (year_ahead/compatibility). const PRODUCTS = { natal: { name: catalogTitle('natal', 'Натальная карта'), price: catalogPrice('natal', 1990), desc: 'Глубокий разбор личности', accent: 'gold', planets: ['sun','moon','mercury'] }, forecast: { name: catalogTitle('year_ahead', 'Прогноз на год'), price: catalogPrice('year_ahead', 2990), desc: 'Транзиты и окна возможностей', accent: 'celest', planets: ['jupiter','saturn'] }, compat: { name: catalogTitle('compatibility', 'Совместимость'), price: catalogPrice('compatibility', 2490), desc: 'Синастрия двух карт', accent: 'rose', planets: ['venus','mars'] }, matrix: { name: catalogTitle('matrix', 'Матрица судьбы'), price: catalogPrice('matrix', 1490), desc: 'Разбор по дате рождения', accent: 'sage', planets: ['sun','moon','mercury'] }, }; const next = () => setStep(Math.min(WIZARD_STEPS.length - 1, step + 1)); const prev = () => setStep(Math.max(0, step - 1)); // Поля рождения — переиспользуются для обычной формы и для обоих партнёров совместимости. const renderBirthFields = (f, set, dateOnly = false) => (
{!dateOnly && ( )} {dateOnly ? ( ) : (
)} {!dateOnly && ( )}
); // Префилл форм при переходе на step-2. React.useEffect(() => { if (step !== 2) return; if (product === 'compat') { // Оба партнёра — из выбранных персон (если выбраны), но полностью редактируемы. setForm(personToForm(persons.find(x => x.id === selectedPerson), 'female')); setForm2(personToForm(persons.find(x => x.id === secondPerson), 'male')); return; } if (personMode === 'saved' && selectedPerson) { setForm(personToForm(persons.find(x => x.id === selectedPerson), 'female')); } }, [step, product, personMode, selectedPerson, secondPerson, persons]); // Отправка заказа на бэкенд + переход на оплату. // Для compatibility — обязательны две сохранённые персоны. // Для natal/year_ahead — берём поля из step-2 формы (даже если step-1 picker // выбрал сохранённую персону, эти поля пред-заполнены и user мог их править). const submitOrder = async () => { setSubmitting(true); setSubmitErr(null); try { const service = PRODUCT_TO_SERVICE[product]; const body = { 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; // Best-effort: сохраняем каждого партнёра в персоны, если отмечено. const savePerson = async (f, b) => { if (!f.saveAsPerson) return; try { await fetch('/api/persons', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: b.name, born_date: b.born_date, born_time: b.born_time, tz_name: b.tz_name || '', place: b.place, lat: b.lat || 0, lon: b.lng || 0, relation: '', gender: b.gender }), }); } catch (_) { /* best-effort */ } }; await savePerson(form, b1); await savePerson(form2, b2); } 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('Заполни имя, дату и место рождения'); } // Прогноз на год требует период — по умолчанию ближайшие 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); } // Сохраняем введённые данные как переиспользуемую персону (если попросили). // Best-effort: ошибка сохранения не должна ломать оформление заказа. if (form.saveAsPerson) { try { await fetch('/api/persons', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: body.birth.name, born_date: body.birth.born_date, born_time: body.birth.born_time, tz_name: body.birth.tz_name || '', place: body.birth.place, lat: body.birth.lat || 0, lon: body.birth.lng || 0, relation: '', gender: form.gender, }), }); } catch (_) { /* best-effort */ } } } 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}`); // Запускаем mock-платёж const pr = await fetch('/api/payments/mock/create', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order_id: data.order_id, payment_method: payMethod }), }); const pd = await pr.json(); if (!pr.ok) throw new Error(pd.error || `HTTP ${pr.status}`); window.location.href = pd.payment_url || `/mock-checkout/${data.order_id}`; } catch (e) { console.error('order.submit', e); 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]) => ( ))}
)} {step === 1 && ( <>

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

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

{/* Mode toggle (для совместимости скрыт — оба партнёра редактируются на шаге «Данные») */} {product !== 'compat' && (
{[ { id: 'saved', label: 'Сохранённые персоны' }, { id: 'new', label: 'Новый человек' }, ].map(m => ( ))}
)} {(personMode === 'saved' || product === 'compat') && ( <> {product === 'compat' && (
Партнёр 1
Партнёр 2
)}
{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' && ( )}
)} {personMode === 'new' && product !== 'compat' && (
)} )} {step === 2 && ( <>

Уточни данные

Чем точнее данные — тем глубже разбор. Особенно важно время рождения для домов карты.

{product === 'compat' ? ( <>
Партнёр 1
{renderBirthFields(form, setF)}
Партнёр 2
{renderBirthFields(form2, setF2)}
) : product === 'matrix' ? (
Матрица судьбы считается по 22 арканам Таро только из даты рождения. Время и место не нужны.
{/* Превью октаграммы — гомогенно с натальной картой */}
Превью матрицы
Главный аркан Верховная Жрица
Точки судьбы 8 + центр
Система 22 аркана Таро
) : (
{product === 'forecast' && (() => { const ymd = (d) => `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; const from = new Date(); const to = new Date(from); to.setFullYear(to.getFullYear() + 1); return (
Период прогноза
Ближайшие 12 месяцев · {ymd(from)} — {ymd(to)}
); })()}
Что мы рассчитаем с этими данными
Положения 10 планет, 12 домов, 28+ аспектов, лунные узлы, Хирон, Лилит — через Swiss Ephemeris.
{/* Live preview */}
Превью карты
Солнце Рак 21°
Луна Скорпион
Асцендент Дева
)} )} {step === 3 && ( <>

Оплата

Способ оплаты
{[ { id: 'sbp', label: 'СБП', sub: 'Перевод по QR · быстро и без комиссии', recommended: true }, { id: 'card', label: 'Банковская карта', sub: 'Visa, MC, МИР · через ЮKassa' }, { id: 'wallet', label: 'ЮMoney / SberPay', sub: 'кошельки' }, ].map((m) => { const active = payMethod === m.id; return ( ); })}
PDF придёт на email, с которого ты вошла. Изменить — в кабинете.
{/* Summary */}
Заказ
{PRODUCTS[product].name} {PRODUCTS[product].price} ₽
Для: {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 || 'новая персона'))}
{tip && (
Аудио-комментарий астролога
)}
К оплате {PRODUCTS[product].price + (tip ? 990 : 0)} ₽
{!user && (
)} {submitErr && (
{submitErr}
)}
152-ФЗ · возврат 7 дней
)}
{/* Navigation */} {step < 3 && (
)}
); }; Object.assign(window, { Wizard });