Problem: - nginx serves blog.html as static file, bypassing Express middleware - setCsrfToken middleware never runs - No CSRF cookie set - Newsletter subscription fails with 403 Forbidden Root cause: nginx config: 'try_files $uri @proxy' serves static files directly Location: /etc/nginx/sites-available/tractatus (line 54) Solution: 1. blog.js now fetches CSRF token via /api/csrf-token on page load 2. getCsrfToken endpoint now creates token if missing (for static pages) 3. Newsletter form uses fetched token for subscription Testing: ✅ Local test: CSRF token fetched successfully ✅ Newsletter subscription: Creates record in database ✅ Verified: test-fix@example.com subscribed via curl test Impact: - Newsletter subscriptions now work on production - Fix applies to all static HTML pages (blog.html, etc.) - Maintains CSRF protection security Files: - public/js/blog.js: Added fetchCsrfToken() + use in newsletter form - src/middleware/csrf-protection.middleware.js: Enhanced getCsrfToken() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
654 lines
19 KiB
JavaScript
654 lines
19 KiB
JavaScript
/**
|
|
* Blog Listing Page - Client-Side Logic
|
|
* Handles fetching, filtering, searching, sorting, and pagination of blog posts
|
|
*/
|
|
|
|
// State management
|
|
let allPosts = [];
|
|
let filteredPosts = [];
|
|
let currentPage = 1;
|
|
const postsPerPage = 9;
|
|
|
|
// Filter state
|
|
const activeFilters = {
|
|
search: '',
|
|
category: '',
|
|
sort: 'date-desc'
|
|
};
|
|
|
|
// CSRF token (fetched on page load)
|
|
let csrfToken = null;
|
|
|
|
/**
|
|
* Initialize the blog page
|
|
*/
|
|
async function init() {
|
|
try {
|
|
// Fetch CSRF token first (required for newsletter subscription)
|
|
await fetchCsrfToken();
|
|
|
|
await loadPosts();
|
|
attachEventListeners();
|
|
} catch (error) {
|
|
console.error('Error initializing blog:', error);
|
|
showError('Failed to load blog posts. Please refresh the page.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch CSRF token from server
|
|
* Required because nginx serves blog.html as static file (bypasses setCsrfToken middleware)
|
|
*/
|
|
async function fetchCsrfToken() {
|
|
try {
|
|
const response = await fetch('/api/csrf-token');
|
|
const data = await response.json();
|
|
|
|
if (data.csrfToken) {
|
|
csrfToken = data.csrfToken;
|
|
console.log('CSRF token fetched successfully');
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to fetch CSRF token:', error);
|
|
// Non-critical - newsletter subscription will fail but blog browsing still works
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load all published blog posts from API
|
|
*/
|
|
async function loadPosts() {
|
|
try {
|
|
const response = await fetch('/api/blog');
|
|
const data = await response.json();
|
|
|
|
if (!data.success) {
|
|
throw new Error(data.error || 'Failed to load posts');
|
|
}
|
|
|
|
allPosts = data.posts || [];
|
|
filteredPosts = [...allPosts];
|
|
|
|
// Apply initial sorting
|
|
sortPosts();
|
|
|
|
// Render initial view
|
|
renderPosts();
|
|
updateResultsCount();
|
|
} catch (error) {
|
|
console.error('Error loading posts:', error);
|
|
showError('Failed to load blog posts');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render blog posts grid
|
|
*/
|
|
function renderPosts() {
|
|
const gridEl = document.getElementById('blog-grid');
|
|
const emptyStateEl = document.getElementById('empty-state');
|
|
|
|
if (filteredPosts.length === 0) {
|
|
gridEl.innerHTML = '';
|
|
emptyStateEl.classList.remove('hidden');
|
|
document.getElementById('pagination').classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
emptyStateEl.classList.add('hidden');
|
|
|
|
// Calculate pagination
|
|
const startIndex = (currentPage - 1) * postsPerPage;
|
|
const endIndex = startIndex + postsPerPage;
|
|
const postsToShow = filteredPosts.slice(startIndex, endIndex);
|
|
|
|
// Render posts
|
|
const postsHTML = postsToShow.map(post => renderPostCard(post)).join('');
|
|
gridEl.innerHTML = postsHTML;
|
|
|
|
// Render pagination
|
|
renderPagination();
|
|
|
|
// Scroll to top when changing pages (except initial load)
|
|
if (currentPage > 1) {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a single post card
|
|
*/
|
|
function renderPostCard(post) {
|
|
const publishedDate = new Date(post.published_at);
|
|
const formattedDate = publishedDate.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
});
|
|
|
|
// Calculate read time (rough estimate: 200 words per minute)
|
|
const wordCount = post.content ? post.content.split(/\s+/).length : 0;
|
|
const readTime = Math.max(1, Math.ceil(wordCount / 200));
|
|
|
|
// Truncate excerpt to 150 characters
|
|
const excerpt = post.excerpt ?
|
|
(post.excerpt.length > 150 ? `${post.excerpt.substring(0, 150) }...` : post.excerpt) :
|
|
'Read more...';
|
|
|
|
// Get category color
|
|
const categoryColor = getCategoryColor(post.category);
|
|
|
|
return `
|
|
<article class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow duration-300">
|
|
<a href="/blog-post.html?slug=${escapeHtml(post.slug)}" class="block">
|
|
${post.featured_image ? `
|
|
<div class="aspect-w-16 aspect-h-9 bg-gray-200">
|
|
<img src="${escapeHtml(post.featured_image)}" alt="${escapeHtml(post.title)}" class="object-cover w-full h-48">
|
|
</div>
|
|
` : `
|
|
<div class="h-48 bg-gradient-to-br ${categoryColor} flex items-center justify-center">
|
|
<svg class="h-16 w-16 text-white opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
</svg>
|
|
</div>
|
|
`}
|
|
|
|
<div class="p-6">
|
|
<!-- Category Badge -->
|
|
${post.category ? `
|
|
<span class="inline-block bg-indigo-100 text-indigo-800 text-xs font-semibold px-2.5 py-0.5 rounded mb-3">
|
|
${escapeHtml(post.category)}
|
|
</span>
|
|
` : ''}
|
|
|
|
<!-- Title -->
|
|
<h2 class="text-xl font-bold text-gray-900 mb-2 line-clamp-2 hover:text-indigo-600 transition">
|
|
${escapeHtml(post.title)}
|
|
</h2>
|
|
|
|
<!-- Excerpt -->
|
|
<p class="text-gray-600 mb-4 line-clamp-3">
|
|
${escapeHtml(excerpt)}
|
|
</p>
|
|
|
|
<!-- Metadata -->
|
|
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
|
<div class="flex items-center">
|
|
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"/>
|
|
</svg>
|
|
<time datetime="${post.published_at}">${formattedDate}</time>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<span>${readTime} min read</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tags -->
|
|
${post.tags && post.tags.length > 0 ? `
|
|
<div class="mt-4 flex flex-wrap gap-1">
|
|
${post.tags.slice(0, 3).map(tag => `
|
|
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">
|
|
${escapeHtml(tag)}
|
|
</span>
|
|
`).join('')}
|
|
${post.tags.length > 3 ? `<span class="text-xs text-gray-500">+${post.tags.length - 3} more</span>` : ''}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</a>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Get category color gradient
|
|
*/
|
|
function getCategoryColor(category) {
|
|
const colorMap = {
|
|
'Framework Updates': 'from-blue-400 to-blue-600',
|
|
'Case Studies': 'from-purple-400 to-purple-600',
|
|
'Research': 'from-green-400 to-green-600',
|
|
'Implementation': 'from-yellow-400 to-yellow-600',
|
|
'Community': 'from-pink-400 to-pink-600'
|
|
};
|
|
return colorMap[category] || 'from-gray-400 to-gray-600';
|
|
}
|
|
|
|
/**
|
|
* Render pagination controls
|
|
*/
|
|
function renderPagination() {
|
|
const paginationEl = document.getElementById('pagination');
|
|
const totalPages = Math.ceil(filteredPosts.length / postsPerPage);
|
|
|
|
if (totalPages <= 1) {
|
|
paginationEl.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
paginationEl.classList.remove('hidden');
|
|
|
|
const prevBtn = document.getElementById('prev-page');
|
|
const nextBtn = document.getElementById('next-page');
|
|
const pageNumbersEl = document.getElementById('page-numbers');
|
|
|
|
// Update prev/next buttons
|
|
prevBtn.disabled = currentPage === 1;
|
|
nextBtn.disabled = currentPage === totalPages;
|
|
|
|
// Render page numbers
|
|
let pageNumbersHTML = '';
|
|
const maxVisiblePages = 5;
|
|
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
|
|
const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
|
|
|
// Adjust start if we're near the end
|
|
if (endPage - startPage < maxVisiblePages - 1) {
|
|
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
|
}
|
|
|
|
// First page + ellipsis
|
|
if (startPage > 1) {
|
|
pageNumbersHTML += `
|
|
<button class="page-number px-3 py-1 border border-gray-300 rounded text-sm" data-page="1">1</button>
|
|
${startPage > 2 ? '<span class="px-2 text-gray-500">...</span>' : ''}
|
|
`;
|
|
}
|
|
|
|
// Visible page numbers
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
const isActive = i === currentPage;
|
|
pageNumbersHTML += `
|
|
<button class="page-number px-3 py-1 border ${isActive ? 'bg-indigo-600 text-white border-indigo-600' : 'border-gray-300 text-gray-700 hover:bg-gray-50'} rounded text-sm" data-page="${i}">
|
|
${i}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
// Ellipsis + last page
|
|
if (endPage < totalPages) {
|
|
pageNumbersHTML += `
|
|
${endPage < totalPages - 1 ? '<span class="px-2 text-gray-500">...</span>' : ''}
|
|
<button class="page-number px-3 py-1 border border-gray-300 rounded text-sm" data-page="${totalPages}">${totalPages}</button>
|
|
`;
|
|
}
|
|
|
|
pageNumbersEl.innerHTML = pageNumbersHTML;
|
|
}
|
|
|
|
/**
|
|
* Apply filters and search
|
|
*/
|
|
function applyFilters() {
|
|
// Reset to first page when filters change
|
|
currentPage = 1;
|
|
|
|
// Start with all posts
|
|
filteredPosts = [...allPosts];
|
|
|
|
// Apply search
|
|
if (activeFilters.search) {
|
|
const searchLower = activeFilters.search.toLowerCase();
|
|
filteredPosts = filteredPosts.filter(post => {
|
|
return (
|
|
post.title.toLowerCase().includes(searchLower) ||
|
|
(post.content && post.content.toLowerCase().includes(searchLower)) ||
|
|
(post.excerpt && post.excerpt.toLowerCase().includes(searchLower)) ||
|
|
(post.tags && post.tags.some(tag => tag.toLowerCase().includes(searchLower)))
|
|
);
|
|
});
|
|
}
|
|
|
|
// Apply category filter
|
|
if (activeFilters.category) {
|
|
filteredPosts = filteredPosts.filter(post => post.category === activeFilters.category);
|
|
}
|
|
|
|
// Sort
|
|
sortPosts();
|
|
|
|
// Update UI
|
|
renderPosts();
|
|
updateResultsCount();
|
|
updateActiveFiltersDisplay();
|
|
}
|
|
|
|
/**
|
|
* Sort posts based on active sort option
|
|
*/
|
|
function sortPosts() {
|
|
switch (activeFilters.sort) {
|
|
case 'date-desc':
|
|
filteredPosts.sort((a, b) => new Date(b.published_at) - new Date(a.published_at));
|
|
break;
|
|
case 'date-asc':
|
|
filteredPosts.sort((a, b) => new Date(a.published_at) - new Date(b.published_at));
|
|
break;
|
|
case 'title-asc':
|
|
filteredPosts.sort((a, b) => a.title.localeCompare(b.title));
|
|
break;
|
|
case 'title-desc':
|
|
filteredPosts.sort((a, b) => b.title.localeCompare(a.title));
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update results count display
|
|
*/
|
|
function updateResultsCount() {
|
|
const countEl = document.getElementById('post-count');
|
|
if (countEl) {
|
|
countEl.textContent = filteredPosts.length;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update active filters display
|
|
*/
|
|
function updateActiveFiltersDisplay() {
|
|
const activeFiltersEl = document.getElementById('active-filters');
|
|
const filterTagsEl = document.getElementById('filter-tags');
|
|
|
|
const hasActiveFilters = activeFilters.search || activeFilters.category;
|
|
|
|
if (!hasActiveFilters) {
|
|
activeFiltersEl.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
activeFiltersEl.classList.remove('hidden');
|
|
|
|
let tagsHTML = '';
|
|
|
|
if (activeFilters.search) {
|
|
tagsHTML += `
|
|
<span class="inline-flex items-center gap-1 px-3 py-1 bg-indigo-100 text-indigo-800 rounded-full text-sm">
|
|
Search: "${escapeHtml(activeFilters.search)}"
|
|
<button class="ml-1 hover:text-indigo-900" data-remove-filter="search">
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
if (activeFilters.category) {
|
|
tagsHTML += `
|
|
<span class="inline-flex items-center gap-1 px-3 py-1 bg-indigo-100 text-indigo-800 rounded-full text-sm">
|
|
Category: ${escapeHtml(activeFilters.category)}
|
|
<button class="ml-1 hover:text-indigo-900" data-remove-filter="category">
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
filterTagsEl.innerHTML = tagsHTML;
|
|
}
|
|
|
|
/**
|
|
* Clear all filters
|
|
*/
|
|
function clearFilters() {
|
|
activeFilters.search = '';
|
|
activeFilters.category = '';
|
|
|
|
// Reset UI elements
|
|
document.getElementById('search-input').value = '';
|
|
document.getElementById('category-filter').value = '';
|
|
|
|
// Reapply filters
|
|
applyFilters();
|
|
}
|
|
|
|
/**
|
|
* Remove specific filter
|
|
*/
|
|
function removeFilter(filterType) {
|
|
if (filterType === 'search') {
|
|
activeFilters.search = '';
|
|
document.getElementById('search-input').value = '';
|
|
} else if (filterType === 'category') {
|
|
activeFilters.category = '';
|
|
document.getElementById('category-filter').value = '';
|
|
}
|
|
|
|
applyFilters();
|
|
}
|
|
|
|
/**
|
|
* Attach event listeners
|
|
*/
|
|
function attachEventListeners() {
|
|
// Search input (debounced)
|
|
const searchInput = document.getElementById('search-input');
|
|
let searchTimeout;
|
|
searchInput.addEventListener('input', e => {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
activeFilters.search = e.target.value.trim();
|
|
applyFilters();
|
|
}, 300);
|
|
});
|
|
|
|
// Category filter
|
|
const categoryFilter = document.getElementById('category-filter');
|
|
categoryFilter.addEventListener('change', e => {
|
|
activeFilters.category = e.target.value;
|
|
applyFilters();
|
|
});
|
|
|
|
// Sort select
|
|
const sortSelect = document.getElementById('sort-select');
|
|
sortSelect.addEventListener('change', e => {
|
|
activeFilters.sort = e.target.value;
|
|
applyFilters();
|
|
});
|
|
|
|
// Clear filters button
|
|
const clearFiltersBtn = document.getElementById('clear-filters');
|
|
clearFiltersBtn.addEventListener('click', clearFilters);
|
|
|
|
// Pagination - prev/next buttons
|
|
const prevBtn = document.getElementById('prev-page');
|
|
const nextBtn = document.getElementById('next-page');
|
|
|
|
prevBtn.addEventListener('click', () => {
|
|
if (currentPage > 1) {
|
|
currentPage--;
|
|
renderPosts();
|
|
}
|
|
});
|
|
|
|
nextBtn.addEventListener('click', () => {
|
|
const totalPages = Math.ceil(filteredPosts.length / postsPerPage);
|
|
if (currentPage < totalPages) {
|
|
currentPage++;
|
|
renderPosts();
|
|
}
|
|
});
|
|
|
|
// Pagination - page numbers (event delegation)
|
|
const pageNumbersEl = document.getElementById('page-numbers');
|
|
pageNumbersEl.addEventListener('click', e => {
|
|
const pageBtn = e.target.closest('.page-number');
|
|
if (pageBtn) {
|
|
currentPage = parseInt(pageBtn.dataset.page, 10);
|
|
renderPosts();
|
|
}
|
|
});
|
|
|
|
// Remove filter tags (event delegation)
|
|
const filterTagsEl = document.getElementById('filter-tags');
|
|
filterTagsEl.addEventListener('click', e => {
|
|
const removeBtn = e.target.closest('[data-remove-filter]');
|
|
if (removeBtn) {
|
|
const filterType = removeBtn.dataset.removeFilter;
|
|
removeFilter(filterType);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show error message
|
|
*/
|
|
function showError(message) {
|
|
const gridEl = document.getElementById('blog-grid');
|
|
gridEl.innerHTML = `
|
|
<div class="col-span-full bg-red-50 border border-red-200 rounded-lg p-6">
|
|
<div class="flex items-start">
|
|
<svg class="h-6 w-6 text-red-600 mr-3 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-red-900 mb-2">Error</h3>
|
|
<p class="text-red-700">${escapeHtml(message)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS
|
|
*/
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Newsletter Modal Functionality
|
|
*/
|
|
function setupNewsletterModal() {
|
|
const modal = document.getElementById('newsletter-modal');
|
|
const openBtn = document.getElementById('open-newsletter-modal');
|
|
const closeBtn = document.getElementById('close-newsletter-modal');
|
|
const cancelBtn = document.getElementById('cancel-newsletter');
|
|
const form = document.getElementById('newsletter-form');
|
|
const successMsg = document.getElementById('newsletter-success');
|
|
const errorMsg = document.getElementById('newsletter-error');
|
|
const errorText = document.getElementById('newsletter-error-message');
|
|
const submitBtn = document.getElementById('newsletter-submit');
|
|
|
|
// Open modal
|
|
if (openBtn) {
|
|
openBtn.addEventListener('click', () => {
|
|
modal.classList.remove('hidden');
|
|
document.getElementById('newsletter-email').focus();
|
|
});
|
|
}
|
|
|
|
// Close modal
|
|
function closeModal() {
|
|
modal.classList.add('hidden');
|
|
form.reset();
|
|
successMsg.classList.add('hidden');
|
|
errorMsg.classList.add('hidden');
|
|
}
|
|
|
|
if (closeBtn) {
|
|
closeBtn.addEventListener('click', closeModal);
|
|
}
|
|
|
|
if (cancelBtn) {
|
|
cancelBtn.addEventListener('click', closeModal);
|
|
}
|
|
|
|
// Close on backdrop click
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// Close on Escape key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// Handle form submission
|
|
if (form) {
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
// Reset messages
|
|
successMsg.classList.add('hidden');
|
|
errorMsg.classList.add('hidden');
|
|
|
|
const email = document.getElementById('newsletter-email').value;
|
|
const name = document.getElementById('newsletter-name').value;
|
|
|
|
// Disable submit button
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = 'Subscribing...';
|
|
|
|
try {
|
|
// Ensure we have a CSRF token
|
|
if (!csrfToken) {
|
|
await fetchCsrfToken();
|
|
}
|
|
|
|
if (!csrfToken) {
|
|
throw new Error('Unable to obtain CSRF token');
|
|
}
|
|
|
|
const response = await fetch('/api/newsletter/subscribe', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
email,
|
|
name: name || null,
|
|
source: 'blog'
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.success) {
|
|
// Show success message
|
|
successMsg.classList.remove('hidden');
|
|
form.reset();
|
|
|
|
// Close modal after 2 seconds
|
|
setTimeout(() => {
|
|
closeModal();
|
|
}, 2000);
|
|
} else {
|
|
// Show error message
|
|
errorText.textContent = data.error || 'Failed to subscribe. Please try again.';
|
|
errorMsg.classList.remove('hidden');
|
|
}
|
|
} catch (error) {
|
|
console.error('Newsletter subscription error:', error);
|
|
errorText.textContent = 'Network error. Please check your connection and try again.';
|
|
errorMsg.classList.remove('hidden');
|
|
} finally {
|
|
// Re-enable submit button
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = 'Subscribe';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
init();
|
|
setupNewsletterModal();
|
|
});
|