// Bortec Cycling — Entrenamientos App (Free) // Implementation of the "Entrenamientos App - Free" design. // Responsive: mobile uses a bottom tab bar, desktop (>=900px) uses a left sidebar. const C = { accent: '#37cc50', accentLight: '#5fe077', panel: '#121212', card: '#1c1c1c', cardAlt: '#262626', text: '#f2f2f2', textSoft: '#a8a8a8', textMuted: '#c4c4c4', border: 'rgba(255,255,255,0.16)', borderLight: 'rgba(255,255,255,0.18)', greenBg: '#1f4d2a', greenText: '#e3fbe7', greenTextLight: '#8ce89c', }; const inputStyle = { minHeight: 42, padding: '8px 12px', fontSize: 14, color: C.text, background: C.card, border: `1px solid ${C.border}`, borderRadius: 8, outline: 'none', }; const smallInputStyle = { ...inputStyle, minHeight: 40, fontSize: 13.5 }; const labelStyle = { fontSize: 12, color: C.textSoft }; function primaryBtn(height = 46, fontSize = 15) { return { height, borderRadius: 8, border: `1px solid ${C.accent}`, background: 'transparent', color: C.accent, fontWeight: 500, fontSize, fontFamily: 'Inter, sans-serif', cursor: 'pointer', }; } function ghostBtn(height = 46, fontSize = 13.5, color = '#f2f2f2') { return { height, borderRadius: 8, border: `1px solid ${C.borderLight}`, background: 'transparent', color, fontWeight: 500, fontSize, fontFamily: 'Inter, sans-serif', cursor: 'pointer', }; } function badge(bg, color, border) { return { display: 'inline-flex', alignItems: 'center', fontSize: 11, padding: '3px 10px', borderRadius: 6, background: bg, color, border, }; } function heading(size, margin) { return { margin: margin ?? 0, fontSize: size, fontWeight: 500, color: C.text, fontFamily: "'Oswald', sans-serif", textTransform: 'uppercase', letterSpacing: '0.01em', lineHeight: size >= 24 ? 1.2 : undefined, }; } const statCard = { borderRadius: 8, background: C.card, padding: '12px 10px', display: 'flex', flexDirection: 'column', gap: 4, }; const sectionCard = { borderRadius: 10, background: C.card, padding: 12, display: 'flex', flexDirection: 'column', gap: 6, }; const tableHeadCell = { textAlign: 'left', fontSize: 10.5, letterSpacing: '0.06em', textTransform: 'uppercase', color: C.textSoft, padding: '6px 4px', borderBottom: `1px solid ${C.border}`, }; const tableCell = { padding: '8px 4px', borderBottom: '1px solid rgba(255,255,255,0.08)' }; const PLAN_PHASES = [ { name: 'Calentamiento', duration: '10 min', zone: 'Z1 · Muy ligero', rpe: '2/10' }, { name: 'Bloque principal', duration: '3 × 5 min', zone: 'Z2–Z3 · Moderado', rpe: '5/10' }, { name: 'Recuperación', duration: '2 min c/u', zone: 'Z1 · Muy ligero', rpe: '2/10' }, { name: 'Enfriamiento', duration: '10 min', zone: 'Z1 · Muy ligero', rpe: '2/10' }, ]; const SUGGESTED_RACES = [ { id: 'r1', name: 'Vuelta al Ajusco', date: '20 sep', location: 'CDMX', distance: '110 km', note: 'Buen siguiente reto después de tu base — subidas moderadas.' }, { id: 'r2', name: 'Copa Fondista Toluca', date: '8 nov', location: 'Toluca', distance: '60 km', note: 'Ideal si quieres tu primera carrera con grupo.' }, { id: 'r3', name: 'Reto Bajío', date: '14 feb', location: 'Guanajuato', distance: '95 km', note: 'La proponemos para el grupo de principiantes cada año.' }, ]; const WEEK_LABELS = ['L', 'M', 'X', 'J', 'V', 'S', 'D']; const WEEK_TYPES = ['rest', 'done', 'done', 'rest', 'today', 'plan', 'plan']; const MONTH_NAMES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; const MONTH_SHORT = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; const TAB_SCREENS = ['inicio', 'plan', 'historial', 'chat', 'carreras']; const CHAT_LIMIT = 3; const NAV_ICONS = { inicio: (color) => ( ), plan: (color) => ( ), historial: (color) => ( ), chat: (color) => ( ), carreras: (color) => ( ), }; class App extends React.Component { state = { bootLoading: true, stage: 'onboarding', isDesktop: (typeof window !== 'undefined' && window.innerWidth >= 900), onboardingStep: 0, loginEmail: '', loginPassword: '', authMode: 'login', authLoading: false, authError: null, authInfo: null, userId: null, screen: 'inicio', chatInput: '', chatMessages: [ { from: 'bot', text: '¡Hola! Soy tu asistente de entrenamiento. Pregúntame lo que quieras sobre tu plan de hoy.' }, ], chatResponsesUsed: 0, introDone: false, intake: { fullName: '', contact: '', age: '', years: '', level: '', modality: '', days: [], schedule: '', ftp: '', weeklyKm: '', health: '', priorPlan: '', }, intakeSaving: false, intakeError: null, perfil: null, marcas: [], pendingMark: null, hasCoachNotification: true, racesTab: 'mios', raceGoals: [ { name: 'Gran Fondo CDMX', date: '12 oct', distance: '80 km' }, ], addRaceOpen: false, newRaceName: '', newRaceDate: '', newRaceDistance: '', suggestedRacesMarked: {}, connectedAccounts: [ { id: 'garmin', name: 'Garmin Connect', connected: false }, { id: 'tp', name: 'TrainingPeaks', connected: true }, ], pushNotifications: true, userPlan: 'free', planLocked: true, liveElapsed: 0, livePaused: false, livePhaseIdx: 0, lastSessionDuration: '00:00', selectedRpe: 5, range: 'semana', rangeOffset: 0, }; componentDidMount() { this._timer = setInterval(() => { if (this.state.screen === 'liveSession' && !this.state.livePaused) { this.setState(s => ({ liveElapsed: s.liveElapsed + 1 })); } }, 1000); this._onResize = () => this.setState({ isDesktop: window.innerWidth >= 900 }); window.addEventListener('resize', this._onResize); this.bootstrapAuth(); } componentWillUnmount() { clearInterval(this._timer); window.removeEventListener('resize', this._onResize); } async bootstrapAuth() { try { const { data: { session } } = await window.sb.auth.getSession(); if (session && session.user) { await this.loadUserData(session.user.id); } } catch (err) { console.error('No se pudo restaurar la sesión', err); } finally { this.setState({ bootLoading: false }); } } async loadUserData(userId) { const [{ data: perfilRow, error: perfilErr }, { data: marcasRows, error: marcasErr }] = await Promise.all([ window.sb.from('perfil').select('*').eq('user_id', userId).maybeSingle(), window.sb.from('marcas').select('*').eq('user_id', userId).order('fecha', { ascending: false }), ]); if (perfilErr) console.error('Error cargando perfil', perfilErr); if (marcasErr) console.error('Error cargando marcas', marcasErr); this.setState({ userId, perfil: perfilRow || null, marcas: marcasRows || [], introDone: !!perfilRow, stage: perfilRow ? 'app' : 'intake', screen: 'inicio', }); } classifyMark(text) { if (/\d{1,2}:\d{2}(:\d{2})?/.test(text)) return 'tiempo'; if (/\d+\s?(w\b|watts?)/i.test(text)) return 'potencia'; if (/\d+\s?(km\/h|kmh)/i.test(text)) return 'velocidad'; if (/(récord|record|marca|mejor tiempo|\bpr\b|logré)/i.test(text)) return 'record'; return 'marca'; } formatFecha(iso) { const d = new Date(iso); return d.getDate() + ' ' + MONTH_SHORT[d.getMonth()].toLowerCase(); } async handleAuthSubmit() { const { loginEmail, loginPassword, authMode } = this.state; const email = loginEmail.trim(); const password = loginPassword; if (!email || !password) { this.setState({ authError: 'Ingresa tu correo y contraseña.' }); return; } this.setState({ authLoading: true, authError: null, authInfo: null }); try { if (authMode === 'register') { const { data, error } = await window.sb.auth.signUp({ email, password }); if (error) throw error; if (!data.session) { this.setState({ authLoading: false, authMode: 'login', authInfo: 'Cuenta creada. Revisa tu correo para confirmarla y luego inicia sesión.' }); return; } await this.loadUserData(data.user.id); } else { const { data, error } = await window.sb.auth.signInWithPassword({ email, password }); if (error) throw error; await this.loadUserData(data.user.id); } this.setState({ authLoading: false, loginPassword: '' }); } catch (err) { this.setState({ authLoading: false, authError: err.message || 'Ocurrió un error, intenta de nuevo.' }); } } async logout() { try { await window.sb.auth.signOut(); } catch (err) { console.error('Error al cerrar sesión', err); } this.setState({ stage: 'login', screen: 'inicio', loginEmail: '', loginPassword: '', userId: null, perfil: null, marcas: [], introDone: false, authMode: 'login', authError: null, authInfo: null, }); } answerFor(text) { const t = text.toLowerCase(); if (t.includes('zona')) return { text: 'Las zonas de FC miden qué tan duro trabajas: Zona 1-2 son ritmos suaves para construir base, Zona 3+ ya es esfuerzo alto. Hoy tu plan pide Zona 2 — cómodo, conversacional.', escalate: false }; if (t.includes('dolor') || t.includes('lesion') || t.includes('lesión')) return { text: 'Si sientes dolor agudo, detén el entrenamiento y descansa. Las molestias musculares leves son normales, el dolor articular no.', escalate: true }; if (t.includes('plan') || t.includes('cambiar') || t.includes('ajustar')) return { text: 'Puedo darte pautas generales, pero los cambios de plan los confirma tu entrenador.', escalate: true }; if (t.includes('comer') || t.includes('comida') || t.includes('nutri')) return { text: 'Antes de rodar, algo ligero en carbohidratos 1-2 horas antes basta. Después del entreno, prioriza proteína y buena hidratación.', escalate: false }; return { text: 'Buena pregunta — no tengo una respuesta certera para eso.', escalate: true }; } seededRandom(seed) { const x = Math.sin(seed) * 10000; return x - Math.floor(x); } getMonday(d) { const date = new Date(d); const day = date.getDay(); const diff = (day === 0 ? -6 : 1) - day; date.setDate(date.getDate() + diff); date.setHours(0, 0, 0, 0); return date; } detectMark(text) { const hasTimeOrNumber = /\d{1,2}:\d{2}(:\d{2})?/.test(text) || /\d+\s?(km\/h|kmh|w\b|watts?)/i.test(text); const hasMarkWord = /(récord|record|marca|mejor tiempo|\bpr\b|logré)/i.test(text); return hasTimeOrNumber || hasMarkWord; } async confirmMark(save) { const mark = this.state.pendingMark; if (!mark) return; if (!save) { this.setState(s => ({ pendingMark: null, chatMessages: [...s.chatMessages, { from: 'bot', label: 'Asistente IA', text: 'Entendido, no la guardé.' }], })); return; } const { userId } = this.state; const tipo = this.classifyMark(mark); this.setState({ pendingMark: null }); const { data, error } = await window.sb.from('marcas') .insert({ user_id: userId, tipo, valor: mark, fuente: 'chat' }) .select() .maybeSingle(); if (error) { this.setState(s => ({ chatMessages: [...s.chatMessages, { from: 'bot', label: 'Asistente IA', text: 'No pude guardar la marca (' + error.message + ').' }], })); return; } this.setState(s => ({ marcas: [data, ...s.marcas], chatMessages: [...s.chatMessages, { from: 'bot', label: 'Asistente IA', text: 'Guardado en tus marcas ✅' }], })); } sendText(text) { const clean = (text || '').trim(); if (!clean || this.state.pendingMark) return; if (this.detectMark(clean)) { this.setState(s => ({ chatInput: '', chatMessages: [...s.chatMessages, { from: 'user', text: clean }, { from: 'bot', label: 'Asistente IA', text: 'Parece una marca nueva: "' + clean + '". ¿La guardo en tu perfil de marcas?' }], pendingMark: clean, })); return; } if (this.state.chatResponsesUsed >= CHAT_LIMIT) return; const { text: reply, escalate } = this.answerFor(clean); this.setState(s => { const msgs = [...s.chatMessages, { from: 'user', text: clean }, { from: 'bot', label: 'Asistente IA', text: reply }]; if (escalate) msgs.push({ from: 'system', text: 'Notificado a Ana, tu entrenadora — te responderá aquí mismo.' }); return { chatInput: '', chatMessages: msgs, chatResponsesUsed: s.chatResponsesUsed + 1 }; }); if (escalate) { setTimeout(() => { this.setState(s => ({ chatMessages: [...s.chatMessages, { from: 'coach', label: 'Ana · Entrenadora', text: 'Vi tu mensaje — lo revisamos en tu próxima sesión, mientras tanto no fuerces el ritmo.' }], })); }, 2200); } } go(screen, extra) { this.setState({ screen, ...(extra || {}) }); } renderOnboarding() { const { onboardingStep } = this.state; const next = () => { if (onboardingStep === 0) this.setState({ onboardingStep: 1 }); else this.setState({ stage: 'login' }); }; const dot = (active) => ({ height: 4, borderRadius: 2, width: active ? 22 : 10, background: active ? C.accent : '#3a3a3a' }); const photo = (label) => (
{label}
); return (
Bortec Cycling {onboardingStep === 0 && (
{photo('foto: ciclista rodando en montaña')}

Entrena con un plan hecho para ti

Planes claros, tu progreso siempre visible, y el respaldo real de tu entrenador.

)} {onboardingStep === 1 && (
{photo('foto: ciclista en carrera')}

Habla directo con tu entrenador

Resuelve dudas al instante y recibe comentarios sobre cada sesión que completas.

)}
); } renderLogin() { const { loginEmail, loginPassword, authMode, authLoading, authError, authInfo } = this.state; const isRegister = authMode === 'register'; const submit = () => this.handleAuthSubmit(); return (
Bortec Cycling

{isRegister ? 'Crea tu cuenta' : 'Inicia sesión'}

{isRegister ? 'Regístrate para armar tu plan y hablar con tu entrenador.' : 'Entra para ver tu plan y hablar con tu entrenador.'}

this.setState({ loginEmail: e.target.value })} placeholder="Correo" style={inputStyle} /> this.setState({ loginPassword: e.target.value })} onKeyDown={e => { if (e.key === 'Enter') submit(); }} placeholder="Contraseña" type="password" style={inputStyle} />
{authError &&

{authError}

} {authInfo &&

{authInfo}

}
{isRegister ? (

¿Ya tienes cuenta? { e.preventDefault(); this.setState({ authMode: 'login', authError: null, authInfo: null }); }}>Inicia sesión

) : (

¿Nuevo aquí? { e.preventDefault(); this.setState({ authMode: 'register', authError: null, authInfo: null }); }}>Crea tu cuenta

)}
); } renderIntake() { const { intake, intakeSaving, intakeError } = this.state; const setField = (field) => (e) => this.setState(s => ({ intake: { ...s.intake, [field]: e.target.value } })); const toggleDay = (key) => () => this.setState(s => ({ intake: { ...s.intake, days: s.intake.days.includes(key) ? s.intake.days.filter(d => d !== key) : [...s.intake.days, key] }, })); const submit = async () => { const { userId } = this.state; if (!userId) { this.setState({ intakeError: 'Sesión no válida, inicia sesión de nuevo.' }); return; } this.setState({ intakeSaving: true, intakeError: null }); const row = { user_id: userId, nombre: intake.fullName.trim(), contacto: intake.contact.trim(), edad: intake.age.trim(), anos_entrenando: intake.years.trim(), nivel: intake.level, modalidad: intake.modality, dias_disponibles: intake.days, disponibilidad_semana: intake.schedule.trim(), disponibilidad_finde: intake.weeklyKm.trim(), ftp: intake.ftp.trim(), condiciones_salud: intake.health.trim(), acepta_responsabilidad: intake.priorPlan === 'Acepto', }; const { data, error } = await window.sb.from('perfil') .upsert(row, { onConflict: 'user_id' }) .select() .maybeSingle(); if (error) { this.setState({ intakeSaving: false, intakeError: error.message }); return; } this.setState({ intakeSaving: false, perfil: data, introDone: true, stage: 'app', screen: 'intakeInvite' }); }; return (

Cuéntanos de ti

Esto ayuda a tu entrenador a armar tu plan. Toma 2 minutos.

{WEEK_LABELS.map(key => { const selected = intake.days.includes(key); return (
{key}
); })}

Declaro que la información entregada es correcta y que participo en las actividades y planes de entrenamiento de Bortec Cycling bajo mi propia responsabilidad, liberando a Bortec Cycling de cualquier lesión o daño derivado de mi práctica del ciclismo.

{['Acepto', 'No acepto'].map(label => { const active = intake.priorPlan === label; return (
this.setState(s => ({ intake: { ...s.intake, priorPlan: label } }))} style={{ padding: '7px 16px', fontSize: 12.5, cursor: 'pointer', color: active ? C.accent : C.text, boxShadow: active ? `inset 0 0 0 1px ${C.accent}` : 'none', }}>{label}
); })}
{intakeError &&

{intakeError}

}
); } renderSidebar() { const { screen } = this.state; const itemStyle = (active) => ({ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 10px', borderRadius: 8, cursor: 'pointer', background: active ? 'rgba(55,204,80,0.12)' : 'transparent', }); const color = (active) => active ? C.accent : C.textSoft; const item = (key, label, onClick) => (
{NAV_ICONS[key](color(screen === key))} {label}
); return (
Bortec Cycling {item('inicio', 'Inicio', () => this.go('inicio'))} {item('plan', 'Plan', () => this.go('plan'))} {item('historial', 'Progreso', () => this.go('historial'))} {item('chat', 'Asistente', () => this.go('chat'))} {item('carreras', 'Carreras', () => this.go('carreras'))}
this.go('profile')} style={itemStyle(screen === 'profile')}>
{(this.props.userName || '?').trim().charAt(0).toUpperCase() || '?'}
Perfil
); } renderHeader() { const { screen, chatResponsesUsed } = this.state; const { userName } = this.props; const isDesktop = this.state.isDesktop; if (!TAB_SCREENS.includes(screen)) return null; const chatAvailable = chatResponsesUsed < CHAT_LIMIT; return (
Bortec Cycling
this.go('profile')} style={{ width: 26, height: 26, borderRadius: '50%', background: C.card, border: `1px solid ${C.borderLight}`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.text, cursor: 'pointer' }}>{(userName || '?').trim().charAt(0).toUpperCase() || '?'}
{screen === 'inicio' && (
Hola, {userName}

Tu semana

Racha: 4 días
)} {screen === 'plan' && (
this.go('inicio')} style={{ cursor: 'pointer', flex: 'none' }}>
Plan de hoy

Resistencia base

)} {screen === 'historial' && (
Progreso

Tu evolución

)} {screen === 'chat' && (
Asistente
{chatAvailable && {Math.max(CHAT_LIMIT - chatResponsesUsed, 0)}/{CHAT_LIMIT} respuestas}

Resuelve tus dudas

)} {screen === 'carreras' && (
Carreras

Tus objetivos

)}
); } renderInicio() { const { weeklySessionGoal, showPowerMetric } = this.props; const { hasCoachNotification } = this.state; const sessionsDone = 3; const weekDays = WEEK_LABELS.map((label, i) => { const t = WEEK_TYPES[i]; const isToday = t === 'today'; return { label, bg: isToday ? '#1a2e1e' : 'transparent', labelColor: isToday ? C.greenTextLight : C.textSoft, dotColor: t === 'done' ? C.accent : (t === 'today' || t === 'plan') ? '#2a8f3c' : 'transparent', }; }); return (
{hasCoachNotification && (
{ this.setState({ hasCoachNotification: false }); this.go('historial'); }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10, background: C.greenBg, cursor: 'pointer' }}>
Ana (tu entrenador) comentó tu última sesión
Ver
)}
FC reposo 58 bpm
{showPowerMetric && (
Potencia prom. 142 W
)} {!showPowerMetric && (
Esfuerzo (RPE) 4/10
)}
Sesiones {sessionsDone}/{weeklySessionGoal}
Esta semana { e.preventDefault(); this.go('historial'); }} style={{ fontSize: 12, color: C.accent, textDecoration: 'none' }}>Ver todo
{weekDays.map((d, i) => (
{d.label}
))}
Vas por buen camino. Cuatro semanas seguidas entrenando — la constancia es lo que construye la forma física, no la intensidad.
); } renderPlan() { const intensityRaw = [15, 20, 60, 25, 65, 25, 65, 25, 60, 20, 15, 10]; const intensityBars = intensityRaw.map(v => ({ height: v + '%', color: v > 45 ? C.accent : C.greenBg })); return (
Principiante 45 min Zona FC 2 · 120–140 bpm

Entrenamiento de resistencia base. Mantén un ritmo donde puedas conversar sin ahogarte — ese es el punto.

Perfil de intensidad
{intensityBars.map((b, i) => (
))}
{PLAN_PHASES.map((p, i) => ( ))}
Fase Dur. Zona RPE
{p.name} {p.duration} {p.zone} {p.rpe}
Consejo: si en el bloque principal no puedes mantener la respiración controlada, baja un poco el ritmo — el progreso viene de repetir, no de forzar.
); } renderHistorial() { const { range, rangeOffset, userPlan, marcas } = this.state; const isPremium = userPlan === 'premium'; const rangeOptions = [ { key: 'semana', label: 'Semana' }, { key: 'mes', label: 'Mes' }, { key: 'anio', label: 'Año' }, ].map(o => ({ label: o.label, key: o.key, active: range === o.key })); const todayDate = new Date(); let periodLabel = '', periodBars = []; const fmtDay = (d) => d.getDate() + ' ' + MONTH_SHORT[d.getMonth()].toLowerCase(); if (range === 'semana') { const monday = this.getMonday(todayDate); monday.setDate(monday.getDate() - rangeOffset * 7); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); periodLabel = fmtDay(monday) + ' – ' + fmtDay(sunday) + (rangeOffset === 0 ? ' · esta semana' : ''); periodBars = WEEK_LABELS.map((lbl, i) => { const dayDate = new Date(monday); dayDate.setDate(monday.getDate() + i); const isFuture = dayDate > todayDate; const seed = monday.getTime() / 86400000 + i * 7.3; const val = isFuture ? 0 : Math.round(20 + this.seededRandom(seed) * 70); const isToday = dayDate.toDateString() === todayDate.toDateString(); return { label: lbl, labelColor: isToday ? C.greenTextLight : C.textSoft, height: Math.max(val, 4) + '%', color: isToday ? C.accent : C.greenBg }; }); } else if (range === 'mes') { const monthDate = new Date(todayDate.getFullYear(), todayDate.getMonth() - rangeOffset, 1); periodLabel = MONTH_NAMES[monthDate.getMonth()] + ' ' + monthDate.getFullYear(); const daysInMonth = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 0).getDate(); const weeksCount = Math.ceil(daysInMonth / 7); periodBars = Array.from({ length: weeksCount }, (_, i) => { const seed = monthDate.getTime() / 2629800000 + i * 3.7; const val = Math.round(25 + this.seededRandom(seed) * 65); const isCurrentWeek = rangeOffset === 0 && i === Math.floor((todayDate.getDate() - 1) / 7); return { label: 'S' + (i + 1), labelColor: isCurrentWeek ? C.greenTextLight : C.textSoft, height: val + '%', color: isCurrentWeek ? C.accent : C.greenBg }; }); } else { const year = todayDate.getFullYear() - rangeOffset; periodLabel = String(year); periodBars = MONTH_SHORT.map((m, i) => { const isFuture = rangeOffset === 0 && i > todayDate.getMonth(); const seed = year * 12 + i * 1.9; const val = isFuture ? 0 : Math.round(20 + this.seededRandom(seed) * 70); const isCurrent = rangeOffset === 0 && i === todayDate.getMonth(); return { label: m, labelColor: isCurrent ? C.greenTextLight : C.textSoft, height: Math.max(val, 4) + '%', color: isCurrent ? C.accent : C.greenBg }; }); } const canGoNext = rangeOffset > 0; const coachComments = [ { session: 'Resistencia base', date: '3 ago', text: 'Buen ritmo constante, mantuviste la Zona 2 casi toda la sesión. Sigue así.' }, { session: 'Recuperación activa', date: '1 ago', text: 'Notaste bien la diferencia entre esfuerzo suave y moderado — eso es justo lo que buscamos esta semana.' }, ]; return (
{rangeOptions.map(r => (
this.setState({ range: r.key, rangeOffset: 0 })} style={{ padding: '7px 14px', fontSize: 12.5, cursor: 'pointer', color: r.active ? C.accent : C.text, boxShadow: r.active ? `inset 0 0 0 1px ${C.accent}` : 'none', }}>{r.label}
))}
this.setState(s => ({ rangeOffset: (s.rangeOffset || 0) + 1 }))} style={{ cursor: 'pointer', flex: 'none' }}> {periodLabel} { if (canGoNext) this.setState(s => ({ rangeOffset: Math.max((s.rangeOffset || 0) - 1, 0) })); }} style={{ cursor: canGoNext ? 'pointer' : 'default', flex: 'none', opacity: canGoNext ? 1 : 0.3 }}>
{periodBars.map((h, i) => (
{h.label}
))}
FC reposo (30d) 58 ▼2
Distancia total 128 km
Racha actual 4 sem.
Comentarios de tu entrenador {isPremium && Premium}
{isPremium && coachComments.map((c, i) => (
{c.session} {c.date}

{c.text}

— Ana, entrenadora
))} {!isPremium && (
Los comentarios personalizados de tu entrenador sobre cada sesión están disponibles en Premium.
)}
Tus marcas recientes {marcas.length === 0 && (
Aún no hay marcas registradas. Cuéntaselas al Asistente en el chat.
)} {marcas.length > 0 && ( {marcas.slice(0, 8).map((m) => ( ))}
Fecha Tipo Valor
{this.formatFecha(m.fecha)} {m.tipo} {m.valor}
)}
); } renderChat() { const { chatMessages, chatInput, pendingMark, chatResponsesUsed } = this.state; const chatAvailable = chatResponsesUsed < CHAT_LIMIT; if (!chatAvailable) { return (
Llegaste al límite de {CHAT_LIMIT} respuestas

Mejora a Premium para preguntas ilimitadas al Asistente y a tu entrenador.

); } const displayMessages = chatMessages.map(m => ({ isSystem: m.from === 'system', isBubble: m.from !== 'system', justify: m.from === 'user' ? 'flex-end' : 'flex-start', align: m.from === 'user' ? 'flex-end' : 'flex-start', bg: m.from === 'user' ? C.accent : m.from === 'coach' ? C.greenBg : C.card, color: m.from === 'user' ? '#0a0a0a' : m.from === 'coach' ? C.greenText : C.text, label: m.label || '', text: m.text, })); const quickQuestions = ['¿Qué es Zona 2?', '¿Puedo entrenar con dolor?', '¿Cómo ajusto el plan?']; return (
{displayMessages.map((m, i) => ( {m.isSystem &&
{m.text}
} {m.isBubble && (
{m.label && {m.label}}
{m.text}
)}
))}
{pendingMark && (
this.confirmMark(true)} style={{ flex: 'none', fontSize: 12, padding: '6px 14px', borderRadius: 999, border: `1px solid ${C.accent}`, color: C.accent, cursor: 'pointer' }}>Sí, guardar
this.confirmMark(false)} style={{ flex: 'none', fontSize: 12, padding: '6px 14px', borderRadius: 999, border: `1px solid ${C.borderLight}`, color: C.textMuted, cursor: 'pointer' }}>No, gracias
)} {!pendingMark && (
{quickQuestions.map((q, i) => (
this.sendText(q)} style={{ flex: 'none', whiteSpace: 'nowrap', fontSize: 12, padding: '6px 12px', borderRadius: 999, border: `1px solid ${C.borderLight}`, color: C.textMuted, cursor: 'pointer' }}>{q}
))}
)}
this.setState({ chatInput: e.target.value })} onKeyDown={e => { if (e.key === 'Enter') this.sendText(chatInput); }} placeholder="Escribe tu pregunta…" style={{ flex: 1, minHeight: 40, padding: '8px 14px', fontSize: 13.5, color: C.text, background: C.card, border: `1px solid ${C.border}`, borderRadius: 20, outline: 'none' }} />
this.sendText(chatInput)} style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: C.accent, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
); } renderCarreras() { const { racesTab, raceGoals, addRaceOpen, newRaceName, newRaceDate, newRaceDistance, suggestedRacesMarked } = this.state; const raceTabOptions = [ { key: 'mios', label: 'Mis objetivos' }, { key: 'sugeridas', label: 'Sugeridas' }, ]; const suggestedRaces = SUGGESTED_RACES.filter(r => !suggestedRacesMarked[r.id]); const submitNewRace = () => { if (!newRaceName.trim()) return; this.setState(s => ({ raceGoals: [...s.raceGoals, { name: newRaceName.trim(), date: newRaceDate.trim() || 'Sin fecha', distance: newRaceDistance.trim() || '—' }], addRaceOpen: false, newRaceName: '', newRaceDate: '', newRaceDistance: '', })); }; return (
{raceTabOptions.map(rt => (
this.setState({ racesTab: rt.key })} style={{ padding: '7px 14px', fontSize: 12.5, cursor: 'pointer', color: racesTab === rt.key ? C.accent : C.text, boxShadow: racesTab === rt.key ? `inset 0 0 0 1px ${C.accent}` : 'none', }}>{rt.label}
))}
{racesTab === 'mios' && (
{raceGoals.length === 0 && (
Aún no tienes carreras objetivo Añade una o mira las que sugiere tu entrenador.
)} {raceGoals.map((g, i) => (
{g.name}
Objetivo this.setState(s => ({ raceGoals: s.raceGoals.filter((_, j) => j !== i) }))} style={{ cursor: 'pointer', color: C.textSoft, fontSize: 13 }}>×
{g.date} · {g.distance}
))} {addRaceOpen && (
this.setState({ newRaceName: e.target.value })} placeholder="Nombre de la carrera" style={{ minHeight: 36, padding: '6px 10px', fontSize: 13, color: C.text, background: '#121212', border: `1px solid ${C.border}`, borderRadius: 8, outline: 'none' }} />
this.setState({ newRaceDate: e.target.value })} placeholder="Fecha" style={{ flex: 1, minHeight: 36, padding: '6px 10px', fontSize: 13, color: C.text, background: '#121212', border: `1px solid ${C.border}`, borderRadius: 8, outline: 'none' }} /> this.setState({ newRaceDistance: e.target.value })} placeholder="Distancia" style={{ flex: 1, minHeight: 36, padding: '6px 10px', fontSize: 13, color: C.text, background: '#121212', border: `1px solid ${C.border}`, borderRadius: 8, outline: 'none' }} />
)} {!addRaceOpen && ( )}
)} {racesTab === 'sugeridas' && (

Carreras que tu entrenador conoce y recomienda para tu nivel.

{suggestedRaces.map(s => (
{s.name} {s.date}
{s.location} · {s.distance}

{s.note}

))}
)}
); } renderProfile() { const { userName, userEmail } = this.props; const { userPlan, marcas, connectedAccounts, pushNotifications, perfil } = this.state; const isPremium = userPlan === 'premium'; const push = pushNotifications; const displayName = (perfil && perfil.nombre) || userName; return (
this.go('inicio')} style={{ cursor: 'pointer', flex: 'none' }}>

Perfil

{(displayName || '?').trim().charAt(0).toUpperCase() || '?'}
{displayName}
{userEmail}
Tu plan
{isPremium ? 'Premium' : 'Free'} {isPremium && Activo}

{isPremium ? 'Incluye comentarios personalizados de tu entrenador en cada sesión.' : 'Plan gratuito. Mejora a Premium para recibir comentarios de tu entrenador sobre cada sesión.'}

Tus marcas {marcas.length === 0 && (
Aún no tienes marcas guardadas. Cuéntaselas al Asistente en el chat.
)} {marcas.map((m) => (
{m.valor} {m.tipo}
{this.formatFecha(m.fecha)}
))}
Cuentas conectadas {connectedAccounts.map(a => (
{a.name}
))}
Notificaciones
Notificaciones push
this.setState(s => ({ pushNotifications: !s.pushNotifications }))} style={{ width: 40, height: 22, borderRadius: 11, background: push ? C.greenBg : '#2e2e2e', padding: 2, cursor: 'pointer', display: 'flex', justifyContent: push ? 'flex-end' : 'flex-start' }}>
); } renderLiveSession() { const { liveElapsed, livePaused, livePhaseIdx } = this.state; const mm = String(Math.floor(liveElapsed / 60)).padStart(2, '0'); const ss = String(liveElapsed % 60).padStart(2, '0'); const liveTimeLabel = mm + ':' + ss; const liveFc = Math.round(128 + 6 * Math.sin(liveElapsed / 3)); const showNextPhaseBtn = livePhaseIdx < PLAN_PHASES.length - 1; return (
{PLAN_PHASES[livePhaseIdx].name} {liveTimeLabel}
{liveFc}
bpm
Z2
zona
{showNextPhaseBtn && ( )}
); } renderIntakeInvite() { const { userName } = this.props; return (
¡Listo, {userName}!

Ahora cuéntanos tus marcas actuales y qué carreras tienes en la mira, en Mis objetivos.

); } renderSummary() { const { lastSessionDuration, selectedRpe } = this.state; const rpeOptions = Array.from({ length: 10 }, (_, i) => i + 1); const saveSummary = () => { this.setState({ screen: 'inicio' }); }; return (

¡Buen trabajo!

Sesión completada — así te fue:

Duración {lastSessionDuration}
FC promedio 132 bpm
¿Qué tan duro se sintió? (RPE)
{rpeOptions.map(v => (
this.setState({ selectedRpe: v })} style={{ width: 30, height: 30, borderRadius: 7, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12.5, cursor: 'pointer', border: `1px solid ${v === selectedRpe ? C.accent : 'rgba(255,255,255,0.18)'}`, color: v === selectedRpe ? C.accent : C.textMuted, }}>{v}
))}
); } renderTabBar() { const { screen } = this.state; if (!TAB_SCREENS.includes(screen)) return null; const tabStyle = (active) => ({ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, padding: '6px 0', cursor: 'pointer', opacity: active ? 1 : 0.7, }); const tabColor = (active) => active ? C.accent : C.textSoft; const tab = (key, label, onClick) => (
{NAV_ICONS[key](tabColor(screen === key))} {label}
); return (
{tab('inicio', 'Inicio', () => this.go('inicio'))} {tab('plan', 'Plan', () => this.go('plan'))} {tab('historial', 'Progreso', () => this.go('historial'))} {tab('chat', 'Asistente', () => this.go('chat'))} {tab('carreras', 'Carreras', () => this.go('carreras'))}
); } renderAppStage() { const { screen } = this.state; const isDesktop = this.state.isDesktop; return (
{isDesktop && this.renderSidebar()}
{this.renderHeader()}
{screen === 'inicio' && this.renderInicio()} {screen === 'plan' && this.renderPlan()} {screen === 'historial' && this.renderHistorial()} {screen === 'chat' && this.renderChat()} {screen === 'carreras' && this.renderCarreras()} {screen === 'profile' && this.renderProfile()} {screen === 'liveSession' && this.renderLiveSession()} {screen === 'intakeInvite' && this.renderIntakeInvite()} {screen === 'summary' && this.renderSummary()}
{!isDesktop && this.renderTabBar()}
); } renderAuthBrandPanel() { return (
Bortec Cycling

Entrena con un plan hecho para ti

Planes claros, tu progreso siempre visible, y el respaldo real de tu entrenador — todo en un solo lugar.

); } render() { const { stage, bootLoading } = this.state; const isDesktop = this.state.isDesktop; if (bootLoading) { return (
Cargando…
); } const isAuthStage = stage === 'onboarding' || stage === 'login' || stage === 'intake'; const formWidth = stage === 'intake' ? 480 : 420; const formBox = (
{stage === 'onboarding' && this.renderOnboarding()} {stage === 'login' && this.renderLogin()} {stage === 'intake' && this.renderIntake()}
); return (
{isDesktop && isAuthStage && this.renderAuthBrandPanel()} {isAuthStage && (
{formBox}
)} {stage === 'app' && this.renderAppStage()}
); } } App.defaultProps = { userName: 'Marco', userEmail: 'atleta@bortec.mx', weeklySessionGoal: 4, showPowerMetric: false, }; const root = ReactDOM.createRoot(document.getElementById('root')); root.render();