| 1 | /** |
| 2 | * MAPS Connect - Mi Perfil |
| 3 | * La cabecera usa la sesión (Auth) como dato real; el detalle académico |
| 4 | * (matrícula, carrera, certificados, stats, actividad) se renderiza solo |
| 5 | * cuando el backend (/estudiantes/perfil) devuelve datos. |
| 6 | */ |
| 7 | |
| 8 | const Perfil = { |
| 9 | state: { |
| 10 | proposito: '', |
| 11 | semestre: null |
| 12 | }, |
| 13 | |
| 14 | async init() { |
| 15 | Layout.init('perfil.html'); |
| 16 | Layout.setPageTitle('Mi Perfil'); |
| 17 | |
| 18 | // La cabecera puede pintarse con la sesión real del usuario. |
| 19 | this.renderHeader(Auth.getUser()); |
| 20 | this.bindEventos(); |
| 21 | |
| 22 | try { |
| 23 | const data = await API.request('/estudiantes/perfil'); |
| 24 | this.renderProfile(data || {}); |
| 25 | } catch (e) { |
| 26 | // El endpoint se implementará en el backend. La UI mantiene estados vacíos. |
| 27 | } |
| 28 | }, |
| 29 | |
| 30 | bindEventos() { |
| 31 | const btn = document.getElementById('btn-editar-proposito'); |
| 32 | btn?.addEventListener('click', () => this.abrirModalProposito()); |
| 33 | |
| 34 | const btnSemestre = document.getElementById('btn-cambiar-semestre'); |
| 35 | btnSemestre?.addEventListener('click', () => this.abrirModalSemestre()); |
| 36 | |
| 37 | const btnFoto = document.getElementById('btn-cambiar-foto'); |
| 38 | const input = document.getElementById('foto-input'); |
| 39 | btnFoto?.addEventListener('click', () => input?.click()); |
| 40 | input?.addEventListener('change', () => this.cambiarFoto(input)); |
| 41 | |
| 42 | // Delegación para los botones Seguir/Siguiendo de "Descubrir personas". |
| 43 | document.getElementById('discover-list')?.addEventListener('click', e => { |
| 44 | const followBtn = e.target.closest('.follow-btn'); |
| 45 | if (!followBtn) return; |
| 46 | e.preventDefault(); |
| 47 | const id = followBtn.getAttribute('data-id'); |
| 48 | if (!id) return; |
| 49 | Utils.alternarSeguir(followBtn, id); |
| 50 | }); |
| 51 | }, |
| 52 | |
| 53 | async cambiarFoto(input) { |
| 54 | const archivo = input && input.files && input.files[0]; |
| 55 | if (!archivo) return; |
| 56 | try { |
| 57 | const dataUrl = await Utils.fotoDataUrl(archivo); |
| 58 | await API.request('/usuarios/foto', { |
| 59 | method: 'PUT', |
| 60 | body: JSON.stringify({ foto: dataUrl }) |
| 61 | }); |
| 62 | Auth.foto(dataUrl); |
| 63 | this.renderHeader(Auth.getUser()); |
| 64 | Utils.toast('Foto de perfil actualizada.', 'success'); |
| 65 | } catch (e) { |
| 66 | Utils.toast(e.message || 'No se pudo actualizar la foto.', 'error'); |
| 67 | } finally { |
| 68 | input.value = ''; |
| 69 | } |
| 70 | }, |
| 71 | |
| 72 | renderHeader(user) { |
| 73 | const host = document.getElementById('profile-header-info'); |
| 74 | if (!host) return; |
| 75 | |
| 76 | const nombre = (user && user.nombre) || ''; |
| 77 | const email = (user && user.email) || ''; |
| 78 | if (!nombre && !email) { |
| 79 | host.innerHTML = ''; |
| 80 | return; |
| 81 | } |
| 82 | |
| 83 | const foto = (user && user.foto) || null; |
| 84 | |
| 85 | host.innerHTML = ` |
| 86 | <div>${Utils.avatarHtml(nombre, foto, 'avatar profile-avatar', nombre)}</div> |
| 87 | <div> |
| 88 | <h2 class="profile-name">${this.esc(nombre)}</h2> |
| 89 | ${email ? `<p class="profile-sub">${this.esc(email)}</p>` : ''} |
| 90 | </div> |
| 91 | `; |
| 92 | }, |
| 93 | |
| 94 | renderProfile(data) { |
| 95 | // Cabecera con datos completos si el backend los trae. |
| 96 | if (data.foto) Auth.foto(data.foto); |
| 97 | if (data.nombre || data.matricula || data.carrera) { |
| 98 | this.renderHeaderData(data); |
| 99 | } |
| 100 | |
| 101 | // Red |
| 102 | this.renderRed(data); |
| 103 | |
| 104 | // Propósito |
| 105 | const purposeBox = document.getElementById('profile-purpose'); |
| 106 | const purposeText = document.getElementById('profile-purpose-text'); |
| 107 | if (purposeBox && purposeText) { |
| 108 | if (data.proposito) { |
| 109 | this.state.proposito = data.proposito; |
| 110 | purposeText.textContent = data.proposito; |
| 111 | purposeBox.hidden = false; |
| 112 | } else { |
| 113 | this.state.proposito = ''; |
| 114 | purposeBox.hidden = true; |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | if (data.semestre != null) { |
| 119 | this.state.semestre = Number(data.semestre); |
| 120 | } |
| 121 | |
| 122 | // Certificados |
| 123 | const certs = Array.isArray(data.certificados) ? data.certificados : []; |
| 124 | this.renderCertificados(certs); |
| 125 | |
| 126 | // Stats |
| 127 | this.renderStats(data.stats || {}); |
| 128 | |
| 129 | // Actividad |
| 130 | const acts = Array.isArray(data.actividad) ? data.actividad : []; |
| 131 | this.renderActividad(acts); |
| 132 | }, |
| 133 | |
| 134 | renderHeaderData(data) { |
| 135 | const host = document.getElementById('profile-header-info'); |
| 136 | if (!host) return; |
| 137 | |
| 138 | const nombre = data.nombre || (Auth.getUser() && Auth.getUser().nombre) || ''; |
| 139 | const foto = data.foto || null; |
| 140 | |
| 141 | const subParts = []; |
| 142 | if (data.matricula) subParts.push(`Matrícula: <strong>${this.esc(data.matricula)}</strong>`); |
| 143 | if (data.carrera) subParts.push(`<strong>${this.esc(data.carrera)}</strong>`); |
| 144 | const sub1 = subParts.length ? `<p class="profile-sub">${subParts.join(' • ')}</p>` : ''; |
| 145 | |
| 146 | const subParts2 = []; |
| 147 | if (data.semestre) subParts2.push(`Semestre Vigente: <strong>${this.esc(data.semestre)}° Semestre</strong>`); |
| 148 | if (data.campus) subParts2.push(this.esc(data.campus)); |
| 149 | const sub2 = subParts2.length ? `<p class="profile-sub" style="margin-top:2px;">${subParts2.join(' • ')}</p>` : ''; |
| 150 | |
| 151 | host.innerHTML = ` |
| 152 | <div>${Utils.avatarHtml(nombre, foto, 'avatar profile-avatar', nombre)}</div> |
| 153 | <div> |
| 154 | <h2 class="profile-name">${this.esc(nombre)}</h2> |
| 155 | ${sub1} |
| 156 | ${sub2} |
| 157 | </div> |
| 158 | `; |
| 159 | }, |
| 160 | |
| 161 | renderRed(data) { |
| 162 | const card = document.getElementById('profile-red'); |
| 163 | if (!card) return; |
| 164 | const siguiendo = data.siguiendo; |
| 165 | const seguidores = data.seguidores; |
| 166 | if (siguiendo == null && seguidores == null) { |
| 167 | card.hidden = true; |
| 168 | return; |
| 169 | } |
| 170 | card.hidden = false; |
| 171 | |
| 172 | const stats = document.getElementById('red-stats'); |
| 173 | if (stats) { |
| 174 | stats.innerHTML = ` |
| 175 | <div class="red-stat"><span class="number">${this.esc(seguidores != null ? seguidores : '—')}</span><span class="label">Seguidores</span></div> |
| 176 | <div class="red-stat"><span class="number">${this.esc(siguiendo != null ? siguiendo : '—')}</span><span class="label">Siguiendo</span></div> |
| 177 | `; |
| 178 | } |
| 179 | |
| 180 | this.cargarDescubrir(); |
| 181 | }, |
| 182 | |
| 183 | async cargarDescubrir() { |
| 184 | const host = document.getElementById('discover-list'); |
| 185 | if (!host) return; |
| 186 | try { |
| 187 | const res = await API.request('/usuarios/descubrir'); |
| 188 | const lista = Array.isArray(res) ? res : (res && Array.isArray(res.data) ? res.data : []); |
| 189 | this.renderDescubrir(lista); |
| 190 | } catch (e) { |
| 191 | host.innerHTML = '<p class="discover-empty">No se pudo cargar la red en este momento.</p>'; |
| 192 | } |
| 193 | }, |
| 194 | |
| 195 | renderDescubrir(lista) { |
| 196 | const host = document.getElementById('discover-list'); |
| 197 | if (!host) return; |
| 198 | if (!lista.length) { |
| 199 | host.innerHTML = '<p class="discover-empty">Ya sigues a todas las personas de tu comunidad.</p>'; |
| 200 | return; |
| 201 | } |
| 202 | |
| 203 | host.innerHTML = lista.map(p => { |
| 204 | const esDocente = p.rol === 'PROFESOR' || p.rol === 'profesor'; |
| 205 | const rol = esDocente ? 'Docente' : (p.carrera ? p.carrera : 'Estudiante'); |
| 206 | const seguidores = Number(p.seguidores) || 0; |
| 207 | const loSigo = p.loSigo === true || p.loSigo === 'true'; |
| 208 | return ` |
| 209 | <div class="discover-person"> |
| 210 | <a class="user-link" href="usuario.html?id=${this.esc(p.id)}" aria-label="Ver perfil de ${this.esc(p.nombre)}">${Utils.avatarHtml(p.nombre, p.foto, 'avatar', p.nombre)}</a> |
| 211 | <div class="discover-info"> |
| 212 | <a class="user-link" href="usuario.html?id=${this.esc(p.id)}"><strong>${this.esc(p.nombre)}</strong></a> |
| 213 | <span>${this.esc(rol)} • ${seguidores} seguidores</span> |
| 214 | </div> |
| 215 | <button class="follow-btn${loSigo ? ' siguiendo' : ''}" type="button" |
| 216 | data-id="${this.esc(p.id)}" data-siguiendo="${loSigo ? 'true' : 'false'}">${loSigo ? 'Siguiendo' : 'Seguir'}</button> |
| 217 | </div>`; |
| 218 | }).join(''); |
| 219 | }, |
| 220 | |
| 221 | renderCertificados(certs) { |
| 222 | const host = document.getElementById('profile-certificados'); |
| 223 | if (!host) return; |
| 224 | if (!certs.length) { host.innerHTML = ''; return; } |
| 225 | host.innerHTML = certs.map(c => this.certCard(c)).join(''); |
| 226 | }, |
| 227 | |
| 228 | certCard(c) { |
| 229 | const titulo = c.titulo || ''; |
| 230 | const desc = c.descripcion || ''; |
| 231 | const estado = c.estado || ''; |
| 232 | const semestre = c.semestre ? `${this.esc(c.semestre)}° Semestre` : ''; |
| 233 | |
| 234 | let badge = ''; |
| 235 | if (estado === 'acreditado') { |
| 236 | badge = `<span class="badge badge-green">✓ Acreditado${semestre ? ' (' + semestre + ')' : ''}</span>`; |
| 237 | } else if (estado === 'encurso' || estado === 'en-curso') { |
| 238 | badge = `<span class="badge badge-green">En Curso${semestre ? ' (' + semestre + ')' : ''}</span>`; |
| 239 | } else if (estado === 'futuro' || estado === 'bloque') { |
| 240 | badge = `<span class="badge badge-gray">Bloque Futuro${semestre ? ' (' + semestre + ')' : ''}</span>`; |
| 241 | } else if (estado) { |
| 242 | badge = `<span class="badge badge-gray">${this.esc(estado)}</span>`; |
| 243 | } |
| 244 | |
| 245 | const activeCls = (estado === 'encurso' || estado === 'en-curso') ? ' active' : ''; |
| 246 | |
| 247 | return ` |
| 248 | <div class="certificate-item${activeCls}"> |
| 249 | <div class="cert-header"> |
| 250 | <span class="cert-title">${this.esc(titulo)}</span> |
| 251 | ${badge} |
| 252 | </div> |
| 253 | ${desc ? `<p class="cert-desc">${this.esc(desc)}</p>` : ''} |
| 254 | </div>`; |
| 255 | }, |
| 256 | |
| 257 | renderStats(stats) { |
| 258 | const host = document.getElementById('profile-stats'); |
| 259 | if (!host) return; |
| 260 | const items = [ |
| 261 | { n: stats.dudasResueltas, l: 'Dudas Resueltas' }, |
| 262 | { n: stats.apuntesCompartidos, l: 'Apuntes Compartidos' }, |
| 263 | { n: stats.votos, l: 'Votos de Utilidad' }, |
| 264 | { n: stats.reputacion, l: 'Reputación Académica' } |
| 265 | ]; |
| 266 | // Solo se pinta si al menos hay un valor con dato. |
| 267 | const hasData = items.some(it => it.n !== undefined && it.n !== null && it.n !== ''); |
| 268 | if (!hasData) { host.innerHTML = ''; return; } |
| 269 | host.innerHTML = items.map(it => ` |
| 270 | <div class="stat-box"> |
| 271 | <span class="number">${this.esc(it.n !== undefined && it.n !== null && it.n !== '' ? it.n : '—')}</span> |
| 272 | <span class="label">${this.esc(it.l)}</span> |
| 273 | </div> |
| 274 | `).join(''); |
| 275 | }, |
| 276 | |
| 277 | renderActividad(acts) { |
| 278 | const host = document.getElementById('profile-actividad'); |
| 279 | if (!host) return; |
| 280 | if (!acts.length) { host.innerHTML = ''; return; } |
| 281 | host.innerHTML = acts.map(a => ` |
| 282 | <li class="activity-item"> |
| 283 | <strong>${this.esc(a.titulo || '')}</strong> |
| 284 | <span>${this.esc(a.detalle || a.descripcion || '')}</span> |
| 285 | </li> |
| 286 | `).join(''); |
| 287 | }, |
| 288 | |
| 289 | abrirModalProposito() { |
| 290 | let modal = document.getElementById('proposito-modal'); |
| 291 | if (!modal) { |
| 292 | modal = document.createElement('div'); |
| 293 | modal.id = 'proposito-modal'; |
| 294 | modal.className = 'proposito-modal-overlay'; |
| 295 | document.body.appendChild(modal); |
| 296 | } |
| 297 | |
| 298 | modal.innerHTML = ` |
| 299 | <div class="proposito-modal" role="dialog" aria-modal="true" aria-label="Editar propósito de vida"> |
| 300 | <h3>Propósito de Vida</h3> |
| 301 | <p class="proposito-modal-sub">Describe tu propósito personal. Aparecerá en tu identidad y Ruta MAPS.</p> |
| 302 | <textarea id="proposito-input" maxlength="1000" rows="4" placeholder="Escribe tu propósito de vida...">${this.esc(this.state.proposito)}</textarea> |
| 303 | <div class="proposito-modal-msg" id="proposito-msg"></div> |
| 304 | <div class="proposito-modal-actions"> |
| 305 | <button class="btn-clean" type="button" id="proposito-cancel">Cancelar</button> |
| 306 | <button class="btn-solid" type="button" id="proposito-save">Guardar</button> |
| 307 | </div> |
| 308 | </div>`; |
| 309 | |
| 310 | modal.addEventListener('click', e => { |
| 311 | if (e.target === modal) this.cerrarModalProposito(); |
| 312 | }); |
| 313 | document.getElementById('proposito-cancel').addEventListener('click', () => this.cerrarModalProposito()); |
| 314 | document.getElementById('proposito-save').addEventListener('click', () => this.guardarProposito()); |
| 315 | document.getElementById('proposito-input').focus(); |
| 316 | |
| 317 | const input = document.getElementById('proposito-input'); |
| 318 | input.addEventListener('keydown', e => { |
| 319 | if (e.key === 'Escape') this.cerrarModalProposito(); |
| 320 | }); |
| 321 | }, |
| 322 | |
| 323 | async guardarProposito() { |
| 324 | const input = document.getElementById('proposito-input'); |
| 325 | const msg = document.getElementById('proposito-msg'); |
| 326 | if (!input) return; |
| 327 | |
| 328 | const proposito = input.value.trim(); |
| 329 | const btn = document.getElementById('proposito-save'); |
| 330 | btn.disabled = true; |
| 331 | btn.textContent = 'Guardando...'; |
| 332 | |
| 333 | try { |
| 334 | await API.request('/estudiantes/proposito', { |
| 335 | method: 'PUT', |
| 336 | body: JSON.stringify({ proposito }) |
| 337 | }); |
| 338 | |
| 339 | this.state.proposito = proposito; |
| 340 | const purposeBox = document.getElementById('profile-purpose'); |
| 341 | const purposeText = document.getElementById('profile-purpose-text'); |
| 342 | if (purposeText) purposeText.textContent = proposito; |
| 343 | if (purposeBox) purposeBox.hidden = !proposito; |
| 344 | |
| 345 | this.cerrarModalProposito(); |
| 346 | Utils.toast(proposito ? 'Propósito de vida actualizado.' : 'Propósito de vida eliminado.', 'success'); |
| 347 | } catch (e) { |
| 348 | if (msg) msg.textContent = e.message || 'No se pudo guardar el propósito.'; |
| 349 | btn.disabled = false; |
| 350 | btn.textContent = 'Guardar'; |
| 351 | } |
| 352 | }, |
| 353 | |
| 354 | cerrarModalProposito() { |
| 355 | const modal = document.getElementById('proposito-modal'); |
| 356 | if (modal) modal.remove(); |
| 357 | }, |
| 358 | |
| 359 | abrirModalSemestre() { |
| 360 | let modal = document.getElementById('semestre-modal'); |
| 361 | if (!modal) { |
| 362 | modal = document.createElement('div'); |
| 363 | modal.id = 'semestre-modal'; |
| 364 | modal.className = 'proposito-modal-overlay'; |
| 365 | document.body.appendChild(modal); |
| 366 | } |
| 367 | |
| 368 | const actual = this.state.semestre != null ? Number(this.state.semestre) : ''; |
| 369 | const opciones = Array.from({ length: 12 }, (_, i) => i + 1) |
| 370 | .map(n => `<option value="${n}"${String(n) === String(actual) ? ' selected' : ''}>${n}° Semestre</option>`) |
| 371 | .join(''); |
| 372 | |
| 373 | modal.innerHTML = ` |
| 374 | <div class="proposito-modal" role="dialog" aria-modal="true" aria-label="Cambiar semestre"> |
| 375 | <h3>Cambiar de Semestre</h3> |
| 376 | <p class="proposito-modal-sub">Al avanzar de semestre se actualizarán tus materias del plan de estudios y la información de la comunidad corresponderá a tu nuevo semestre.</p> |
| 377 | <label for="semestre-select" style="display:block;margin-bottom:6px;font-weight:bold;">Semestre actualmente vigente</label> |
| 378 | <select id="semestre-select" class="input-search">${opciones}</select> |
| 379 | <div class="proposito-modal-msg" id="semestre-msg"></div> |
| 380 | <div class="proposito-modal-actions"> |
| 381 | <button class="btn-clean" type="button" id="semestre-cancel">Cancelar</button> |
| 382 | <button class="btn-solid" type="button" id="semestre-save">Guardar</button> |
| 383 | </div> |
| 384 | </div>`; |
| 385 | |
| 386 | modal.addEventListener('click', e => { |
| 387 | if (e.target === modal) this.cerrarModalSemestre(); |
| 388 | }); |
| 389 | document.getElementById('semestre-cancel').addEventListener('click', () => this.cerrarModalSemestre()); |
| 390 | document.getElementById('semestre-save').addEventListener('click', () => this.guardarSemestre()); |
| 391 | }, |
| 392 | |
| 393 | async guardarSemestre() { |
| 394 | const select = document.getElementById('semestre-select'); |
| 395 | const msg = document.getElementById('semestre-msg'); |
| 396 | if (!select) return; |
| 397 | |
| 398 | const semestre = Number(select.value); |
| 399 | const btn = document.getElementById('semestre-save'); |
| 400 | btn.disabled = true; |
| 401 | btn.textContent = 'Guardando...'; |
| 402 | |
| 403 | try { |
| 404 | await API.request('/estudiantes/semestre', { |
| 405 | method: 'PUT', |
| 406 | body: JSON.stringify({ semestre }) |
| 407 | }); |
| 408 | this.cerrarModalSemestre(); |
| 409 | this.state.semestre = semestre; |
| 410 | Utils.toast(`Avanzaste al ${semestre}° semestre.`, 'success'); |
| 411 | |
| 412 | try { |
| 413 | const data = await API.request('/estudiantes/perfil'); |
| 414 | this.renderProfile(data || {}); |
| 415 | } catch (e) { |
| 416 | // La cabecera ya se re-renderiza si es posible; el resto se mantiene. |
| 417 | } |
| 418 | } catch (e) { |
| 419 | if (msg) msg.textContent = e.message || 'No se pudo actualizar el semestre.'; |
| 420 | btn.disabled = false; |
| 421 | btn.textContent = 'Guardar'; |
| 422 | } |
| 423 | }, |
| 424 | |
| 425 | cerrarModalSemestre() { |
| 426 | const modal = document.getElementById('semestre-modal'); |
| 427 | if (modal) modal.remove(); |
| 428 | }, |
| 429 | |
| 430 | esc(str) { |
| 431 | return String(str == null ? '' : str) |
| 432 | .replace(/&/g, '&') |
| 433 | .replace(/</g, '<') |
| 434 | .replace(/>/g, '>') |
| 435 | .replace(/"/g, '"') |
| 436 | .replace(/'/g, '''); |
| 437 | }, |
| 438 | }; |
| 439 | |
| 440 | document.addEventListener('DOMContentLoaded', () => Perfil.init()); |