// App.jsx — root, routing, sidebar, responsive layout // (syncPushPrefsToIDB is defined in index.html before this script loads) const I18N = { es: { home: 'Inicio', map: 'Mapa', report: 'Reportar', news: 'Novedades', profile: 'Perfil', frequency: 'Frecuencia', normalService: 'Normal', delays: 'Demoras', alert: 'Alerta', nextTrain: 'Próximo tren', endToEnd: 'punta a punta', allNormal: 'Todo funcionando con normalidad', linesWithIssues: 'líneas con novedades', liveUpdate: 'En vivo', openMap: 'Ver mapa', reportIncident: 'Reportar', nearYou: 'Cerca tuyo', status: 'Estado', schematic: 'Esquema', geographic: 'Mapa real', liveTrains: 'Trenes en vivo', whatHappened: '¿Qué está pasando?', whichLine: '¿Qué línea?', addDetails: 'Agregá detalles opcionales...', photo: 'Foto', pickStation: 'Elegir estación', sendReport: 'Enviar reporte', thanks: '¡Gracias!', reportSent: 'Tu reporte ayuda a otros pasajeros. Lo vamos a revisar en los próximos minutos.', backHome: 'Volver al inicio', assistant: 'Asistente', askMe: 'Preguntame algo...', settings: 'Ajustes', appearance: 'Apariencia', theme: 'Tema', language: 'Idioma', notifications: 'Notificaciones', data: 'Datos y privacidad', aboutApp: 'Acerca de', about: 'Acerca de', yourLocation: 'Tu ubicación', }, en: { home: 'Home', map: 'Map', report: 'Report', news: 'News', profile: 'Profile', frequency: 'Frequency', normalService: 'Normal', delays: 'Delays', alert: 'Alert', nextTrain: 'Next train', endToEnd: 'end to end', allNormal: 'All lines running normally', linesWithIssues: 'lines with issues', liveUpdate: 'Live', openMap: 'Open map', reportIncident: 'Report', nearYou: 'Near you', status: 'Status', schematic: 'Schematic', geographic: 'Geographic', liveTrains: 'Live trains', whatHappened: "What's happening?", whichLine: 'Which line?', addDetails: 'Add optional details...', photo: 'Photo', pickStation: 'Pick station', sendReport: 'Send report', thanks: 'Thanks!', reportSent: "Your report helps other passengers. We'll review it in the next few minutes.", backHome: 'Back home', assistant: 'Assistant', askMe: 'Ask me something...', settings: 'Settings', appearance: 'Appearance', theme: 'Theme', language: 'Language', notifications: 'Notifications', data: 'Data & privacy', aboutApp: 'About', about: 'About', yourLocation: 'Your location', } }; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "theme": "light", "lang": "es" }/*EDITMODE-END*/; // Breakpoint for sidebar / desktop layout const DESKTOP_BP = 768; function useIsDesktop() { const [wide, setWide] = React.useState(window.innerWidth >= DESKTOP_BP); React.useEffect(() => { const fn = () => setWide(window.innerWidth >= DESKTOP_BP); window.addEventListener('resize', fn); return () => window.removeEventListener('resize', fn); }, []); return wide; } function App() { const initial = (() => { try { return JSON.parse(localStorage.getItem('alsubte') || '{}'); } catch { return {}; } })(); const [screen, setScreen] = React.useState(initial.screen || 'main'); const [params, setParams] = React.useState(initial.params || {}); const [theme, setTheme] = React.useState(initial.theme || TWEAK_DEFAULTS.theme || 'auto'); const [lang, setLang] = React.useState(initial.lang || TWEAK_DEFAULTS.lang); const [authed, setAuthed] = React.useState(false); const [pushPrefs, setPushPrefs] = React.useState(() => { try { const saved = JSON.parse(localStorage.getItem('alsubte-push-prefs') || 'null'); if (saved && typeof saved === 'object') return { ...PUSH_DEFAULTS, ...saved, lines: { ...PUSH_DEFAULTS.lines, ...(saved.lines || {}) }, lineConfig: { ...PUSH_DEFAULTS.lineConfig, ...(saved.lineConfig || {}) } }; } catch {} return PUSH_DEFAULTS; }); const [tweaksOpen, setTweaksOpen] = React.useState(false); const isDesktop = useIsDesktop(); // PWA install prompt — captured on beforeinstallprompt, shown after visiting a line detail const [installPrompt, setInstallPrompt] = React.useState(null); const [showInstallBanner, setShowInstallBanner] = React.useState(false); const hasVisitedLineDetail = React.useRef(false); React.useEffect(() => { const handler = (e) => { e.preventDefault(); setInstallPrompt(e); }; window.addEventListener('beforeinstallprompt', handler); return () => window.removeEventListener('beforeinstallprompt', handler); }, []); React.useEffect(() => { if (screen === 'lineDetail') hasVisitedLineDetail.current = true; if (hasVisitedLineDetail.current && installPrompt && !showInstallBanner) { const dismissed = localStorage.getItem('pwa-install-dismissed'); if (!dismissed) setShowInstallBanner(true); } }, [screen, installPrompt]); // eslint-disable-line const t = React.useCallback((k) => I18N[lang][k] || k, [lang]); React.useEffect(() => { if (theme === 'auto') { const mq = window.matchMedia('(prefers-color-scheme: dark)'); const apply = () => document.documentElement.setAttribute('data-theme', mq.matches ? 'dark' : 'light'); apply(); mq.addEventListener('change', apply); return () => mq.removeEventListener('change', apply); } else { document.documentElement.setAttribute('data-theme', theme); } }, [theme]); React.useEffect(() => { localStorage.setItem('alsubte', JSON.stringify({ screen, params, theme, lang })); }, [screen, params, theme, lang]); React.useEffect(() => { localStorage.setItem('alsubte-push-prefs', JSON.stringify(pushPrefs)); if (window.syncPushPrefsToIDB) window.syncPushPrefsToIDB(pushPrefs); }, [pushPrefs]); React.useEffect(() => { const handler = (e) => { if (e.data?.type === '__activate_edit_mode') setTweaksOpen(true); if (e.data?.type === '__deactivate_edit_mode') setTweaksOpen(false); }; window.addEventListener('message', handler); window.parent.postMessage({ type: '__edit_mode_available' }, '*'); return () => window.removeEventListener('message', handler); }, []); // ── Browser history integration ─────────────────────────── React.useEffect(() => { // Stamp the current entry so it has state (handles hard-reload / direct URL) window.history.replaceState( { screen: initial.screen || 'main', params: initial.params || {} }, '', window.location.pathname + window.location.search ); const onPop = (e) => { const st = e.state; if (st?.screen) { setScreen(st.screen); setParams(st.params || {}); } else { setScreen('main'); setParams({}); } }; window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, []); // eslint-disable-line react-hooks/exhaustive-deps const go = React.useCallback((name, p = {}) => { setScreen(name); setParams(p); window.history.pushState({ screen: name, params: p }, '', window.location.pathname + window.location.search); }, []); const appProps = { go, t, theme, setTheme, lang, setLang, authed, setAuthed, pushPrefs, setPushPrefs, isDesktop }; const renderScreen = () => { // Desktop main → full-width dashboard (no .page wrapper) if (isDesktop && screen === 'main') return ; if (isDesktop && screen === 'incidentReport') return ; // Map is full-bleed at any size if (screen === 'map') return ; // Every other screen gets centered + max-width on desktop via .page const narrow = (child) =>
{child}
; const wide = (child) =>
{child}
; switch (screen) { case 'main': return narrow(); case 'lineDetail': return wide(); case 'news': return narrow(); case 'chat': return narrow(); case 'incidentReport': return narrow(); case 'about': return narrow(); case 'profile': return narrow(); case 'auth': return narrow(); case 'settings': return narrow(); case 'legal': return narrow(); default: return narrow(); } }; const showBottomNav = !isDesktop && !['lineDetail','chat','about','incidentReport','legal'].includes(screen); return (
{/* BETA ribbon — top-left corner, above everything */}
BETA
{/* Sidebar — rendered by CSS only on ≥768px */} {/* Main content */}
{renderScreen()}
{showBottomNav && ( go(id)} t={t} /> )}
{/* PWA install banner */} {showInstallBanner && (
Instalar alSubte
Acceso rápido desde tu pantalla
)} {/* Tweaks panel */}

Tweaks

Tema
Idioma
); } function saveTweak(key, val, setter) { setter(val); window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: val } }, '*'); } /* ── Sidebar ─────────────────────────────────────────────── */ function AppSidebar({ screen, go, t, authed }) { const items = [ { id: 'main', label: t('home'), icon: 'home' }, { id: 'map', label: t('map'), icon: 'map' }, { id: 'news', label: t('news'), icon: 'news' }, { id: 'settings', label: t('settings'), icon: 'settings'}, { id: 'about', label: t('about'), icon: 'info' }, ]; const activeId = ['lineDetail','incidentReport'].includes(screen) ? 'main' : screen; return ( ); } /* ── Desktop home ────────────────────────────────────────── */ function DesktopHome({ go, t }) { const [tick, setTick] = React.useState(0); React.useEffect(() => { const i = setInterval(() => setTick(x=>x+1), 90); return ()=>clearInterval(i); }, []); const lineStatus = useLiveStatus(); const wx = useWeather(); const totalAlerts = Object.entries(lineStatus).filter(([id, s]) => s.state !== 'normal' && getServiceState(id).state !== 'closed' ).length; const allClosedInfo = getAllClosedInfo(); const [annDismissed, setAnnDismissed] = React.useState(() => loadDismissed()); const activeAnns = React.useMemo(() => getActiveAnnouncements(annDismissed), [annDismissed, tick]); const dismissAnn = (id) => { const next = new Set(annDismissed); next.add(id); saveDismissed(next); setAnnDismissed(next); }; const [statusEvents, setStatusEvents] = React.useState([]); React.useEffect(() => { loadStatusEvents(5).then(evs => setStatusEvents(evs || [])); }, []); return (
{/* Page header */}

Estado del Subte

{t('liveUpdate')} · {new Date().toLocaleTimeString('es-AR',{hour:'2-digit',minute:'2-digit'})} {wx && ( {wx.icon} {wx.temp}° ST {wx.feels}° {wx.desc} )}
{/* All-closed banner */} {allClosedInfo.allClosed && (
Servicio finalizado en toda la red
Inicio del servicio nuevamente {allClosedInfo.opensDay} a las {allClosedInfo.opensAt}
)} {/* Anuncios trascendentales */} {activeAnns.length > 0 && (
{activeAnns.map(a => dismissAnn(a.id)} />)}
)} {/* Line grid — 4 cols on wide, 3 on medium (via CSS class) */}
{LINES.map(line => { const st = lineStatus[line.id]; const isDark = line.id === 'H' || line.id === 'P'; const txt = isDark ? '#0B1220' : '#fff'; const sub = isDark ? 'rgba(11,18,32,.7)' : 'rgba(255,255,255,.8)'; const hasClosed = line.stations.some(s => CLOSED_TODAY.includes(s)); const svc = getServiceState(line.id); const closed = svc.state === 'closed'; const closingSoon = svc.state === 'closing-soon'; const minsLeft = closingSoon ? Math.max(1, Math.round(svc.minsUntilClose)) : null; return ( ); })}
{/* Bottom: map + novedades/reportes */}

Mapa de la red

l.id)} showTrains={false} showEntrances={false} mini={true} onLineTap={() => go('map')} />

Novedades

{statusEvents.length === 0 ? (
Sin novedades en los últimos 5 días.
) : ( statusEvents.slice(0, 5).map((ev, i) => ) )}
); } ReactDOM.createRoot(document.getElementById('root')).render( );