1import { fetchWithCache } from '/SiteAssets/SanCarlos/assets/js/cacheManager.js'; 2 3// eventosCalendario.js - SharePoint API + Calendario + Eventos Periódicos Corregidos + Carrusel SWR 4 5// DATOS DE RESPALDO (Ajustados con la estructura real de SharePoint) 6const FALLBACK_EVENTOS = [ 7 { 8 Title: "Jornada de Vacunación Veterinaria y Bienestar Animal", 9 Description: "Atención gratuita para caninos y felinos del sector urbano. Vacunación antirrábica, desparasitación y registro de mascotas.", 10 EventDate: "2026-08-13T15:00:00Z", 11 EndDate: "2026-09-28T16:00:00Z", // Fecha fin de la serie completa 12 Location: "Municipio de Simijaca", 13 Url: "", 14 Imagen: "/SiteAssets/V3/assets/img/turismo-parque.jpg", 15 Categoria: "Salud Pública", 16 fRecurrent: true, 17 TipoRecorrencia: "semanal", 18 DiasSemana: [4], // 4 = Jueves 19 RecurrenceEndDate: "2026-09-28T23:59:59Z" 20 }, 21 { 22 Title: "Festival TurÃstico y Cultural de la Tradición", 23 Description: "Grandioso encuentro municipal con muestras artesanales, gastronomÃa tÃpica, alborada musical y comparsas culturales.", 24 EventDate: "2026-08-13T08:00:00Z", 25 EndDate: "2026-08-15T22:00:00Z", 26 Location: "Parque Principal de Simijaca", 27 Url: "/MiMunicipio/Paginas/Sitios-de-Interes.aspx", 28 Imagen: "/SiteAssets/V3/assets/img/turismo-festival.jpg", 29 Categoria: "Cultura y Turismo", 30 fRecurrent: false 31 }, 32 { 33 Title: "Caminata Ecológica Cerro del Santuario", 34 Description: "Recorrido guiado de senderismo para el avistamiento de aves y educación ambiental.", 35 EventDate: "2026-08-20T07:00:00Z", 36 EndDate: "2026-08-20T12:00:00Z", 37 Location: "Sendero Vereda Chuntaquira", 38 Url: "/MiMunicipio/Paginas/Sitios-de-Interes.aspx", 39 Imagen: "/SiteAssets/V3/assets/img/turismo-cerro.jpg", 40 Categoria: "Ecoturismo", 41 fRecurrent: false 42 } 43]; 44 45// ESTADO GLOBAL 46let state = { 47 currentDate: new Date(), 48 viewYear: new Date().getFullYear(), 49 viewMonth: new Date().getMonth(), // 0-11 50 selectedDateStr: formatDateKey(new Date()), // "YYYY-MM-DD" 51 rawEventsList: [], 52 eventsMap: {}, 53 carouselIndex: 0 54}; 55 56// HELPER: Formatea Date a "YYYY-MM-DD" 57function formatDateKey(dateObj) { 58 const y = dateObj.getFullYear(); 59 const m = String(dateObj.getMonth() + 1).padStart(2, '0'); 60 const d = String(dateObj.getDate()).padStart(2, '0'); 61 return `${y}-${m}-${d}`; 62} 63 64// HELPER: Parsea string de fecha a objeto Date 65function parseDate(dateStr) { 66 if (!dateStr) return new Date(); 67 const d = new Date(dateStr); 68 return isNaN(d.getTime()) ? new Date() : d; 69} 70 71// OBTENCIÃN DE DATOS DESDE SHAREPOINT (Biblioteca 'CalendarioEventos') 72export async function getEventosSharePoint() { 73 const url = "/sp_api.aspx?ref=get-list-items"; 74 const body = { 75 fields: [ 76 "Title", 77 "Description", 78 "EventDate", 79 "EndDate", 80 "Location", 81 "Url", 82 "Imagen", 83 "Categoria", 84 "fRecurrent", 85 "RecurrenceData", 86 "RecurrenceType", 87 "RecurrenceEndDate", 88 "TipoRecorrencia", 89 "DiasSemana" 90 ], 91 site: "/", 92 list: "CalendarioEventos" 93 }; 94 95 try { 96 const response = await fetch(url, { 97 method: "POST", 98 headers: { 99 "Content-Type": "application/json", 100 Accept: "application/json" 101 }, 102 body: JSON.stringify(body) 103 }); 104 105 if (!response.ok) throw new Error(`HTTP ${response.status}`); 106 const data = await response.json(); 107 108 if (data.status !== "ok") throw new Error(data.message); 109 110 const items = Array.isArray(data.data) ? data.data : []; 111 return items.length > 0 ? items : FALLBACK_EVENTOS; 112 } catch (error) { 113 console.warn("Fallo al obtener eventos de SharePoint, cargando respaldo:", error); 114 return FALLBACK_EVENTOS; 115 } 116} 117 118// MOTOR CORREGIDO DE EXPANSIÃN DE RECURRENCIA 119function generateEventOccurrences(event, windowStart, windowEnd) { 120 const isRecurrent = Boolean( 121 event.fRecurrent === true || 122 event.fRecurrent === "true" || 123 event.fRecurrent === 1 || 124 event.fRecurrent === "1" || 125 event.TipoRecorrencia || 126 event.RecurrenceData 127 ); 128 129 const start = parseDate(event.EventDate); 130 const rawEnd = parseDate(event.EndDate || event.EventDate); 131 132 // --- CASO 1: EVENTO ÃNICO / NO RECURRENTE (Puede ser de un solo dÃa o varios dÃas) --- 133 if (!isRecurrent) { 134 const dates = [];
135 let cur = new Date(start.getFullYear(), start.getMonth(), start.getDate()); 136 const last = new Date(rawEnd.getFullYear(), rawEnd.getMonth(), rawEnd.getDate()); 137 138 while (cur <= last) { 139 dates.push({ 140 dateKey: formatDateKey(cur), 141 eventInstance: event 142 }); 143 cur.setDate(cur.getDate() + 1); 144 } 145 return dates; 146 } 147 148 // --- CASO 2: EVENTO PERIÃDICO / RECURRENTE --- 149 // A. Extraer horas de inicio y fin para CADA sesión individual 150 let startHour = start.getHours(); 151 let startMin = start.getMinutes(); 152 let endHour = rawEnd.getHours(); 153 let endMin = rawEnd.getMinutes(); 154 155 // Si no hay diferencia de horas, por defecto dura 1 hora la sesión 156 if (startHour === endHour && startMin === endMin) { 157 endHour = (startHour + 1) % 24; 158 } 159 160 // B. Determinar la fecha lÃmite de finalización de la SERIE de repeticiones 161 let seriesEndDate = windowEnd; 162 163 if (event.RecurrenceEndDate) { 164 seriesEndDate = parseDate(event.RecurrenceEndDate); 165 } else if (rawEnd > start && (rawEnd.getTime() - start.getTime() > 24 * 3600 * 1000)) { 166 // Si la EndDate traÃda de SP es más de 24h después de la EventDate en un evento recurrente, 167 // significa que EndDate almacena el fin de la SERIE COMPLETA. 168 seriesEndDate = rawEnd; 169 } 170 171 const maxLimit = seriesEndDate < windowEnd ? seriesEndDate : windowEnd; 172 173 // C. Determinar dÃas de la semana en que se repite 174 const recType = (event.TipoRecorrencia || event.RecurrenceType || 'semanal').toLowerCase(); 175 176 let daysOfWeek = Array.isArray(event.DiasSemana) ? event.DiasSemana : []; 177 if (daysOfWeek.length === 0 && typeof event.DiasSemana === 'string') { 178 daysOfWeek = event.DiasSemana.split(',').map(n => parseInt(n.trim(), 10)).filter(n => !isNaN(n)); 179 } 180 181 // Si viene del XML de SharePoint (RecurrenceData) 182 if (daysOfWeek.length === 0 && event.RecurrenceData) { 183 const rd = String(event.RecurrenceData).toLowerCase(); 184 if (rd.includes('mo="true"')) daysOfWeek.push(1); 185 if (rd.includes('tu="true"')) daysOfWeek.push(2); 186 if (rd.includes('we="true"')) daysOfWeek.push(3); 187 if (rd.includes('th="true"') || rd.includes('thur="true"')) daysOfWeek.push(4); 188 if (rd.includes('fr="true"')) daysOfWeek.push(5); 189 if (rd.includes('sa="true"')) daysOfWeek.push(6); 190 if (rd.includes('su="true"')) daysOfWeek.push(0); 191 } 192 193 // Si no se especificaron dÃas, usar el dÃa de la semana de la fecha de inicio 194 if (daysOfWeek.length === 0) { 195 daysOfWeek = [start.getDay()]; 196 } 197 198 const occurrences = []; 199 let currentCursor = new Date(start.getFullYear(), start.getMonth(), start.getDate()); 200 201 // D. Generar instancias ÃNICAMENTE en las fechas que cumplen la regla 202 while (currentCursor <= maxLimit) { 203 if (currentCursor >= windowStart) { 204 let match = false; 205 206 if (recType.includes('diaria') || recType.includes('daily')) { 207 match = true; 208 } else if (recType.includes('semanal') || recType.includes('weekly')) { 209 const dayNum = currentCursor.getDay(); // 0=Dom, 1=Lun, ..., 6=Sáb 210 if (daysOfWeek.includes(dayNum)) { 211 match = true; 212 } 213 } else if (recType.includes('mensual') || recType.includes('monthly')) { 214 if (currentCursor.getDate() === start.getDate()) { 215 match = true; 216 } 217 } else if (recType.includes('anual') || recType.includes('yearly')) { 218 if (currentCursor.getDate() === start.getDate() && currentCursor.getMonth() === start.getMonth()) { 219 match = true; 220 } 221 } 222 223 if (match) { 224 // Crear las fechas especÃficas para la sesión de ESTE dÃa 225 const occStart = new Date(currentCursor); 226 occStart.setHours(startHour, startMin, 0, 0); 227 228 const occEnd = new Date(currentCursor); 229 occEnd.setHours(endHour, endMin, 0, 0); 230 231 if (occEnd <= occStart) { 232 occEnd.setDate(occEnd.getDate() + 1); 233 } 234 235 const occEvent = { 236 ...event, 237 EventDate: occStart.toISOString(), 238 EndDate: occEnd.toISOString(), 239 isRecurrentInstance: true 240 }; 241 242 occurrences.push({ 243 dateKey: formatDateKey(currentCursor), 244 eventInstance: occEvent 245 }); 246 } 247 } 248 249 // Avanzar un dÃa 250 currentCursor.setDate(currentCursor.getDate() + 1); 251 } 252 253 return occurrences; 254} 255 256// CONSTRUCCIÃN DEL MAPA INDEXADO 257function buildEventsMap(eventsList) { 258 const map = {}; 259 260 const now = new Date(); 261 const windowStart = new Date(now.getFullYear() - 1, 0, 1); 262 const windowEnd = new Date(now.getFullYear() + 2, 11, 31); 263 264 eventsList.forEach(event => { 265 const occurrences = generateEventOccurrences(event, windowStart, windowEnd); 266
267 occurrences.forEach(({ dateKey, eventInstance }) => { 268 if (!map[dateKey]) map[dateKey] = []; 269 270 // Evitar duplicados exactos en la misma fecha 271 const exists = map[dateKey].some(e => e.Title === eventInstance.Title && e.EventDate === eventInstance.EventDate); 272 if (!exists) { 273 map[dateKey].push(eventInstance); 274 } 275 }); 276 }); 277 278 return map; 279} 280 281// RENDERIZADO DE LA REJILLA DEL CALENDARIO 282function renderCalendarGrid() { 283 const gridContainer = document.getElementById('cal-days-grid'); 284 const titleContainer = document.getElementById('cal-month-year-title'); 285 if (!gridContainer || !titleContainer) return; 286 287 const monthNames = [ 288 "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", 289 "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre" 290 ]; 291 292 titleContainer.textContent = `${monthNames[state.viewMonth]} ${state.viewYear}`; 293 294 const firstDayOfMonth = new Date(state.viewYear, state.viewMonth, 1); 295 const daysInMonth = new Date(state.viewYear, state.viewMonth + 1, 0).getDate(); 296 297 let startingDay = firstDayOfMonth.getDay() - 1; 298 if (startingDay === -1) startingDay = 6; 299 300 const todayStr = formatDateKey(state.currentDate); 301 302 let html = ''; 303 304 for (let i = 0; i < startingDay; i++) { 305 html += `<div class="h-10 sm:h-11 rounded-xl bg-transparent" aria-hidden="true"></div>`; 306 } 307 308 for (let day = 1; day <= daysInMonth; day++) { 309 const dayDateObj = new Date(state.viewYear, state.viewMonth, day); 310 const dayStr = formatDateKey(dayDateObj); 311 312 const hasEvents = Boolean(state.eventsMap[dayStr] && state.eventsMap[dayStr].length > 0); 313 const isToday = (dayStr === todayStr); 314 const isSelected = (dayStr === state.selectedDateStr); 315 316 let btnClasses = "relative h-10 sm:h-11 rounded-2xl font-montserrat font-bold text-xs sm:text-sm flex flex-col items-center justify-center transition-all cursor-pointer border-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary "; 317 318 if (isSelected) { 319 btnClasses += "bg-text-dark text-white shadow-md scale-105 z-10 "; 320 } else if (isToday) { 321 btnClasses += "bg-surface-base text-primary ring-2 ring-primary font-black shadow-xs "; 322 } else if (hasEvents) { 323 btnClasses += "bg-primary/15 text-primary hover:bg-primary/25 font-extrabold "; 324 } else { 325 btnClasses += "bg-surface-base text-text-dark hover:bg-border-subtle/50 font-medium "; 326 } 327 328 const eventsCount = hasEvents ? state.eventsMap[dayStr].length : 0; 329 330 html += ` 331 <button 332 type="button" 333 class="${btnClasses}" 334 data-date="${dayStr}" 335 aria-label="${day} de ${monthNames[state.viewMonth]} ${state.viewYear}${hasEvents ? `, ${eventsCount} eventos` : ''}" 336 ${isSelected ? 'aria-selected="true"' : ''} 337 > 338 <span>${day}</span> 339 ${hasEvents ? ` 340 <span class="absolute bottom-1 w-1.5 h-1.5 rounded-full ${isSelected ? 'bg-white' : 'bg-primary'}"></span> 341 ` : ''} 342 </button> 343 `; 344 } 345 346 gridContainer.innerHTML = html; 347 348 gridContainer.querySelectorAll('button[data-date]').forEach(btn => { 349 btn.addEventListener('click', () => { 350 const selected = btn.getAttribute('data-date'); 351 state.selectedDateStr = selected; 352 state.carouselIndex = 0; 353 renderCalendarGrid(); 354 renderEventDetails(); 355 }); 356 }); 357} 358 359// RENDERIZADO DEL PANEL DE DETALLES Y CARRUSEL 360function renderEventDetails() { 361 const container = document.getElementById('event-details-container'); 362 if (!container) return; 363 364 const eventsForSelectedDate = state.eventsMap[state.selectedDateStr] || []; 365 366 const [y, m, d] = state.selectedDateStr.split('-').map(Number); 367 const dateObj = new Date(y, m - 1, d); 368 const formattedHeaderDate = dateObj.toLocaleDateString('es-CO', { 369 weekday: 'long', 370 day: 'numeric', 371 month: 'long', 372 year: 'numeric' 373 }); 374 375 // ESTADO VACÃO 376 if (eventsForSelectedDate.length === 0) { 377 container.innerHTML = ` 378 <div class="flex flex-col items-center justify-center text-center py-12 my-auto"> 379 <div class="w-16 h-16 rounded-2xl bg-surface-alt text-text-muted flex items-center justify-center mb-4 border border-solid border-border-subtle"> 380 <svg class="w-8 h-8 stroke-current fill-none" stroke-width="1.8" viewBox="0 0 24 24" aria-hidden="true"> 381 <path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/> 382 </svg> 383 </div>
384 <span class="font-montserrat font-bold text-xs uppercase tracking-wider text-text-muted">Fecha Sin Programación</span> 385 <h3 class="font-montserrat font-extrabold text-lg text-text-dark capitalize mt-1 mb-2"> 386 ${formattedHeaderDate} 387 </h3> 388 <p class="font-work-sans text-xs sm:text-sm text-text-muted max-w-sm m-0 leading-relaxed"> 389 No hay eventos agendados para este dÃa. Seleccione los dÃas destacados con punto verde en el calendario. 390 </p> 391 </div> 392 `; 393 return; 394 } 395 396 const totalEvents = eventsForSelectedDate.length; 397 if (state.carouselIndex >= totalEvents) state.carouselIndex = 0; 398 if (state.carouselIndex < 0) state.carouselIndex = totalEvents - 1; 399 400 const currentEvent = eventsForSelectedDate[state.carouselIndex]; 401 402 const title = currentEvent.Title || "Evento Institucional"; 403 const description = currentEvent.Description || "Sin descripción disponible."; 404 const location = currentEvent.Location || "Municipio de Simijaca"; 405 const category = currentEvent.Categoria || "Evento General"; 406 const imgSrc = currentEvent.Imagen || "/SiteAssets/V3/assets/img/turismo-placeholder.jpg"; 407 const hasUrl = Boolean(currentEvent.Url && typeof currentEvent.Url === 'string' && currentEvent.Url.trim() !== ''); 408 const isRecurrent = Boolean(currentEvent.isRecurrentInstance || currentEvent.fRecurrent); 409 410 const startDate = parseDate(currentEvent.EventDate); 411 const endDate = parseDate(currentEvent.EndDate); 412 413 const timeFormatOptions = { hour: '2-digit', minute: '2-digit', hour12: true }; 414 const startTimeStr = startDate.toLocaleTimeString('es-CO', timeFormatOptions); 415 const endTimeStr = endDate.toLocaleTimeString('es-CO', timeFormatOptions); 416 417 const timeDisplay = (formatDateKey(startDate) === formatDateKey(endDate)) 418 ? `${startTimeStr} - ${endTimeStr}` 419 : `${startDate.getDate()} de ${startDate.toLocaleDateString('es-CO', {month:'short'})} - ${endDate.getDate()} de ${endDate.toLocaleDateString('es-CO', {month:'short'})}`; 420 421 container.innerHTML = ` 422 <!-- Cabecera de la Ficha --> 423 <div class="flex items-center justify-between pb-4 mb-4 border-b border-solid border-border-subtle/60 gap-3"> 424 <div class="flex items-center gap-2 flex-wrap"> 425 <span class="inline-flex items-center gap-1.5 text-primary font-montserrat font-extrabold text-[11px] uppercase tracking-wider"> 426 <svg class="w-3.5 h-3.5 fill-current shrink-0" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg> 427 <span>${category}</span> 428 </span> 429 430 ${isRecurrent ? ` 431 <span class="inline-flex items-center gap-1 bg-primary/10 text-primary font-montserrat font-bold text-[10px] uppercase px-2 py-0.5 rounded-full" title="Este evento se repite periódicamente"> 432 <svg class="w-3 h-3 stroke-[2.5] fill-none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> 433 <span>Periódico</span> 434 </span> 435 ` : ''} 436 437 <h3 class="w-full font-montserrat font-black text-sm sm:text-base text-text-dark capitalize my-0 leading-tight"> 438 ${formattedHeaderDate} 439 </h3> 440 </div> 441 442 ${totalEvents > 1 ? ` 443 <!-- Controles del Carrusel --> 444 <div class="flex items-center gap-2 shrink-0"> 445 <span class="font-montserrat font-bold text-xs text-text-muted bg-surface-alt px-2.5 py-1 rounded-full border border-solid border-border-subtle"> 446 ${state.carouselIndex + 1} de ${totalEvents} 447 </span> 448 <button 449 type="button" 450 id="event-carousel-prev" 451 class="w-8 h-8 rounded-full bg-surface-alt hover:bg-primary hover:text-white text-text-dark border border-solid border-border-subtle flex items-center justify-center transition-colors cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary" 452 aria-label="Evento anterior" 453 title="Ver evento anterior de este dÃa" 454 > 455 <svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg> 456 </button> 457 <button 458 type="button"
459 id="event-carousel-next" 460 class="w-8 h-8 rounded-full bg-surface-alt hover:bg-primary hover:text-white text-text-dark border border-solid border-border-subtle flex items-center justify-center transition-colors cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary" 461 aria-label="Evento siguiente" 462 title="Ver siguiente evento de este dÃa" 463 > 464 <svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg> 465 </button> 466 </div> 467 ` : ''} 468 </div> 469 470 <!-- Cuerpo del Evento (Imagen + Información) --> 471 <div class="grid grid-cols-1 sm:grid-cols-12 gap-5 items-center my-auto"> 472 <div class="sm:col-span-5 relative w-full h-44 sm:h-52 rounded-2xl overflow-hidden bg-surface-muted shadow-xs border border-solid border-border-subtle/60 shrink-0"> 473 <img 474 src="${imgSrc}" 475 alt="Imagen alusiva a ${title}" 476 class="w-full h-full object-cover block" 477 loading="lazy" 478 /> 479 </div> 480 481 <div class="sm:col-span-7 flex flex-col justify-between"> 482 <div> 483 <h4 class="font-montserrat font-extrabold text-lg sm:text-xl text-text-dark my-0 leading-snug line-clamp-2"> 484 ${title} 485 </h4> 486 487 <div class="flex flex-col gap-1.5 my-3 font-montserrat font-bold text-xs text-text-muted"> 488 <div class="flex items-center gap-2"> 489 <svg class="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"> 490 <path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/> 491 </svg> 492 <span>${timeDisplay}</span> 493 </div> 494 <div class="flex items-center gap-2"> 495 <svg class="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"> 496 <path stroke-linecap="round" stroke-linejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/> 497 <path stroke-linecap="round" stroke-linejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/> 498 </svg> 499 <span class="truncate">${location}</span> 500 </div> 501 </div> 502 503 <p class="font-work-sans text-xs sm:text-sm text-text-muted leading-relaxed m-0 line-clamp-3"> 504 ${description} 505 </p> 506 </div> 507 </div> 508 </div> 509 510 <!-- Pie de Tarjeta: Enlace / Brochure --> 511 <div class="pt-5 mt-5 border-t border-solid border-border-subtle/60 flex items-center justify-between"> 512 ${hasUrl ? ` 513 <a 514 href="${currentEvent.Url}" 515 target="_blank" 516 rel="noopener noreferrer" 517 class="inline-flex items-center gap-2 bg-primary hover:bg-primary-hover text-white font-montserrat font-bold text-xs py-2.5 px-5 rounded-full transition-all duration-300 no-underline shadow-xs hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary" 518 title="Ver más información sobre ${title}" 519 > 520 <span>Más información / Brochure</span> 521 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" aria-hidden="true"> 522 <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/> 523 </svg> 524 </a> 525 ` : ` 526 <span class="text-xs font-montserrat font-semibold text-text-muted/70 italic"> 527 Sin brochure adjunto 528 </span> 529 `} 530 531 ${totalEvents > 1 ? ` 532 <div class="flex items-center gap-1" aria-hidden="true"> 533 ${eventsForSelectedDate.map((_, i) => ` 534 <span class="w-2 h-2 rounded-full transition-all ${i === state.carouselIndex ? 'bg-primary w-4' : 'bg-border-subtle'}"></span> 535 `).join('')} 536 </div> 537 ` : ''} 538 </div> 539 `; 540 541 if (totalEvents > 1) {
542 const prevBtn = document.getElementById('event-carousel-prev'); 543 const nextBtn = document.getElementById('event-carousel-next'); 544 545 if (prevBtn) { 546 prevBtn.addEventListener('click', () => { 547 state.carouselIndex = (state.carouselIndex - 1 + totalEvents) % totalEvents; 548 renderEventDetails(); 549 }); 550 } 551 552 if (nextBtn) { 553 nextBtn.addEventListener('click', () => { 554 state.carouselIndex = (state.carouselIndex + 1) % totalEvents; 555 renderEventDetails(); 556 }); 557 } 558 } 559} 560 561// LISTENERS DEL CALENDARIO 562function setupCalendarControls() { 563 const prevBtn = document.getElementById('cal-prev-month'); 564 const nextBtn = document.getElementById('cal-next-month'); 565 const todayBtn = document.getElementById('cal-btn-today'); 566 567 if (prevBtn) { 568 prevBtn.addEventListener('click', () => { 569 state.viewMonth--; 570 if (state.viewMonth < 0) { 571 state.viewMonth = 11; 572 state.viewYear--; 573 } 574 renderCalendarGrid(); 575 }); 576 } 577 578 if (nextBtn) { 579 nextBtn.addEventListener('click', () => { 580 state.viewMonth++; 581 if (state.viewMonth > 11) { 582 state.viewMonth = 0; 583 state.viewYear++; 584 } 585 renderCalendarGrid(); 586 }); 587 } 588 589 if (todayBtn) { 590 todayBtn.addEventListener('click', () => { 591 state.currentDate = new Date(); 592 state.viewYear = state.currentDate.getFullYear(); 593 state.viewMonth = state.currentDate.getMonth(); 594 state.selectedDateStr = formatDateKey(state.currentDate); 595 state.carouselIndex = 0; 596 597 renderCalendarGrid(); 598 renderEventDetails(); 599 }); 600 } 601} 602 603// INICIALIZACIÃN 604export async function initEventosCalendario() { 605 const handleRender = (data) => { 606 const rawEvents = (data && data.length > 0) ? data : FALLBACK_EVENTOS; 607 608 state.rawEventsList = rawEvents; 609 state.eventsMap = buildEventsMap(rawEvents); 610 611 renderCalendarGrid(); 612 renderEventDetails(); 613 setupCalendarControls(); 614 }; 615 616 await fetchWithCache('eventos_municipales', getEventosSharePoint, handleRender); 617} 618 619document.addEventListener('DOMContentLoaded', async () => { 620 await initEventosCalendario(); 621});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.