// cabinet.jsx — Personal cabinet screens // ——— SHARED DATA (legacy mocks — kept only as fallback for the dead CabinetReading detail view) ——— const PERSONS = [ { id: 1, name: 'Алина', subname: 'я', date: '14.07.1994', time: '11:32', place: 'Москва', sun: 'cancer', moon: 'scorpio', asc: 'virgo', primary: true }, { id: 2, name: 'Дима', subname: 'партнёр', date: '03.11.1991', time: '06:15', place: 'Санкт-Петербург', sun: 'scorpio', moon: 'taurus', asc: 'sagittarius' }, { id: 3, name: 'Маша', subname: 'мама', date: '22.04.1962', time: '—', place: 'Воронеж', sun: 'taurus', moon: 'pisces', asc: 'leo' }, { id: 4, name: 'Лёва', subname: 'сын', date: '08.03.2019', time: '19:48', place: 'Москва', sun: 'pisces', moon: 'libra', asc: 'cancer' }, { id: 5, name: 'Ева', subname: 'подруга',date: '17.09.1995', time: '14:00', place: 'Тбилиси', sun: 'virgo', moon: 'gemini', asc: 'capricorn' }, ]; const READINGS = [ { id: 1, kind: 'Натальная карта', person: 'Алина', date: '12 мая 2026', status: 'ready', pages: 50, accent: 'gold', planets: ['sun','moon','mercury'] }, { id: 2, kind: 'Совместимость', person: 'Алина × Дима', date: '02 мая 2026', status: 'ready', pages: 42, accent: 'rose', planets: ['venus','mars'] }, { id: 3, kind: 'Прогноз на год', person: 'Алина', date: '18 апр 2026', status: 'ready', pages: 38, accent: 'celest', planets: ['jupiter','saturn'] }, { id: 4, kind: 'Натальная карта', person: 'Лёва', date: '14 мар 2026', status: 'ready', pages: 50, accent: 'gold', planets: ['sun','moon'] }, { id: 5, kind: 'Натальная карта', person: 'Маша', date: '03 фев 2026', status: 'ready', pages: 50, accent: 'gold', planets: ['sun','moon'] }, { id: 6, kind: 'Прогноз на год', person: 'Дима', date: 'генерируется', status: 'pending', pages: 38, accent: 'celest', planets: ['jupiter','saturn'] }, ]; const ZODIAC_RUS = { aries: 'Овен', taurus: 'Телец', gemini: 'Близнецы', cancer: 'Рак', leo: 'Лев', virgo: 'Дева', libra: 'Весы', scorpio: 'Скорпион', sagittarius: 'Стрелец', capricorn: 'Козерог', aquarius: 'Водолей', pisces: 'Рыбы' }; // ——— Shared cabinet data context ——— // Загружается один раз при монтировании Cabinet и расходится во все подстраницы, // чтобы переключение вкладок не дёргало API повторно. const CabinetDataCtx = React.createContext({ user: null, persons: [], orders: [], loading: true, // unreadSupport — сколько веток с непрочитанным ответом. Живёт в шелле, а не // в разделе «Поддержка»: значок нужен как раз тому, кто туда не заходит. unreadSupport: 0, refetchPersons: async () => {}, refetchOrders: async () => {}, refetchSupport: async () => {}, // Фильтр «разборы одной персоны» живёт в шелле, а не в разделе «Разборы»: // его ставит карточка персоны на соседней вкладке, а состояние раздела // умирает при каждом переключении. personFilter: '', openReadingsForPerson: () => {}, clearPersonFilter: () => {}, }); // ——— Шапка кабинета ——— // Своей больше нет: шапка одна на весь сайт (SiteNav из landing.jsx, разметка — // web/landing/partials/nav.html). Здесь она отличалась мелочами — не было // ссылки в кабинет, «Админ» выглядел иначе, — и каждая такая мелочь при // правке общей шапки оставалась позади. const CabinetTopBar = () => { const { user } = React.useContext(CabinetDataCtx); // Пока кабинет не дотянул свои данные, user пуст — и передавать его как // «гость» нельзя: шапка на секунду показывала бы «Войти» и «Регистрация» // человеку, который уже сидит в своём кабинете. undefined значит «не знаю»: // шапка спросит /api/auth/me сама и перерисуется, когда данные придут. return ; }; // ——— Cabinet header / shell ——— // Разделы кабинета — колонкой слева. Лентой под шапкой они уезжали в // горизонтальную прокрутку на телефоне и на узком окне, и «Журнал» с // «Настройками» существовали только для тех, кто догадался её проскроллить. // Кнопка нового разбора остаётся в той же колонке внизу: это главное действие // кабинета, и терять его при переходе между разделами нельзя. const CabinetSideNav = ({ subPage, onSubPage }) => { const { unreadSupport } = React.useContext(CabinetDataCtx); const groups = [ // Первым пунктом и с золотой заливкой, а не кнопкой в углу. Люди приходили // в поддержку с вопросом «а где тут заказать»: кнопка жила в подшапке, // выглядела как элемент оформления и на телефоне уезжала за край вместе с // лентой разделов. // // «Новый разбор», а не «Заказать»: заказывают столик и пиццу, а здесь // человек открывает про себя то, чего не знал. Слово «заказ» оставляем // там, где речь и правда о платеже. { items: [{ id: 'shop', label: 'Новый разбор', cta: true, icon: }] }, { items: [ { id: 'home', label: 'Сегодня' }, { id: 'readings', label: 'Разборы' }, { id: 'persons', label: 'Персоны' }, { id: 'calendar', label: 'Транзиты' }, { id: 'journal', label: 'Журнал' }, { id: 'support', label: 'Поддержка', badge: unreadSupport > 0 ? : null }, { id: 'settings', label: 'Настройки' }, ] }, ]; return ; }; // ——— Витрина кабинета ——— // // Отвечает на три вопроса разом, с которыми люди приходили в поддержку: где // смотреть (пункт меню), что вообще бывает (карточки с ценами) и как (кнопка // открывает визард на выбранном разборе, а не на первом попавшемся). // // Цены и названия — из общего каталога (/api/catalog), как на лендинге и в // визарде: вторая копия цен в кабинете разошлась бы с настоящей на первой же // правке прайса. // Планеты и подпись о составе — те же, что на карточках разборов и в визарде: // человек видит одну и ту же карточку на всём пути от «что бывает» до «вот твой // готовый разбор», и узнаёт её, а не разбирается заново. const CABINET_OFFERS = [ { id: 'natal', key: 'natal', fallbackName: 'Натальная карта', fallbackPrice: 1990, desc: 'Почему одно и то же повторяется — и в какой точке это каждый раз начинается.', shape: '6 глав · PDF и читалка', accent: 'gold', planets: ['sun', 'moon', 'mercury'] }, { id: 'forecast', key: 'year_ahead', fallbackName: 'Прогноз на год', fallbackPrice: 2990, desc: 'Когда действовать, а когда переждать: окна года по твоей карте.', shape: 'календарь года · помесячные окна', accent: 'celest', planets: ['jupiter', 'saturn'] }, { id: 'compat', key: 'compatibility', fallbackName: 'Совместимость', fallbackPrice: 2490, desc: 'Мой ли это человек: как вы совпадаете и обо что спотыкаетесь.', shape: 'синастрия двоих · от притяжения до конфликтов', accent: 'rose', planets: ['venus', 'mars'] }, { id: 'matrix', key: 'matrix', fallbackName: 'Матрица судьбы', fallbackPrice: 1490, desc: 'Предназначение и сильные стороны по дате рождения.', shape: 'октаграмма · от ядра до кармы', accent: 'sage', planets: ['sun', 'moon', 'mercury'] }, ]; const OfferCard = ({ offer }) => { const name = catalogTitle(offer.key, offer.fallbackName); const priceText = catalogPriceText(offer.key, offer.fallbackPrice); return ( ); }; // ——— Cabinet · Today (home) ——— // DiscountNote — «у тебя есть скидка», строкой в карточке разборов. // // Отдельным баннером над карточкой это был второй блок про одно и то же: // сверху сообщение, что разборы сейчас дешевле, сразу под ним — сами разборы с // ценами. Скидка без цен ничего не говорит, цены без скидки не объясняют, почему // они такие; вместе это одна карточка. // // Данные — из /api/catalog (window.__discount), считает их бэк. const discountInfo = () => { const d = window.__discount; if (!d || !d.percent) return null; const until = d.expires_at ? new Date(d.expires_at).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }) : ''; return { percent: d.percent, label: d.label || `Скидка ${d.percent}%`, // «До 7 сентября» важнее самой скидки: постоянная скидка не двигает решение, // а срок двигает. until, note: until ? `до ${until} — дальше цена вернётся к обычной` : 'пока активна подписка', }; }; // ——— «Что ещё скрыто в карте» на сводке ——— // // Короткая полоса вместо полноценной витрины: на «Сегодня» человек пришёл // смотреть свой день, и продавать ему тут во весь экран нечестно. Но знать, // что бывает ещё, он должен без похода по разделам. const ShopTeaser = () => { const discount = discountInfo(); return (
Что ещё скрыто в карте
{discount ? (
{discount.label} · −{discount.percent}% {discount.note}
) : (
Карта уже посчитана — вводить данные заново не придётся
)}
{CABINET_OFFERS.slice(0, 3).map(o => ( ))}
); }; const CabinetShop = () => { const bundles = (typeof catalogBundles === 'function' ? catalogBundles() : []) || []; return (
{/* Не «что заказать»: заказ — это про кассу, а человек приходит сюда с вопросом о себе. Карта у него уже посчитана, и выбирает он не товар, а какую её часть наконец прочитать. */}

Что ещё скрыто в твоей карте

Карта посчитана один раз и целиком — по дате, времени и месту рождения. Дальше вопрос только в том, о чём спросить её сейчас. Данные вводить заново не придётся.

{/* Две колонки, а не auto-fit: разборов четыре, и на обычной ширине auto-fit раскладывал их по три — четвёртый висел один в новой строке. */}
{CABINET_OFFERS.map(o => )}
{bundles.length > 0 && ( <>

Сразу несколько сторон

Одна карта отвечает на несколько вопросов сразу — и вместе это дешевле, чем по одному.

{bundles.map(b => ( ))}
)}
); }; // ——— Shared helpers (orders → reading cards) ——— // SERVICE_META — соответствие service-key → визуальные параметры карточек разбора. const SERVICE_META = { natal: { kind: 'Натальная карта', accent: 'gold', planets: ['sun','moon','mercury'], pages: 50 }, year_ahead: { kind: 'Прогноз на год', accent: 'celest', planets: ['jupiter','saturn'], pages: 38 }, compatibility: { kind: 'Совместимость', accent: 'rose', planets: ['venus','mars'], pages: 42 }, matrix: { kind: 'Матрица судьбы', accent: 'sage', planets: ['sun','moon','mercury'], pages: 17 }, personal_forecast: { kind: 'Индивидуальный гороскоп', accent: 'celest', planets: ['moon','mercury'], pages: 12 }, }; const fmtOrderDate = (iso) => { if (!iso) return '—'; try { return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' }); } catch (_) { return '—'; } }; // nameKey — ключ, по которому разборы одного человека сходятся в одну группу. // // Группируем по имени, а не по person_id, потому что person_id есть у // меньшинства заказов: его проставляют только те, что оформлены из сохранённой // персоны. У живого аккаунта это оказалось 3 разбора из 25 — фильтр по id // предлагал выбрать одного человека, хотя заказов было на десяток разных. // // Плата за это — полные тёзки сольются в одну группу. Против «фильтра, который // не видит 22 разбора из 25» это несопоставимо меньшая беда. const nameKey = (s) => (s || '').trim().toLowerCase().replace(/\s+/g, ' '); const datePart = (d) => (d || '').slice(0, 10); // personKey — ключ, по которому разборы одного человека сходятся в одну группу: // имя И дата рождения. // // Одного имени мало. Разбор — это данные рождения конкретного человека, и два // тёзки с разными датами склеились бы в одного молча; в списке «его» разборов // оказался бы чужой. Дата разводит их надёжно: совпадение имени и дня рождения // внутри одного аккаунта — это уже почти наверняка один человек. // // Группируем в пределах одного аккаунта: и заказы, и персоны кабинет получает // только свои (/api/orders и /api/persons отдают строки текущего юзера), // поэтому чужие персоны сюда не попадают в принципе. // // Даты может не быть в совсем старых заказах — такая группа остаётся отдельной // и подписывается «без даты», а не приклеивается к одноимённой с датой. const personKey = (name, born) => `${nameKey(name)}|${datePart(born) || '?'}`; // Участники разбора: сохранённая персона, если заказ к ней привязан, иначе имя // и дата из самого заказа. Форм две — birth у обычного разбора и // partner1/partner2 у совместимости, — но сюда они приходят уже разобранными // бэкендом. // // linked отличает «заказ действительно привязан к этой персоне» от «совпали имя // и дата». Разница видна человеку: сопоставление по совпадению мы показываем // прямо в интерфейсе, а не выдаём за связь, которой нет. const readingParticipants = (o, personsById) => { const out = []; const add = (id, rawName, rawBorn) => { const saved = id ? personsById[id] : null; // У привязанного заказа берём имя и дату из самой персоны: карточка персоны // подписана ими же, и расхождение развело бы заказ с его собственной // карточкой (персону могли отредактировать после заказа). const name = (saved && saved.name) || rawName || ''; const born = (saved && saved.born_date) || rawBorn || ''; if (!name) return; const key = personKey(name, born); const seen = out.find(p => p.key === key); if (seen) { if (id) { seen.id = id; seen.linked = true; } return; } out.push({ id: id || null, name, born: datePart(born), key, linked: !!id }); }; add(o.person_id, o.person_name, o.person_born_date); add(o.person2_id, o.person2_name, o.person2_born_date); return out; }; // matchedByName — сколько разборов попало в группу по совпадению имени и даты, // а не по привязке к сохранённой персоне. Ноль — группа собрана только из // привязанных заказов, предупреждать не о чем. const matchedByName = (readings, key) => (readings || []).filter(r => { const p = (r.participants || []).find(x => x.key === key); return p && !p.linked; }).length; const orderToReading = (o, personsById) => { const meta = SERVICE_META[o.service] || SERVICE_META.natal; const participants = readingParticipants(o, personsById); // Совместимость подписываем обоими именами. Раньше ветка с «×» стояла // последней и была недостижима: имя первой персоны находилось раньше и // обрывало цепочку — разбор «Марина × Аня» показывался как «Марина». const person = (o.service === 'compatibility' && participants.length === 2 ? `${participants[0].name} × ${participants[1].name}` : (participants[0] && participants[0].name)) || 'Разбор'; return { id: o.id, kind: meta.kind, person, date: o.status === 'ready' || o.status === 'pending' ? (o.status === 'pending' || o.status === 'generating' ? 'генерируется' : fmtOrderDate(o.ready_at || o.created_at)) : fmtOrderDate(o.created_at), status: (o.status === 'pending' || o.status === 'generating') ? 'pending' : o.status, pages: meta.pages, accent: meta.accent, planets: meta.planets, pdf_url: o.pdf_url, // Участники — основа и для фильтра, и для счётчика на карточке персоны. participants, person_id: o.person_id || null, person2_id: o.person2_id || null, created_at: o.created_at || null, }; }; // Разбор относится к человеку, если тот в нём участвует — первым или вторым. // Совместимость принадлежит обоим: у каждого из двоих это один и тот же его // разбор, и прятать его у второго участника незачем. const readingBelongsTo = (r, key) => !!key && (r.participants || []).some(p => p.key === key); // Helpers for person rendering — backend отдаёт born_date в ISO, время "HH:MM". const isoToDot = (iso) => { if (!iso || iso.length < 10) return ''; return `${iso.slice(8,10)}.${iso.slice(5,7)}.${iso.slice(0,4)}`; }; // DailyForecast — ежедневный персональный прогноз в кабинете. // // Один и тот же текст, что уходит в мессенджер и на почту: считает его один // PersonalDaily с общим кэшем. Иначе «мне прислали не то, что на сайте». // // Не подписчику показываем предложение, а не ошибку: согласие на регулярное // списание — отдельная явная галочка, а не мелкий шрифт (astro-r9o.7). // dailyExcerpt — первые строки прогноза для тизера на главной. Текст приходит // готовым HTML, а в карточку-сводку нужен голый абзац: теги вырезаем здесь, // а не прячем переполнение — обрезанный по высоте

всё равно тянет за собой // всю разметку и ломает высоту сетки. const dailyExcerpt = (html, limit = 190) => { if (!html) return ''; const text = String(html).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); if (text.length <= limit) return text; const cut = text.slice(0, limit); const stop = cut.lastIndexOf(' '); return (stop > 60 ? cut.slice(0, stop) : cut) + '…'; }; const DailyForecast = ({ compact = false }) => { const { persons } = React.useContext(CabinetDataCtx); // compact — карточка-тизер на «Сегодня»; разворачивается по кнопке в тот же // полный блок, что живёт в разделе. const [expanded, setExpanded] = React.useState(false); const [sub, setSub] = React.useState(null); const [daily, setDaily] = React.useState(null); const [consent, setConsent] = React.useState(false); const [personId, setPersonId] = React.useState(''); // Кому оформляем: себе или другому человеку (мать ребёнку, подруга подруге). const [forOther, setForOther] = React.useState(false); const [recipientEmail, setRecipientEmail] = React.useState(''); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(''); const [copied, setCopied] = React.useState(false); // Пояс и час доставки. Пока /api/subscriptions/me не ответил — пусто, иначе // селект успел бы показать московское утро и подменить им реальный выбор. const [tz, setTZ] = React.useState(''); const [hour, setHour] = React.useState(9); const [cardErr, setCardErr] = React.useState(''); // Считать можно только по персоне с датой рождения — остальные в выборе // не нужны: подписка на них всё равно не оформится. const usable = (persons || []).filter(p => p.born_date); React.useEffect(() => { if (!personId && usable.length) setPersonId(usable[0].id); }, [usable.length]); // Два запроса отдельно, а не Promise.all: статус подписки приходит мгновенно, // а текст прогноза может считаться. Связав их, карточка не появлялась бы, // пока не досчитается прогноз. const load = React.useCallback(async () => { try { const r = await fetch('/api/subscriptions/me', { credentials: 'same-origin' }); const d = r.ok ? await r.json() : { active: false }; setSub(d); if (d.tz) { // Подписки ещё нет и пояс стоит по умолчанию — значит человек его не // выбирал, и правильнее показать тот, в котором он сейчас живёт. // У подписчика ничего не трогаем: его выбор мы уже знаем. const guess = browserTZ(); setTZ(!d.active && d.tz === 'Europe/Moscow' && guess ? guess : d.tz); } if (typeof d.daily_hour === 'number') setHour(d.daily_hour); } catch (e) { setSub({ active: false }); } try { const r = await fetch('/api/daily', { credentials: 'same-origin' }); setDaily(r.ok ? await r.json() : { entitled: false }); } catch (e) { setDaily({ entitled: false }); } }, []); React.useEffect(() => { load(); }, [load]); const subscribe = async () => { setErr(''); setBusy(true); try { const r = await fetch('/api/subscriptions', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ consent_recurring: true, person_id: personId, for_other: forOther, recipient_email: forOther ? recipientEmail.trim() : '', // Подписка другому: час доставки задаёт тот, кто читает, а не тот, // кто платит, — бэкенд эти поля для неё и не примет. tz: forOther ? '' : tz, daily_hour: forOther ? undefined : hour, }), }); const d = await r.json(); if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status); // На страницу банка сами не уводим. Кнопка называется «Попробовать 7 дней // бесплатно», а что именно показывает банк по ссылке привязки — форму // карты или сразу оплату на месяц — не проверено (см. astro-ie0.6). // Пока не проверено, человек переходит туда сам, из карточки подписки, // прочитав, за что и когда с него спишут. await load(); } catch (e) { setErr('Не получилось оформить. Попробуй ещё раз.'); console.error('subscribe', e); } finally { setBusy(false); } }; // Смена времени доставки у действующей подписки. Сохраняем сразу: отдельная // кнопка «применить» на двух селектах только добавляет способ забыть нажать. const saveDelivery = async (next) => { const prevTZ = tz, prevHour = hour; if (next.tz !== undefined) setTZ(next.tz); if (next.hour !== undefined) setHour(next.hour); try { const r = await fetch('/api/account/delivery', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tz: next.tz !== undefined ? next.tz : tz, daily_hour: next.hour !== undefined ? next.hour : hour, }), }); if (!r.ok) throw new Error('HTTP ' + r.status); const d = await r.json(); setTZ(d.tz); setHour(d.daily_hour); } catch (e) { // Откатываем показанное: висящий на экране выбор, которого нет на // сервере, — худший исход, человек будет ждать письмо не в тот час. setTZ(prevTZ); setHour(prevHour); console.error('delivery prefs', e); } }; // Переход к привязке карты. // // Ссылку берём в момент нажатия, а не заранее: у банка она живёт неделю, и // показанная на странице час назад может уже не работать. Бэкенд отдаёт // сохранённую, пока та жива, и выпускает новую, когда протухла. const attachCard = async () => { setCardErr(''); setBusy(true); try { const r = await fetch('/api/subscriptions/payment-link', { method: 'POST', credentials: 'same-origin', }); const d = await r.json(); if (!r.ok || !d.payment_link) throw new Error(d.error || 'HTTP ' + r.status); window.location.href = d.payment_link; } catch (e) { setCardErr('Банк не ответил. Попробуй ещё раз через минуту.'); console.error('attach card', e); setBusy(false); } }; const changePerson = async (id) => { setPersonId(id); setBusy(true); try { await fetch('/api/subscriptions/person', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ person_id: id }), }); await load(); } catch (e) { console.error('set person', e); } finally { setBusy(false); } }; const detachRecipient = async () => { setBusy(true); try { await fetch('/api/subscriptions/recipient', { method: 'DELETE', credentials: 'same-origin' }); await load(); } catch (e) { console.error('detach', e); } finally { setBusy(false); } }; const copyInvite = async (url) => { try { await navigator.clipboard.writeText(url); setCopied(true); setTimeout(() => setCopied(false), 1600); } catch (e) { console.warn('clipboard', e); } }; // Отмена — в один клик, но с подтверждением: кнопка стоит рядом с датой // окончания, и промахнуться по ней легко. Спросить один раз дешевле, чем // объяснять потом, почему прогноз пропал. Это не «отмена в два шага»: // переписки с поддержкой по-прежнему нет. const cancel = async () => { const until = fmtDate(sub && sub.until); const ok = await astro.confirm({ title: 'Отменить подписку?', body: until ? `Прогноз будет приходить до ${until} — период уже оплачен. Дальше списаний не будет. Гороскоп по знаку останется бесплатным.` : 'Дальше списаний не будет. Гороскоп по знаку останется бесплатным.', okText: 'Отменить подписку', cancelText: 'Оставить', danger: true, }); if (!ok) return; setBusy(true); try { await fetch('/api/subscriptions/cancel', { method: 'POST', credentials: 'same-origin' }); await load(); } catch (e) { console.error('cancel', e); } finally { setBusy(false); } }; if (!sub) return null; const fmtDate = (iso) => iso ? new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }) : ''; // Подписку оплатил кто-то другой. Своей у человека при этом может не быть // вовсе, поэтому блок живёт до ветки «активна / предложение». const received = sub.received; const ReceivedNote = () => received ? (

подписку оплатил другой человек · до {fmtDate(received.until)}
) : null; // Развёрнутый блок на главной занимает обе колонки сводки: форма подписки // с парой «себе/другому», поясом и часом доставки в половине ширины ужимается // до нечитаемого. const fullCls = 'card' + (compact ? ' cab-tile-wide' : ''); const fullStyle = { padding: '32px', marginBottom: compact ? 0 : '32px' }; // ——— короткая карточка для «Сегодня» ——— // Главная кабинета — сводка, а не форма. Оформление подписки с выбором // персоны, часового пояса, часа доставки и галочкой согласия занимало // пол-экрана у человека, который зашёл прочитать сегодняшний прогноз. // Здесь — тизер; по кнопке разворачивается ровно тот же полный блок. if (compact && !expanded) { const entitled = sub.active || !!received; const ready = daily && daily.ready; return (
Прогноз на сегодня
{entitled && sub.trial && ( пробный период )}
{entitled ? ( ready ? ( <>
{daily.title}

{dailyExcerpt(daily.html)}

) : (

Прогноз на сегодня ещё готовится — загляни через несколько минут или дождись письма.

) ) : ( <>
Каждое утро — про твой день

По твоей натальной карте, а не по знаку зодиака. Первые {sub.trial_days} дней бесплатно, дальше {sub.price_rub} ₽ в месяц.

)}
); } // ——— подписки нет своей, но её оплатил кто-то другой ——— if (!sub.active && received) { return (
Ежедневный прогноз
{daily && daily.ready ? ( <>
{daily.title}
) : (

Прогноз на сегодня ещё готовится — загляни через несколько минут.

)}
); } // ——— подписки нет: предложение ——— if (!sub.active) { return (
Ежедневный прогноз
Каждое утро — про твой день

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

{[ { id: false, title: 'Себе', sub: 'Прогноз буду получать я' }, { id: true, title: 'Другому человеку', sub: 'Пришлём ссылку — он подтвердит' }, ].map(o => ( ))}
{forOther && ( )} {usable.length === 0 ? (
Сначала добавь персону с датой рождения
Прогноз считается по натальной карте — без даты, времени и места рождения считать не из чего.
) : (
{forOther ? 'Чья карта — персона получателя' : 'По кому считать'} setConsent(e.target.checked)} style={{ marginTop: 4 }} /> Первые {sub.trial_days} дней бесплатно, дальше {sub.price_rub} ₽ в месяц. Согласен на регулярное списание. Отменить можно в один клик, здесь же.
Спишем {sub.price_rub} ₽ только после пробных дней
{err &&
{err}
}
); } // ——— подписка есть: сам прогноз ——— return (
Ежедневный прогноз
{sub.status === 'past_due' ? списание не прошло : sub.trial ? пробный период до {fmtDate(sub.until)} : активна до {fmtDate(sub.until)}}
{/* Списание не прошло. Без этого блока кабинет показывал «активна до» с датой в прошлом, а человек узнавал о проблеме с картой в день, когда прогноз переставал приходить. */} {sub.status === 'past_due' && (
Не получилось списать оплату
Банк отказал в списании {sub.price_rub} ₽ — обычно это лимит по карте или временная блокировка. Прогноз пока приходит: мы пробуем ещё несколько дней. Обнови карту, чтобы он не прервался.
{cardErr &&
{cardErr}
}
)} {sub.card_pending && (
Привяжи карту, чтобы подписка не кончилась
Пробные дни идут бесплатно. Спишем {sub.price_rub} ₽ только после них — и только если карта привязана, иначе подписка просто закончится. Уже привязал? Тогда ничего делать не нужно.
{cardErr &&
{cardErr}
}
)} {sub.for_other && !sub.recipient_accepted && sub.invite_url && (
Передай ссылку получателю
По ней он подтвердит аккаунт и начнёт получать прогноз. Пока не подтвердит — прогноз не приходит никому.
{sub.invite_url}
)} {sub.for_other && sub.recipient_accepted && (
прогноз получает {sub.recipient_email || 'приглашённый человек'}
)} {usable.length > 0 && !sub.for_other && (
считаем по
toggleMail(e.target.checked)} /> {mail.enabled ? 'присылаем' : 'выключено'} )}
)} {rows.map(p => (
{p.title || p.platform} {!p.linked && ( не подключён )} {p.linked && !p.paused && ( подключён )} {p.linked && p.paused && ( подключён, но рассылка на паузе )}
{p.linked && p.paused && ( )} {p.linked ? ( ) : ( )}
{p.linked && p.paused && (
Утренний прогноз сюда не приходит. Так бывает после «Отписаться» в боте или если одно сообщение не удалось доставить — например, диалог был очищен или бот заблокирован. Нажми «Возобновить», и рассылка вернётся со следующего утра.
)}
))}
); }; // ——— Плитки главной кабинета ——— // Три сущности — транзиты, разборы, персоны — на «Сегодня» показываются // короткими списками: сколько их, три верхних строки и ссылка в раздел. // Подробности (карточки с глифами, календарь, формы) живут в разделах, куда // эти ссылки и ведут. // TransitTeaser — ближайшие транзиты по карте персоны. // // До этого здесь стоял захардкоженный в вёрстке список: «тригон Юпитера к // Солнцу, орб 2°15', до 24 мая» — выдуманные аспекты, не посчитанные ни по // чьей карте, с кнопкой «обновить», которая ничего не делала. Плюс ни слова о // том, что это значит. Теперь берём то же, что показывает раздел «Транзиты» // (/api/transits — Swiss Ephemeris), вместе с человеческим объяснением. const TransitTeaser = () => { const { persons, loading } = React.useContext(CabinetDataCtx); const [state, setState] = React.useState({ status: 'loading', events: [] }); const person = React.useMemo(() => { const withBirth = (persons || []).filter(p => p.has_birth || p.born_date); return withBirth.find(p => p.is_default) || withBirth[0] || null; }, [persons]); React.useEffect(() => { if (!person) { if (!loading) setState({ status: 'no_person', events: [] }); return; } let alive = true; setState({ status: 'loading', events: [] }); fetch(`/api/transits?person_id=${encodeURIComponent(person.id)}&month=${monthKey(new Date())}`, { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))) .then(j => { if (!alive) return; const today = j.today || ''; const all = j.events || []; // Впереди — то, что ещё не прошло. Если месяц на исходе и впереди // пусто, показываем последние события месяца, а не пустую карточку. const ahead = all.filter(e => !today || e.date >= today); setState({ status: 'ok', events: (ahead.length ? ahead : all).slice(0, 2) }); }) .catch(() => { if (alive) setState({ status: 'error', events: [] }); }); return () => { alive = false; }; }, [person ? person.id : '', loading]); return (
Транзиты
{person && по карте {person.name}}
{state.status === 'loading' && (
Считаем по твоей карте…
)} {state.status === 'no_person' && ( <>

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

)} {state.status === 'error' && (

Не удалось посчитать транзиты. Загляни в раздел — там можно выбрать месяц и персону.

)} {state.status === 'ok' && ( state.events.length === 0 ? (

В этом месяце крупных событий по твоей карте нет — спокойный фон.

) : (
{state.events.map((e, i) => { const tone = TRANSIT_TONES[e.tone] || TRANSIT_TONES.info; return (
{e.title} {fmtDayMonth(e.date)}

{e.meaning}

); })}
) )} {(state.status === 'ok') && ( )}
); }; // ReadingsTeaser — последние разборы строками. // // Три карточки во всю ширину с глифами планет и числом страниц уезжают в // раздел «Разборы»: на сводке от них нужно только «что это и открыть». const ReadingsTeaser = ({ readings, loading }) => (
Разборы
{readings.length > 0 && ( {readings.length} )}
{loading ? (
Загружаем твои разборы…
) : readings.length === 0 ? ( <>

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

) : ( <>
{readings.slice(0, 3).map(r => ( ))}
)}
); // PersonsTeaser — сохранённые персоны, они же покупка в два клика. // // Отдельный блок «Купить в 2 клика» на три карточки был ровно этим же списком, // только крупнее: строка ведёт в визард с уже подставленной персоной. const PersonsTeaser = ({ persons }) => (
Персоны
{persons.length > 0 && ( {persons.length} )}
{persons.length === 0 ? ( <>

Персона — это чьи-то дата, время и место рождения. По ней считаются разборы, транзиты и утренний прогноз; вводить их каждый раз заново не придётся.

) : ( <>
нажми на персону — разбор за два клика, {catalogPriceText('natal', 1990)}
{persons.slice(0, 3).map(p => { const dot = isoToDot(p.born_date); return ( ); })}
)}
); const CabinetHome = () => { const { user, persons, orders, loading } = React.useContext(CabinetDataCtx); const personsById = React.useMemo(() => Object.fromEntries(persons.map(p => [p.id, p])), [persons]); const readings = React.useMemo(() => orders.map(o => orderToReading(o, personsById)), [orders, personsById]); const firstName = (user?.name || user?.email || '').split(/[\s@]/)[0] || 'друг'; const today = new Date().toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', weekday: 'long' }); return (
{/* Приветствие — первым и одной строкой. Раньше на его месте стояла карточка в пол-экрана: натальная карта фоном, чип «Личный кабинет», дата, абзац про количество разборов и почта. Места занимала больше всех на странице, а сообщала меньше всех. */}
Привет, {firstName}.
{today}
{/* Что можно заказать — на первом экране, а не только в разделе. Человек с уже купленным разбором не видел в кабинете ни одного места, где написано, что бывает ещё: карточка «Создать первый» показывалась только пустому кабинету, а дальше заказ жил кнопкой в углу. Отсюда и «теряемся, где и что заказать» в поддержке. */} {/* Дальше — сводка: четыре плитки с короткими списками, каждая уводит в свой раздел за подробностями. До этого все четыре сущности раскрывались прямо здесь целиком, и «Сегодня» приходилось пролистывать, чтобы понять, что вообще происходит. */}
{/* Просьба оценить разбор — под сводкой, а не над ней: она появляется редко и не должна быть первым, что видит вернувшийся человек. */} {/* Приглашение — последним: оно про будущее, а не про сегодня. */}
); }; // ——— Приглашение подруги ——— // // Обе стороны получают одинаково, и об этом сказано прямо. Программа, где // пригласивший получает больше приглашённого, превращает рекомендацию в // продажу — а рекомендуют только то, за что не стыдно. const ReferralCard = () => { const [data, setData] = React.useState(null); const [copied, setCopied] = React.useState(false); React.useEffect(() => { let alive = true; fetch('/api/referral', { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : null) .then(d => { if (alive && d && d.code) setData(d); }) .catch(() => {}); return () => { alive = false; }; }, []); if (!data) return null; const copy = async () => { try { await navigator.clipboard.writeText(data.url); setCopied(true); setTimeout(() => setCopied(false), 1600); } catch (e) { console.warn('clipboard', e); } }; return (
Приглашение
Позови того, кому это нужнее
По твоей ссылке первый разбор будет на {data.percent}% дешевле. Когда он его получит, тебе придёт скидка {data.bonus_percent}% на следующий.
{data.invited > 0 && (
{data.invited}
{data.invited === 1 ? 'приглашённый купил' : 'приглашённых купили'}
)}
e.target.select()} style={{ flex: 1, minWidth: '240px', fontSize: '12px' }} />
); }; // ——— Reading card ——— const ReadingCard = ({ r, onClick }) => ( ); // ——— Cabinet · Readings list ——— const CabinetReadings = () => { const { persons, orders, loading, personFilter, openReadingsForPerson, clearPersonFilter } = React.useContext(CabinetDataCtx); const [filter, setFilter] = React.useState('all'); const personsById = React.useMemo(() => Object.fromEntries(persons.map(p => [p.id, p])), [persons]); const readings = React.useMemo(() => orders.map(o => orderToReading(o, personsById)), [orders, personsById]); // Фильтр по персоне применяем ПЕРВЫМ: счётчики на вкладках типов должны // показывать, сколько натальных у выбранной персоны, а не сколько их всего. // Иначе чип обещает восемь, а список отдаёт один. const byPerson = React.useMemo( () => (personFilter ? readings.filter(r => readingBelongsTo(r, personFilter)) : readings), [readings, personFilter] ); const tabs = [ { id: 'all', label: 'Все', count: byPerson.length }, { id: 'natal', label: 'Натальные карты', count: byPerson.filter(r => r.kind === 'Натальная карта').length }, { id: 'forecast', label: 'Прогнозы', count: byPerson.filter(r => r.kind === 'Прогноз на год').length }, { id: 'compat', label: 'Совместимость', count: byPerson.filter(r => r.kind === 'Совместимость').length }, ]; const filtered = filter === 'all' ? byPerson : byPerson.filter(r => { if (filter === 'natal') return r.kind === 'Натальная карта'; if (filter === 'forecast') return r.kind === 'Прогноз на год'; return r.kind === 'Совместимость'; }); // Список для выбора строим из самих разборов, а не из сохранённых персон. // Причина — в nameKey: привязка заказа к персоне есть у меньшинства, и список // «кого можно выбрать» из persons предлагал одного человека там, где заказов // было на десяток разных. Заодно отсюда исчезают сохранённые персоны, по // которым не заказывали ни разу: выбирать их нечем. const personOptions = React.useMemo(() => { const buckets = new Map(); readings.forEach(r => (r.participants || []).forEach(p => { const b = buckets.get(p.key); if (b) { b.count += 1; if (p.id) b.id = p.id; } else { buckets.set(p.key, { value: p.key, label: p.name, born: p.born, id: p.id, count: 1 }); } })); const all = [...buckets.values()] .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'ru')); // Полные тёзки с разными датами — разные группы, и в списке они обязаны // различаться. Дату дописываем только там, где имя повторяется: у остальных // это лишний шум. const seen = {}; all.forEach(b => { seen[nameKey(b.label)] = (seen[nameKey(b.label)] || 0) + 1; }); return all.map(b => ({ value: b.value, label: seen[nameKey(b.label)] > 1 ? `${b.label} · ${b.born ? isoToDot(b.born) : 'без даты'}` : b.label, hint: `${b.count}`, })); }, [readings]); const selectedPerson = React.useMemo( () => (personFilter ? personOptions.find(o => o.value === personFilter) || null : null), [personFilter, personOptions] ); const guessedInGroup = React.useMemo( () => (personFilter ? matchedByName(byPerson, personFilter) : 0), [byPerson, personFilter] ); // Разборы по этой персоне могли исчезнуть (например, единственный отменили), // и фильтр остался бы включённым, но невидимым: показывать в заголовке нечего, // а список пуст без объяснений. Снимаем сами. React.useEffect(() => { if (personFilter && !loading && !personOptions.some(o => o.value === personFilter)) clearPersonFilter(); }, [personFilter, personOptions, loading, clearPersonFilter]); return (
{selectedPerson ? `${byPerson.length} из ${readings.length} · ${selectedPerson.label}` : `${readings.length} разборов`}
{/* Через точку-разделитель, а не «Разборы Ани»: имена в русском склоняются, а надёжно склонять их из JS нечем — «Разборы Аня» читается как ошибка, которой в продукте за 1990 ₽ быть не должно. Разделитель уже используется в этом же интерфейсе («Разборы · 6»). */}

{selectedPerson ? <>Разборы · {selectedPerson.label} : <>Мои разборы}

{/* Фильтр по персоне. Раньше на этом месте стояла кнопка с иконкой фильтра, которая ничего не делала. Список с поиском, а не чипы: персон бывает три десятка, и строкой чипов они не помещаются. */}
{personOptions.length > 0 && ( setFirstName(e.target.value)} autoComplete="given-name" />
{/* .field на
, а не на
)} {/* Не «Сегодня»: так называется первая вкладка кабинета, две одинаковые кнопки на одном экране читаются как одно и то же действие. */}
{/* Что это вообще такое — без этого страница читается как набор символов */}
Как это читать

Планеты продолжают двигаться и после твоего рождения. Транзит — момент, когда планета на небе встаёт под значимым углом к точке твоей натальной карты. Это не предсказание событий: это про фон — какие темы в эти дни идут легче, а какие требуют внимания. Даты считаются эфемеридами, не выдумываются.

{['ease', 'edge', 'key', 'info'].map(k => ( {TRANSIT_TONES[k].label} ))}
{/* Разбор: даты на странице посчитаны, здесь они превращаются в текст */}
{picked ? `Разбор дня · ${fmtDayMonth(picked)}` : 'Разбор месяца'}

{picked ? 'Что из событий этого дня складывается вместе — и что с этим делать.' : 'Те же события, но связным текстом: какие темы у месяца главные, где окна, где трение.'}

{free && !free.subscriber && free.total > 0 && (

Первые {free.total} разбора — бесплатно, по одному на карту рождения. Дальше разбор любого месяца входит в Ежедневный прогноз.

)}
{!(interp && interp.status === 'ready') && (
{freeHint && (
{freeHint}
)}
)}
{interp && interp.status === 'ready' && (
{interp.title}
)} {interp && interp.status === 'generating' && (
{interp.title || 'Разбор'} — в работе
Считается минуту-другую. Страницу можно закрыть или обновить: разбор записан за тобой и появится здесь сам, второй раз ничего заказывать не нужно.
)} {interp && interp.status === 'slow' && (
Разбор всё ещё считается — он записан за тобой, загляни сюда через несколько минут.
)} {interp && interp.status === 'failed' && (
)} {interp && (interp.status === 'locked' || interp.status === 'no_birth') && (
{interp.upsell && ( )}
)} {interp && interp.status === 'error' && (
Не удалось получить разбор. Попробуй ещё раз.
)}
{data && data.note && (
{data.note}
)} {error && (
{error}
)}
{['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(d => (
{d}
))}
{monthGrid(month).map((cell, i) => { if (!cell) return
; const dayEvents = byDate[cell.iso] || []; const isToday = cell.iso === today; const isPicked = cell.iso === picked; const tones = []; dayEvents.forEach(e => { if (tones.indexOf(e.tone) < 0) tones.push(e.tone); }); return ( ); })}
{data && data.background && data.background.length > 0 && (
Фон месяца
{data.background.map((b, i) => (
{b}
))}

Медленные планеты стоят в этих домах месяцами — это декорация, на которой происходят события выше.

)}
{picked ? fmtDayMonth(picked) : 'Ближайшие окна'}
{picked && ( )}
{loading &&
Считаем транзиты…
} {!loading && upcoming.length === 0 && (
{picked ? 'В этот день значимых транзитов нет — обычный фоновый день.' : 'В этом месяце значимых событий не нашлось.'}
)} {!loading && upcoming.map((e, i) => )}
); }; // ——— Cabinet · Journal ——— // Бэкенда для записей журнала пока нет — данные демонстрационные. const CabinetJournal = () => (
23 записи

Журнал самонаблюдений

Отслеживай состояние и события — алгоритм сопоставит их с транзитами.

Журнал сейчас демо — сохранение и сопоставление с транзитами появятся на следующей итерации.
{/* Mood input */}
Как ты сегодня?
{[ { label: 'тяжело', color: 'rose' }, { label: 'устало', color: 'rose' }, { label: 'ровно', color: 'celest' }, { label: 'хорошо', color: 'sage' }, { label: 'на подъёме', color: 'gold' }, ].map((m, i) => ( ))}