// App.jsx — root component + navigation (pregnancy / cycle / ttc modes)
(function () {
const { useState, useCallback, useEffect, useRef } = React;
const { t, calculateCurrentWeek, calculateCycle, GOALS, COLOR_SCHEMES, applyColorScheme } = window.AliaData;
const localISO = window.AliaData.localISO || (d => d.toISOString().split('T')[0]);
const todayISO = window.AliaData.todayISO || (() => localISO(new Date()));
const formatDayMonth = window.AliaData.formatDayMonth || (iso => iso || '');
const { isLoggedIn, logout: apiLogout, getProfile, updateProfile, saveCycle, getCycles, deleteCycle, getLogs, saveLog, getMe,
getNotifications, markNotificationsRead } = window.AliaAPI;
const { NotificationsSheet } = window;
const { currentCycleInfo } = window.AliaData;
/* ── SVG Icons ───────────────────────────────────────────────── */
function IconHome({ size = 24 }) {
return ;
}
function IconCompass({ size = 24 }) {
return ;
}
function IconTools({ size = 24 }) {
return ;
}
/* ── Baby Info Sheet ─────────────────────────────────────────── */
function BabyInfoSheet({ lang, currentName, currentGender, onSave, onClose }) {
const [name, setName] = useState(currentName || '');
const [gender, setGender] = useState(currentGender || 'unknown');
const GENDERS = [
{ id:'unknown', ru:'Не знаем', en:'Unknown', emoji:'👶' },
{ id:'boy', ru:'Мальчик', en:'Boy', emoji:'💙' },
{ id:'girl', ru:'Девочка', en:'Girl', emoji:'💗' },
];
const s = sheetStyles();
return (
e.stopPropagation()}>
{lang === 'ru' ? 'Имя и пол малыша' : "Baby's name & gender"}
{lang === 'ru' ? 'Имя малыша' : "Baby's name"}
setName(e.target.value)} />
{lang === 'ru' ? 'Пол малыша' : 'Gender'}
{GENDERS.map(g => (
setGender(g.id)}>
{g.emoji}
{lang === 'ru' ? g.ru : g.en}
))}
onSave(name.trim(), gender)}>
{lang === 'ru' ? 'Сохранить' : 'Save'}
);
}
/* ── Goal Sheet (переключение режима) ─────────────────────────── */
function GoalSheet({ lang, currentGoal, onSave, onClose }) {
const [goal, setGoal] = useState(currentGoal || 'pregnancy');
const s = sheetStyles();
const ru = lang === 'ru';
return (
e.stopPropagation()}>
{t('goalTitle', lang)}
{GOALS.map(g => (
setGoal(g.id)}>
{g.emoji}
{ru ? g.ru : g.en}
))}
onSave(goal)}>{t('saveBtn', lang)}
);
}
/* ── Cycle Settings Sheet ─────────────────────────────────────── */
function CycleSettingsSheet({ lang, cycle, onSave, onClose }) {
const [cycleLen, setCycleLen] = useState(cycle.cycle_length || 28);
const [periodLen, setPeriodLen] = useState(cycle.period_length || 5);
const [luteal, setLuteal] = useState(cycle.luteal_phase || '');
const [last, setLast] = useState(cycle.last_period || '');
const s = sheetStyles();
const today = todayISO();
const clamp = (v, lo, hi, def) => {
const n = Math.round(Number(v));
if (!isFinite(n) || isNaN(n)) return def;
return Math.max(lo, Math.min(hi, n));
};
return (
e.stopPropagation()}>
{t('cycleSettings', lang)}
{t('labelLMP', lang)}
setLast(e.target.value)} />
{t('lutealPhase', lang)} ({lang==='ru'?'необяз.':'optional'})
setLuteal(e.target.value)}
onBlur={() => setLuteal(luteal === '' ? '' : clamp(luteal, 9, 16, 14))} />
{lang === 'ru' ? 'Цикл 20–45 дн., менструация 1–10 дн., лютеиновая фаза 9–16 дн.'
: 'Cycle 20–45 d, period 1–10 d, luteal phase 9–16 d.'}
{
const pl = clamp(periodLen, 1, 10, 5);
const cl = clamp(cycleLen, 20, 45, 28);
onSave({
cycle_length: cl,
period_length: Math.min(pl, cl - 1),
luteal_phase: luteal === '' ? null : clamp(luteal, 9, 16, 14),
last_period: last || null,
});
}}>{t('saveBtn', lang)}
);
}
/* ── Settings Sheet ──────────────────────────────────────────── */
function SettingsSheet({ lang, colorScheme, appMode, onColorScheme, onProfile, onLangToggle, onEditDate, onBabyInfo, onGoal, onCycle, onLegal, onLogout, onClose }) {
const s = sheetStyles();
return (
e.stopPropagation()}>
{lang === 'ru' ? 'Настройки' : 'Settings'}
{lang === 'ru' ? '👤 Профиль' : '👤 Profile'} ›
{lang === 'ru' ? 'Цветовая тема' : 'Color theme'}
{COLOR_SCHEMES.map(sc => (
onColorScheme(sc.id)}>
))}
{t('goalTitle', lang)} ›
{appMode === 'pregnancy' ? (
{lang === 'ru' ? 'Имя и пол малыша' : "Baby's name & gender"} ›
) : (
{t('cycleSettings', lang)} ›
)}
{lang === 'ru' ? 'Язык' : 'Language'}
{lang === 'ru' ? 'Русский → EN' : 'English → RU'}
{appMode === 'pregnancy' && (
{t('editDate', lang)} ›
)}
{lang === 'ru' ? '⚖️ Правовая информация' : '⚖️ Legal'} ›
{lang === 'ru' ? 'Выйти из аккаунта' : 'Sign out'}
↩
);
}
/* ── Legal Sheets ────────────────────────────────────────────── */
function LegalSheet({ lang, onOpenDoc, onClose }) {
const s = sheetStyles();
const docs = (window.AliaLegal && window.AliaLegal.docs) || [];
return (
e.stopPropagation()}>
{lang === 'ru' ? 'Правовая информация' : 'Legal'}
{docs.map(d => (
onOpenDoc(d.id)}>
{d.icon + ' ' + d.title[lang]} ›
))}
{lang === 'ru'
? 'Приложение носит информационно-справочный характер и не заменяет консультацию врача.'
: 'The app is for informational purposes only and does not replace medical advice.'}
);
}
function LegalDocSheet({ lang, docId, onBack, onClose }) {
const s = sheetStyles();
const docs = (window.AliaLegal && window.AliaLegal.docs) || [];
const doc = docs.find(d => d.id === docId);
if (!doc) return null;
return (
e.stopPropagation()}>
‹
{doc.icon + ' ' + doc.title[lang]}
{doc.body[lang]}
);
}
/* ── Profile Sheet ───────────────────────────────────────────── */
function ProfileSheet({ lang, isGuest, goal, appMode, dueDate, currentWeek, cycle, onLogout, onClose }) {
const ru = lang === 'ru';
const s = sheetStyles();
const [me, setMe] = useState(() => ({
name: localStorage.getItem('alia_name') || '',
email: localStorage.getItem('alia_email') || '',
created_at: null,
}));
const [err, setErr] = useState(false);
// Подтягиваем актуальные данные аккаунта из БД (заодно проверяем токен)
useEffect(() => {
if (isGuest || !isLoggedIn() || !getMe) return;
getMe().then(d => {
setMe(m => ({ ...m, ...d }));
if (d.name != null) localStorage.setItem('alia_name', d.name);
if (d.email != null) localStorage.setItem('alia_email', d.email);
}).catch(() => setErr(true));
}, []); // eslint-disable-line
const modeLabel = appMode === 'pregnancy'
? (ru ? 'Беременность' : 'Pregnancy')
: appMode === 'ttc'
? (ru ? 'Планирование беременности' : 'Trying to conceive')
: (ru ? 'Слежение за циклом' : 'Cycle tracking');
const created = me.created_at ? new Date(me.created_at).toLocaleDateString(ru ? 'ru-RU' : 'en-US',
{ year:'numeric', month:'long', day:'numeric' }) : null;
const displayName = (me.name || '').trim() || (isGuest ? (ru ? 'Гость' : 'Guest') : (ru ? 'Без имени' : 'No name'));
const initial = (me.name || me.email || (isGuest ? 'G' : '?')).trim().charAt(0).toUpperCase();
const ps = {
head: { display:'flex', alignItems:'center', gap:14, margin:'4px 0 18px' },
avatar: { width:60, height:60, borderRadius:'50%', background:'var(--primary)', color:'#fff',
display:'flex', alignItems:'center', justifyContent:'center', fontSize:26, fontWeight:900, flexShrink:0 },
name: { fontSize:20, fontWeight:900, color:'var(--text)' },
sub: { fontSize:14, color:'var(--text-2)', marginTop:2, wordBreak:'break-all' },
badge: (ok) => ({ display:'inline-block', marginTop:6, fontSize:11.5, fontWeight:800, letterSpacing:'0.3px',
borderRadius:20, padding:'3px 10px', color: ok ? '#2E7D5B' : '#B26A00',
background: ok ? 'rgba(46,125,91,0.12)' : 'rgba(178,106,0,0.12)' }),
note: { background:'var(--primary-light)', borderRadius:14, padding:'12px 14px', fontSize:13.5,
color:'var(--text)', lineHeight:1.5, margin:'4px 0 14px' },
};
return (
e.stopPropagation()}>
{ru ? 'Профиль' : 'Profile'}
{initial}
{displayName}
{!isGuest && me.email &&
{me.email}
}
{isGuest ? (ru ? 'Гостевой режим' : 'Guest mode') : (ru ? '✓ Синхронизируется' : '✓ Synced')}
{isGuest ? (
{ru ? 'Вы вошли как гость. Данные хранятся только на этом устройстве и не синхронизируются между устройствами. Создайте аккаунт, чтобы сохранять их в облаке.'
: 'You are in guest mode. Data is stored only on this device and is not synced across devices. Create an account to keep it in the cloud.'}
) : (
<>
{ru ? 'Аккаунт' : 'Account'}
{err ? (ru ? 'нет связи' : 'offline') : (ru ? 'активен' : 'active')}
{created && (
{ru ? 'Регистрация' : 'Member since'}
{created}
)}
>
)}
{ru ? 'Режим' : 'Mode'}
{modeLabel}
{appMode === 'pregnancy' && dueDate && (
{ru ? 'ПДР' : 'Due date'}
{new Date(dueDate).toLocaleDateString(ru ? 'ru-RU' : 'en-US')}{currentWeek ? ` · ${ru ? 'нед.' : 'wk'} ${currentWeek}` : ''}
)}
{appMode !== 'pregnancy' && cycle && cycle.last_period && (
{ru ? 'Последние месячные' : 'Last period'}
{new Date(cycle.last_period).toLocaleDateString(ru ? 'ru-RU' : 'en-US')}
)}
{isGuest ? (ru ? 'Создать аккаунт / войти' : 'Create account / sign in') : (ru ? 'Выйти из аккаунта' : 'Sign out')}
↩
);
}
/* ── Edit Date Sheet ─────────────────────────────────────────── */
function EditDateSheet({ lang, currentDueDate, onSave, onClose }) {
const { calculateDueFromLMP } = window.AliaData;
const [mode, setMode] = useState('pdr');
const [val, setVal] = useState(currentDueDate || '');
const today = todayISO();
const maxPDR = localISO(new Date(Date.now() + 300 * 86400000));
const s = sheetStyles();
return (
e.stopPropagation()}>
{t('setDueDateTitle', lang)}
{ setMode('pdr'); setVal(''); }}>{t('inputModePDR', lang)}
{ setMode('lmp'); setVal(''); }}>{t('inputModeLMP', lang)}
setVal(e.target.value)} />
{ if (!val) return; onSave(mode === 'lmp' ? calculateDueFromLMP(val) : val); }}>
{t('btnContinue', lang)}
);
}
/* ── Cycle Home (режимы cycle / ttc) ──────────────────────────── */
function CycleHomeScreen({ lang, goal, cycle, periods, onLogPeriod, onOpenSettings, onOpenCalendar, onOpenPhaseInfo }) {
const ru = lang === 'ru';
const info = currentCycleInfo(periods, cycle) || calculateCycle(cycle);
const phaseLabel = {
period: ru ? 'Менструация' : 'Period',
follicular: ru ? 'Фолликулярная фаза' : 'Follicular phase',
fertile: ru ? 'Фертильное окно' : 'Fertile window',
luteal: ru ? 'Лютеиновая фаза' : 'Luteal phase',
delay: ru ? 'Задержка' : 'Delay',
};
const phaseColor = { period:'#E8637A', fertile:'#3BAF82', follicular:'#7FA8E0', luteal:'#C98BD4', delay:'#E8923D' };
const pluralDayRu = window.AliaData.pluralDayRu || (() => 'дн.');
const st = {
scroll:{ height:'100%', overflowY:'auto', WebkitOverflowScrolling:'touch', paddingBottom:'24px' },
inner:{ padding:'20px 16px', display:'flex', flexDirection:'column', gap:14, maxWidth:480, margin:'0 auto' },
ring:{ width:230, height:230, margin:'8px auto', borderRadius:'50%', display:'flex', flexDirection:'column',
alignItems:'center', justifyContent:'center', color:'#fff',
background: info ? `conic-gradient(${phaseColor[info.phase]||'var(--primary)'} ${(info.cycleDay/info.cycleLength)*360}deg, var(--primary-light) 0deg)` : 'var(--primary-light)' },
ringInner:{ width:190, height:190, borderRadius:'50%', background:'var(--bg-card)', display:'flex',
flexDirection:'column', alignItems:'center', justifyContent:'center', gap:4 },
bigNum:{ fontSize:44, fontWeight:900, color:'var(--text)' },
phase:{ fontSize:15, fontWeight:800, color: info ? (phaseColor[info.phase]||'var(--primary)') : 'var(--text-2)' },
sub:{ fontSize:13, color:'var(--text-2)' },
card:{ background:'var(--bg-card)', borderRadius:18, padding:'16px 18px', boxShadow:'0 2px 12px var(--shadow)' },
row:{ display:'flex', justifyContent:'space-between', padding:'9px 0', borderBottom:'1px solid var(--border)' },
btn:{ padding:'15px 0', borderRadius:15, border:'none', background:'var(--primary)', color:'#fff',
fontSize:16, fontWeight:800, cursor:'pointer', fontFamily:'Nunito,sans-serif', width:'100%' },
btn2:{ padding:'13px 0', borderRadius:14, border:'2px solid var(--border)', background:'var(--bg-card)',
color:'var(--text)', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'Nunito,sans-serif', width:'100%' },
};
if (!info) {
return (
{ru ? 'Настройте цикл' : 'Set up your cycle'}
{ru ? 'Укажите дату последней менструации и длину цикла, чтобы видеть прогнозы овуляции и месячных.' : 'Add your last period and cycle length to see ovulation and period predictions.'}
{t('cycleSettings', lang)}
);
}
return (
{formatDayMonth(todayISO(), lang)}
{ru ? 'День цикла' : 'Cycle day'}
{info.cycleDay}
{ e.stopPropagation(); onOpenPhaseInfo && onOpenPhaseInfo(info.phase); }}
style={{ ...st.phase, display:'inline-flex', alignItems:'center', gap:5, border:'none',
background:'none', cursor:'pointer', fontFamily:'Nunito,sans-serif', padding:'2px 6px' }}>
{phaseLabel[info.phase]} ⓘ
{info.delayDays > 0 && (
⏳
{ru ? `Задержка ${info.delayDays} ${pluralDayRu(info.delayDays)}` : `Period late by ${info.delayDays} ${info.delayDays === 1 ? 'day' : 'days'}`}
{ru ? 'Месячные не начались в ожидаемый день. Отметьте их начало или сделайте тест.'
: "Your period hasn't started as expected. Log its start or take a test."}
)}
{goal === 'ttc' && (
{info.isOvulationToday ? (ru ? '🌸 Сегодня овуляция!' : '🌸 Ovulation today!')
: info.daysToOvulation >= 0 ? (ru ? `До овуляции ${info.daysToOvulation} дн.` : `Ovulation in ${info.daysToOvulation} d`)
: (ru ? 'Овуляция прошла' : 'Ovulation passed')}
)}
{ru ? 'Следующие месячные' : 'Next period'} {info.delayDays > 0 ? (ru ? `задержка ${info.delayDays} ${pluralDayRu(info.delayDays)}` : `late ${info.delayDays} d`) : `${info.daysToNextPeriod} ${t('days', lang)} · ${formatDayMonth(info.nextPeriod, lang)}`}
{ru ? 'Овуляция' : 'Ovulation'} {formatDayMonth(info.ovulation, lang)}
{ru ? 'Фертильное окно' : 'Fertile window'} {formatDayMonth(info.fertileStart, lang)} – {formatDayMonth(info.fertileEnd, lang)}
{ru ? '🩸 Отметить начало месячных' : '🩸 Log period start'}
{'📅 ' + t('calOpen', lang)}
{t('cycleSettings', lang)}
);
}
/* ── Main App ────────────────────────────────────────────────── */
function App() {
useEffect(() => { if (window.__aliaSplashHide) window.__aliaSplashHide(); }, []);
// Гостевой режим отключён: доступ к приложению только после регистрации/входа.
// Старый флаг гостя очищаем, чтобы прежние гости попадали на экран входа.
try { localStorage.removeItem('alia_guest'); } catch (e) {}
const [isAuth, setIsAuth] = useState(() => isLoggedIn());
const [isGuest, setIsGuest] = useState(false);
const [lang, setLang] = useState(() => localStorage.getItem('alia_lang') || 'ru');
const [dueDate, setDueDateSt] = useState(() => localStorage.getItem('alia_due_date') || '');
const [setupDone, setSetupDone] = useState(() => !!localStorage.getItem('alia_setup_done') || !!localStorage.getItem('alia_due_date'));
const [tab, setTab] = useState('home');
const [toolsView, setToolsView] = useState(null); // быстрый переход к инструменту с главной
const [toolsKey, setToolsKey] = useState(0); // сброс на сетку при тапе по вкладке
const [profileLoading, setProfileLoading] = useState(false);
const [refreshTick, setRefreshTick] = useState(0); // обновление данных (кнопка / pull-to-refresh)
const [refreshing, setRefreshing] = useState(false);
const [pullY, setPullY] = useState(0); // pull-to-refresh: смещение оттягивания
const pullRef = useRef({ startY: 0, active: false });
// Навигация с главного экрана: kicks/contractions → вкладка «Инструменты» + нужный экран
const navigate = useCallback(target => {
if (target === 'kicks' || target === 'contractions') { setToolsView(target); setTab('tools'); }
else setTab(target);
}, []);
// Уведомления
const [notifications, setNotifications] = useState(() => {
try { return JSON.parse(localStorage.getItem('alia_notifications') || '[]'); } catch (e) { return []; }
});
const [showNotifications, setShowNotifications] = useState(false);
const unreadCount = notifications.filter(n => !n.is_read).length;
const [showSettings, setShowSettings] = useState(false);
const [showProfile, setShowProfile] = useState(false);
const [showEditDate, setShowEditDate] = useState(false);
const [showBabyInfo, setShowBabyInfo] = useState(false);
const [showGoal, setShowGoal] = useState(false);
const [showCycle, setShowCycle] = useState(false);
const [showCalendar, setShowCalendar] = useState(false);
const [showPeriodEdit, setShowPeriodEdit] = useState(false);
const [showLegal, setShowLegal] = useState(false);
const [legalDoc, setLegalDoc] = useState(null);
const [phaseInfo, setPhaseInfo] = useState(null);
const [logs, setLogs] = useState(() => {
try { return JSON.parse(localStorage.getItem('alia_logs') || '{}'); } catch (e) { return {}; }
});
const [periods, setPeriods] = useState(() => {
try { return JSON.parse(localStorage.getItem('alia_periods') || '[]'); } catch (e) { return []; }
});
const [colorScheme, setColorSchemeSt] = useState(() => localStorage.getItem('alia_color_scheme') || 'rose');
const [babyName, setBabyNameSt] = useState(() => localStorage.getItem('alia_baby_name') || '');
const [babyGender, setBabyGenderSt] = useState(() => localStorage.getItem('alia_baby_gender') || 'unknown');
// Режимы и цикл
const [appMode, setAppModeSt] = useState(() => localStorage.getItem('alia_app_mode') || 'pregnancy');
const [goal, setGoalSt] = useState(() => localStorage.getItem('alia_goal') || 'pregnancy');
const [cycle, setCycleSt] = useState(() => ({
last_period: localStorage.getItem('alia_last_period') || '',
cycle_length: +(localStorage.getItem('alia_cycle_length') || 28),
period_length: +(localStorage.getItem('alia_period_length') || 5),
luteal_phase: localStorage.getItem('alia_luteal_phase') ? +localStorage.getItem('alia_luteal_phase') : null,
}));
// Load profile from API
useEffect(() => {
if (!isAuth || isGuest || !isLoggedIn()) return;
if (refreshTick === 0) setProfileLoading(true); // полноэкранный сплэш только при первой загрузке
getProfile()
.then(p => {
if (p.lang) { setLang(p.lang); localStorage.setItem('alia_lang', p.lang); }
if (p.due_date) { setDueDateSt(p.due_date); localStorage.setItem('alia_due_date', p.due_date); }
if (p.baby_name) { setBabyNameSt(p.baby_name); localStorage.setItem('alia_baby_name', p.baby_name); }
if (p.baby_gender) { setBabyGenderSt(p.baby_gender); localStorage.setItem('alia_baby_gender', p.baby_gender); }
if (p.color_scheme) { setColorSchemeSt(p.color_scheme); localStorage.setItem('alia_color_scheme', p.color_scheme); }
if (p.app_mode) { setAppModeSt(p.app_mode); localStorage.setItem('alia_app_mode', p.app_mode); }
if (p.goal) { setGoalSt(p.goal); localStorage.setItem('alia_goal', p.goal); }
// План родов: зеркалим в localStorage, чтобы экран плана родов подхватил его на новом устройстве
if (p.birth_plan && typeof p.birth_plan === 'object') localStorage.setItem('alia_birth_plan', JSON.stringify(p.birth_plan));
const cyc = {
last_period: p.last_period || '',
cycle_length: p.cycle_length || 28,
period_length: p.period_length || 5,
luteal_phase: p.luteal_phase || null,
};
setCycleSt(cyc);
localStorage.setItem('alia_cycle_length', cyc.cycle_length);
localStorage.setItem('alia_period_length', cyc.period_length);
if (cyc.last_period) localStorage.setItem('alia_last_period', cyc.last_period);
if (cyc.luteal_phase) localStorage.setItem('alia_luteal_phase', cyc.luteal_phase);
// setup считается завершённым, если есть ПДР (беременность) или дата месячных (цикл)
if (p.due_date || p.last_period || localStorage.getItem('alia_setup_done')) setSetupDone(true);
})
.catch(() => {})
.finally(() => setProfileLoading(false));
}, [isAuth, refreshTick]); // eslint-disable-line
useEffect(() => {
applyColorScheme(colorScheme);
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyColorScheme(colorScheme);
if (mq.addEventListener) mq.addEventListener('change', handler);
return () => { if (mq.removeEventListener) mq.removeEventListener('change', handler); };
}, [colorScheme]);
function syncProfile(fields) {
if (isLoggedIn() && !isGuest) updateProfile(fields).catch(() => {});
}
const setDueDate = useCallback(d => { setDueDateSt(d); localStorage.setItem('alia_due_date', d); syncProfile({ due_date: d }); }, []); // eslint-disable-line
const setColorScheme = useCallback(id => { setColorSchemeSt(id); localStorage.setItem('alia_color_scheme', id); syncProfile({ color_scheme: id }); }, []); // eslint-disable-line
const setBabyName = useCallback(n => { setBabyNameSt(n); localStorage.setItem('alia_baby_name', n); syncProfile({ baby_name: n }); }, []); // eslint-disable-line
const setBabyGender = useCallback(g => { setBabyGenderSt(g); localStorage.setItem('alia_baby_gender', g); syncProfile({ baby_gender: g }); }, []); // eslint-disable-line
const setGoalAndMode = useCallback(g => {
const def = GOALS.find(x => x.id === g) || GOALS[0];
setGoalSt(g); setAppModeSt(def.mode);
localStorage.setItem('alia_goal', g); localStorage.setItem('alia_app_mode', def.mode);
syncProfile({ goal: g, app_mode: def.mode });
}, []); // eslint-disable-line
const saveCycleSettings = useCallback(c => {
// Запоминаем прежний «первый день месячных» ДО перезаписи — чтобы заменить
// его в истории, а не плодить дубли.
const prevLast = localStorage.getItem('alia_last_period') || null;
const newLast = c.last_period || null;
setCycleSt(c);
localStorage.setItem('alia_cycle_length', c.cycle_length);
localStorage.setItem('alia_period_length', c.period_length);
if (newLast) localStorage.setItem('alia_last_period', newLast); else localStorage.removeItem('alia_last_period');
if (c.luteal_phase) localStorage.setItem('alia_luteal_phase', c.luteal_phase); else localStorage.removeItem('alia_luteal_phase');
syncProfile({ cycle_length: c.cycle_length, period_length: c.period_length,
luteal_phase: c.luteal_phase, last_period: newLast });
// Только если ДАТА последних месячных реально изменилась — это исправление
// якоря: заменяем прежний старт новым (прочая история циклов не трогается).
// Если поменяли только длины/лютеин — историю не трогаем вообще.
if (newLast !== prevLast) {
const loggedIn = isLoggedIn() && !localStorage.getItem('alia_guest');
setPeriods(prev => {
let list = (prev || []).filter(p => p && p.start_date);
if (prevLast) list = list.filter(p => p.start_date !== prevLast);
if (newLast) list = list.filter(p => p.start_date !== newLast);
if (newLast) list.push({ start_date: newLast, end_date: null });
list.sort((a, b) => a.start_date.localeCompare(b.start_date));
localStorage.setItem('alia_periods', JSON.stringify(list));
return list;
});
if (loggedIn) {
if (newLast && saveCycle) saveCycle({ start_date: newLast }).catch(() => {});
// удаляем прежний якорь из БД (id берём из свежего списка)
if (prevLast && deleteCycle && getCycles) {
getCycles().then(res => {
const rows = (res && res.periods) || [];
rows.filter(r => r.start_date === prevLast && r.id).forEach(r => deleteCycle(r.id).catch(() => {}));
}).catch(() => {});
}
}
}
}, []); // eslint-disable-line
const handleAuth = useCallback(userData => {
if (userData.isGuest) { localStorage.setItem('alia_guest', '1'); setIsGuest(true); }
else {
localStorage.removeItem('alia_guest'); setIsGuest(false);
// Сохраняем имя/почту для экрана «Профиль» (офлайн-отображение)
if (userData.name != null) localStorage.setItem('alia_name', userData.name);
if (userData.email != null) localStorage.setItem('alia_email', userData.email);
}
if (userData.lang) { setLang(userData.lang); localStorage.setItem('alia_lang', userData.lang); }
setIsAuth(true);
}, []);
const handleSetupComplete = useCallback(payload => {
const { lang: l, goal: g, mode, due_date, baby_name, baby_gender, last_period, cycle_length, period_length } = payload;
setLang(l); localStorage.setItem('alia_lang', l);
setGoalSt(g); setAppModeSt(mode);
localStorage.setItem('alia_goal', g); localStorage.setItem('alia_app_mode', mode);
if (due_date) setDueDate(due_date);
if (baby_name) setBabyName(baby_name);
if (baby_gender) setBabyGender(baby_gender);
const cyc = { last_period: last_period || '', cycle_length: cycle_length || 28, period_length: period_length || 5, luteal_phase: null };
setCycleSt(cyc);
localStorage.setItem('alia_cycle_length', cyc.cycle_length);
localStorage.setItem('alia_period_length', cyc.period_length);
if (cyc.last_period) localStorage.setItem('alia_last_period', cyc.last_period);
localStorage.setItem('alia_setup_done', '1');
setSetupDone(true);
if (isLoggedIn() && !localStorage.getItem('alia_guest')) {
updateProfile({ lang: l, goal: g, app_mode: mode, due_date: due_date || null,
baby_name: baby_name || '', baby_gender: baby_gender || 'unknown',
last_period: last_period || null, cycle_length, period_length }).catch(() => {});
}
}, [setDueDate, setBabyName, setBabyGender]); // eslint-disable-line
const logPeriod = useCallback(() => {
const today = todayISO();
// Обновляем якорь «последние месячные», НЕ трогая прошлую историю циклов.
setCycleSt(c => ({ ...c, last_period: today }));
localStorage.setItem('alia_last_period', today);
syncProfile({ last_period: today });
// Добавляем сегодня как НОВЫЙ старт месячных (прошлые циклы сохраняются)
setPeriods(prev => {
if (prev.some(p => p.start_date === today)) return prev;
const next = [...prev, { start_date: today, end_date: null }].sort((a, b) => a.start_date.localeCompare(b.start_date));
localStorage.setItem('alia_periods', JSON.stringify(next));
return next;
});
if (isLoggedIn() && !isGuest && saveCycle) saveCycle({ start_date: today }).catch(() => {});
}, [isGuest]); // eslint-disable-line
// Load daily logs (memorable days) + period history from API for signed-in users
useEffect(() => {
if (!isAuth || isGuest || !isLoggedIn()) return;
if (getLogs) {
getLogs()
.then(res => {
const rows = (res && res.logs) || [];
const map = {};
rows.forEach(r => { if (r && r.log_date) map[r.log_date] = r.payload || {}; });
setLogs(map);
localStorage.setItem('alia_logs', JSON.stringify(map));
})
.catch(() => {});
}
if (getCycles) {
getCycles()
.then(res => {
const rows = (res && res.periods) || [];
const list = rows.map(r => ({ start_date: r.start_date, end_date: r.end_date || null }))
.filter(x => x.start_date);
setPeriods(list);
localStorage.setItem('alia_periods', JSON.stringify(list));
})
.catch(() => {});
}
}, [isAuth, refreshTick]); // eslint-disable-line
// Загрузка уведомлений с сервера
useEffect(() => {
if (!isAuth || !isLoggedIn()) return;
getNotifications && getNotifications()
.then(res => {
const list = (res && res.notifications) || [];
setNotifications(list);
localStorage.setItem('alia_notifications', JSON.stringify(list));
})
.catch(() => {});
}, [isAuth, refreshTick]); // eslint-disable-line
// Слушаем сообщения от service worker (push пришёл пока приложение открыто)
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
const handler = (event) => {
if (event.data && event.data.type === 'PUSH_NOTIFICATION') {
// Добавляем новое уведомление в начало списка
const p = event.data.payload;
const newNotif = {
id: Date.now(),
type: p.type || 'period_reminder',
title_ru: p.title_ru || '',
title_en: p.title_en || '',
body_ru: p.body_ru || '',
body_en: p.body_en || '',
is_read: false,
scheduled_for: p.scheduled_for || '',
created_at: new Date().toISOString(),
};
setNotifications(prev => {
const next = [newNotif, ...prev];
localStorage.setItem('alia_notifications', JSON.stringify(next));
return next;
});
}
if (event.data && event.data.type === 'OPEN_NOTIFICATIONS') {
setShowNotifications(true);
}
};
navigator.serviceWorker.addEventListener('message', handler);
return () => navigator.serviceWorker.removeEventListener('message', handler);
}, []); // eslint-disable-line
const handleMarkNotificationsRead = useCallback(() => {
setNotifications(prev => {
const next = prev.map(n => ({ ...n, is_read: true }));
localStorage.setItem('alia_notifications', JSON.stringify(next));
return next;
});
if (isLoggedIn()) markNotificationsRead && markNotificationsRead().catch(() => {});
}, []); // eslint-disable-line
const saveLogEntry = useCallback((dateISO, payload) => {
setLogs(prev => {
const next = { ...prev, [dateISO]: payload };
localStorage.setItem('alia_logs', JSON.stringify(next));
return next;
});
if (isLoggedIn() && !isGuest && saveLog) {
saveLog({ log_date: dateISO, payload }).catch(() => {});
}
}, [isGuest]); // eslint-disable-line
// Save full period history (from the editor): diff against current, sync to backend
const savePeriods = useCallback((episodes) => {
const list = (episodes || []).filter(e => e && e.start_date)
.map(e => ({ start_date: e.start_date, end_date: e.end_date || null }))
.sort((a, b) => a.start_date.localeCompare(b.start_date));
const prevStarts = periods.map(p => p.start_date);
const nextStarts = list.map(p => p.start_date);
setPeriods(list);
localStorage.setItem('alia_periods', JSON.stringify(list));
// keep last_period (latest start) in profile/cycle for the home ring fallback
const latest = list.length ? list[list.length - 1].start_date : '';
setCycleSt(c => {
const nc = { ...c, last_period: latest };
if (latest) localStorage.setItem('alia_last_period', latest); else localStorage.removeItem('alia_last_period');
return nc;
});
if (isLoggedIn() && !isGuest) {
if (latest) syncProfile({ last_period: latest });
if (saveCycle) list.forEach(p => saveCycle({ start_date: p.start_date, end_date: p.end_date }).catch(() => {}));
// delete removed starts
if (deleteCycle) {
const removed = prevStarts.filter(s => nextStarts.indexOf(s) === -1);
// backend deletes by id; we don't track ids here, so re-fetch and reconcile
if (removed.length && getCycles) {
getCycles().then(res => {
const rows = (res && res.periods) || [];
rows.filter(r => removed.indexOf(r.start_date) !== -1 && r.id)
.forEach(r => deleteCycle(r.id).catch(() => {}));
}).catch(() => {});
}
}
}
}, [periods, isGuest]); // eslint-disable-line
const toggleLang = useCallback(() => {
const nl = lang === 'ru' ? 'en' : 'ru';
setLang(nl); localStorage.setItem('alia_lang', nl); syncProfile({ lang: nl });
}, [lang]); // eslint-disable-line
// Обновление данных: перезапрашивает профиль/логи/циклы (через refreshTick в deps эффектов)
// и перемонтирует активный экран инструментов, чтобы он заново подтянул данные из БД.
const doRefresh = useCallback(() => {
if (refreshing) return;
setRefreshing(true);
setRefreshTick(t => t + 1);
setTimeout(() => setRefreshing(false), 900);
}, [refreshing]);
// Выход: чистим токен и ВСЕ локальные данные (alia_*), чтобы следующий
// аккаунт/гость не видел чужой кэш, затем перезагружаем на экран входа.
const doLogout = useCallback(() => {
apiLogout();
try {
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.indexOf('alia_') === 0) keys.push(k);
}
keys.forEach(k => localStorage.removeItem(k));
} catch (e) {}
window.location.reload();
}, []); // eslint-disable-line
// ── Routing ────────────────────────────────────────────────
if (!isAuth) return ;
if (profileLoading) return (
);
if (!setupDone) return ;
const currentWeek = calculateCurrentWeek(dueDate);
const isPreg = appMode === 'pregnancy';
const tabs = [
{ id:'home', Icon: IconHome, label: t('navHome', lang) },
{ id:'useful', Icon: IconCompass, label: t('navUseful', lang) },
{ id:'tools', Icon: IconTools, label: t('navTools', lang) },
];
const st = {
root: { display:'flex', flexDirection:'column', height:'100dvh', background:'var(--bg)', overflow:'hidden', fontFamily:'Nunito, sans-serif' },
header: { display:'flex', alignItems:'center', justifyContent:'space-between', padding:'12px 20px',
paddingTop:'calc(12px + env(safe-area-inset-top))', background:'var(--bg-card)',
borderBottom:'1px solid var(--border)', flexShrink:0, boxShadow:'0 1px 8px var(--shadow)' },
logo: { fontSize:22, fontWeight:900, color:'var(--primary)', letterSpacing:'-0.5px' },
headerRight: { display:'flex', alignItems:'center', gap:10 },
langBtn: { background:'var(--primary-light)', border:'none', borderRadius:8, padding:'6px 12px', fontSize:12,
fontWeight:800, color:'var(--primary)', cursor:'pointer', fontFamily:'Nunito,sans-serif', letterSpacing:'0.3px' },
settingsBtn: { background:'var(--bg)', border:'none', borderRadius:10, padding:'7px 10px', fontSize:18, cursor:'pointer' },
bellWrap: { position:'relative', display:'inline-flex' },
bellBtn: { background:'var(--bg)', border:'none', borderRadius:10, padding:'7px 10px', fontSize:18, cursor:'pointer', lineHeight:1 },
bellBadge: { position:'absolute', top:2, right:2, width:16, height:16, borderRadius:'50%',
background:'var(--primary)', color:'#fff', fontSize:10, fontWeight:900,
display:'flex', alignItems:'center', justifyContent:'center', pointerEvents:'none' },
refreshBtn: { background:'var(--bg)', border:'none', borderRadius:10, padding:'7px 10px', fontSize:18, cursor:'pointer',
lineHeight:1, display:'inline-flex', alignItems:'center', justifyContent:'center' },
screenWrap: { flex:1, overflow:'hidden', position:'relative' },
tabBar: { display:'flex', background:'var(--bg-card)', borderTop:'1px solid var(--border)', paddingBottom:'env(safe-area-inset-bottom)', flexShrink:0 },
tabItem: (active) => ({ flex:1, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center',
gap:3, padding:'10px 4px', border:'none', background:'none', cursor:'pointer',
color: active ? 'var(--primary)' : 'var(--text-2)', fontFamily:'Nunito,sans-serif', transition:'color .2s' }),
tabDot: { width:4, height:4, borderRadius:2, background:'var(--primary)' },
tabLabel: { fontSize:10, fontWeight:700, letterSpacing:'0.2px' },
};
// ── Pull-to-refresh: ищем ближайший скролл-контейнер и тянем, только если он вверху ──
function scrollableAtTop(target) {
let el = target;
while (el && el !== document.body && el.nodeType === 1) {
const oy = window.getComputedStyle(el).overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) return el.scrollTop <= 0;
el = el.parentElement;
}
return true;
}
const PULL_THRESHOLD = 60;
function onTouchStart(e) {
if (refreshing || !e.touches || !e.touches[0]) return;
pullRef.current = { startY: e.touches[0].clientY, active: scrollableAtTop(e.target) };
}
function onTouchMove(e) {
if (!pullRef.current.active || refreshing) return;
const dy = e.touches[0].clientY - pullRef.current.startY;
if (dy > 0) setPullY(Math.min(dy * 0.5, 80)); else setPullY(0);
}
function onTouchEnd() {
if (pullY >= PULL_THRESHOLD && !refreshing) doRefresh();
setPullY(0);
pullRef.current.active = false;
}
const pullActive = pullY > 0 || refreshing;
return (
🌸 Alia
setShowNotifications(true)} aria-label="notifications">
🔔
{unreadCount > 0 && (
{unreadCount > 9 ? '9+' : unreadCount}
)}
{lang === 'ru' ? 'EN' : 'RU'}
setShowSettings(true)}>⚙️
{pullActive && (
)}
{tab === 'home' && (isPreg
?
: setShowCycle(true)} onOpenCalendar={() => setShowCalendar(true)} onOpenPhaseInfo={ph => setPhaseInfo(ph)} />
)}
{tab === 'useful' && }
{tab === 'tools' && setToolsView(null)} refreshTick={refreshTick} />}
{tabs.map(({ id, Icon, label }) => (
{ if (id === 'tools') { setToolsView(null); setToolsKey(k => k + 1); } setTab(id); }}>
{tab === id &&
}
{label}
))}
{showSettings && (
setColorScheme(id)}
onProfile={() => { setShowSettings(false); setShowProfile(true); }}
onLangToggle={() => { toggleLang(); setShowSettings(false); }}
onEditDate={() => { setShowSettings(false); setShowEditDate(true); }}
onBabyInfo={() => { setShowSettings(false); setShowBabyInfo(true); }}
onGoal={() => { setShowSettings(false); setShowGoal(true); }}
onCycle={() => { setShowSettings(false); setShowCycle(true); }}
onLegal={() => { setShowSettings(false); setShowLegal(true); }}
onLogout={doLogout}
onClose={() => setShowSettings(false)} />
)}
{showLegal && (
setLegalDoc(id)}
onClose={() => setShowLegal(false)} />
)}
{legalDoc && (
setLegalDoc(null)}
onClose={() => { setLegalDoc(null); setShowLegal(false); }} />
)}
{showProfile && (
setShowProfile(false)} />
)}
{showEditDate && (
{ setDueDate(d); setShowEditDate(false); }} onClose={() => setShowEditDate(false)} />
)}
{showBabyInfo && (
{ setBabyName(name); setBabyGender(gender); setShowBabyInfo(false); }}
onClose={() => setShowBabyInfo(false)} />
)}
{showGoal && (
{ setGoalAndMode(g); setShowGoal(false); setTab('home'); }} onClose={() => setShowGoal(false)} />
)}
{showCycle && (
{ saveCycleSettings(c); setShowCycle(false); }} onClose={() => setShowCycle(false)} />
)}
{showCalendar && (
setShowPeriodEdit(true)}
onClose={() => setShowCalendar(false)} />
)}
{showPeriodEdit && (
{ savePeriods(eps); setShowPeriodEdit(false); }}
onClose={() => setShowPeriodEdit(false)} />
)}
{phaseInfo && (
setPhaseInfo(null)} />
)}
{showNotifications && NotificationsSheet && (
setShowNotifications(false)}
onMarkRead={() => { handleMarkNotificationsRead(); }}
onNewNotification={() => {
// Перезагружаем список с сервера после теста
window.AliaAPI.getNotifications && window.AliaAPI.getNotifications()
.then(res => {
const list = (res && res.notifications) || [];
setNotifications(list);
localStorage.setItem('alia_notifications', JSON.stringify(list));
}).catch(() => {});
}}
/>
)}
);
}
/* ── Shared sheet styles ─────────────────────────────────────── */
function sheetStyles() {
return {
overlay: { position:'fixed', inset:0, background:'rgba(0,0,0,0.4)', zIndex:200, display:'flex', alignItems:'flex-end', backdropFilter:'blur(4px)' },
sheet: { width:'100%', background:'var(--bg-card)', borderRadius:'24px 24px 0 0', padding:'20px 24px',
paddingBottom:'calc(28px + env(safe-area-inset-bottom))', display:'flex', flexDirection:'column', gap:16,
boxShadow:'0 -4px 24px rgba(0,0,0,0.15)', maxWidth:520, margin:'0 auto' },
handle: { width:40, height:4, borderRadius:2, background:'var(--border)', margin:'0 auto' },
title: { fontSize:18, fontWeight:800, color:'var(--text)' },
label: { fontSize:11, fontWeight:700, color:'var(--text-2)', letterSpacing:'0.5px', textTransform:'uppercase', marginBottom:8, display:'block' },
input: { width:'100%', padding:'14px 15px', borderRadius:13, border:'2px solid var(--border)', background:'var(--bg)',
color:'var(--text)', fontSize:16, fontFamily:'Nunito,sans-serif', outline:'none', boxSizing:'border-box' },
genderRow: { display:'flex', gap:10 },
genderBtn: (a) => ({ flex:1, padding:'13px 6px', borderRadius:15, border:'2px solid',
borderColor: a ? 'var(--primary)' : 'var(--border)', background: a ? 'var(--primary-light)' : 'var(--bg)',
color: a ? 'var(--primary)' : 'var(--text-2)', fontSize:11, fontWeight:700, cursor:'pointer',
fontFamily:'Nunito,sans-serif', display:'flex', flexDirection:'column', alignItems:'center', gap:6 }),
goalBtn: (a) => ({ display:'flex', alignItems:'center', gap:12, width:'100%', padding:'14px 16px', borderRadius:15, border:'2px solid',
borderColor: a ? 'var(--primary)' : 'var(--border)', background: a ? 'var(--primary-light)' : 'var(--bg)',
color: a ? 'var(--primary)' : 'var(--text)', fontSize:15, fontWeight:700, cursor:'pointer', fontFamily:'Nunito,sans-serif', textAlign:'left' }),
modeBtn: (a) => ({ flex:1, padding:'10px 8px', borderRadius:13, border:'2px solid',
borderColor: a ? 'var(--primary)' : 'var(--border)', background: a ? 'var(--primary-light)' : 'var(--bg)',
color: a ? 'var(--primary)' : 'var(--text-2)', fontSize:13, fontWeight:700, cursor:'pointer', fontFamily:'Nunito,sans-serif' }),
saveBtn: { padding:'15px 0', borderRadius:15, border:'none', background:'var(--primary)', color:'#fff', fontSize:16, fontWeight:800, cursor:'pointer', fontFamily:'Nunito,sans-serif' },
row: { display:'flex', alignItems:'center', justifyContent:'space-between', padding:'13px 0', borderBottom:'1px solid var(--border)', cursor:'pointer' },
rowLast: { display:'flex', alignItems:'center', justifyContent:'space-between', padding:'13px 0', cursor:'pointer' },
rowLabel: { fontSize:16, fontWeight:600, color:'var(--text)' },
rowValue: { fontSize:14, color:'var(--text-2)', fontWeight:500 },
schemeSection: { padding:'4px 0 12px', borderBottom:'1px solid var(--border)' },
schemeLbl: { fontSize:16, fontWeight:600, color:'var(--text)', marginBottom:12 },
swatches: { display:'flex', flexWrap:'wrap', gap:12, alignItems:'center' },
swatch: (active, color) => ({ width: active ? 34 : 28, height: active ? 34 : 28, borderRadius:'50%', background: color,
cursor:'pointer', border: active ? '3px solid var(--text)' : '2px solid transparent',
boxShadow: active ? '0 0 0 2px var(--bg-card), 0 2px 8px rgba(0,0,0,0.2)' : '0 2px 6px rgba(0,0,0,0.15)', transition:'all .2s', flexShrink:0 }),
};
}
ReactDOM.createRoot(document.getElementById('root')).render( );
})();