// 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, refetchPersons: async () => {}, refetchOrders: async () => {}, }); // ——— Cabinet top bar (logo + email + logout) ——— const CabinetTopBar = () => { const { user } = React.useContext(CabinetDataCtx); return (
Star Path
{user?.email && ( {user.email} )} {user?.role === 'admin' && ( Админ )}
); }; // ——— Cabinet header / shell ——— const CabinetHeader = ({ subPage, onSubPage }) => { const items = [ { id: 'home', label: 'Сегодня' }, { id: 'readings', label: 'Разборы' }, { id: 'persons', label: 'Персоны' }, { id: 'calendar', label: 'Транзиты' }, { id: 'journal', label: 'Журнал' }, ]; return (
{items.map(it => ( ))}
); }; // ——— 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 '—'; } }; const orderToReading = (o, personsById) => { const meta = SERVICE_META[o.service] || SERVICE_META.natal; const person = (o.person_id && personsById[o.person_id]?.name) || o.person_name || (o.service === 'compatibility' && o.person2_id ? `${personsById[o.person_id]?.name || '—'} × ${personsById[o.person2_id]?.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, }; }; // 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)}`; }; // ——— Cabinet · Today (home) ——— 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 ordersCount = orders.length; const today = new Date().toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', weekday: 'long' }); return (
{/* Приветствие + статус */}
Личный кабинет {today}
Привет,
{firstName}.

{ordersCount === 0 ? 'У тебя пока нет разборов. Начни с натальной карты — 50 страниц о тебе за 1 990 ₽.' : `У тебя ${ordersCount} ${ordersCount === 1 ? 'разбор' : ordersCount < 5 ? 'разбора' : 'разборов'}. Открой любой ниже или закажи новый.`}

{user?.email && ( {user.email} )}
{/* Live transits */}
Текущие транзиты
{[ { p1: 'jupiter', p2: 'sun', aspect: 'тригон', orb: '2°15\'', when: 'до 24 мая', good: true }, { p1: 'saturn', p2: 'moon', aspect: 'квадрат', orb: '4°02\'', when: 'до 03 июня', good: false }, { p1: 'venus', p2: 'mercury', aspect: 'соединение', orb: '0°54\'', when: 'сегодня', good: true }, ].map((t, i) => (
{t.aspect === 'соединение' ? '⊙' : t.aspect === 'тригон' ? '△' : '□'}
{t.aspect}
{t.when}
{t.orb}
))}
{/* Быстрый заказ — на основе сохранённых персон */} {persons.length > 0 && (

Купить в 2 клика

на основе сохранённых персон
{persons.slice(0, 3).map((p) => { const dot = isoToDot(p.born_date); return ( ); })}
)} {/* Недавние разборы */}

Недавние разборы

{readings.length > 3 && ( )}
{loading ? (
Загружаем твои разборы…
) : readings.length === 0 ? (
Здесь пока пусто

Создай свой первый разбор — это 50 страниц о тебе, готовые за 2 минуты.

) : (
{readings.slice(0, 3).map(r => ( { window.location.href = `/order/${r.id}`; }} /> ))}
)}
); }; // ——— Reading card ——— const ReadingCard = ({ r, onClick }) => ( ); // ——— Cabinet · Readings list ——— const CabinetReadings = () => { const { persons, orders, loading } = 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 tabs = [ { id: 'all', label: 'Все', count: readings.length }, { id: 'natal', label: 'Натальные карты', count: readings.filter(r => r.kind === 'Натальная карта').length }, { id: 'forecast', label: 'Прогнозы', count: readings.filter(r => r.kind === 'Прогноз на год').length }, { id: 'compat', label: 'Совместимость', count: readings.filter(r => r.kind === 'Совместимость').length }, ]; const filtered = filter === 'all' ? readings : readings.filter(r => { if (filter === 'natal') return r.kind === 'Натальная карта'; if (filter === 'forecast') return r.kind === 'Прогноз на год'; return r.kind === 'Совместимость'; }); return (
{readings.length} разборов

Мои разборы

{/* Tabs */}
{tabs.map(t => ( ))}
{loading ? (
Загружаем твои разборы…
) : filtered.length === 0 ? (
{readings.length === 0 ? <>Пока нет ни одного разбора : <>В этой категории пусто}

{readings.length === 0 ? 'Создай свой первый разбор — 50 страниц о тебе за 2 минуты.' : 'Попробуй другой фильтр или закажи новый разбор.'}

) : (
{filtered.map(r => ( { window.location.href = `/order/${r.id}`; }} /> ))}
)}
); }; // ——— 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; }; const PersonCard = ({ p, onEdit, onOrder }) => { // p: либо backend-DTO (id строка, born_date "YYYY-MM-DD", born_time "HH:MM", place, relation, is_default), // либо legacy-мок (date, time, place, subname, sun/moon/asc). const isBackend = typeof p.id === 'string' && (p.born_date !== undefined || p.relation !== undefined || p.has_birth !== undefined); const displayName = p.name || '—'; const subname = isBackend ? relationLabel(p.relation) : (p.subname || ''); const dateText = isBackend ? (isoToDot(p.born_date) || '—') : (p.date || '—'); const timeText = isBackend ? (p.born_time || '—') : (p.time || '—'); const placeText = p.place || '—'; const primary = isBackend ? !!p.is_default : !!p.primary; // legacy mock содержит явные знаки sun/moon/asc, у backend их нет до полного расчёта. const sunSign = !isBackend ? p.sun : null; const moonSign = !isBackend ? p.moon : null; const ascSign = !isBackend ? p.asc : null; return (
{primary &&
я
}
{sunSign ? : }
{displayName}
{subname && (
{subname}
)}
{dateText} {timeText}
{placeText}
{sunSign && (
{ZODIAC_RUS[sunSign]} {moonSign && ( {ZODIAC_RUS[moonSign]} )} {ascSign && ( ASC {ZODIAC_RUS[ascSign]} )}
)}
); }; const NewPersonCard = ({ onClick }) => ( ); // Inline-редактор персоны — общий для PATCH (есть id) и POST (нет id). const PersonEditor = ({ person, onClose, onSaved, onDeleted }) => { // person передаётся как объект backend-DTO (или пустой шаблон для создания). // Локальный стейт держим в виде «формы» — поля в человеческом виде. const [name, setName] = React.useState(person?.name || ''); const [relation, setRelation] = React.useState(person?.relation || ''); const [bornDate, setBornDate] = React.useState(person?.born_date || ''); // YYYY-MM-DD const [bornTime, setBornTime] = React.useState(person?.born_time || ''); // HH:MM const [place, setPlace] = React.useState(person?.place || ''); const [gender, setGender] = React.useState(person?.gender || 'female'); // 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 (!name.trim()) { setError('Имя обязательно'); return; } setSaving(true); try { const body = { name: name.trim(), born_date: bornDate || '', born_time: bornTime || '', tz_name: tzName || 'Europe/Moscow', place: place || '', relation: relation || '', gender: gender || 'female', }; 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 (
e.stopPropagation()} style={{ maxWidth: '720px', width: '100%', padding: '40px', background: 'var(--bg-raised)', maxHeight: '90vh', overflowY: 'auto', }}>
{isNew ? 'Новая персона' : 'Редактирование'}

Данные персоны

Что мы рассчитаем
Солнце Луна ASC + 12 домов, аспекты
{error && (
{error}
)}
); }; const CabinetPersons = () => { const { persons, loading, refetchPersons } = React.useContext(CabinetDataCtx); const [editing, setEditing] = React.useState(null); const [creating, setCreating] = React.useState(false); // Для блока совместимости — выбираем двоих из реальных персон. const [compatA, setCompatA] = React.useState(''); const [compatB, setCompatB] = React.useState(''); React.useEffect(() => { if (persons.length >= 1 && !compatA) setCompatA(persons[0].id); if (persons.length >= 2 && !compatB) setCompatB(persons[1].id); }, [persons]); return (
{persons.length} сохранены

Мои персоны

Сохрани близких — заказывай разборы и совместимость в два клика, не вводя данные заново.

{loading ? (
Загружаем персоны…
) : (
{persons.map(p => )} setCreating(true)} />
)} {/* Compatibility builder */} {persons.length >= 2 && (
Совместимость

Сравни двоих из сохранённых

)} {editing && ( setEditing(null)} onSaved={refetchPersons} onDeleted={refetchPersons} /> )} {creating && ( setCreating(false)} onSaved={refetchPersons} /> )}
); }; // ——— Cabinet · Reading detail (dead code — навигация теперь идёт на /order/{id}) ——— const CabinetReading = ({ reading, onBack }) => { const r = reading || READINGS[0]; const [page, setPage] = React.useState(7); return (
{/* Left — metadata + chart */}
{r.kind}

{r.person}

Создан {r.date}
Объём {r.pages} страниц
Модель DeepSeek + Swiss Eph.
Версия v2.3 / 2026
Главные размещения
{[ { p: 'sun', label: 'Солнце', sign: 'cancer', house: '4' }, { p: 'moon', label: 'Луна', sign: 'scorpio', house: '8' }, { p: 'venus', label: 'Венера', sign: 'gemini', house: '3' }, { p: 'mars', label: 'Марс', sign: 'libra', house: '7' }, ].map((pl, i) => (
{pl.label}
{ZODIAC_RUS[pl.sign]} · {pl.house}d
))}
{/* Right — PDF reader */}
{/* Chapter nav */}
стр. {page} / {r.pages}
{/* PDF page */}
Глава 1 · Ядро личности

Солнце в Раке,
Луна в Скорпионе.

Ты родилась с парадоксом внутри: мягкая внешне (Рак), но с глубоким, иногда тёмным внутренним миром (Скорпион). Это даёт тебе редкий дар — видеть людей насквозь, оставаясь при этом доброй.

Эмоциональная палитра — от лёгкой меланхолии до экстатической вовлечённости. Когда что-то цепляет — ты вкладываешься на 200%. Когда отстраняешься — это надолго.

Что с этим делать

Учись доверять первому впечатлению — оно у тебя точнее, чем у 80% людей. И не вини себя за «слишком много чувств» — это твой инструмент работы.

На уровне отношений с собой — научись разделять «грустно потому что так звёзды» и «грустно потому что я устала». Луна в Скорпионе любит драматизировать...

); }; // ——— Cabinet · Calendar (transits) ——— // Бэкенда для расчёта транзитов пока нет — данные демонстрационные. const CabinetCalendar = () => { const days = Array.from({ length: 35 }, (_, i) => i - 2); // -2 to 32 (leading days) return (
Май 2026

Транзиты и окна

Транзиты сейчас демо — рассчитываем на следующей итерации.
{/* Calendar */}
{['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(d => (
{d}
))}
{days.map((d, i) => { const inMonth = d > 0 && d <= 31; const isToday = d === 18; // dummy events const events = { 3: ['gold'], 7: ['rose'], 12: ['celest', 'gold'], 14: ['sage'], 18: ['gold', 'rose'], 21: ['celest'], 24: ['gold'], 28: ['rose', 'sage'] }[d] || []; return (
{inMonth ? d : (d <= 0 ? 30 + d : d - 31)}
{events.map((e, j) => ( ))}
); })}
{/* Upcoming events */}
Ближайшие окна
{[ { date: '18 мая', label: 'Венера ☌ Меркурий', desc: 'Лёгкий день для денежных разговоров.', accent: 'gold' }, { date: '21 мая', label: 'Меркурий → Близнецы', desc: 'Окно для важных коммуникаций до 4 июня.', accent: 'celest' }, { date: '24 мая', label: 'Юпитер △ Солнце', desc: 'Удачный день для шага в новое.', accent: 'gold' }, { date: '28 мая', label: 'Марс □ Сатурн', desc: 'Возможна фрустрация — не торопить.', accent: 'rose' }, { date: '03 июня', label: 'Полнолуние в Стрельце', desc: 'Кульминация темы свободы и обучения.', accent: 'sage' }, ].map((e, i) => (
{e.label} {e.date}

{e.desc}

))}
); }; // ——— 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) => ( ))}