Implemented RSS feeds, newsletter subscriptions, email templates, and admin UI to encourage repeat visits from self-selected visitors. ## RSS Feeds - Created RSS 2.0 feed generation (main + topic-specific) - Endpoints: /feed.xml and /feed/:topic.xml - Added getPublished() and getPublishedByTag() to BlogPost model ## Newsletter Subscriptions - Created reusable newsletter modal component - Added to index, researcher, implementer, leader pages - Interest selection: research, implementation, governance, project-updates - Added newsletter trigger button to footer - Uses existing /api/newsletter/subscribe endpoint ## Email Templates - Created comprehensive specifications for 4 newsletter tiers - Research Updates (monthly) - scholarly audience - Implementation Notes (bi-weekly) - practitioners - Governance Discussions (sporadic) - stakeholders - Project Updates (quarterly) - general audience - Documented template variables, design guidelines ## Admin UI - Enhanced newsletter management with "Send Newsletter" section - Tier selection, subject/preview input, JSON content editor - Preview/test/send buttons (UI ready, email service TBD) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
316 lines
12 KiB
JavaScript
316 lines
12 KiB
JavaScript
/**
|
|
* Newsletter Subscription Component
|
|
* Reusable modal for newsletter subscriptions across the website
|
|
*/
|
|
|
|
class TractausNewsletter {
|
|
constructor() {
|
|
this.isOpen = false;
|
|
this.isMobile = window.matchMedia('(max-width: 768px)').matches;
|
|
this.csrfToken = null;
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
this.renderModal();
|
|
this.attachEventListeners();
|
|
await this.fetchCsrfToken();
|
|
}
|
|
|
|
async fetchCsrfToken() {
|
|
try {
|
|
const response = await fetch('/api/csrf-token');
|
|
const data = await response.json();
|
|
this.csrfToken = data.csrfToken;
|
|
} catch (error) {
|
|
console.error('[Newsletter] Failed to fetch CSRF token:', error);
|
|
}
|
|
}
|
|
|
|
renderModal() {
|
|
const modalHTML = `
|
|
<div id="newsletter-modal" class="hidden fixed inset-0 z-50" role="dialog" aria-modal="true">
|
|
<div id="newsletter-backdrop" class="absolute inset-0 bg-gray-900/60 backdrop-blur-sm"></div>
|
|
|
|
<div class="absolute inset-0 flex items-center justify-center p-4">
|
|
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg relative">
|
|
<button id="newsletter-close"
|
|
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
|
|
aria-label="Close newsletter modal">
|
|
<svg class="w-6 h-6" 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>
|
|
|
|
<!-- Form Step -->
|
|
<div id="newsletter-form-step" class="p-8">
|
|
<div class="text-center mb-6">
|
|
<div class="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-4">
|
|
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
</svg>
|
|
</div>
|
|
<h2 class="text-2xl font-bold text-gray-900 mb-2">Stay Connected</h2>
|
|
<p class="text-gray-600">
|
|
Get updates on AI governance research, framework developments, and implementation insights.
|
|
Choose what matters to you.
|
|
</p>
|
|
</div>
|
|
|
|
<form id="newsletter-form" class="space-y-4">
|
|
<div>
|
|
<label for="newsletter-email" class="block text-sm font-medium text-gray-700 mb-1">
|
|
Email Address <span class="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="email"
|
|
id="newsletter-email"
|
|
name="email"
|
|
required
|
|
placeholder="your.email@example.com"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="newsletter-name" class="block text-sm font-medium text-gray-700 mb-1">
|
|
Name (Optional)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="newsletter-name"
|
|
name="name"
|
|
placeholder="Your name"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
Interests (Select all that apply)
|
|
</label>
|
|
<div class="space-y-2">
|
|
<label class="flex items-start cursor-pointer">
|
|
<input type="checkbox" name="interest" value="research" checked
|
|
class="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
|
<span class="ml-2 text-sm text-gray-700">
|
|
Research Updates <em class="text-gray-500">(monthly)</em>
|
|
</span>
|
|
</label>
|
|
<label class="flex items-start cursor-pointer">
|
|
<input type="checkbox" name="interest" value="implementation"
|
|
class="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
|
<span class="ml-2 text-sm text-gray-700">
|
|
Implementation Notes <em class="text-gray-500">(bi-weekly)</em>
|
|
</span>
|
|
</label>
|
|
<label class="flex items-start cursor-pointer">
|
|
<input type="checkbox" name="interest" value="governance"
|
|
class="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
|
<span class="ml-2 text-sm text-gray-700">
|
|
Governance Discussions <em class="text-gray-500">(sporadic)</em>
|
|
</span>
|
|
</label>
|
|
<label class="flex items-start cursor-pointer">
|
|
<input type="checkbox" name="interest" value="project-updates"
|
|
class="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
|
<span class="ml-2 text-sm text-gray-700">
|
|
Project Updates <em class="text-gray-500">(quarterly)</em>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-semibold hover:bg-blue-700 transition-colors">
|
|
Subscribe
|
|
</button>
|
|
|
|
<p class="text-xs text-center text-gray-500">
|
|
We respect your privacy. Unsubscribe anytime. No spam, ever.
|
|
</p>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Success Step -->
|
|
<div id="newsletter-success-step" class="hidden p-8 text-center">
|
|
<div class="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
|
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
<h2 class="text-2xl font-bold text-gray-900 mb-2">You're All Set!</h2>
|
|
<p class="text-gray-600 mb-6">
|
|
Thanks for subscribing! You'll receive updates based on your interests.
|
|
Watch your inbox for our next update.
|
|
</p>
|
|
<button id="newsletter-done-btn" class="bg-gray-200 text-gray-800 py-2 px-6 rounded-lg font-semibold hover:bg-gray-300 transition-colors">
|
|
Done
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Error Step -->
|
|
<div id="newsletter-error-step" class="hidden p-8 text-center">
|
|
<div class="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4">
|
|
<svg class="w-8 h-8 text-red-600" 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>
|
|
<h2 class="text-2xl font-bold text-gray-900 mb-2">Something Went Wrong</h2>
|
|
<p id="newsletter-error-message" class="text-gray-600 mb-6">
|
|
We couldn't process your subscription. Please try again.
|
|
</p>
|
|
<button id="newsletter-retry-btn" class="bg-gray-200 text-gray-800 py-2 px-6 rounded-lg font-semibold hover:bg-gray-300 transition-colors">
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
|
}
|
|
|
|
attachEventListeners() {
|
|
const modal = document.getElementById('newsletter-modal');
|
|
const backdrop = document.getElementById('newsletter-backdrop');
|
|
const closeBtn = document.getElementById('newsletter-close');
|
|
const form = document.getElementById('newsletter-form');
|
|
const doneBtn = document.getElementById('newsletter-done-btn');
|
|
const retryBtn = document.getElementById('newsletter-retry-btn');
|
|
|
|
// Close handlers
|
|
backdrop.addEventListener('click', () => this.close());
|
|
closeBtn.addEventListener('click', () => this.close());
|
|
doneBtn.addEventListener('click', () => this.close());
|
|
retryBtn.addEventListener('click', () => this.showStep('form'));
|
|
|
|
// Form submission
|
|
form.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.handleSubmit(e);
|
|
});
|
|
|
|
// Escape key to close
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && this.isOpen) {
|
|
this.close();
|
|
}
|
|
});
|
|
|
|
// Global trigger - look for elements with data-newsletter-trigger
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.closest('[data-newsletter-trigger]')) {
|
|
e.preventDefault();
|
|
this.open();
|
|
}
|
|
});
|
|
}
|
|
|
|
open() {
|
|
const modal = document.getElementById('newsletter-modal');
|
|
modal.classList.remove('hidden');
|
|
this.isOpen = true;
|
|
document.body.classList.add('overflow-hidden');
|
|
this.showStep('form');
|
|
}
|
|
|
|
close() {
|
|
const modal = document.getElementById('newsletter-modal');
|
|
modal.classList.add('hidden');
|
|
this.isOpen = false;
|
|
document.body.classList.remove('overflow-hidden');
|
|
|
|
// Reset form
|
|
const form = document.getElementById('newsletter-form');
|
|
form.reset();
|
|
|
|
// Re-check the research interest by default
|
|
const researchCheckbox = form.querySelector('input[value="research"]');
|
|
if (researchCheckbox) {
|
|
researchCheckbox.checked = true;
|
|
}
|
|
}
|
|
|
|
showStep(step) {
|
|
const steps = {
|
|
form: 'newsletter-form-step',
|
|
success: 'newsletter-success-step',
|
|
error: 'newsletter-error-step'
|
|
};
|
|
|
|
Object.values(steps).forEach(stepId => {
|
|
const el = document.getElementById(stepId);
|
|
if (el) el.classList.add('hidden');
|
|
});
|
|
|
|
const targetStep = document.getElementById(steps[step]);
|
|
if (targetStep) {
|
|
targetStep.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
async handleSubmit(e) {
|
|
const form = e.target;
|
|
const submitBtn = form.querySelector('button[type="submit"]');
|
|
const originalBtnText = submitBtn.textContent;
|
|
|
|
try {
|
|
// Disable submit button
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = 'Subscribing...';
|
|
|
|
// Collect form data
|
|
const formData = new FormData(form);
|
|
const email = formData.get('email');
|
|
const name = formData.get('name');
|
|
const interestCheckboxes = form.querySelectorAll('input[name="interest"]:checked');
|
|
const interests = Array.from(interestCheckboxes).map(cb => cb.value);
|
|
|
|
// Prepare request data
|
|
const data = {
|
|
email,
|
|
name: name || undefined,
|
|
source: window.location.pathname,
|
|
interests
|
|
};
|
|
|
|
// Submit to API
|
|
const response = await fetch('/api/newsletter/subscribe', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': this.csrfToken
|
|
},
|
|
credentials: 'include',
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
this.showStep('success');
|
|
} else {
|
|
throw new Error(result.error || 'Subscription failed');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('[Newsletter] Subscription error:', error);
|
|
const errorMsg = document.getElementById('newsletter-error-message');
|
|
errorMsg.textContent = error.message || 'We couldn\'t process your subscription. Please try again.';
|
|
this.showStep('error');
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = originalBtnText;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.tractausNewsletter = new TractausNewsletter();
|
|
});
|
|
} else {
|
|
window.tractausNewsletter = new TractausNewsletter();
|
|
}
|