- Add interactive modal popup for announcement 'Read More' functionality - Fix nonce conflict by creating separate hvac_announcements_ajax object - Implement secure AJAX handler with rate limiting and permission checks - Add comprehensive modal CSS with smooth animations and responsive design - Include accessibility features (ARIA, keyboard navigation, screen reader support) - Create detailed documentation in docs/ANNOUNCEMENT-MODAL-SYSTEM.md - Update API-REFERENCE.md with new modal endpoints and security details - Add automated Playwright E2E testing for modal functionality - All modal interactions working: click to open, X to close, ESC to close, outside click - Production-ready with full error handling and content sanitization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
259 lines
No EOL
8.8 KiB
JavaScript
259 lines
No EOL
8.8 KiB
JavaScript
/**
|
|
* HVAC Announcements View Handler
|
|
* Handles modal popup for viewing announcements
|
|
*
|
|
* @package HVAC_Community_Events
|
|
*/
|
|
|
|
jQuery(document).ready(function($) {
|
|
'use strict';
|
|
|
|
// Cache DOM elements
|
|
var $modal = $('#announcement-modal');
|
|
var $modalTitle = $modal.find('.modal-title');
|
|
var $modalMeta = $modal.find('.modal-meta');
|
|
var $modalContentText = $modal.find('.modal-content-text');
|
|
var $modalClose = $modal.find('.modal-close');
|
|
var isLoading = false;
|
|
|
|
// Handle announcement link clicks (both old and new styles)
|
|
$(document).on('click', '.announcement-link, .read-more-btn', function(e) {
|
|
e.preventDefault();
|
|
|
|
if (isLoading) {
|
|
return;
|
|
}
|
|
|
|
var announcementId = $(this).data('id');
|
|
|
|
if (!announcementId) {
|
|
console.error('No announcement ID found');
|
|
return;
|
|
}
|
|
|
|
openAnnouncementModal(announcementId);
|
|
});
|
|
|
|
// Handle modal close button
|
|
$modalClose.on('click', function() {
|
|
closeModal();
|
|
});
|
|
|
|
// Close modal when clicking outside
|
|
$modal.on('click', function(e) {
|
|
if (e.target === this) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// Close modal with ESC key
|
|
$(document).on('keydown', function(e) {
|
|
if (e.key === 'Escape' && $modal.is(':visible')) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Open announcement in modal
|
|
*/
|
|
function openAnnouncementModal(announcementId) {
|
|
isLoading = true;
|
|
|
|
// Show modal with loading state
|
|
$modalTitle.text('Loading...');
|
|
$modalMeta.empty();
|
|
$modalContentText.html('<div class="modal-loading">Loading announcement...</div>');
|
|
$modal.addClass('active').show();
|
|
|
|
// Prevent body scroll
|
|
$('body').addClass('modal-open');
|
|
|
|
// Make AJAX request to get announcement content
|
|
$.ajax({
|
|
url: hvac_announcements_ajax.ajax_url,
|
|
type: 'POST',
|
|
data: {
|
|
action: 'hvac_view_announcement',
|
|
id: announcementId,
|
|
nonce: hvac_announcements_ajax.nonce
|
|
},
|
|
success: function(response) {
|
|
if (response.success && response.data) {
|
|
var data = response.data;
|
|
|
|
// Populate modal with content
|
|
$modalTitle.text(data.title || 'Announcement');
|
|
|
|
// Build meta information
|
|
var metaHtml = '';
|
|
if (data.date) {
|
|
metaHtml += '<span class="meta-date">' + escapeHtml(data.date) + '</span>';
|
|
}
|
|
if (data.author) {
|
|
metaHtml += '<span class="meta-author">by ' + escapeHtml(data.author) + '</span>';
|
|
}
|
|
$modalMeta.html(metaHtml);
|
|
|
|
// Set content
|
|
$modalContentText.html(data.content || '<p>No content available.</p>');
|
|
|
|
// Focus on modal for accessibility
|
|
$modal.attr('aria-hidden', 'false');
|
|
$modalContentText.focus();
|
|
} else {
|
|
var errorMsg = response.data || 'Failed to load announcement';
|
|
$modalTitle.text('Error');
|
|
$modalMeta.empty();
|
|
$modalContentText.html('<div class="modal-error"><p>' + errorMsg + '</p></div>');
|
|
}
|
|
},
|
|
error: function(xhr, status, error) {
|
|
console.error('AJAX error:', error);
|
|
$modalTitle.text('Error');
|
|
$modalMeta.empty();
|
|
$modalContentText.html('<div class="modal-error"><p>Error loading announcement. Please try again.</p></div>');
|
|
},
|
|
complete: function() {
|
|
isLoading = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Close modal
|
|
*/
|
|
function closeModal() {
|
|
$modal.removeClass('active');
|
|
setTimeout(function() {
|
|
$modal.hide();
|
|
$modalTitle.empty();
|
|
$modalMeta.empty();
|
|
$modalContentText.empty();
|
|
$('body').removeClass('modal-open');
|
|
$modal.attr('aria-hidden', 'true');
|
|
}, 300);
|
|
}
|
|
|
|
/**
|
|
* Handle Load More button for timeline
|
|
*/
|
|
$(document).on('click', '.load-more-announcements', function(e) {
|
|
e.preventDefault();
|
|
|
|
var $button = $(this);
|
|
var currentPage = parseInt($button.data('page'));
|
|
var maxPages = parseInt($button.data('max'));
|
|
|
|
if (currentPage > maxPages) {
|
|
return;
|
|
}
|
|
|
|
$button.prop('disabled', true).text('Loading...');
|
|
|
|
$.ajax({
|
|
url: hvac_announcements_ajax.ajax_url,
|
|
type: 'POST',
|
|
data: {
|
|
action: 'hvac_get_announcements',
|
|
page: currentPage,
|
|
per_page: 10,
|
|
status: 'publish',
|
|
nonce: hvac_announcements_ajax.nonce
|
|
},
|
|
success: function(response) {
|
|
if (response.success && response.data.announcements) {
|
|
var announcements = response.data.announcements;
|
|
var $timeline = $('.timeline-wrapper');
|
|
|
|
// Append new announcements to timeline
|
|
announcements.forEach(function(announcement) {
|
|
var html = buildAnnouncementItem(announcement);
|
|
$timeline.append(html);
|
|
});
|
|
|
|
// Update button
|
|
if (currentPage >= maxPages) {
|
|
$button.parent().remove();
|
|
} else {
|
|
$button.data('page', currentPage + 1);
|
|
$button.prop('disabled', false).text('Load More Announcements');
|
|
}
|
|
}
|
|
},
|
|
error: function() {
|
|
$button.prop('disabled', false).text('Load More Announcements');
|
|
alert('Error loading more announcements. Please try again.');
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Build announcement item HTML
|
|
*/
|
|
function buildAnnouncementItem(announcement) {
|
|
var html = '<article class="timeline-item">';
|
|
html += '<div class="timeline-marker"></div>';
|
|
html += '<div class="timeline-content">';
|
|
html += '<header class="timeline-header">';
|
|
html += '<h3 class="timeline-title">';
|
|
// Ensure ID is numeric to prevent attribute injection
|
|
html += '<a href="#" class="announcement-link" data-id="' + parseInt(announcement.id, 10) + '">';
|
|
html += escapeHtml(announcement.title);
|
|
html += '</a>';
|
|
html += '</h3>';
|
|
html += '<div class="timeline-meta">';
|
|
html += '<span class="timeline-date">' + formatDate(announcement.date) + '</span>';
|
|
html += '<span class="timeline-author">' + escapeHtml(announcement.author) + '</span>';
|
|
html += '</div>';
|
|
html += '</header>';
|
|
|
|
if (announcement.featured_image) {
|
|
html += '<div class="timeline-thumbnail">';
|
|
// Safely build image element
|
|
var imgSrc = String(announcement.featured_image || '').replace(/"/g, '"');
|
|
html += '<img src="' + imgSrc + '" alt="">';
|
|
html += '</div>';
|
|
}
|
|
|
|
if (announcement.excerpt) {
|
|
// Excerpt is pre-sanitized server-side with wp_kses_post, safe to insert as HTML
|
|
html += '<div class="timeline-excerpt">' + announcement.excerpt + '</div>';
|
|
}
|
|
|
|
if (announcement.categories && announcement.categories.length > 0) {
|
|
html += '<div class="timeline-categories">';
|
|
announcement.categories.forEach(function(category) {
|
|
html += '<span class="category-badge">' + escapeHtml(category) + '</span>';
|
|
});
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
html += '</article>';
|
|
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Format date string
|
|
*/
|
|
function formatDate(dateString) {
|
|
var date = new Date(dateString);
|
|
var options = { year: 'numeric', month: 'long', day: 'numeric' };
|
|
return date.toLocaleDateString('en-US', options);
|
|
}
|
|
|
|
/**
|
|
* Escape HTML for security
|
|
*/
|
|
function escapeHtml(text) {
|
|
var map = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
};
|
|
return text.replace(/[&<>"']/g, function(m) { return map[m]; });
|
|
}
|
|
}); |