main
js 440 lines 17.7 KB
Raw
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, '&amp;')
433 .replace(/</g, '&lt;')
434 .replace(/>/g, '&gt;')
435 .replace(/"/g, '&quot;')
436 .replace(/'/g, '&#39;');
437 },
438 };
439
440 document.addEventListener('DOMContentLoaded', () => Perfil.init());