/** * WPFA Events JavaScript Module * Handles search, admin functionality for events page * * @package */ const wpfaEvents = (function () { // Private variables let config = {}; let elements = {}; function showNotice(message, type = 'error') { const container = document.querySelector('.wpfaevent-notification-container') || document.querySelector('.wpfa-events-page') || document.body; const notice = document.createElement('div'); const noticeMessage = document.createElement('p'); notice.className = `notice notice-${type} is-dismissible wpfaevent-events-notice`; noticeMessage.textContent = message; notice.appendChild(noticeMessage); container .querySelectorAll('.wpfaevent-events-notice') .forEach((existingNotice) => existingNotice.remove()); container.insertBefore(notice, container.firstChild); notice.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function showDeleteConfirmation(message, onConfirm) { const overlay = document.createElement('div'); const dialog = document.createElement('div'); const text = document.createElement('p'); const actions = document.createElement('div'); const cancelButton = document.createElement('button'); const confirmButton = document.createElement('button'); overlay.className = 'wpfaevent-confirm-overlay'; overlay.setAttribute( 'style', 'position:fixed;inset:0;background:rgba(15,23,42,0.45);display:flex;align-items:center;justify-content:center;padding:24px;z-index:10000;' ); dialog.setAttribute( 'style', 'background:#fff;border-radius:12px;box-shadow:0 24px 48px rgba(15,23,42,0.2);max-width:420px;width:100%;padding:24px;' ); text.textContent = message; actions.setAttribute( 'style', 'display:flex;gap:12px;justify-content:flex-end;margin-top:20px;' ); cancelButton.type = 'button'; cancelButton.className = 'button'; cancelButton.textContent = config.i18n.cancelDelete || 'Cancel'; confirmButton.type = 'button'; confirmButton.className = 'button button-primary'; confirmButton.textContent = config.i18n.confirmDeleteButton || 'Delete'; const closeDialog = function () { overlay.remove(); }; cancelButton.addEventListener('click', closeDialog); overlay.addEventListener('click', function (event) { if (event.target === overlay) { closeDialog(); } }); confirmButton.addEventListener('click', function () { closeDialog(); onConfirm(); }); actions.append(cancelButton, confirmButton); dialog.append(text, actions); overlay.appendChild(dialog); document.body.appendChild(overlay); confirmButton.focus(); } /** * Helper to extract error message from AJAX response * * @param {Object|string|null} data AJAX response payload. * @param {string} fallback Fallback message to display. * @return {string} A user-facing error message. */ function getErrorMessage(data, fallback) { if (data && typeof data.data === 'object' && data.data?.message) { return `${fallback}: ${data.data.message}`; } if (data && typeof data.data === 'string') { return `${fallback}: ${data.data}`; } return fallback; } /** * Initialize the events module * * @param {Object} options Event page configuration. */ function init(options) { config = options || {}; // Ensure i18n object exists even if PHP fails to provide it config.i18n = config.i18n || {}; // Cache DOM elements cacheElements(); // Setup event listeners setupEventListeners(); // Setup character counters setupCharacterCounters(); if (config.canManageContent) { setupAdminFunctionality(); } // Setup search functionality setupSearch(); } /** * Cache DOM elements */ function cacheElements() { elements = { // Search & filters searchInput: document.getElementById('eventSearchInput'), filterTrack: document.getElementById('filterTrack'), filterLocation: document.getElementById('filterLocation'), searchBtn: document.getElementById('searchEventsBtn'), // Admin buttons createEventBtn: document.getElementById('createEventBtn'), // Modals createEventModal: document.getElementById('createEventModal'), editEventModal: document.getElementById('editEventModal'), // Modal close buttons closeCreateEventModal: document.querySelector( '#createEventModal .close-btn' ), closeEditEventModal: document.querySelector( '#editEventModal .close-btn' ), // Forms createEventForm: document.getElementById('createEventForm'), editEventForm: document.getElementById('editEventForm'), // Events container eventsContainer: document.getElementById('events-container'), resultsCount: document.getElementById('resultsCount'), totalCount: document.getElementById('totalCount'), }; } /** * Setup event listeners */ function setupEventListeners() { // Create event button if (elements.createEventBtn) { elements.createEventBtn.addEventListener( 'click', openCreateEventModal ); } // Modal close buttons if (elements.closeCreateEventModal) { elements.closeCreateEventModal.addEventListener( 'click', closeCreateEventModal ); } if (elements.closeEditEventModal) { elements.closeEditEventModal.addEventListener( 'click', closeEditEventModal ); } // Close modals on background click if (elements.createEventModal) { elements.createEventModal.addEventListener('click', function (e) { if (e.target === this) { closeCreateEventModal(); } }); } if (elements.editEventModal) { elements.editEventModal.addEventListener('click', function (e) { if (e.target === this) { closeEditEventModal(); } }); } // Form submissions if (elements.createEventForm) { elements.createEventForm.addEventListener( 'submit', handleCreateEventFormSubmit ); } if (elements.editEventForm) { elements.editEventForm.addEventListener( 'submit', handleEditEventFormSubmit ); } setupTimeFormatControls(elements.createEventForm); setupTimeFormatControls(elements.editEventForm); // Event card actions delegation (admin only) if (elements.eventsContainer) { elements.eventsContainer.addEventListener( 'click', handleCardActions ); } } /** * Setup character counters */ function setupCharacterCounters() { document.querySelectorAll('textarea').forEach((textarea) => { const counter = textarea.nextElementSibling; if (counter?.classList.contains('wpfaevent-char-counter')) { const update = () => { const currentLength = textarea.value.length; counter.textContent = `${currentLength}`; if ( textarea.maxLength > 0 && currentLength >= textarea.maxLength ) { counter.classList.add('limit-reached'); } else { counter.classList.remove('limit-reached'); } }; textarea.addEventListener('input', update); textarea.form?.addEventListener('reset', () => { setTimeout(update, 0); }); // Call update immediately update(); /** * * If data is loaded dynamically (e.g. via AJAX or WP Modal), * we wait a tiny bit to catch the filled value. */ if (textarea.value.length === 0) { setTimeout(update, 100); } } }); } /** * Get the Classic editor API loaded by wp_enqueue_editor(). * * @return {Object|null} Editor API, or null when it is not loaded. */ function getEditorApi() { const wp = window.wp || {}; // The block editor package takes over wp.editor and keeps this API as wp.oldEditor. const api = wp.oldEditor || wp.editor; if (window.tinymce && typeof api?.initialize === 'function') { return api; } return null; } /** * Descriptions waiting for their editor to finish initializing, by textarea ID. * * @type {Object} */ const pendingDescriptions = {}; /** * Show a description in a modal's Description field. * * On first use the field becomes a WordPress visual editor, so admins * edit formatted text instead of the HTML stored for the event. * * @param {string} id Description textarea ID. * @param {string} value Description HTML or plain text. */ function setDescription(id, value) { const textarea = document.getElementById(id); if (!textarea) { return; } const api = getEditorApi(); if (!api) { textarea.value = value; return; } // Older descriptions typed in the plain textarea use line breaks for // paragraphs. HTML descriptions are kept as they are. const content = /<(p|div|ul|ol|h[1-6]|blockquote|pre|table)\b/i.test( value ) ? value : api.autop(value); const editor = window.tinymce.get(id); if (editor?.initialized) { editor.setContent(content); editor.save(); return; } // Applied on init, so an editor still loading can't show an older description. pendingDescriptions[id] = content; textarea.value = content; if (editor) { return; } const counter = textarea.nextElementSibling?.classList.contains( 'wpfaevent-char-counter' ) ? textarea.nextElementSibling : null; // TinyMCE hides the textarea, so the browser can't flag it; the submit handler checks it instead. textarea.required = false; api.initialize(id, { tinymce: { setup(instance) { instance.on('init', () => { if (id in pendingDescriptions) { instance.setContent(pendingDescriptions[id]); delete pendingDescriptions[id]; } }); instance.on( 'init input change keyup undo redo SetContent', () => { instance.save(); if (counter) { updateDescriptionCounter(instance, counter); } } ); }, }, quicktags: false, mediaButtons: false, }); } /** * Update a Description field's counter from the visual editor. * * @param {Object} editor TinyMCE editor instance. * @param {HTMLElement} counter Counter element below the field. */ function updateDescriptionCounter(editor, counter) { // Count the text the admin sees, not the HTML tags. counter.textContent = `${editor.getContent({ format: 'text' }).trim().length}`; } /** * Check whether a description has no visible text. * * The visual editor can send markup such as `

 

` for a field * that looks empty. * * @param {string} html Description HTML. * @return {boolean} True when nothing would be shown. */ function isBlankDescription(html) { // DOMParser documents are inert, so markup in the description can't run. const doc = new window.DOMParser().parseFromString(html, 'text/html'); return (doc.body.textContent || '').trim() === ''; } /** * Copy visual editor content into the description textareas. */ function saveDescriptions() { if (getEditorApi()) { window.tinymce.triggerSave(); } } /** * Setup all-day toggles for event time inputs * * @param {HTMLFormElement|null|undefined} form Event form element. */ function setupTimeFormatControls(form) { const allDayField = form?.querySelector('[name="all_day"]'); if (!allDayField) { return; } allDayField.addEventListener('change', () => syncTimeFields(form)); form.addEventListener('reset', () => { setTimeout(() => syncTimeFields(form), 0); }); syncTimeFields(form); } /** * Disable time inputs while an event is marked all-day * * @param {HTMLFormElement|null|undefined} form Event form element. */ function syncTimeFields(form) { const allDayField = form?.querySelector('[name="all_day"]'); const timeFields = form?.querySelector('.wpfaevent-time-fields'); const timeInputs = form?.querySelectorAll('[name="start_time"], [name="end_time"]') || []; const isAllDay = Boolean(allDayField?.checked); timeFields?.classList.toggle('is-disabled', isAllDay); timeInputs.forEach((input) => { input.disabled = isAllDay; if (isAllDay) { input.value = ''; } }); } /** * Select a timezone value without losing the server-rendered default * * @param {HTMLSelectElement|null} select Timezone select element. * @param {string} value Selected timezone value. */ function setSelectValue(select, value) { if (!select || !value) { return; } const previousValue = select.value; select.value = value; if (select.value !== value) { select.value = previousValue; } } /** * Preserve the legacy time payload expected by older handlers * * @param {FormData} formData Event form data. */ function appendLegacyTimeAlias(formData) { const startTime = formData.get('start_time') || ''; if (formData.has('time')) { formData.set('time', formData.get('time') || startTime); } else { formData.append('time', startTime); } if (!formData.has('all_day')) { formData.append('all_day', '0'); } } /** * Setup search functionality */ function setupSearch() { if (elements.searchInput) { elements.searchInput.addEventListener('keyup', filterEvents); elements.searchInput.addEventListener('search', filterEvents); } if (elements.filterTrack) { elements.filterTrack.addEventListener('change', filterEvents); } if (elements.filterLocation) { elements.filterLocation.addEventListener('change', filterEvents); } if (elements.searchBtn) { elements.searchBtn.addEventListener('click', filterEvents); } // Date filter tabs document.querySelectorAll('.date-filter-btn').forEach((btn) => { btn.addEventListener('click', function () { document .querySelectorAll('.date-filter-btn') .forEach((b) => b.classList.remove('active')); this.classList.add('active'); // Update the URL dynamically if ( typeof window !== 'undefined' && window.history && window.history.pushState ) { const url = new URL(window.location.href); const filter = this.dataset.filter; if (filter && filter !== 'all') { url.searchParams.set('filter', filter); } else { url.searchParams.delete('filter'); } window.history.pushState({ filter }, '', url.toString()); } filterEvents(); }); }); // Check for URL parameter to pre-select filter tab on load const urlParams = new URLSearchParams(window.location.search); const urlFilter = urlParams.get('filter'); const allowedFilters = ['all', 'upcoming', 'past', 'bookmarked']; if (urlFilter && allowedFilters.includes(urlFilter)) { const targetBtn = Array.from( document.querySelectorAll('.date-filter-btn') ).find((btn) => btn.dataset.filter === urlFilter); if (targetBtn) { document .querySelectorAll('.date-filter-btn') .forEach((b) => b.classList.remove('active')); targetBtn.classList.add('active'); filterEvents(); } else if (urlFilter === 'bookmarked') { // Favorites tab is only available when logged in. if ( typeof window !== 'undefined' && window.history && window.history.replaceState ) { const cleanUrl = new URL(window.location.href); cleanUrl.searchParams.delete('filter'); window.history.replaceState({}, '', cleanUrl.toString()); } filterEvents(); } } // Handle popstate (back/forward browser buttons) if (typeof window !== 'undefined') { window.addEventListener('popstate', function () { const params = new URLSearchParams(window.location.search); const currentFilter = params.get('filter') || 'all'; const targetBtn = Array.from( document.querySelectorAll('.date-filter-btn') ).find((btn) => btn.dataset.filter === currentFilter); if (targetBtn) { document .querySelectorAll('.date-filter-btn') .forEach((b) => b.classList.remove('active')); targetBtn.classList.add('active'); filterEvents(); } }); } } /** * Update active classes in navigation header based on filter selection. * * @param {string} filter Active filter name. */ function updateNavigationHeader(filter) { const navMain = document.querySelector('.nav-links-main'); if (!navMain) { return; } const upcomingLink = navMain.querySelector( '[data-nav="upcoming"], .nav-link-upcoming' ) || navMain.querySelector( 'a[href*="filter=upcoming"], a[href*="/events"]:not([href*="filter=past"])' ); const pastLink = navMain.querySelector('[data-nav="past"], .nav-link-past') || navMain.querySelector('a[href*="filter=past"]'); if (upcomingLink && pastLink) { if (filter === 'past') { upcomingLink.classList.remove('active'); pastLink.classList.add('active'); } else { pastLink.classList.remove('active'); upcomingLink.classList.add('active'); } } } /** * Filter events based on all active filter controls. */ function filterEvents() { if (!elements.eventsContainer) { return; } const activeDateBtn = document.querySelector('.date-filter-btn.active'); const dateFilter = activeDateBtn ? activeDateBtn.dataset.filter : 'all'; // Update active classes in navigation header updateNavigationHeader(dateFilter); const searchTerm = ( elements.searchInput ? elements.searchInput.value : '' ) .toLowerCase() .trim(); const track = elements.filterTrack ? elements.filterTrack.value : ''; const location = elements.filterLocation ? elements.filterLocation.value.toLowerCase() : ''; const allCards = elements.eventsContainer.querySelectorAll('.event-card'); let visibleCount = 0; allCards.forEach((card) => { const name = (card.dataset.name || '').toLowerCase(); const place = (card.dataset.place || '').toLowerCase(); const description = (card.dataset.description || '').toLowerCase(); const cardTrack = (card.dataset.track || '').toLowerCase(); const isPast = card.dataset.isPast === '1'; const isBookmarked = card.classList.contains('is-bookmarked') || card.dataset.isBookmarked === '1'; const textMatch = !searchTerm || name.includes(searchTerm) || place.includes(searchTerm) || description.includes(searchTerm); const trackMatch = !track || cardTrack .split(',') .some((s) => s.trim() === track.toLowerCase()); const locationMatch = !location || place === location; const dateMatch = dateFilter === 'all' || (dateFilter === 'past' && isPast) || (dateFilter === 'upcoming' && !isPast) || (dateFilter === 'bookmarked' && isBookmarked); const isVisible = textMatch && trackMatch && locationMatch && dateMatch; card.style.display = isVisible ? '' : 'none'; if (isVisible) { visibleCount++; } }); if (elements.resultsCount) { elements.resultsCount.textContent = visibleCount; } if (elements.totalCount) { elements.totalCount.textContent = allCards.length; } } /** * Setup admin functionality */ function setupAdminFunctionality() { // Add admin class to body for styling document.body.classList.add('wpfa-is-admin'); } /** * Handle event card actions * * @param {Event} e Click event from the event card container. */ function handleCardActions(e) { const target = e.target; // Edit event button if ( target.matches('.btn-edit-event') || target.closest('.btn-edit-event') ) { e.preventDefault(); e.stopPropagation(); const button = target.matches('.btn-edit-event') ? target : target.closest('.btn-edit-event'); const card = button.closest('.event-card'); openEditEventModal(card); } // Delete event button else if ( target.matches('.btn-delete-event') || target.closest('.btn-delete-event') ) { e.preventDefault(); e.stopPropagation(); const button = target.matches('.btn-delete-event') ? target : target.closest('.btn-delete-event'); const eventId = button.closest('.event-card').dataset.postId; const eventName = button.closest('.event-card').dataset.name; deleteEvent(eventId, eventName); } } /** * Open modal for creating new event */ function openCreateEventModal() { // Reset form if (elements.createEventForm) { elements.createEventForm.reset(); // Set today's date as default const today = new Date().toISOString().split('T')[0]; const eventDateInput = document.getElementById('eventDate'); if (eventDateInput) { eventDateInput.value = today; eventDateInput.min = today; } // Use the smart function to reset all counters to "0 / max" setupCharacterCounters(); syncTimeFields(elements.createEventForm); } // Show modal if (elements.createEventModal) { elements.createEventModal.style.display = 'flex'; } // After showing the modal, so the editor is sized to the visible field. setDescription('eventDescription', ''); } /** * Open modal for editing event * * @param {HTMLElement} card Event card element. */ function openEditEventModal(card) { // Get data from card const eventId = card.dataset.postId; // Fill form with data from card dataset document.getElementById('editEventId').value = eventId; document.getElementById('editEventName').value = card.dataset.name || ''; document.getElementById('editEventDate').value = card.dataset.date || ''; document.getElementById('editEventEndDate').value = card.dataset.endDate || ''; document.getElementById('editEventPlace').value = card.dataset.place || ''; document.getElementById('editEventLeadText').value = card.dataset.leadText || ''; document.getElementById('editRegistrationLink').value = card.dataset.registrationLink || ''; document.getElementById('editCfsLink').value = card.dataset.cfsLink || ''; const editStartTime = document.getElementById('editEventStartTime'); const editEndTime = document.getElementById('editEventEndTime'); const editAllDay = document.getElementById('editEventAllDay'); const editTimezone = document.getElementById('editEventTimezone'); if (editStartTime) { editStartTime.value = card.dataset.startTime || card.dataset.time || ''; } if (editEndTime) { editEndTime.value = card.dataset.endTime || ''; } if (editAllDay) { editAllDay.checked = card.dataset.allDay === '1' || card.dataset.allDay === 'true'; } setSelectValue(editTimezone, card.dataset.timezone || ''); syncTimeFields(elements.editEventForm); // Sync all counters at once // This looks for all textareas and updates their specific counters setupCharacterCounters(); // Show modal if (elements.editEventModal) { elements.editEventModal.style.display = 'flex'; } // After showing the modal, so the editor is sized to the visible field. setDescription('editEventDescription', card.dataset.description || ''); } /** * Handle create event form submission * * @param {SubmitEvent} e Create form submit event. */ function handleCreateEventFormSubmit(e) { e.preventDefault(); if (!config.canManageContent) { showNotice( config.i18n.noPermission || 'You do not have permission to perform this action.' ); return; } const form = e.target; saveDescriptions(); const formData = new FormData(form); appendLegacyTimeAlias(formData); const submitBtn = form.querySelector('button[type="submit"]'); // Validate required fields - using ACTUAL form field names const requiredFields = [ 'title', 'content', 'start_date', 'location', 'registration_link', ]; const missingFields = []; requiredFields.forEach((field) => { const value = formData.get(field) || ''; const isBlank = field === 'content' ? isBlankDescription(value) : value.trim() === ''; if (isBlank) { missingFields.push(field); } }); if (missingFields.length > 0) { showNotice( config.i18n.missingFields || 'Missing required fields: ' + missingFields.join(', ') ); // Re-enable button if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = config.i18n.addEventButton || 'Create Card'; } return; } // Disable button during submission if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = config.i18n.creating || 'Creating...'; } // Add nonce and AJAX action formData.append('action', 'wpfa_add_event'); formData.append('nonce', config.adminNonce); // Send form data fetch(config.ajaxUrl, { method: 'POST', body: formData, }) .then((response) => response.json()) .then((data) => { if (data.success) { window.location.reload(); } else { const baseMsg = config.i18n.addError || 'Error creating event'; showNotice(getErrorMessage(data, baseMsg)); // Re-enable button if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = config.i18n.addEventButton || 'Create Card'; } } }) .catch(() => { showNotice( config.i18n.addErrorGeneric || 'Error creating event. Please try again.' ); // Re-enable button if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = config.i18n.addEventButton || 'Create Card'; } }); } /** * Handle edit event form submission * * @param {SubmitEvent} e Edit form submit event. */ function handleEditEventFormSubmit(e) { e.preventDefault(); if (!config.canManageContent) { showNotice( config.i18n.noPermission || 'You do not have permission to perform this action.' ); return; } const form = e.target; saveDescriptions(); const formData = new FormData(form); appendLegacyTimeAlias(formData); const submitBtn = form.querySelector('button[type="submit"]'); // Validate required fields - using ACTUAL form field names const requiredFields = [ 'title', 'content', 'start_date', 'location', 'registration_link', ]; const missingFields = []; requiredFields.forEach((field) => { const value = formData.get(field) || ''; const isBlank = field === 'content' ? isBlankDescription(value) : value.trim() === ''; if (isBlank) { missingFields.push(field); } }); if (missingFields.length > 0) { showNotice( config.i18n.missingFields || 'Missing required fields: ' + missingFields.join(', ') ); // Re-enable button if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = config.i18n.editEventButton || 'Save Changes'; } return; } // Disable button during submission if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = config.i18n.saving || 'Saving...'; } // Add nonce and AJAX action formData.append('action', 'wpfa_update_event'); formData.append('nonce', config.adminNonce); // Send form data fetch(config.ajaxUrl, { method: 'POST', body: formData, }) .then((response) => response.json()) .then((data) => { if (data.success) { window.location.reload(); } else { const baseMsg = config.i18n.updateError || 'Error updating event'; showNotice(getErrorMessage(data, baseMsg)); // Re-enable button if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = config.i18n.editEventButton || 'Save Changes'; } } }) .catch(() => { showNotice( config.i18n.updateErrorGeneric || 'Error updating event. Please try again.' ); // Re-enable button if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = config.i18n.editEventButton || 'Save Changes'; } }); } /** * Delete event confirmation and AJAX call * * @param {string} eventId Event post ID. * @param {string} eventName Event title. */ function deleteEvent(eventId, eventName) { const confirmMsg = config.i18n.confirmDelete ? config.i18n.confirmDelete.replace('%s', eventName) : `Are you sure you want to delete "${eventName}"? This action cannot be undone.`; showDeleteConfirmation(confirmMsg, function () { fetch(config.ajaxUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ action: 'wpfa_delete_event', nonce: config.adminNonce, event_id: eventId, }), }) .then((response) => response.json()) .then((data) => { if (data.success) { window.location.reload(); } else { const baseMsg = config.i18n.deleteError || 'Error deleting event'; showNotice(getErrorMessage(data, baseMsg)); } }) .catch(() => { showNotice( config.i18n.deleteErrorGeneric || 'Error deleting event. Please try again.' ); }); }); } /** * Close create event modal */ function closeCreateEventModal() { if (elements.createEventModal) { elements.createEventModal.style.display = 'none'; } } /** * Close edit event modal */ function closeEditEventModal() { if (elements.editEventModal) { elements.editEventModal.style.display = 'none'; } } // Public API return { init, openCreateEventModal, openEditEventModal, closeCreateEventModal, closeEditEventModal, filterEvents, }; })(); // Export to global if (typeof window !== 'undefined') { Object.assign(window, { WPFA_Events: wpfaEvents, wpfaEvents, }); } // Initialize when page loads if (typeof document !== 'undefined') { document.addEventListener('DOMContentLoaded', function () { // Check if config exists (only on events page) if (typeof wpfaeventEventsConfig !== 'undefined') { wpfaEvents.init(wpfaeventEventsConfig); } }); }