// 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"}
setName(e.target.value)} />
{GENDERS.map(g => ( ))}
); } /* ── 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 => ( ))}
); } /* ── 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)}
setLast(e.target.value)} />
setCycleLen(e.target.value)} onBlur={() => setCycleLen(clamp(cycleLen, 20, 45, 28))} />
setPeriodLen(e.target.value)} onBlur={() => setPeriodLen(clamp(periodLen, 1, 10, 5))} />
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.'}
); } /* ── 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)}
setVal(e.target.value)} />
); } /* ── 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.'}
); } return (
{formatDayMonth(todayISO(), lang)}
{ru ? 'День цикла' : 'Cycle day'}
{info.cycleDay}
{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)}
); } /* ── 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 (
🌸
Alia
); 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
{unreadCount > 0 && (
{unreadCount > 9 ? '9+' : unreadCount}
)}
{pullActive && (
)}
{tab === 'home' && (isPreg ? : setShowCycle(true)} onOpenCalendar={() => setShowCalendar(true)} onOpenPhaseInfo={ph => setPhaseInfo(ph)} /> )} {tab === 'useful' && } {tab === 'tools' && setToolsView(null)} refreshTick={refreshTick} />}
{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(); })();