tractatus/public/js/components/newsletter.js
TheFlow 4408b694f9 feat: Add comprehensive visitor retention system
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>
2025-11-04 10:42:43 +13:00

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();
}