/** * Calendar Widget for Fabiola Ledesma Booking System * Vanilla JS — no dependencies required */ (function () { 'use strict'; const SERVICES = [ { value: 'diseno-estrategico', label: 'Consultoría Diseño Estratégico' }, { value: 'ux-leadership', label: 'UX Leadership Coaching' }, { value: 'workshop-corporativo', label: 'Workshop Corporativo' }, { value: 'evaluacion-producto', label: 'Evaluación de Producto' }, { value: 'consultoria-b2b', label: 'Consultoría B2B' }, ]; const MONTH_NAMES = [ 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', ]; const DAY_NAMES = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb']; function getApiBase() { return (window.API_BASE_URL || '').replace(/\/$/, ''); } // ─── State ──────────────────────────────────────────────────────────────── function createState() { const today = new Date(); return { year: today.getFullYear(), month: today.getMonth() + 1, // 1-based selectedDate: null, // 'YYYY-MM-DD' selectedSlot: null, // 'HH:MM' availability: {}, // { 'YYYY-MM-DD': ['09:00','10:00',...] } loading: false, slotsLoading: false, bookingState: 'idle', // 'idle' | 'submitting' | 'success' | 'error' bookingError: null, bookingResult: null, }; } // ─── API ────────────────────────────────────────────────────────────────── async function fetchAvailability(year, month) { const url = `${getApiBase()}/availability?year=${year}&month=${month}`; const res = await fetch(url); if (!res.ok) throw new Error(`Error ${res.status}`); const data = await res.json(); // Transform API shape { calendar: [{date, slots:[{time,available}]}] } // into the map shape { 'YYYY-MM-DD': ['HH:MM', ...] } used by the widget const availability = {}; for (const day of (data.calendar || [])) { const available = (day.slots || []).filter(s => s.available).map(s => s.time); if (available.length > 0) availability[day.date] = available; } return { availability }; } async function submitBooking(payload) { const url = `${getApiBase()}/book`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json(); if (!res.ok) throw new Error(data.message || `Error ${res.status}`); return data; } // ─── Helpers ────────────────────────────────────────────────────────────── function pad2(n) { return String(n).padStart(2, '0'); } function toDateStr(year, month, day) { return `${year}-${pad2(month)}-${pad2(day)}`; } function todayStr() { const t = new Date(); return toDateStr(t.getFullYear(), t.getMonth() + 1, t.getDate()); } function formatDisplayDate(dateStr) { const [y, m, d] = dateStr.split('-').map(Number); return `${d} de ${MONTH_NAMES[m - 1]} de ${y}`; } function formatTime(slot) { const [h] = slot.split(':').map(Number); const suffix = h < 12 ? 'am' : 'pm'; const h12 = h % 12 === 0 ? 12 : h % 12; return `${h12}:00 ${suffix}`; } // ─── Render ─────────────────────────────────────────────────────────────── function renderSkeleton() { return `
${Array.from({ length: 35 }).map(() => '
').join('')}
`; } function renderCalendarGrid(state) { const { year, month, availability, selectedDate } = state; const today = todayStr(); const firstDay = new Date(year, month - 1, 1).getDay(); const daysInMonth = new Date(year, month, 0).getDate(); let html = `
${DAY_NAMES.map(d => `
${d}
`).join('')}`; // empty cells before first day for (let i = 0; i < firstDay; i++) { html += '
'; } for (let day = 1; day <= daysInMonth; day++) { const dateStr = toDateStr(year, month, day); const isPast = dateStr < today; const isToday = dateStr === today; const isSelected = dateStr === selectedDate; const slots = availability[dateStr] || []; const hasSlots = slots.length > 0; let cls = 'cal-cell'; if (isPast) cls += ' cal-past'; else if (isSelected) cls += ' cal-selected'; else if (hasSlots) cls += ' cal-available'; else cls += ' cal-unavailable'; if (isToday) cls += ' cal-today'; const disabled = isPast || !hasSlots; const ariaLabel = `${day} de ${MONTH_NAMES[month - 1]}${hasSlots ? `, ${slots.length} horario${slots.length > 1 ? 's' : ''} disponible${slots.length > 1 ? 's' : ''}` : ', sin disponibilidad'}`; html += ` `; } html += '
'; return html; } function renderTimeSlots(state) { const { selectedDate, availability, selectedSlot, slotsLoading } = state; if (!selectedDate) return ''; if (slotsLoading) { return `

Cargando horarios...

`; } const slots = availability[selectedDate] || []; return `

Horarios para el ${formatDisplayDate(selectedDate)}

${slots.length === 0 ? '

No hay horarios disponibles para este día.

' : `
${slots.map(slot => { const isActive = slot === selectedSlot; return ``; }).join('')}
` }
`; } function renderBookingForm(state) { const { selectedDate, selectedSlot, bookingState, bookingError, bookingResult } = state; if (!selectedDate || !selectedSlot) return ''; if (bookingState === 'success' && bookingResult) { return `

¡Reserva confirmada!

Tu cita ha sido agendada para el ${formatDisplayDate(selectedDate)} a las ${formatTime(selectedSlot)}.

Recibirás un correo de confirmación en ${bookingResult.email || ''}.

Referencia: ${bookingResult.bookingId || bookingResult.id || 'N/A'}

`; } const serviceOptions = SERVICES.map(s => `` ).join(''); const isSubmitting = bookingState === 'submitting'; return `

Completa tu reserva

${formatDisplayDate(selectedDate)} · ${formatTime(selectedSlot)}

${bookingError ? `` : ''}
`; } function renderStyles() { if (document.getElementById('cal-styles')) return; const style = document.createElement('style'); style.id = 'cal-styles'; style.textContent = ` .cal-widget { font-family: inherit; color: #1e293b; } .cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; } .cal-month-label { font-size: 1.1rem; font-weight: 600; color: #1e293b; } .cal-nav-btn { background: none; border: 1px solid #e2e8f0; border-radius: 0.375rem; cursor: pointer; padding: 0.375rem 0.625rem; font-size: 1rem; color: #475569; transition: background 0.15s, border-color 0.15s; } .cal-nav-btn:hover { background: #f1f5f9; border-color: #94a3b8; } .cal-nav-btn:disabled { opacity: 0.4; cursor: not-allowed; } .cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; } .cal-day-name { text-align: center; font-size: 0.7rem; font-weight: 600; color: #94a3b8; padding: 0.25rem 0 0.5rem; text-transform: uppercase; } .cal-cell { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-size: 0.875rem; border: none; cursor: pointer; transition: background 0.15s, color 0.15s; background: transparent; position: relative; } .cal-empty { background: transparent !important; cursor: default; } .cal-past { color: #cbd5e1; cursor: not-allowed; } .cal-unavailable { color: #94a3b8; cursor: not-allowed; } .cal-available { background: #fce4ee; color: #A84F6F; font-weight: 600; cursor: pointer; } .cal-available:hover { background: #f9c6d8; } .cal-selected { background: #C96A8A !important; color: #fff !important; font-weight: 700; } .cal-today { box-shadow: 0 0 0 2px #C96A8A; } .cal-today.cal-past { box-shadow: 0 0 0 2px #cbd5e1; } .cal-skeleton { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; } .cal-skeleton-cell { aspect-ratio: 1; border-radius: 50%; background: #e2e8f0; animation: cal-pulse 1.5s ease-in-out infinite; } @keyframes cal-pulse { 0%,100%{opacity:1} 50%{opacity:0.4} } .cal-loading { text-align: center; padding: 2rem; color: #64748b; } .cal-slots-section { margin-top: 1.25rem; } .cal-slots-title { font-size: 0.95rem; font-weight: 600; margin-bottom: 0.75rem; color: #334155; } .cal-slots-loading { color: #64748b; font-size: 0.875rem; } .cal-no-slots { color: #94a3b8; font-size: 0.875rem; } .cal-slots-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; } .cal-slot-btn { padding: 0.375rem 0.75rem; border-radius: 1rem; border: 1.5px solid #E8A0BA; background: #fff; color: #A84F6F; font-size: 0.85rem; cursor: pointer; transition: all 0.15s; font-weight: 500; } .cal-slot-btn:hover { background: #fce4ee; } .cal-slot-selected { background: #C96A8A !important; color: #fff !important; border-color: #C96A8A !important; } .cal-form-section { margin-top: 1.5rem; border-top: 1px solid #e2e8f0; padding-top: 1.5rem; } .cal-form-title { font-size: 1rem; font-weight: 700; margin-bottom: 0.25rem; color: #1e293b; } .cal-form-subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 1rem; } .cal-field { margin-bottom: 1rem; } .cal-label { display: block; font-size: 0.85rem; font-weight: 500; color: #374151; margin-bottom: 0.25rem; } .cal-optional { font-weight: 400; color: #9ca3af; } .cal-input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 0.375rem; font-size: 0.9rem; outline: none; transition: border-color 0.15s, box-shadow 0.15s; box-sizing: border-box; font-family: inherit; color: #1e293b; background: #fff; } .cal-input:focus { border-color: #C96A8A; box-shadow: 0 0 0 3px rgba(201,106,138,0.15); } .cal-input:disabled { background: #f9fafb; color: #9ca3af; } .cal-textarea { resize: vertical; min-height: 80px; } .cal-btn-primary { width: 100%; padding: 0.625rem 1.25rem; background: #C96A8A; color: #fff; border: none; border-radius: 0.5rem; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: background 0.15s; display: flex; align-items: center; justify-content: center; gap: 0.5rem; } .cal-btn-primary:hover:not(:disabled) { background: #A84F6F; } .cal-btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } .cal-btn-secondary { margin-top: 1rem; padding: 0.5rem 1rem; background: #f1f5f9; color: #334155; border: 1px solid #e2e8f0; border-radius: 0.5rem; font-size: 0.9rem; cursor: pointer; transition: background 0.15s; } .cal-btn-secondary:hover { background: #e2e8f0; } .cal-spinner { width: 1em; height: 1em; border: 2px solid rgba(255,255,255,0.4); border-top-color: #fff; border-radius: 50%; animation: cal-spin 0.6s linear infinite; display: inline-block; } @keyframes cal-spin { to { transform: rotate(360deg); } } .cal-error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; border-radius: 0.375rem; padding: 0.625rem 0.75rem; font-size: 0.875rem; margin-bottom: 1rem; } .cal-confirmation { text-align: center; padding: 1.5rem 0; } .cal-confirmation-icon { width: 3rem; height: 3rem; background: #22c55e; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; margin: 0 auto 1rem; font-weight: 700; } .cal-confirmation-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.5rem; color: #166534; } .cal-confirmation p { font-size: 0.9rem; color: #374151; margin-bottom: 0.5rem; } .cal-confirmation-ref { font-size: 0.8rem; color: #6b7280; } .cal-confirmation code { background: #f3f4f6; padding: 0.125rem 0.375rem; border-radius: 0.25rem; } `; document.head.appendChild(style); } // ─── Main Widget ────────────────────────────────────────────────────────── function initCalendar(containerId) { const container = document.getElementById(containerId); if (!container) { console.error(`[Calendar] Container #${containerId} not found`); return; } renderStyles(); const state = createState(); function render() { const today = new Date(); const isCurrentMonth = state.year === today.getFullYear() && state.month === today.getMonth() + 1; container.innerHTML = `
${MONTH_NAMES[state.month - 1]} ${state.year}
${state.loading ? `
${renderSkeleton()}
` : renderCalendarGrid(state)} ${renderTimeSlots(state)} ${renderBookingForm(state)}
`; attachEvents(); } function attachEvents() { const prevBtn = document.getElementById('cal-prev'); const nextBtn = document.getElementById('cal-next'); if (prevBtn) { prevBtn.addEventListener('click', () => { if (state.month === 1) { state.month = 12; state.year--; } else { state.month--; } state.selectedDate = null; state.selectedSlot = null; loadAvailability(); }); } if (nextBtn) { nextBtn.addEventListener('click', () => { if (state.month === 12) { state.month = 1; state.year++; } else { state.month++; } state.selectedDate = null; state.selectedSlot = null; loadAvailability(); }); } container.querySelectorAll('.cal-cell:not([disabled]):not(.cal-empty)').forEach(btn => { btn.addEventListener('click', () => { const date = btn.dataset.date; state.selectedDate = date; state.selectedSlot = null; state.bookingState = 'idle'; state.bookingError = null; render(); // Scroll slots into view on mobile const slotsEl = container.querySelector('.cal-slots-section'); if (slotsEl) slotsEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); }); container.querySelectorAll('.cal-slot-btn').forEach(btn => { btn.addEventListener('click', () => { state.selectedSlot = btn.dataset.slot; state.bookingState = 'idle'; state.bookingError = null; render(); const formEl = container.querySelector('.cal-form-section'); if (formEl) formEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); }); const bookingForm = document.getElementById('cal-booking-form'); if (bookingForm) { bookingForm.addEventListener('submit', handleBookingSubmit); } const newBookingBtn = document.getElementById('cal-new-booking'); if (newBookingBtn) { newBookingBtn.addEventListener('click', () => { state.selectedDate = null; state.selectedSlot = null; state.bookingState = 'idle'; state.bookingError = null; state.bookingResult = null; render(); }); } } async function handleBookingSubmit(e) { e.preventDefault(); const form = e.target; const nombre = form.nombre.value.trim(); const email = form.email.value.trim(); const telefono = form.telefono.value.trim(); const servicio = form.servicio.value; const mensaje = form.mensaje ? form.mensaje.value.trim() : ''; // Basic validation if (!nombre || !email || !telefono || !servicio) { state.bookingError = 'Por favor completa todos los campos requeridos.'; state.bookingState = 'idle'; render(); return; } const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailPattern.test(email)) { state.bookingError = 'Por favor ingresa un email válido.'; state.bookingState = 'idle'; render(); return; } state.bookingState = 'submitting'; state.bookingError = null; render(); const payload = { date: state.selectedDate, time: state.selectedSlot, nombre, email, telefono, servicio, mensaje, }; try { const result = await submitBooking(payload); state.bookingState = 'success'; state.bookingResult = { ...result, email }; } catch (err) { state.bookingState = 'error'; state.bookingError = err.message || 'Ocurrió un error al procesar tu reserva. Intenta de nuevo.'; } render(); } async function loadAvailability() { state.loading = true; render(); try { const data = await fetchAvailability(state.year, state.month); state.availability = data.availability || data || {}; } catch (err) { console.error('[Calendar] Failed to load availability:', err); state.availability = {}; } finally { state.loading = false; render(); } } // Initial load loadAvailability(); } // Export window.initCalendar = initCalendar; })();