// 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 (
{/* Не «что заказать»: заказ — это про кассу, а человек приходит сюда с
вопросом о себе. Карта у него уже посчитана, и выбирает он не товар,
а какую её часть наконец прочитать. */}
Что ещё скрыто в твоей карте
Карта посчитана один раз и целиком — по дате, времени и месту рождения. Дальше вопрос
только в том, о чём спросить её сейчас. Данные вводить заново не придётся.
{/* Две колонки, а не 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 ? (
Сначала добавь персону с датой рождения
Прогноз считается по натальной карте — без даты, времени и места рождения считать не из чего.
{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} ₽ только после них — и только если карта привязана,
иначе подписка просто закончится. Уже привязал? Тогда ничего делать не нужно.
По ней он подтвердит аккаунт и начнёт получать прогноз. Пока не подтвердит — прогноз не приходит никому.
{sub.invite_url}
)}
{sub.for_other && sub.recipient_accepted && (
прогноз получает {sub.recipient_email || 'приглашённый человек'}
)}
{usable.length > 0 && !sub.for_other && (
считаем по
{!sub.person_id && (
персона удалена — выбери, по кому считать
)}
)}
{!sub.for_other && (
saveDelivery({ tz: v })} onHour={v => saveDelivery({ hour: v })}
note="Новое время начнёт действовать со следующей доставки. Сегодняшний прогноз, если он уже ушёл, второй раз не придёт." />
)}
{daily && daily.ready ? (
<>
{daily.title}
>
) : (
Прогноз на сегодня ещё готовится. Загляни через несколько минут — или дождись письма.
{' '}Если данных рождения нет, добавь персону во вкладке «Персоны».
)}
);
};
// MessengerLinks — привязка мессенджеров к аккаунту.
//
// Бэкенд-флоу существовал давно (одноразовый код + deep-link + fan-out
// уведомлений), но кнопка к нему жила только на легаси-странице account.html,
// которую роутер не отдаёт: /account — это SPA. То есть привязаться было
// технически можно и практически неоткуда.
//
// Показываем только те мессенджеры, боты которых подняты на сервере: кнопка,
// ведущая к боту без токена, хуже отсутствующей — человек решит, что сломано.
const MessengerLinks = () => {
const [rows, setRows] = React.useState(null);
const [mail, setMail] = React.useState(null);
const [busy, setBusy] = React.useState('');
const load = React.useCallback(async () => {
try {
const [br, cr] = await Promise.all([
fetch('/api/account/bot-link', { credentials: 'same-origin' }),
fetch('/api/account/channels', { credentials: 'same-origin' }),
]);
setRows(br.ok ? ((await br.json()).platforms || []) : []);
if (cr.ok) {
const d = await cr.json();
setMail((d.channels || []).find(c => c.kind === 'email') || null);
}
} catch (e) { setRows([]); }
}, []);
// Почта — отдельная ручка: мессенджер это привязка диалога, а почта просто
// выключатель на уже известном адресе.
const toggleMail = async (enabled) => {
setBusy('email');
setMail(m => m ? { ...m, enabled } : m); // оптимистично: галочка не должна залипать
try {
await fetch('/api/account/channels', {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'email', enabled }),
});
} catch (e) { console.error('channel', e); }
finally { setBusy(''); await load(); }
};
React.useEffect(() => { load(); }, [load]);
const connect = async (platform) => {
setBusy(platform);
try {
const r = await fetch('/api/account/bot-link/start?platform=' + encodeURIComponent(platform), {
method: 'POST', credentials: 'same-origin',
});
const d = await r.json();
const url = (d.links || {})[platform] || d.deep_link;
if (!url) throw new Error('нет ссылки');
// Открываем сразу: код живёт 15 минут, и лишний экран между кликом и
// ботом — лишний шанс до бота не дойти.
window.open(url, '_blank', 'noopener');
} catch (e) {
console.error('bot-link.start', e);
} finally {
setBusy('');
}
};
const disconnect = async (platform) => {
setBusy(platform);
try {
await fetch('/api/account/bot-link?platform=' + encodeURIComponent(platform), {
method: 'DELETE', credentials: 'same-origin',
});
await load();
} catch (e) {
console.error('bot-link.unlink', e);
} finally {
setBusy('');
}
};
// Пауза — не то же самое, что «не подключён»: диалог привязан, но рассылка
// в него не идёт. Встать она могла и сама, от одной недоставки, поэтому
// человеку нужна кнопка, а не только надпись.
const resume = async (platform) => {
setBusy(platform);
try {
await fetch('/api/account/bot-link/resume?platform=' + encodeURIComponent(platform), {
method: 'POST', credentials: 'same-origin',
});
await load();
} catch (e) {
console.error('bot-link.resume', e);
} finally {
setBusy('');
}
};
// Ни одного настроенного бота и почты — карточки нет вовсе.
if (!rows || (rows.length === 0 && !mail)) return null;
return (
Уведомления
Куда присылать готовые разборы и утренний прогноз. После нажатия откроется диалог с ботом — подтверди привязку там.
Утренний прогноз сюда не приходит. Так бывает после «Отписаться» в боте
или если одно сообщение не удалось доставить — например, диалог был очищен
или бот заблокирован. Нажми «Возобновить», и рассылка вернётся со следующего утра.
)}
))}
);
};
// ——— Плитки главной кабинета ———
// Три сущности — транзиты, разборы, персоны — на «Сегодня» показываются
// короткими списками: сколько их, три верхних строки и ссылка в раздел.
// Подробности (карточки с глифами, календарь, формы) живут в разделах, куда
// эти ссылки и ведут.
// 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' && (
Не удалось посчитать транзиты. Загляни в раздел — там можно выбрать месяц и персону.
);
};
// 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)}
{/* Приветствие — первым и одной строкой. Раньше на его месте стояла
карточка в пол-экрана: натальная карта фоном, чип «Личный кабинет»,
дата, абзац про количество разборов и почта. Места занимала больше
всех на странице, а сообщала меньше всех. */}
Привет, {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}% на следующий.
);
};
// ——— 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»). */}
{/* Фильтр по персоне. Раньше на этом месте стояла кнопка с иконкой
фильтра, которая ничего не делала. Список с поиском, а не чипы:
персон бывает три десятка, и строкой чипов они не помещаются. */}
{personOptions.length > 0 && (
{selectedPerson && (
{selectedPerson.label}
{/* Говорим прямо, что часть разборов собрана по совпадению имени и
даты: связь установили мы, а не человек, и выдавать её за
настоящую привязку нечестно. */}
{guessedInGroup > 0 && (
{guessedInGroup} из них {guessedInGroup === 1 ? 'сопоставлен' : 'сопоставлены'} по
совпадению имени и даты рождения: {guessedInGroup === 1 ? 'заказ оформлен' : 'заказы оформлены'} без
выбора сохранённой персоны.
)}
)}
{/* Tabs */}
{tabs.map(t => (
))}
{loading ? (
Загружаем твои разборы…
) : filtered.length === 0 ? (
{readings.length === 0
? <>Пока нет ни одного разбора>
: selectedPerson && byPerson.length === 0
? <>По {selectedPerson.label} разборов нет>
: <>В этой категории пусто>}
{readings.length === 0
? 'Первый разбор отвечает на вопрос, который ты носишь дольше всего: почему одно и то же повторяется — и в какой точке это каждый раз начинается.'
: 'Попробуй другой фильтр — или открой в карте что-то ещё.'}
);
};
// ——— Cabinet · Persons ———
// Преобразуем относительную «роль» к компактному отображению.
// Бэкенд хранит role в поле relation (mom / partner / self / ...).
const RELATION_RUS = {
self: 'я',
partner: 'партнёр',
mom: 'мама',
dad: 'папа',
mother: 'мама',
father: 'папа',
child: 'ребёнок',
son: 'сын',
daughter: 'дочь',
friend: 'друг',
sister: 'сестра',
brother: 'брат',
family: 'семья',
other: 'другое',
};
const relationLabel = (rel) => {
if (!rel) return '';
const key = String(rel).toLowerCase();
return RELATION_RUS[key] || rel;
};
// Разборы персоны — счётчиком на карточке и списком в модалке.
//
// Списком прямо в карточке это уже было, и от него пришлось отказаться: у
// человека с десятком персон каждая карточка вырастала на треть, а сетка
// переставала читаться. На карточке остаётся одна строка со счётчиком, весь
// список — по клику.
// partnerName — второй участник совместимости. В модалке конкретной персоны
// «Совместимость» без второго имени не отвечает на единственный вопрос, ради
// которого её тут открыли: с кем.
const partnerName = (r, personKey) => {
const other = (r.participants || []).find(p => p.key !== personKey);
return other ? other.name : '';
};
const PersonReadingsModal = ({ person, readings, onClose, onOpenInList }) => {
const selfKey = personKey(person.name, person.born_date);
// Сколько разборов подтянуто по совпадению имени и даты. Говорим об этом
// прямо: человек должен видеть, что связь установили мы, а не он.
const byName = matchedByName(readings, selfKey);
// Esc закрывает: модалка перекрывает всю страницу, и мышью до крестика ещё
// надо дойти.
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
{/* Через разделитель, а не «Разборы Ани»: склонять имена из JS нечем. */}
Разборы · {person.name}
{byName > 0 && (
{byName} из них {byName === 1 ? 'сопоставлен' : 'сопоставлены'} по совпадению имени и даты
рождения — {byName === 1 ? 'этот заказ оформлен' : 'эти заказы оформлены'} без выбора
сохранённой персоны. Такие строки помечены.
)}
{/* Одна строка, а не список: карточки стоят в сетке, и блок из трёх
разборов раздувал каждую. Список — в модалке по клику. */}
{readings !== undefined && (
readings.length > 0 ? (
) : (
разборов пока нет
)
)}
);
};
const NewPersonCard = ({ onClick }) => (
);
// Inline-редактор персоны — общий для PATCH (есть id) и POST (нет id).
const PersonEditor = ({ person, onClose, onSaved, onDeleted }) => {
// person передаётся как объект backend-DTO (или пустой шаблон для создания).
// Локальный стейт держим в виде «формы» — поля в человеческом виде.
// Имя и фамилия раздельно: соцсети отдают их разными полями, и склейка
// теряла структуру. first_name может не прийти у старых записей — тогда
// разбираем целое имя по первому пробелу.
const splitName = (full) => {
const t = (full || '').trim();
const i = t.indexOf(' ');
return i < 0 ? [t, ''] : [t.slice(0, i), t.slice(i + 1).trim()];
};
const [fallbackFirst, fallbackLast] = splitName(person?.name);
const [firstName, setFirstName] = React.useState(person?.first_name || fallbackFirst || '');
const [lastName, setLastName] = React.useState(person?.last_name || fallbackLast || '');
const [relation, setRelation] = React.useState(person?.relation || '');
const [bornDate, setBornDate] = React.useState(person?.born_date || ''); // YYYY-MM-DD
const [bornTime, setBornTime] = React.useState(dtNormTime(person?.born_time)); // HH:MM
const [place, setPlace] = React.useState(person?.place || '');
// Координаты места: приходят из подсказки геокодера (или уже сохранены у
// персоны). Без них бэкенду пришлось бы геокодить строку заново на каждом
// заказе — а одноимённые города он разрешает не так, как имел в виду юзер.
const [placeLat, setPlaceLat] = React.useState(person?.lat || 0);
const [placeLon, setPlaceLon] = React.useState(person?.lon || 0);
const [gender, setGender] = React.useState(person?.gender || ''); // ''|female|male
const [tzName, setTzName] = React.useState(person?.tz_name || 'Europe/Moscow');
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState(null);
const isNew = !person?.id;
const save = async () => {
setError(null);
if (!firstName.trim()) {
setError('Имя обязательно');
return;
}
setSaving(true);
try {
const body = {
first_name: firstName.trim(),
last_name: lastName.trim(),
born_date: bornDate || '',
born_time: bornTime || '',
tz_name: tzName || 'Europe/Moscow',
place: place || '',
lat: placeLat || 0,
lon: placeLon || 0,
relation: relation || '',
gender: gender,
};
const url = isNew ? '/api/persons' : `/api/persons/${encodeURIComponent(person.id)}`;
const method = isNew ? 'POST' : 'PATCH';
const r = await fetch(url, {
method,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
let msg = 'Не удалось сохранить';
try { const j = await r.json(); if (j?.error) msg = j.error; } catch (_) {}
setError(msg);
return;
}
await onSaved?.();
onClose?.();
} catch (e) {
setError('Сетевая ошибка');
} finally {
setSaving(false);
}
};
const remove = async () => {
if (isNew) { onClose?.(); return; }
if (!window.confirm(`Удалить персону «${name}»?`)) return;
setSaving(true);
try {
const r = await fetch(`/api/persons/${encodeURIComponent(person.id)}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (!r.ok && r.status !== 204) {
setError('Не удалось удалить');
return;
}
await onDeleted?.();
onClose?.();
} catch (e) {
setError('Сетевая ошибка');
} finally {
setSaving(false);
}
};
return (
В тебе живёт парадокс: мягкость снаружи (Рак) и глубокий, иногда тёмный внутренний мир (Скорпион). Это даёт редкий дар — видеть людей насквозь и при этом оставаться добрым человеком.
Эмоциональная палитра — от лёгкой меланхолии до экстатической вовлечённости. Когда что-то цепляет — ты вкладываешься на 200%. Когда отстраняешься — это надолго.
Что с этим делать
Учись доверять первому впечатлению — оно у тебя точнее, чем у 80% людей. И не вини себя за «слишком много чувств» — это твой инструмент работы.
На уровне отношений с собой — научись разделять «грустно потому что так звёзды» и «грустно потому что просто нет сил». Луна в Скорпионе любит драматизировать...
);
};
// ——— Cabinet · Calendar (transits) ———
// Транзиты считает бэкенд (/api/transits): даты и тексты приходят готовыми,
// здесь только календарь, выбор месяца и раскрытие смысла события.
const TRANSIT_TONES = {
ease: { color: 'var(--sage)', label: 'идёт легче' },
edge: { color: 'var(--rose)', label: 'трение' },
key: { color: 'var(--gold)', label: 'тема на максимум' },
info: { color: 'var(--celest)', label: 'смена фона' },
major: { color: 'var(--gold)', label: 'затмение' },
};
const MONTHS_RU = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
const MONTHS_RU_GEN = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
// "2026-08-14" → "14 августа". Строку режем сами: new Date() на ISO-дате
// съезжает на сутки в минусовых поясах.
const fmtDayMonth = (iso) => {
if (!iso) return '';
const [, m, d] = iso.split('-');
return `${Number(d)} ${MONTHS_RU_GEN[Number(m) - 1] || ''}`;
};
const monthKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const shiftMonth = (key, delta) => {
const [y, m] = key.split('-').map(Number);
const d = new Date(y, m - 1 + delta, 1);
return monthKey(d);
};
// Сетка месяца с понедельника: ведущие/хвостовые пустышки, чтобы дни встали по дням недели.
const monthGrid = (key) => {
const [y, m] = key.split('-').map(Number);
const first = new Date(y, m - 1, 1);
const daysInMonth = new Date(y, m, 0).getDate();
const lead = (first.getDay() + 6) % 7; // Вс=0 → 6
const cells = [];
for (let i = 0; i < lead; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ day: d, iso: `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` });
}
while (cells.length % 7 !== 0) cells.push(null);
return cells;
};
const TransitEventCard = ({ e }) => {
const tone = TRANSIT_TONES[e.tone] || TRANSIT_TONES.info;
const window_ = e.window;
return (
{e.title}{fmtDayMonth(e.date)}
{e.meaning}
{window_ && (
{window_.from_open && window_.to_open
? 'в орбе весь месяц'
: `в орбе ${window_.from_open ? 'с начала месяца' : fmtDayMonth(window_.from)} — ${window_.to_open ? 'до конца месяца' : fmtDayMonth(window_.to)}`}
)}
);
};
const CabinetCalendar = () => {
const { persons, loading: personsLoading } = React.useContext(CabinetDataCtx);
const withBirth = React.useMemo(() => (persons || []).filter(p => p.has_birth || p.born_date), [persons]);
const [personId, setPersonId] = React.useState('');
const [month, setMonth] = React.useState(() => monthKey(new Date()));
const [data, setData] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState('');
const [picked, setPicked] = React.useState('');
// Разбор словами: те же события, но с ответом «и что мне с этим делать».
// Считает бэкенд (/api/transits/interpretation), здесь только состояния.
const [interp, setInterp] = React.useState(null);
const [interpBusy, setInterpBusy] = React.useState(false);
// Счётчик поколений: смена персоны, месяца или дня обесценивает и показанный
// разбор, и уже запущенный опрос — иначе ответ про август прилетал бы
// на открытый сентябрь.
const genRef = React.useRef(0);
React.useEffect(() => () => { genRef.current++; }, []);
// Сколько разборов ещё бесплатны. Приходит с любым ответом ручки, поэтому
// счётчик виден до нажатия кнопки, а не только когда бесплатные кончились.
const [free, setFree] = React.useState(null);
// Опрос состояния разбора.
//
// order=false — «что с этим разбором сейчас» (peek): ручка на такой запрос
// ничего не тратит и ничего не начинает. Именно им страница открывается,
// поэтому заказ виден на любом заходе, а не только в той вкладке, где его
// нажали.
// order=true — сам заказ. Дальше опрашиваем уже пассивно.
const pollInterpretation = React.useCallback(async ({ order }) => {
if (!personId) return;
const token = ++genRef.current;
const base = `/api/transits/interpretation?person_id=${encodeURIComponent(personId)}&month=${month}`
+ (picked ? `&day=${picked}` : '');
let url = order ? base : base + '&peek=1';
if (order) { setInterpBusy(true); setInterp(null); }
// Модель отвечает минуту-две, поэтому ручка сразу говорит «считаем», а текст
// забираем повторными заходами. Ждать одним запросом нельзя: соединение
// оборвётся раньше, чем придёт ответ, а генерация уже оплачена.
for (let i = 0; i < 40; i++) {
let d;
try {
const r = await fetch(url, { credentials: 'same-origin' });
d = await r.json();
if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status);
} catch (e) {
if (token !== genRef.current) return;
setInterp({ status: 'error' }); setInterpBusy(false);
return;
}
if (token !== genRef.current) return;
if (d.free) setFree(d.free);
setInterp(d);
if (d.status !== 'generating') { setInterpBusy(false); return; }
setInterpBusy(true);
url = base + '&peek=1';
await new Promise(res => setTimeout(res, 6000));
if (token !== genRef.current) return;
}
// Дальше не ждём, но и не теряем: заказ записан на сервере, и следующее
// открытие страницы подхватит его тем же peek.
setInterp({ status: 'slow' }); setInterpBusy(false);
}, [personId, month, picked]);
// Заход на страницу (и смена персоны, месяца, дня) — сразу спрашиваем
// состояние. Это тот самый вопрос, ответа на который раньше не было нигде:
// заказан ли уже разбор и не готов ли он.
React.useEffect(() => {
genRef.current++;
setInterp(null); setInterpBusy(false);
// Счётчик аккаунта оставляем (он не зависит от окна), а «уже открыт» —
// нет: иначе при переключении месяца новый месяц секунду выглядел бы
// оплаченным.
setFree(f => (f && f.covered ? { ...f, covered: false } : f));
if (!personId) return;
pollInterpretation({ order: false });
}, [personId, month, picked, pollInterpretation]);
const askInterpretation = () => pollInterpretation({ order: true });
// Кнопку закрываем ровно тогда, когда нажимать её бессмысленно: разбор дня
// без подписки и исчерпанные бесплатные. Что именно мешает — говорит текст
// из ответа ручки, он уже показан ниже.
const interpLocked = !!(interp && interp.status === 'locked');
// Во что обойдётся кнопка — словами и до нажатия.
//
// Раньше про бесплатные разборы не было сказано нигде: человек либо не знал,
// что первые бесплатны, либо узнавал о лимите, только упёршись в него.
const freeHint = React.useMemo(() => {
if (!free) return '';
if (free.subscriber) return 'Входит в подписку';
if (free.covered) return 'Уже открыт — повторно ничего не спишется';
if (!free.total) return '';
if (picked) return 'Разбор дня — по подписке';
const left = Number(free.left) || 0;
if (left <= 0) return 'Бесплатные разборы кончились';
return `Бесплатно · осталось ${left} из ${free.total}`;
}, [free, picked]);
// Счётчик для шапки раздела.
//
// Отдельно от подсказки под кнопкой: та живёт в колонке, которая целиком
// исчезает, как только разбор посчитан. Получалось, что человек, у которого
// уже что-то открыто — то есть ровно тот, кто потратил бесплатные, — не
// видел остатка нигде.
const freeBadge = React.useMemo(() => {
if (!free) return null;
if (free.subscriber) return { text: 'Разборы входят в подписку', gold: true };
if (!free.total) return null;
const left = Number(free.left) || 0;
if (left <= 0) return { text: 'Бесплатные разборы кончились', gold: false };
return { text: `Бесплатных разборов: ${left} из ${free.total}`, gold: true };
}, [free]);
React.useEffect(() => {
if (personId || withBirth.length === 0) return;
const def = withBirth.find(p => p.is_default) || withBirth[0];
setPersonId(def.id);
}, [withBirth, personId]);
React.useEffect(() => {
if (!personId) { if (!personsLoading) setLoading(false); return; }
let alive = true;
setLoading(true); setError('');
fetch(`/api/transits?person_id=${encodeURIComponent(personId)}&month=${month}`, { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.error || 'Не удалось посчитать транзиты')))
.then(j => { if (alive) { setData(j); setPicked(''); } })
// Данные прошлого месяца при ошибке не оставляем: иначе «Фон месяца» и
// список событий продолжают показывать чужой месяц как свой.
.catch(err => { if (alive) { setError(String(err)); setData(null); } })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [personId, month, personsLoading]);
const events = (data && data.events) || [];
const byDate = React.useMemo(() => {
const m = {};
events.forEach(e => { (m[e.date] = m[e.date] || []).push(e); });
return m;
}, [events]);
const today = (data && data.today) || '';
const [y, m] = month.split('-').map(Number);
const isCurrentMonth = month === monthKey(new Date());
// Правая колонка: выбранный день, иначе — то, что впереди (в прошлых месяцах — весь список).
const upcoming = React.useMemo(() => {
if (picked) return byDate[picked] || [];
const future = events.filter(e => !today || e.date >= today);
return (future.length ? future : events).slice(0, 6);
}, [picked, byDate, events, today]);
const personOptions = React.useMemo(
() => withBirth.map(p => ({ value: p.id, label: p.name, hint: p.relation ? relationLabel(p.relation) : '' })),
[withBirth]
);
if (!personsLoading && withBirth.length === 0) {
return (
Транзиты и окна
Календарь строится по натальной карте, поэтому нужна персона с датой рождения.
Со временем и местом рождения он точнее: добавятся дома и точки ASC/MC.
);
}
return (
{MONTHS_RU[m - 1]} {y}
Транзиты и окна
{freeBadge && (
{freeBadge.text}
)}
{personOptions.length > 1 && (
)}
{/* Не «Сегодня»: так называется первая вкладка кабинета, две одинаковые
кнопки на одном экране читаются как одно и то же действие. */}
{/* Что это вообще такое — без этого страница читается как набор символов */}
Как это читать
Планеты продолжают двигаться и после твоего рождения. Транзит — момент, когда планета на небе
встаёт под значимым углом к точке твоей натальной карты. Это не предсказание событий: это про
фон — какие темы в эти дни идут легче, а какие требуют внимания. Даты считаются
эфемеридами, не выдумываются.
{/* Разбор: даты на странице посчитаны, здесь они превращаются в текст */}
{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' && (
Разбор всё ещё считается — он записан за тобой, загляни сюда через несколько минут.
{[
{ date: '17 мая 2026', mood: 'sage', moodLabel: 'хорошо', txt: 'Сделала презентацию — приняли без правок. Чувствую, что переход на новую работу был правильным. Венера на МС сегодня — наверное, оттуда уверенность.', transit: 'Венера на МС, тригон Юпитера' },
{ date: '14 мая 2026', mood: 'rose', moodLabel: 'тяжело', txt: 'Поругалась с Димой. Опять про деньги. Чувствую — что-то старое поднимается, не из этой ситуации.', transit: 'Марс □ Венера, тёмная Луна' },
{ date: '12 мая 2026', mood: 'celest', moodLabel: 'ровно', txt: 'Прочитала натальную карту целиком. Раздел про мать — попадание. Надо позвонить.', transit: '—' },
].map((e, i) => (
{e.date}
{e.moodLabel}
{e.transit}
{e.txt}
))}
);
// ——— Смена пароля ———
//
// Раньше это были два идущих подряд astro.prompt(): «текущий пароль», затем
// «новый». Модалка для них живёт в app.css, которую SPA не подключает, —
// поэтому вместо окна на странице расползалась голая разметка, и формы как
// таковой человек не видел. Форма нужна здесь же, в карточке профиля, на
// вёрстке кабинета.
//
// Три поля вместо двух: повтор нового пароля ловит опечатку до того, как она
// станет паролем, который никто не помнит.
const CabPasswordForm = ({ onDone }) => {
const [open, setOpen] = React.useState(false);
const [f, setF] = React.useState({ old: '', next: '', repeat: '' });
const [err, setErr] = React.useState('');
const [busy, setBusy] = React.useState(false);
const close = () => { setOpen(false); setF({ old: '', next: '', repeat: '' }); setErr(''); };
const submit = async (e) => {
e.preventDefault();
if (busy) return;
if (!f.old) return setErr('Введи текущий пароль.');
if (f.next.length < 8) return setErr('Новый пароль — минимум 8 символов.');
if (f.next !== f.repeat) return setErr('Пароли не совпали.');
if (f.next === f.old) return setErr('Новый пароль совпадает с текущим.');
setBusy(true); setErr('');
try {
await cabJSON('POST', '/api/auth/password/change', { old_password: f.old, new_password: f.next });
close();
onDone('Пароль изменён — письмо ушло на почту.');
} catch (e2) {
// Бэкенд отвечает по-английски и одинаково на «нет пароля у аккаунта»
// и «пароль неверный»: вход по ссылке из письма пароля не заводит.
const m = String((e2 && e2.message) || '');
setErr(/invalid current password/i.test(m)
? 'Текущий пароль не подошёл. Если ты входишь по ссылке из письма, пароля у аккаунта ещё нет — задай его через «Забыли пароль» на странице входа.'
: /at least 8/i.test(m) ? 'Новый пароль — минимум 8 символов.'
: m || 'Не получилось сменить пароль.');
} finally { setBusy(false); }
};
if (!open) {
return ;
}
// Подпись поля — обычным текстом, а не классом .label: тот переводит строку
// в верхний регистр, и «минимум 8 символов» начинает кричать на человека.
const field = (label, key, autoComplete) => (
);
return (
);
};
// ——— Main cabinet shell ———
// CabinetSettings — профиль, каналы уведомлений и история оплат.
//
// Уведомления живут здесь, а не на «Сегодня»: это настройка, к которой
// возвращаются раз в полгода, и на главной она занимала место у того, ради
// чего человек заходит каждый день.
const CabinetSettings = () => {
const { user, orders } = React.useContext(CabinetDataCtx);
const [msg, setMsg] = React.useState('');
const [tab, setTab] = React.useState('profile');
const deleteAccount = async () => {
const ok = await astro.confirm({
title: 'Удалить аккаунт?',
body: 'Аккаунт, разборы и PDF будут удалены безвозвратно. Это нельзя отменить.',
okText: 'Удалить навсегда', danger: true,
});
if (!ok) return;
try {
await astro.api('DELETE', '/api/account');
window.location.href = '/';
} catch (e) { setMsg(e.message || 'Не получилось удалить аккаунт.'); }
};
// История оплат строится из заказов: своей таблицы платежей пока нет, а
// выдумывать её ради красивого заголовка хуже, чем показать то, что есть.
// Списаний по подписке здесь не будет, пока не подключён рекуррент.
const paid = (orders || []).filter(o => o.status !== 'pending_payment' && o.status !== 'cancelled');
const tabs = [
{ id: 'profile', label: 'Профиль' },
{ id: 'notify', label: 'Уведомления' },
{ id: 'billing', label: 'Оплаты' },
{ id: 'account', label: 'Аккаунт' },
];
return (
Настройки
{/* Разделы вкладками, а не одной простынёй: сюда заходят с конкретным
вопросом — сменить почту, выключить письма, посмотреть списания. */}
Списания по подписке появятся здесь, когда подключим регулярные платежи.
)}
{tab === 'account' && (
Удаление аккаунта
Удалятся аккаунт, разборы и PDF. Отменить будет нельзя.
)}
);
};
// ============ ОТЗЫВЫ И ПОДДЕРЖКА ============
//
// Отзывы на сайте раньше были написаны нами. Здесь начинается настоящий путь:
// человек ставит оценку разбору, решает, показывать ли текст на сайте, и —
// если мы правили его слова — сам говорит, можно ли публиковать правку.
// Разговор об этом идёт в том же чате, что и обычные обращения: отдельный
// «канал для отзывов» человек не найдёт, а ответ по почте не вернётся в систему.
const REVIEW_STATUS = {
pending: { label: 'на модерации', color: 'celest' },
edit_proposed: { label: 'ждёт твоего ответа', color: 'gold' },
approved: { label: 'опубликован', color: 'sage' },
rejected: { label: 'не опубликован', color: 'rose' },
withdrawn: { label: 'снят с сайта', color: 'muted' },
};
const cabJSON = async (method, path, body) => {
const opts = { method, credentials: 'same-origin', headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
let data = null;
try { data = await r.json(); } catch (e) {}
if (!r.ok) throw new Error((data && data.error) || ('HTTP ' + r.status));
return data;
};
// ——— Оценка звёздами ———
const CabStars = ({ value, onChange, size = 26 }) => (
{[1, 2, 3, 4, 5].map(n => (
))}
);
// ——— Форма отзыва о разборе ———
//
// Одна форма, два разных согласия: оценка и текст нужны нам всегда (по ним
// правятся промпты), публикация — только если человек сам её разрешил.
const CabReviewForm = ({ pending, user, onSent }) => {
const [rating, setRating] = React.useState(0);
const [text, setText] = React.useState('');
const [publish, setPublish] = React.useState(false);
const [name, setName] = React.useState((user?.name || '').split(' ')[0] || '');
const [note, setNote] = React.useState('');
const [state, setState] = React.useState({ sending: false, error: null });
const send = async () => {
if (!rating) { setState({ sending: false, error: 'Поставь оценку от 1 до 5' }); return; }
if (publish && !text.trim()) { setState({ sending: false, error: 'Для публикации нужен текст отзыва' }); return; }
if (publish && !name.trim()) { setState({ sending: false, error: 'Как тебя подписать под отзывом?' }); return; }
setState({ sending: true, error: null });
try {
const d = await cabJSON('POST', '/api/feedback', {
order_id: pending.order_id,
csat: rating,
comment: text.trim(),
publish,
author_name: name.trim(),
author_note: note.trim(),
});
onSent(d);
} catch (e) {
setState({ sending: false, error: e.message });
}
};
return (
Опубликуем только с твоего согласия — текст выйдет под твоим именем.
было
«{review.body}»
стало
«{review.edited_body}»
{review.edit_note && (
Почему правили: {review.edit_note}
)}
);
};
// ——— Блок отзывов на «Сегодня» ———
//
// Показываем по одному делу за раз: сначала то, что ждёт ответа человека,
// потом просьбу оценить самый свежий разбор. Список всех отзывов живёт
// в разделе «Поддержка», здесь ему не место.
const CabReviewInbox = () => {
const { user, loading } = React.useContext(CabinetDataCtx);
const [pending, setPending] = React.useState([]);
const [mine, setMine] = React.useState([]);
const [dismissed, setDismissed] = React.useState(() => sessionStorage.getItem('fb_dismissed') || '');
const [thanks, setThanks] = React.useState(null);
const load = React.useCallback(async () => {
try {
const [p, m] = await Promise.all([
cabJSON('GET', '/api/feedback/pending').catch(() => ({ pending: [] })),
cabJSON('GET', '/api/reviews/mine').catch(() => ({ reviews: [] })),
]);
setPending(p.pending || []);
setMine(m.reviews || []);
} catch (e) { /* блок необязательный — молча пропускаем */ }
}, []);
React.useEffect(() => { load(); }, [load]);
if (loading) return null;
const waitingEdit = mine.find(r => r.status === 'edit_proposed');
if (waitingEdit) {
return { setThanks(null); load(); }} />;
}
if (thanks) {
return (
Спасибо
{thanks}
);
}
// Письмо «расскажи, как тебе разбор» приводит сюда со своим order_id:
// у человека может быть несколько готовых разборов, и спрашивать надо про тот,
// о котором мы написали.
const asked = new URLSearchParams(window.location.search).get('feedback') || '';
const next = pending.find(p => p.order_id === asked) || pending.find(p => p.order_id !== dismissed);
if (!next) return null;
return (
{
setThanks(d && d.review
? 'Отзыв ушёл на модерацию — обычно отвечаем в тот же день. Опубликуем только тот текст, который ты одобришь.'
: 'Оценка записана. По таким ответам мы правим сами тексты разборов.');
load();
}}
/>
{/* Правка отзыва решается прямо в ветке: разговор и кнопка не должны
жить на разных экранах. */}
{state.review && state.review.status === 'edit_proposed' && (
{ load(); onChanged && onChanged(); }} />
)}
);
};
// ——— Раздел «Поддержка»: чат и отзывы ———
const CabinetSupport = () => {
const { orders, refetchSupport } = React.useContext(CabinetDataCtx);
const [tickets, setTickets] = React.useState([]);
const [reviews, setReviews] = React.useState([]);
// Ветка из письма открывается сразу — ради этого письмо и содержит ссылку.
const [openId, setOpenId] = React.useState(
() => new URLSearchParams(window.location.search).get('ticket') || null);
const [composing, setComposing] = React.useState(false);
const [draft, setDraft] = React.useState({ subject: '', body: '', order_id: '' });
const [state, setState] = React.useState({ loading: true, error: null });
const load = React.useCallback(async () => {
// Две независимые ручки. Раньше их связывал Promise.all: падение списка
// обращений уносило с собой и отзывы, и человек с опубликованным отзывом
// читал «ты пока не оставлял отзывов». Грузим каждую сама по себе.
const [t, r] = await Promise.allSettled([
cabJSON('GET', '/api/support/tickets'),
cabJSON('GET', '/api/reviews/mine'),
]);
if (t.status === 'fulfilled') setTickets(t.value.tickets || []);
if (r.status === 'fulfilled') setReviews(r.value.reviews || []);
const failed = [t, r].find(x => x.status === 'rejected');
setState({ loading: false, error: failed ? (failed.reason && failed.reason.message) || 'Не удалось загрузить' : null });
}, []);
React.useEffect(() => { load(); }, [load]);
const createTicket = async () => {
if (!draft.subject.trim() || !draft.body.trim()) return;
try {
await cabJSON('POST', '/api/support/tickets', {
subject: draft.subject.trim(),
body: draft.body.trim(),
order_id: draft.order_id || undefined,
});
setDraft({ subject: '', body: '', order_id: '' });
setComposing(false);
await load();
} catch (e) {
setState(s => ({ ...s, error: e.message }));
}
};
return (