/** * Custom Polls - JavaScript Module * * Módulo ES6+ para manejo de encuestas via AJAX. * Sin dependencias externas - Vanilla JavaScript * * @package Guacanagarix\Components\Polls * @version 2.0.0 */ (function() { 'use strict'; /** * Configuración del módulo * Se sobrescribe con customPollsConfig si está definido. * useRestApi: true usa /wp-json/guacanagarix/v1/polls/{id}/vote (preferido) * useRestApi: false cae al endpoint AJAX clásico (compatibilidad) */ const DEFAULT_CONFIG = { ajaxUrl: '/wp-admin/admin-ajax.php', action: 'custom_poll_vote', restBase: '/wp-json/guacanagarix/v1', restNonce: '', useRestApi: true, selectors: { form: '.custom-poll-form', container: '.custom-poll-container', submit: '.custom-poll-submit', loading: '.custom-poll-loading', error: '.custom-poll-error', pollId: 'input[name="poll_id"]', nonce: 'input[name="nonce"]', answersRadio: 'input[name="poll_answer"]:checked', answersCheckbox: 'input[name="poll_answer[]"]:checked' }, messages: { selectOption: 'Por favor, selecciona una opción.', connectionError: 'Error de conexión. Por favor, inténtalo de nuevo.', processError: 'Error al procesar el voto.' }, animation: { enabled: true, duration: 300, easing: 'ease-out' } }; /** * Merge configuración global si existe */ const CONFIG = Object.freeze({ ...DEFAULT_CONFIG, ...(window.customPollsConfig || {}), selectors: Object.freeze({ ...DEFAULT_CONFIG.selectors, ...(window.customPollsConfig?.selectors || {}) }), messages: Object.freeze({ ...DEFAULT_CONFIG.messages, ...(window.customPollsConfig?.messages || {}) }), animation: Object.freeze({ ...DEFAULT_CONFIG.animation, ...(window.customPollsConfig?.animation || {}) }) }); /** * Utilidades DOM */ const DOM = { /** * Seleccionar elemento único */ $(selector, context = document) { return context.querySelector(selector); }, /** * Seleccionar múltiples elementos como array */ $$(selector, context = document) { return [...context.querySelectorAll(selector)]; }, /** * Mostrar elemento */ show(el) { if (el) el.style.display = ''; }, /** * Ocultar elemento */ hide(el) { if (el) el.style.display = 'none'; }, /** * Establecer texto */ setText(el, text) { if (el) el.textContent = text; }, /** * Habilitar/deshabilitar */ setDisabled(el, disabled) { if (el) el.disabled = disabled; }, /** * Parsear HTML string a elemento DOM */ parseHTML(html) { const template = document.createElement('template'); template.innerHTML = html.trim(); return template.content.firstElementChild; }, /** * Verificar si elemento existe */ exists(el) { return el !== null && el !== undefined; } }; /** * Módulo de animaciones CSS */ const Animator = { /** * Animar fade out */ fadeOut(el) { return new Promise(resolve => { if (!DOM.exists(el) || !CONFIG.animation.enabled) { return resolve(); } const { duration, easing } = CONFIG.animation; el.style.transition = `opacity ${duration}ms ${easing}`; el.style.opacity = '0'; setTimeout(resolve, duration); }); }, /** * Animar fade in */ fadeIn(el) { return new Promise(resolve => { if (!DOM.exists(el) || !CONFIG.animation.enabled) { return resolve(); } const { duration, easing } = CONFIG.animation; el.style.opacity = '0'; el.style.transition = `opacity ${duration}ms ${easing}`; // Forzar reflow para activar transición void el.offsetHeight; el.style.opacity = '1'; setTimeout(resolve, duration); }); } }; /** * Cliente HTTP usando Fetch API */ const Http = { /** * Realizar petición POST con cuerpo JSON (REST API) */ async postJson(url, data) { // La cabecera solo viaja si hay nonce. Un X-WP-Nonce vacío o caducado // hace que rest_cookie_check_errors responda 403 antes de llegar al // endpoint; sin cabecera, WP procesa la petición como anónima, que es // lo que esta encuesta admite. Ver Http.postJson en el changelog. const headers = { 'Content-Type': 'application/json' }; if (CONFIG.restNonce) { headers['X-WP-Nonce'] = CONFIG.restNonce; } const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(data), credentials: 'same-origin' }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.message ?? `HTTP ${response.status}: ${response.statusText}`); } return response.json(); }, /** * Realizar petición POST con datos de formulario (AJAX clásico) */ async post(url, data) { const body = new URLSearchParams(); // Serializar datos planos y arrays for (const [key, value] of Object.entries(data)) { if (Array.isArray(value)) { value.forEach(v => body.append(`${key}[]`, v)); } else { body.append(key, value); } } const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' }, body: body.toString(), credentials: 'same-origin' }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response.json(); } }; /** * Controlador principal de encuestas */ class PollsController { #boundOnSubmit; constructor() { this.#boundOnSubmit = this.#onFormSubmit.bind(this); this.#bindEvents(); } /** * Vincular eventos usando delegación */ #bindEvents() { document.addEventListener('submit', this.#boundOnSubmit); } /** * Desvincular eventos (útil para cleanup) */ destroy() { document.removeEventListener('submit', this.#boundOnSubmit); } /** * Handler de submit del formulario */ async #onFormSubmit(event) { const form = event.target; if (!form.matches(CONFIG.selectors.form)) { return; } event.preventDefault(); await this.#processVote(form); } /** * Procesar votación completa */ async #processVote(form) { const elements = this.#getElements(form); const data = this.#extractFormData(form); // Validación if (!this.#validate(data, elements)) { return; } // Estado de carga this.#setLoading(elements, true); try { const response = await this.#submitVote(data); await this.#handleSuccess(response, elements); } catch (error) { this.#handleError(elements, CONFIG.messages.connectionError); console.error('[CustomPolls] Error:', error); } } /** * Obtener referencias a elementos del DOM */ #getElements(form) { const { selectors } = CONFIG; return { form, container: form.closest(selectors.container), submit: DOM.$(selectors.submit, form), loading: DOM.$(selectors.loading, form), error: DOM.$(selectors.error, form) }; } /** * Extraer datos del formulario */ #extractFormData(form) { const { selectors } = CONFIG; const selectedInputs = DOM.$$( `${selectors.answersRadio}, ${selectors.answersCheckbox}`, form ); return { pollId: DOM.$(selectors.pollId, form)?.value ?? '', nonce: DOM.$(selectors.nonce, form)?.value ?? '', answers: selectedInputs.map(input => input.value) }; } /** * Validar datos antes de enviar */ #validate(data, elements) { if (data.answers.length === 0) { this.#showError(elements, CONFIG.messages.selectOption); return false; } return true; } /** * Establecer estado de carga en UI */ #setLoading(elements, isLoading) { const { submit, loading, error } = elements; // Limpiar errores previos DOM.hide(error); DOM.setText(error, ''); // Toggle botón y spinner DOM.setDisabled(submit, isLoading); isLoading ? DOM.hide(submit) : DOM.show(submit); isLoading ? DOM.show(loading) : DOM.hide(loading); } /** * Mostrar mensaje de error */ #showError(elements, message) { DOM.setText(elements.error, message); DOM.show(elements.error); } /** * Enviar voto al servidor. * Usa REST API cuando useRestApi=true, AJAX clásico como fallback. */ async #submitVote(data) { if (CONFIG.useRestApi) { return Http.postJson( `${CONFIG.restBase}/polls/${encodeURIComponent(data.pollId)}/vote`, { option_ids: data.answers.map(Number) } ); } // Compatibilidad AJAX clásico return Http.post(CONFIG.ajaxUrl, { action: CONFIG.action, poll_id: data.pollId, nonce: data.nonce, poll_answers: data.answers }); } /** * Manejar respuesta exitosa del servidor. * Soporta REST ({ success, html }) y AJAX clásico ({ success, data: { html } }). */ async #handleSuccess(response, elements) { // REST shape const html = response.html ?? response.data?.html; const success = response.success ?? false; const message = response.message ?? response.data?.message ?? CONFIG.messages.processError; if (!success || !html) { this.#handleError(elements, message); return; } await this.#replaceWithResults(elements.container, html); } /** * Reemplazar contenedor con vista de resultados. * results.php devuelve múltiples elementos hermanos (